From 5f8add21c408103c9bb43fe8b9825f16257de929 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:43:13 +0900 Subject: [PATCH 1/3] fix(storage): create evidence/ dir on demand in put_evidence receipt-backed capture wrote the source but silently filed 0 claims on any KB created before receipts existed: put_evidence opened evidence/.yaml with mode "x" but never made the evidence/ dir, so the write threw FileNotFoundError and capture_answer's cli wrapper swallowed it (source written, no claims, no error). mkdir(parents=True, exist_ok=True) first, the way put_source already does. fresh `vouch init` created evidence/ so this only bit pre-existing kbs. regression test covers put_evidence on a kb whose evidence/ dir is absent. --- src/vouch/storage.py | 3 +++ tests/test_storage.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 1ec3085c..7c431aae 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -762,6 +762,9 @@ def relations_to(self, node_id: str) -> list[Relation]: def put_evidence(self, ev: Evidence) -> Evidence: if not (self._source_dir(ev.source_id) / "meta.yaml").exists(): raise ValueError(f"evidence {ev.id} cites unknown source {ev.source_id}") + # KBs created before receipts existed have no evidence/ dir; create it + # on demand so receipt-backed claims work on any KB, not just fresh init. + self._evidence_path(ev.id).parent.mkdir(parents=True, exist_ok=True) try: with self._evidence_path(ev.id).open("x", encoding="utf-8") as f: f.write(_yaml_dump(ev.model_dump(mode="json"))) diff --git a/tests/test_storage.py b/tests/test_storage.py index ce775688..7e97e004 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -633,6 +633,20 @@ def test_evidence_can_back_claim(store: KBStore) -> None: assert store.get_claim("c1").evidence == ["e1"] +def test_evidence_dir_created_on_demand(store: KBStore) -> None: + """A KB created before receipts existed has no evidence/ dir. put_evidence + must create it rather than crash — regression: receipt-backed capture + silently filed 0 claims (source written, evidence write threw) on such KBs. + """ + import shutil + + src = store.put_source(b"raw doc") + shutil.rmtree(store.kb_dir / "evidence", ignore_errors=True) + ev = store.put_evidence(Evidence(id="e1", source_id=src.id, + locator="L1-L5", quote="snippet")) + assert (store.kb_dir / "evidence" / f"{ev.id}.yaml").exists() + + # --- proposals ------------------------------------------------------------ From 7a4ad6e38b2ecdd01d601c505c214f6802cedf97 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:45:49 +0900 Subject: [PATCH 2/3] fix(capture): passive answer-capture is strictly opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capture_answer used to file a pending claim per answer when no auto-approve gate was set, which floods the review queue on every substantive turn once the Stop hook is installed. now it returns `gate-closed` and writes nothing unless review.auto_approve_on_receipt or approver_role: trusted-agent is set — the gate is the on-switch, so `install-mcp` is safe for anyone who has not opted into passive memory. the gated session-rollup path is unaffected. --- src/vouch/capture.py | 21 +++++++++++++++++---- tests/test_capture_answer.py | 16 ++++++++-------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index ddfdf025..654681e0 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -479,10 +479,12 @@ def capture_answer( Fires from a host Stop hook (the turn just finished). Extracts the last exchange, ingests the *answer* as a content-addressed source, files a receipt-backed claim per quotable span (``extract.extract_receipt_claims``), - and approves each one the review gate allows — self-approval clears under + and auto-approves each one — self-approval clears under ``review.approver_role: trusted-agent`` or, for these verbatim-quoting - claims, ``review.auto_approve_on_receipt``. With neither gate on the claims - stay pending: the review gate is honoured, never bypassed. + claims, ``review.auto_approve_on_receipt``. Passive capture is strictly + opt-in: with neither gate set it does nothing (returns ``gate-closed``), so + it never floods the review queue with a pending claim per answer. The gated + session-rollup path (``finalize``) is the review-first capture flow. Idempotent and quiet by design: an answer already ingested (same bytes) is skipped, and answers shorter than ``min_answer_chars`` (acknowledgements) @@ -504,6 +506,16 @@ def capture_answer( if not cfg.enabled: return _answer_skip(session_id, "disabled") + # Passive answer-capture is strictly opt-in: with no auto-approve gate set it + # does nothing, rather than flood the review queue with a pending claim per + # answer. The gated session-rollup path handles review-first capture. + review = proposals_mod._review_config(store) + if not ( + review.get("auto_approve_on_receipt") + or review.get("approver_role") == "trusted-agent" + ): + return _answer_skip(session_id, "gate-closed") + exchange = last_exchange(transcript_path) if exchange is None: return _answer_skip(session_id, "no-answer") @@ -538,7 +550,8 @@ def capture_answer( ) approved += 1 except ProposalError: - # gate closed (no trusted-agent, no receipt opt-in): leave pending. + # defensive: the gate is open (checked above), but a protected kind + # or a race could still block a single claim — leave it pending. pass return { "captured": True, "skipped": None, "session_id": session_id, diff --git a/tests/test_capture_answer.py b/tests/test_capture_answer.py index ed87e3ec..bd67318c 100644 --- a/tests/test_capture_answer.py +++ b/tests/test_capture_answer.py @@ -134,16 +134,16 @@ def test_capture_answer_approves_under_trusted_agent(store: KBStore, tmp_path: P assert res["approved"] == res["filed"] >= 3 -def test_capture_answer_leaves_pending_when_gate_off(store: KBStore, tmp_path: Path) -> None: - # default starter config: neither opt-in set. +def test_capture_answer_skips_when_gate_off(store: KBStore, tmp_path: Path) -> None: + # default starter config: neither opt-in set. passive capture is opt-in, so + # it does NOTHING rather than flood the review queue with per-answer claims. tp = _transcript(tmp_path, [_user(QUESTION), _assistant(ANSWER)]) res = cap.capture_answer(store, "sess-1", tp) - assert res["captured"] is True - assert res["filed"] >= 3 - assert res["approved"] == 0 - # the review gate is honoured — claims wait for a human. - pending = [p for p in store.list_proposals(ProposalStatus.PENDING)] - assert len(pending) >= 3 + assert res["captured"] is False + assert res["skipped"] == "gate-closed" + assert res["filed"] == 0 + # nothing was written — no source, no pending proposals. + assert store.list_proposals(ProposalStatus.PENDING) == [] def test_capture_answer_skips_short_answer(store: KBStore, tmp_path: Path) -> None: From 06ac9f74e2c790221a6ee97bf719f8f46c4e107f Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:46:02 +0900 Subject: [PATCH 3/3] docs(readme): document how to enable passive memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add a "Passive memory" section: the two-step enable (install-mcp wires the Stop answer-capture + per-prompt recall hooks; set review.auto_approve_on_receipt, or approver_role: trusted-agent, to open the gate), how it behaves (saves per answer not on tab-close, recalled next session), and honest caveats — it captures every substantive answer, recall is injected not enforced (use `vouch synthesize` / `/vouch-ask` for a guaranteed grounded answer), short and duplicate answers are skipped. also correct the install-mcp hook list, which had drifted to three hooks. --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a991b17c..45ed1e1e 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ vouch init # creates .vouch/ with starter config vouch install-mcp claude-code # wires capture hooks into Claude Code ``` -`install-mcp` writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and three hooks — `PostToolUse` capture, `SessionEnd` rollup, `SessionStart` recall. Restart Claude Code so they load. +`install-mcp` writes `.mcp.json` (the `kb.*` MCP tools), the `/vouch-*` slash commands, and the Claude Code hooks: `PostToolUse` capture, `SessionEnd` rollup, `SessionStart` recall, `UserPromptSubmit` per-prompt recall, and a `Stop` answer-capture (inert until you switch on [passive memory](#passive-memory)). Restart Claude Code so they load. **2. Point `compile` at an LLM** — the only step that needs a model. In `.vouch/config.yaml`: @@ -225,6 +225,64 @@ git add .vouch/ && git commit -m "kb: approve session summary" Pending drafts (`proposed/`) and the derived search index (`state.db`) are gitignored — what lands in history is exactly what passed review. +## Passive memory + +The loop above keeps a human at the gate: every session rolls up into a +*pending* page you approve. If you'd rather your agent **remember its own +answers automatically** — ask a question in one session, have it recalled in the +next, with no approval step — turn on *passive memory*. It's **off by default** +and enables in two steps. + +**1. Wire the hooks** (the same `install-mcp` from above already does this): + +```bash +vouch init +vouch install-mcp claude-code # restart Claude Code so the hooks load +``` + +Two of the installed hooks drive passive memory: `Stop` → `vouch capture answer` +(saves each answer) and `UserPromptSubmit` → `vouch context-hook` (injects +relevant approved knowledge into every prompt, zero tool calls). + +**2. Open the auto-approve gate** in `.vouch/config.yaml`: + +```yaml +review: + auto_approve_on_receipt: true # the receipt is the reviewer +``` + +A captured answer is stored as a source and split into claims that **quote it +verbatim**; a claim whose byte-offset receipt verifies is approved +mechanically, with no human. To trust the agent for *everything*, not only +receipt-backed claims, use `approver_role: trusted-agent` instead. **With +neither set, passive capture is inert** — the `Stop` hook is a no-op, so it can +never flood your review queue. The gate is the on-switch. + +That's the whole setup. Now: + +- **Ask** a substantive question. When the turn finishes, the answer is saved + and auto-approved — verify with `vouch status` (claims went up, `pending: 0`). + It saves **per answer, not on tab-close**, so there's nothing to close. +- **Open a new session** and ask something related. `SessionStart` recall and + the per-prompt hook surface the earlier answer, so the agent starts from + memory instead of re-deriving it. + +### Before you rely on it + +- **It saves every substantive answer, not just the important ones** — the KB + accumulates over time. Passive memory trades tidiness for zero friction; + selective capture (keep facts, drop chatter) is not built yet. Prune with + `vouch review` / delete, or run passive mode only for focused sessions. +- **Recall is injected, not enforced.** The per-prompt hook adds vouch knowledge + to the model's context and asks it to ground its answer there; a model can + still answer from its own priors. For a **guaranteed** KB-grounded answer, + call `vouch synthesize ""` (or the `/vouch-ask` slash command) — it + answers from approved claims only, with citations, or tells you the KB has + nothing. +- **Short answers and exact repeats are skipped** — a length floor plus a + content-hash dedup keep the every-turn hook from saving acknowledgements or + duplicates. + ## The rules underneath * **Writes require approval.** Agents file *proposals* via the `kb.*` MCP tools (or `vouch serve --transport jsonl`); approval is the only path to a durable artifact, and the approver must differ from the proposer unless you opt out.