diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff39a63..6c4a6082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ All notable changes to vouch are documented here. Format follows - demo image build: `hatch_build.py` (the build hook pyproject.toml declares for console bundling) is copied into the docker build context; the image had been unbuildable since the hook landed (#474). +- `vouch capture finalize-all` reads the session id off the SessionStart + hook payload on stdin, the same wire `capture finalize` reads at + SessionEnd. the shipped hook passes no `--session-id` and nothing sets + `VOUCH_SESSION_ID`, so the sweep silently no-opped on every session + start and buffers orphaned by sessions that ended without SessionEnd + were never rolled up into pending proposals (#492). ## [1.3.0] — 2026-07-14 diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5a8bd3ce..b2cf7f23 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2428,11 +2428,34 @@ def capture_answer_cmd(session_id: str | None) -> None: @capture.command("finalize-all") -@click.option("--session-id", default=None, help="Current session id (else env VOUCH_SESSION_ID).") +@click.option( + "--session-id", default=None, + help="Current session id (else stdin payload, else env VOUCH_SESSION_ID).", +) @click.option("--max-age-seconds", type=float, default=3600.0, help="Max age in seconds.") def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> None: - """Finalize all capture buffers except current session (SessionStart cleanup).""" - sid = session_id or os.environ.get("VOUCH_SESSION_ID") or "" + """Finalize all buffers except current session (SessionStart hook payload on stdin).""" + # the shipped SessionStart hook passes no --session-id and nothing sets + # VOUCH_SESSION_ID, so the current session id has to come off the hook + # payload on stdin — the same wire `capture finalize` reads at SessionEnd. + # without it sid is always "" and the sweep no-ops, leaving the buffers of + # sessions that died without SessionEnd on disk forever. + payload: dict[str, Any] = {} + if not sys.stdin.isatty(): + raw = sys.stdin.read() + if raw.strip(): + try: + loaded = json.loads(raw) + if isinstance(loaded, dict): + payload = loaded + except json.JSONDecodeError: + payload = {} + sid = ( + session_id + or str(payload.get("session_id") or "") + or os.environ.get("VOUCH_SESSION_ID") + or "" + ) if not sid: # No session ID provided; silently succeed _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) diff --git a/tests/test_capture.py b/tests/test_capture.py index e40ca0ed..9d8144e1 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -413,6 +413,7 @@ def commands(event: str) -> list[str]: assert any("capture observe" in c for c in commands("PostToolUse")) assert any("capture finalize" in c for c in commands("SessionEnd")) assert any("capture banner" in c for c in commands("SessionStart")) + assert any("capture finalize-all" in c for c in commands("SessionStart")) def test_capture_finalize_all_cmd_with_old_buffers(tmp_path: Path, monkeypatch) -> None: @@ -477,6 +478,70 @@ def test_capture_finalize_all_cmd_reads_session_from_env(tmp_path: Path, monkeyp assert current_sess in output["skipped_current"] +def test_capture_finalize_all_cmd_reads_session_from_stdin(tmp_path: Path) -> None: + """The shipped SessionStart hook passes no --session-id, so the current + session id has to come off the hook payload on stdin.""" + import os + import time as time_mod + + store = _make_store(tmp_path) + current_sess = "current-from-stdin" + old_sess = "old-session" + + old_path = cap.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + old_path.write_text( + '{"ts": 1.0, "tool": "Read", "summary": "test1"}\n' + '{"ts": 2.0, "tool": "Read", "summary": "test2"}\n' + '{"ts": 3.0, "tool": "Read", "summary": "test3"}\n' + ) + old_mtime = time_mod.time() - 7200 + os.utime(old_path, (old_mtime, old_mtime)) + + curr_path = cap.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + result = CliRunner().invoke( + cli, ["capture", "finalize-all"], + input=_json.dumps({"session_id": current_sess, "cwd": str(tmp_path)}), + env={"VOUCH_KB_PATH": str(store.kb_dir)}, + ) + + assert result.exit_code == 0 + output = _json.loads(result.output) + assert old_sess in output["finalized"] + assert current_sess in output["skipped_current"] + + +def test_capture_finalize_all_cmd_survives_malformed_stdin(tmp_path: Path) -> None: + """A hook must never fail the host turn on an unparseable payload.""" + store = _make_store(tmp_path) + result = CliRunner().invoke( + cli, ["capture", "finalize-all"], + input="not json at all", + env={"VOUCH_KB_PATH": str(store.kb_dir)}, + ) + assert result.exit_code == 0 + assert _json.loads(result.output)["finalized"] == [] + + +def test_capture_finalize_all_cmd_flag_beats_stdin(tmp_path: Path) -> None: + """An explicit --session-id wins over the stdin payload.""" + store = _make_store(tmp_path) + curr_path = cap.buffer_path(store, "from-flag") + curr_path.parent.mkdir(parents=True, exist_ok=True) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + result = CliRunner().invoke( + cli, ["capture", "finalize-all", "--session-id", "from-flag"], + input=_json.dumps({"session_id": "from-stdin"}), + env={"VOUCH_KB_PATH": str(store.kb_dir)}, + ) + + assert result.exit_code == 0 + assert "from-flag" in _json.loads(result.output)["skipped_current"] + + def test_capture_finalize_all_cmd_silent_on_no_kb(tmp_path: Path, monkeypatch) -> None: """CLI command should silently succeed if KB not found.""" runner = CliRunner()