Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/session-flow/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "session-flow",
"version": "0.17.24",
"version": "0.18.0",
"description": "Session-lifecycle toolkit of thirteen skills: workflow (navigate a staged dev workflow and suggest the next stage), handoff (write a save-point and resume prompt for /clear-and-resume), continue-in-background (delegate the task to a fresh background agent that continues it now — same save-point engine as handoff, delivered by launching a detached claude --bg session seeded with the resume prompt; launches only on explicit user request), keep-going (recover and continue after any interruption OR when live off-thread work looks stalled — inventory off-thread work, inspect its real output, act only on evidence, then continue; after a usage limit lifts it continues rather than summarizing-and-stalling), find-handoff (recover a lost handoff after /clear — when the resume prompt was written but never copied — via a read-only detection ladder: known-location glob of the handoffs dir, then a bounded, recency-ranked transcript scan for the handoff directive and dashed-rail markers, then a confirm-before-resume gate; surfaces only the resume prompt + metadata, never raw transcript content), clean-stop (get to a durable, linked stopping point before the machine may go away — sweep every repo/worktree for uncommitted, unpushed, or PR-less work, push it durable, put breadcrumbs in PR/issue bodies, then give a free-and-clear verdict), retro (structured end-of-session retrospective with transcript metrics and learning codification), running-retro (in-flight retrospective checkpoints that spawn a subagent to analyze the transcript so far and append classified findings to a cumulative running ledger — capture and route only, the live counterpart to retro; also owns a detached-observer substrate that can watch a session out-of-band and run the checkpoint autonomously after the session ends), orient (read-only session orientation — synthesize where we stand, what we are doing, and why, from durable + off-thread state the built-in /recap never sees: ledgers, handoffs, workflow checklists, running-retro ledgers, open PRs and work-items, and git), orchestrate (arm a session or worker with proactive-orchestration imperatives), reanchor (verify a session's working assumptions are still true against live reality — referenced PRs/issues/branches, base-branch drift, renamed/version-drifted surfaces, stale memory-tier files, and the goal a handoff records, compared across the chain so a re-derived goal reports as drift — before building on them), reconcile (retire finished off-thread work and reconcile this session's task ledger with reality — the prune-and-reconcile counterpart to keep-going's resume: inventory the work this session spawned, inspect its real state, retire the finished and close proven-done tasks, auto-settling the finished and gating any kill of still-running work; sibling sessions in the project are reported read-only), and setup (check-centric verification of the observer's runtime prerequisites and configuration).",
"author": {
"name": "Melodic Software",
Expand Down
38 changes: 38 additions & 0 deletions plugins/session-flow/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog — session-flow plugin

## [0.18.0]

### Added

- **`retro`: the multi-session parser now reports chain coverage (#1980).** Chain discovery walks
`previous_handoff` pointers backwards, so it stops at the first session that wrote no handoff
file — and a walk that ended early was indistinguishable in the output from a genuinely short
chain. The reported case ran a 10-session chain linked by hand-pasted continuation prompts and
got a retrospective authored from 2 sessions, with nothing signalling the gap. The multi-session
output carries a `chain_coverage` block (`requested` / `found` / `available` / `ratio`), where
`available` counts the transcripts present for the project — the denominator the walk itself
cannot see — and the human-readable `summary` carries the same ratio. The skill now states its
discovery basis and must not present a low-coverage chain retro silently; below ~0.5 it names the
counts and offers `--sessions` with the ids enumerated.

### Fixed

- **`retro`: `parse_transcript.py --sessions` accepts a comma-joined list instead of silently
resolving nothing (#1980).** The option is declared `nargs="+"`, so `--sessions a,b,c` — the
shape a caller reaches for when the ids were just written into prose — was consumed as ONE
literal token. It matched no transcript file, and the run reported "0 with transcript" for a
chain whose transcripts all existed: a wrong answer rather than an error. Tokens are now split on
`,` after parsing (a session id never contains one, so the split cannot change the meaning of a
correctly space-separated invocation), empty fragments are dropped, and a `--sessions` value that
resolves to no ids at all reaches the existing usage error.

- **`retro`: a repeated session-id is parsed once, not once per mention.** Every multi-session
number is a sum over the requested list, so naming one id twice — easy once a comma-joined list
can be mixed with a space-separated one — doubled the aggregate token and turn totals and counted
a single transcript twice against an `available` denominator that counts its file once,
publishing a `chain_coverage.ratio` of 2.0 and a summary reading "covering 2 of 1 transcript(s)".
`build_multi_session_output` now deduplicates its ids first-occurrence-wins, which keeps the
order the roles depend on (first id = current session). The rule lives in that one function so
every entry point is covered, `--chain-from` included; the walk's own cycle guard stays, because
a pointer cycle has to terminate the walk rather than be cleaned up after it.
`chain_coverage.requested` and the `pass` status now compare against the deduplicated list, so a
run that named an id twice reports `requested: 1` and still passes.

## [0.17.24]

### Fixed
Expand Down
9 changes: 9 additions & 0 deletions plugins/session-flow/skills/retro/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ parser's `--chain-from` walks `previous_handoff` frontmatter pointers
backwards from the newest handoff file and aggregates metrics across every chained transcript. See
`context/session.md` Phase 1.

**State the discovery basis, and never present a low-coverage chain retro silently.** The walk
follows `previous_handoff` pointers, so it stops at the first session that wrote no handoff file —
a chain linked by hand-pasted continuation prompts instead of save-points can end after one hop.
The parser reports what it saw in `chain_coverage` (`requested` / `found` / `available` / `ratio`,
where `available` counts the transcripts present for this project). When `ratio` is below ~0.5, say
so before presenting: name the found and available counts, and offer `--sessions` with the ids
enumerated explicitly. A retro authored from a fifth of the evidence must not read like a complete
one.

## What this skill does NOT do

- **Does not run builds or tests** — that's the consuming repo's verify stage
Expand Down
22 changes: 20 additions & 2 deletions plugins/session-flow/skills/retro/context/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,26 @@ parser is stdlib-only.

JSON to stdout: `status` / `summary`, plus per-session `data` (session info, turns, tokens, tool
usage + rejections, compactions, turn durations, stop reasons, files modified, subagents, errors)
and — in multi-session form — an `aggregate` block. Exit codes: 0 = success, 1 = warning,
2 = error.
and — in multi-session form — an `aggregate` block and a `chain_coverage` block. Exit codes:
0 = success, 1 = warning, 2 = error.

`--sessions` accepts its ids space-separated OR comma-joined; both spell the same list.

### Check chain coverage before presenting

`chain_coverage` reports `requested` / `found` / `available` / `ratio` — `available` being the
transcripts present for this project, which is the denominator the walk itself cannot see. The
`--chain-from` walk ends at the first session that wrote no handoff file, so a chain linked by
hand-pasted continuation prompts can cover a fraction of the work and still look complete here.

When `ratio` is below ~0.5, say so before presenting the retro — name `found` and `available`, and
offer to re-run with the ids enumerated:

```bash
"$PY" "$PARSER" --sessions <sid1> <sid2> ... --base "$SESSION_DATA_DIR"
```

Do not silently scope a chain retrospective to what the walk happened to reach.

### Present metrics

Expand Down
75 changes: 74 additions & 1 deletion plugins/session-flow/skills/retro/scripts/parse_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
Arguments:
session_id Single UUID session identifier (positional, backward compat)
base_path Base directory containing session JSONL files (positional)
--sessions Multiple UUIDs to parse + aggregate (first = current, rest = chained prior)
--sessions Multiple UUIDs to parse + aggregate (first = current, rest = chained prior).
Space-separated or comma-joined; both forms mean the same list.
--chain-from Walk session chain via handoff frontmatter (previous_handoff)
starting from the given handoff journal entry file; emits aggregated multi-session output
--base Base directory containing session JSONL files (required with --sessions / --chain-from)
Expand All @@ -29,6 +30,12 @@
{
"status": "...",
"summary": "...",
"chain_coverage": {
"requested": N, # session-ids this run was asked to parse
"found": N, # of those, how many had a transcript
"available": N, # transcripts present in <base> (null if unreadable)
"ratio": 0.0-1.0, # found / available; omitted when available is 0/null
},
"sessions": [
{"id": "<UUID>", "role": "current"|"previous",
"transcript_present": true|false, "subagents_present": true|false,
Expand Down Expand Up @@ -594,6 +601,23 @@ def build_multi_session_output(
"aggregate": {},
}

# One transcript must count ONCE however many times its id was named.
# A repeat is easy to produce — pasting a comma-joined list next to a
# space-separated one, or a `previous_handoff` chain that loops back —
# and every downstream number is a sum over this list: the aggregate
# token and turn totals would double, and `transcripts_present` could
# exceed `available` (one file, counted twice), publishing a coverage
# ratio above 1.0 against a field documented as 0.0-1.0. Deduplicated
# HERE rather than at the argument parser: this function owns the rule
# for every entry point, so a new caller cannot reintroduce the defect
# by skipping a parser-side filter. The `--chain-from` walk still drops
# revisits of its own (`seen` in extract_chain_from_handoff, plus the
# prepend guard in main) because a cycle must terminate the WALK, not
# merely be cleaned up afterwards; those are cheap and independent, not
# a second owner of this rule. Order-preserving: the first id is the
# current session and the rest are its chain, in order.
session_ids = list(dict.fromkeys(session_ids))

sessions_out: list[dict[str, Any]] = []
agg_tools: Counter[str] = Counter()
agg_subagents: list[dict[str, Any]] = []
Expand Down Expand Up @@ -633,6 +657,30 @@ def build_multi_session_output(
tagged["session_id"] = sid
agg_subagents.append(tagged)

# Chain coverage: how much of what this project HAS did the chain cover?
# A backward `previous_handoff` walk terminates at the first session that
# wrote no handoff file, and a walk that stopped early is indistinguishable
# in this output from a genuinely short chain — the reported failure was a
# 10-session chain retro authored from 2 sessions with nothing signalling
# the gap. `available` counts the transcripts sitting in the same base
# directory, which IS the per-project transcript directory, so the ratio
# answers "did we look at most of this project's sessions?" without
# asserting that every sibling transcript belongs to this chain — some will
# not, which is why this is coverage evidence for a reader, not a filter.
try:
available = len([p for p in base_path.glob("*.jsonl") if p.is_file()])
except OSError:
# An unreadable base directory is already reported per session; coverage
# is diagnostic, so degrade to "unknown" rather than fail the parse.
available = None
chain_coverage: dict[str, Any] = {
"requested": len(session_ids),
"found": transcripts_present,
"available": available,
}
if available:
chain_coverage["ratio"] = round(transcripts_present / available, 3)
Comment thread
kyle-sexton marked this conversation as resolved.

if transcripts_present == len(session_ids):
status = "pass"
elif transcripts_present > 0 or agg_subagents:
Expand All @@ -642,17 +690,25 @@ def build_multi_session_output(
# Nothing found at all — a bad base dir, stale chain, or wrong SIDs
# must fail loudly, not read as a successful all-zero retro.
status = "error"
coverage_note = ""
if available:
coverage_note = (
f", covering {transcripts_present} of {available} transcript(s) "
f"present for this project"
)
summary = (
f"Multi-session retro: {len(session_ids)} chained session(s) "
f"({transcripts_present} with transcript), "
f"{total_assistant} assistant turns total, "
f"{total_human} human messages, "
f"{total_compactions} compaction(s)"
f"{coverage_note}"
)

return {
"status": status,
"summary": summary,
"chain_coverage": chain_coverage,
"sessions": sessions_out,
"aggregate": {
"total_assistant_turns": total_assistant,
Expand Down Expand Up @@ -710,6 +766,23 @@ def _parse_legacy_or_argparse(argv: list[str]) -> argparse.Namespace:
)
ns = parser.parse_args(argv)
ns.session_id = None
if ns.sessions:
# `--sessions a,b,c` is the shape a caller reaches for when the ids were
# just written into prose, and `nargs="+"` takes the whole comma-joined
# string as ONE token. That token matches no transcript file, so the run
# reported "0 with transcript" for a chain whose transcripts all exist —
# a wrong answer, not an error. A session id never contains a comma, so
# splitting on it is unambiguous and cannot change the meaning of a
# correctly space-separated invocation. Empty fragments (a trailing
# comma, `a,,b`) are dropped rather than passed on as an id that cannot
# exist; if every token was empty the list goes falsy and main() reports
# the no-session-id usage error.
ns.sessions = [
sid
for token in ns.sessions
for sid in (part.strip() for part in token.split(","))
if sid
]
return ns


Expand Down
111 changes: 111 additions & 0 deletions plugins/session-flow/skills/retro/scripts/test_parse_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,117 @@ def test_multi_session_all_missing_is_error(tmp_path):
assert all(s["transcript_present"] is False for s in output["sessions"])


def test_chain_coverage_reports_the_unwalked_remainder(tmp_path):
"""A chain covering 2 of 5 project transcripts says so in chain_coverage.

A `previous_handoff` walk stops at the first session that wrote no handoff
file, and without this field a 2-of-10 walk reads exactly like a genuine
2-session chain.
"""
for sid in ("sid-a", "sid-b", "sid-c", "sid-d", "sid-e"):
_write_assistant_event(tmp_path, sid)
result = _run_multi(["--sessions", "sid-a", "sid-b", "--base", str(tmp_path)])
result.check_returncode()
output = json.loads(result.stdout)
cov = output["chain_coverage"]
assert cov["requested"] == 2
assert cov["found"] == 2
assert cov["available"] == 5
assert cov["ratio"] == 0.4
# The ratio is also visible without reading the structured field.
assert "2 of 5 transcript(s)" in output["summary"]


def test_chain_coverage_full_when_the_walk_saw_everything(tmp_path):
"""Covering every transcript in the base directory reports ratio 1.0."""
_write_assistant_event(tmp_path, "sid-a")
_write_assistant_event(tmp_path, "sid-b")
result = _run_multi(["--sessions", "sid-a", "sid-b", "--base", str(tmp_path)])
result.check_returncode()
cov = json.loads(result.stdout)["chain_coverage"]
assert cov == {"requested": 2, "found": 2, "available": 2, "ratio": 1.0}


def test_multi_session_comma_joined(tmp_path):
"""--sessions a,b resolves the same list a space-separated invocation does.

argparse took the whole comma-joined string as ONE token, which matched no
transcript file — so a chain whose transcripts all exist reported zero found
instead of erroring on the caller's shape.
"""
_write_assistant_event(tmp_path, "sid-curr")
_write_assistant_event(tmp_path, "sid-prev")
result = _run_multi(["--sessions", "sid-curr,sid-prev", "--base", str(tmp_path)])
result.check_returncode()
output = json.loads(result.stdout)
assert output["status"] == "pass"
assert [s["id"] for s in output["sessions"]] == ["sid-curr", "sid-prev"]
assert all(s["transcript_present"] for s in output["sessions"])
# Order carries meaning: the first id is the current session.
assert output["sessions"][0]["role"] == "current"


def test_multi_session_mixed_separators_and_empty_fragments(tmp_path):
"""Comma and space forms mix, and empty fragments are dropped, not parsed."""
_write_assistant_event(tmp_path, "sid-a")
_write_assistant_event(tmp_path, "sid-b")
_write_assistant_event(tmp_path, "sid-c")
result = _run_multi(
["--sessions", "sid-a, sid-b,", ",sid-c", "--base", str(tmp_path)]
)
result.check_returncode()
output = json.loads(result.stdout)
assert [s["id"] for s in output["sessions"]] == ["sid-a", "sid-b", "sid-c"]


def test_multi_session_repeated_id_counts_once(tmp_path):
"""A repeated session-id is parsed once, not once per mention.

Pasting a comma-joined list next to a space-separated one is the easy way
to name the same id twice. Counted twice, one transcript would inflate the
aggregate totals and push chain_coverage past its documented 0.0-1.0 range
(`found` 2, `available` 1, ratio 2.0).
"""
_write_assistant_event(tmp_path, "sid-a")
result = _run_multi(["--sessions", "sid-a,sid-a", "sid-a", "--base", str(tmp_path)])
result.check_returncode()
output = json.loads(result.stdout)
assert [s["id"] for s in output["sessions"]] == ["sid-a"]
assert output["chain_coverage"] == {
"requested": 1,
"found": 1,
"available": 1,
"ratio": 1.0,
}
assert output["aggregate"]["total_assistant_turns"] == 1
assert output["status"] == "pass"


def test_multi_session_dedupe_preserves_first_seen_order(tmp_path):
"""Deduplication keeps the first occurrence, so role assignment survives.

Order carries meaning here — the first id is the current session — so a
dedupe that kept the LAST occurrence would silently re-label the chain.
"""
for sid in ("sid-curr", "sid-prev"):
_write_assistant_event(tmp_path, sid)
result = _run_multi(
["--sessions", "sid-curr", "sid-prev", "sid-curr", "--base", str(tmp_path)]
)
result.check_returncode()
output = json.loads(result.stdout)
assert [s["id"] for s in output["sessions"]] == ["sid-curr", "sid-prev"]
assert output["sessions"][0]["role"] == "current"
assert output["sessions"][1]["role"] == "previous"


def test_multi_session_only_separators_is_usage_error(tmp_path):
"""--sessions , yields no ids at all → the no-session-id usage error."""
result = _run_multi(["--sessions", ",", "--base", str(tmp_path)])
assert result.returncode == 2
assert json.loads(result.stdout)["status"] == "error"


def test_notebook_edit_counts_as_file_modification(tmp_path):
"""NotebookEdit tool_use file_path lands in files_modified."""
data = _run_with_event(
Expand Down