Skip to content
Closed
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` / `vouch pins list` / `vouch unpin <id>`. Pinned claims and
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 1 addition & 23 deletions adapters/claude-code/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
6 changes: 5 additions & 1 deletion adapters/claude-code/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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[<abs project>].mcpServers`. The `.mcp.json`
Expand Down
16 changes: 8 additions & 8 deletions docs/gittensor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/tutorials/remember-across-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
141 changes: 138 additions & 3 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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:
Expand All @@ -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),
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
49 changes: 48 additions & 1 deletion src/vouch/session_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand All @@ -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",
Expand Down
Loading
Loading