From 47eaf56858565ec2a511b4ebf0c71d82cc6fd11a Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Thu, 30 Jul 2026 08:29:27 -0700 Subject: [PATCH 1/5] feat(capture): make realtime observe opt-in (default off) Fixes #602: capture.realtime defaults false; observe no-ops; Claude adapter drops PostToolUse/Stop; finalize rebuilds observations from transcript so min_observations still works. --- CHANGELOG.md | 7 + README.md | 2 +- adapters/claude-code/.claude/settings.json | 23 --- adapters/claude-code/install.yaml | 5 +- src/vouch/capture.py | 60 +++++++- src/vouch/cli.py | 19 ++- src/vouch/session_split.py | 16 +- src/vouch/storage.py | 4 + tests/test_adopt.py | 4 +- tests/test_capture.py | 164 ++++++++++++++++++--- tests/test_capture_answer.py | 33 +++-- tests/test_install_adapter.py | 12 +- tests/test_session_split.py | 103 ++++++++++--- tests/test_session_transcript.py | 4 +- 14 files changed, 358 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5a72cb..571728a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -226,6 +226,13 @@ All notable changes to vouch are documented here. Format follows markers; absolute bench scores shift, paired comparisons were fair either way. the reference baseline table is refreshed. ### Changed +- **capture.realtime defaults off** (#602): + per-tool `PostToolUse` observe is opt-in. when off (the new default), + `capture observe` no-ops with `{"skipped": "realtime-disabled"}` and + SessionEnd rebuilds tool activity from the Claude transcript so + `min_observations` still works. shipped claude-code hooks drop + PostToolUse/Stop; re-install does not prune old hooks from existing + `settings.json`. set `capture.realtime: true` to restore the buffer. - **core PRs can auto-merge, on two mechanical bars.** the blanket "core is never armed" refusal is gone; both authorization surfaces (the `auto-merge` label and the `/auto-merge` comment) now route through one diff --git a/README.md b/README.md index 6086ef6e..e88e06d2 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ cd /path/to/your/project vouch install-mcp claude-code # creates .vouch/ (if missing) + wires Claude Code ``` -`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and five hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `PostToolUse` capture, `Stop` answer capture, `SessionEnd` rollup. It also registers vouch as a local-scope MCP server in `~/.claude.json` (the `⚑` line in the output). **Reload your editor window** (VS Code: *Developer: Reload Window*) so it loads. +`install-mcp` initialises the KB when no `.vouch/` is discoverable (pass `--no-init` to skip; `vouch init` still exists for KB-only setup), then writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and the weight-bearing hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `SessionEnd` rollup. Per-tool `PostToolUse` observe / `Stop` answer are opt-in (`capture.realtime`; default off). It also registers vouch as a local-scope MCP server in `~/.claude.json` (the `⚑` line in the output). **Reload your editor window** (VS Code: *Developer: Reload Window*) so it loads. > **Why the extra registration?** A committed `.mcp.json` is a *project*-scope server, and Claude Code only loads one after a per-user approval — which the **VS Code extension never prompts for**, so `.mcp.json` alone leaves the `kb_*` tools invisible in the extension (they sit at "pending approval", while the hooks quietly work — easy to misread as "connected"). The local-scope entry `install-mcp` writes is trusted on sight, so a fresh install just connects. Verify with `claude mcp list` (`vouch … ✔ Connected`). Pass `--no-approve` to skip it and approve `.mcp.json` yourself. diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index 39d3815e..f82fd979 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -55,29 +55,6 @@ ] } ], - "PostToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "vouch capture observe || true" - } - ] - } - ], - "Stop": [ - { - "comment": "save this turn's answer as durable, recallable knowledge — receipt-verified claims auto-approve under the starter-config default (review.auto_approve_on_receipt; set false to keep every write behind vouch review); fires every turn but skips short/duplicate answers; never blocks the turn", - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "vouch capture answer || true" - } - ] - } - ], "SessionEnd": [ { "matcher": "*", diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml index 36e84e89..dc34b932 100644 --- a/adapters/claude-code/install.yaml +++ b/adapters/claude-code/install.yaml @@ -7,8 +7,11 @@ # company-brain set: `/vouch-ask`, `/vouch-remember`, `/vouch-record`, # `/vouch-followup`, `/vouch-standup`). # T4 = `.claude/settings.json`: SessionStart (kb status + capture review banner + -# recall digest of approved knowledge), PostToolUse (capture observe), +# recall digest of approved knowledge), UserPromptSubmit (context-hook), # SessionEnd (capture finalize), plus read-only kb_* auto-allow. +# Per-tool PostToolUse observe / Stop answer are opt-in via +# capture.realtime (default off); finalize rebuilds activity from the +# transcript. # # user_mcp = a local-scope MCP registration written to the user's # `~/.claude.json` under `projects[].mcpServers`. The `.mcp.json` diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 5e7e425c..861b5bff 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -38,6 +38,7 @@ from .storage import KBStore DEFAULT_ENABLED = True +DEFAULT_REALTIME = False DEFAULT_MIN_OBSERVATIONS = 3 DEFAULT_DEDUP_WINDOW_SECONDS = 60.0 # "session": claims are extracted once at SessionEnd from the full transcript. @@ -51,6 +52,7 @@ @dataclass(frozen=True) class CaptureConfig: enabled: bool = DEFAULT_ENABLED + realtime: bool = DEFAULT_REALTIME min_observations: int = DEFAULT_MIN_OBSERVATIONS dedup_window_seconds: float = DEFAULT_DEDUP_WINDOW_SECONDS answer_mode: str = DEFAULT_ANSWER_MODE @@ -72,6 +74,7 @@ def load_config(store: KBStore) -> CaptureConfig: answer_mode = DEFAULT_ANSWER_MODE return CaptureConfig( enabled=coerce_bool(raw.get("enabled", DEFAULT_ENABLED), DEFAULT_ENABLED), + realtime=coerce_bool(raw.get("realtime", DEFAULT_REALTIME), DEFAULT_REALTIME), min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)), dedup_window_seconds=float( raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS) @@ -132,6 +135,10 @@ def observe( cfg = config or load_config(store) if not cfg.enabled: return False + # Opt-in: per-tool PostToolUse harvest is off by default (#602). Finalize + # rebuilds the same observation shape from the transcript instead. + if not cfg.realtime: + return False # Mask credentials before anything is persisted: the buffer rolls into a # committed session page and the append-only audit log, so a secret that # reaches it is permanent. Masked first, so dedup compares masked text too. @@ -208,6 +215,57 @@ def summarize_tool( return out +def observations_from_transcript(transcript_path: Path) -> list[dict[str, Any]]: + """Rebuild PostToolUse-shaped observations from a Claude Code transcript. + + Used when ``capture.realtime`` is off so SessionEnd finalize can still feed + ``session_split.summarize``'s ``min_observations`` gate without the + per-tool buffer. + """ + try: + from .transcript import parse_claude_transcript + + parsed = parse_claude_transcript(transcript_path) + except (OSError, UnicodeDecodeError, ValueError, TypeError, KeyError): + return [] + out: list[dict[str, Any]] = [] + for msg in parsed.get("messages") or []: + if not isinstance(msg, dict): + continue + for block in msg.get("blocks") or []: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + name = block.get("name") + tip = block.get("input") if isinstance(block.get("input"), dict) else {} + result = block.get("result") + response: object = "" + if isinstance(result, dict): + response = result.get("content") or "" + if result.get("is_error"): + response = f"error: {response}" + obs = summarize_tool( + str(name) if name is not None else None, + tip, + response, + ) + if obs is None: + continue + record: dict[str, Any] = { + "ts": 0.0, + "tool": obs["tool"], + "summary": mask_secrets(str(obs["summary"])), + } + tid = block.get("id") + if isinstance(tid, str) and tid: + record["tool_use_id"] = tid + if obs.get("files"): + record["files"] = list(obs["files"]) + if obs.get("cmd"): + record["cmd"] = mask_secrets(str(obs["cmd"])) + out.append(record) + return out + + def _git_changes(cwd: Path) -> tuple[list[str], str]: """Return (changed_files, diff_stat). Empty on any failure / non-repo.""" try: @@ -581,7 +639,7 @@ def finalize( result = session_split.summarize( store, session_id, intent=intent, cwd=cwd, project=project, generated_at=generated_at, mode=mode, config=cfg, origin=origin, - sources=sources, + sources=sources, transcript_path=transcript_path, ) if answers is not None: result["answers"] = answers diff --git a/src/vouch/cli.py b/src/vouch/cli.py index dfcaeb0f..6131d092 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2949,6 +2949,18 @@ def capture_observe_cmd() -> None: session_id = str(payload.get("session_id") or "") if not session_id: return + start, ok = _hook_start(payload) + if not ok: + return + store = _capture_store(start) + if store is None: + return + cfg = capture_mod.load_config(store) + if not cfg.realtime: + _emit_json({"skipped": "realtime-disabled"}) + return + if not cfg.enabled: + return tool_input = payload.get("tool_input") obs = capture_mod.summarize_tool( payload.get("tool_name"), @@ -2957,18 +2969,13 @@ def capture_observe_cmd() -> None: ) if obs is None: return - start, ok = _hook_start(payload) - if not ok: - return - store = _capture_store(start) - if store is None: - return tool_use_id = payload.get("tool_use_id") capture_mod.observe( store, session_id, tool=obs["tool"], summary=obs["summary"], files=obs.get("files"), cmd=obs.get("cmd"), tool_use_id=str(tool_use_id) if tool_use_id else None, + config=cfg, ) except Exception: # a capture failure must never break the user's tool call. diff --git a/src/vouch/session_split.py b/src/vouch/session_split.py index f8b042fa..9e566443 100644 --- a/src/vouch/session_split.py +++ b/src/vouch/session_split.py @@ -99,6 +99,7 @@ def summarize( config: capture.CaptureConfig | None = None, origin: Path | None = None, sources: list[str] | None = None, + transcript_path: Path | None = None, ) -> dict[str, Any]: """Roll a session buffer into PENDING page proposals. Never approves. @@ -115,10 +116,23 @@ def summarize( `sources` are source ids the mechanical page cites (the session-answers source `capture.finalize` registers). A cited session page clears the admission gate's uncited-diary rule on its own merits. + + When ``capture.realtime`` is off, observations come from the transcript + (if provided) instead of the per-tool buffer (#602). """ cfg = config or capture.load_config(store) path = capture.buffer_path(store, session_id) - observations = capture._read_observations(path) + buffered = capture._read_observations(path) + if cfg.realtime: + observations = buffered + elif transcript_path is not None: + observations = capture.observations_from_transcript(transcript_path) + # empty transcript still allows draining a leftover realtime buffer + if not observations: + observations = buffered + else: + # finalize-all / orphan sweep: no transcript, drain the buffer + observations = buffered if not cfg.enabled: return {"captured": len(observations), "summary_proposal_id": None, "summary_proposal_ids": [], "mode": "skipped", "skipped": "disabled", diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 6bc9fce0..79c5b4fb 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -96,6 +96,10 @@ def _starter_config() -> dict[str, Any]: "capture": { # auto-capture agent sessions into pending summaries. "enabled": True, + # per-tool PostToolUse buffer; off by default — SessionEnd rebuilds + # observations from the transcript (#602). set true to restore the + # crash-resistant realtime harvest (and wire PostToolUse yourself). + "realtime": False, "min_observations": 3, # answer memory: "session" extracts claims once at SessionEnd from # the full transcript; "turn" files claims on every Stop hook. diff --git a/tests/test_adopt.py b/tests/test_adopt.py index 9a24df0d..8a03daa9 100644 --- a/tests/test_adopt.py +++ b/tests/test_adopt.py @@ -418,11 +418,13 @@ def test_fallback_session_summary_records_its_origin( being left silently behind.""" from vouch import capture as cap + _RT_CFG = cap.CaptureConfig(realtime=True) + origin = tmp_path / "projA" origin.mkdir() for i in range(3): cap.observe(personal, "sum-1", tool="Edit", summary=f"edited f{i}.py", - now=float(i)) + now=float(i), config=_RT_CFG) result = cap.finalize(personal, "sum-1", cwd=origin, project=origin.name, origin=origin) # finalize still returns the id even though the uncited session rollup is diff --git a/tests/test_capture.py b/tests/test_capture.py index 45191a01..dc95ba5e 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -9,6 +9,8 @@ from vouch import capture as cap from vouch.storage import KBStore, _starter_config +_RT_CFG = cap.CaptureConfig(realtime=True) + @pytest.fixture def store(tmp_path: Path) -> KBStore: @@ -18,24 +20,28 @@ def store(tmp_path: Path) -> KBStore: def test_load_config_defaults(store: KBStore) -> None: cfg = cap.load_config(store) assert cfg.enabled is True + assert cfg.realtime is False assert cfg.min_observations == 3 assert cfg.dedup_window_seconds == 60.0 def test_load_config_reads_override(store: KBStore) -> None: store.config_path.write_text( - "capture:\n enabled: false\n min_observations: 5\n" + "capture:\n enabled: false\n realtime: true\n min_observations: 5\n" ) cfg = cap.load_config(store) assert cfg.enabled is False + assert cfg.realtime is True assert cfg.min_observations == 5 def test_load_config_quoted_false_does_not_enable(store: KBStore) -> None: """Regression: bool("false") is True in plain Python, so a mistakenly quoted `enabled: "false"` previously silently kept capture enabled.""" - store.config_path.write_text('capture:\n enabled: "false"\n') - assert cap.load_config(store).enabled is False + store.config_path.write_text('capture:\n enabled: "false"\n realtime: "false"\n') + cfg = cap.load_config(store) + assert cfg.enabled is False + assert cfg.realtime is False def test_buffer_path_under_captures_dir(store: KBStore) -> None: @@ -44,7 +50,9 @@ def test_buffer_path_under_captures_dir(store: KBStore) -> None: def test_starter_config_has_capture_namespace() -> None: - assert _starter_config()["capture"]["enabled"] is True + cap_cfg = _starter_config()["capture"] + assert cap_cfg["enabled"] is True + assert cap_cfg["realtime"] is False def test_finalize_all_drains_receipt_backlog(store: KBStore) -> None: @@ -70,7 +78,7 @@ def test_init_gitignores_captures(tmp_path: Path) -> None: def test_observe_appends_line(store: KBStore) -> None: - wrote = cap.observe(store, "s1", tool="Edit", summary="Edited a.py", now=100.0) + wrote = cap.observe(store, "s1", tool="Edit", summary="Edited a.py", now=100.0, config=_RT_CFG) assert wrote is True lines = cap.buffer_path(store, "s1").read_text().splitlines() assert len(lines) == 1 @@ -84,8 +92,7 @@ def test_observe_masks_secrets_before_buffering(store: KBStore) -> None: store, "s1", tool="Bash", summary="Ran: export AWS_KEY=AKIAIOSFODNN7EXAMPLE", cmd="export AWS_KEY=AKIAIOSFODNN7EXAMPLE", - now=100.0, - ) + now=100.0, config=_RT_CFG) assert wrote is True obs = cap._read_observations(cap.buffer_path(store, "s1")) assert len(obs) == 1 @@ -94,11 +101,11 @@ def test_observe_masks_secrets_before_buffering(store: KBStore) -> None: def test_observe_dedups_within_window(store: KBStore) -> None: - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0) + assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0, config=_RT_CFG) # identical within 60s window -> skipped - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=130.0) is False + assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=130.0, config=_RT_CFG) is False # same key past the window -> written again - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=200.0) + assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=200.0, config=_RT_CFG) assert len(cap.buffer_path(store, "s1").read_text().splitlines()) == 2 @@ -108,30 +115,32 @@ def test_observe_dedups_on_tool_use_id(store: KBStore) -> None: recorded once — exact and window-free, however the summaries drift.""" assert cap.observe( store, "s1", tool="Read", summary="Read a.py", - now=100.0, tool_use_id="toolu_abc", - ) + now=100.0, tool_use_id="toolu_abc", config=_RT_CFG) # same event id, different summary, far outside the text-dedup window assert cap.observe( store, "s1", tool="Read", summary="Read a.py (drifted wording)", - now=999.0, tool_use_id="toolu_abc", - ) is False + now=999.0, tool_use_id="toolu_abc", config=_RT_CFG) is False # a different event id still records assert cap.observe( store, "s1", tool="Read", summary="Read b.py", - now=999.0, tool_use_id="toolu_def", - ) + now=999.0, tool_use_id="toolu_def", config=_RT_CFG) lines = cap.buffer_path(store, "s1").read_text().splitlines() assert len(lines) == 2 def test_observe_without_tool_use_id_keeps_window_dedup(store: KBStore) -> None: """Hosts that don't send an event id keep the legacy text+window dedup.""" - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0) - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=130.0) is False + assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0, config=_RT_CFG) + assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=130.0, config=_RT_CFG) is False def test_observe_noop_when_disabled(store: KBStore) -> None: - store.config_path.write_text("capture:\n enabled: false\n") + store.config_path.write_text("capture:\n enabled: false\n realtime: true\n") + assert cap.observe(store, "s1", tool="Edit", summary="x") is False + assert not cap.buffer_path(store, "s1").exists() + + +def test_observe_noop_when_realtime_disabled(store: KBStore) -> None: assert cap.observe(store, "s1", tool="Edit", summary="x") is False assert not cap.buffer_path(store, "s1").exists() @@ -150,6 +159,7 @@ def test_observe_cli_routes_by_payload_cwd( elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() monkeypatch.chdir(elsewhere) + store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") payload = { "session_id": "s-routed", "cwd": str(store.root), @@ -213,7 +223,7 @@ def test_summarize_tool_read_grep_web_task() -> None: def test_observe_stores_cmd_field(store: KBStore) -> None: - cap.observe(store, "s1", tool="Bash", summary="Ran: ls", cmd="ls -la", now=1.0) + cap.observe(store, "s1", tool="Bash", summary="Ran: ls", cmd="ls -la", now=1.0, config=_RT_CFG) line = cap.buffer_path(store, "s1").read_text() assert "ls -la" in line @@ -303,8 +313,9 @@ def test_build_summary_body_renders_git_and_commands() -> None: def _seed(store: KBStore, sid: str, n: int) -> None: + store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") for i in range(n): - cap.observe(store, sid, tool="Edit", summary=f"Edited f{i}.py", now=float(i)) + cap.observe(store, sid, tool="Edit", summary=f"Edited f{i}.py", now=float(i), config=_RT_CFG) def test_finalize_files_one_auto_rejected_page(store: KBStore, tmp_path: Path) -> None: @@ -476,6 +487,7 @@ def _run(store: KBStore, args: list[str], stdin: str = "") -> object: def test_cli_observe_appends(store: KBStore) -> None: + store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") payload = _json.dumps({ "session_id": "cc-1", "tool_name": "Edit", @@ -493,8 +505,9 @@ def test_cli_observe_never_errors_on_garbage(store: KBStore) -> None: def test_cli_finalize_files_proposal(store: KBStore) -> None: + store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") for i in range(3): - cap.observe(store, "cc-2", tool="Edit", summary=f"Edited f{i}.py", now=float(i)) + cap.observe(store, "cc-2", tool="Edit", summary=f"Edited f{i}.py", now=float(i), config=_RT_CFG) payload = _json.dumps({"session_id": "cc-2", "cwd": str(store.kb_dir.parent)}) res = _run(store, ["capture", "finalize"], stdin=payload) assert res.exit_code == 0 @@ -509,8 +522,9 @@ def test_cli_finalize_files_proposal(store: KBStore) -> None: def test_cli_banner_silent_after_capture_auto_rejected(store: KBStore) -> None: + store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") for i in range(3): - cap.observe(store, "cc-3", tool="Edit", summary=f"Edited f{i}.py", now=float(i)) + cap.observe(store, "cc-3", tool="Edit", summary=f"Edited f{i}.py", now=float(i), config=_RT_CFG) cap.finalize(store, "cc-3", cwd=store.kb_dir.parent) # the session page was auto-rejected by admission, so nothing awaits review: # the SessionStart banner stays silent rather than announcing a pending page. @@ -527,6 +541,8 @@ def test_cli_banner_silent_when_none(store: KBStore) -> None: def test_adapter_settings_wires_capture_hooks() -> None: + import json as _json + root = Path(__file__).resolve().parents[1] settings = _json.loads( (root / "adapters/claude-code/.claude/settings.json").read_text() @@ -540,10 +556,102 @@ def commands(event: str) -> list[str]: out.append(h.get("command", "")) return out - assert any("capture observe" in c for c in commands("PostToolUse")) + # realtime harvest is opt-in (#602); shipped template keeps weight-bearing hooks + assert "PostToolUse" not in hooks + assert "Stop" not in hooks 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")) + assert any("context-hook" in c for c in commands("UserPromptSubmit")) + + +def test_observations_from_transcript_rebuilds_tool_activity(tmp_path: Path) -> None: + import json as _json + + transcript = tmp_path / "session.jsonl" + rows = [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "Edit", + "input": {"file_path": "/proj/a.py"}, + }, + { + "type": "tool_use", + "id": "toolu_2", + "name": "Bash", + "input": {"command": "pytest -q"}, + }, + { + "type": "tool_use", + "id": "toolu_3", + "name": "Read", + "input": {"file_path": "/proj/b.py"}, + }, + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}, + {"type": "tool_result", "tool_use_id": "toolu_2", "content": "passed"}, + {"type": "tool_result", "tool_use_id": "toolu_3", "content": "body"}, + ] + }, + }, + ] + transcript.write_text( + "\n".join(_json.dumps(r) for r in rows) + "\n", encoding="utf-8", + ) + obs = cap.observations_from_transcript(transcript) + assert len(obs) == 3 + assert {o["tool"] for o in obs} == {"Edit", "Bash", "Read"} + assert any("Edited a.py" in o["summary"] for o in obs) + assert any(o.get("cmd") == "pytest -q" for o in obs) + + +def test_finalize_uses_transcript_when_realtime_off( + store: KBStore, tmp_path: Path, +) -> None: + import json as _json + + transcript = tmp_path / "sess.jsonl" + tools = [ + ("toolu_a", "Edit", {"file_path": "/p/a.py"}), + ("toolu_b", "Edit", {"file_path": "/p/b.py"}), + ("toolu_c", "Edit", {"file_path": "/p/c.py"}), + ] + rows = [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "tool_use", "id": tid, "name": name, "input": tip} + for tid, name, tip in tools + ] + }, + }, + { + "type": "user", + "message": { + "content": [ + {"type": "tool_result", "tool_use_id": tid, "content": "ok"} + for tid, _, _ in tools + ] + }, + }, + ] + transcript.write_text( + "\n".join(_json.dumps(r) for r in rows) + "\n", encoding="utf-8", + ) + result = cap.finalize(store, "sess-tx", transcript_path=transcript, mode="mechanical") + assert result.get("summary_proposal_id") or result.get("summarized") def test_capture_finalize_all_cmd_with_old_buffers(tmp_path: Path, monkeypatch) -> None: @@ -1043,6 +1151,14 @@ def test_observe_cli_buffers_in_personal_kb_on_fallback( _fallback_machine: KBStore, tmp_path: Path, monkeypatch ) -> None: personal = _fallback_machine + # Keep personal.fallback_capture (set by the fixture) while opting into + # realtime observe — a full rewrite would wipe the fallback flag. + import yaml as _yaml + + cfg = _yaml.safe_load(personal.config_path.read_text(encoding="utf-8")) or {} + cfg.setdefault("capture", {})["enabled"] = True + cfg.setdefault("capture", {})["realtime"] = True + personal.config_path.write_text(_yaml.safe_dump(cfg), encoding="utf-8") nowhere = tmp_path / "no-kb-project" nowhere.mkdir() monkeypatch.chdir(nowhere) diff --git a/tests/test_capture_answer.py b/tests/test_capture_answer.py index 7387c216..4add195e 100644 --- a/tests/test_capture_answer.py +++ b/tests/test_capture_answer.py @@ -21,6 +21,8 @@ import pytest from vouch import capture as cap + +_RT_CFG = cap.CaptureConfig(realtime=True) from vouch.models import ProposalStatus from vouch.storage import KBStore @@ -387,7 +389,7 @@ def test_finalize_page_cites_session_source(store: KBStore, tmp_path: Path) -> N """The rollup page cites the answers source, so it clears admission.""" tp = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)]) for i in range(3): - cap.observe(store, "s1", tool="Edit", summary=f"Edit f{i}.py", now=float(i)) + cap.observe(store, "s1", tool="Edit", summary=f"Edit f{i}.py", now=float(i), config=_RT_CFG) res = cap.finalize(store, "s1", cwd=None, transcript_path=tp) assert res["answers"]["captured"] is True src_id = res["answers"]["source"] @@ -401,7 +403,7 @@ def test_finalize_recites_source_on_refinalize(store: KBStore, tmp_path: Path) - tp = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)]) cap.capture_session_answers(store, "s1", tp) for i in range(3): - cap.observe(store, "s1", tool="Edit", summary=f"Edit f{i}.py", now=float(i)) + cap.observe(store, "s1", tool="Edit", summary=f"Edit f{i}.py", now=float(i), config=_RT_CFG) res = cap.finalize(store, "s1", cwd=None, transcript_path=tp) assert res["answers"]["skipped"] == "already-captured" prop = store.get_proposal(res["summary_proposal_id"]) @@ -430,20 +432,31 @@ def test_finalize_recites_source_on_refinalize(store: KBStore, tmp_path: Path) - def _enrich_stub(tmp_path: Path, output: str) -> str: - script = tmp_path / "enrich.sh" - script.write_text( - f"#!/bin/sh\ncat > /dev/null\ncat <<'JSON'\n{output}\nJSON\n", - encoding="utf-8", + import sys + + out = tmp_path / "enrich-out.json" + out.write_text(output, encoding="utf-8") + return ( + f'{sys.executable} -c "import pathlib,sys; ' + f'sys.stdin.read(); ' + f'sys.stdout.write(pathlib.Path(r\'{out}\').read_text(encoding=\'utf-8\'))"' ) - return f"sh {script}" def test_finalize_supersedes_updated_claims(store: KBStore, tmp_path: Path) -> None: from vouch.models import ClaimStatus + import yaml store.config_path.write_text( - "review:\n auto_approve_on_receipt: true\n" - f'capture:\n enrich:\n llm_cmd: "{_enrich_stub(tmp_path, UPDATE_JSON)}"\n', + yaml.safe_dump( + { + "review": {"auto_approve_on_receipt": True}, + "capture": { + "realtime": True, + "enrich": {"llm_cmd": _enrich_stub(tmp_path, UPDATE_JSON)}, + }, + } + ), encoding="utf-8", ) # session 1 states the old value; its claims become durable via receipts @@ -459,7 +472,7 @@ def test_finalize_supersedes_updated_claims(store: KBStore, tmp_path: Path) -> N d2 = tmp_path / "s2" d2.mkdir() for i in range(3): - cap.observe(store, "s2", tool="Edit", summary=f"Edit f{i}.py", now=float(i)) + cap.observe(store, "s2", tool="Edit", summary=f"Edit f{i}.py", now=float(i), config=_RT_CFG) res = cap.finalize( store, "s2", cwd=None, transcript_path=_transcript(d2, [_user("region?"), _assistant(NEW_ANSWER)]), diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index f45f1d81..6310af9f 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -166,9 +166,8 @@ def test_settings_json_merges_into_existing(tmp_path: Path) -> None: # vouch content merged in assert "mcp__vouch__kb_status" in merged["permissions"]["allow"] assert any("capture banner" in c for c in start_cmds) - post = [h["command"] for g in merged["hooks"].get("PostToolUse", []) for h in g["hooks"]] end = [h["command"] for g in merged["hooks"].get("SessionEnd", []) for h in g["hooks"]] - assert any("capture observe" in c for c in post) + assert "PostToolUse" not in merged["hooks"] assert any("capture finalize" in c for c in end) assert ".claude/settings.json" in result.merged @@ -189,13 +188,14 @@ def test_settings_json_merge_is_idempotent(tmp_path: Path) -> None: assert ".claude/settings.json" not in second.merged data = json.loads(after) - observe_cmds = [ + assert "PostToolUse" not in data.get("hooks", {}) + end_cmds = [ h["command"] - for g in data["hooks"]["PostToolUse"] + for g in data["hooks"].get("SessionEnd", []) for h in g["hooks"] - if "capture observe" in h["command"] + if "capture finalize" in h["command"] ] - assert len(observe_cmds) == 1 # not duplicated + assert len(end_cmds) == 1 # not duplicated def test_settings_json_written_fresh_when_absent(tmp_path: Path) -> None: diff --git a/tests/test_session_split.py b/tests/test_session_split.py index b6ddfa27..3e18d411 100644 --- a/tests/test_session_split.py +++ b/tests/test_session_split.py @@ -58,15 +58,27 @@ def test_split_config_quoted_false_enabled_does_not_enable(store: KBStore) -> No assert load_split_config(store).enabled is False +_RT_CFG = None # set lazily so import stays light + + +def _rt_cfg(): + from vouch import capture + global _RT_CFG + if _RT_CFG is None: + _RT_CFG = capture.CaptureConfig(realtime=True) + return _RT_CFG + + def _observe(store: KBStore, sid: str, n: int, tool: str = "Edit") -> None: from vouch import capture + cfg = _rt_cfg() for i in range(n): - capture.observe(store, sid, tool=tool, summary=f"{tool} file{i}.py", now=float(i)) + capture.observe(store, sid, tool=tool, summary=f"{tool} file{i}.py", now=float(i), config=cfg) def test_below_min_skips_and_deletes_buffer(store: KBStore) -> None: from vouch import capture - capture.observe(store, "s1", tool="Edit", summary="one", now=1.0) + capture.observe(store, "s1", tool="Edit", summary="one", now=1.0, config=_rt_cfg()) res = session_split.summarize(store, "s1") assert res["skipped"] == "below-min" assert res["summary_proposal_ids"] == [] @@ -106,19 +118,38 @@ def test_finalize_still_returns_summary_proposal_id(store: KBStore) -> None: def _stub_llm(tmp_path: Path, drafts: list[dict]) -> str: + """Return an llm_cmd that ignores stdin and emits canned drafts. + + Uses a Python one-liner so the stub works on Windows (no ``cat``) and + embeds a path that YAML can quote safely via :func:`_config_with_split`. + """ + import sys + out = tmp_path / "drafts.json" out.write_text(json.dumps(drafts), encoding="utf-8") - return f"cat {out}" + return ( + f'{sys.executable} -c "import pathlib,sys; ' + f'sys.stdout.write(pathlib.Path(r\'{out}\').read_text(encoding=\'utf-8\'))"' + ) def _config_with_split( store: KBStore, llm_cmd: str, threshold: int = 3, max_pages: int = 6 ) -> None: + import yaml + store.config_path.write_text( - "capture:\n split:\n" - f" threshold_observations: {threshold}\n" - f" max_pages: {max_pages}\n" - f" llm_cmd: \"{llm_cmd}\"\n", + yaml.safe_dump( + { + "capture": { + "split": { + "threshold_observations": threshold, + "max_pages": max_pages, + "llm_cmd": llm_cmd, + } + } + } + ), encoding="utf-8", ) @@ -202,8 +233,8 @@ def test_cap_enforced(store: KBStore, tmp_path: Path) -> None: def test_host_neutral_tool_names_do_not_crash(store: KBStore, tmp_path: Path) -> None: from vouch import capture for i, tool in enumerate(["fs.write", "shell.exec", "browser.open"]): - capture.observe(store, "s1", tool=tool, summary=f"{tool} did thing {i}", now=float(i)) - capture.observe(store, "s1", tool="fs.write", summary="one more", now=9.0) + capture.observe(store, "s1", tool=tool, summary=f"{tool} did thing {i}", now=float(i), config=_rt_cfg()) + capture.observe(store, "s1", tool="fs.write", summary="one more", now=9.0, config=_rt_cfg()) cmd = _stub_llm(tmp_path, [{"title": "the work", "body": "did things " * 15}]) _config_with_split(store, cmd, threshold=3) res = session_split.summarize(store, "s1", mode="auto") @@ -212,14 +243,23 @@ def test_host_neutral_tool_names_do_not_crash(store: KBStore, tmp_path: Path) -> def test_truncation_flagged_when_over_budget(store: KBStore, tmp_path: Path) -> None: from vouch import capture + import yaml # distinct summaries so capture.observe's dedup window does not collapse them for i in range(50): - capture.observe(store, "s1", tool="Edit", summary=f"edit {i} " + "x" * 200, now=float(i)) + capture.observe(store, "s1", tool="Edit", summary=f"edit {i} " + "x" * 200, now=float(i), config=_rt_cfg()) cmd = _stub_llm(tmp_path, [{"title": "t", "body": "b " * 20}]) store.config_path.write_text( - "capture:\n split:\n threshold_observations: 3\n" - " max_input_chars: 500\n" - f" llm_cmd: \"{cmd}\"\n", + yaml.safe_dump( + { + "capture": { + "split": { + "threshold_observations": 3, + "max_input_chars": 500, + "llm_cmd": cmd, + } + } + } + ), encoding="utf-8", ) res = session_split.summarize(store, "s1", mode="auto") @@ -306,7 +346,7 @@ def test_summarize_returns_webapp_keys_on_split(store: KBStore, tmp_path: Path) def test_summarize_webapp_keys_on_skip(store: KBStore) -> None: from vouch import capture - capture.observe(store, "s1", tool="Edit", summary="one", now=1.0) + capture.observe(store, "s1", tool="Edit", summary="one", now=1.0, config=_rt_cfg()) res = session_split.summarize(store, "s1") assert res["summarized"] is False assert res["session_id"] == "s1" @@ -388,19 +428,26 @@ def test_kb_list_sessions_registered_and_returns_sessions( def _enrich_stub(tmp_path: Path, output: str = ENRICH_JSON) -> str: - script = tmp_path / "enrich-llm.sh" - script.write_text( - f"#!/bin/sh\ncat > /dev/null\ncat <<'JSON'\n{output}\nJSON\n", - encoding="utf-8", + """Python stub so enrich tests run without ``sh``/heredoc (Windows).""" + import sys + + out = tmp_path / "enrich-out.json" + out.write_text(output, encoding="utf-8") + return ( + f'{sys.executable} -c "import pathlib,sys; ' + f'sys.stdin.read(); ' + f'sys.stdout.write(pathlib.Path(r\'{out}\').read_text(encoding=\'utf-8\'))"' ) - return f"sh {script}" def test_mechanical_page_enriched(store: KBStore, tmp_path: Path) -> None: from vouch.models import ProposalStatus + import yaml store.config_path.write_text( - f'capture:\n enrich:\n llm_cmd: "{_enrich_stub(tmp_path)}"\n', + yaml.safe_dump( + {"capture": {"enrich": {"llm_cmd": _enrich_stub(tmp_path)}}} + ), encoding="utf-8", ) _observe(store, "s1", 5) @@ -439,10 +486,20 @@ def test_enrichment_failure_files_plain_page(store: KBStore, tmp_path: Path) -> def test_split_failure_fallback_skips_enrichment(store: KBStore, tmp_path: Path) -> None: # split forced on and broken; a working enrich cmd must NOT be attempted # on the fallback path (the LLM already failed once this run). + import yaml + store.config_path.write_text( - "capture:\n" - ' split:\n threshold_observations: 3\n llm_cmd: "false"\n' - f' enrich:\n llm_cmd: "{_enrich_stub(tmp_path)}"\n', + yaml.safe_dump( + { + "capture": { + "split": { + "threshold_observations": 3, + "llm_cmd": "false", + }, + "enrich": {"llm_cmd": _enrich_stub(tmp_path)}, + } + } + ), encoding="utf-8", ) _observe(store, "s1", 5) diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 3aa673b9..5e70d959 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -10,6 +10,8 @@ from vouch import capture, transcript from vouch.storage import KBStore +_RT_CFG = capture.CaptureConfig(realtime=True) + def _write_jsonl(path: Path, records: list[dict]) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -144,7 +146,7 @@ def test_load_transcript_degrades_to_observations( ) -> None: monkeypatch.setenv("VOUCH_CLAUDE_PROJECTS_DIR", str(store.kb_dir / "none")) sid = "99999999-9999-9999-9999-999999999999" - capture.observe(store, sid, tool="Edit", summary="Edited x.go") + capture.observe(store, sid, tool="Edit", summary="Edited x.go", config=_RT_CFG) out = transcript.load_transcript(store, sid) assert out["available"] is False assert out["observations"][0]["tool"] == "Edit" From 901cec269f7cd95f6036ecb9645591189ad9af9a Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Thu, 30 Jul 2026 08:56:15 -0700 Subject: [PATCH 2/5] style(test): wrap long observe lines for ruff E501/E402 CI lint failed on #645 after the realtime opt-in test edits. --- tests/test_capture.py | 31 ++++++++++++++++++++++++------- tests/test_capture_answer.py | 6 +++--- tests/test_session_split.py | 22 +++++++++++++++++----- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/tests/test_capture.py b/tests/test_capture.py index dc95ba5e..9cb8ac0d 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -103,9 +103,13 @@ def test_observe_masks_secrets_before_buffering(store: KBStore) -> None: def test_observe_dedups_within_window(store: KBStore) -> None: assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0, config=_RT_CFG) # identical within 60s window -> skipped - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=130.0, config=_RT_CFG) is False + assert cap.observe( + store, "s1", tool="Read", summary="Read a.py", now=130.0, config=_RT_CFG, + ) is False # same key past the window -> written again - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=200.0, config=_RT_CFG) + assert cap.observe( + store, "s1", tool="Read", summary="Read a.py", now=200.0, config=_RT_CFG, + ) assert len(cap.buffer_path(store, "s1").read_text().splitlines()) == 2 @@ -130,8 +134,12 @@ def test_observe_dedups_on_tool_use_id(store: KBStore) -> None: def test_observe_without_tool_use_id_keeps_window_dedup(store: KBStore) -> None: """Hosts that don't send an event id keep the legacy text+window dedup.""" - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=100.0, config=_RT_CFG) - assert cap.observe(store, "s1", tool="Read", summary="Read a.py", now=130.0, config=_RT_CFG) is False + assert cap.observe( + store, "s1", tool="Read", summary="Read a.py", now=100.0, config=_RT_CFG, + ) + assert cap.observe( + store, "s1", tool="Read", summary="Read a.py", now=130.0, config=_RT_CFG, + ) is False def test_observe_noop_when_disabled(store: KBStore) -> None: @@ -315,7 +323,10 @@ def test_build_summary_body_renders_git_and_commands() -> None: def _seed(store: KBStore, sid: str, n: int) -> None: store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") for i in range(n): - cap.observe(store, sid, tool="Edit", summary=f"Edited f{i}.py", now=float(i), config=_RT_CFG) + cap.observe( + store, sid, tool="Edit", summary=f"Edited f{i}.py", + now=float(i), config=_RT_CFG, + ) def test_finalize_files_one_auto_rejected_page(store: KBStore, tmp_path: Path) -> None: @@ -507,7 +518,10 @@ def test_cli_observe_never_errors_on_garbage(store: KBStore) -> None: def test_cli_finalize_files_proposal(store: KBStore) -> None: store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") for i in range(3): - cap.observe(store, "cc-2", tool="Edit", summary=f"Edited f{i}.py", now=float(i), config=_RT_CFG) + cap.observe( + store, "cc-2", tool="Edit", summary=f"Edited f{i}.py", + now=float(i), config=_RT_CFG, + ) payload = _json.dumps({"session_id": "cc-2", "cwd": str(store.kb_dir.parent)}) res = _run(store, ["capture", "finalize"], stdin=payload) assert res.exit_code == 0 @@ -524,7 +538,10 @@ def test_cli_finalize_files_proposal(store: KBStore) -> None: def test_cli_banner_silent_after_capture_auto_rejected(store: KBStore) -> None: store.config_path.write_text("capture:\n enabled: true\n realtime: true\n") for i in range(3): - cap.observe(store, "cc-3", tool="Edit", summary=f"Edited f{i}.py", now=float(i), config=_RT_CFG) + cap.observe( + store, "cc-3", tool="Edit", summary=f"Edited f{i}.py", + now=float(i), config=_RT_CFG, + ) cap.finalize(store, "cc-3", cwd=store.kb_dir.parent) # the session page was auto-rejected by admission, so nothing awaits review: # the SessionStart banner stays silent rather than announcing a pending page. diff --git a/tests/test_capture_answer.py b/tests/test_capture_answer.py index 4add195e..9e251bf7 100644 --- a/tests/test_capture_answer.py +++ b/tests/test_capture_answer.py @@ -21,11 +21,10 @@ import pytest from vouch import capture as cap - -_RT_CFG = cap.CaptureConfig(realtime=True) from vouch.models import ProposalStatus from vouch.storage import KBStore +_RT_CFG = cap.CaptureConfig(realtime=True) # an answer with three clean, quotable sentences (>160 chars) so segment_source # yields receipt-verifiable claims. ANSWER = ( @@ -444,9 +443,10 @@ def _enrich_stub(tmp_path: Path, output: str) -> str: def test_finalize_supersedes_updated_claims(store: KBStore, tmp_path: Path) -> None: - from vouch.models import ClaimStatus import yaml + from vouch.models import ClaimStatus + store.config_path.write_text( yaml.safe_dump( { diff --git a/tests/test_session_split.py b/tests/test_session_split.py index 3e18d411..fb4ba552 100644 --- a/tests/test_session_split.py +++ b/tests/test_session_split.py @@ -73,7 +73,10 @@ def _observe(store: KBStore, sid: str, n: int, tool: str = "Edit") -> None: from vouch import capture cfg = _rt_cfg() for i in range(n): - capture.observe(store, sid, tool=tool, summary=f"{tool} file{i}.py", now=float(i), config=cfg) + capture.observe( + store, sid, tool=tool, summary=f"{tool} file{i}.py", + now=float(i), config=cfg, + ) def test_below_min_skips_and_deletes_buffer(store: KBStore) -> None: @@ -233,7 +236,10 @@ def test_cap_enforced(store: KBStore, tmp_path: Path) -> None: def test_host_neutral_tool_names_do_not_crash(store: KBStore, tmp_path: Path) -> None: from vouch import capture for i, tool in enumerate(["fs.write", "shell.exec", "browser.open"]): - capture.observe(store, "s1", tool=tool, summary=f"{tool} did thing {i}", now=float(i), config=_rt_cfg()) + capture.observe( + store, "s1", tool=tool, summary=f"{tool} did thing {i}", + now=float(i), config=_rt_cfg(), + ) capture.observe(store, "s1", tool="fs.write", summary="one more", now=9.0, config=_rt_cfg()) cmd = _stub_llm(tmp_path, [{"title": "the work", "body": "did things " * 15}]) _config_with_split(store, cmd, threshold=3) @@ -242,11 +248,16 @@ def test_host_neutral_tool_names_do_not_crash(store: KBStore, tmp_path: Path) -> def test_truncation_flagged_when_over_budget(store: KBStore, tmp_path: Path) -> None: - from vouch import capture import yaml + + from vouch import capture # distinct summaries so capture.observe's dedup window does not collapse them for i in range(50): - capture.observe(store, "s1", tool="Edit", summary=f"edit {i} " + "x" * 200, now=float(i), config=_rt_cfg()) + capture.observe( + store, "s1", tool="Edit", + summary=f"edit {i} " + "x" * 200, + now=float(i), config=_rt_cfg(), + ) cmd = _stub_llm(tmp_path, [{"title": "t", "body": "b " * 20}]) store.config_path.write_text( yaml.safe_dump( @@ -441,9 +452,10 @@ def _enrich_stub(tmp_path: Path, output: str = ENRICH_JSON) -> str: def test_mechanical_page_enriched(store: KBStore, tmp_path: Path) -> None: - from vouch.models import ProposalStatus import yaml + from vouch.models import ProposalStatus + store.config_path.write_text( yaml.safe_dump( {"capture": {"enrich": {"llm_cmd": _enrich_stub(tmp_path)}}} From 4858d0ac6a804821603020c17c9de28d5db88e1d Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Thu, 30 Jul 2026 11:11:41 -0700 Subject: [PATCH 3/5] test(capture): cover realtime-off CLI and transcript edge paths Exercise observations_from_transcript parse/error/skip branches and capture observe CLI paths for realtime-disabled, capture-disabled, and no-store so the #645 diff-coverage gate hits 100%. --- tests/test_capture.py | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/tests/test_capture.py b/tests/test_capture.py index 9cb8ac0d..9a89c517 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -633,6 +633,144 @@ def test_observations_from_transcript_rebuilds_tool_activity(tmp_path: Path) -> assert any(o.get("cmd") == "pytest -q" for o in obs) +def test_observations_from_transcript_edge_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cover parse failure, non-dict messages, errored tools, and skips.""" + # unreadable / missing path → empty (OSError path) + assert cap.observations_from_transcript(tmp_path / "missing.jsonl") == [] + + # parse raises → empty + broken = tmp_path / "broken.jsonl" + broken.write_text("{not json\n", encoding="utf-8") + + def _boom(_path): + raise ValueError("bad transcript") + + monkeypatch.setattr( + "vouch.transcript.parse_claude_transcript", _boom, + ) + assert cap.observations_from_transcript(broken) == [] + + def _odd(_path): + return { + "messages": [ + "skip-me", + { + "blocks": [ + { + "type": "tool_use", + "name": "Bash", + "input": {"command": "false"}, + "result": { + "content": "exit 1", + "is_error": True, + }, + }, + { + "type": "tool_use", + "name": "NotARealTool", + "input": {}, + "result": {"content": "x"}, + }, + "not-a-block", + ], + }, + ], + } + + monkeypatch.setattr( + "vouch.transcript.parse_claude_transcript", _odd, + ) + obs = cap.observations_from_transcript(broken) + assert len(obs) == 1 + assert obs[0]["tool"] == "Bash" + assert obs[0]["summary"].startswith("Command failed:") + + +def test_observe_cli_skips_when_realtime_disabled( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + import json as _json + + from click.testing import CliRunner + + from vouch.cli import cli + + monkeypatch.chdir(store.root) + # default starter has realtime: false + payload = _json.dumps({ + "session_id": "s-rt-off", + "cwd": str(store.root), + "tool_name": "Edit", + "tool_input": {"file_path": str(store.root / "a.py")}, + "tool_response": "ok", + }) + result = CliRunner().invoke(cli, ["capture", "observe"], input=payload) + assert result.exit_code == 0, result.output + out = _json.loads(result.output) + assert out["skipped"] == "realtime-disabled" + assert not cap.buffer_path(store, "s-rt-off").exists() + + +def test_observe_cli_silent_when_capture_disabled( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + import json as _json + + from click.testing import CliRunner + + from vouch.cli import cli + + monkeypatch.chdir(store.root) + store.config_path.write_text( + "capture:\n enabled: false\n realtime: true\n", encoding="utf-8", + ) + payload = _json.dumps({ + "session_id": "s-off", + "cwd": str(store.root), + "tool_name": "Edit", + "tool_input": {"file_path": str(store.root / "a.py")}, + "tool_response": "ok", + }) + result = CliRunner().invoke(cli, ["capture", "observe"], input=payload) + assert result.exit_code == 0, result.output + assert result.output.strip() == "" + assert not cap.buffer_path(store, "s-off").exists() + + +def test_observe_cli_noop_when_no_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """No project KB and no personal fallback → observe exits quietly.""" + import json as _json + + from click.testing import CliRunner + + from vouch import hub + from vouch.cli import cli + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + monkeypatch.setenv(hub.REGISTRY_ENV, str(fake_home / "registry.yaml")) + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False) + nowhere = tmp_path / "nowhere" + nowhere.mkdir() + monkeypatch.chdir(nowhere) + payload = _json.dumps({ + "session_id": "s-nostore", + "cwd": str(nowhere), + "tool_name": "Edit", + "tool_input": {"file_path": str(nowhere / "a.py")}, + "tool_response": "ok", + }) + result = CliRunner().invoke(cli, ["capture", "observe"], input=payload) + assert result.exit_code == 0, result.output + assert result.output.strip() == "" + + def test_finalize_uses_transcript_when_realtime_off( store: KBStore, tmp_path: Path, ) -> None: From 312df0cf7214a4946d73696ae8e5445863456992 Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Thu, 30 Jul 2026 19:52:05 -0700 Subject: [PATCH 4/5] fix(capture): address #645 review on host-neutral rebuild Reuse Claude-then-Codex transcript detection for observations_from_transcript, check enabled before realtime skip JSON, and prune retired PostToolUse/Stop hooks on adapter reinstall so upgrades drop per-tool spawns. --- src/vouch/capture.py | 52 +++++++++++++++++--- src/vouch/cli.py | 4 +- src/vouch/install_adapter.py | 71 +++++++++++++++++++++++++- tests/test_capture.py | 93 ++++++++++++++++++++++++++++++++--- tests/test_install_adapter.py | 67 +++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 18 deletions(-) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 861b5bff..7e930b54 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -216,18 +216,41 @@ def summarize_tool( def observations_from_transcript(transcript_path: Path) -> list[dict[str, Any]]: - """Rebuild PostToolUse-shaped observations from a Claude Code transcript. + """Rebuild PostToolUse-shaped observations from a host transcript. Used when ``capture.realtime`` is off so SessionEnd finalize can still feed ``session_split.summarize``'s ``min_observations`` gate without the - per-tool buffer. + per-tool buffer. Tries the Claude parser first, then Codex — same + host-neutral detection ``transcript.load_transcript`` uses when the + caller already has a path (#602 / #645 review). + + Bound: inherits each parser's ``max_messages=2000`` default, so very + long sessions may drop their oldest tool calls (the realtime buffer + had no such cap). """ - try: - from .transcript import parse_claude_transcript + from .transcript import parse_claude_transcript, parse_codex_transcript + + parsed: dict[str, Any] | None = None + for parse in (parse_claude_transcript, parse_codex_transcript): + try: + candidate = parse(transcript_path) + except (OSError, UnicodeDecodeError, ValueError, TypeError, KeyError): + continue + if not isinstance(candidate, dict): + continue + obs = _observations_from_parsed(candidate) + if obs: + return obs + # keep the first successful parse as a fallback (empty session) + if parsed is None: + parsed = candidate + return _observations_from_parsed(parsed) if parsed is not None else [] + + +def _observations_from_parsed(parsed: dict[str, Any]) -> list[dict[str, Any]]: + """Turn normalized transcript ``messages``/``blocks`` into buffer rows.""" + from .codex_rollout import _observation_from_call - parsed = parse_claude_transcript(transcript_path) - except (OSError, UnicodeDecodeError, ValueError, TypeError, KeyError): - return [] out: list[dict[str, Any]] = [] for msg in parsed.get("messages") or []: if not isinstance(msg, dict): @@ -248,6 +271,21 @@ def observations_from_transcript(transcript_path: Path) -> list[dict[str, Any]]: tip, response, ) + # Codex tool names (exec_command, apply_patch, …) are not in + # _OBSERVED_TOOLS; map them the same way codex_rollout ingest does. + if obs is None and name is not None: + # parse_codex_transcript wraps args as {"arguments": ...} / + # {"input": ...}; unwrap so _observation_from_call sees the + # same shape parse_rollout feeds it. + call_args: object = tip + if isinstance(tip, dict) and "arguments" in tip: + call_args = tip.get("arguments") + obs = _observation_from_call(str(name), call_args) + if obs is not None and isinstance(response, str) and response: + text = response.lower() + if "error" in text or "failed" in text: + short = str(obs.get("summary", "")).removeprefix("Ran: ") + obs = {**obs, "summary": f"Command failed: {short}"} if obs is None: continue record: dict[str, Any] = { diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 6131d092..49ab17a6 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2956,11 +2956,11 @@ def capture_observe_cmd() -> None: if store is None: return cfg = capture_mod.load_config(store) + if not cfg.enabled: + return if not cfg.realtime: _emit_json({"skipped": "realtime-disabled"}) return - if not cfg.enabled: - return tool_input = payload.get("tool_input") obs = capture_mod.summarize_tool( payload.get("tool_name"), diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index b2758b30..6d3d74ff 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -691,12 +691,81 @@ def _event_commands(groups: Any) -> set[str]: return cmds +def _is_retired_vouch_hook_command(command: object) -> bool: + """True for vouch PostToolUse/Stop hooks retired by capture.realtime (#602). + + Matched by substring so both bare ``vouch capture observe`` and wrapped + ``vouch capture observe || true`` forms are pruned on reinstall. + """ + if not isinstance(command, str): + return False + return ( + "vouch capture observe" in command + or "vouch capture answer" in command + ) + + +def _prune_retired_vouch_hooks(dst: dict[str, Any]) -> bool: + """Remove vouch-owned PostToolUse/Stop observe/answer hooks from ``dst``. + + The shipped Claude template no longer installs those events (#602); a + re-run of the installer must actually drop them from existing + ``settings.json`` files, not only avoid re-adding them. + """ + hooks = dst.get("hooks") + if not isinstance(hooks, dict): + return False + changed = False + for event in ("PostToolUse", "Stop"): + groups = hooks.get(event) + if not isinstance(groups, list): + continue + new_groups: list[Any] = [] + for group in groups: + if not isinstance(group, dict): + new_groups.append(group) + continue + hook_list = group.get("hooks") + if not isinstance(hook_list, list): + new_groups.append(group) + continue + kept = [ + h for h in hook_list + if not ( + isinstance(h, dict) + and _is_retired_vouch_hook_command(h.get("command")) + ) + ] + if len(kept) != len(hook_list): + changed = True + if not kept: + # drop the whole group when it only held retired vouch hooks + continue + if kept is not hook_list: + refreshed = {k: v for k, v in group.items() if k != "hooks"} + refreshed["hooks"] = kept + new_groups.append(refreshed) + else: + new_groups.append(group) + if new_groups != groups: + changed = True + if new_groups: + hooks[event] = new_groups + else: + del hooks[event] + return changed + + def _merge_settings(src: dict[str, Any], dst: dict[str, Any]) -> bool: """Merge our ``permissions.allow`` + ``hooks`` into an existing settings dict in place. Returns True if ``dst`` changed. Idempotent: re-merging the same ``src`` is a no-op because every command / permission is deduped. + + Also prunes retired vouch PostToolUse/Stop observe/answer hooks so an + upgrade from a pre-``capture.realtime`` install actually removes the + per-tool spawns (#602). """ - changed = False + changed = _prune_retired_vouch_hooks(dst) # permissions.allow — union, preserving the user's order. src_perms = src.get("permissions") diff --git a/tests/test_capture.py b/tests/test_capture.py index 9a89c517..c94e85fb 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -633,6 +633,53 @@ def test_observations_from_transcript_rebuilds_tool_activity(tmp_path: Path) -> assert any(o.get("cmd") == "pytest -q" for o in obs) +def test_observations_from_transcript_rebuilds_codex_tool_activity( + tmp_path: Path, +) -> None: + """Codex rollouts must rebuild observations too — hardcoding the Claude + parser left non-Claude hosts with an empty buffer and silent + below-min finalize (#645 review).""" + import json as _json + + transcript = tmp_path / "rollout.jsonl" + rows = [ + {"type": "session_meta", "payload": { + "id": "019eec6b-4a0c-7ad0-afd1-68973c902231", + "cwd": "/proj", "timestamp": "2026-06-22T08:01:54Z"}}, + {"type": "turn_context", "payload": { + "turn_id": "t1", "cwd": "/proj", "model": "gpt-5-codex"}}, + {"type": "response_item", "payload": { + "type": "message", "role": "user", + "content": [{"type": "input_text", "text": "run tests"}]}}, + {"type": "response_item", "payload": { + "type": "function_call", "name": "exec_command", + "arguments": '{"cmd": "pytest -q"}', "call_id": "call_1"}}, + {"type": "response_item", "payload": { + "type": "function_call_output", "call_id": "call_1", + "output": "3 passed"}}, + {"type": "response_item", "payload": { + "type": "custom_tool_call", "name": "apply_patch", + "input": "*** Begin Patch\n*** Add File: /proj/a.py\n+hi\n", + "call_id": "call_2"}}, + {"type": "response_item", "payload": { + "type": "custom_tool_call_output", "call_id": "call_2", + "output": "Success"}}, + {"type": "response_item", "payload": { + "type": "function_call", "name": "exec_command", + "arguments": '{"cmd": "ls src"}', "call_id": "call_3"}}, + {"type": "response_item", "payload": { + "type": "function_call_output", "call_id": "call_3", + "output": "a.py\n"}}, + ] + transcript.write_text( + "\n".join(_json.dumps(r) for r in rows) + "\n", encoding="utf-8", + ) + obs = cap.observations_from_transcript(transcript) + assert len(obs) >= 3 + assert any(o["tool"] == "Bash" and "pytest" in o["summary"] for o in obs) + assert any(o["tool"] == "Edit" for o in obs) + + def test_observations_from_transcript_edge_paths( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -640,19 +687,18 @@ def test_observations_from_transcript_edge_paths( # unreadable / missing path → empty (OSError path) assert cap.observations_from_transcript(tmp_path / "missing.jsonl") == [] - # parse raises → empty + # both parsers raise → empty broken = tmp_path / "broken.jsonl" broken.write_text("{not json\n", encoding="utf-8") - def _boom(_path): + def _boom(_path, **_kwargs): raise ValueError("bad transcript") - monkeypatch.setattr( - "vouch.transcript.parse_claude_transcript", _boom, - ) + monkeypatch.setattr("vouch.transcript.parse_claude_transcript", _boom) + monkeypatch.setattr("vouch.transcript.parse_codex_transcript", _boom) assert cap.observations_from_transcript(broken) == [] - def _odd(_path): + def _odd(_path, **_kwargs): return { "messages": [ "skip-me", @@ -668,8 +714,9 @@ def _odd(_path): }, }, { + # ignored by both summarize_tool and codex mapper "type": "tool_use", - "name": "NotARealTool", + "name": "update_plan", "input": {}, "result": {"content": "x"}, }, @@ -679,8 +726,10 @@ def _odd(_path): ], } + monkeypatch.setattr("vouch.transcript.parse_claude_transcript", _odd) monkeypatch.setattr( - "vouch.transcript.parse_claude_transcript", _odd, + "vouch.transcript.parse_codex_transcript", + lambda _p, **_k: {"messages": []}, ) obs = cap.observations_from_transcript(broken) assert len(obs) == 1 @@ -739,6 +788,34 @@ def test_observe_cli_silent_when_capture_disabled( assert not cap.buffer_path(store, "s-off").exists() +def test_observe_cli_silent_when_disabled_even_if_realtime_off( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """enabled:false must stay silent even when realtime is also off — + otherwise a fully-disabled KB prints the skip JSON (#645 review).""" + import json as _json + + from click.testing import CliRunner + + from vouch.cli import cli + + monkeypatch.chdir(store.root) + store.config_path.write_text( + "capture:\n enabled: false\n realtime: false\n", encoding="utf-8", + ) + payload = _json.dumps({ + "session_id": "s-off-both", + "cwd": str(store.root), + "tool_name": "Edit", + "tool_input": {"file_path": str(store.root / "a.py")}, + "tool_response": "ok", + }) + result = CliRunner().invoke(cli, ["capture", "observe"], input=payload) + assert result.exit_code == 0, result.output + assert result.output.strip() == "" + assert "realtime-disabled" not in result.output + + def test_observe_cli_noop_when_no_store( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 6310af9f..fa0bdb25 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -175,6 +175,73 @@ def test_settings_json_merges_into_existing(tmp_path: Path) -> None: assert ".claude/settings.json" not in result.written +def test_settings_json_merge_prunes_retired_observe_hooks(tmp_path: Path) -> None: + """Re-installing must drop pre-realtime vouch PostToolUse/Stop hooks + while keeping the user's own hooks on those events (#645 review).""" + settings_dir = tmp_path / ".claude" + settings_dir.mkdir() + (settings_dir / "settings.json").write_text(json.dumps({ + "hooks": { + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture observe || true", + }, + { + "type": "command", + "command": "my-own-post-tool-hook", + }, + ], + }, + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture answer || true", + }, + ], + }, + ], + "SessionEnd": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "my-own-session-end", + }, + ], + }, + ], + }, + })) + result = install("claude-code", target=tmp_path, tier="T4") + merged = json.loads((settings_dir / "settings.json").read_text()) + + post = [ + h["command"] + for g in merged["hooks"].get("PostToolUse", []) + for h in g["hooks"] + ] + assert "my-own-post-tool-hook" in post + assert not any("vouch capture observe" in c for c in post) + assert "Stop" not in merged["hooks"] # only held the retired vouch hook + end = [ + h["command"] + for g in merged["hooks"].get("SessionEnd", []) + for h in g["hooks"] + ] + assert "my-own-session-end" in end + assert any("capture finalize" in c for c in end) + assert ".claude/settings.json" in result.merged + + def test_settings_json_merge_is_idempotent(tmp_path: Path) -> None: (tmp_path / ".claude").mkdir() (tmp_path / ".claude" / "settings.json").write_text(json.dumps({"hooks": {}})) From 35d74bcbe9a2d37ff943f00baf81cb9ca4149d63 Mon Sep 17 00:00:00 2001 From: kurosawareiji7007-hub Date: Thu, 30 Jul 2026 20:03:52 -0700 Subject: [PATCH 5/5] test(capture): cover host-neutral rebuild and prune edge paths Hit the remaining diff-coverage gaps on non-dict parse results, Codex failed-command rewrite, and prune_retired malformed/untouched groups. --- src/vouch/install_adapter.py | 2 +- tests/test_capture.py | 42 +++++++++++++++++++++++++ tests/test_install_adapter.py | 58 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index 6d3d74ff..35a899f5 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -741,7 +741,7 @@ def _prune_retired_vouch_hooks(dst: dict[str, Any]) -> bool: if not kept: # drop the whole group when it only held retired vouch hooks continue - if kept is not hook_list: + if len(kept) != len(hook_list): refreshed = {k: v for k, v in group.items() if k != "hooks"} refreshed["hooks"] = kept new_groups.append(refreshed) diff --git a/tests/test_capture.py b/tests/test_capture.py index c94e85fb..bb91b580 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -698,6 +698,17 @@ def _boom(_path, **_kwargs): monkeypatch.setattr("vouch.transcript.parse_codex_transcript", _boom) assert cap.observations_from_transcript(broken) == [] + # non-dict parse result is skipped (host-neutral loop continue) + monkeypatch.setattr( + "vouch.transcript.parse_claude_transcript", + lambda _p, **_k: ["not", "a", "dict"], + ) + monkeypatch.setattr( + "vouch.transcript.parse_codex_transcript", + lambda _p, **_k: {"messages": []}, + ) + assert cap.observations_from_transcript(broken) == [] + def _odd(_path, **_kwargs): return { "messages": [ @@ -736,6 +747,37 @@ def _odd(_path, **_kwargs): assert obs[0]["tool"] == "Bash" assert obs[0]["summary"].startswith("Command failed:") + # Codex mapper path: failed shell output rewrites summary via + # _observation_from_call (summarize_tool does not know exec_command). + def _codex_fail(_path, **_kwargs): + return { + "messages": [ + { + "blocks": [ + { + "type": "tool_use", + "name": "exec_command", + "input": {"arguments": '{"cmd": "pytest -q"}'}, + "result": { + "content": "failed: 1 error", + }, + }, + ], + }, + ], + } + + monkeypatch.setattr( + "vouch.transcript.parse_claude_transcript", _boom, + ) + monkeypatch.setattr( + "vouch.transcript.parse_codex_transcript", _codex_fail, + ) + failed = cap.observations_from_transcript(broken) + assert len(failed) == 1 + assert failed[0]["tool"] == "Bash" + assert failed[0]["summary"].startswith("Command failed:") + def test_observe_cli_skips_when_realtime_disabled( store: KBStore, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index fa0bdb25..a8173f7e 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -242,6 +242,64 @@ def test_settings_json_merge_prunes_retired_observe_hooks(tmp_path: Path) -> Non assert ".claude/settings.json" in result.merged +def test_prune_retired_vouch_hooks_edge_shapes() -> None: + """Cover non-string commands, malformed groups, and untouched groups.""" + from vouch.install_adapter import ( + _is_retired_vouch_hook_command, + _prune_retired_vouch_hooks, + ) + + assert _is_retired_vouch_hook_command(None) is False + assert _is_retired_vouch_hook_command(42) is False + assert _is_retired_vouch_hook_command("vouch capture observe") is True + + dst = { + "hooks": { + "PostToolUse": [ + "not-a-group", + {"matcher": "*", "hooks": "not-a-list"}, + { + "matcher": "Edit", + "hooks": [ + {"type": "command", "command": "user-only-hook"}, + {"type": "command", "command": None}, + ], + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch capture observe || true", + }, + ], + }, + ], + "Stop": "not-a-list", + }, + } + assert _prune_retired_vouch_hooks(dst) is True + post = dst["hooks"]["PostToolUse"] + assert "not-a-group" in post + assert {"matcher": "*", "hooks": "not-a-list"} in post + assert any( + isinstance(g, dict) + and g.get("matcher") == "Edit" + and any(h.get("command") == "user-only-hook" for h in g["hooks"]) + for g in post + ) + assert not any( + isinstance(g, dict) + and isinstance(g.get("hooks"), list) + and any( + isinstance(h, dict) and "vouch capture observe" in str(h.get("command")) + for h in g["hooks"] + ) + for g in post + ) + assert dst["hooks"]["Stop"] == "not-a-list" + + def test_settings_json_merge_is_idempotent(tmp_path: Path) -> None: (tmp_path / ".claude").mkdir() (tmp_path / ".claude" / "settings.json").write_text(json.dumps({"hooks": {}}))