Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 26 additions & 3 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []})
Expand Down
65 changes: 65 additions & 0 deletions tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down