Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down Expand Up @@ -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 "<question>"` (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.
Expand Down
21 changes: 17 additions & 4 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand Down
16 changes: 8 additions & 8 deletions tests/test_capture_answer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +644 to +647

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not ignore failure to remove the evidence directory.

ignore_errors=True can leave evidence/ present, allowing this regression test to pass without exercising the missing-directory path. Remove the directory without suppressing errors, or assert that it no longer exists before calling put_evidence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage.py` around lines 644 - 647, Update the evidence-directory
setup in the test around store.put_evidence to ensure shutil.rmtree removes the
directory without ignore_errors=True, or explicitly assert the directory is
absent before invoking put_evidence. Preserve the existing assertion that the
evidence YAML file is recreated successfully.



# --- proposals ------------------------------------------------------------


Expand Down
Loading