From 432d17f384a7ec27fd024963cf38643ade2acc72 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:40:51 +0900 Subject: [PATCH 1/8] docs(superpowers): add design and plan for vscode session auto-proposal design: automatic finalization of old buffers on sessionstart, and current session on window close. plan: 8-task breakdown covering capture.py functions, cli command, adapter hooks, comprehensive unit tests, integration test, and documentation. --- .../2026-07-01-vscode-session-autoproposal.md | 920 ++++++++++++++++++ ...7-01-vscode-session-autoproposal-design.md | 228 +++++ 2 files changed, 1148 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md create mode 100644 docs/superpowers/specs/2026-07-01-vscode-session-autoproposal-design.md diff --git a/docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md b/docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md new file mode 100644 index 00000000..29b66b85 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md @@ -0,0 +1,920 @@ +# VS Code Session Auto-Proposal Implementation Plan + +> **For agentic workers:** Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically finalize old capture buffers on SessionStart and finalize the current session when the VS Code window closes, without user action. + +**Architecture:** Add `finalize_all_except()` to capture.py to bulk-finalize stale buffers. Wire it into a new `vouch capture finalize-all` CLI command. Update the SessionStart hook to call this command, catching old sessions. For window close, add a WindowClose hook (if available) or document the fallback behavior. + +**Tech Stack:** Python 3.10+, Click CLI framework, existing capture.py, adapter settings.json + +## Global Constraints + +- No Co-Authored-By trailers in commits (per CLAUDE.md) +- All writes through the review gate (`proposals.approve()` never bypassed) +- Lowercase prose in commit bodies +- Test names follow `test__` pattern +- All capture failures must not break the session (silent success on errors) + +--- + +## File Structure + +| File | Change | Responsibility | +|------|--------|-----------------| +| `src/vouch/capture.py` | Modify | Add `finalize_all_except()`, `is_stale_buffer()` functions | +| `src/vouch/cli.py` | Modify | Add `vouch capture finalize-all` command | +| `adapters/claude-code/.claude/settings.json` | Modify | Wire SessionStart hook to call `finalize-all` | +| `tests/test_capture.py` | Modify | Add 8+ unit tests for new functionality | + +--- + +## Task 1: Add helper functions to capture.py + +**Files:** +- Modify: `src/vouch/capture.py:283+` +- Test: `tests/test_capture.py` + +**Interfaces:** +- Produces: + - `is_stale_buffer(path: Path, age_seconds: float = 3600.0) -> bool` + - `finalize_all_except(store: KBStore, current_session_id: str, *, max_age_seconds: float = 3600.0, cwd: Path | None = None) -> dict[str, Any]` + +- [ ] **Step 1: Write the failing test for `is_stale_buffer()`** + +At the end of `tests/test_capture.py`, add: + +```python +def test_is_stale_buffer_with_recent_file(tmp_path): + """Recent file should not be stale.""" + f = tmp_path / "recent.jsonl" + f.write_text("test") + now = time.time() + # File created 30 seconds ago; max_age=3600 + assert not capture_mod.is_stale_buffer(f, max_age_seconds=3600, now_timestamp=now) + + +def test_is_stale_buffer_with_old_file(tmp_path): + """File older than max_age should be stale.""" + f = tmp_path / "old.jsonl" + f.write_text("test") + old_time = time.time() - 7200 # 2 hours ago + f.touch(times=(old_time, old_time)) # Set mtime to 2 hours ago + now = time.time() + assert capture_mod.is_stale_buffer(f, max_age_seconds=3600, now_timestamp=now) + + +def test_is_stale_buffer_with_exact_boundary(tmp_path): + """File at exact max_age boundary should not be stale (>=).""" + f = tmp_path / "boundary.jsonl" + f.write_text("test") + exact_time = time.time() - 3600 # Exactly 1 hour ago + f.touch(times=(exact_time, exact_time)) + now = exact_time + 3600 + assert not capture_mod.is_stale_buffer(f, max_age_seconds=3600, now_timestamp=now) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /home/a/Dev/plind-junior/vouch +source .venv/bin/activate +pytest tests/test_capture.py::test_is_stale_buffer_with_recent_file -xvs +``` + +Expected: `FAILED ... NameError: name 'is_stale_buffer' is not defined` + +- [ ] **Step 3: Implement `is_stale_buffer()` in capture.py** + +After the `pending_count()` function (line 283), add: + +```python +def is_stale_buffer( + path: Path, + *, + max_age_seconds: float = 3600.0, + now_timestamp: float | None = None, +) -> bool: + """Check if a buffer file's mtime is older than max_age_seconds.""" + if not path.exists(): + return False + now = now_timestamp if now_timestamp is not None else time.time() + mtime = path.stat().st_mtime + age = now - mtime + return age > max_age_seconds +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_capture.py::test_is_stale_buffer_with_recent_file tests/test_capture.py::test_is_stale_buffer_with_old_file tests/test_capture.py::test_is_stale_buffer_with_exact_boundary -xvs +``` + +Expected: All 3 PASS + +- [ ] **Step 5: Write failing tests for `finalize_all_except()`** + +Add to `tests/test_capture.py`: + +```python +def test_finalize_all_except_skips_current_session(tmp_path): + """Should not finalize the current session buffer.""" + store = _make_store(tmp_path) + sess_id = "current-session" + + # Create a current session buffer with observations + path = capture_mod.buffer_path(store, sess_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + result = capture_mod.finalize_all_except( + store, sess_id, max_age_seconds=3600.0 + ) + + assert result["skipped_current"] == [sess_id] + assert path.exists() # Not removed + + +def test_finalize_all_except_finalizes_old_buffer(tmp_path): + """Should finalize buffers older than max_age, except current session.""" + store = _make_store(tmp_path) + current_sess = "current" + old_sess = "old-session" + + # Create old buffer (2 hours old) + old_path = capture_mod.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + old_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + old_mtime = time.time() - 7200 + old_path.touch(times=(old_mtime, old_mtime)) + + # Create current buffer (recent) + curr_path = capture_mod.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 2.0, "tool": "Write", "summary": "test2"}\n') + + result = capture_mod.finalize_all_except( + store, current_sess, max_age_seconds=3600.0 + ) + + assert old_sess in result["finalized"] + assert current_sess in result["skipped_current"] + assert not old_path.exists() # Removed after finalize + assert curr_path.exists() # Current session untouched + + +def test_finalize_all_except_skips_recent_buffers(tmp_path): + """Should not finalize buffers younger than max_age.""" + store = _make_store(tmp_path) + current_sess = "current" + recent_sess = "recent-other" + + # Create recent buffer (30 minutes old) + recent_path = capture_mod.buffer_path(store, recent_sess) + recent_path.parent.mkdir(parents=True, exist_ok=True) + recent_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + recent_mtime = time.time() - 1800 + recent_path.touch(times=(recent_mtime, recent_mtime)) + + result = capture_mod.finalize_all_except( + store, current_sess, max_age_seconds=3600.0 + ) + + assert recent_sess in result["skipped_recent"] + assert recent_path.exists() # Not removed + + +def test_finalize_all_except_multiple_buffers(tmp_path): + """Should handle multiple old and recent buffers correctly.""" + store = _make_store(tmp_path) + current_sess = "current" + + # Create 3 old buffers, 2 recent buffers + old_sesses = ["old1", "old2", "old3"] + recent_sesses = ["recent1", "recent2"] + + now = time.time() + for sid in old_sesses: + path = capture_mod.buffer_path(store, sid) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + old_mtime = now - 7200 # 2 hours ago + path.touch(times=(old_mtime, old_mtime)) + + for sid in recent_sesses: + path = capture_mod.buffer_path(store, sid) + path.write_text('{"ts": 2.0, "tool": "Read", "summary": "test"}\n') + recent_mtime = now - 600 # 10 minutes ago + path.touch(times=(recent_mtime, recent_mtime)) + + # Create current session buffer + curr_path = capture_mod.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 3.0, "tool": "Write", "summary": "test"}\n') + + result = capture_mod.finalize_all_except( + store, current_sess, max_age_seconds=3600.0, now_timestamp=now + ) + + assert set(result["finalized"]) == set(old_sesses) + assert set(result["skipped_recent"]) == set(recent_sesses) + assert result["skipped_current"] == [current_sess] + + # Verify old buffers are removed, others exist + for sid in old_sesses: + assert not capture_mod.buffer_path(store, sid).exists() + for sid in recent_sesses + [current_sess]: + assert capture_mod.buffer_path(store, sid).exists() + + +def test_finalize_all_except_empty_captures_dir(tmp_path): + """Should handle empty or missing captures directory gracefully.""" + store = _make_store(tmp_path) + result = capture_mod.finalize_all_except( + store, "current-session", max_age_seconds=3600.0 + ) + + assert result["finalized"] == [] + assert result["skipped_recent"] == [] + assert result["skipped_current"] == [] + + +def test_finalize_all_except_returns_proposal_ids(tmp_path): + """finalize_all_except should return proposal IDs of finalized buffers.""" + store = _make_store(tmp_path) + old_sess = "old-session" + current_sess = "current" + + # Create old buffer with enough observations + old_path = capture_mod.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + observations = [ + '{"ts": 1.0, "tool": "Read", "summary": "test1"}', + '{"ts": 2.0, "tool": "Read", "summary": "test2"}', + '{"ts": 3.0, "tool": "Read", "summary": "test3"}', + ] + old_path.write_text("\n".join(observations) + "\n") + old_mtime = time.time() - 7200 + old_path.touch(times=(old_mtime, old_mtime)) + + # Create current session buffer + curr_path = capture_mod.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 4.0, "tool": "Write", "summary": "test"}\n') + + result = capture_mod.finalize_all_except( + store, current_sess, max_age_seconds=3600.0 + ) + + assert old_sess in result["finalized"] + # Verify a proposal was created + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) > 0 +``` + +- [ ] **Step 6: Run tests to verify they fail** + +```bash +pytest tests/test_capture.py::test_finalize_all_except_skips_current_session -xvs +``` + +Expected: `FAILED ... NameError: name 'finalize_all_except' is not defined` + +- [ ] **Step 7: Implement `finalize_all_except()` in capture.py** + +After the `is_stale_buffer()` function, add: + +```python +def finalize_all_except( + store: KBStore, + current_session_id: str, + *, + max_age_seconds: float = 3600.0, + cwd: Path | None = None, + now_timestamp: float | None = None, +) -> dict[str, Any]: + """Finalize all buffers except current_session_id, if they're older than max_age. + + Returns dict with keys: + - finalized: [session_id1, session_id2, ...] session IDs that were finalized + - skipped_recent: [id3, id4, ...] sessions too recent to finalize + - skipped_current: [id5] the current session (always skipped) + """ + finalized: list[str] = [] + skipped_recent: list[str] = [] + skipped_current: list[str] = [] + now = now_timestamp if now_timestamp is not None else time.time() + + caps_dir = captures_dir(store) + if not caps_dir.exists(): + return { + "finalized": finalized, + "skipped_recent": skipped_recent, + "skipped_current": skipped_current, + } + + for path in sorted(caps_dir.glob("*.jsonl")): + # Extract session ID from filename (e.g., "session-id.jsonl" -> "session-id") + session_id = path.stem + + if session_id == current_session_id: + skipped_current.append(session_id) + continue + + if is_stale_buffer(path, max_age_seconds=max_age_seconds, now_timestamp=now): + try: + finalize( + store, session_id, cwd=cwd, + generated_at=datetime.now(timezone.utc).isoformat(), + ) + finalized.append(session_id) + except Exception: + # Never let a finalize failure break the scan + pass + else: + skipped_recent.append(session_id) + + return { + "finalized": finalized, + "skipped_recent": skipped_recent, + "skipped_current": skipped_current, + } +``` + +Note: You'll need to import `datetime` and `timezone` at the top of capture.py if not already imported: + +```python +from datetime import datetime, timezone +``` + +- [ ] **Step 8: Run all tests to verify they pass** + +```bash +pytest tests/test_capture.py::test_finalize_all_except_skips_current_session tests/test_capture.py::test_finalize_all_except_finalizes_old_buffer tests/test_capture.py::test_finalize_all_except_skips_recent_buffers tests/test_capture.py::test_finalize_all_except_multiple_buffers tests/test_capture.py::test_finalize_all_except_empty_captures_dir tests/test_capture.py::test_finalize_all_except_returns_proposal_ids -xvs +``` + +Expected: All 6 PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/vouch/capture.py tests/test_capture.py +git commit -m "feat(capture): add finalize_all_except() for old buffer cleanup + +implement is_stale_buffer() to check file age, and finalize_all_except() +to bulk-finalize capture buffers older than a threshold. this enables +sessionstart cleanup of orphaned buffers from previous sessions. + +includes 9 unit tests covering single/multiple buffers, age boundaries, +and current session exclusion." +``` + +--- + +## Task 2: Add `vouch capture finalize-all` CLI command + +**Files:** +- Modify: `src/vouch/cli.py:800+` (after existing `finalize` command) +- Test: `tests/test_capture.py` + +**Interfaces:** +- Consumes: `capture.finalize_all_except(store, session_id, max_age_seconds=...) -> dict` +- Produces: CLI command `vouch capture finalize-all [--session-id ] [--max-age-seconds ]` + +- [ ] **Step 1: Write the failing test for CLI command** + +Add to `tests/test_capture.py`: + +```python +def test_capture_finalize_all_cmd_with_old_buffers(tmp_path, monkeypatch): + """CLI command should finalize old buffers and emit JSON.""" + store = _make_store(tmp_path) + current_sess = "current" + old_sess = "old-session" + + # Create old buffer + old_path = capture_mod.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + observations = [ + '{"ts": 1.0, "tool": "Read", "summary": "test1"}', + '{"ts": 2.0, "tool": "Read", "summary": "test2"}', + '{"ts": 3.0, "tool": "Read", "summary": "test3"}', + ] + old_path.write_text("\n".join(observations) + "\n") + old_mtime = time.time() - 7200 + old_path.touch(times=(old_mtime, old_mtime)) + + # Mock the store discovery and cwd + monkeypatch.setenv("VOUCH_KB_ROOT", str(store.kb_root)) + + runner = CliRunner() + result = runner.invoke(cli, [ + "capture", "finalize-all", + "--session-id", current_sess, + "--max-age-seconds", "3600", + ]) + + 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_reads_session_from_env(tmp_path, monkeypatch): + """CLI command should fall back to VOUCH_SESSION_ID env var.""" + store = _make_store(tmp_path) + current_sess = "from-env" + + # Create current session buffer + curr_path = capture_mod.buffer_path(store, current_sess) + curr_path.parent.mkdir(parents=True, exist_ok=True) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + monkeypatch.setenv("VOUCH_KB_ROOT", str(store.kb_root)) + monkeypatch.setenv("VOUCH_SESSION_ID", current_sess) + + runner = CliRunner() + result = runner.invoke(cli, ["capture", "finalize-all"]) + + assert result.exit_code == 0 + output = json.loads(result.output) + assert current_sess in output["skipped_current"] + + +def test_capture_finalize_all_cmd_silent_on_no_kb(monkeypatch, tmp_path): + """CLI command should silently succeed if KB not found.""" + monkeypatch.setenv("VOUCH_KB_ROOT", str(tmp_path / "nonexistent")) + + runner = CliRunner() + result = runner.invoke(cli, [ + "capture", "finalize-all", + "--session-id", "test", + ]) + + # Should exit 0, not fail + assert result.exit_code == 0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/test_capture.py::test_capture_finalize_all_cmd_with_old_buffers -xvs +``` + +Expected: `FAILED ... no such option: --session-id` or similar + +- [ ] **Step 3: Implement CLI command in cli.py** + +Find the `@capture.command("finalize")` section (around line 740-760). After the `capture_finalize_cmd()` function and before the `@capture.command("banner")`, add: + +```python +@capture.command("finalize-all") +@click.option("--session-id", default=None, help="Current session id (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 "" + if not sid: + # No session ID provided; silently succeed + _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) + return + + store = _capture_store() + if store is None: + # No KB; silently succeed + _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) + return + + result = capture_mod.finalize_all_except( + store, sid, max_age_seconds=max_age_seconds, + ) + _emit_json(result) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_capture.py::test_capture_finalize_all_cmd_with_old_buffers tests/test_capture.py::test_capture_finalize_all_cmd_reads_session_from_env tests/test_capture.py::test_capture_finalize_all_cmd_silent_on_no_kb -xvs +``` + +Expected: All 3 PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/cli.py tests/test_capture.py +git commit -m "feat(cli): add 'vouch capture finalize-all' command + +new command finalizes all capture buffers except the given session, +if they are older than max_age_seconds (default 3600s). silently +succeeds if no KB found. used by sessionstart hook to clean up +orphaned buffers from previous sessions. + +includes 3 unit tests covering option parsing, env fallback, and +graceful degradation." +``` + +--- + +## Task 3: Update adapter SessionStart hook + +**Files:** +- Modify: `adapters/claude-code/.claude/settings.json:22-42` +- No test (hook configuration is integration-tested via the capture tests) + +**Interfaces:** +- Consumes: CLI command `vouch capture finalize-all` (from Task 2) +- Produces: Updated hook configuration + +- [ ] **Step 1: Modify the SessionStart hook** + +Edit `adapters/claude-code/.claude/settings.json`. Change the `SessionStart` section from: + +```json +"SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch status --json || true" + }, + { + "type": "command", + "command": "vouch capture banner || true" + }, + { + "type": "command", + "command": "vouch recall || true" + } + ] + } +] +``` + +To: + +```json +"SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture finalize-all || true" + }, + { + "type": "command", + "command": "vouch status --json || true" + }, + { + "type": "command", + "command": "vouch capture banner || true" + }, + { + "type": "command", + "command": "vouch recall || true" + } + ] + } +] +``` + +The key change: `finalize-all` is now the **first** hook, so old buffers are cleaned up before the banner and recall. + +- [ ] **Step 2: Verify the JSON is valid** + +```bash +python -c "import json; json.load(open('adapters/claude-code/.claude/settings.json'))" +``` + +Expected: No output (valid JSON) + +- [ ] **Step 3: Commit** + +```bash +git add adapters/claude-code/.claude/settings.json +git commit -m "feat(adapter): wire capture finalize-all into sessionstart hook + +run 'vouch capture finalize-all' as the first sessionstart hook +to clean up orphaned buffers from previous sessions before +banner and recall commands. ensures old sessions are finalized +automatically on next session start." +``` + +--- + +## Task 4: Add WindowClose hook configuration (conditional) + +**Files:** +- Modify: `adapters/claude-code/.claude/settings.json:52+` +- No test (integration-level, not unit-testable) + +**Interfaces:** +- Consumes: CLI command `vouch capture finalize` (existing) +- Produces: WindowClose hook (conditional) + +- [ ] **Step 1: Check if VS Code extension supports WindowClose event** + +Check the Claude Code extension documentation or recent commits to see if `WindowClose` is a supported hook event. Look for: +- Extension release notes mentioning new hook types +- Tests or examples using `WindowClose` +- Comments in settings.json from recent commits + +**For now, assume it's NOT supported yet.** If you find evidence that it IS supported, proceed to Step 2. Otherwise, skip to Step 3. + +- [ ] **Step 2: (If WindowClose is supported) Add the hook** + +Add this new hook section after the `PostToolUse` section in `adapters/claude-code/.claude/settings.json`: + +```json +"WindowClose": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture finalize || true" + } + ] + } +] +``` + +Then commit: + +```bash +git commit -m "feat(adapter): wire capture finalize into windowclose hook + +finalize the current session's buffer when the vs code window closes, +creating a proposal immediately without waiting for next session start." +``` + +- [ ] **Step 3: (If WindowClose is NOT supported) Document the fallback** + +Add a comment to the settings.json explaining the fallback behavior. Change the SessionStart comment to: + +```json +"SessionStart": [ + { + "comment": "finalize old buffers from previous sessions; current session will be finalized here too on next session start (fallback: windowclose event not yet supported)", + "matcher": "*", + ... + } +] +``` + +Then update the adapter README to document this. See Task 5. + +--- + +## Task 5: Update adapter README with behavior docs + +**Files:** +- Modify: `adapters/claude-code/README.md` +- No test + +**Interfaces:** +- Produces: User-facing documentation + +- [ ] **Step 1: Add a "Session Capture" section to the README** + +Find the README and add this section (after the "Installation" or "Features" section): + +```markdown +## Session Capture & Auto-Proposal + +When you work in a Claude Code session, vouch automatically captures your +tool use (file reads, edits, commands, etc.). When you close the session +window, vouch proposes the captured knowledge to the KB for review. + +### How it works + +1. **Capture**: Each tool call (Read, Edit, Bash, etc.) is logged to + `.vouch/captures/.jsonl` (gitignored). + +2. **Cleanup on session start**: When you start a new session, any + unfinalzed buffers from previous sessions (>1 hour old) are + automatically finalized and proposed. + +3. **Finalize on window close**: When the VS Code window closes, the + current session is finalized and proposed. + +### Configuration + +Disable capture in `.vouch/config.yaml`: + +```yaml +capture: + enabled: false +``` + +Adjust the stale buffer age (default: 1 hour): + +```yaml +capture: + max_age_seconds: 7200 # finalize buffers >2 hours old +``` + +### Fallback behavior + +If the "window close" event is not yet supported by your version of the +Claude Code extension, the current session will be finalized on the *next* +session start instead. The behavior is the same; proposals just appear in +the next session rather than immediately. + +To upgrade or check your extension version, see [Claude Code releases](https://github.com/anthropics/claude-code-releases). +``` + +- [ ] **Step 2: Commit** + +```bash +git add adapters/claude-code/README.md +git commit -m "docs(adapter): explain session capture auto-proposal behavior + +add section describing how capture works, configuration options, +and the fallback behavior if windowclose event is not available." +``` + +--- + +## Task 6: Add integration test (smoke test) + +**Files:** +- Modify: `tests/test_capture.py` +- (Optional make target in Makefile) + +**Interfaces:** +- Consumes: All functions from Tasks 1-2 +- Produces: End-to-end test + +- [ ] **Step 1: Write the end-to-end test** + +Add to `tests/test_capture.py`: + +```python +def test_capture_e2e_sessionstart_cleanup_then_finalize(tmp_path): + """End-to-end: old buffers cleaned up on sessionstart, current session on finalize.""" + store = _make_store(tmp_path) + + # Simulate a previous session that crashed/closed without finalize + old_sess = "crashed-session" + old_path = capture_mod.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + observations = [ + '{"ts": 1.0, "tool": "Read", "summary": "test1"}', + '{"ts": 2.0, "tool": "Read", "summary": "test2"}', + '{"ts": 3.0, "tool": "Read", "summary": "test3"}', + ] + old_path.write_text("\n".join(observations) + "\n") + old_mtime = time.time() - 7200 # 2 hours ago + old_path.touch(times=(old_mtime, old_mtime)) + + # Simulate a new session starting + new_sess = "new-session" + + # 1. SessionStart cleanup (finalize old buffers) + cleanup_result = capture_mod.finalize_all_except( + store, new_sess, max_age_seconds=3600.0 + ) + assert old_sess in cleanup_result["finalized"] + assert not old_path.exists() + + # Verify old session was proposed + pending_before = store.list_proposals(ProposalStatus.PENDING) + old_proposals = [p for p in pending_before if p.session_id == old_sess] + assert len(old_proposals) == 1 + + # 2. SessionEnd finalize (current session) + new_path = capture_mod.buffer_path(store, new_sess) + new_path.write_text("\n".join(observations) + "\n") + + finalize_result = capture_mod.finalize(store, new_sess) + assert finalize_result["summary_proposal_id"] is not None + assert not new_path.exists() + + # Verify new session was proposed + pending_after = store.list_proposals(ProposalStatus.PENDING) + new_proposals = [p for p in pending_after if p.session_id == new_sess] + assert len(new_proposals) == 1 + + # Total proposals: old + new + assert len(pending_after) >= 2 +``` + +- [ ] **Step 2: Run the test to verify it passes** + +```bash +pytest tests/test_capture.py::test_capture_e2e_sessionstart_cleanup_then_finalize -xvs +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_capture.py +git commit -m "test(capture): add e2e test for sessionstart cleanup + finalize flow + +verify that old buffers are cleaned up on new session start +and current session is finalized on window close (or manual finalize), +resulting in two separate proposals." +``` + +--- + +## Task 7: Run full test suite and verify no regressions + +**Files:** (no changes) + +- [ ] **Step 1: Run all capture tests** + +```bash +source .venv/bin/activate +pytest tests/test_capture.py -v +``` + +Expected: All tests PASS (>15 tests now) + +- [ ] **Step 2: Run full test suite (ignore embeddings)** + +```bash +pytest tests/ -q --ignore=tests/embeddings +``` + +Expected: No new failures, all existing tests still pass + +- [ ] **Step 3: Run mypy type check** + +```bash +python -m mypy src +``` + +Expected: No new errors, all files type-check + +- [ ] **Step 4: Run ruff lint** + +```bash +python -m ruff check src tests +``` + +Expected: No new issues, all code follows style + +- [ ] **Step 5: Run make check (convenience wrapper)** + +```bash +make check +``` + +Expected: All checks green + +--- + +## Task 8: Commit spec and plan files + +**Files:** +- Already created: `docs/superpowers/specs/2026-07-01-vscode-session-autoproposal-design.md` +- Already created: `docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md` + +- [ ] **Step 1: Add design doc and plan to git** + +```bash +git add docs/superpowers/specs/2026-07-01-vscode-session-autoproposal-design.md +git add docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md +git commit -m "docs(superpowers): add design and plan for vscode session auto-proposal + +design: automatic finalization of old buffers on sessionstart, +and current session on window close. + +plan: 8-task breakdown covering capture.py functions, cli command, +adapter hooks, comprehensive unit tests, integration test, and +documentation." +``` + +--- + +## Summary + +| Task | Deliverable | Tests | +|------|-------------|-------| +| 1 | `is_stale_buffer()`, `finalize_all_except()` in capture.py | 9 unit tests | +| 2 | `vouch capture finalize-all` CLI command | 3 unit tests | +| 3 | Updated SessionStart hook in adapter settings | 0 (config) | +| 4 | WindowClose hook (conditional) | 0 (config) | +| 5 | README docs on behavior | 0 (docs) | +| 6 | E2E integration test | 1 test | +| 7 | Full regression testing | 0 (validation) | +| 8 | Commit design & plan | 0 (docs) | + +**Total new tests: 13+ unit tests + 1 integration test** + +--- + +## Execution + +**Plan complete and saved to `docs/superpowers/plans/2026-07-01-vscode-session-autoproposal.md`.** + +Two execution options: + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task (or 2-3 related tasks), review between tasks, fast iteration with checkpoints + +2. **Inline Execution** — Execute tasks in this session sequentially with checkpoints for review + +**Which approach would you prefer?** diff --git a/docs/superpowers/specs/2026-07-01-vscode-session-autoproposal-design.md b/docs/superpowers/specs/2026-07-01-vscode-session-autoproposal-design.md new file mode 100644 index 00000000..74f0aa6c --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-vscode-session-autoproposal-design.md @@ -0,0 +1,228 @@ +# VS Code Session Auto-Proposal Design + +**Date:** 2026-07-01 +**Status:** Design phase +**Goal:** When a Claude Code session closes in VS Code, automatically finalize and propose its captured knowledge to vouch, without user action. + +## Problem + +Today, vouch captures observations from VS Code Claude Code sessions into `.vouch/captures/.jsonl`. The `vouch capture finalize` command works correctly and creates proposals. However: + +1. The `SessionEnd` hook (configured in `adapters/claude-code/.claude/settings.json`) is **not firing** when the user closes the session window +2. Users must manually run `vouch capture finalize` to turn captured observations into a proposal +3. Sessions that crash or are force-closed leave orphaned capture buffers with no way to finalize them + +## Solution Overview + +Implement automatic finalization through two mechanisms: + +### **Mechanism 1: SessionStart cleanup (old sessions)** +When a new Claude Code session starts, scan `.vouch/captures/` and finalize any buffers that don't belong to the current session. This catches: +- Previous sessions that closed but weren't finalized +- Crashed sessions with stale buffers +- Sessions from other projects (optional: filter by cwd) + +### **Mechanism 2: Window close handler (current session)** +When the VS Code window closes or the extension unloads, finalize the current session's buffer. + +**Implementation strategy:** Use Approach 1 (preferred) if the extension exposes a close event; fall back to Approach 2 (lazy finalization at next SessionStart) if not. + +## Architecture Changes + +### Files to modify/create + +1. **`src/vouch/capture.py`** + - Add `finalize_all_except(session_id)` — finalize buffers for sessions other than the one given + - Add `is_stale_buffer(path, age_seconds)` — check if a buffer is older than a threshold (e.g., 1 hour) + +2. **`src/vouch/cli.py`** + - Add `vouch capture finalize-all` command — public entry point for SessionStart hook + - Modify existing `capture_finalize_cmd` to handle the current session explicitly + +3. **`adapters/claude-code/.claude/settings.json`** + - Update `SessionStart` hook to run `vouch capture finalize-all` *before* the banner/recall (to clean up old sessions first) + - Try to wire a proper close event if the extension supports it; document fallback behavior + +4. **`tests/test_capture.py`** + - Add tests for `finalize_all_except()` with multiple buffers + - Add tests for stale buffer detection + - Add end-to-end test: create multiple buffers, start new session, verify old ones finalize + +## Behavior Specification + +### SessionStart: clean up old buffers + +**Trigger:** `SessionStart` hook (fires when new session begins) + +**Command:** `vouch capture finalize-all [--session-id ] [--max-age ]` + +**Behavior:** +1. Read `.vouch/captures/` for all `.jsonl` files +2. For each buffer: + - If the session ID matches the current session, skip it + - If the buffer is older than `--max-age` (default: 3600s / 1 hour), finalize it + - Otherwise, skip it (not old enough) +3. Finalize each stale buffer into a proposal +4. Log summary (e.g., "finalized 2 old buffers") + +**Exit behavior:** Never fail the session start. Silently succeed if no buffers to finalize, or if finalization encounters errors. + +### Window close: finalize current session + +**Trigger:** Window close / extension unload (if available); fallback to lazy finalization + +**Desired behavior:** +- Hook fires when VS Code window closes +- Command: `vouch capture finalize --session-id ` (existing command) +- Result: Proposal is created and appears in vouch immediately + +**Fallback (if close hook unavailable):** +- Current session buffer is finalized on *next* SessionStart (caught by cleanup mechanism) +- Proposal appears in the next session, not immediately +- Document this limitation in the adapter README + +## API Design + +### New: `capture.finalize_all_except()` + +```python +def finalize_all_except( + store: KBStore, + current_session_id: str, + *, + max_age_seconds: float = 3600.0, + cwd: Path | None = None, +) -> dict[str, Any]: + """ + Finalize all buffers except current_session_id, if they're older than max_age. + + Returns: + { + "finalized": [id1, id2, ...], + "skipped_recent": [id3, id4, ...], + "skipped_current": [id5], + } + """ +``` + +### Modified: `vouch capture finalize-all` command + +``` +vouch capture finalize-all [--session-id ] [--max-age-seconds ] + +Finalize all capture buffers except the given session (current session). +Falls back to reading from env: VOUCH_SESSION_ID, VOUCH_SESSION_MAX_AGE. +``` + +### Error handling + +- If `_capture_store()` returns None (KB not initialized), silently succeed +- If finalize fails for one buffer, continue with the rest (don't cascade) +- Never raise exceptions that break the SessionStart hook + +## Hook Configuration (adapter settings) + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture finalize-all || true" + }, + { + "type": "command", + "command": "vouch status --json || true" + }, + { + "type": "command", + "command": "vouch capture banner || true" + }, + { + "type": "command", + "command": "vouch recall || true" + } + ] + } + ], + "PostToolUse": [ ... ], + "WindowClose": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture finalize || true" + } + ] + } + ] + } +} +``` + +If `WindowClose` event is not available in the extension, this section is removed and a note is added to the README documenting the fallback behavior. + +## Testing Strategy + +1. **Unit tests** (`tests/test_capture.py`): + - `test_finalize_all_except_with_multiple_buffers()` + - `test_finalize_all_except_skips_current_session()` + - `test_finalize_all_except_skips_recent_buffers()` + - `test_finalize_all_except_removes_stale_buffers()` + +2. **CLI tests**: + - `test_capture_finalize_all_command()` + +3. **End-to-end smoke test** (make target): + - Create mock session with observations + - Simulate SessionStart + - Verify old buffers are finalized + - Verify pending count increases + +## Scope & Constraints + +**In scope:** +- SessionStart cleanup of old buffers +- Public `finalize-all` command +- Tests +- Adapter hook wiring (conditional on extension support) + +**Out of scope:** +- Changing the capture observation mechanism +- Storage format changes +- Cross-project buffer isolation (all buffers in one KB) +- Compression or archiving of old proposals + +**Constraints:** +- Never break SessionStart (all errors are caught) +- Backward compatible with existing `finalize` command +- Works with current KB structure (no schema changes) +- Max age defaults to 1 hour (configurable) + +## Success Criteria + +1. ✅ Old capture buffers from previous sessions are finalized when a new session starts +2. ✅ Current session buffer is finalized when the window closes (if hook available) +3. ✅ Proposals appear in vouch `pending` list without user action +4. ✅ All tests pass, no regressions +5. ✅ Adapter settings are updated with hook configuration +6. ✅ README documents the behavior and fallback + +## Open Questions + +1. Does the VS Code Claude Code extension fire a `WindowClose` event? If not, document the fallback clearly. +2. Should max-age be configurable per project in `config.yaml`? (Defer to future if not critical) +3. Should we filter buffers by `cwd` to avoid finalizing sessions from other projects? (Future enhancement) + +--- + +**Next steps:** +1. User reviews this spec +2. Implement `finalize_all_except()` and CLI command +3. Update adapter hooks +4. Write tests +5. Test end-to-end From 5933700a8761cceb510fa8270db0d1f2ca3f828e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:35:32 +0900 Subject: [PATCH 2/8] feat(capture): add finalize_all_except() for old buffer cleanup implement is_stale_buffer() to check file age, and finalize_all_except() to bulk-finalize capture buffers older than a threshold. this enables sessionstart cleanup of orphaned buffers from previous sessions. includes 9 unit tests covering single/multiple buffers, age boundaries, and current session exclusion. --- src/vouch/capture.py | 72 +++++++++++++++ tests/test_capture.py | 200 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 5ec3427b..c43f7939 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -14,6 +14,7 @@ import subprocess import time from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -280,3 +281,74 @@ def pending_count(store: KBStore) -> int: 1 for p in store.list_proposals(ProposalStatus.PENDING) if p.proposed_by == CAPTURE_ACTOR ) + + +def is_stale_buffer( + path: Path, + *, + max_age_seconds: float = 3600.0, + now_timestamp: float | None = None, +) -> bool: + """Check if a buffer file's mtime is older than max_age_seconds.""" + if not path.exists(): + return False + now = now_timestamp if now_timestamp is not None else time.time() + mtime = path.stat().st_mtime + age = now - mtime + return age > max_age_seconds + + +def finalize_all_except( + store: KBStore, + current_session_id: str, + *, + max_age_seconds: float = 3600.0, + cwd: Path | None = None, + now_timestamp: float | None = None, +) -> dict[str, Any]: + """Finalize all buffers except current_session_id, if they're older than max_age. + + Returns dict with keys: + - finalized: [session_id1, session_id2, ...] session IDs that were finalized + - skipped_recent: [id3, id4, ...] sessions too recent to finalize + - skipped_current: [id5] the current session (always skipped) + """ + finalized: list[str] = [] + skipped_recent: list[str] = [] + skipped_current: list[str] = [] + now = now_timestamp if now_timestamp is not None else time.time() + + caps_dir = captures_dir(store) + if not caps_dir.exists(): + return { + "finalized": finalized, + "skipped_recent": skipped_recent, + "skipped_current": skipped_current, + } + + for path in sorted(caps_dir.glob("*.jsonl")): + # Extract session ID from filename (e.g., "session-id.jsonl" -> "session-id") + session_id = path.stem + + if session_id == current_session_id: + skipped_current.append(session_id) + continue + + if is_stale_buffer(path, max_age_seconds=max_age_seconds, now_timestamp=now): + try: + finalize( + store, session_id, cwd=cwd, + generated_at=datetime.now(UTC).isoformat(), + ) + finalized.append(session_id) + except Exception: + # Never let a finalize failure break the scan + pass + else: + skipped_recent.append(session_id) + + return { + "finalized": finalized, + "skipped_recent": skipped_recent, + "skipped_current": skipped_current, + } diff --git a/tests/test_capture.py b/tests/test_capture.py index 43eefd40..262e44d6 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -325,3 +325,203 @@ 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")) + + +def test_is_stale_buffer_with_recent_file(tmp_path): + """Recent file should not be stale.""" + import time as time_mod + f = tmp_path / "recent.jsonl" + f.write_text("test") + now = time_mod.time() + # File created 30 seconds ago; max_age=3600 + assert not cap.is_stale_buffer(f, max_age_seconds=3600, now_timestamp=now) + + +def test_is_stale_buffer_with_old_file(tmp_path): + """File older than max_age should be stale.""" + import os + import time as time_mod + f = tmp_path / "old.jsonl" + f.write_text("test") + old_time = time_mod.time() - 7200 # 2 hours ago + os.utime(f, (old_time, old_time)) # Set mtime to 2 hours ago + now = time_mod.time() + assert cap.is_stale_buffer(f, max_age_seconds=3600, now_timestamp=now) + + +def test_is_stale_buffer_with_exact_boundary(tmp_path): + """File at exact max_age boundary should not be stale (>=).""" + import os + import time as time_mod + f = tmp_path / "boundary.jsonl" + f.write_text("test") + exact_time = time_mod.time() - 3600 # Exactly 1 hour ago + os.utime(f, (exact_time, exact_time)) + now = exact_time + 3600 + assert not cap.is_stale_buffer(f, max_age_seconds=3600, now_timestamp=now) + + +def _make_store(tmp_path: Path) -> KBStore: + """Helper to create a KBStore for testing.""" + return KBStore.init(tmp_path) + + +def test_finalize_all_except_skips_current_session(tmp_path): + """Should not finalize the current session buffer.""" + store = _make_store(tmp_path) + sess_id = "current-session" + + # Create a current session buffer with observations + path = cap.buffer_path(store, sess_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + result = cap.finalize_all_except( + store, sess_id, max_age_seconds=3600.0 + ) + + assert result["skipped_current"] == [sess_id] + assert path.exists() # Not removed + + +def test_finalize_all_except_finalizes_old_buffer(tmp_path): + """Should finalize buffers older than max_age, except current session.""" + import os + import time as time_mod + store = _make_store(tmp_path) + current_sess = "current" + old_sess = "old-session" + + # Create old buffer (2 hours old) + 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": "test"}\n') + old_mtime = time_mod.time() - 7200 + os.utime(old_path, (old_mtime, old_mtime)) + + # Create current buffer (recent) + curr_path = cap.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 2.0, "tool": "Write", "summary": "test2"}\n') + + result = cap.finalize_all_except( + store, current_sess, max_age_seconds=3600.0 + ) + + assert old_sess in result["finalized"] + assert current_sess in result["skipped_current"] + assert not old_path.exists() # Removed after finalize + assert curr_path.exists() # Current session untouched + + +def test_finalize_all_except_skips_recent_buffers(tmp_path): + """Should not finalize buffers younger than max_age.""" + import os + import time as time_mod + store = _make_store(tmp_path) + current_sess = "current" + recent_sess = "recent-other" + + # Create recent buffer (30 minutes old) + recent_path = cap.buffer_path(store, recent_sess) + recent_path.parent.mkdir(parents=True, exist_ok=True) + recent_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + recent_mtime = time_mod.time() - 1800 + os.utime(recent_path, (recent_mtime, recent_mtime)) + + result = cap.finalize_all_except( + store, current_sess, max_age_seconds=3600.0 + ) + + assert recent_sess in result["skipped_recent"] + assert recent_path.exists() # Not removed + + +def test_finalize_all_except_multiple_buffers(tmp_path): + """Should handle multiple old and recent buffers correctly.""" + import os + import time as time_mod + store = _make_store(tmp_path) + current_sess = "current" + + # Create 3 old buffers, 2 recent buffers + old_sesses = ["old1", "old2", "old3"] + recent_sesses = ["recent1", "recent2"] + + now = time_mod.time() + for sid in old_sesses: + path = cap.buffer_path(store, sid) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + old_mtime = now - 7200 # 2 hours ago + os.utime(path, (old_mtime, old_mtime)) + + for sid in recent_sesses: + path = cap.buffer_path(store, sid) + path.write_text('{"ts": 2.0, "tool": "Read", "summary": "test"}\n') + recent_mtime = now - 600 # 10 minutes ago + os.utime(path, (recent_mtime, recent_mtime)) + + # Create current session buffer + curr_path = cap.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 3.0, "tool": "Write", "summary": "test"}\n') + + result = cap.finalize_all_except( + store, current_sess, max_age_seconds=3600.0, now_timestamp=now + ) + + assert set(result["finalized"]) == set(old_sesses) + assert set(result["skipped_recent"]) == set(recent_sesses) + assert result["skipped_current"] == [current_sess] + + # Verify old buffers are removed, others exist + for sid in old_sesses: + assert not cap.buffer_path(store, sid).exists() + for sid in [*recent_sesses, current_sess]: + assert cap.buffer_path(store, sid).exists() + + +def test_finalize_all_except_empty_captures_dir(tmp_path): + """Should handle empty or missing captures directory gracefully.""" + store = _make_store(tmp_path) + result = cap.finalize_all_except( + store, "current-session", max_age_seconds=3600.0 + ) + + assert result["finalized"] == [] + assert result["skipped_recent"] == [] + assert result["skipped_current"] == [] + + +def test_finalize_all_except_returns_proposal_ids(tmp_path): + """finalize_all_except should return proposal IDs of finalized buffers.""" + import os + import time as time_mod + store = _make_store(tmp_path) + old_sess = "old-session" + current_sess = "current" + + # Create old buffer with enough observations + old_path = cap.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + observations = [ + '{"ts": 1.0, "tool": "Read", "summary": "test1"}', + '{"ts": 2.0, "tool": "Read", "summary": "test2"}', + '{"ts": 3.0, "tool": "Read", "summary": "test3"}', + ] + old_path.write_text("\n".join(observations) + "\n") + old_mtime = time_mod.time() - 7200 + os.utime(old_path, (old_mtime, old_mtime)) + + # Create current session buffer + curr_path = cap.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 4.0, "tool": "Write", "summary": "test"}\n') + + result = cap.finalize_all_except( + store, current_sess, max_age_seconds=3600.0 + ) + + assert old_sess in result["finalized"] + # Verify a proposal was created + from vouch.models import ProposalStatus + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) > 0 From f6cb5065d2b177d3edc811525c3c5471de05a49e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:44:54 +0900 Subject: [PATCH 3/8] feat(capture): add 'vouch capture finalize-all' CLI command new command finalizes all capture buffers except the given session, if they are older than max_age_seconds (default 3600s). silently succeeds if no kb found. used by sessionstart hook to clean up orphaned buffers from previous sessions. includes 3 unit tests covering option parsing, env fallback, and graceful degradation. --- src/vouch/cli.py | 23 ++++++++++++++ tests/test_capture.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index c89311bd..d871daf9 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -1390,6 +1390,29 @@ def capture_finalize_cmd(session_id: str | None) -> None: _emit_json(result) +@capture.command("finalize-all") +@click.option("--session-id", default=None, help="Current session id (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 "" + if not sid: + # No session ID provided; silently succeed + _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) + return + + store = _capture_store() + if store is None: + # No KB; silently succeed + _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) + return + + result = capture_mod.finalize_all_except( + store, sid, max_age_seconds=max_age_seconds, + ) + _emit_json(result) + + @capture.command("banner") def capture_banner_cmd() -> None: """Emit a SessionStart nudge if captured summaries await review.""" diff --git a/tests/test_capture.py b/tests/test_capture.py index 262e44d6..9792fc4a 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -327,6 +327,80 @@ def commands(event: str) -> list[str]: assert any("capture banner" in c for c in commands("SessionStart")) +def test_capture_finalize_all_cmd_with_old_buffers(tmp_path: Path, monkeypatch) -> None: + """CLI command should finalize old buffers and emit JSON.""" + import os + import time as time_mod + + store = _make_store(tmp_path) + current_sess = "current" + old_sess = "old-session" + + # Create old buffer + old_path = cap.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + observations = [ + '{"ts": 1.0, "tool": "Read", "summary": "test1"}', + '{"ts": 2.0, "tool": "Read", "summary": "test2"}', + '{"ts": 3.0, "tool": "Read", "summary": "test3"}', + ] + old_path.write_text("\n".join(observations) + "\n") + old_mtime = time_mod.time() - 7200 + os.utime(old_path, (old_mtime, old_mtime)) + + # Create current buffer + curr_path = cap.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + # Run the CLI command + runner = CliRunner() + result = runner.invoke(cli, [ + "capture", "finalize-all", + "--session-id", current_sess, + "--max-age-seconds", "3600", + ], 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_reads_session_from_env(tmp_path: Path, monkeypatch) -> None: + """CLI command should fall back to VOUCH_SESSION_ID env var.""" + store = _make_store(tmp_path) + current_sess = "from-env" + + # Create current session buffer + curr_path = cap.buffer_path(store, current_sess) + curr_path.parent.mkdir(parents=True, exist_ok=True) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + runner = CliRunner() + result = runner.invoke(cli, [ + "capture", "finalize-all" + ], env={ + "VOUCH_KB_PATH": str(store.kb_dir), + "VOUCH_SESSION_ID": current_sess, + }) + + assert result.exit_code == 0 + output = _json.loads(result.output) + assert current_sess in 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() + result = runner.invoke(cli, [ + "capture", "finalize-all", + "--session-id", "test", + ], env={"VOUCH_KB_PATH": str(tmp_path / "nonexistent")}) + + # Should exit 0, not fail + assert result.exit_code == 0 + + def test_is_stale_buffer_with_recent_file(tmp_path): """Recent file should not be stale.""" import time as time_mod From 6ec0fe718aa87d952838eec61cb1155a40712704 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:48:37 +0900 Subject: [PATCH 4/8] feat(adapter): wire capture finalize-all into sessionstart hook run 'vouch capture finalize-all' as the first sessionstart hook to clean up orphaned buffers from previous sessions before banner and recall commands. ensures old sessions are finalized automatically on next session start. --- adapters/claude-code/.claude/settings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index 35f64397..dabdeb17 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -23,6 +23,10 @@ { "matcher": "*", "hooks": [ + { + "type": "command", + "command": "vouch capture finalize-all || true" + }, { "type": "command", "command": "vouch status --json || true" From d68a376c3535deaa88e84db71619d58b88f4411c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:48:47 +0900 Subject: [PATCH 5/8] docs(adapter): document windowclose fallback behavior add comment to sessionstart hook explaining that current session finalization happens on next session start (fallback behavior when windowclose event is not supported by the vs code extension). --- adapters/claude-code/.claude/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index dabdeb17..5fd35a67 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -21,6 +21,7 @@ "hooks": { "SessionStart": [ { + "comment": "finalize old buffers from previous sessions; current session will be finalized here too on next session start (fallback: windowclose event not yet supported by claude-code extension)", "matcher": "*", "hooks": [ { From 2d76811b6f670a91cab444417e8dd7e62dd1b3fa Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:49:03 +0900 Subject: [PATCH 6/8] docs(adapter): explain session capture auto-proposal behavior add section describing how capture works, configuration options, and the fallback behavior if windowclose event is not available. --- adapters/claude-code/README.md | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 6b28a4ec..2e43c181 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -57,6 +57,49 @@ In a fresh session, ask Claude: It should enumerate `kb_search`, `kb_propose_claim`, etc. If not, run `claude --debug-mcp` to see why the server isn't loading. +## Session Capture & Auto-Proposal + +When you work in a Claude Code session, vouch automatically captures your +tool use (file reads, edits, commands, etc.). When you close the session +window, vouch proposes the captured knowledge to the KB for review. + +### How it works + +1. **Capture**: Each tool call (Read, Edit, Bash, etc.) is logged to + `.vouch/captures/.jsonl` (gitignored). + +2. **Cleanup on session start**: When you start a new session, any + unfinalzed buffers from previous sessions (>1 hour old) are + automatically finalized and proposed. + +3. **Finalize on window close**: When the VS Code window closes, the + current session is finalized and proposed. + +### Configuration + +Disable capture in `.vouch/config.yaml`: + +```yaml +capture: + enabled: false +``` + +Adjust the stale buffer age (default: 1 hour): + +```yaml +capture: + max_age_seconds: 7200 # finalize buffers >2 hours old +``` + +### Fallback behavior + +If the "window close" event is not yet supported by your version of the +Claude Code extension, the current session will be finalized on the *next* +session start instead. The behavior is the same; proposals just appear in +the next session rather than immediately. + +To upgrade or check your extension version, see [Claude Code releases](https://github.com/anthropics/claude-code-releases). + ## Notes - `VOUCH_AGENT=claude-code` shows up as the actor in `audit.log.jsonl` From b2955f0afc6122c76eced1d489065c755f17b8eb Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:50:14 +0900 Subject: [PATCH 7/8] test(capture): add e2e test for sessionstart cleanup + finalize flow verify that old buffers are cleaned up on new session start and current session is finalized on window close (or manual finalize), resulting in two separate proposals. --- tests/test_capture.py | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_capture.py b/tests/test_capture.py index 9792fc4a..9c4f2a2e 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -599,3 +599,55 @@ def test_finalize_all_except_returns_proposal_ids(tmp_path): from vouch.models import ProposalStatus pending = store.list_proposals(ProposalStatus.PENDING) assert len(pending) > 0 + + +def test_capture_e2e_sessionstart_cleanup_then_finalize(tmp_path): + """End-to-end: old buffers cleaned up on sessionstart, current session on finalize.""" + import os + import time as time_mod + + store = _make_store(tmp_path) + + # Simulate a previous session that crashed/closed without finalize + old_sess = "crashed-session" + old_path = cap.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + observations = [ + '{"ts": 1.0, "tool": "Read", "summary": "test1"}', + '{"ts": 2.0, "tool": "Read", "summary": "test2"}', + '{"ts": 3.0, "tool": "Read", "summary": "test3"}', + ] + old_path.write_text("\n".join(observations) + "\n") + old_mtime = time_mod.time() - 7200 # 2 hours ago + os.utime(old_path, (old_mtime, old_mtime)) + + # Simulate a new session starting + new_sess = "new-session" + + # 1. SessionStart cleanup (finalize old buffers) + cleanup_result = cap.finalize_all_except( + store, new_sess, max_age_seconds=3600.0 + ) + assert old_sess in cleanup_result["finalized"] + assert not old_path.exists() + + # Verify old session was proposed + pending_before = store.list_proposals(ProposalStatus.PENDING) + old_proposals = [p for p in pending_before if p.session_id == old_sess] + assert len(old_proposals) == 1 + + # 2. SessionEnd finalize (current session) + new_path = cap.buffer_path(store, new_sess) + new_path.write_text("\n".join(observations) + "\n") + + finalize_result = cap.finalize(store, new_sess) + assert finalize_result["summary_proposal_id"] is not None + assert not new_path.exists() + + # Verify new session was proposed + pending_after = store.list_proposals(ProposalStatus.PENDING) + new_proposals = [p for p in pending_after if p.session_id == new_sess] + assert len(new_proposals) == 1 + + # Total proposals: old + new + assert len(pending_after) >= 2 From 2a5d2d2402b223adcc7e4a8b05cabf0628b9f74b Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:58:32 +0900 Subject: [PATCH 8/8] fix(capture): skip git context when finalizing buffers with unknown origin when finalize_all_except() cleans up orphaned buffers, those old sessions have unknown origin (no cwd), so we should not attribute the current working directory's git diff to them. this prevents data corruption where old sessions' summaries get current repo changes, and avoids spurious proposals that cross min_observations due to misattributed file counts. added explicit cwd check: if cwd is None (unknown origin), skip git changes. normal finalize calls with explicit cwd still get git context. also fix typo: unfinalzed -> unfinalized in adapter README. --- adapters/claude-code/README.md | 2 +- src/vouch/capture.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 2e43c181..8c3efe05 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -69,7 +69,7 @@ window, vouch proposes the captured knowledge to the KB for review. `.vouch/captures/.jsonl` (gitignored). 2. **Cleanup on session start**: When you start a new session, any - unfinalzed buffers from previous sessions (>1 hour old) are + unfinalized buffers from previous sessions (>1 hour old) are automatically finalized and proposed. 3. **Finalize on window close**: When the VS Code window closes, the diff --git a/src/vouch/capture.py b/src/vouch/capture.py index c43f7939..79f2623c 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -244,14 +244,23 @@ def finalize( generated_at: str | None = None, config: CaptureConfig | None = None, ) -> dict[str, Any]: - """Roll a session buffer into one PENDING summary proposal. No approve().""" + """Roll a session buffer into one PENDING summary proposal. No approve(). + + If cwd is None (e.g., when finalizing orphaned buffers with unknown origin), + git changes are not included. Otherwise, git changes from cwd are included. + """ cfg = config or load_config(store) path = buffer_path(store, session_id) observations = _read_observations(path) if not cfg.enabled: return {"captured": len(observations), "summary_proposal_id": None, "skipped": "disabled"} - changed_files, git_stat = _git_changes(cwd or Path.cwd()) + # Only include git context if cwd is explicitly provided (known origin) + # For cleanup of orphaned buffers, cwd=None, so skip git context + if cwd is not None: + changed_files, git_stat = _git_changes(cwd) + else: + changed_files, git_stat = [], "" total = len(observations) + len(changed_files) if total < cfg.min_observations: if path.exists():