diff --git a/plugins/session-flow/.claude-plugin/plugin.json b/plugins/session-flow/.claude-plugin/plugin.json index b0117e5ce..2a0daf32d 100644 --- a/plugins/session-flow/.claude-plugin/plugin.json +++ b/plugins/session-flow/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index c76cce91e..95dd9e54e 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -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 diff --git a/plugins/session-flow/skills/retro/SKILL.md b/plugins/session-flow/skills/retro/SKILL.md index 825a1f88f..ebd6db116 100644 --- a/plugins/session-flow/skills/retro/SKILL.md +++ b/plugins/session-flow/skills/retro/SKILL.md @@ -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 diff --git a/plugins/session-flow/skills/retro/context/session.md b/plugins/session-flow/skills/retro/context/session.md index 5fe50d08e..868e359b1 100644 --- a/plugins/session-flow/skills/retro/context/session.md +++ b/plugins/session-flow/skills/retro/context/session.md @@ -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 ... --base "$SESSION_DATA_DIR" +``` + +Do not silently scope a chain retrospective to what the walk happened to reach. ### Present metrics diff --git a/plugins/session-flow/skills/retro/scripts/parse_transcript.py b/plugins/session-flow/skills/retro/scripts/parse_transcript.py index 14521a539..0a30e172b 100755 --- a/plugins/session-flow/skills/retro/scripts/parse_transcript.py +++ b/plugins/session-flow/skills/retro/scripts/parse_transcript.py @@ -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) @@ -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 (null if unreadable) + "ratio": 0.0-1.0, # found / available; omitted when available is 0/null + }, "sessions": [ {"id": "", "role": "current"|"previous", "transcript_present": true|false, "subagents_present": true|false, @@ -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]] = [] @@ -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) + if transcripts_present == len(session_ids): status = "pass" elif transcripts_present > 0 or agg_subagents: @@ -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, @@ -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 diff --git a/plugins/session-flow/skills/retro/scripts/test_parse_transcript.py b/plugins/session-flow/skills/retro/scripts/test_parse_transcript.py index 291a6c368..0c76e0997 100644 --- a/plugins/session-flow/skills/retro/scripts/test_parse_transcript.py +++ b/plugins/session-flow/skills/retro/scripts/test_parse_transcript.py @@ -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(