Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 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.

Expand Down
23 changes: 0 additions & 23 deletions adapters/claude-code/.claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
5 changes: 4 additions & 1 deletion adapters/claude-code/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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[<abs project>].mcpServers`. The `.mcp.json`
Expand Down
98 changes: 97 additions & 1 deletion src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -208,6 +215,95 @@ def summarize_tool(
return out


def observations_from_transcript(transcript_path: Path) -> list[dict[str, Any]]:
"""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. 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).
"""
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

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,
)
# 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] = {
"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:
Expand Down Expand Up @@ -581,7 +677,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
Expand Down
19 changes: 13 additions & 6 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.enabled:
return
if not cfg.realtime:
_emit_json({"skipped": "realtime-disabled"})
return
tool_input = payload.get("tool_input")
obs = capture_mod.summarize_tool(
payload.get("tool_name"),
Expand All @@ -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.
Expand Down
71 changes: 70 additions & 1 deletion src/vouch/install_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 len(kept) != len(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")
Expand Down
16 changes: 15 additions & 1 deletion src/vouch/session_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion tests/test_adopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading