diff --git a/CHANGELOG.md b/CHANGELOG.md index c582c62d..c51e9125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Changed +- **real-time capture is opt-in; the per-tool-call hook is gone** (#602): new + config key `capture.realtime`, default **false**. `vouch capture observe` is + a no-op when it is off, and the shipped claude-code adapter no longer + registers a `PostToolUse` hook at all — on a busy session that was hundreds + of python process spawns, each loading the kb store, to append a line to an + ephemeral buffer whose only consumer is the once-per-session rollup. The + `Stop` → `vouch capture answer` hook goes with it: under the default + `capture.answer_mode: session` it could never file anything. `SessionStart`, + `UserPromptSubmit` and `SessionEnd` are the three that carry weight. + `capture.finalize` now reconstructs the session's tool activity from the + transcript it already reads (`capture.observations_from_transcript`), so + summaries keep their "files modified / activity / notable commands" sections + and the `min_observations` gate keeps counting real work — the transcript is + the receipt-bearing artifact and strictly richer than per-tool-call hearsay. + Set `capture.realtime: true` to restore the buffer as a crash-resistant + backstop; buffered and reconstructed records are merged on `tool_use_id`, so + a call recorded by both still counts once. No stored artifact changes shape, + and installed hosts keep their current `settings.json` until they re-run + `vouch install-mcp`. + ### Added - **explicit pins — a working set that always enters the pack** (#615): `vouch pin ` / `vouch pins list` / `vouch unpin `. Pinned claims and diff --git a/README.md b/README.md index 6086ef6e..5e447029 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 three hooks — `SessionStart` recall, `UserPromptSubmit` per-prompt recall, `SessionEnd` rollup (which also files the session's answer memory). Nothing runs per tool call: `capture.realtime` is off by default and the rollup reconstructs tool activity from the transcript. 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..72099873 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -55,31 +55,9 @@ ] } ], - "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": [ { + "comment": "the one hook that carries the session's knowledge: rolls the transcript into a PENDING summary and extracts receipt-backed claims once, with every turn in view. no PostToolUse hook — tool activity is reconstructed here from the transcript (set capture.realtime: true to also keep the per-call buffer as a crash backstop). no Stop hook — under the default capture.answer_mode: session it could never file anything.", "matcher": "*", "hooks": [ { diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml index 36e84e89..2a29a6dc 100644 --- a/adapters/claude-code/install.yaml +++ b/adapters/claude-code/install.yaml @@ -7,8 +7,12 @@ # 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. +# No PostToolUse: `capture.realtime` is off by default and finalize +# reconstructs tool activity from the transcript, so a per-tool-call +# process spawn buys nothing (issue #602). No Stop either — under the +# default `capture.answer_mode: session` it can never file anything. # # user_mcp = a local-scope MCP registration written to the user's # `~/.claude.json` under `projects[].mcpServers`. The `.mcp.json` diff --git a/docs/gittensor.md b/docs/gittensor.md index 1082a9e9..edfe3e7d 100644 --- a/docs/gittensor.md +++ b/docs/gittensor.md @@ -78,11 +78,11 @@ vouch install-mcp claude-code That writes `.mcp.json` (so the agent can `kb.search` / `kb.context` the KB for cited answers) **and** `.claude/settings.json`, which registers: -- a `PostToolUse` hook (`vouch capture observe`) that harvests each tool call - into a gitignored scratch buffer, -- a `SessionEnd` hook (`vouch capture finalize`) that rolls the buffer plus a - `git diff` backstop into **one pending session-summary page** — mechanically, - no LLM, never auto-approved, +- a `SessionEnd` hook (`vouch capture finalize`) that reads the session's tool + calls back out of the transcript and rolls them, plus a `git diff` backstop, + into **one pending session-summary page** — mechanically, no LLM, never + auto-approved (set `capture.realtime: true` to also keep the per-tool-call + scratch buffer as a crash backstop), - a `SessionStart` hook that runs `vouch recall` (injecting approved knowledge) and nudges any pending summaries. @@ -94,9 +94,9 @@ is actual `vouch` output (paths shortened; trimmed where marked `…`). **Session 1.** A Claude Code session maps the codebase, reads `CONTRIBUTING.md`, and works issue #212 (a connection-pool leak): a test run fails along the way, the fix and the regression test the merge bar demands -land, and the changelog gets its entry. The `PostToolUse` hook harvests each -of those tool calls as they happen; at session end the `SessionEnd` hook rolls -them into one pending page: +land, and the changelog gets its entry. At session end the `SessionEnd` hook +reads those tool calls back out of the transcript and rolls them into one +pending page: ```console $ vouch pending diff --git a/docs/tutorials/remember-across-sessions.md b/docs/tutorials/remember-across-sessions.md index d655a0ca..77c1b27f 100644 --- a/docs/tutorials/remember-across-sessions.md +++ b/docs/tutorials/remember-across-sessions.md @@ -44,9 +44,11 @@ Done — 7 written, 0 appended, 0 merged, 0 skipped That's the whole integration: `.mcp.json` gives Claude Code the `kb_*` tools, `CLAUDE.md` teaches it the propose-with-evidence protocol, and -`.claude/settings.json` wires the hooks — `PostToolUse` harvests the -session into a scratch buffer, `SessionEnd` rolls it into one pending -summary, and `SessionStart` injects all approved knowledge +`.claude/settings.json` wires the hooks — `SessionEnd` rolls the +session's tool activity into one pending summary (read back out of the +transcript, so nothing fires per tool call), `UserPromptSubmit` injects +context for the prompt you just typed, and `SessionStart` injects all +approved knowledge (`vouch recall`) into every new session before your first message. From here on, you just talk to Claude Code. diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 5e7e425c..a0b86574 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -44,6 +44,12 @@ # "turn": legacy behaviour — claims filed from each answer on every Stop hook. DEFAULT_ANSWER_MODE = "session" _ANSWER_MODES = frozenset({"session", "turn"}) +# Real-time observation is off by default (issue #602). The buffer's only +# consumer is the once-per-session rollup, and `finalize` reconstructs the +# same tool activity from the transcript it already reads — which is the +# receipt-bearing artifact, and strictly richer than per-call hearsay. Turn +# this on to keep the buffer as a crash-resistant backstop. +DEFAULT_REALTIME = False CAPTURE_ACTOR = "vouch-capture" CAPTURE_PAGE_TYPE = "session" @@ -54,6 +60,7 @@ class CaptureConfig: min_observations: int = DEFAULT_MIN_OBSERVATIONS dedup_window_seconds: float = DEFAULT_DEDUP_WINDOW_SECONDS answer_mode: str = DEFAULT_ANSWER_MODE + realtime: bool = DEFAULT_REALTIME def load_config(store: KBStore) -> CaptureConfig: @@ -77,6 +84,7 @@ def load_config(store: KBStore) -> CaptureConfig: raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS) ), answer_mode=answer_mode, + realtime=coerce_bool(raw.get("realtime", DEFAULT_REALTIME), DEFAULT_REALTIME), ) @@ -128,9 +136,15 @@ def observe( config: CaptureConfig | None = None, tool_use_id: str | None = None, ) -> bool: - """Append one observation to the session buffer. Returns True if written.""" + """Append one observation to the session buffer. Returns True if written. + + A no-op unless ``capture.realtime`` is on. Off (the default) the buffer has + no consumer worth a process spawn per tool call — ``finalize`` reconstructs + the same activity from the transcript. The check comes before any file I/O + so the hook, if one is still wired, costs nothing beyond the config read. + """ cfg = config or load_config(store) - if not cfg.enabled: + if not cfg.enabled or 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 @@ -208,6 +222,117 @@ def summarize_tool( return out +def _parse_ts(raw: object) -> float: + """Transcript ISO timestamp -> epoch seconds; 0.0 when absent/unparseable.""" + if not isinstance(raw, str) or not raw: + return 0.0 + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() + except ValueError: + return 0.0 + + +def _tool_result_text(block: dict[str, Any]) -> str: + """Flatten a tool_result block's content into the text summarize_tool reads. + + The host writes ``content`` as a bare string on some turns and a list of + typed blocks on others; ``is_error`` is the authoritative failure signal + when present, so it is folded into the text the Bash branch greps. + """ + parts: list[str] = [] + if block.get("is_error"): + parts.append("error") + content = block.get("content") + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and isinstance(item.get("text"), str): + parts.append(item["text"]) + return "\n".join(parts) + + +def observations_from_transcript( + transcript_path: Path, *, max_observations: int = 1000 +) -> list[dict[str, Any]]: + """Reconstruct the session's tool activity from a host transcript. + + Same observation shape ``observe`` writes to the buffer, built from the + artifact ``finalize`` already reads — so the summary keeps its "files + modified / activity / notable commands" sections, and the + ``min_observations`` gate keeps counting real work, with the per-tool-call + hook switched off (issue #602). ``codex_rollout`` does the same thing for + codex rollouts; this is the claude-transcript door onto one rollup. + + Each ``tool_use`` block is paired with the ``tool_result`` carrying its id, + so the Bash branch still distinguishes a failed command from a clean one. + Returns ``[]`` for an unreadable transcript — reconstruction is a best + effort that must never cost the session its summary. + """ + try: + rows = transcript_path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return [] + + calls: list[tuple[str, str, dict[str, Any], float]] = [] + results: dict[str, str] = {} + for line in rows: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(obj, dict): + continue + msg = obj.get("message") + content = msg.get("content") if isinstance(msg, dict) else None + if not isinstance(content, list): + continue + ts = _parse_ts(obj.get("timestamp")) + for block in content: + if not isinstance(block, dict): + continue + kind = block.get("type") + if kind == "tool_use": + use_id = str(block.get("id") or "") + name = block.get("name") + tool_input = block.get("input") + if not name or not isinstance(name, str): + continue + calls.append( + (use_id, name, tool_input if isinstance(tool_input, dict) else {}, ts) + ) + elif kind == "tool_result": + use_id = str(block.get("tool_use_id") or "") + if use_id: + results[use_id] = _tool_result_text(block) + + out: list[dict[str, Any]] = [] + for use_id, name, tool_input, ts in calls: + obs = summarize_tool(name, tool_input, results.get(use_id, "")) + if obs is None: + continue + # Masked here for the same reason `observe` masks: this record rolls + # into a committed session page and the append-only audit log. + record: dict[str, Any] = { + "ts": ts, + "tool": obs["tool"], + "summary": mask_secrets(str(obs["summary"])), + } + if use_id: + record["tool_use_id"] = use_id + if obs.get("files"): + record["files"] = obs["files"] + if obs.get("cmd"): + record["cmd"] = mask_secrets(str(obs["cmd"])) + out.append(record) + if len(out) >= max_observations: + break + return out + + def _git_changes(cwd: Path) -> tuple[list[str], str]: """Return (changed_files, diff_stat). Empty on any failure / non-repo.""" try: @@ -556,6 +681,11 @@ def finalize( answer memory happens: the full transcript is handed to ``capture_session_answers`` once, instead of a Stop hook filing claims on every turn. A claim-extraction failure never loses the summary. + + Tool activity is reconstructed from the same transcript + (``observations_from_transcript``) and merged with whatever the buffer + holds, so the summary survives ``capture.realtime`` being off — which is + the default (issue #602). """ from . import session_split # deferred: breaks the capture<->session_split cycle cfg = config or load_config(store) @@ -578,10 +708,15 @@ def finalize( source_id = answers.get("source") if source_id: sources = [str(source_id)] + transcript_observations = ( + observations_from_transcript(transcript_path) + if transcript_path is not None + else [] + ) 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, extra_observations=transcript_observations, ) if answers is not None: result["answers"] = answers diff --git a/src/vouch/session_split.py b/src/vouch/session_split.py index f8b042fa..e26449ed 100644 --- a/src/vouch/session_split.py +++ b/src/vouch/session_split.py @@ -87,6 +87,45 @@ def load_split_config(store: KBStore) -> SplitConfig: ) +def _merge_observations( + buffered: list[dict[str, Any]], extra: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """Union of buffer and reconstructed observations, in timestamp order. + + With `capture.realtime` on, both sources describe the same tool calls; + `tool_use_id` identifies the event across them, so the pair counts once + against `min_observations` and appears once in the rendered activity list. + Records without an id (older buffers, hosts that omit it) fall back to + (tool, summary, cmd) identity. + """ + if not extra: + return buffered + if not buffered: + return extra + seen_ids = { + str(o["tool_use_id"]) for o in buffered if o.get("tool_use_id") + } + seen_keys = { + (str(o.get("tool", "")), str(o.get("summary", "")), str(o.get("cmd", ""))) + for o in buffered + } + merged = list(buffered) + for obs in extra: + use_id = obs.get("tool_use_id") + if use_id and str(use_id) in seen_ids: + continue + key = ( + str(obs.get("tool", "")), str(obs.get("summary", "")), str(obs.get("cmd", "")) + ) + if not use_id and key in seen_keys: + continue + merged.append(obs) + # Stable on ties so a buffer record and its reconstructed twin, or two + # calls inside one assistant turn (identical timestamp), keep their order. + merged.sort(key=lambda o: float(o.get("ts", 0.0) or 0.0)) + return merged + + def summarize( store: KBStore, session_id: str, @@ -99,6 +138,7 @@ def summarize( config: capture.CaptureConfig | None = None, origin: Path | None = None, sources: list[str] | None = None, + extra_observations: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Roll a session buffer into PENDING page proposals. Never approves. @@ -115,10 +155,17 @@ 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. + + `extra_observations` are observations reconstructed outside the buffer — + `capture.finalize` passes the ones it reads back out of the transcript. + They are merged with the buffer's, so this stays the one rollup whether + `capture.realtime` is on, off, or was toggled mid-session. """ cfg = config or capture.load_config(store) path = capture.buffer_path(store, session_id) - observations = capture._read_observations(path) + observations = _merge_observations( + capture._read_observations(path), extra_observations or [] + ) 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 75483f83..e0eeb212 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -100,6 +100,11 @@ def _starter_config() -> dict[str, Any]: # answer memory: "session" extracts claims once at SessionEnd from # the full transcript; "turn" files claims on every Stop hook. "answer_mode": "session", + # real-time observation: off means `capture observe` is a no-op and + # finalize reconstructs tool activity from the transcript instead of + # a per-tool-call buffer. Turn on to keep the buffer as a + # crash-resistant backstop, at one process spawn per tool call. + "realtime": False, "split": { # llm topical split for large sessions; llm_cmd falls back to # compile.llm_cmd when null. see session_split.py. diff --git a/tests/test_adopt.py b/tests/test_adopt.py index 9a24df0d..0d293489 100644 --- a/tests/test_adopt.py +++ b/tests/test_adopt.py @@ -422,7 +422,7 @@ def test_fallback_session_summary_records_its_origin( 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=cap.CaptureConfig(realtime=True)) 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..bb11dce5 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -5,14 +5,28 @@ from pathlib import Path import pytest +import yaml from vouch import capture as cap from vouch.storage import KBStore, _starter_config +def _enable_realtime(kb: KBStore) -> KBStore: + """Opt the KB into the real-time buffer. + + `capture.realtime` defaults to off (issue #602), so every test that + exercises `observe` and the buffer it feeds has to say so explicitly. The + default-off behaviour has its own tests below. + """ + loaded = yaml.safe_load(kb.config_path.read_text(encoding="utf-8")) or {} + loaded.setdefault("capture", {})["realtime"] = True + kb.config_path.write_text(yaml.safe_dump(loaded, sort_keys=False), encoding="utf-8") + return kb + + @pytest.fixture def store(tmp_path: Path) -> KBStore: - return KBStore.init(tmp_path) + return _enable_realtime(KBStore.init(tmp_path)) def test_load_config_defaults(store: KBStore) -> None: @@ -540,10 +554,17 @@ def commands(event: str) -> list[str]: out.append(h.get("command", "")) return out - assert any("capture observe" in c for c in commands("PostToolUse")) assert any("capture finalize" in c for c in commands("SessionEnd")) assert any("capture banner" in c for c in commands("SessionStart")) assert any("capture finalize-all" in c for c in commands("SessionStart")) + assert any("context-hook" in c for c in commands("UserPromptSubmit")) + # issue #602: the two per-event hooks are gone. PostToolUse spawned a + # process per tool call to feed a buffer whose only consumer is the + # SessionEnd rollup, which now reconstructs the same activity from the + # transcript; Stop could never file anything under the default + # `capture.answer_mode: session`. + assert "PostToolUse" not in hooks + assert "Stop" not in hooks def test_capture_finalize_all_cmd_with_old_buffers(tmp_path: Path, monkeypatch) -> None: @@ -707,8 +728,8 @@ def test_is_stale_buffer_with_exact_boundary(tmp_path): def _make_store(tmp_path: Path) -> KBStore: - """Helper to create a KBStore for testing.""" - return KBStore.init(tmp_path) + """Helper to create a KBStore for testing (real-time buffer on).""" + return _enable_realtime(KBStore.init(tmp_path)) def test_finalize_all_except_skips_current_session(tmp_path): @@ -955,6 +976,7 @@ def _fallback_machine(tmp_path_factory, monkeypatch): root = hub.personal_kb_root() assert root is not None personal = KBStore.init(root) + _enable_realtime(personal) _turn_mode(personal) hub.register_kb(root, role="personal", actor="t") hub.set_personal_fallback(root, True) @@ -1086,3 +1108,168 @@ def test_fallback_off_captures_nowhere( input=_json.dumps({"session_id": "fb-5", "cwd": str(nowhere)}), ) assert "run `vouch init` here to enable durable memory" in banner.output + + +# --- real-time capture is opt-in (issue #602) ------------------------------- + + +def _tool_transcript(tmp_path: Path, name: str = "tools.jsonl") -> Path: + """A transcript with four tool calls, one of them a failed Bash.""" + transcript = tmp_path / name + lines = [ + {"type": "user", "message": {"role": "user", "content": [ + {"type": "text", "text": "fix the parser"}]}}, + {"type": "assistant", "timestamp": "2026-07-30T10:00:00Z", + "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Read", + "input": {"file_path": "/repo/src/parser.py"}}, + {"type": "tool_use", "id": "t2", "name": "Edit", + "input": {"file_path": "/repo/src/parser.py"}}, + ]}}, + {"type": "user", "timestamp": "2026-07-30T10:00:01Z", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok"}, + {"type": "tool_result", "tool_use_id": "t2", "content": "ok"}, + ]}}, + {"type": "assistant", "timestamp": "2026-07-30T10:00:02Z", + "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t3", "name": "Bash", + "input": {"command": "pytest tests/test_parser.py"}}, + {"type": "tool_use", "id": "t4", "name": "TodoWrite", "input": {}}, + ]}}, + {"type": "user", "timestamp": "2026-07-30T10:00:03Z", + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t3", "is_error": True, + "content": [{"type": "text", "text": "1 failed"}]}, + ]}}, + ] + transcript.write_text( + "\n".join(_json.dumps(entry) for entry in lines), encoding="utf-8" + ) + return transcript + + +def test_realtime_defaults_off(tmp_path: Path) -> None: + kb = KBStore.init(tmp_path) + assert cap.load_config(kb).realtime is False + assert _starter_config()["capture"]["realtime"] is False + + +def test_realtime_quoted_true_enables(tmp_path: Path) -> None: + kb = KBStore.init(tmp_path) + kb.config_path.write_text('capture:\n realtime: "true"\n', encoding="utf-8") + assert cap.load_config(kb).realtime is True + + +def test_observe_is_a_noop_when_realtime_is_off(tmp_path: Path) -> None: + # The point of the default: no buffer file, so no per-tool-call write. + kb = KBStore.init(tmp_path) + assert cap.observe(kb, "s1", tool="Read", summary="Read a.py") is False + assert not cap.buffer_path(kb, "s1").exists() + + +def test_observations_from_transcript_reconstructs_tool_activity( + tmp_path: Path, +) -> None: + obs = cap.observations_from_transcript(_tool_transcript(tmp_path)) + assert [o["tool"] for o in obs] == ["Read", "Edit", "Bash"] # TodoWrite unobserved + assert [o["tool_use_id"] for o in obs] == ["t1", "t2", "t3"] + assert obs[0]["summary"] == "Read parser.py" + assert obs[1]["files"] == ["/repo/src/parser.py"] + # is_error on the tool_result is what makes this a failure, not the text + assert obs[2]["summary"].startswith("Command failed: pytest") + assert obs[2]["cmd"] == "pytest tests/test_parser.py" + assert obs[0]["ts"] < obs[2]["ts"] + + +def test_observations_from_transcript_masks_secrets(tmp_path: Path) -> None: + transcript = tmp_path / "secret.jsonl" + transcript.write_text(_json.dumps({ + "type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Bash", + "input": {"command": "curl -H 'Authorization: Bearer sk-abcdef1234567890'"}}, + ]}}), encoding="utf-8") + obs = cap.observations_from_transcript(transcript) + assert "sk-abcdef1234567890" not in obs[0]["cmd"] + assert "sk-abcdef1234567890" not in obs[0]["summary"] + + +def test_observations_from_transcript_survives_a_bad_path(tmp_path: Path) -> None: + assert cap.observations_from_transcript(tmp_path / "nope.jsonl") == [] + + +def test_finalize_summarizes_from_the_transcript_with_realtime_off( + tmp_path: Path, +) -> None: + """The compatibility case the issue calls out: with no buffer, the + min_observations gate has to count reconstructed activity or a session + touching fewer than 3 files would file no summary at all.""" + kb = KBStore.init(tmp_path / "proj") + assert cap.load_config(kb).realtime is False + res = cap.finalize( + kb, "s-reconstructed", transcript_path=_tool_transcript(tmp_path), + ) + assert res["captured"] == 3 + assert res["summary_proposal_id"] is not None + body = kb.get_proposal(res["summary_proposal_id"]).payload["body"] + assert "Read parser.py" in body + assert "Command failed: pytest" in body + + +def test_finalize_counts_a_buffered_call_once_when_realtime_is_on( + tmp_path: Path, +) -> None: + """Both sources describe the same tool calls when realtime is on; + tool_use_id is what keeps the pair from being counted twice.""" + kb = _enable_realtime(KBStore.init(tmp_path / "proj")) + transcript = _tool_transcript(tmp_path) + for use_id, tool, summary in ( + ("t1", "Read", "Read parser.py"), + ("t2", "Edit", "Edited parser.py"), + ): + assert cap.observe( + kb, "s-both", tool=tool, summary=summary, tool_use_id=use_id, now=1.0 + ) + res = cap.finalize(kb, "s-both", transcript_path=transcript) + assert res["captured"] == 3 # 2 buffered + only the unseen Bash call + body = kb.get_proposal(res["summary_proposal_id"]).payload["body"] + assert body.count("Read parser.py") == 1 + + +def test_reconstruction_skips_every_malformed_row(tmp_path: Path) -> None: + """A transcript is the host's file, not ours — one bad row must not cost + the session its summary, so every shape below is skipped, not raised on.""" + transcript = tmp_path / "messy.jsonl" + transcript.write_text( + "\n".join([ + "", # blank line + " ", # whitespace-only line + "{not json at all", # undecodable + '"a bare string"', # valid json, not a dict + _json.dumps({"type": "assistant", "message": "not-a-dict"}), + _json.dumps({"type": "assistant", "message": {"content": "not-a-list"}}), + _json.dumps({ + "type": "assistant", "timestamp": "not-a-timestamp", + "message": {"content": [ + "a bare string block", # not a dict + {"type": "tool_use", "id": "x1"}, # no name + {"type": "tool_use", "id": "x2", "name": 7}, # name not a str + {"type": "text", "text": "prose"}, # not a tool call + {"type": "tool_result", "content": "orphan"}, # no tool_use_id + {"type": "tool_use", "id": "x3", "name": "Read", + "input": {"file_path": "/repo/a.py"}}, + ]}, + }), + ]), + encoding="utf-8", + ) + obs = cap.observations_from_transcript(transcript) + assert [o["tool_use_id"] for o in obs] == ["x3"] + assert obs[0]["ts"] == 0.0 # unparseable timestamp degrades, never raises + + +def test_reconstruction_respects_its_ceiling(tmp_path: Path) -> None: + obs = cap.observations_from_transcript( + _tool_transcript(tmp_path), max_observations=2 + ) + assert len(obs) == 2 diff --git a/tests/test_capture_answer.py b/tests/test_capture_answer.py index 7387c216..9c48edff 100644 --- a/tests/test_capture_answer.py +++ b/tests/test_capture_answer.py @@ -58,6 +58,12 @@ def _assistant(text: str) -> dict: return {"type": "assistant", "message": _msg("assistant", text)} +# `capture.realtime` is off by default (issue #602) and these tests seed the +# buffer directly, so they say so explicitly rather than through config.yaml — +# several of them overwrite it to set a `review:` key. +_REALTIME = cap.CaptureConfig(realtime=True) + + @pytest.fixture def store(tmp_path: Path) -> KBStore: return KBStore.init(tmp_path / "kb") @@ -387,7 +393,8 @@ 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=_REALTIME) res = cap.finalize(store, "s1", cwd=None, transcript_path=tp) assert res["answers"]["captured"] is True src_id = res["answers"]["source"] @@ -401,7 +408,8 @@ 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=_REALTIME) res = cap.finalize(store, "s1", cwd=None, transcript_path=tp) assert res["answers"]["skipped"] == "already-captured" prop = store.get_proposal(res["summary_proposal_id"]) @@ -459,7 +467,8 @@ 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=_REALTIME) 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..2515db08 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -166,10 +166,16 @@ 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"]] + prompt = [ + h["command"] + for g in merged["hooks"].get("UserPromptSubmit", []) + 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 any("context-hook" in c for c in prompt) assert any("capture finalize" in c for c in end) + # issue #602: no per-tool-call hook is installed any more. + assert "PostToolUse" not in merged["hooks"] assert ".claude/settings.json" in result.merged assert ".claude/settings.json" not in result.skipped @@ -189,13 +195,13 @@ 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 = [ + finalize_cmds = [ h["command"] - for g in data["hooks"]["PostToolUse"] + for g in data["hooks"]["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(finalize_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..3b2c5210 100644 --- a/tests/test_session_split.py +++ b/tests/test_session_split.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +import yaml from vouch import session_split from vouch.session_split import SplitConfig, load_split_config @@ -14,7 +15,14 @@ @pytest.fixture def store(tmp_path: Path) -> KBStore: - return KBStore.init(tmp_path) + # `capture.realtime` is off by default (issue #602); these tests seed the + # buffer through `capture.observe`, so they opt in. The default-off path + # (transcript reconstruction) is covered in tests/test_capture.py. + kb = KBStore.init(tmp_path) + loaded = yaml.safe_load(kb.config_path.read_text(encoding="utf-8")) or {} + loaded.setdefault("capture", {})["realtime"] = True + kb.config_path.write_text(yaml.safe_dump(loaded, sort_keys=False), encoding="utf-8") + return kb def test_split_config_defaults(store: KBStore) -> None: @@ -60,8 +68,14 @@ def test_split_config_quoted_false_enabled_does_not_enable(store: KBStore) -> No def _observe(store: KBStore, sid: str, n: int, tool: str = "Edit") -> None: from vouch import capture + + # Explicit config: several callers overwrite config.yaml to set another + # `capture.*` key, which drops the fixture's `realtime: true`. + cfg = capture.CaptureConfig(realtime=True) 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: @@ -462,3 +476,30 @@ def test_cited_session_page_clears_admission(store: KBStore, tmp_path: Path) -> assert prop.payload["sources"] == [src.id] assert prop.status is ProposalStatus.PENDING assert prop.decided_by is None + + +# --- merging buffered and reconstructed observations (issue #602) ----------- + + +def test_merge_prefers_tool_use_id_then_falls_back_to_content() -> None: + buffered = [ + {"ts": 2.0, "tool": "Read", "summary": "Read a.py", "tool_use_id": "t1"}, + {"ts": 3.0, "tool": "Edit", "summary": "Edited b.py"}, # no id: older buffer + ] + extra = [ + # same call, same id -> one record + {"ts": 1.0, "tool": "Read", "summary": "Read a.py", "tool_use_id": "t1"}, + # same call, no id on either side -> deduped on tool/summary/cmd + {"ts": 1.5, "tool": "Edit", "summary": "Edited b.py"}, + # genuinely new + {"ts": 4.0, "tool": "Bash", "summary": "Ran: pytest", "tool_use_id": "t9"}, + ] + merged = session_split._merge_observations(buffered, extra) + assert [o.get("tool_use_id") or o["tool"] for o in merged] == ["t1", "Edit", "t9"] + assert [o["ts"] for o in merged] == [2.0, 3.0, 4.0] # timestamp order + + +def test_merge_short_circuits_when_either_side_is_empty() -> None: + buffered = [{"ts": 1.0, "tool": "Read", "summary": "Read a.py"}] + assert session_split._merge_observations(buffered, []) is buffered + assert session_split._merge_observations([], buffered) is buffered diff --git a/tests/test_session_transcript.py b/tests/test_session_transcript.py index 3aa673b9..57daf0c0 100644 --- a/tests/test_session_transcript.py +++ b/tests/test_session_transcript.py @@ -144,7 +144,8 @@ 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=capture.CaptureConfig(realtime=True)) out = transcript.load_transcript(store, sid) assert out["available"] is False assert out["observations"][0]["tool"] == "Edit"