From d416e6559952c7aaad1e900b54c24f25455d87e8 Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Sun, 21 Jun 2026 22:15:45 +0100 Subject: [PATCH 01/34] chore(capabilities): pin pluginApi compat baseline, fail CI on host-compat drift --- CHANGELOG.md | 1 + schemas/capabilities.schema.json | 6 +++ src/vouch/capabilities.py | 33 +++++++++++++++ src/vouch/models.py | 9 ++++ tests/test_capabilities.py | 70 ++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b88c0498..38fb0f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to vouch are documented here. Format follows New `vouch schema list` and `vouch schema sync` commands inspect declared kinds and audit existing pages against them; `propose-page` gains `--kind` and repeatable `--meta key=value`. +- `kb.capabilities` now reports a `host_compat` block mirroring the `openclaw.compat` constraints declared in `openclaw.plugin.json` (e.g. `pluginApi`), so non-OpenClaw clients can detect compat without parsing the manifest. A new test asserts the two stay in sync and fails CI on drift (#237). - `kb.synthesize` — answer-mode retrieval over the review-gated KB. Answers a query in prose from approved claims only, with an inline `[claim_id]` citation behind every sentence, an explicit `gaps` block listing query diff --git a/schemas/capabilities.schema.json b/schemas/capabilities.schema.json index b986604e..f795ed3d 100644 --- a/schemas/capabilities.schema.json +++ b/schemas/capabilities.schema.json @@ -12,6 +12,12 @@ "title": "Context Engines", "type": "array" }, + "host_compat": { + "additionalProperties": true, + "description": "Per-host compatibility ranges (#237). Mirrors the `openclaw.compat` block in openclaw.plugin.json so non-OpenClaw clients can detect compat without parsing the manifest, e.g. {\"openclaw\": {\"pluginApi\": \">=2026.4.0\"}}.", + "title": "Host Compat", + "type": "object" + }, "knowledge_capability": { "additionalProperties": true, "title": "Knowledge Capability", diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 39872ade..b84ccaf9 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -7,10 +7,21 @@ from __future__ import annotations +import json +import logging +from pathlib import Path + from . import __version__ from .models import Capabilities from .openclaw.context_engine import describe_engine +_log = logging.getLogger(__name__) + +# Path to the plugin manifest, relative to this module. capabilities.py lives +# at src/vouch/capabilities.py; openclaw.plugin.json lives at the repo root, +# three levels up (src/vouch/ -> src/ -> repo root). +_PLUGIN_MANIFEST_PATH = Path(__file__).resolve().parent.parent.parent / "openclaw.plugin.json" + # The full method surface this implementation exposes. Keep this list in # sync with the MCP server + JSONL server registrations — `test_capabilities` # asserts they match. @@ -72,6 +83,27 @@ ] +def _load_host_compat() -> dict[str, dict[str, str]]: + """Read the `openclaw.compat` block from openclaw.plugin.json (#237). + + Surfaced in `kb.capabilities` as `host_compat` so non-OpenClaw clients + can detect compat without parsing the manifest themselves. Returns an + empty dict (rather than raising) if the manifest is missing or + malformed — capabilities() must never fail to report basic info just + because the manifest moved or this is installed as a standalone wheel + without the manifest packaged alongside it. + """ + try: + manifest = json.loads(_PLUGIN_MANIFEST_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + _log.debug("openclaw.plugin.json unreadable, host_compat will be empty: %s", e) + return {} + compat = manifest.get("openclaw", {}).get("compat") + if not isinstance(compat, dict): + return {} + return {"openclaw": {k: str(v) for k, v in compat.items()}} + + def capabilities() -> Capabilities: retrieval = ["fts5", "substring"] try: @@ -94,4 +126,5 @@ def capabilities() -> Capabilities: "config_path": "retrieval.scope", }, context_engines=[describe_engine()], + host_compat=_load_host_compat(), ) diff --git a/src/vouch/models.py b/src/vouch/models.py index 23f94aa4..e4b27727 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -456,3 +456,12 @@ class Capabilities(BaseModel): default_factory=list, description="OpenClaw context engines exposed (see openclaw.plugin.json)", ) + host_compat: dict[str, Any] = Field( + default_factory=dict, + description=( + "Per-host compatibility ranges (#237). Mirrors the " + "`openclaw.compat` block in openclaw.plugin.json so non-OpenClaw " + "clients can detect compat without parsing the manifest, e.g. " + '{"openclaw": {"pluginApi": ">=2026.4.0"}}.' + ), + ) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 736b20ba..6908a3cb 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -2,6 +2,11 @@ from __future__ import annotations +import json +from pathlib import Path + +import pytest + from vouch import capabilities from vouch.jsonl_server import HANDLERS @@ -15,3 +20,68 @@ def test_capabilities_matches_jsonl_handlers() -> None: f"missing handlers={declared - implemented}, " f"missing capabilities={implemented - declared}" ) + + +# --- host_compat drift detection (#237) ----------------------------------- +# +# vouch declares openclaw.compat.pluginApi in openclaw.plugin.json. The same +# value must surface in kb.capabilities so non-OpenClaw clients can detect +# compat without parsing the manifest. These tests fail CI with a clear +# message if the two declarations drift apart. + +_MANIFEST_PATH = ( + Path(__file__).resolve().parent.parent / "openclaw.plugin.json" +) + + +def _manifest_plugin_api() -> str: + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + return manifest["openclaw"]["compat"]["pluginApi"] + + +def test_capabilities_host_compat_matches_openclaw_manifest() -> None: + """kb.capabilities.host_compat.openclaw.pluginApi must equal the + pluginApi range declared in openclaw.plugin.json. A bump in one file + without the other is exactly the "host compat drift" #237 asks CI to + catch.""" + caps = capabilities.capabilities() + manifest_range = _manifest_plugin_api() + capabilities_range = caps.host_compat.get("openclaw", {}).get("pluginApi") + assert capabilities_range == manifest_range, ( + f"host compat drift: openclaw.plugin.json declares pluginApi=" + f"{manifest_range!r} but kb.capabilities.host_compat reports " + f"{capabilities_range!r}. Keep both in sync." + ) + + +def test_capabilities_host_compat_present_and_nonempty() -> None: + """host_compat must not silently degrade to {} when the manifest is + readable -- that would defeat the drift check above by making both + sides agree on "missing" rather than catching real drift.""" + caps = capabilities.capabilities() + assert "openclaw" in caps.host_compat + assert "pluginApi" in caps.host_compat["openclaw"] + assert caps.host_compat["openclaw"]["pluginApi"].strip() != "" + + +def test_load_host_compat_returns_empty_on_missing_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """_load_host_compat must degrade gracefully (empty dict, no raise) if + the manifest is absent -- e.g. installed as a standalone wheel without + openclaw.plugin.json packaged alongside it.""" + monkeypatch.setattr( + capabilities, "_PLUGIN_MANIFEST_PATH", tmp_path / "does-not-exist.json" + ) + assert capabilities._load_host_compat() == {} + + +def test_load_host_compat_returns_empty_on_malformed_manifest( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + """A malformed manifest must not crash capabilities() -- it's reporting + diagnostic info, not validating the install.""" + bad = tmp_path / "openclaw.plugin.json" + bad.write_text("{not valid json", encoding="utf-8") + monkeypatch.setattr(capabilities, "_PLUGIN_MANIFEST_PATH", bad) + assert capabilities._load_host_compat() == {} From 45f32c298dec94a89ae3055be42a2383a70e61b3 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:02:47 +0900 Subject: [PATCH 02/34] feat(dual-solve): surface engine logs --- CHANGELOG.md | 3 ++ src/vouch/cli.py | 14 +++++++- src/vouch/dual_solve.py | 51 ++++++++++++++++++++++++++++- src/vouch/web/dual_solve_api.py | 4 ++- src/vouch/web/static/dual_solve.css | 3 ++ src/vouch/web/static/dual_solve.js | 13 +++++++- tests/test_dual_solve.py | 45 +++++++++++++++++++++++-- tests/test_web_dual_solve.py | 10 ++++-- 8 files changed, 134 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75723dbd..5ba7890e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ All notable changes to vouch are documented here. Format follows - dual-solve JSON, review-ui job, and choose responses now include `changed_files` for each candidate and the kept branch, so desktop and browser clients can show the resulting files without parsing unified diffs. +- dual-solve JSON and review-ui job responses now include each engine's returned + output log plus a deterministic recommendation hint based on success and diff + scope, so clients can compare Claude and Codex results before choosing. - `vouch review-ui --allow-dual-solve` — a browser SPA that runs `dual-solve` on a github issue link, streams progress over the review-ui's websocket, shows both engines' diffs side by side, and lets you pick the winner. Off by default; diff --git a/src/vouch/cli.py b/src/vouch/cli.py index ca465cae..9cf69649 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2029,21 +2029,33 @@ def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, _emit_json({ "issue": {"number": issue.number, "title": issue.title, "url": issue.url}, + "recommendation": ds_mod.recommendation(candidates), "candidates": [ {"engine": c.engine, "branch": c.branch, "ok": c.ok, "error": c.error, "changed_files": ds_mod.changed_files(c.diff), - "diff": c.diff} for c in candidates + "log": c.log, "diff": c.diff} for c in candidates ], }) return for c in candidates: click.echo(f"\n=== {c.engine} ({c.branch}) ===", err=True) + if c.log.strip(): + click.echo("--- engine log ---", err=True) + click.echo(c.log) if c.ok: + if c.log.strip(): + click.echo("--- diff ---", err=True) click.echo(c.diff) else: click.echo(f"(failed: {c.error})", err=True) + rec = ds_mod.recommendation(candidates) + if rec.get("reason"): + label = f"recommendation: {rec['engine']}" if rec.get("engine") \ + else "recommendation: no automatic pick" + click.echo(f"{label} -- {rec['reason']}", err=True) + ok = [c for c in candidates if c.ok] if not ok: raise click.ClickException("both engines failed; nothing to choose") diff --git a/src/vouch/dual_solve.py b/src/vouch/dual_solve.py index d7734af5..dedbb82d 100644 --- a/src/vouch/dual_solve.py +++ b/src/vouch/dual_solve.py @@ -37,6 +37,7 @@ "parse_issue_ref", "parse_summary", "prepare", + "recommendation", "record_to_kb", "repo_root", "run_candidate", @@ -73,6 +74,7 @@ class Candidate: worktree: Path diff: str = "" sha: str = "" + log: str = "" ok: bool = False error: str | None = None @@ -97,6 +99,53 @@ def changed_files(diff: str) -> list[str]: return files +def _diff_stats(candidate: Candidate) -> tuple[int, int]: + files = changed_files(candidate.diff) + return len(files), len(candidate.diff.splitlines()) + + +def recommendation(candidates: list[Candidate]) -> dict[str, str | None]: + """Return a deterministic reviewer hint for the two dual-solve candidates. + + This is deliberately a scope heuristic, not an automated quality judgment: + successful candidates beat failed ones; then the smaller changed-file count + and smaller diff win. Ties stay unresolved for the human reviewer. + """ + ok = [c for c in candidates if c.ok] + if not ok: + return { + "engine": None, + "reason": "neither engine produced a usable diff.", + } + if len(ok) == 1: + return { + "engine": ok[0].engine, + "reason": f"only {ok[0].engine} produced a usable diff.", + } + + ranked = sorted(ok, key=_diff_stats) + best = ranked[0] + other = ranked[1] + best_files, best_lines = _diff_stats(best) + other_files, other_lines = _diff_stats(other) + if (best_files, best_lines) == (other_files, other_lines): + return { + "engine": None, + "reason": ( + "both engines produced equally scoped diffs; " + "review the logs and tests before choosing." + ), + } + return { + "engine": best.engine, + "reason": ( + f"{best.engine} has the smaller scoped diff " + f"({best_files} files, {best_lines} lines vs " + f"{other_files} files, {other_lines} lines)." + ), + } + + def parse_issue_ref(ref: str) -> tuple[str | None, str]: """Normalize an issue reference for ``gh issue view``. @@ -209,7 +258,7 @@ def run_candidate(engine: Engine, issue: Issue, prompt: str, root: Path, return cand try: - engine.fix(cwd=str(worktree), prompt=prompt) + cand.log = engine.fix(cwd=str(worktree), prompt=prompt) except Exception as exc: cand.error = f"engine failed: {exc}" return cand diff --git a/src/vouch/web/dual_solve_api.py b/src/vouch/web/dual_solve_api.py index 5b0e08df..c696cb94 100644 --- a/src/vouch/web/dual_solve_api.py +++ b/src/vouch/web/dual_solve_api.py @@ -68,9 +68,10 @@ def _serialize(job: DualSolveJob) -> dict[str, Any]: "candidates": [ {"engine": c.engine, "branch": c.branch, "ok": c.ok, "error": c.error, "changed_files": ds.changed_files(c.diff), - "diff": c.diff} + "log": c.log, "diff": c.diff} for c in job.candidates ], + "recommendation": ds.recommendation(job.candidates), "proposed_ids": list(job.proposed_ids), "kept_branch": job.kept_branch, "changed_files": ds.changed_files(kept.diff) if kept is not None else [], @@ -200,4 +201,5 @@ async def dual_solve_choose(req: _ChooseReq) -> dict[str, Any]: "kept_branch": job.kept_branch, "proposed_ids": ids, "changed_files": ds.changed_files(chosen.diff) if chosen is not None else [], + "recommendation": ds.recommendation(job.candidates), } diff --git a/src/vouch/web/static/dual_solve.css b/src/vouch/web/static/dual_solve.css index e94148a8..e77e4bf8 100644 --- a/src/vouch/web/static/dual_solve.css +++ b/src/vouch/web/static/dual_solve.css @@ -3,9 +3,12 @@ .ds-run input { flex: 1; } .ds-progress { background:#111; color:#ddd; padding:.5rem; white-space:pre-wrap; } .ds-error { color:#b00; } +.ds-recommendation { border:1px solid #ddd; padding:.5rem; background:#f8f8f8; } .ds-panes { display:grid; grid-template-columns:1fr 1fr; gap:1rem; } .ds-pane { border:1px solid #ccc; padding:.5rem; overflow:auto; } .ds-changed-files { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; margin:.25rem 0 .5rem; } +.ds-log { margin:.25rem 0 .75rem; } +.ds-log pre { max-height:220px; overflow:auto; white-space:pre-wrap; background:#111; color:#ddd; padding:.5rem; } .ds-file-head { font-weight:600; margin-top:.5rem; } .ds-pane pre { margin:0; font-size:12px; overflow-x:auto; } .ln-add { background:#e6ffed; display:block; } diff --git a/src/vouch/web/static/dual_solve.js b/src/vouch/web/static/dual_solve.js index fc0e9b85..a7772013 100644 --- a/src/vouch/web/static/dual_solve.js +++ b/src/vouch/web/static/dual_solve.js @@ -36,7 +36,7 @@ export default { const job = reactive({ id: null, status: "idle", progress: [], candidates: [], issue: null, error: null, kept_branch: null, proposed_ids: [], - changed_files: [], + changed_files: [], recommendation: null, }); function applyState(s) { @@ -64,6 +64,7 @@ export default { async function run() { job.progress = []; job.error = null; job.candidates = []; job.kept_branch = null; job.proposed_ids = []; job.changed_files = []; + job.recommendation = null; const r = await fetch("/dual-solve/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -105,6 +106,12 @@ export default {

#{{job.issue.number}} {{job.issue.title}}

{{ job.progress.join('\\n') }}

{{ job.error }}

+

+ recommendation: + {{job.recommendation.engine}} + no automatic pick + -- {{job.recommendation.reason}} +

@@ -113,6 +120,10 @@ export default {
  • {{f}}
+
+ {{c.engine}} log +
{{c.log}}
+
{{f.path}}
{{l.text}}\\n
diff --git a/tests/test_dual_solve.py b/tests/test_dual_solve.py index 12d7584d..0f0979fe 100644 --- a/tests/test_dual_solve.py +++ b/tests/test_dual_solve.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import json from pathlib import Path import pytest @@ -56,6 +57,32 @@ def test_changed_files_extracts_paths_from_git_diff(): assert ds.changed_files(diff) == ["README.md", "src/new.py"] +def test_recommendation_prefers_smaller_successful_diff(): + claude = ds.Candidate( + "claude", "b-claude", Path("/w/claude"), + diff="diff --git a/a.txt b/a.txt\n+one\n", ok=True, + ) + codex = ds.Candidate( + "codex", "b-codex", Path("/w/codex"), + diff="diff --git a/a.txt b/a.txt\n+one\n+two\n", ok=True, + ) + + rec = ds.recommendation([codex, claude]) + + assert rec["engine"] == "claude" + assert "smaller scoped diff" in (rec["reason"] or "") + + +def test_recommendation_avoids_tiebreaking_equal_scope(): + claude = ds.Candidate("claude", "b1", Path("/a"), diff="d", ok=True) + codex = ds.Candidate("codex", "b2", Path("/b"), diff="x", ok=True) + + rec = ds.recommendation([claude, codex]) + + assert rec["engine"] is None + assert "equally scoped" in (rec["reason"] or "") + + def test_require_engines_raises_when_missing(monkeypatch): monkeypatch.setattr(ds.shutil, "which", lambda b: None) with pytest.raises(RuntimeError, match="not on PATH"): @@ -140,6 +167,7 @@ def test_run_candidate_success_commits_and_captures_sha(tmp_path): assert cand.engine == "claude" assert cand.branch == "vouch-dual/3-fix-bug-claude" assert cand.diff == "patch text" and cand.sha == "abc123" + assert cand.log == "done" assert any(c[:5] == ["git", "-C", str(root), "worktree", "add"] for c in fr.calls) assert any(c[:4] == ["git", "-C", str(wt), "commit"] for c in fr.calls) assert any(c and c[0] == "claude" for c in fr.calls) @@ -390,8 +418,16 @@ def test_cli_dual_solve_json_is_noninteractive(monkeypatch, tmp_path): from vouch.cli import cli issue = ds.Issue("t", "b", number=1) - cands = [ds.Candidate("claude", "b1", tmp_path / "a", diff="DA", ok=True), - ds.Candidate("codex", "b2", tmp_path / "b", diff="DB", ok=True)] + cands = [ + ds.Candidate( + "claude", "b1", tmp_path / "a", + diff="diff --git a/a b/a\n+1\n", log="claude log", ok=True, + ), + ds.Candidate( + "codex", "b2", tmp_path / "b", + diff="diff --git a/b b/b\n+1\n+2\n", log="codex log", ok=True, + ), + ] monkeypatch.setattr("vouch.dual_solve._require_engines", lambda: None) monkeypatch.setattr("vouch.dual_solve.repo_root", lambda r, c: tmp_path) monkeypatch.setattr("vouch.dual_solve.prepare", @@ -403,7 +439,10 @@ def test_cli_dual_solve_json_is_noninteractive(monkeypatch, tmp_path): r = CliRunner().invoke(cli, ["dual-solve", "o/n#1", "--json"]) assert r.exit_code == 0, r.output - assert '"engine"' in r.output and "DA" in r.output and "DB" in r.output + body = json.loads(r.output) + assert body["recommendation"]["engine"] == "claude" + assert body["candidates"][0]["log"] == "claude log" + assert body["candidates"][1]["log"] == "codex log" # --json must not prompt and must not finalize/record. assert finalize_called["n"] == 0 diff --git a/tests/test_web_dual_solve.py b/tests/test_web_dual_solve.py index 00a4bcd5..4ea528df 100644 --- a/tests/test_web_dual_solve.py +++ b/tests/test_web_dual_solve.py @@ -80,9 +80,11 @@ def __init__(self, *, repo_root, runner, image): def _fake_prepare(monkeypatch, *, calls): issue = ds.Issue("Fix bug", "body", number=4, url="u") cA = ds.Candidate("claude", "vouch-dual/4-fix-bug-claude", Path("/w/claude"), - diff="diff --git a/x b/x\n+1\n", sha="s1", ok=True) + diff="diff --git a/x b/x\n+1\n", sha="s1", + log="claude fixed it", ok=True) cX = ds.Candidate("codex", "vouch-dual/4-fix-bug-codex", Path("/w/codex"), - diff="diff --git a/y b/y\n+2\n", sha="s2", ok=True) + diff="diff --git a/y b/y\n+2\n+3\n", sha="s2", + log="codex fixed it too", ok=True) def fake(store, issue_ref, root, runner, *, claude_effort="high", codex_effort="high", autonomy="edit", dry_run=False, @@ -117,6 +119,9 @@ def test_run_starts_job_and_reaches_ready(git_kb, monkeypatch): assert [x["engine"] for x in state["candidates"]] == ["claude", "codex"] assert state["candidates"][0]["changed_files"] == ["x"] assert state["candidates"][1]["changed_files"] == ["y"] + assert state["candidates"][0]["log"] == "claude fixed it" + assert state["candidates"][1]["log"] == "codex fixed it too" + assert state["recommendation"]["engine"] == "claude" # autonomy is forced to edit regardless of input assert calls[0]["autonomy"] == "edit" @@ -200,6 +205,7 @@ def test_choose_winner_finalizes_and_returns_ids(git_kb, monkeypatch): assert r.json()["proposed_ids"] == ["prop-1", "prop-2"] assert r.json()["kept_branch"] == "vouch-dual/4-fix-bug-codex" assert r.json()["changed_files"] == ["y"] + assert r.json()["recommendation"]["engine"] == "claude" assert captured["winner"] == "codex" assert captured["record"] is True and captured["reason"] == "cleaner" From 0f9687e656bd713f83771b30ed3e31b4047685bb Mon Sep 17 00:00:00 2001 From: Yaroslav98214 Date: Sun, 21 Jun 2026 18:57:20 +0000 Subject: [PATCH 03/34] fix(sessions): make crystallize retry idempotent for summary pages Partial crystallize runs that already wrote session-{id} summary pages failed on retry with "page already exists". Upsert the summary page and derive its artifact list from all approved session proposals. Fixes #139 Co-authored-by: Cursor --- CHANGELOG.md | 2 ++ src/vouch/sessions.py | 29 +++++++++++++++++++++++------ tests/test_sessions.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75723dbd..a4da06b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,8 @@ All notable changes to vouch are documented here. Format follows - `vault_to_kb` skips filing a second proposal when a pending proposal already targets the same page id (with differing body), preventing duplicate proposals on repeated sync runs before approval (fixes #219). - `vault_to_kb` now warns when a user edits a claim stub instead of silently dropping the edit, directing the user to edit the citing page instead, and reports it via the dedicated `claim_stubs_edited` field on `VaultSyncResult` (fixes #219). - `approve()` now supports updating an existing page via `KBStore.update_page` when a PAGE proposal's id matches an existing artifact (the vault-edit flow), instead of raising `cannot approve: page already exists` for every vault edit (fixes #219). +- `crystallize()` now updates an existing session summary page on retry instead of + failing with `page session-... already exists` after a partial approval run (#139). ### Fixed - `vouch serve` now fails fast with a clear `vouch init` hint when no `.vouch/` KB is present, instead of starting a server that immediately misbehaves (#95). diff --git a/src/vouch/sessions.py b/src/vouch/sessions.py index 44e8b285..d243646b 100644 --- a/src/vouch/sessions.py +++ b/src/vouch/sessions.py @@ -14,9 +14,9 @@ from . import audit, index_db, volunteer_context from . import salience as salience_mod -from .models import Page, PageType, ProposalStatus, Session +from .models import Page, PageType, ProposalKind, ProposalStatus, Session from .proposals import approve -from .storage import KBStore +from .storage import ArtifactNotFoundError, KBStore logger = logging.getLogger(__name__) @@ -99,18 +99,24 @@ def crystallize( }) summary_page_id: str | None = None - if write_summary_page and approved_artifact_ids: + approved_for_session = _approved_artifact_ids_for_session(store, sess.id) + if write_summary_page and approved_for_session: page = Page( id=f"session-{sess.id}", title=f"Session {sess.id}", type=PageType.SESSION, - body=_build_summary_body(sess, approved_artifact_ids), + body=_build_summary_body(sess, approved_for_session), claims=[ - aid for aid in approved_artifact_ids + aid for aid in approved_for_session if (store.kb_dir / "claims" / f"{aid}.yaml").exists() ], ) - store.put_page(page) + try: + store.get_page(page.id) + except ArtifactNotFoundError: + store.put_page(page) + else: + store.update_page(page) with index_db.open_db(store.kb_dir) as conn: index_db.index_page( conn, id=page.id, title=page.title, body=page.body, @@ -155,3 +161,14 @@ def _build_summary_body(sess: Session, ids: list[str]) -> str: for aid in ids: lines.append(f"- `{aid}`") return "\n".join(lines) + + +def _approved_artifact_ids_for_session(store: KBStore, session_id: str) -> list[str]: + ids = { + str(pr.payload.get("id")) + for pr in store.list_proposals(ProposalStatus.APPROVED) + if pr.session_id == session_id and pr.kind in { + ProposalKind.CLAIM, ProposalKind.PAGE, ProposalKind.ENTITY, ProposalKind.RELATION, + } and pr.payload.get("id") + } + return sorted(ids) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 9e3cbfed..e87cadb1 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -170,3 +170,37 @@ def test_crystallize_audit_event_records_summary_page_id( assert cryst_events, "no session.crystallize audit event found" last = cryst_events[-1] assert page_id in last["object_ids"], last["object_ids"] + + +def test_crystallize_retry_updates_existing_summary_page(store: KBStore) -> None: + from unittest.mock import patch + + src = store.put_source(b"e") + sess = sess_mod.session_start(store, agent="a", task="retry") + propose_claim(store, text="first", evidence=[src.id], proposed_by="a", session_id=sess.id) + propose_claim(store, text="second", evidence=[src.id], proposed_by="a", session_id=sess.id) + sess_mod.session_end(store, sess.id) + + real_approve = approve + calls = {"n": 0} + + def flaky_approve(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 2: + raise ValueError("transient") + return real_approve(*args, **kwargs) + + with patch("vouch.sessions.approve", side_effect=flaky_approve): + first = sess_mod.crystallize(store, sess.id, approver="u") + + assert len(first["approved"]) == 1 + assert len(first["failures"]) == 1 + assert first["summary_page_id"] is not None + + second = sess_mod.crystallize(store, sess.id, approver="u") + assert len(second["approved"]) == 1 + assert second["failures"] == [] + assert second["summary_page_id"] == first["summary_page_id"] + + summary = store.get_page(second["summary_page_id"]) + assert sorted(summary.claims) == sorted([c.id for c in store.list_claims()]) From 536c4ce89f3147481fb5e5ec16eb1066c6c7cdf8 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Tue, 30 Jun 2026 19:19:26 -0700 Subject: [PATCH 04/34] fix(models): reject empty text/name/title on claim/entity/page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the non-empty contract for `Claim.text`, `Entity.name`, and `Page.title` lived only in the `propose_*` helpers, so `store.put_*`, `store.update_*`, and bundle/sync import (via `_validate_content`) all silently accepted empty or whitespace-only values and landed artifacts carrying zero of the field's semantic content. add `@field_validator`s on each field, mirroring the existing `Claim.evidence` min-citation validator (#81/#82): they reject blank input at construction time, so every write path — direct construction, put/update, and import — inherits the check at once. the validators gate on emptiness only and preserve surrounding whitespace of non-blank values. `Source.locator` is deliberately left out of scope — its format question is bigger and deserves its own issue, as the report notes. closes #155 --- CHANGELOG.md | 1 + src/vouch/models.py | 30 +++++++++++++++++++ tests/test_storage.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4d85de..eb9a94bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ All notable changes to vouch are documented here. Format follows KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes (#226). ### Fixed +- `Claim.text`, `Entity.name`, and `Page.title` now reject empty / whitespace-only values at the model layer via `@field_validator`s, mirroring the `Claim.evidence` min-citation validator (#81/#82). the non-empty contract previously lived only in the `propose_*` helpers, so `store.put_*`, `store.update_*`, and bundle/sync import all silently accepted blank-content artifacts; enforcing on the model closes every write path at once (fixes #155). - `parse_since` (the `--since` parser behind `vouch metrics`/`vouch audit`) now raises a clean `MetricsError` for a duration too large to represent (e.g. `--since 1000000000000d`), instead of letting an uncaught `OverflowError` traceback escape — restoring the documented "clean error, not a traceback" contract. - `sync_apply` now loads the sync source exactly once and passes the same `_SyncSource` instance into `sync_check`, closing a TOCTOU window where a bundle replaced on disk between the two `_load_source` calls could cause the validation and write phases to operate on different snapshots. Also eliminates redundant directory walks (KB sources) and triple tarball opens (bundle sources). Fixes #217. - `vault_to_kb` now passes `slug_hint=page_id` to `propose_page` so vault edit proposals target the existing page id from frontmatter instead of a slugified copy of the title (fixes #219). diff --git a/src/vouch/models.py b/src/vouch/models.py index 23f94aa4..d08ed8c0 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -231,6 +231,18 @@ def _at_least_one_citation(cls, v: list[str]) -> list[str]: "(README §'Object model'; CONTRIBUTING §'Things we won't merge')" ) return v + + @field_validator("text") + @classmethod + def _text_non_empty(cls, v: str) -> str: + # Same shape as _at_least_one_citation: the non-empty contract lived + # only in proposals.propose_claim, so store.put_claim, + # store.update_claim, and bundle.import_apply via _validate_content + # accepted text="" / whitespace and landed a claim carrying zero + # semantic content. Enforce on the model to close all paths at once. + if not v.strip(): + raise ValueError("claim text must not be empty") + return v entities: list[str] = Field(default_factory=list) supersedes: list[str] = Field(default_factory=list) superseded_by: str | None = None @@ -266,6 +278,15 @@ class Entity(BaseModel): created_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) + @field_validator("name") + @classmethod + def _name_non_empty(cls, v: str) -> str: + # See Claim._text_non_empty — the propose_entity check alone left + # store.put_entity and bundle import accepting name="" / whitespace. + if not v.strip(): + raise ValueError("entity name must not be empty") + return v + class Relation(BaseModel): """Typed edge between entities / claims / pages.""" @@ -314,6 +335,15 @@ def _normalize_type(cls, v: Any) -> str: raise ValueError("page type must be a non-empty string") return v.strip() + @field_validator("title") + @classmethod + def _title_non_empty(cls, v: str) -> str: + # See Claim._text_non_empty — the propose_page check alone left + # store.put_page and bundle import accepting title="" / whitespace. + if not v.strip(): + raise ValueError("page title must not be empty") + return v + # --- audit + sessions ----------------------------------------------------- diff --git a/tests/test_storage.py b/tests/test_storage.py index ce1d643f..7c3098ad 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -196,6 +196,47 @@ def test_update_claim_rejects_empty_evidence(store: KBStore) -> None: assert (store.kb_dir / "claims" / "c1.yaml").read_text() == persisted_before +@pytest.mark.parametrize("bad", ["", " ", "\n\t "]) +def test_claim_model_rejects_empty_text(store: KBStore, bad: str) -> None: + """Regression for #155: same shape as the #81 evidence validator, on the + text field. Empty / whitespace-only text is rejected at the model layer so + direct construction, store.put_claim, and bundle import all inherit it.""" + src = store.put_source(b"e") + with pytest.raises(ValidationError, match="text must not be empty"): + Claim(id="c1", text=bad, evidence=[src.id]) + + +def test_put_claim_rejects_empty_text(store: KBStore) -> None: + src = store.put_source(b"e") + with pytest.raises(ValidationError, match="text must not be empty"): + store.put_claim(Claim(id="c1", text=" ", evidence=[src.id])) + assert not (store.kb_dir / "claims" / "c1.yaml").exists() + + +def test_update_claim_rejects_empty_text(store: KBStore) -> None: + """A previously-populated claim cannot be mutated down to blank text and + silently re-persisted — update_claim re-validates via model_validate.""" + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="cited", evidence=[src.id])) + persisted_before = (store.kb_dir / "claims" / "c1.yaml").read_text() + + c = store.get_claim("c1") + c.text = " " + + with pytest.raises(ValidationError, match="text must not be empty"): + store.update_claim(c) + assert (store.kb_dir / "claims" / "c1.yaml").read_text() == persisted_before + + +def test_claim_text_preserves_surrounding_whitespace_when_non_blank( + store: KBStore, +) -> None: + """The validator gates on emptiness only — it must not strip content.""" + src = store.put_source(b"e") + c = Claim(id="c1", text=" padded text ", evidence=[src.id]) + assert c.text == " padded text " + + # --- pages ---------------------------------------------------------------- @@ -217,6 +258,19 @@ def test_page_with_frontmatter_round_trip(store: KBStore) -> None: assert back.type == PageType.CONCEPT +@pytest.mark.parametrize("bad", ["", " ", "\n"]) +def test_page_model_rejects_empty_title(bad: str) -> None: + """Regression for #155 — empty / whitespace titles rejected on the model.""" + with pytest.raises(ValidationError, match="title must not be empty"): + Page(id="p1", title=bad) + + +def test_put_page_rejects_empty_title(store: KBStore) -> None: + with pytest.raises(ValidationError, match="title must not be empty"): + store.put_page(Page(id="p1", title=" ")) + assert not (store.kb_dir / "pages" / "p1.md").exists() + + # --- entities + relations ------------------------------------------------- @@ -226,6 +280,19 @@ def test_entity_round_trip(store: KBStore) -> None: assert back.name == "Foo" +@pytest.mark.parametrize("bad", ["", " ", "\t\n"]) +def test_entity_model_rejects_empty_name(bad: str) -> None: + """Regression for #155 — empty / whitespace names rejected on the model.""" + with pytest.raises(ValidationError, match="name must not be empty"): + Entity(id="e1", name=bad, type=EntityType.CONCEPT) + + +def test_put_entity_rejects_empty_name(store: KBStore) -> None: + with pytest.raises(ValidationError, match="name must not be empty"): + store.put_entity(Entity(id="e1", name=" ", type=EntityType.CONCEPT)) + assert not (store.kb_dir / "entities" / "e1.yaml").exists() + + def test_relation_round_trip(store: KBStore) -> None: store.put_entity(Entity(id="a", name="A", type=EntityType.PROJECT)) store.put_entity(Entity(id="b", name="B", type=EntityType.PROJECT)) From 89815c0088771cf70d9d6406138768b011c78a74 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Tue, 30 Jun 2026 19:25:45 -0700 Subject: [PATCH 05/34] refactor(models): extract shared _require_non_empty validator helper address review on #300: the three #155 field validators (Claim.text / Entity.name / Page.title) were structurally identical strip-checks. extract a module-level `_require_non_empty(v, label)` helper they all delegate to, keeping the per-field error messages. also fix a mid-entry sentence casing in the CHANGELOG. no behaviour change. --- CHANGELOG.md | 2 +- src/vouch/models.py | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9a94bc..4cacf1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,7 +96,7 @@ All notable changes to vouch are documented here. Format follows KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes (#226). ### Fixed -- `Claim.text`, `Entity.name`, and `Page.title` now reject empty / whitespace-only values at the model layer via `@field_validator`s, mirroring the `Claim.evidence` min-citation validator (#81/#82). the non-empty contract previously lived only in the `propose_*` helpers, so `store.put_*`, `store.update_*`, and bundle/sync import all silently accepted blank-content artifacts; enforcing on the model closes every write path at once (fixes #155). +- `Claim.text`, `Entity.name`, and `Page.title` now reject empty / whitespace-only values at the model layer via `@field_validator`s, mirroring the `Claim.evidence` min-citation validator (#81/#82). The non-empty contract previously lived only in the `propose_*` helpers, so `store.put_*`, `store.update_*`, and bundle/sync import all silently accepted blank-content artifacts; enforcing on the model closes every write path at once (fixes #155). - `parse_since` (the `--since` parser behind `vouch metrics`/`vouch audit`) now raises a clean `MetricsError` for a duration too large to represent (e.g. `--since 1000000000000d`), instead of letting an uncaught `OverflowError` traceback escape — restoring the documented "clean error, not a traceback" contract. - `sync_apply` now loads the sync source exactly once and passes the same `_SyncSource` instance into `sync_check`, closing a TOCTOU window where a bundle replaced on disk between the two `_load_source` calls could cause the validation and write phases to operate on different snapshots. Also eliminates redundant directory walks (KB sources) and triple tarball opens (bundle sources). Fixes #217. - `vault_to_kb` now passes `slug_hint=page_id` to `propose_page` so vault edit proposals target the existing page id from frontmatter instead of a slugified copy of the title (fixes #219). diff --git a/src/vouch/models.py b/src/vouch/models.py index d08ed8c0..3855d6e4 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -82,6 +82,18 @@ def _coerce_artifact_scope(value: object) -> object: return value +def _require_non_empty(v: str, label: str) -> str: + """Reject empty / whitespace-only required text fields (#155). + + Shared by the ``Claim.text`` / ``Entity.name`` / ``Page.title`` + validators. Gates on emptiness only — non-blank values pass through + unchanged, so surrounding whitespace is preserved. + """ + if not v.strip(): + raise ValueError(f"{label} must not be empty") + return v + + class ArtifactScope(BaseModel): """Structured scope: visibility tier plus optional project/agent binding.""" @@ -240,9 +252,7 @@ def _text_non_empty(cls, v: str) -> str: # store.update_claim, and bundle.import_apply via _validate_content # accepted text="" / whitespace and landed a claim carrying zero # semantic content. Enforce on the model to close all paths at once. - if not v.strip(): - raise ValueError("claim text must not be empty") - return v + return _require_non_empty(v, "claim text") entities: list[str] = Field(default_factory=list) supersedes: list[str] = Field(default_factory=list) superseded_by: str | None = None @@ -283,9 +293,7 @@ class Entity(BaseModel): def _name_non_empty(cls, v: str) -> str: # See Claim._text_non_empty — the propose_entity check alone left # store.put_entity and bundle import accepting name="" / whitespace. - if not v.strip(): - raise ValueError("entity name must not be empty") - return v + return _require_non_empty(v, "entity name") class Relation(BaseModel): @@ -340,9 +348,7 @@ def _normalize_type(cls, v: Any) -> str: def _title_non_empty(cls, v: str) -> str: # See Claim._text_non_empty — the propose_page check alone left # store.put_page and bundle import accepting title="" / whitespace. - if not v.strip(): - raise ValueError("page title must not be empty") - return v + return _require_non_empty(v, "page title") # --- audit + sessions ----------------------------------------------------- From 96ea554bd18b0f8326a2dc7784545dcb26c76d73 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Sun, 5 Jul 2026 18:05:07 +0800 Subject: [PATCH 06/34] fix: resolve RPC traceback leakage on internal errors Unexpected exceptions in handle_request() included full stack traces in the JSON error envelope, exposing paths and internals to HTTP /rpc clients. Log server-side and return message only. Co-authored-by: Cursor --- src/vouch/jsonl_server.py | 6 ++++-- tests/test_jsonl_server.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5f42e16b..39ff620c 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -18,9 +18,9 @@ from __future__ import annotations import json +import logging import os import sys -import traceback from collections.abc import Callable from contextvars import ContextVar from pathlib import Path @@ -37,6 +37,8 @@ from .capabilities import capabilities as build_caps from .context import build_context_pack from .logging_config import configure_logging + +_log = logging.getLogger("vouch.jsonl_server") from .models import ProposalStatus from .proposals import ( EXPIRE_ACTOR, @@ -761,12 +763,12 @@ def handle_request(envelope: dict) -> dict: "error": {"code": "invalid_request", "message": str(e)}, } except Exception as e: + _log.exception("internal error handling %s", method) return { "id": req_id, "ok": False, "error": { "code": "internal_error", "message": str(e), - "traceback": traceback.format_exc(), }, } diff --git a/tests/test_jsonl_server.py b/tests/test_jsonl_server.py index 65627b76..dc1aa0a7 100644 --- a/tests/test_jsonl_server.py +++ b/tests/test_jsonl_server.py @@ -234,3 +234,17 @@ def test_jsonl_self_approval_allowed_with_trusted_agent_config( resp = handle_request({"id": "2", "method": "kb.approve", "params": {"proposal_id": pid}}) assert resp["ok"] + + +def test_jsonl_internal_error_omits_traceback(monkeypatch) -> None: + """HTTP /rpc clients must not receive server tracebacks on unexpected errors.""" + from vouch import jsonl_server + + def _boom(_params: dict) -> dict: + raise RuntimeError("secret internals") + + monkeypatch.setitem(jsonl_server.HANDLERS, "kb.status", _boom) + resp = handle_request({"id": "x", "method": "kb.status", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "internal_error" + assert "traceback" not in resp["error"] From 24c55c257a6a91f63153ae49c0c7c01b2451a84a Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Fri, 3 Jul 2026 01:15:51 -0500 Subject: [PATCH 07/34] =?UTF-8?q?feat(review):=20kb.triage=5Fpending=20?= =?UTF-8?q?=E2=80=94=20advisory=20triage=20scoring=20for=20the=20pending?= =?UTF-8?q?=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a long `kb.list_pending` forces the reviewer to reconstruct, per proposal, whether the claim fits the existing kb, whether its citations resolve, whether it duplicates something already filed, and whether it contradicts an approved claim. this adds an optional triage pass that scores each pending proposal on those four signals and attaches a `_meta.vouch_triage` block (recommendation/score/signals/rationale) to help a reviewer prioritize, without ever deciding anything itself. read-only by construction: the pass never calls proposals.approve/reject, store.put_*, or store.move_proposal_to_decided — a human still calls kb.approve/kb.reject. citation_quality reuses proposals._payload_block_reason; duplication_risk reuses the propose-time embedding similarity path (embeddings.similarity.find_similar_on_propose) and degrades to a difflib heuristic when the embeddings extra isn't installed. fit uses a separate, lower-threshold embedding search so a near-duplicate hit doesn't also inflate fit and cancel out its own duplication penalty. opt-in via `triage.enabled: true` in config.yaml (default false). registered at all four kb.* surface sites (server.py, jsonl_server.py, capabilities.py, cli.py) plus `vouch triage [proposal-id...]` with `--json` and `--reverse`. --- CHANGELOG.md | 13 + src/vouch/capabilities.py | 1 + src/vouch/cli.py | 46 ++++ src/vouch/jsonl_server.py | 7 + src/vouch/server.py | 17 ++ src/vouch/triage.py | 503 ++++++++++++++++++++++++++++++++++++++ tests/test_triage.py | 433 ++++++++++++++++++++++++++++++++ 7 files changed, 1020 insertions(+) create mode 100644 src/vouch/triage.py create mode 100644 tests/test_triage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 71809356..8987817e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `kb.triage_pending` — advisory triage scoring over the pending-review queue. + Scores each pending proposal on fit, citation quality, duplication risk, and + contradiction risk, then attaches a `_meta.vouch_triage` block + (`recommendation`, `score`, `signals`, `rationale`) to help a reviewer + prioritize a long `kb.list_pending`. Read-only and advisory only: it never + calls `kb.approve` / `kb.reject` and never moves a proposal out of pending — + a human still decides. Duplication and fit reuse the propose-time embedding + similarity path and degrade to a `difflib` heuristic when the `[embeddings]` + extra isn't installed. Opt-in via `triage.enabled: true` in `config.yaml`. + `vouch triage [proposal-id...]` mirrors it on the CLI with `--json` and + `--reverse` (#322). + ## [1.2.1] — 2026-07-06 ### Fixed diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 3fd21c75..90be307b 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -33,6 +33,7 @@ "kb.list_relations", "kb.list_sources", "kb.list_pending", + "kb.triage_pending", "kb.register_source", "kb.register_source_from_path", "kb.propose_claim", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a490fcf9..cd0a30ae 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -1030,6 +1030,52 @@ def list_relations() -> None: click.echo(output) +@cli.command() +@click.argument("proposal_ids", nargs=-1) +@click.option( + "--json", "as_json", is_flag=True, + help="Emit machine-readable _meta.vouch_triage blocks.", +) +@click.option( + "--reverse", is_flag=True, + help="Ascending order (worst-first) instead of the default descending (best-first).", +) +def triage(proposal_ids: tuple[str, ...], as_json: bool, reverse: bool) -> None: + """Advisory triage scoring over pending proposals (opt-in: triage.enabled). + + Scores each proposal on fit, citation quality, duplication risk, and + contradiction risk, then prints a ranked table. Never approves or + rejects — a human still decides via `vouch approve` / `vouch reject`. + """ + from . import triage as triage_mod + + store = _load_store() + with _cli_errors(): + results = triage_mod.triage_pending(store, proposal_ids=list(proposal_ids) or None) + results.sort(key=lambda r: r["_meta"]["vouch_triage"]["score"], reverse=not reverse) + + if as_json: + _emit_json(results) + return + if not results: + click.echo("no pending proposals to triage") + return + for r in results: + block = r["_meta"]["vouch_triage"] + preview = ( + r["payload"].get("text") + or r["payload"].get("title") + or r["payload"].get("name") + or r["payload"].get("id") + or "-" + ) + click.echo( + f"{block['score']:.2f} [{block['recommendation']:>11}] " + f"{r['id']} [{r['kind']}] {str(preview).strip()[:80]}" + ) + click.echo(f" {block['rationale']}") + + @cli.command() @click.argument("proposal_ids", nargs=-1, required=True) @click.option("--reason", default=None) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5e3b62b7..e7c48581 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -294,6 +294,12 @@ def _h_list_pending(_: dict) -> list[dict]: ] +def _h_triage_pending(p: dict) -> list[dict]: + from . import triage as triage_mod + + return triage_mod.triage_pending(_store(), proposal_ids=p.get("proposal_ids")) + + def _h_register_source(p: dict) -> dict: s = _store() src = s.put_source( @@ -729,6 +735,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.list_relations": _h_list_relations, "kb.list_sources": _h_list_sources, "kb.list_pending": _h_list_pending, + "kb.triage_pending": _h_triage_pending, "kb.register_source": _h_register_source, "kb.register_source_from_path": _h_register_source_from_path, "kb.propose_claim": _h_propose_claim, diff --git a/src/vouch/server.py b/src/vouch/server.py index 8cabee08..e79e5083 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -382,6 +382,23 @@ def kb_list_pending() -> list[dict[str, Any]]: ] +@mcp.tool() +def kb_triage_pending(proposal_ids: list[str] | None = None) -> list[dict[str, Any]]: + """Advisory triage scoring over the pending-review queue. + + Attaches `_meta.vouch_triage` (recommendation/score/signals/rationale) + to each pending proposal's view. Read-only — never approves, rejects, + or otherwise decides; a human still calls `kb_approve` / `kb_reject`. + Opt-in: disabled unless `triage.enabled: true` is set in config.yaml. + """ + from . import triage as triage_mod + + try: + return triage_mod.triage_pending(_store(), proposal_ids=proposal_ids) + except (ValueError, ArtifactNotFoundError) as e: + raise ValueError(str(e)) from e + + # === write tools — gated (produce proposals) ============================= diff --git a/src/vouch/triage.py b/src/vouch/triage.py new file mode 100644 index 00000000..31202e40 --- /dev/null +++ b/src/vouch/triage.py @@ -0,0 +1,503 @@ +"""Advisory triage scoring for the pending-review queue (issue #322). + +Read-only. Scores each pending proposal on four signals — fit, citation +quality, duplication risk, and contradiction risk — and folds them into a +composite ``score`` plus an advisory ``recommendation``. The result is +attached as ``_meta.vouch_triage`` on the proposal's own ``model_dump``. + +This never decides anything: no call here ever reaches +``proposals.approve``, ``proposals.reject``, ``store.put_*``, or +``store.move_proposal_to_decided``. A human still calls ``kb.approve`` / +``kb.reject``; ``recommendation`` is a hint the reviewer may ignore. + +Opt-in: disabled unless ``triage.enabled: true`` is set in +``.vouch/config.yaml`` (mirrors the defensive yaml-read pattern in +``salience.reflex_cfg`` / ``embeddings.similarity.similarity_threshold`` — +no pydantic Config model yet, see issue #243). + +Duplication risk reuses the embedding path already built for propose-time +warnings (``embeddings.similarity.find_similar_on_propose``); fit uses the +same underlying primitive (``index_db.search_embedding``) at a lower +threshold band so a near-duplicate hit doesn't also inflate fit and cancel +out its own duplication penalty (see ``_topical_fit_scores``). When no +embedder is registered (base install, no ``[embeddings]`` extra), both +signals fall back to a ``difflib`` text-similarity heuristic so the method +still returns a full block. +""" + +from __future__ import annotations + +import difflib +import re +from dataclasses import dataclass +from typing import Any + +import yaml + +from .models import Proposal, ProposalKind, ProposalStatus +from .proposals import _payload_block_reason +from .storage import ArtifactNotFoundError, KBStore + +DEFAULT_WEIGHTS: dict[str, float] = { + "fit": 0.3, + "citation_quality": 0.3, + "duplication_risk": 0.2, + "contradiction_risk": 0.2, +} + +_APPROVE_THRESHOLD = 0.7 +_REJECT_THRESHOLD = 0.35 +_FUZZY_MATCH_FLOOR = 0.3 +_CONTRADICTION_CANDIDATE_FLOOR = 0.35 + +_NEGATION_MARKERS = frozenset({ + "not", "no", "never", "cannot", "isnt", "doesnt", "wont", "wasnt", + "arent", "dont", "didnt", "hasnt", "havent", "without", "neither", "nor", +}) + + +class TriageError(ValueError): + """Raised when `kb.triage_pending` is invoked while disabled, or misused.""" + + +@dataclass(frozen=True) +class TriageConfig: + enabled: bool + backend: str + weights: dict[str, float] + + +def triage_cfg(store: KBStore) -> TriageConfig: + """Read `triage.*` from config.yaml defensively. Default: disabled.""" + cfg: dict[str, Any] = {} + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + cfg = loaded + except Exception: + pass + + triage = cfg.get("triage") + triage = triage if isinstance(triage, dict) else {} + + enabled = triage.get("enabled", False) + enabled = bool(enabled) if isinstance(enabled, bool) else False + + backend = triage.get("backend", "embeddings") + backend = backend if isinstance(backend, str) else "embeddings" + + weights = dict(DEFAULT_WEIGHTS) + weights_cfg = triage.get("weights") + if isinstance(weights_cfg, dict): + for key in weights: + value = weights_cfg.get(key) + if isinstance(value, int | float) and not isinstance(value, bool): + weights[key] = float(value) + + return TriageConfig(enabled=enabled, backend=backend, weights=weights) + + +# --- shared helpers --------------------------------------------------------- + + +def _referenced_entity_ids(proposal: Proposal) -> list[str]: + if proposal.kind in (ProposalKind.CLAIM, ProposalKind.PAGE): + return list(proposal.payload.get("entities") or []) + return [] + + +def _has_negation(text: str) -> bool: + tokens = set(re.findall(r"[a-z']+", text.casefold())) + tokens = {t.replace("'", "") for t in tokens} + return bool(tokens & _NEGATION_MARKERS) + + +def _safe_embedder() -> Any | None: + try: + from .embeddings import get_embedder + + return get_embedder() + except Exception: + return None + + +def _best_fuzzy_match( + text: str, pool: list[tuple[str, str]], +) -> tuple[str | None, float]: + needle = text.casefold() + best_id: str | None = None + best_ratio = 0.0 + for cid, candidate in pool: + candidate = (candidate or "").strip() + if not candidate: + continue + ratio = difflib.SequenceMatcher(None, needle, candidate.casefold()).ratio() + if ratio > best_ratio: + best_ratio, best_id = ratio, cid + if best_id is None or best_ratio < _FUZZY_MATCH_FLOOR: + return None, 0.0 + return best_id, best_ratio + + +def _claim_text_pool( + store: KBStore, *, exclude_proposal_id: str, exclude_claim_id: str | None, +) -> list[tuple[str, str]]: + pool = [ + (c.id, c.text) for c in store.list_claims() if c.id != exclude_claim_id + ] + pool += [ + (p.id, str(p.payload.get("text", ""))) + for p in store.list_proposals(ProposalStatus.PENDING) + if p.kind == ProposalKind.CLAIM and p.id != exclude_proposal_id + ] + return pool + + +def _embedding_hits_for_claim( + store: KBStore, proposal: Proposal, *, use_embeddings: bool, +) -> list[dict[str, Any]] | None: + """`find_similar_on_propose` hits, or None when the embedding path can't run. + + None means "no embedder available (or backend forced to heuristic)" — + callers fall back to a difflib heuristic. `[]` means the embedder ran + and genuinely found nothing similar. Every hit returned is, by that + function's own contract, at or above the near-duplicate threshold — + it's a duplicate detector, not a general similarity search. Used by + `duplication_risk` and `contradiction_risk`; deliberately NOT reused + for `fit` (see `_topical_fit_scores`). + """ + if not use_embeddings or proposal.kind != ProposalKind.CLAIM: + return None + text = str(proposal.payload.get("text", "")).strip() + if not text or _safe_embedder() is None: + return None + try: + from .embeddings.similarity import find_similar_on_propose + except ImportError: + return None + return find_similar_on_propose( + store, text, exclude_claim_id=proposal.payload.get("id"), + ) + + +def _topical_fit_scores(store: KBStore, proposal: Proposal, embedder: Any) -> list[float]: + """Cosine scores against the approved corpus, below the duplicate band. + + A near-duplicate hit is already penalized by `duplication_risk`; letting + it also inflate `fit` would let the two signals cancel each other out + for the exact-duplicate case. So this looks at a lower, wider band + (`min_score=0.3`) and excludes anything at or above the near-duplicate + threshold (`review.similarity_threshold`, default 0.95). + """ + text = str(proposal.payload.get("text", "")).strip() + if not text: + return [] + try: + from . import index_db + from .embeddings.similarity import similarity_threshold + + vec = embedder.encode(text) + dup_threshold = similarity_threshold(store) + hits = index_db.search_embedding( + store.kb_dir, query_vec=vec, kinds=("claim", "page"), limit=5, min_score=0.3, + ) + except Exception: + return [] + exclude_id = proposal.payload.get("id") + return [ + float(cos) for _kind, cid, _snip, cos in hits + if cid != exclude_id and cos < dup_threshold + ] + + +# --- signals ----------------------------------------------------------------- + + +def _signal_citation_quality(store: KBStore, proposal: Proposal) -> dict[str, Any]: + block = _payload_block_reason(store, proposal) + if block: + return {"score": 0.0, "reason": block} + if proposal.kind in (ProposalKind.CLAIM, ProposalKind.RELATION): + n = len(proposal.payload.get("evidence") or []) + if n == 0: + # Relations may legitimately have no evidence; claims can't reach + # here with n == 0 (Claim._at_least_one_citation already blocked). + return { + "score": 0.6, + "reason": "relation has no evidence citation (allowed, but weaker)", + } + score = min(1.0, 0.7 + 0.15 * (n - 1)) + return {"score": round(score, 4), "reason": f"{n} evidence citation(s) resolve cleanly"} + if proposal.kind == ProposalKind.PAGE: + sources = proposal.payload.get("sources") or [] + claims = proposal.payload.get("claims") or [] + if not sources and not claims: + return {"score": 0.5, "reason": "page has no source/claim citations"} + return { + "score": 1.0, + "reason": f"{len(sources)} source(s), {len(claims)} claim(s) resolve cleanly", + } + return {"score": 1.0, "reason": "entity payload resolves cleanly"} + + +def _signal_fit( + store: KBStore, proposal: Proposal, embedder: Any | None, +) -> dict[str, Any]: + entity_ids = _referenced_entity_ids(proposal) + known = {e.id for e in store.list_entities()} + overlap: float | None = None + if entity_ids: + overlap = sum(1 for e in entity_ids if e in known) / len(entity_ids) + + topical: float | None = None + if embedder is not None and proposal.kind == ProposalKind.CLAIM: + scores = _topical_fit_scores(store, proposal, embedder) + if scores: + topical = sum(scores) / len(scores) + + parts = [v for v in (overlap, topical) if v is not None] + if not parts: + return { + "score": 0.5, + "reason": "no referenced entities or approved-corpus signal; neutral fit", + } + bits = [] + if overlap is not None: + bits.append(f"{overlap:.0%} of referenced entities already known") + if topical is not None: + bits.append(f"mean topical similarity to approved corpus {topical:.2f}") + return {"score": round(sum(parts) / len(parts), 4), "reason": "; ".join(bits)} + + +def _duplication_risk_structural(store: KBStore, proposal: Proposal) -> dict[str, Any]: + if proposal.kind == ProposalKind.RELATION: + triple = ( + proposal.payload.get("source"), + proposal.payload.get("relation"), + proposal.payload.get("target"), + ) + for r in store.list_relations(): + if (r.source, r.relation.value, r.target) == triple: + return {"score": 1.0, "reason": f"identical relation already approved: {r.id}"} + for p in store.list_proposals(ProposalStatus.PENDING): + if p.kind != ProposalKind.RELATION or p.id == proposal.id: + continue + other = (p.payload.get("source"), p.payload.get("relation"), p.payload.get("target")) + if other == triple: + return {"score": 1.0, "reason": f"identical relation already pending: {p.id}"} + return {"score": 0.0, "reason": "no identical relation found"} + + if proposal.kind == ProposalKind.ENTITY: + name = str(proposal.payload.get("name", "")).strip() + pool = [(e.id, e.name) for e in store.list_entities()] + pool += [ + (p.id, str(p.payload.get("name", ""))) + for p in store.list_proposals(ProposalStatus.PENDING) + if p.kind == ProposalKind.ENTITY and p.id != proposal.id + ] + else: # PAGE + name = str(proposal.payload.get("title", "")).strip() + pool = [(pg.id, pg.title) for pg in store.list_pages()] + pool += [ + (p.id, str(p.payload.get("title", ""))) + for p in store.list_proposals(ProposalStatus.PENDING) + if p.kind == ProposalKind.PAGE and p.id != proposal.id + ] + + if not name: + return {"score": 0.0, "reason": "no name/title to compare"} + best_id, best_ratio = _best_fuzzy_match(name, pool) + if best_id is None: + return {"score": 0.0, "reason": "no similarly-named artifact found (heuristic backend)"} + return { + "score": round(best_ratio, 4), + "reason": f"name similarity {best_ratio:.2f} vs {best_id} (heuristic backend)", + } + + +def _signal_duplication_risk( + store: KBStore, proposal: Proposal, hits: list[dict[str, Any]] | None, +) -> dict[str, Any]: + if proposal.kind != ProposalKind.CLAIM: + return _duplication_risk_structural(store, proposal) + + text = str(proposal.payload.get("text", "")).strip() + if not text: + return {"score": 0.0, "reason": "no claim text to compare"} + + if hits is None: + pool = _claim_text_pool( + store, exclude_proposal_id=proposal.id, + exclude_claim_id=proposal.payload.get("id"), + ) + best_id, best_ratio = _best_fuzzy_match(text, pool) + if best_id is None: + return {"score": 0.0, "reason": "no near-duplicate claims found (heuristic backend)"} + return { + "score": round(best_ratio, 4), + "reason": f"text similarity {best_ratio:.2f} vs {best_id} (heuristic backend)", + } + + if not hits: + return {"score": 0.0, "reason": "no near-duplicate claims found (embedding backend)"} + top = max(hits, key=lambda w: w["cosine"]) + return { + "score": round(float(top["cosine"]), 4), + "reason": ( + f"cosine {top['cosine']:.2f} vs {top['artifact_kind']} " + f"{top['artifact_id']} (embedding backend)" + ), + } + + +def _signal_contradiction_risk( + store: KBStore, proposal: Proposal, hits: list[dict[str, Any]] | None, +) -> dict[str, Any]: + if proposal.kind != ProposalKind.CLAIM: + return {"score": 0.0, "reason": "contradiction risk is only assessed for claim proposals"} + + text = str(proposal.payload.get("text", "")).strip() + if not text: + return {"score": 0.0, "reason": "no claim text to compare"} + + entity_ids = set(proposal.payload.get("entities") or []) + neg = _has_negation(text) + + if hits is not None: + backend = "embedding" + candidates = [ + (h["artifact_id"], float(h["cosine"])) + for h in hits + if h.get("artifact_kind") == "claim" + ] + else: + backend = "heuristic" + pool = _claim_text_pool( + store, exclude_proposal_id=proposal.id, + exclude_claim_id=proposal.payload.get("id"), + ) + candidates = [ + (cid, ratio) + for cid, ratio in ( + (cid, difflib.SequenceMatcher(None, text.casefold(), ctext.casefold()).ratio()) + for cid, ctext in pool + ) + if ratio >= _CONTRADICTION_CANDIDATE_FLOOR + ] + + if not candidates: + return {"score": 0.0, "reason": f"no topically related claims found ({backend} backend)"} + + conflicts: list[tuple[str, float]] = [] + for cid, sim in candidates: + try: + claim = store.get_claim(cid) + except ArtifactNotFoundError: + continue # candidate is a pending proposal, not yet an approved claim + if entity_ids & set(claim.entities) and _has_negation(claim.text) != neg: + conflicts.append((cid, sim)) + + if not conflicts: + return { + "score": 0.0, + "reason": ( + f"{len(candidates)} related claim(s), " + f"no polarity conflict ({backend} backend)" + ), + } + top_id, top_sim = max(conflicts, key=lambda c: c[1]) + score = round(min(1.0, 0.5 + top_sim / 2), 4) + return { + "score": score, + "reason": ( + f"possible polarity conflict with {top_id} " + f"(similarity {top_sim:.2f}, {backend} backend)" + ), + } + + +# --- composite --------------------------------------------------------------- + + +def _composite_score(signals: dict[str, dict[str, Any]], weights: dict[str, float]) -> float: + goodness = { + "fit": signals["fit"]["score"], + "citation_quality": signals["citation_quality"]["score"], + "duplication_risk": 1.0 - signals["duplication_risk"]["score"], + "contradiction_risk": 1.0 - signals["contradiction_risk"]["score"], + } + total_weight = sum(weights.get(k, 0.0) for k in goodness) or 1.0 + raw = sum(goodness[k] * weights.get(k, 0.0) for k in goodness) / total_weight + return round(min(1.0, max(0.0, raw)), 4) + + +def _recommendation(score: float, signals: dict[str, dict[str, Any]]) -> str: + if signals["citation_quality"]["score"] == 0.0: + # A blocked payload can't be approved as-is (approve() would raise) — + # no composite score should be able to override that. + return "reject" + if score >= _APPROVE_THRESHOLD: + return "approve" + if score <= _REJECT_THRESHOLD: + return "reject" + return "needs-human" + + +def _rationale(recommendation: str, score: float, signals: dict[str, dict[str, Any]]) -> str: + parts = "; ".join(f"{name}: {sig['reason']}" for name, sig in signals.items()) + return f"{recommendation} (score {score:.2f}) — {parts}" + + +def score_proposal( + store: KBStore, proposal: Proposal, *, + weights: dict[str, float] | None = None, + use_embeddings: bool = True, +) -> dict[str, Any]: + """Compute the `_meta.vouch_triage` block for one pending proposal.""" + weights = weights or DEFAULT_WEIGHTS + hits = _embedding_hits_for_claim(store, proposal, use_embeddings=use_embeddings) + embedder = _safe_embedder() if use_embeddings else None + signals = { + "fit": _signal_fit(store, proposal, embedder), + "citation_quality": _signal_citation_quality(store, proposal), + "duplication_risk": _signal_duplication_risk(store, proposal, hits), + "contradiction_risk": _signal_contradiction_risk(store, proposal, hits), + } + score = _composite_score(signals, weights) + recommendation = _recommendation(score, signals) + return { + "recommendation": recommendation, + "score": score, + "signals": signals, + "rationale": _rationale(recommendation, score, signals), + } + + +def triage_pending( + store: KBStore, proposal_ids: list[str] | None = None, +) -> list[dict[str, Any]]: + """Score pending proposals (default: all of them) — read-only, advisory. + + Raises TriageError if `triage.enabled` isn't `true` in config.yaml. + """ + cfg = triage_cfg(store) + if not cfg.enabled: + raise TriageError( + "triage is disabled; set triage.enabled: true in .vouch/config.yaml to opt in" + ) + use_embeddings = cfg.backend != "heuristic" + + if proposal_ids: + proposals = [store.get_proposal(pid) for pid in proposal_ids] + proposals = [p for p in proposals if p.status == ProposalStatus.PENDING] + else: + proposals = store.list_proposals(ProposalStatus.PENDING) + + out: list[dict[str, Any]] = [] + for p in proposals: + result = p.model_dump(mode="json") + result.setdefault("_meta", {})["vouch_triage"] = score_proposal( + store, p, weights=cfg.weights, use_embeddings=use_embeddings, + ) + out.append(result) + return out diff --git a/tests/test_triage.py b/tests/test_triage.py new file mode 100644 index 00000000..ed46b8c9 --- /dev/null +++ b/tests/test_triage.py @@ -0,0 +1,433 @@ +"""Advisory triage scoring over the pending-review queue — issue #322.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from vouch import triage +from vouch.cli import cli +from vouch.jsonl_server import HANDLERS, handle_request +from vouch.models import Claim, Entity, EntityType, Proposal, ProposalKind, ProposalStatus +from vouch.proposals import propose_claim, propose_entity +from vouch.storage import KBStore + +SIGNAL_NAMES = {"fit", "citation_quality", "duplication_risk", "contradiction_risk"} + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _enable_triage(store: KBStore, **overrides: object) -> None: + cfg = {"triage": {"enabled": True, **overrides}} + store.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + +def _no_embedder(monkeypatch: pytest.MonkeyPatch) -> None: + def _raise(name: str | None = None) -> None: + raise KeyError("no embedder registered") + + monkeypatch.setattr("vouch.embeddings.get_embedder", _raise) + + +def _assert_block_shape(block: dict) -> None: + assert set(block) == {"recommendation", "score", "signals", "rationale"} + assert block["recommendation"] in {"approve", "reject", "needs-human"} + assert 0.0 <= block["score"] <= 1.0 + assert set(block["signals"]) == SIGNAL_NAMES + for sig in block["signals"].values(): + assert 0.0 <= sig["score"] <= 1.0 + assert isinstance(sig["reason"], str) and sig["reason"] + assert isinstance(block["rationale"], str) and block["rationale"] + + +# --- opt-in gate ------------------------------------------------------------- + + +def test_disabled_by_default_raises(store: KBStore) -> None: + with pytest.raises(triage.TriageError, match="disabled"): + triage.triage_pending(store) + + +def test_enabled_scores_pending_proposals(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="vouch requires citations", evidence=[src.id], proposed_by="agent") + results = triage.triage_pending(store) + assert len(results) == 1 + + +# --- output shape -------------------------------------------------------------- + + +def test_triage_block_shape(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="vouch requires citations", evidence=[src.id], proposed_by="agent") + [result] = triage.triage_pending(store) + assert result["kind"] == "claim" + _assert_block_shape(result["_meta"]["vouch_triage"]) + + +# --- no-write invariant --------------------------------------------------------- + + +def test_never_mutates_pending_queue(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + p1 = propose_claim(store, text="a claim", evidence=[src.id], proposed_by="agent").id + p2 = propose_entity(store, name="widget", entity_type="concept", proposed_by="agent").id + + before = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + triage.triage_pending(store) + after = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} + + assert before == after == {p1, p2} + assert store.list_proposals(ProposalStatus.APPROVED) == [] + assert store.list_proposals(ProposalStatus.REJECTED) == [] + assert store.list_claims() == [] + assert store.list_entities() == [] + + +# --- citation_quality: reuses proposals._payload_block_reason ----------------- + + +def test_citation_quality_flags_dangling_ref_and_forces_reject( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + # Bypass propose_claim's own ref validation (store.put_proposal is raw + # I/O) to simulate a dangling reference slipping into the queue — + # the same shape proposals._payload_block_reason guards at approve time. + bad = Proposal( + id="bad-1", kind=ProposalKind.CLAIM, proposed_by="agent", + payload={ + "id": "c-bad", "text": "x", "type": "observation", "confidence": 0.7, + "evidence": ["missing-source"], "entities": [], "tags": [], + }, + ) + store.put_proposal(bad) + [result] = triage.triage_pending(store) + block = result["_meta"]["vouch_triage"] + assert block["signals"]["citation_quality"]["score"] == 0.0 + assert "missing-source" in block["signals"]["citation_quality"]["reason"] + assert block["recommendation"] == "reject" + + +def test_citation_quality_scores_clean_claim_positively( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="a well cited claim", evidence=[src.id], proposed_by="agent") + [result] = triage.triage_pending(store) + assert result["_meta"]["vouch_triage"]["signals"]["citation_quality"]["score"] > 0.0 + + +# --- duplication_risk: heuristic fallback (default in this dev env) ---------- + + +def test_duplication_risk_heuristic_fallback_flags_near_duplicate( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + text = "auth uses jwts in the authorization header for every request" + store.put_claim(Claim(id="c1", text=text, evidence=[src.id])) + propose_claim(store, text=text, evidence=[src.id], proposed_by="agent") + [result] = triage.triage_pending(store) + dup = result["_meta"]["vouch_triage"]["signals"]["duplication_risk"] + assert dup["score"] > 0.9 + assert "heuristic backend" in dup["reason"] + assert "c1" in dup["reason"] + + +def test_duplication_risk_heuristic_no_match_for_unrelated_text( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + store.put_claim(Claim(id="c1", text="apples and oranges", evidence=[src.id])) + propose_claim( + store, text="zebras run fast in the savanna", evidence=[src.id], proposed_by="agent", + ) + [result] = triage.triage_pending(store) + dup = result["_meta"]["vouch_triage"]["signals"]["duplication_risk"] + assert dup["score"] == 0.0 + assert "heuristic backend" in dup["reason"] + + +def test_duplication_risk_relation_exact_match( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + from vouch.models import Relation + + store.put_entity(Entity(id="a", name="A", type=EntityType.CONCEPT)) + store.put_entity(Entity(id="b", name="B", type=EntityType.CONCEPT)) + store.put_relation( + Relation(id="a--relates_to--b", source="a", relation="relates_to", target="b") + ) + dup_proposal = Proposal( + id="rel-1", kind=ProposalKind.RELATION, proposed_by="agent", + payload={ + "id": "a--relates_to--b-2", "source": "a", "relation": "relates_to", + "target": "b", "confidence": 0.7, "evidence": [], + }, + ) + store.put_proposal(dup_proposal) + [result] = triage.triage_pending(store) + dup = result["_meta"]["vouch_triage"]["signals"]["duplication_risk"] + assert dup["score"] == 1.0 + assert "already approved" in dup["reason"] + + +# --- fit: entity-overlap heuristic (no embeddings needed) --------------------- + + +def test_fit_scores_high_when_entities_already_known( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + store.put_entity(Entity(id="jwt", name="JWT", type=EntityType.CONCEPT)) + src = store.put_source(b"evidence") + propose_claim( + store, text="jwt tokens expire after an hour", evidence=[src.id], + entities=["jwt"], proposed_by="agent", + ) + [result] = triage.triage_pending(store) + fit = result["_meta"]["vouch_triage"]["signals"]["fit"] + assert fit["score"] == 1.0 + + +def test_fit_neutral_when_no_entities_referenced( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="an unrelated observation", evidence=[src.id], proposed_by="agent") + [result] = triage.triage_pending(store) + fit = result["_meta"]["vouch_triage"]["signals"]["fit"] + assert fit["score"] == 0.5 + + +# --- contradiction_risk -------------------------------------------------------- + + +def test_contradiction_risk_flags_polarity_conflict( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + store.put_entity(Entity(id="api", name="API", type=EntityType.CONCEPT)) + src = store.put_source(b"evidence") + store.put_claim(Claim( + id="c1", text="the api requires an auth token for every request", + evidence=[src.id], entities=["api"], + )) + propose_claim( + store, text="the api does not require an auth token for every request", + evidence=[src.id], entities=["api"], proposed_by="agent", + ) + [result] = triage.triage_pending(store) + conflict = result["_meta"]["vouch_triage"]["signals"]["contradiction_risk"] + assert conflict["score"] > 0.0 + assert "c1" in conflict["reason"] + + +def test_contradiction_risk_no_conflict_without_shared_entity( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + store.put_claim(Claim( + id="c1", text="the api requires an auth token for every request", + evidence=[src.id], + )) + propose_claim( + store, text="the api does not require an auth token for every request", + evidence=[src.id], proposed_by="agent", + ) + [result] = triage.triage_pending(store) + conflict = result["_meta"]["vouch_triage"]["signals"]["contradiction_risk"] + assert conflict["score"] == 0.0 + + +def test_contradiction_risk_not_applicable_to_non_claim_kind( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + propose_entity(store, name="widget", entity_type="concept", proposed_by="agent") + [result] = triage.triage_pending(store) + conflict = result["_meta"]["vouch_triage"]["signals"]["contradiction_risk"] + assert conflict["score"] == 0.0 + assert "only assessed for claim proposals" in conflict["reason"] + + +# --- embeddings-present path (requires numpy; skipped without it) ------------ + + +@pytest.fixture +def _mock_embedder() -> None: + pytest.importorskip("numpy") + from tests.embeddings._fakes import MockEmbedder + from vouch.embeddings import register + from vouch.embeddings.base import DEFAULT_MODEL_NAME + + register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)) + + +def test_duplication_risk_embedding_backend_flags_exact_duplicate( + store: KBStore, _mock_embedder: None, +) -> None: + _enable_triage(store) + src = store.put_source(b"evidence") + text = "auth uses jwts in the authorization header" + store.put_claim(Claim(id="c1", text=text, evidence=[src.id])) + propose_claim(store, text=text, evidence=[src.id], proposed_by="agent") + [result] = triage.triage_pending(store) + block = result["_meta"]["vouch_triage"] + dup = block["signals"]["duplication_risk"] + assert dup["score"] >= 0.95 + assert "embedding backend" in dup["reason"] + # A near-duplicate hit is penalized by duplication_risk and must not + # also inflate fit via the same signal (see _topical_fit_scores). + assert block["recommendation"] != "approve" + + +def test_backend_heuristic_config_forces_fallback_even_with_embedder( + store: KBStore, _mock_embedder: None, +) -> None: + _enable_triage(store, backend="heuristic") + src = store.put_source(b"evidence") + text = "auth uses jwts in the authorization header" + store.put_claim(Claim(id="c1", text=text, evidence=[src.id])) + propose_claim(store, text=text, evidence=[src.id], proposed_by="agent") + [result] = triage.triage_pending(store) + dup = result["_meta"]["vouch_triage"]["signals"]["duplication_risk"] + assert "heuristic backend" in dup["reason"] + + +# --- proposal_ids filter / config plumbing ------------------------------------ + + +def test_proposal_ids_filters_to_subset(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + p1 = propose_claim(store, text="first claim", evidence=[src.id], proposed_by="agent").id + propose_claim(store, text="second claim", evidence=[src.id], proposed_by="agent") + results = triage.triage_pending(store, proposal_ids=[p1]) + assert [r["id"] for r in results] == [p1] + + +def test_custom_weights_read_from_config(store: KBStore) -> None: + custom_weights = { + "fit": 1.0, "citation_quality": 0.0, "duplication_risk": 0.0, "contradiction_risk": 0.0, + } + _enable_triage(store, weights=custom_weights) + cfg = triage.triage_cfg(store) + assert cfg.weights == custom_weights + + +def test_disabled_config_value_keeps_default_false(store: KBStore) -> None: + raw = yaml.safe_dump({"triage": {"weights": {"fit": 0.9}}}) + store.config_path.write_text(raw, encoding="utf-8") + cfg = triage.triage_cfg(store) + assert cfg.enabled is False + + +# --- registration sites -------------------------------------------------------- + + +def test_jsonl_handler_registered() -> None: + assert "kb.triage_pending" in HANDLERS + + +def test_jsonl_triage_pending_disabled_returns_invalid_request( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + import vouch.jsonl_server as jsonl_server + + monkeypatch.setattr(jsonl_server, "_store", lambda: store) + resp = handle_request({"id": "1", "method": "kb.triage_pending", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request" + + +def test_jsonl_triage_pending_enabled_returns_blocks( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + import vouch.jsonl_server as jsonl_server + + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="a claim", evidence=[src.id], proposed_by="agent") + monkeypatch.setattr(jsonl_server, "_store", lambda: store) + resp = handle_request({"id": "1", "method": "kb.triage_pending", "params": {}}) + assert resp["ok"] is True + [item] = resp["result"] + _assert_block_shape(item["_meta"]["vouch_triage"]) + + +def test_cli_triage_disabled_shows_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + result = CliRunner().invoke(cli, ["triage"]) + assert result.exit_code != 0 + assert "Traceback" not in result.output + assert "Error:" in result.output + assert "disabled" in result.output + + +def test_cli_triage_json_output(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="a claim", evidence=[src.id], proposed_by="agent") + monkeypatch.chdir(store.root) + result = CliRunner().invoke(cli, ["triage", "--json"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + _assert_block_shape(data[0]["_meta"]["vouch_triage"]) + + +def test_cli_triage_sorts_ranked_table(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _no_embedder(monkeypatch) + _enable_triage(store) + src = store.put_source(b"evidence") + propose_claim(store, text="a well cited unique claim", evidence=[src.id], proposed_by="agent") + bad = Proposal( + id="bad-1", kind=ProposalKind.CLAIM, proposed_by="agent", + payload={ + "id": "c-bad", "text": "y", "type": "observation", "confidence": 0.7, + "evidence": ["missing-source"], "entities": [], "tags": [], + }, + ) + store.put_proposal(bad) + monkeypatch.chdir(store.root) + result = CliRunner().invoke(cli, ["triage"]) + assert result.exit_code == 0, result.output + lines = [ln for ln in result.output.splitlines() if ln and ln[0].isdigit()] + scores = [float(ln.split()[0]) for ln in lines] + assert scores == sorted(scores, reverse=True) From f2b554c5db9e7254dffddbdd343f821875d18d78 Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Thu, 2 Jul 2026 23:39:25 -0500 Subject: [PATCH 08/34] feat(diff): register kb.diff at all four kb.* surface sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vouch diff shipped CLI-only in 0.1.0, an explicit non-goal at the time ("MCP/JSONL parity ... kb.* surface unchanged"). that leaves it the only read method skipping the four-site registration convention documented in CLAUDE.md, so agents talking MCP/JSONL have no way to fetch a revision diff. adds the kb_diff MCP tool, the kb.diff JSONL handler, and the capabilities.METHODS entry, next to the other by-id read tools (kb_read_claim/kb_read_page) with the same unrestricted-read posture. also makes new_id optional for a superseded claim: diff_artifacts and the CLI both resolve it from superseded_by when omitted, erroring clearly when there's no successor (pages still require an explicit new_id — they have no successor pointer). closes #327. --- CHANGELOG.md | 8 ++ .../specs/2026-05-25-vouch-diff-design.md | 42 +++++++- src/vouch/capabilities.py | 1 + src/vouch/cli.py | 10 +- src/vouch/diff.py | 21 +++- src/vouch/jsonl_server.py | 9 ++ src/vouch/server.py | 12 +++ tests/test_diff.py | 98 +++++++++++++++++++ 8 files changed, 191 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71809356..2f70d287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,14 @@ All notable changes to vouch are documented here. Format follows ## [1.1.0] — 2026-07-03 ### Added +- `kb.diff` — `vouch diff ` (0.1.0) now has full `kb.*` + parity: an MCP tool and a JSONL `kb.diff` handler alongside the existing + CLI, registered in `capabilities.METHODS` like every other read method. + `` is now optional for a claim: omitting it resolves the diff + against `superseded_by`, with a clear error when the claim has no + successor (pages still require an explicit `new_id` — they have no + successor pointer). Read-only throughout; still no writes, proposals, or + audit events (#327). - auto-capture: claude code sessions are harvested via hooks and filed as a single pending session-summary proposal for human approval. a `PostToolUse` hook (`vouch capture observe`) appends compact tool-use observations to an diff --git a/docs/superpowers/specs/2026-05-25-vouch-diff-design.md b/docs/superpowers/specs/2026-05-25-vouch-diff-design.md index f5bb4ea0..5bd1f862 100644 --- a/docs/superpowers/specs/2026-05-25-vouch-diff-design.md +++ b/docs/superpowers/specs/2026-05-25-vouch-diff-design.md @@ -23,7 +23,14 @@ field. Read-only: no writes, no proposals, no audit events. `last_confirmed_at`, `approved_by`). - **Line-diff the long text.** `claim.text` / `page.body` render as a `difflib` unified diff; everything else as `field: old → new`. -- **CLI-only.** Read-only inspection; does not touch the `kb.*` capability set. +- **Full `kb.*` parity.** Registered as `kb.diff` at all four sites (MCP tool, + JSONL handler, `capabilities.METHODS`, CLI) like any other read method — + see "MCP/JSONL parity" under Non-goals below for the superseded original + call on this. +- **Omitted `new_id` resolves via `superseded_by`.** For a claim, `new_id` is + optional; when omitted it resolves to `old_claim.superseded_by`, erroring + clearly if that's unset. Pages have no successor pointer, so `new_id` is + required for a page. ## Components — `src/vouch/diff.py` @@ -37,7 +44,10 @@ Raised for unknown ids and mismatched kinds. `kind: str, old_id: str, new_id: str, changes: list[FieldChange], text_diff: list[str]`. -### `diff_artifacts(store, old_id, new_id) -> ArtifactDiff` +### `diff_artifacts(store, old_id, new_id=None) -> ArtifactDiff` +- **`new_id` resolution:** if omitted, `old_id` must resolve to a claim with + `superseded_by` set — that becomes `new_id`. A page, or a claim without a + successor, raises `DiffError` naming the id. - **Kind resolution:** try `store.get_claim` on both ids → both succeed ⇒ `kind="claim"`. Otherwise try `store.get_page` on both → `kind="page"`. If an id resolves to neither, raise `DiffError("unknown artifact: ")`. If one is @@ -55,7 +65,7 @@ Field sets (long text field rendered as `text_diff`, the rest as changes): - **Page** — body *(diff)*; title, type, status, claims, entities, sources, tags. -## CLI — `vouch diff OLD NEW [--json]` +## CLI — `vouch diff OLD [NEW] [--json]` Follows existing patterns (`_load_store`, `_cli_errors`, `_emit_json`). @@ -74,6 +84,20 @@ diff claim - `--json` → `_emit_json` of the `ArtifactDiff` as a dict. - No differences → prints `no differences`. +## `kb.diff` — MCP + JSONL + +Same read as the CLI, exposed for agents: + +- **MCP** `kb_diff(old_id, new_id=None) -> dict` in `server.py`, next to + `kb_read_claim`/`kb_read_page` in the unrestricted-read section. +- **JSONL** `_h_diff` reads `params["old_id"]` (required) and + `params["new_id"]` (optional) — `kb.diff` in `HANDLERS`. +- **capabilities** `kb.diff` in `METHODS`, next to `kb.read_relation`. +- Both return `dataclasses.asdict(ArtifactDiff)`. +- Unrestricted like the other by-id read tools (`kb_read_claim`, + `kb_read_page`) — no `ViewerContext`/scope filtering, since resolving a + *specific known id* carries the same exposure either way. + ## Error handling - Unknown id (neither claim nor page) → `DiffError` → clean CLI `Error:` line. @@ -90,6 +114,14 @@ diff claim ## Non-goals -- Following supersede chains automatically (caller passes both ids). +- Following supersede chains more than one hop (omitted `new_id` resolves one + `superseded_by` link, not the full chain to the latest revision). - Diffing entities/relations/sources (claims and pages only, per ROADMAP). -- MCP/JSONL parity (`kb.*` surface unchanged). +- `ViewerContext` scope filtering on `kb.diff` (see "MCP + JSONL" above — + matches the other by-id read tools). + +Superseded decision from the original design: "MCP/JSONL parity" was +initially scoped out ("CLI-only... does not touch the `kb.*` capability +set"). Issue #327 pointed out this leaves `kb.diff` as the only read method +skipping the four-site registration convention (`CLAUDE.md` §"When you add a +new kb.* method"), so it was added — see "MCP + JSONL" above. diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 3fd21c75..749c10a6 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -27,6 +27,7 @@ "kb.read_claim", "kb.read_entity", "kb.read_relation", + "kb.diff", "kb.list_pages", "kb.list_claims", "kb.list_entities", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a490fcf9..992c8a6a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2979,10 +2979,14 @@ def sync_apply_cmd(source_path: str, on_conflict: str) -> None: @cli.command() @click.argument("old_id") -@click.argument("new_id") +@click.argument("new_id", required=False) @click.option("--json", "as_json", is_flag=True, default=False, help="Emit the diff as JSON.") -def diff(old_id: str, new_id: str, as_json: bool) -> None: - """Show what changed between two claim or two page revisions.""" +def diff(old_id: str, new_id: str | None, as_json: bool) -> None: + """Show what changed between two claim or two page revisions. + + NEW_ID is optional for a claim that has been superseded: it resolves to + ``superseded_by`` automatically. + """ from .diff import diff_artifacts store = _load_store() diff --git a/src/vouch/diff.py b/src/vouch/diff.py index 8387afa0..392072e2 100644 --- a/src/vouch/diff.py +++ b/src/vouch/diff.py @@ -72,11 +72,28 @@ def _line_diff(old: str, new: str) -> list[str]: )) -def diff_artifacts(store: KBStore, old_id: str, new_id: str) -> ArtifactDiff: - """Diff two same-kind artifacts (both claims or both pages) by id.""" +def diff_artifacts(store: KBStore, old_id: str, new_id: str | None = None) -> ArtifactDiff: + """Diff two same-kind artifacts (both claims or both pages) by id. + + ``new_id`` is optional for claims: when omitted, it resolves to + ``old_id``'s ``superseded_by`` field. Pages have no successor pointer, so + omitting ``new_id`` for a page is an error. + """ old_kind = _kind_of(store, old_id) if old_kind is None: raise DiffError(f"unknown artifact: {old_id}") + + if new_id is None: + if old_kind != "claim": + raise DiffError( + f"{old_id} is a {old_kind}; pages have no successor pointer, " + "pass new_id explicitly" + ) + old_claim = store.get_claim(old_id) + if not old_claim.superseded_by: + raise DiffError(f"{old_id} has not been superseded; pass new_id explicitly") + new_id = old_claim.superseded_by + new_kind = _kind_of(store, new_id) if new_kind is None: raise DiffError(f"unknown artifact: {new_id}") diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5e3b62b7..b1dbbc50 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -249,6 +249,14 @@ def _h_read_relation(p: dict) -> dict: return _store().get_relation(p["relation_id"]).model_dump(mode="json") +def _h_diff(p: dict) -> dict: + from dataclasses import asdict + + from .diff import diff_artifacts + + return asdict(diff_artifacts(_store(), p["old_id"], p.get("new_id"))) + + def _h_list_pages(p: dict) -> list[dict]: pages = filter_pages( _store().list_pages(), @@ -723,6 +731,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.read_claim": _h_read_claim, "kb.read_entity": _h_read_entity, "kb.read_relation": _h_read_relation, + "kb.diff": _h_diff, "kb.list_pages": _h_list_pages, "kb.list_claims": _h_list_claims, "kb.list_entities": _h_list_entities, diff --git a/src/vouch/server.py b/src/vouch/server.py index 8cabee08..d4aff659 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -309,6 +309,18 @@ def kb_read_relation(relation_id: str) -> dict[str, Any]: raise ValueError(str(e)) from e +@mcp.tool() +def kb_diff(old_id: str, new_id: str | None = None) -> dict[str, Any]: + """Field-level diff between two claim revisions or two page revisions. + + new_id is optional for a superseded claim: resolves to superseded_by. + """ + from dataclasses import asdict + + from .diff import diff_artifacts + return asdict(diff_artifacts(_store(), old_id, new_id)) + + @mcp.tool() def kb_list_pages( *, diff --git a/tests/test_diff.py b/tests/test_diff.py index 4189d806..f26c063a 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -7,8 +7,11 @@ import pytest from click.testing import CliRunner +from vouch import audit +from vouch.capabilities import capabilities from vouch.cli import cli from vouch.diff import ArtifactDiff, DiffError, diff_artifacts +from vouch.jsonl_server import HANDLERS, handle_request from vouch.models import Claim, ClaimStatus, Page from vouch.storage import KBStore @@ -80,6 +83,36 @@ def test_diff_mismatched_kinds_raises(store: KBStore) -> None: diff_artifacts(store, "c1", "p1") +def test_diff_omitted_new_id_resolves_via_superseded_by(store: KBStore) -> None: + _claim(store, "c2", text="new wording") + _claim(store, "c1", text="old wording", superseded_by="c2") + d = diff_artifacts(store, "c1") + assert d.new_id == "c2" + assert any(line.startswith("+new wording") for line in d.text_diff) + + +def test_diff_omitted_new_id_without_successor_raises(store: KBStore) -> None: + _claim(store, "c1") + with pytest.raises(DiffError, match="has not been superseded"): + diff_artifacts(store, "c1") + + +def test_diff_omitted_new_id_for_page_raises(store: KBStore) -> None: + store.put_page(Page(id="p1", title="P", body="b")) + with pytest.raises(DiffError, match="pages have no successor pointer"): + diff_artifacts(store, "p1") + + +def test_diff_read_only_writes_no_audit_event_or_proposal(store: KBStore) -> None: + _claim(store, "c1", text="old") + _claim(store, "c2", text="new") + before = list(audit.read_events(store.kb_dir)) + diff_artifacts(store, "c1", "c2") + after = list(audit.read_events(store.kb_dir)) + assert after == before + assert store.list_proposals() == [] + + # --- CLI ------------------------------------------------------------------ @@ -127,3 +160,68 @@ def test_cli_diff_unknown_id_clean_error( assert res.exit_code != 0 assert "Traceback" not in res.output assert "unknown artifact: nope" in res.output + + +def test_cli_diff_omitted_new_id_resolves_successor( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c2", text="new") + _claim(store, "c1", text="old", superseded_by="c2") + res = CliRunner().invoke(cli, ["diff", "c1"]) + assert res.exit_code == 0, res.output + assert "diff claim c1 → c2" in res.output + + +def test_cli_diff_omitted_new_id_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + res = CliRunner().invoke(cli, ["diff", "c1"]) + assert res.exit_code != 0 + assert "Traceback" not in res.output + assert "has not been superseded" in res.output + + +# --- kb.* RPC surface ------------------------------------------------------- + + +def test_diff_method_in_capabilities() -> None: + methods = set(capabilities().methods) + assert "kb.diff" in methods + assert set(capabilities().methods) == set(HANDLERS.keys()) + + +def test_kb_diff_over_jsonl(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1", status=ClaimStatus.WORKING) + _claim(store, "c2", status=ClaimStatus.STABLE) + resp = handle_request( + {"id": "1", "method": "kb.diff", "params": {"old_id": "c1", "new_id": "c2"}} + ) + assert resp["ok"] is True, resp + assert resp["result"]["kind"] == "claim" + changed = {c["field"]: (c["old"], c["new"]) for c in resp["result"]["changes"]} + assert changed["status"] == ("working", "stable") + + +def test_kb_diff_missing_param_over_jsonl( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + resp = handle_request({"id": "2", "method": "kb.diff", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" + + +def test_kb_diff_unknown_id_over_jsonl( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + resp = handle_request( + {"id": "3", "method": "kb.diff", "params": {"old_id": "c1", "new_id": "nope"}} + ) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request" From b4fd4691a61100ba395f5f6b549dd56f487b1315 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:32:36 +0900 Subject: [PATCH 09/34] fix(jsonl): define module logger after the import block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #361 inserted `_log = logging.getLogger(...)` between two import groups in jsonl_server.py. that statement-among-imports trips ruff's E402 on every import that follows it, so `ruff check src tests` — the lint gate in ci — now fails on the whole repo. move the logger definition below the imports (where storage.py already keeps its own module logger); no behaviour change. --- src/vouch/jsonl_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 8aea8cab..97b4753c 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -40,8 +40,6 @@ from .capabilities import capabilities as build_caps from .context import build_context_pack from .logging_config import configure_logging - -_log = logging.getLogger("vouch.jsonl_server") from .models import ProposalStatus from .page_filters import filter_pages from .proposals import ( @@ -65,6 +63,8 @@ ) from .synthesize import synthesize +_log = logging.getLogger("vouch.jsonl_server") + # Per-request actor override. The HTTP transport sets this from the # X-Vouch-Agent header so audit attribution is correct without mutating # process-wide env (each ThreadingHTTPServer request thread gets its own From 1a3f1c3dc3e4006db9beab60443178342dc80d19 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:11:49 +0900 Subject: [PATCH 10/34] feat(adapters): toml_merge install strategy for codex config.toml .codex/config.toml is codex's primary config file, so the plain-copy path silently skipped any project where codex was already configured and vouch never got wired. add a toml_merge entry flag mirroring json_merge: parse the existing destination with tomllib, deep-merge the template's tables into it (existing user values always win on conflict), and write back. flip the codex T1 entry to toml_merge. writing uses a minimal hand-rolled serializer (tables, arrays, inline tables in arrays, scalars, datetimes) so the dependency set stays unchanged; the output must survive a tomllib round-trip back to the merged data, and anything the serializer can't faithfully re-emit degrades to skipped rather than risking the user's config. closes #384 --- adapters/codex/install.yaml | 6 +- src/vouch/install_adapter.py | 180 ++++++++++++++++++++++++++++++++- tests/test_install_adapter.py | 183 ++++++++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 4 deletions(-) diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml index 7ba68b2f..3e41ef45 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -3,8 +3,12 @@ # Codex reads `/.codex/config.toml` (also `~/.codex/config.toml` for # user-global). We ship the project-local form so `vouch install-mcp codex` # doesn't touch home-directory state -- see issue #179 scope decision. +# +# `.codex/config.toml` is codex's *primary* config file (model, approval +# policy, other MCP servers), so T1 deep-merges into an existing one instead +# of skipping it -- see issue #384. Existing user values always win. host: codex pretty: OpenAI Codex CLI tiers: T1: - - { src: config.toml, dst: .codex/config.toml } + - { src: config.toml, dst: .codex/config.toml, toml_merge: true } diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index ba4f0b1c..abc426af 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -12,6 +12,9 @@ inside a `` ... `` block (``InstallResult.appended``). If the fence already exists, the file is treated as skipped -- so reruns of ``vouch install-mcp`` stay flat-noop. +* **settings.json with ``json_merge`` / config.toml with ``toml_merge``** -> + an existing destination is deep-merged into instead of skipped + (``InstallResult.merged``); the user's existing values always win. Tiers stack from T1 (the minimum: MCP wire) through T4 (full integration: slash commands and host-side hooks). Each manifest declares only the tiers @@ -27,8 +30,11 @@ from __future__ import annotations +import datetime import json +import re import shutil +import tomllib from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -75,6 +81,7 @@ class _FileEntry: dst: str # path relative to the target directory fenced_append: bool = False # CLAUDE.md-style: append inside our fence json_merge: bool = False # settings.json-style: deep-merge into existing + toml_merge: bool = False # config.toml-style: deep-merge into existing @dataclass(frozen=True) @@ -135,10 +142,32 @@ def _load_manifest(host: str) -> _Manifest: raise AdapterError( f"{host}: install.yaml tier {tier_name}: every entry needs a non-empty `dst`" ) - fenced = bool(raw.get("fenced_append", False)) - json_merge = bool(raw.get("json_merge", False)) + def _flag(name: str, raw: Any = raw, tier_name: str = tier_name) -> bool: + # Require an actual YAML boolean. `bool(raw.get(...))` would + # coerce a mistakenly-quoted `toml_merge: "false"` (a + # non-empty string) to True and silently enable a merge + # strategy, so reject anything that isn't a real bool. + val = raw.get(name, False) + if not isinstance(val, bool): + raise AdapterError( + f"{host}: install.yaml tier {tier_name}: `{name}` must be " + f"a boolean, got {type(val).__name__} ({val!r})" + ) + return val + + fenced = _flag("fenced_append") + json_merge = _flag("json_merge") + toml_merge = _flag("toml_merge") + if fenced + json_merge + toml_merge > 1: + raise AdapterError( + f"{host}: install.yaml tier {tier_name}: entry sets more than " + f"one of fenced_append/json_merge/toml_merge; pick one strategy" + ) parsed_entries.append( - _FileEntry(src=src, dst=dst, fenced_append=fenced, json_merge=json_merge) + _FileEntry( + src=src, dst=dst, fenced_append=fenced, + json_merge=json_merge, toml_merge=toml_merge, + ) ) if parsed_entries: parsed[tier_name] = parsed_entries @@ -224,6 +253,10 @@ def install(adapter: str, *, target: Path, tier: str = "T4") -> InstallResult: _install_json_merge(src, dst, result, entry.dst) continue + if entry.toml_merge: + _install_toml_merge(src, dst, result, entry.dst) + continue + if dst.exists(): result.skipped.append(entry.dst) continue @@ -389,3 +422,144 @@ def _install_json_merge( result.merged.append(rel_dst) else: result.skipped.append(rel_dst) + + +def _merge_toml(src: dict[str, Any], dst: dict[str, Any]) -> bool: + """Recursively add ``src`` keys missing from ``dst`` in place. Returns + True if ``dst`` changed. Same never-clobber convention as + :func:`_merge_settings`: on any conflict — a key present on both sides + with non-table values, or with mismatched types — the user's existing + ``dst`` value wins and only genuinely missing nested keys are filled in. + Idempotent: re-merging the same ``src`` reports no change. + """ + changed = False + for key, src_val in src.items(): + if key not in dst: + dst[key] = src_val + changed = True + continue + dst_val = dst[key] + if ( + isinstance(src_val, dict) + and isinstance(dst_val, dict) + and _merge_toml(src_val, dst_val) + ): + changed = True + return changed + + +_BARE_TOML_KEY = re.compile(r"[A-Za-z0-9_-]+") + + +def _toml_key(key: str) -> str: + if _BARE_TOML_KEY.fullmatch(key): + return key + # TOML basic strings share JSON's escape rules, so json.dumps is a + # valid quoted-key serializer. + return json.dumps(key) + + +def _toml_inline(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + if value != value or value in (float("inf"), float("-inf")): + raise ValueError(f"non-finite float not serialized: {value!r}") + return repr(value) + if isinstance(value, str): + return json.dumps(value) + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, list): + return "[" + ", ".join(_toml_inline(v) for v in value) + "]" + if isinstance(value, dict): + pairs = ", ".join( + f"{_toml_key(str(k))} = {_toml_inline(v)}" for k, v in value.items() + ) + return "{" + pairs + "}" + raise ValueError(f"unsupported TOML value type: {type(value).__name__}") + + +def _emit_toml_table( + table: dict[str, Any], path: list[str], lines: list[str] +) -> None: + plain = [(k, v) for k, v in table.items() if not isinstance(v, dict)] + subs = [(k, v) for k, v in table.items() if isinstance(v, dict)] + # A header is only needed for the table's own keys, or to make an empty + # table exist at all; sub-table headers imply their parents. + if path and (plain or not subs): + if lines: + lines.append("") + lines.append("[" + ".".join(_toml_key(p) for p in path) + "]") + for key, value in plain: + lines.append(f"{_toml_key(str(key))} = {_toml_inline(value)}") + for key, value in subs: + _emit_toml_table(value, [*path, str(key)], lines) + + +def _toml_dumps(data: dict[str, Any]) -> str: + """Serialize the merged config back to TOML. + + Deliberately minimal — covers the shapes tomllib can produce from the + configs we merge into (tables, arrays, inline tables inside arrays, + scalars, datetimes), not the whole spec. Lists containing tables are + emitted as arrays of inline tables rather than ``[[table]]`` blocks. + Raises ValueError on anything it can't faithfully re-emit; the caller + treats that as "leave the user's file alone". + """ + lines: list[str] = [] + _emit_toml_table(data, [], lines) + return "\n".join(lines) + "\n" if lines else "" + + +def _install_toml_merge( + src: Path, dst: Path, result: InstallResult, rel_dst: str +) -> None: + """config.toml-style: deep-merge our tables into a pre-existing TOML + file instead of skipping it. ``.codex/config.toml`` is codex's primary + config file, so a plain copy-or-skip would leave vouch unwired on any + project where codex is already configured (vouchdev/vouch#384). + + States mirror :func:`_install_json_merge`: + + * dst missing -> copy fresh (``written``) + * dst exists, merge adds keys -> merge + rewrite (``merged``) + * dst exists, nothing to add -> skip (``skipped``); already installed + * dst exists, unparseable -> skip (``skipped``); never clobber the user + + Rewriting re-serializes the whole file (comments and formatting are not + preserved — same trade-off ``_install_json_merge`` already makes). The + serialized result must survive a tomllib round-trip back to the merged + data; anything the minimal serializer can't faithfully re-emit degrades + to ``skipped`` rather than risking the user's config. + """ + if not dst.exists(): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + result.written.append(rel_dst) + return + + try: + dst_data = tomllib.loads(dst.read_text(encoding="utf-8")) + src_data = tomllib.loads(src.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + # Malformed or unreadable user file — leave it untouched. + result.skipped.append(rel_dst) + return + + if not _merge_toml(src_data, dst_data): + result.skipped.append(rel_dst) + return + + try: + text = _toml_dumps(dst_data) + if tomllib.loads(text) != dst_data: + raise ValueError("serializer round-trip mismatch") + except (ValueError, tomllib.TOMLDecodeError): + result.skipped.append(rel_dst) + return + + dst.write_text(text, encoding="utf-8") + result.merged.append(rel_dst) diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 83036e8c..1a11d283 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -319,6 +319,189 @@ def test_install_openclaw_is_idempotent(tmp_path: Path) -> None: } +# --- codex: config.toml deep-merge (vouchdev/vouch#384) --------------------- + + +def test_codex_toml_merges_into_existing_config(tmp_path: Path) -> None: + """User already has .codex/config.toml — codex's *primary* config file. + The old plain-copy path silently skipped it, so vouch never got wired on + any project where codex was already configured. toml_merge adds + [mcp_servers.vouch] while preserving every unrelated table and value.""" + import tomllib + + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "config.toml").write_text( + 'model = "gpt-5"\napproval_policy = "never"\n\n' + '[mcp_servers.other]\ncommand = "other-server"\nargs = ["--fast"]\n', + encoding="utf-8", + ) + result = install("codex", target=tmp_path, tier="T1") + data = tomllib.loads((codex_dir / "config.toml").read_text(encoding="utf-8")) + + # user content preserved + assert data["model"] == "gpt-5" + assert data["approval_policy"] == "never" + assert data["mcp_servers"]["other"]["command"] == "other-server" + assert data["mcp_servers"]["other"]["args"] == ["--fast"] + + # vouch content merged in + assert data["mcp_servers"]["vouch"]["command"] == "vouch" + assert data["mcp_servers"]["vouch"]["args"] == ["serve"] + assert data["mcp_servers"]["vouch"]["env"]["VOUCH_AGENT"] == "codex" + + assert ".codex/config.toml" in result.merged + assert ".codex/config.toml" not in result.skipped + assert ".codex/config.toml" not in result.written + + +def test_codex_toml_merge_is_idempotent(tmp_path: Path) -> None: + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "config.toml").write_text('model = "gpt-5"\n', encoding="utf-8") + install("codex", target=tmp_path, tier="T1") + first = (codex_dir / "config.toml").read_text(encoding="utf-8") + second = install("codex", target=tmp_path, tier="T1") + after = (codex_dir / "config.toml").read_text(encoding="utf-8") + + assert first == after # no change on re-run + assert ".codex/config.toml" in second.skipped + assert ".codex/config.toml" not in second.merged + + +def test_codex_toml_fresh_install_writes_template(tmp_path: Path) -> None: + import tomllib + + result = install("codex", target=tmp_path, tier="T1") + cfg = tmp_path / ".codex" / "config.toml" + assert cfg.is_file() + data = tomllib.loads(cfg.read_text(encoding="utf-8")) + assert data["mcp_servers"]["vouch"]["command"] == "vouch" + assert ".codex/config.toml" in result.written + assert ".codex/config.toml" not in result.merged + + +def test_codex_toml_existing_vouch_entry_wins(tmp_path: Path) -> None: + """Conflict convention matches _install_json_merge: never clobber the + user. An existing [mcp_servers.vouch] value stays; only genuinely + missing keys (here the env table) are filled in.""" + import tomllib + + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "config.toml").write_text( + '[mcp_servers.vouch]\ncommand = "/opt/custom/vouch"\nargs = ["serve", "--debug"]\n', + encoding="utf-8", + ) + result = install("codex", target=tmp_path, tier="T1") + data = tomllib.loads((codex_dir / "config.toml").read_text(encoding="utf-8")) + + # the user's conflicting values win, deterministically + assert data["mcp_servers"]["vouch"]["command"] == "/opt/custom/vouch" + assert data["mcp_servers"]["vouch"]["args"] == ["serve", "--debug"] + # the missing env table is deep-merged in + assert data["mcp_servers"]["vouch"]["env"]["VOUCH_AGENT"] == "codex" + assert ".codex/config.toml" in result.merged + + +def test_codex_toml_malformed_existing_is_skipped(tmp_path: Path) -> None: + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "config.toml").write_text("= not valid toml [", encoding="utf-8") + before = (codex_dir / "config.toml").read_text(encoding="utf-8") + result = install("codex", target=tmp_path, tier="T1") + # unreadable user file is left untouched, not clobbered + assert (codex_dir / "config.toml").read_text(encoding="utf-8") == before + assert ".codex/config.toml" in result.skipped + assert ".codex/config.toml" not in result.merged + + +def test_toml_dumps_roundtrips_shipped_shapes() -> None: + """The hand-rolled serializer must faithfully re-emit everything tomllib + can hand it from the config shapes we merge into: nested tables, arrays, + inline tables inside arrays, quoted keys, and scalar types.""" + import tomllib + + from vouch.install_adapter import _toml_dumps + + data = { + "model": "gpt-5", + "temperature": 0.5, + "retries": 3, + "verbose": True, + "tags": ["a", "b"], + "weird key.name": "quoted", + "profiles": [{"name": "fast"}, {"name": "safe"}], + "mcp_servers": { + "vouch": { + "command": "vouch", + "args": ["serve"], + "env": {"VOUCH_AGENT": "codex"}, + }, + }, + } + assert tomllib.loads(_toml_dumps(data)) == data + + +def test_merge_toml_reports_no_change_when_subset() -> None: + from vouch.install_adapter import _merge_toml + + dst = {"a": {"b": 1, "c": [1, 2]}, "top": "x"} + src = {"a": {"b": 999}} # conflicting value: dst wins, nothing to add + assert _merge_toml(src, dst) is False + assert dst["a"]["b"] == 1 + + +def _write_manifest(tmp_path: Path, host: str, body: str, monkeypatch) -> None: + """Point the loader at a throwaway adapters dir holding one manifest.""" + import vouch.install_adapter as ia + + (tmp_path / host).mkdir(parents=True) + (tmp_path / host / "install.yaml").write_text(body, encoding="utf-8") + monkeypatch.setattr(ia, "ADAPTERS_DIR", tmp_path) + + +def test_manifest_non_boolean_flag_is_rejected(tmp_path: Path, monkeypatch) -> None: + """A quoted `"false"` is a non-empty string; bool() would read it as + True and silently enable a merge. The loader must reject it.""" + from vouch.install_adapter import _load_manifest + + _write_manifest(tmp_path, "badhost", ( + "host: badhost\n" + "tiers:\n" + " T1:\n" + ' - { src: a, dst: b, toml_merge: "false" }\n' + ), monkeypatch) + with pytest.raises(AdapterError, match="`toml_merge` must be a boolean"): + _load_manifest("badhost") + + +def test_manifest_multiple_strategies_rejected(tmp_path: Path, monkeypatch) -> None: + from vouch.install_adapter import _load_manifest + + _write_manifest(tmp_path, "badhost", ( + "host: badhost\n" + "tiers:\n" + " T1:\n" + " - { src: a, dst: b, json_merge: true, toml_merge: true }\n" + ), monkeypatch) + with pytest.raises(AdapterError, match="more than one of"): + _load_manifest("badhost") + + +def test_manifest_boolean_flags_still_accepted(tmp_path: Path, monkeypatch) -> None: + from vouch.install_adapter import _load_manifest + + _write_manifest(tmp_path, "okhost", ( + "host: okhost\n" + "tiers:\n" + " T1:\n" + " - { src: a, dst: b, toml_merge: true }\n" + ), monkeypatch) + manifest = _load_manifest("okhost") + assert manifest.tiers["T1"][0].toml_merge is True + + # --- error paths ---------------------------------------------------------- From 628c733b021b93ecd012a4880eabcc68dae80e55 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:22:08 +0900 Subject: [PATCH 11/34] feat(adapters): codex T2 agents.md fenced snippet codex reads AGENTS.md for project instructions the way cursor does, but the codex adapter stopped at T1, so a codex session got the kb tools with no standing guidance on recall-first or the review gate. ship adapters/codex/AGENTS.md.snippet as a T2 tier with the standard fence markers, kept in lockstep with cursor's snippet modulo the host name (enforced by a sync test). also close the edited-fence gap in _install_fenced per the ticket's acceptance criteria: a fence body that drifted from the shipped snippet is replaced within the markers (reported as merged) instead of being skipped forever, while user content outside the fence stays untouched. a begin marker without an end marker is treated as corrupt and left alone. closes #385 --- adapters/codex/AGENTS.md.snippet | 31 +++++++++ adapters/codex/install.yaml | 8 +++ src/vouch/install_adapter.py | 70 +++++++++++++++++-- tests/test_install_adapter.py | 116 +++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 adapters/codex/AGENTS.md.snippet diff --git a/adapters/codex/AGENTS.md.snippet b/adapters/codex/AGENTS.md.snippet new file mode 100644 index 00000000..593f97df --- /dev/null +++ b/adapters/codex/AGENTS.md.snippet @@ -0,0 +1,31 @@ +# Vouch — knowledge base + +This repo uses **vouch** for durable agent knowledge. The KB lives in +`.vouch/` and is reviewed in PRs like any other code. + +## How to remember things + +To preserve a fact, decision, or workflow across sessions: + +1. Register evidence: `kb_register_source` (or + `kb_register_source_from_path` for a file). +2. Propose a claim that cites it: `kb_propose_claim`. Every claim + MUST cite at least one source or evidence id. +3. For richer write-ups, propose pages: `kb_propose_page` with a + markdown body that references claims. + +You **cannot** write durable knowledge directly. Proposals land in +`.vouch/proposed/` and require human approval via `vouch approve`. +This is intentional. + +## How to read + +- `kb_search` for keyword search. +- `kb_context` to fill a working set for a task ("what does this KB know + about X?"). +- `kb_read_*` for specific ids. + +## Identity + +Set `VOUCH_AGENT=codex` in your env (or the MCP entry's `env:` block) so the +audit log can attribute writes to this host. diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml index 3e41ef45..29eb1a37 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -9,6 +9,14 @@ # of skipping it -- see issue #384. Existing user values always win. host: codex pretty: OpenAI Codex CLI +fence: + begin: "" + end: "" tiers: T1: - { src: config.toml, dst: .codex/config.toml, toml_merge: true } + # T2 = AGENTS.md fenced snippet (codex reads AGENTS.md for project + # instructions the way cursor does -- see issue #385). Kept in lockstep + # with adapters/cursor/AGENTS.md.snippet modulo the host name. + T2: + - { src: AGENTS.md.snippet, dst: AGENTS.md, fenced_append: true } diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index abc426af..4fce5cd0 100644 --- a/src/vouch/install_adapter.py +++ b/src/vouch/install_adapter.py @@ -280,10 +280,15 @@ def _install_fenced( States: - * dst is missing -> write fresh, fenced (``written``) - * dst exists, fence not in file -> append fenced block (``appended``) - * dst exists, fence already in -> skip (``skipped``); we are the - author and there's nothing to do + * dst is missing -> write fresh, fenced (``written``) + * dst exists, fence not in file -> append fenced block (``appended``) + * dst exists, fence body up to date -> skip (``skipped``); we are the + author and there's nothing to do + * dst exists, fence body edited -> replace within the markers + (``merged``); the fence is ours, + content around it is the user's + * dst exists, begin without end -> skip (``skipped``); corrupt fence + we refuse to mangle """ snippet = src.read_text(encoding="utf-8") fenced_block = f"\n{manifest.fence_begin}\n{snippet.rstrip()}\n{manifest.fence_end}\n" @@ -295,16 +300,71 @@ def _install_fenced( return existing = dst.read_text(encoding="utf-8") - if manifest.fence_begin in existing: + span = _fence_span(existing, manifest.fence_begin, manifest.fence_end) + if span is not None: + start, stop = span + current = existing[start:stop] + expected = fenced_block.strip("\n") + if current == expected: + result.skipped.append(rel_dst) + return + # The fence is ours: bring an edited body back in sync in place, + # touching nothing outside the markers. + refreshed = existing[:start] + expected + existing[stop:] + dst.write_text(refreshed, encoding="utf-8") + result.merged.append(rel_dst) + return + + if _has_standalone_line(existing, manifest.fence_begin): + # A begin marker on its own line with no matching end marker: a + # corrupt fence we can't cleanly parse. Don't append a second one. result.skipped.append(rel_dst) return # User-authored content above; append our fenced block at the bottom. + # (A file that merely *mentions* the marker text in prose or a code + # sample has no standalone fence, so it lands here and gets the block + # appended rather than being mistaken for an existing install.) new_content = existing.rstrip() + "\n" + fenced_block dst.write_text(new_content, encoding="utf-8") result.appended.append(rel_dst) +def _has_standalone_line(text: str, marker: str) -> bool: + return any(line.strip() == marker for line in text.splitlines()) + + +def _fence_span(text: str, begin: str, end: str) -> tuple[int, int] | None: + """Char offsets of a well-formed fence whose ``begin``/``end`` markers + each occupy their own line, or None if there's no such pair. + + Only standalone marker lines count — text that merely mentions the + marker inside prose or a fenced code sample is ignored, so a passing + reference can't be mistaken for an installed fence (and can't cause an + in-place rewrite to clobber unrelated content between two stray + mentions). The returned span runs from the start of the begin line to + the end of the end-marker text (excluding its trailing newline), so a + caller can splice a replacement in without disturbing the surrounding + file. + """ + lines = text.splitlines(keepends=True) + begin_idx: int | None = None + end_idx: int | None = None + for i, line in enumerate(lines): + stripped = line.strip() + if begin_idx is None: + if stripped == begin: + begin_idx = i + elif stripped == end: + end_idx = i + break + if begin_idx is None or end_idx is None: + return None + start = sum(len(line) for line in lines[:begin_idx]) + stop = sum(len(line) for line in lines[:end_idx]) + len(lines[end_idx].rstrip("\n")) + return start, stop + + def _event_commands(groups: Any) -> set[str]: """Every hook ``command`` string already present under one hooks-event.""" cmds: set[str] = set() diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 1a11d283..493241b6 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -319,6 +319,122 @@ def test_install_openclaw_is_idempotent(tmp_path: Path) -> None: } +# --- codex: T2 AGENTS.md fenced snippet (vouchdev/vouch#385) ---------------- + + +def test_codex_t2_appends_snippet_to_existing_agents_md(tmp_path: Path) -> None: + """Codex reads AGENTS.md for project instructions the way cursor does; + without the snippet a codex session gets the kb tools but no standing + guidance on recall-first or the review gate.""" + (tmp_path / "AGENTS.md").write_text("# My project\n\nExisting content.\n") + result = install("codex", target=tmp_path, tier="T2") + final = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + assert "Existing content." in final + assert "" in final + assert "" in final + assert "AGENTS.md" in result.appended + + +def test_codex_t2_creates_agents_md_when_absent(tmp_path: Path) -> None: + result = install("codex", target=tmp_path, tier="T2") + agents = tmp_path / "AGENTS.md" + assert agents.is_file() + assert "" in agents.read_text(encoding="utf-8") + assert "AGENTS.md" in result.written + + +def test_codex_t2_rerun_is_noop(tmp_path: Path) -> None: + install("codex", target=tmp_path, tier="T2") + before = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + again = install("codex", target=tmp_path, tier="T2") + after = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + assert before == after + assert "AGENTS.md" in again.skipped + assert "AGENTS.md" not in again.appended + + +def test_codex_t1_does_not_touch_agents_md(tmp_path: Path) -> None: + install("codex", target=tmp_path, tier="T1") + assert not (tmp_path / "AGENTS.md").exists() + + +def test_codex_snippet_stays_in_lockstep_with_cursor(tmp_path: Path) -> None: + """The two snippets carry the same invariants (recall first, all writes + via proposals, review stays human) and are phrased host-neutrally: the + only difference allowed is the host name itself.""" + codex = (ADAPTERS_DIR / "codex" / "AGENTS.md.snippet").read_text(encoding="utf-8") + cursor = (ADAPTERS_DIR / "cursor" / "AGENTS.md.snippet").read_text(encoding="utf-8") + assert codex == cursor.replace("cursor", "codex") + + +def test_fenced_refresh_replaces_edited_fence_body(tmp_path: Path) -> None: + """An edited fence body is brought back in sync within the markers; + user content outside the fence is untouched (vouchdev/vouch#385).""" + (tmp_path / "AGENTS.md").write_text("# Mine\n\nAbove.\n") + install("codex", target=tmp_path, tier="T2") + installed = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + + tampered = installed.replace( + "", + "\nstale hand edits\n", + ) + "\nBelow.\n" + (tmp_path / "AGENTS.md").write_text(tampered, encoding="utf-8") + + result = install("codex", target=tmp_path, tier="T2") + final = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + assert "stale hand edits" not in final + assert "Above." in final + assert "Below." in final + assert final.count("") == 1 + assert "AGENTS.md" in result.merged + assert "AGENTS.md" not in result.skipped + + +def test_fenced_append_when_marker_only_mentioned_in_prose(tmp_path: Path) -> None: + """A file that merely *mentions* the marker text (docs, a code sample) + has no standalone fence, so the snippet is appended rather than the + mention being mistaken for an existing install and skipped.""" + (tmp_path / "AGENTS.md").write_text( + "# Docs\n\nWe wrap vouch content in `` markers.\n", + encoding="utf-8", + ) + result = install("codex", target=tmp_path, tier="T2") + final = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + # the prose line survived, and a real standalone fence was appended + assert "We wrap vouch content in" in final + assert any(line.strip() == "" for line in final.splitlines()) + assert "AGENTS.md" in result.appended + assert "AGENTS.md" not in result.skipped + + +def test_fenced_refresh_ignores_marker_mention_below_real_fence(tmp_path: Path) -> None: + """Re-running stays a flat no-op even when the user pasted the marker + text into prose below the installed fence: the standalone fence is + up to date, the prose mention is not a second fence.""" + install("codex", target=tmp_path, tier="T2") + installed = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + (tmp_path / "AGENTS.md").write_text( + installed + "\nnote: the `` line is ours.\n", + encoding="utf-8", + ) + before = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + result = install("codex", target=tmp_path, tier="T2") + assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == before + assert "AGENTS.md" in result.skipped + + +def test_fenced_refresh_leaves_unclosed_fence_alone(tmp_path: Path) -> None: + """A begin marker without an end marker is a corrupt state we refuse to + mangle — the file is left untouched and reported skipped.""" + (tmp_path / "AGENTS.md").write_text( + "content\n\nno end marker here\n", encoding="utf-8" + ) + before = (tmp_path / "AGENTS.md").read_text(encoding="utf-8") + result = install("codex", target=tmp_path, tier="T2") + assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == before + assert "AGENTS.md" in result.skipped + + # --- codex: config.toml deep-merge (vouchdev/vouch#384) --------------------- From 109a3a3897e698c5bcdda8f35ac4b4a9b0314ad6 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:26:45 +0900 Subject: [PATCH 12/34] feat(adapters): codex T3 skills mirroring the vouch slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-code T3 ships nine slash commands; codex users got none of the guided flows. ship them as codex skills under the project-local .codex/skills/ as a T3 tier. scope decision the ticket asked to resolve first: codex loads custom prompts only from ~/.codex/prompts/ (user-global) and has deprecated them upstream in favour of skills, which do have a project-local home at /.codex/skills/ in trusted projects. the #179 rule forbids a project-scoped install from touching home-directory state, so prompts are out and skills are in — recorded in the manifest comment and the adapter readme, with a manual-copy pointer for users who still want ~/.codex/prompts. the SKILL.md files are referenced from the openclaw mirror rather than duplicated (same reuse pattern openclaw itself applies to the claude-code commands), so one edit updates every host. a parametrized sync test asserts each installed codex skill body equals the matching claude-code command body, and a scope test asserts every installed path stays under the project target. closes #386 --- adapters/codex/README.md | 36 +++++++++++++- adapters/codex/install.yaml | 18 +++++++ tests/test_install_adapter.py | 88 +++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/adapters/codex/README.md b/adapters/codex/README.md index cfb324a4..ae028608 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -6,8 +6,19 @@ Wires `vouch serve` into [OpenAI's Codex CLI][codex] as an MCP server. ## Setup -Codex reads MCP server config from `~/.codex/config.toml`. Add a -`vouch` entry: +```bash +vouch install-mcp codex +``` + +This writes the `vouch` MCP entry into the *project-local* +`/.codex/config.toml` (deep-merged into any existing one, so +your other servers and settings are preserved) — never into +`~/.codex/config.toml`, per the project-scoped install rule. Codex +loads project-local `.codex/` config for trusted projects, so trust +the project when codex asks. + +Prefer a user-global setup, or no installer? Add the entry to +`~/.codex/config.toml` (or `/.codex/config.toml`) by hand: ```toml [mcp_servers.vouch] @@ -20,6 +31,27 @@ VOUCH_AGENT = "codex" Restart any running `codex` session. +## Skills (T3) + +`vouch install-mcp codex --tier T3` also drops the vouch guided flows +(`vouch-recall`, `vouch-status`, `vouch-resolve-issue`, +`vouch-propose-from-pr`, plus the company-brain set) into the +project-local `.codex/skills/` directory. Codex discovers skills from +`/.codex/skills/` in trusted projects, so they surface in the +session automatically — ask for a skill by name (e.g. "use the +vouch-recall skill for X") or let codex pick them up from context. + +Why skills and not custom prompts: codex loads custom prompts only +from `~/.codex/prompts/` (user-global) and has deprecated them in +favour of skills. A project-scoped `vouch install-mcp` never touches +home-directory state, so skills are the surface vouch ships. If you +prefer slash-style prompts anyway, copy the installed +`.codex/skills/*/SKILL.md` bodies into `~/.codex/prompts/.md` +yourself. + +The skill bodies are identical to the claude-code slash commands +(enforced by a sync test), so the flows behave the same on every host. + ## Notes - Codex respects MCP tool naming verbatim, so the tools appear as diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml index 29eb1a37..016be7ba 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -20,3 +20,21 @@ tiers: # with adapters/cursor/AGENTS.md.snippet modulo the host name. T2: - { src: AGENTS.md.snippet, dst: AGENTS.md, fenced_append: true } + # T3 = the vouch guided flows as codex *skills* under the project-local + # `.codex/skills/` -- see issue #386. Scope decision recorded here per + # that ticket: codex custom prompts load only from ~/.codex/prompts/ + # (user-global) and are deprecated upstream in favour of skills, and the + # #179 rule forbids a project-scoped install from touching home-directory + # state, so prompts are out and project-local skills are in. The SKILL.md + # files are referenced from the openclaw mirror rather than duplicated; + # their bodies are sync-tested against the claude-code commands. + T3: + - { src: ../openclaw/skills/vouch-recall/SKILL.md, dst: .codex/skills/vouch-recall/SKILL.md } + - { src: ../openclaw/skills/vouch-status/SKILL.md, dst: .codex/skills/vouch-status/SKILL.md } + - { src: ../openclaw/skills/vouch-resolve-issue/SKILL.md, dst: .codex/skills/vouch-resolve-issue/SKILL.md } + - { src: ../openclaw/skills/vouch-propose-from-pr/SKILL.md, dst: .codex/skills/vouch-propose-from-pr/SKILL.md } + - { src: ../openclaw/skills/vouch-ask/SKILL.md, dst: .codex/skills/vouch-ask/SKILL.md } + - { src: ../openclaw/skills/vouch-remember/SKILL.md, dst: .codex/skills/vouch-remember/SKILL.md } + - { src: ../openclaw/skills/vouch-record/SKILL.md, dst: .codex/skills/vouch-record/SKILL.md } + - { src: ../openclaw/skills/vouch-followup/SKILL.md, dst: .codex/skills/vouch-followup/SKILL.md } + - { src: ../openclaw/skills/vouch-standup/SKILL.md, dst: .codex/skills/vouch-standup/SKILL.md } diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 493241b6..e4adad83 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -435,6 +435,94 @@ def test_fenced_refresh_leaves_unclosed_fence_alone(tmp_path: Path) -> None: assert "AGENTS.md" in result.skipped +# --- codex: T3 skills mirroring the vouch slash commands +# (vouchdev/vouch#386) ------------------------------------------------------ +# +# Scope decision from the ticket: codex custom prompts live only under +# ~/.codex/prompts/ (user-global) and are deprecated upstream in favour of +# skills, which DO have a project-local home at /.codex/skills/. +# Shipping skills keeps the #179 rule intact — a project-scoped install +# never touches home-directory state. + +_CODEX_SKILL_NAMES = ( + "vouch-ask", + "vouch-followup", + "vouch-propose-from-pr", + "vouch-recall", + "vouch-record", + "vouch-remember", + "vouch-resolve-issue", + "vouch-standup", + "vouch-status", +) + + +def _body_after_frontmatter(text: str) -> str: + parts = text.split("---", 2) + assert len(parts) == 3, "expected yaml frontmatter" + return parts[2].strip() + + +def test_install_codex_t3_ships_all_nine_skills(tmp_path: Path) -> None: + result = install("codex", target=tmp_path, tier="T3") + for name in _CODEX_SKILL_NAMES: + skill = tmp_path / ".codex" / "skills" / name / "SKILL.md" + assert skill.is_file(), f"missing {name}" + assert f".codex/skills/{name}/SKILL.md" in result.written + + +def test_codex_t3_writes_nothing_outside_the_project(tmp_path: Path) -> None: + """#179 invariant: every installed path stays under the target — a + project-scoped install must never reach ~/.codex.""" + result = install("codex", target=tmp_path, tier="T3") + for rel in (*result.written, *result.appended, *result.merged): + resolved = (tmp_path / rel).resolve() + assert resolved.is_relative_to(tmp_path.resolve()), rel + + +@pytest.mark.parametrize("name", _CODEX_SKILL_NAMES) +def test_codex_skills_stay_in_sync_with_claude_commands( + name: str, tmp_path: Path +) -> None: + """The skill bodies are the claude-code command bodies — referenced in + place from the openclaw mirror rather than forked, so one edit updates + every host and this test catches any drift.""" + install("codex", target=tmp_path, tier="T3") + skill = tmp_path / ".codex" / "skills" / name / "SKILL.md" + command = ( + REPO_ROOT / "adapters" / "claude-code" / ".claude" / "commands" / f"{name}.md" + ) + assert _body_after_frontmatter(skill.read_text(encoding="utf-8")) == ( + _body_after_frontmatter(command.read_text(encoding="utf-8")) + ), f"{name}: codex SKILL.md body drifted from the claude-code command" + + +def test_codex_skill_frontmatter_names_match_dirs(tmp_path: Path) -> None: + """Codex resolves a skill by its frontmatter name; a mismatch with the + directory name would ship a skill that answers to the wrong id.""" + install("codex", target=tmp_path, tier="T3") + for name in _CODEX_SKILL_NAMES: + text = (tmp_path / ".codex" / "skills" / name / "SKILL.md").read_text( + encoding="utf-8" + ) + frontmatter = text.split("---", 2)[1] + # Match the whole `name:` line, not a substring: `name: vouch-recall` + # must not be satisfied by `name: vouch-recall-typo`. + name_lines = [ + ln.strip() for ln in frontmatter.splitlines() + if ln.strip().startswith("name:") + ] + assert f"name: {name}" in name_lines, (name, name_lines) + + +def test_install_codex_t3_is_idempotent(tmp_path: Path) -> None: + install("codex", target=tmp_path, tier="T3") + second = install("codex", target=tmp_path, tier="T3") + assert second.written == [] + for name in _CODEX_SKILL_NAMES: + assert f".codex/skills/{name}/SKILL.md" in second.skipped + + # --- codex: config.toml deep-merge (vouchdev/vouch#384) --------------------- From 0eb7917a09f66b7fc10f3cedce2d2b481cff26f0 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:34:52 +0900 Subject: [PATCH 13/34] feat(capture): ingest codex session rollouts into review-gated summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session auto-capture was claude-code-only: hooks drive capture observe and capture finalize live. codex has no hook stream, but it persists every session as a rollout jsonl under $CODEX_HOME/sessions containing user messages, tool calls, and outputs — everything the existing rollup needs, just after the fact. new cli command `vouch capture ingest-codex [ | --latest]` parses one rollout through a small dedicated parser (codex_rollout.py) that maps function_call records into the same observation shape capture.observe produces — shell commands with failure detection from exit codes, apply_patch heredocs surfaced as file edits, mcp tools under their own names, session mechanics skipped — then reuses the existing build_summary_body -> propose_page rollup. one code path from observation to proposal, two front doors. the rollout format is not a stable public contract: unknown record types are tolerated, and unreadable, compressed, or meta-less files degrade to a CodexRolloutError with an actionable message and a non-zero exit, never a stack trace. re-ingesting a session is a no-op keyed on the rollout's session id; --latest resolves the newest rollout whose recorded cwd matches the current project. proposals are attributed to the codex actor (VOUCH_AGENT wins when set), respect capture's enabled/min_observations config, and never touch approve(). fixture rollouts use placeholder data only, enforced by a test. closes #387 --- src/vouch/cli.py | 49 +++ src/vouch/codex_rollout.py | 374 +++++++++++++++++++++ tests/fixtures/codex/rollout-basic.jsonl | 17 + tests/fixtures/codex/rollout-no-meta.jsonl | 2 + tests/test_codex_rollout.py | 299 ++++++++++++++++ 5 files changed, 741 insertions(+) create mode 100644 src/vouch/codex_rollout.py create mode 100644 tests/fixtures/codex/rollout-basic.jsonl create mode 100644 tests/fixtures/codex/rollout-no-meta.jsonl create mode 100644 tests/test_codex_rollout.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a490fcf9..853d2a46 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -25,6 +25,7 @@ from . import __version__, bundle, health, volunteer_context from . import audit as audit_mod from . import capture as capture_mod +from . import codex_rollout as codex_rollout_mod from . import compile as compile_mod from . import digest as digest_mod from . import fetch as fetch_mod @@ -98,6 +99,7 @@ def _cli_errors() -> Iterator[None]: ProposalError, LifecycleError, migrations_mod.MigrationError, + codex_rollout_mod.CodexRolloutError, ) as e: raise click.ClickException(str(e)) from e @@ -2022,6 +2024,53 @@ def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> _emit_json(result) +@capture.command("ingest-codex") +@click.argument( + "rollout", required=False, + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +@click.option( + "--latest", is_flag=True, + help="Resolve the newest codex rollout recorded for this project (by cwd).", +) +@click.option( + "--codex-home", type=click.Path(file_okay=False, path_type=Path), default=None, + help="Codex state dir holding sessions/ (default: $CODEX_HOME or ~/.codex).", +) +def capture_ingest_codex_cmd( + rollout: Path | None, latest: bool, codex_home: Path | None +) -> None: + """Ingest one codex session rollout into a PENDING summary proposal. + + Codex has no live hook stream; it persists each session as a rollout + jsonl instead. This maps the rollout's tool calls into the same + observation shape `capture observe` produces and reuses the existing + rollup, so the result is the same review-gated summary a claude + session yields. Re-ingesting a session is a no-op; review with + `vouch review`. + """ + if (rollout is None) == (not latest): + raise click.ClickException("pass exactly one of ROLLOUT or --latest") + store = _load_store() + with _cli_errors(): + if latest: + found = codex_rollout_mod.find_latest_rollout( + Path.cwd(), codex_home=codex_home + ) + if found is None: + raise click.ClickException( + "no codex rollout found for this project under " + f"{(codex_home or codex_rollout_mod.default_codex_home()) / 'sessions'}; " + "pass a rollout file explicitly" + ) + rollout = found + assert rollout is not None + result = codex_rollout_mod.ingest_rollout( + store, rollout, generated_at=datetime.now(UTC).isoformat() + ) + _emit_json(result) + + @capture.command("banner") def capture_banner_cmd() -> None: """Emit a SessionStart nudge if captured summaries await review.""" diff --git a/src/vouch/codex_rollout.py b/src/vouch/codex_rollout.py new file mode 100644 index 00000000..c94684c7 --- /dev/null +++ b/src/vouch/codex_rollout.py @@ -0,0 +1,374 @@ +"""Ingest OpenAI Codex CLI session rollouts into review-gated summaries. + +Codex has no live hook stream the way claude-code does, but it persists +every session as a rollout file — ``$CODEX_HOME/sessions/YYYY/MM/DD/`` +``rollout--.jsonl`` — holding user messages, tool calls, +and outputs: everything ``capture.build_summary_body`` needs, just after +the fact instead of live. ``vouch capture ingest-codex`` maps rollout +records into the same observation shape ``capture.observe`` produces, then +reuses the existing rollup (``build_summary_body`` -> ``propose_page``) so +a codex session yields the same kind of PENDING session-summary proposal a +claude session does: one code path from observation to proposal, two front +doors. + +The rollout format is not a stable public contract. Parsing is therefore +tolerant — unknown record types are skipped — but a file that doesn't look +like a rollout at all degrades to :class:`CodexRolloutError` with an +actionable message, never a stack trace. Ingesting the same session twice +is a no-op: the session id in the rollout is the natural dedup key. + +Never calls ``approve()`` — the review gate stays intact. A human reviews +the proposal with ``vouch review`` like any other write. +""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from . import capture +from .proposals import propose_page +from .storage import KBStore + +# The default proposer when VOUCH_AGENT isn't set: rollouts are codex +# sessions, so the audit trail attributes them to the codex actor the +# adapter configures (`VOUCH_AGENT=codex` in adapters/codex/config.toml). +CODEX_ACTOR = "codex" + +_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd" +_MAX_PROMPT_CHARS = 240 +_PATCH_FILE_RE = re.compile(r"^\*\*\* (Add|Update|Delete) File: (.+)$", re.MULTILINE) +_EXIT_CODE_RE = re.compile(r"exited with code (\d+)") + +# Codex tool calls that are session mechanics, not work worth summarizing. +_IGNORED_CALLS = frozenset({"update_plan", "write_stdin", "list_mcp_resources"}) +_SHELL_CALLS = frozenset({"exec_command", "shell", "local_shell", "container.exec"}) + + +class CodexRolloutError(RuntimeError): + """Raised when a rollout file can't be read or doesn't parse as one. + + The CLI layer translates this into a clean ``Error: ...`` line via + ``_cli_errors``; nothing is written to the KB when it's raised. + """ + + +@dataclass +class CodexSession: + """The capture-relevant slice of one parsed rollout.""" + + session_id: str + cwd: str | None = None + started_at: str | None = None + first_prompt: str | None = None + observations: list[dict[str, Any]] = field(default_factory=list) + + +def _clean_prompt(raw: str) -> str | None: + """Mirror ``capture.first_user_prompt``'s hygiene: skip host wrapper + messages and meta lines, collapse whitespace, cap the length.""" + text = raw.strip() + if not text or text.startswith("<"): + return None + if text.lower().startswith("caveat:"): + return None + collapsed = " ".join(text.split()) + if len(collapsed) > _MAX_PROMPT_CHARS: + collapsed = collapsed[: _MAX_PROMPT_CHARS - 1].rstrip() + "…" + return collapsed + + +def _patch_observation(patch: str) -> dict[str, Any] | None: + """Turn an apply_patch payload into an Edit/Created observation.""" + matches = _PATCH_FILE_RE.findall(patch) + if not matches: + return None + files = [path.strip() for _, path in matches] + verbs = {verb for verb, _ in matches} + if verbs == {"Add"}: + verb = "Created" + elif verbs == {"Delete"}: + verb = "Deleted" + else: + verb = "Edited" + name = files[0].rsplit("/", 1)[-1] + summary = f"{verb} {name}" if len(files) == 1 else f"{verb} {len(files)} files" + return {"tool": "Edit", "summary": summary, "files": files} + + +def _observation_from_call(name: str, arguments: object) -> dict[str, Any] | None: + """Map one codex ``function_call`` record into an observation, or None + to skip. Shapes match ``capture.summarize_tool``'s conventions so the + rollup renders codex and claude sessions identically.""" + if not name or name in _IGNORED_CALLS: + return None + args: dict[str, Any] = {} + if isinstance(arguments, str): + try: + loaded = json.loads(arguments) + if isinstance(loaded, dict): + args = loaded + except json.JSONDecodeError: + args = {} + elif isinstance(arguments, dict): + args = arguments + + if name == "apply_patch": + patch = str(args.get("input") or args.get("patch") or "") + obs = _patch_observation(patch) + if obs is not None: + return obs + + if name in _SHELL_CALLS or name == "apply_patch": + cmd = args.get("cmd") or args.get("command") or "" + if isinstance(cmd, list): + cmd = " ".join(str(c) for c in cmd) + cmd = str(cmd) + first_line = cmd.splitlines()[0] if cmd else "" + # Codex often applies patches through the shell tool as an + # `apply_patch < Iterator[str]: + """Yield the rollout's lines one at a time, decoded as UTF-8. + + Streams from the file handle rather than slurping the whole file into + memory — codex rollouts can carry large tool outputs. The zstd magic + header is checked up front (compressed rollouts aren't parsed here). + """ + try: + fh = path.open("rb") + except OSError as e: + raise CodexRolloutError(f"cannot read rollout file {path}: {e}") from e + with fh: + if fh.read(4) == _ZSTD_MAGIC: + raise CodexRolloutError( + f"{path.name} is zstd-compressed; decompress it first " + f"(`zstd -d {path.name}`) and ingest the .jsonl" + ) + fh.seek(0) + for raw_line in fh: + yield raw_line.decode("utf-8", errors="replace") + + +def parse_rollout(path: Path) -> CodexSession: + """Parse one rollout jsonl into a :class:`CodexSession`. + + Unknown record types are tolerated (the format drifts); a file that is + unreadable, compressed, or has no ``session_meta`` record raises + :class:`CodexRolloutError` with a message that says what to do next. + """ + session_id: str | None = None + cwd: str | None = None + started_at: str | None = None + first_prompt: str | None = None + observations: list[dict[str, Any]] = [] + # call_id -> index into observations, so a later function_call_output + # can mark the command as failed the way summarize_tool does live. + open_calls: dict[str, int] = {} + + for line in _iter_rollout_lines(path): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(record, dict): + continue + rtype = record.get("type") + payload = record.get("payload") + if not isinstance(payload, dict): + continue + + if rtype == "session_meta" and session_id is None: + sid = payload.get("id") or payload.get("session_id") + if isinstance(sid, str) and sid.strip(): + session_id = sid.strip() + raw_cwd = payload.get("cwd") + if isinstance(raw_cwd, str) and raw_cwd: + cwd = raw_cwd + raw_ts = payload.get("timestamp") + if isinstance(raw_ts, str) and raw_ts: + started_at = raw_ts + + elif rtype == "response_item": + ptype = payload.get("type") + if ptype == "function_call": + obs = _observation_from_call( + str(payload.get("name") or ""), payload.get("arguments") + ) + if obs is not None: + observations.append(obs) + call_id = payload.get("call_id") + if isinstance(call_id, str) and obs["tool"] == "Bash": + open_calls[call_id] = len(observations) - 1 + elif ptype == "function_call_output": + call_id = payload.get("call_id") + idx = open_calls.pop(call_id, None) if isinstance(call_id, str) else None + if idx is not None: + output = payload.get("output") + match = _EXIT_CODE_RE.search(str(output)) + if match and match.group(1) != "0": + obs = observations[idx] + obs["summary"] = "Command failed: " + obs["summary"].removeprefix( + "Ran: " + ) + + elif rtype == "event_msg": + if payload.get("type") == "user_message" and first_prompt is None: + msg = payload.get("message") + if isinstance(msg, str): + first_prompt = _clean_prompt(msg) + + if session_id is None: + raise CodexRolloutError( + f"{path.name}: no session_meta record found — this doesn't look " + f"like a codex rollout, or its schema has drifted; expected a " + f"session_meta record carrying an `id`" + ) + return CodexSession( + session_id=session_id, + cwd=cwd, + started_at=started_at, + first_prompt=first_prompt, + observations=observations, + ) + + +def default_codex_home() -> Path: + env = os.environ.get("CODEX_HOME") + return Path(env) if env else Path.home() / ".codex" + + +def _rollout_meta_cwd(path: Path) -> str | None: + """The ``cwd`` recorded in a rollout's ``session_meta``, or None.""" + try: + with path.open(encoding="utf-8") as fh: + first = json.loads(fh.readline()) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return None + if not isinstance(first, dict) or first.get("type") != "session_meta": + return None + payload = first.get("payload") + if isinstance(payload, dict) and isinstance(payload.get("cwd"), str): + return payload["cwd"] + return None + + +def find_latest_rollout(cwd: Path, *, codex_home: Path | None = None) -> Path | None: + """Newest rollout whose session ran in ``cwd``, or None. + + Rollout filenames embed the full timestamp + (``rollout-YYYY-MM-DDThh-mm-ss-.jsonl``), so ``path.name`` orders + chronologically regardless of directory. Rather than sort the whole + ``sessions/`` tree up front, keep the newest match seen and skip + opening any candidate that can't beat it — most rollouts never get read. + """ + sessions = (codex_home or default_codex_home()) / "sessions" + if not sessions.is_dir(): + return None + target = str(cwd.resolve()) + best: Path | None = None + for path in sessions.rglob("rollout-*.jsonl"): + if best is not None and path.name <= best.name: + continue + if _rollout_meta_cwd(path) == target: + best = path + return best + + +def find_existing_proposal(store: KBStore, session_id: str) -> str | None: + """Id of any proposal (any status) already filed for this session.""" + for proposal in store.list_proposals(None): + if proposal.session_id == session_id: + return proposal.id + return None + + +def ingest_rollout( + store: KBStore, + path: Path, + *, + actor: str | None = None, + generated_at: str | None = None, +) -> dict[str, Any]: + """Roll one rollout into a PENDING summary proposal. No ``approve()``. + + Honours the same ``capture:`` config as live capture (``enabled``, + ``min_observations``) so the two front doors gate identically, and + dedups on the rollout's session id: re-ingesting reports the existing + proposal instead of filing a second one. + """ + cfg = capture.load_config(store) + session = parse_rollout(path) + if not cfg.enabled: + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": None, + "skipped": "disabled", + } + + existing = find_existing_proposal(store, session.session_id) + if existing is not None: + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": existing, + "skipped": "already-ingested", + } + + if len(session.observations) < cfg.min_observations: + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": None, + "skipped": "below-min", + } + + project = None + if session.cwd: + project = session.cwd.rstrip("/").rsplit("/", 1)[-1] or None + title, body = capture.build_summary_body( + session.session_id, + session.observations, + [], # no git backstop: the session's working tree is long gone + "", + project=project, + generated_at=generated_at or session.started_at, + first_prompt=session.first_prompt, + ) + proposal = propose_page( + store, + title=title, + body=body, + page_type=capture.CAPTURE_PAGE_TYPE, + proposed_by=actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR, + session_id=session.session_id, + rationale="ingested codex session rollout", + ) + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": proposal.id, + } diff --git a/tests/fixtures/codex/rollout-basic.jsonl b/tests/fixtures/codex/rollout-basic.jsonl new file mode 100644 index 00000000..f4a51d9b --- /dev/null +++ b/tests/fixtures/codex/rollout-basic.jsonl @@ -0,0 +1,17 @@ +{"timestamp": "2026-07-01T09:00:00.000Z", "type": "session_meta", "payload": {"session_id": "0197aaaa-1111-7000-8000-000000000001", "id": "0197aaaa-1111-7000-8000-000000000001", "timestamp": "2026-07-01T09:00:00.000Z", "cwd": "/home/alice-example/projects/acme-app", "originator": "codex_exec", "cli_version": "0.142.0", "source": "exec"}} +{"timestamp": "2026-07-01T09:00:00.100Z", "type": "event_msg", "payload": {"type": "task_started", "turn_id": "0197aaaa-1111-7000-8000-0000000000t1"}} +{"timestamp": "2026-07-01T09:00:00.200Z", "type": "turn_context", "payload": {"turn_id": "0197aaaa-1111-7000-8000-0000000000t1", "cwd": "/home/alice-example/projects/acme-app", "model": "example-model"}} +{"timestamp": "2026-07-01T09:00:00.300Z", "type": "event_msg", "payload": {"type": "user_message", "message": "add a health endpoint to the acme api"}} +{"timestamp": "2026-07-01T09:00:01.000Z", "type": "response_item", "payload": {"type": "reasoning", "id": "rs_example", "summary": [], "encrypted_content": "opaque"}} +{"timestamp": "2026-07-01T09:00:02.000Z", "type": "response_item", "payload": {"type": "function_call", "id": "fc_1", "name": "exec_command", "arguments": "{\"cmd\":\"pytest -q\",\"workdir\":\"/home/alice-example/projects/acme-app\"}", "call_id": "call_1"}} +{"timestamp": "2026-07-01T09:00:03.000Z", "type": "response_item", "payload": {"type": "function_call_output", "call_id": "call_1", "output": "Process exited with code 1\nOutput:\n2 failed, 10 passed"}} +{"timestamp": "2026-07-01T09:00:04.000Z", "type": "response_item", "payload": {"type": "function_call", "id": "fc_2", "name": "exec_command", "arguments": "{\"cmd\":\"apply_patch <<'EOF'\\n*** Begin Patch\\n*** Update File: src/acme/api.py\\n@@\\n-old\\n+new\\n*** End Patch\\nEOF\",\"workdir\":\"/home/alice-example/projects/acme-app\"}", "call_id": "call_2"}} +{"timestamp": "2026-07-01T09:00:05.000Z", "type": "response_item", "payload": {"type": "function_call_output", "call_id": "call_2", "output": "Process exited with code 0\nOutput:\nDone!"}} +{"timestamp": "2026-07-01T09:00:06.000Z", "type": "response_item", "payload": {"type": "function_call", "id": "fc_3", "name": "update_plan", "arguments": "{\"plan\":[{\"step\":\"example\",\"status\":\"completed\"}]}", "call_id": "call_3"}} +{"timestamp": "2026-07-01T09:00:07.000Z", "type": "response_item", "payload": {"type": "function_call", "id": "fc_4", "name": "kb_search", "arguments": "{\"query\":\"health endpoint\"}", "call_id": "call_4"}} +{"timestamp": "2026-07-01T09:00:08.000Z", "type": "response_item", "payload": {"type": "function_call_output", "call_id": "call_4", "output": "{\"results\":[]}"}} +{"timestamp": "2026-07-01T09:00:09.000Z", "type": "response_item", "payload": {"type": "function_call", "id": "fc_5", "name": "exec_command", "arguments": "{\"cmd\":\"pytest -q\",\"workdir\":\"/home/alice-example/projects/acme-app\"}", "call_id": "call_5"}} +{"timestamp": "2026-07-01T09:00:10.000Z", "type": "response_item", "payload": {"type": "function_call_output", "call_id": "call_5", "output": "Process exited with code 0\nOutput:\n12 passed"}} +{"timestamp": "2026-07-01T09:00:11.000Z", "type": "world_state", "payload": {"unknown_future_field": true}} +{"timestamp": "2026-07-01T09:00:12.000Z", "type": "event_msg", "payload": {"type": "agent_message", "message": "added the endpoint and the tests pass"}} +{"timestamp": "2026-07-01T09:00:13.000Z", "type": "event_msg", "payload": {"type": "task_complete", "turn_id": "0197aaaa-1111-7000-8000-0000000000t1", "last_agent_message": "added the endpoint and the tests pass"}} diff --git a/tests/fixtures/codex/rollout-no-meta.jsonl b/tests/fixtures/codex/rollout-no-meta.jsonl new file mode 100644 index 00000000..cc1d9252 --- /dev/null +++ b/tests/fixtures/codex/rollout-no-meta.jsonl @@ -0,0 +1,2 @@ +{"timestamp": "2026-07-01T09:00:00.300Z", "type": "event_msg", "payload": {"type": "user_message", "message": "hello"}} +{"timestamp": "2026-07-01T09:00:02.000Z", "type": "response_item", "payload": {"type": "function_call", "id": "fc_1", "name": "exec_command", "arguments": "{\"cmd\":\"ls\"}", "call_id": "call_1"}} diff --git a/tests/test_codex_rollout.py b/tests/test_codex_rollout.py new file mode 100644 index 00000000..353b6046 --- /dev/null +++ b/tests/test_codex_rollout.py @@ -0,0 +1,299 @@ +"""`vouch capture ingest-codex` — codex rollout parsing + review-gated +ingest (vouchdev/vouch#387). + +Codex persists sessions as rollout jsonl files instead of emitting live +hooks. The parser maps rollout records into capture observations; the +ingest path reuses the existing rollup so a codex session yields the same +kind of PENDING page proposal a claude session does, deduped on the +rollout's session id. Fixtures use placeholder data only (alice-example). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import codex_rollout as cr +from vouch.cli import cli +from vouch.models import ProposalKind, ProposalStatus +from vouch.storage import KBStore + +FIXTURES = Path(__file__).parent / "fixtures" / "codex" +BASIC = FIXTURES / "rollout-basic.jsonl" +NO_META = FIXTURES / "rollout-no-meta.jsonl" +BASIC_SESSION = "0197aaaa-1111-7000-8000-000000000001" + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +# --- parser ---------------------------------------------------------------- + + +def test_parse_basic_rollout_extracts_session_meta() -> None: + session = cr.parse_rollout(BASIC) + assert session.session_id == BASIC_SESSION + assert session.cwd == "/home/alice-example/projects/acme-app" + assert session.started_at == "2026-07-01T09:00:00.000Z" + assert session.first_prompt == "add a health endpoint to the acme api" + + +def test_parse_maps_tool_calls_to_observations() -> None: + session = cr.parse_rollout(BASIC) + summaries = [o["summary"] for o in session.observations] + # exec_command with a non-zero exit is marked failed, like live capture + assert "Command failed: pytest -q" in summaries + # ...and the same command succeeding later reads as a plain run + assert "Ran: pytest -q" in summaries + # an apply_patch heredoc through the shell surfaces as a file edit + edit = next(o for o in session.observations if o["tool"] == "Edit") + assert edit["files"] == ["src/acme/api.py"] + assert edit["summary"] == "Edited api.py" + # mcp/custom tools keep their own name + assert any(o["tool"] == "kb_search" for o in session.observations) + # session mechanics (update_plan) are not observations + assert not any("update_plan" in o["tool"] for o in session.observations) + + +def test_parse_keeps_bash_cmd_for_notable_commands() -> None: + session = cr.parse_rollout(BASIC) + bash = [o for o in session.observations if o["tool"] == "Bash"] + assert all(o.get("cmd") for o in bash) + + +def test_patch_observation_verbs() -> None: + add = cr._patch_observation("*** Add File: src/new.py\n") + assert add is not None and add["summary"] == "Created new.py" + delete = cr._patch_observation("*** Delete File: src/old.py\n") + assert delete is not None and delete["summary"] == "Deleted old.py" + update = cr._patch_observation("*** Update File: src/mod.py\n") + assert update is not None and update["summary"] == "Edited mod.py" + mixed = cr._patch_observation("*** Add File: a.py\n*** Delete File: b.py\n") + assert mixed is not None and mixed["summary"] == "Edited 2 files" + + +def test_parse_tolerates_unknown_record_types() -> None: + # rollout-basic.jsonl includes a world_state record and a reasoning + # item; both must be skipped, not fatal. + session = cr.parse_rollout(BASIC) + assert len(session.observations) == 4 + + +def test_parse_without_session_meta_is_actionable_error() -> None: + with pytest.raises(cr.CodexRolloutError, match="session_meta"): + cr.parse_rollout(NO_META) + + +def test_parse_missing_file_is_actionable_error(tmp_path: Path) -> None: + with pytest.raises(cr.CodexRolloutError, match="cannot read"): + cr.parse_rollout(tmp_path / "nope.jsonl") + + +def test_parse_zstd_compressed_is_actionable_error(tmp_path: Path) -> None: + frame = tmp_path / "rollout-2026-07-01T09-00-00-x.jsonl" + frame.write_bytes(b"\x28\xb5\x2f\xfd" + b"\x00" * 16) + with pytest.raises(cr.CodexRolloutError, match="zstd"): + cr.parse_rollout(frame) + + +# --- ingest ---------------------------------------------------------------- + + +def test_ingest_files_one_pending_page(store: KBStore) -> None: + result = cr.ingest_rollout(store, BASIC, generated_at="2026-07-01T10:00:00Z") + pid = result["summary_proposal_id"] + assert pid is not None + assert result["captured"] == 4 + pend = store.list_proposals(ProposalStatus.PENDING) + assert [p.id for p in pend] == [pid] + pr = pend[0] + assert pr.kind == ProposalKind.PAGE + assert pr.proposed_by == cr.CODEX_ACTOR + assert pr.session_id == BASIC_SESSION + assert pr.payload["type"] == "session" + body = pr.payload["body"] + assert "src/acme/api.py" in body + assert "pytest -q" in body + assert pr.payload["title"].startswith("session: add a health endpoint") + assert "[acme-app]" in pr.payload["title"] + + +def test_ingest_respects_vouch_agent_env(store: KBStore, monkeypatch) -> None: + monkeypatch.setenv("VOUCH_AGENT", "codex-nightly") + cr.ingest_rollout(store, BASIC) + pend = store.list_proposals(ProposalStatus.PENDING) + assert pend[0].proposed_by == "codex-nightly" + + +def test_reingest_same_session_is_noop(store: KBStore) -> None: + first = cr.ingest_rollout(store, BASIC) + second = cr.ingest_rollout(store, BASIC) + assert second["skipped"] == "already-ingested" + assert second["summary_proposal_id"] == first["summary_proposal_id"] + assert len(store.list_proposals(None)) == 1 + + +def test_ingest_below_min_files_nothing(store: KBStore) -> None: + store.config_path.write_text("capture:\n min_observations: 99\n") + result = cr.ingest_rollout(store, BASIC) + assert result["skipped"] == "below-min" + assert result["summary_proposal_id"] is None + assert store.list_proposals(None) == [] + + +def test_ingest_noop_when_capture_disabled(store: KBStore) -> None: + store.config_path.write_text("capture:\n enabled: false\n") + result = cr.ingest_rollout(store, BASIC) + assert result["skipped"] == "disabled" + assert store.list_proposals(None) == [] + + +def test_ingest_never_writes_approved_content(store: KBStore) -> None: + """The review gate stays intact: ingest files a proposal, not a page.""" + cr.ingest_rollout(store, BASIC) + assert store.list_pages() == [] + + +# --- --latest resolution ---------------------------------------------------- + + +def _write_rollout(sessions: Path, day: str, stamp: str, sid: str, cwd: str) -> Path: + d = sessions / day + d.mkdir(parents=True, exist_ok=True) + path = d / f"rollout-{stamp}-{sid}.jsonl" + meta = { + "timestamp": f"{day.replace('/', '-')}T00:00:00.000Z", + "type": "session_meta", + "payload": {"id": sid, "session_id": sid, "cwd": cwd}, + } + path.write_text(json.dumps(meta) + "\n", encoding="utf-8") + return path + + +def test_find_latest_rollout_matches_project_cwd(tmp_path: Path) -> None: + home = tmp_path / "codex-home" + sessions = home / "sessions" + project = tmp_path / "proj" + project.mkdir() + other = "/home/alice-example/elsewhere" + _write_rollout(sessions, "2026/07/01", "2026-07-01T08-00-00", "aaa", str(project)) + newest_match = _write_rollout( + sessions, "2026/07/02", "2026-07-02T08-00-00", "bbb", str(project) + ) + _write_rollout(sessions, "2026/07/03", "2026-07-03T08-00-00", "ccc", other) + found = cr.find_latest_rollout(project, codex_home=home) + assert found == newest_match + + +def test_find_latest_rollout_none_when_no_match(tmp_path: Path) -> None: + home = tmp_path / "codex-home" + _write_rollout(home / "sessions", "2026/07/01", "2026-07-01T08-00-00", + "aaa", "/home/alice-example/elsewhere") + project = tmp_path / "proj" + project.mkdir() + assert cr.find_latest_rollout(project, codex_home=home) is None + + +def test_find_latest_rollout_none_without_sessions_dir(tmp_path: Path) -> None: + assert cr.find_latest_rollout(tmp_path, codex_home=tmp_path / "nope") is None + + +# --- CLI surface ------------------------------------------------------------ + + +def test_cli_ingest_codex_files_proposal(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + res = CliRunner().invoke(cli, ["capture", "ingest-codex", str(BASIC)]) + assert res.exit_code == 0, res.output + out = json.loads(res.output) + assert out["summary_proposal_id"] + assert out["session_id"] == BASIC_SESSION + + +def test_cli_ingest_codex_reingest_reports_noop(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + runner = CliRunner() + runner.invoke(cli, ["capture", "ingest-codex", str(BASIC)]) + res = runner.invoke(cli, ["capture", "ingest-codex", str(BASIC)]) + assert res.exit_code == 0, res.output + assert json.loads(res.output)["skipped"] == "already-ingested" + + +def test_cli_ingest_codex_malformed_is_clean_error( + store: KBStore, monkeypatch +) -> None: + monkeypatch.chdir(store.kb_dir.parent) + res = CliRunner().invoke(cli, ["capture", "ingest-codex", str(NO_META)]) + assert res.exit_code != 0 + assert "Traceback" not in res.output + assert "Error:" in res.output + assert store.list_proposals(None) == [] + + +def test_cli_ingest_codex_requires_exactly_one_source( + store: KBStore, monkeypatch +) -> None: + monkeypatch.chdir(store.kb_dir.parent) + runner = CliRunner() + neither = runner.invoke(cli, ["capture", "ingest-codex"]) + assert neither.exit_code != 0 + assert "exactly one" in neither.output + both = runner.invoke(cli, ["capture", "ingest-codex", str(BASIC), "--latest"]) + assert both.exit_code != 0 + assert "exactly one" in both.output + + +def test_cli_ingest_codex_latest_resolves_for_project( + store: KBStore, tmp_path: Path, monkeypatch +) -> None: + project = store.kb_dir.parent + home = tmp_path / "codex-home" + sid = "0197bbbb-2222-7000-8000-000000000002" + path = _write_rollout( + home / "sessions", "2026/07/02", "2026-07-02T09-00-00", sid, + str(project.resolve()), + ) + # give the rollout enough activity to clear min_observations + with path.open("a", encoding="utf-8") as fh: + for i in range(3): + fh.write(json.dumps({ + "timestamp": "2026-07-02T09:00:01.000Z", + "type": "response_item", + "payload": { + "type": "function_call", "name": "exec_command", + "arguments": json.dumps({"cmd": f"echo step-{i}"}), + "call_id": f"call_{i}", + }, + }) + "\n") + monkeypatch.chdir(project) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--latest", "--codex-home", str(home)], + ) + assert res.exit_code == 0, res.output + assert json.loads(res.output)["session_id"] == sid + + +def test_cli_ingest_codex_latest_no_rollout_is_clean_error( + store: KBStore, tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(store.kb_dir.parent) + res = CliRunner().invoke( + cli, + ["capture", "ingest-codex", "--latest", "--codex-home", + str(tmp_path / "empty-home")], + ) + assert res.exit_code != 0 + assert "no codex rollout found" in res.output + + +def test_fixtures_use_placeholder_data_only() -> None: + """Privacy rule: fixture rollouts must not carry real paths or names.""" + for fixture in FIXTURES.glob("*.jsonl"): + text = fixture.read_text(encoding="utf-8") + assert "alice-example" in text or "session_meta" not in text + assert "/home/a/" not in text From 79ed4c13b61691950a4d1a3ee43fa8340c99617f Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:44:50 +0900 Subject: [PATCH 14/34] feat(adapters): codex T4 hook wiring for automatic session capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit with `vouch capture ingest-codex` available, codex can self-capture the way claude-code T4 does. the ticket assumed the `notify` setting in config.toml, but codex only honours notify in user-global config — it is a restricted key in project-local layers — and the #179 rule forbids touching ~/.codex. codex's hooks system is the project-local equivalent: `.codex/hooks.json` (loaded in trusted projects) fires Stop when a turn completes, with a payload carrying the session id and transcript path. T4 ships that file through json_merge, so an existing user hooks.json is deep-merged into, never overwritten, and re-runs don't duplicate the hook. the handler is a new --hook mode on ingest-codex: read the stop payload from stdin, resolve the session's rollout (transcript_path when present, else by session id in the rollout filename), ingest idempotently, and exit 0 no matter what — the same never-break-the- host rule capture observe follows. stop fires per turn, not per session end, so the finalize policy the ticket left open is resolved as idempotent re-ingest: the dedup guard now refreshes a session's still-PENDING proposal in place when the rollout grew (same id, updated summary, audit-logged), reports an unchanged rollout as a no-op, and never resurrects a proposal a human already decided. storage gains update_proposal, a pure-I/O in-place rewrite of a pending proposal file. wiring only — everything still lands through propose_page; nothing touches approve(). closes #388 --- adapters/codex/README.md | 26 ++++++ adapters/codex/hooks.json | 15 +++ adapters/codex/install.yaml | 11 +++ src/vouch/cli.py | 34 +++++-- src/vouch/codex_rollout.py | 119 ++++++++++++++++++++++-- src/vouch/storage.py | 14 +++ tests/test_codex_rollout.py | 166 +++++++++++++++++++++++++++++++++- tests/test_install_adapter.py | 51 +++++++++++ 8 files changed, 419 insertions(+), 17 deletions(-) create mode 100644 adapters/codex/hooks.json diff --git a/adapters/codex/README.md b/adapters/codex/README.md index ae028608..562b7290 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -52,6 +52,32 @@ yourself. The skill bodies are identical to the claude-code slash commands (enforced by a sync test), so the flows behave the same on every host. +## Automatic session capture (T4) + +`vouch install-mcp codex` (default tier T4) wires automatic capture +through codex's hooks system: `.codex/hooks.json` registers a `Stop` +hook that runs `vouch capture ingest-codex --hook` when a turn +completes. The handler reads the hook payload, resolves the session's +rollout file, and rolls it into ONE pending session-summary proposal — +the same review-gated summary a claude-code session produces. Because +`Stop` fires per turn, re-ingest is idempotent: an unchanged session +is a no-op, a session that grew refreshes its pending proposal in +place, and a proposal you've already reviewed is never resurrected. + +Failure semantics match `capture observe`: the `--hook` mode exits 0 +no matter what, so a capture problem can never break your codex turn. +Nothing is auto-approved — review with `vouch review`. + +Hooks merge, not clobber: if you already have a `.codex/hooks.json`, +the installer deep-merges the vouch hook in next to yours (re-runs +don't duplicate it). Project-local hooks run in trusted projects only, +so trust the project when codex asks. + +Why not codex's `notify` setting: codex honours `notify` only in +user-global config (`~/.codex/config.toml`), which a project-scoped +install never touches. If you prefer notify anyway, point it at a +wrapper that calls `vouch capture ingest-codex --hook` yourself. + ## Notes - Codex respects MCP tool naming verbatim, so the tools appear as diff --git a/adapters/codex/hooks.json b/adapters/codex/hooks.json new file mode 100644 index 00000000..acaf8d04 --- /dev/null +++ b/adapters/codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "vouch capture ingest-codex --hook", + "statusMessage": "vouch capture" + } + ] + } + ] + } +} diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml index 016be7ba..9d4cb447 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -38,3 +38,14 @@ tiers: - { src: ../openclaw/skills/vouch-record/SKILL.md, dst: .codex/skills/vouch-record/SKILL.md } - { src: ../openclaw/skills/vouch-followup/SKILL.md, dst: .codex/skills/vouch-followup/SKILL.md } - { src: ../openclaw/skills/vouch-standup/SKILL.md, dst: .codex/skills/vouch-standup/SKILL.md } + # T4 = automatic session capture -- see issue #388. codex's hooks system + # fires Stop when a turn completes; the handler re-ingests the session's + # rollout idempotently (`vouch capture ingest-codex --hook` exits 0 even + # on failure, so capture can never break a codex turn), updating the + # session's single PENDING summary proposal as the session grows. + # hooks live in their own `.codex/hooks.json` file (project-local in + # trusted projects) and json_merge preserves any hooks the user already + # has. note: the legacy `notify` setting can't be used here -- codex only + # honours it in user-global config, which the #179 rule forbids touching. + T4: + - { src: hooks.json, dst: .codex/hooks.json, json_merge: true } diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 329799a1..8cd475a4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2079,22 +2079,44 @@ def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> "--latest", is_flag=True, help="Resolve the newest codex rollout recorded for this project (by cwd).", ) +@click.option( + "--hook", "hook_mode", is_flag=True, + help="Read a codex Stop-hook payload from stdin; never fails the host " + "(exits 0 even on errors, like `capture observe`).", +) @click.option( "--codex-home", type=click.Path(file_okay=False, path_type=Path), default=None, help="Codex state dir holding sessions/ (default: $CODEX_HOME or ~/.codex).", ) def capture_ingest_codex_cmd( - rollout: Path | None, latest: bool, codex_home: Path | None + rollout: Path | None, latest: bool, hook_mode: bool, codex_home: Path | None ) -> None: """Ingest one codex session rollout into a PENDING summary proposal. - Codex has no live hook stream; it persists each session as a rollout - jsonl instead. This maps the rollout's tool calls into the same - observation shape `capture observe` produces and reuses the existing - rollup, so the result is the same review-gated summary a claude - session yields. Re-ingesting a session is a no-op; review with + Codex has no live notify stream vouch can use project-locally; it + persists each session as a rollout jsonl instead. This maps the + rollout's tool calls into the same observation shape `capture observe` + produces and reuses the existing rollup, so the result is the same + review-gated summary a claude session yields. Re-ingesting an + unchanged session is a no-op; a session that grew since the last + ingest refreshes its one PENDING proposal in place. Review with `vouch review`. """ + if hook_mode: + # Hook wire (codex `Stop` event): parse the stdin payload, resolve + # the session's rollout, ingest idempotently. Exits 0 no matter + # what — capture must never break the user's codex turn. + try: + raw = "" if sys.stdin.isatty() else sys.stdin.read() + payload = json.loads(raw) if raw.strip() else {} + if isinstance(payload, dict): + codex_rollout_mod.ingest_hook_payload( + _capture_store(), payload, codex_home=codex_home + ) + except Exception: + # the hook contract is exit 0 — never surface an error here. + pass + return if (rollout is None) == (not latest): raise click.ClickException("pass exactly one of ROLLOUT or --latest") store = _load_store() diff --git a/src/vouch/codex_rollout.py b/src/vouch/codex_rollout.py index c94684c7..b23d409a 100644 --- a/src/vouch/codex_rollout.py +++ b/src/vouch/codex_rollout.py @@ -31,7 +31,8 @@ from pathlib import Path from typing import Any -from . import capture +from . import audit, capture +from .models import Proposal, ProposalStatus from .proposals import propose_page from .storage import KBStore @@ -297,14 +298,46 @@ def find_latest_rollout(cwd: Path, *, codex_home: Path | None = None) -> Path | return best -def find_existing_proposal(store: KBStore, session_id: str) -> str | None: - """Id of any proposal (any status) already filed for this session.""" +def find_existing_proposal(store: KBStore, session_id: str) -> Proposal | None: + """Any proposal (any status) already filed for this session.""" for proposal in store.list_proposals(None): if proposal.session_id == session_id: - return proposal.id + return proposal return None +def find_rollout_by_session_id( + session_id: str, *, codex_home: Path | None = None +) -> Path | None: + """The rollout file for one session id — codex embeds the id in the + filename (``rollout--.jsonl``), so no file needs opening. + + The session id comes from a hook payload, so it's matched as a literal + filename suffix rather than interpolated into the glob pattern: a + payload carrying glob metacharacters (``*``, ``?``, ``[``) can't widen + the search or change its semantics. + """ + sessions = (codex_home or default_codex_home()) / "sessions" + if not sessions.is_dir() or not session_id.strip(): + return None + suffix = f"-{session_id}.jsonl" + best: Path | None = None + for path in sessions.rglob("rollout-*.jsonl"): + if not path.name.endswith(suffix): + continue + if best is None or path.name > best.name: + best = path + return best + + +def _comparable_body(body: str) -> str: + """The summary body minus its generation timestamp, so re-ingesting an + unchanged rollout compares equal across runs.""" + return "\n".join( + line for line in body.splitlines() if not line.startswith("- generated:") + ) + + def ingest_rollout( store: KBStore, path: Path, @@ -316,8 +349,12 @@ def ingest_rollout( Honours the same ``capture:`` config as live capture (``enabled``, ``min_observations``) so the two front doors gate identically, and - dedups on the rollout's session id: re-ingesting reports the existing - proposal instead of filing a second one. + dedups on the rollout's session id: at most one proposal per session, + ever. Because codex's Stop hook fires per *turn* rather than at session + end, re-ingesting a session that grew since the last ingest refreshes + the still-PENDING proposal in place (same id, updated summary) instead + of filing a duplicate; an unchanged rollout is a flat no-op, and a + decided proposal is history — it blocks re-ingest regardless. """ cfg = capture.load_config(store) session = parse_rollout(path) @@ -330,11 +367,11 @@ def ingest_rollout( } existing = find_existing_proposal(store, session.session_id) - if existing is not None: + if existing is not None and existing.status != ProposalStatus.PENDING: return { "session_id": session.session_id, "captured": len(session.observations), - "summary_proposal_id": existing, + "summary_proposal_id": existing.id, "skipped": "already-ingested", } @@ -358,12 +395,42 @@ def ingest_rollout( generated_at=generated_at or session.started_at, first_prompt=session.first_prompt, ) + resolved_actor = actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR + + if existing is not None: + if _comparable_body(body) == _comparable_body( + str(existing.payload.get("body", "")) + ): + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": existing.id, + "skipped": "already-ingested", + } + refreshed = existing.model_copy(deep=True) + refreshed.payload["title"] = title.strip() + refreshed.payload["body"] = body + store.update_proposal(refreshed) + audit.log_event( + store.kb_dir, + event="proposal.page.update", + actor=resolved_actor, + object_ids=[existing.id], + data={"reason": "codex rollout re-ingest", "captured": len(session.observations)}, + ) + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": existing.id, + "updated": True, + } + proposal = propose_page( store, title=title, body=body, page_type=capture.CAPTURE_PAGE_TYPE, - proposed_by=actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR, + proposed_by=resolved_actor, session_id=session.session_id, rationale="ingested codex session rollout", ) @@ -372,3 +439,37 @@ def ingest_rollout( "captured": len(session.observations), "summary_proposal_id": proposal.id, } + + +def ingest_hook_payload( + store: KBStore | None, + payload: dict[str, Any], + *, + codex_home: Path | None = None, +) -> dict[str, Any] | None: + """Handle one codex Stop-hook payload; never raises. + + The hook wire (`vouch capture ingest-codex --hook`) must exit 0 even on + failure — a capture problem must never break the user's codex turn, + the same rule ``capture observe`` follows. Returns the ingest result, + or None when there was nothing safe to do. + """ + try: + if store is None: + return None + session_id = str(payload.get("session_id") or "") + if not session_id: + return None + rollout: Path | None = None + transcript = payload.get("transcript_path") + if isinstance(transcript, str) and transcript.endswith(".jsonl"): + candidate = Path(transcript) + if candidate.is_file(): + rollout = candidate + if rollout is None: + rollout = find_rollout_by_session_id(session_id, codex_home=codex_home) + if rollout is None: + return None + return ingest_rollout(store, rollout) + except Exception: + return None diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 653ea806..2024164c 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -815,6 +815,20 @@ def put_proposal(self, proposal: Proposal) -> Proposal: ) from e return proposal + def update_proposal(self, proposal: Proposal) -> Proposal: + """Overwrite an existing *pending* proposal file in place. + + Pure I/O: the caller decides whether a refresh is legitimate (only + pending proposals may be rewritten — a decided one is history). + """ + path = self._proposal_path(proposal.id) + if not path.exists(): + raise ArtifactNotFoundError(f"proposal {proposal.id}") + path.write_text( + _yaml_dump(proposal.model_dump(mode="json")), encoding="utf-8" + ) + return proposal + def get_proposal(self, proposal_id: str) -> Proposal: for path in (self._proposal_path(proposal_id), self._decided_path(proposal_id)): if path.exists(): diff --git a/tests/test_codex_rollout.py b/tests/test_codex_rollout.py index 353b6046..2969008c 100644 --- a/tests/test_codex_rollout.py +++ b/tests/test_codex_rollout.py @@ -139,7 +139,9 @@ def test_reingest_same_session_is_noop(store: KBStore) -> None: def test_ingest_below_min_files_nothing(store: KBStore) -> None: - store.config_path.write_text("capture:\n min_observations: 99\n") + store.config_path.write_text( + "capture:\n min_observations: 99\n", encoding="utf-8" + ) result = cr.ingest_rollout(store, BASIC) assert result["skipped"] == "below-min" assert result["summary_proposal_id"] is None @@ -147,7 +149,9 @@ def test_ingest_below_min_files_nothing(store: KBStore) -> None: def test_ingest_noop_when_capture_disabled(store: KBStore) -> None: - store.config_path.write_text("capture:\n enabled: false\n") + store.config_path.write_text( + "capture:\n enabled: false\n", encoding="utf-8" + ) result = cr.ingest_rollout(store, BASIC) assert result["skipped"] == "disabled" assert store.list_proposals(None) == [] @@ -159,6 +163,164 @@ def test_ingest_never_writes_approved_content(store: KBStore) -> None: assert store.list_pages() == [] +# --- per-turn re-ingest: refresh the pending proposal (vouchdev/vouch#388) -- + + +def _grown_copy(tmp_path: Path) -> Path: + """BASIC plus one more tool call, same session id — a later turn.""" + grown = tmp_path / f"rollout-2026-07-01T09-30-00-{BASIC_SESSION}.jsonl" + extra = { + "timestamp": "2026-07-01T09:30:00.000Z", + "type": "response_item", + "payload": { + "type": "function_call", "name": "exec_command", + "arguments": json.dumps({"cmd": "ruff check src"}), + "call_id": "call_extra", + }, + } + grown.write_text( + BASIC.read_text(encoding="utf-8") + json.dumps(extra) + "\n", + encoding="utf-8", + ) + return grown + + +def test_reingest_grown_session_updates_pending_in_place( + store: KBStore, tmp_path: Path +) -> None: + first = cr.ingest_rollout(store, BASIC) + pid = first["summary_proposal_id"] + second = cr.ingest_rollout(store, _grown_copy(tmp_path)) + assert second["updated"] is True + assert second["summary_proposal_id"] == pid + proposals = store.list_proposals(None) + assert len(proposals) == 1 # refreshed, not duplicated + assert "ruff check src" in proposals[0].payload["body"] + assert proposals[0].status == ProposalStatus.PENDING + + +def test_reingest_decided_session_stays_decided( + store: KBStore, tmp_path: Path +) -> None: + """A proposal the human already reviewed is history — a later turn must + not resurrect or mutate it.""" + from vouch.proposals import approve + + first = cr.ingest_rollout(store, BASIC) + approve(store, first["summary_proposal_id"], approved_by="alice-example") + result = cr.ingest_rollout(store, _grown_copy(tmp_path)) + assert result["skipped"] == "already-ingested" + assert len(store.list_proposals(ProposalStatus.PENDING)) == 0 + + +def test_reingest_update_lands_in_audit_log(store: KBStore, tmp_path: Path) -> None: + from vouch import audit + + cr.ingest_rollout(store, BASIC) + cr.ingest_rollout(store, _grown_copy(tmp_path)) + events = [e.event for e in audit.read_events(store.kb_dir)] + assert "proposal.page.update" in events + + +# --- hook wire (--hook): codex Stop event ------------------------------------ + + +def test_ingest_hook_payload_files_proposal(store: KBStore) -> None: + result = cr.ingest_hook_payload( + store, + {"session_id": BASIC_SESSION, "transcript_path": str(BASIC), + "hook_event_name": "Stop", "cwd": str(store.kb_dir.parent)}, + ) + assert result is not None + assert result["summary_proposal_id"] + + +def test_ingest_hook_payload_resolves_by_session_id( + store: KBStore, tmp_path: Path +) -> None: + home = tmp_path / "codex-home" + day = home / "sessions" / "2026" / "07" / "01" + day.mkdir(parents=True) + rollout = day / f"rollout-2026-07-01T09-00-00-{BASIC_SESSION}.jsonl" + rollout.write_text(BASIC.read_text(encoding="utf-8"), encoding="utf-8") + result = cr.ingest_hook_payload( + store, {"session_id": BASIC_SESSION}, codex_home=home + ) + assert result is not None + assert result["summary_proposal_id"] + + +def test_ingest_hook_payload_never_raises(store: KBStore, tmp_path: Path) -> None: + assert cr.ingest_hook_payload(None, {"session_id": "x"}) is None + assert cr.ingest_hook_payload(store, {}) is None + assert ( + cr.ingest_hook_payload( + store, {"session_id": "no-such-session"}, + codex_home=tmp_path / "empty", + ) + is None + ) + + +def test_cli_hook_mode_files_proposal(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + payload = json.dumps({ + "session_id": BASIC_SESSION, + "transcript_path": str(BASIC), + "hook_event_name": "Stop", + }) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--hook"], input=payload + ) + assert res.exit_code == 0, res.output + assert len(store.list_proposals(ProposalStatus.PENDING)) == 1 + + +def test_cli_hook_mode_exits_zero_on_garbage(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--hook"], input="{ not json" + ) + assert res.exit_code == 0, res.output + + +def test_cli_hook_mode_exits_zero_without_kb(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) # no .vouch anywhere above tmp_path + payload = json.dumps({"session_id": BASIC_SESSION, "transcript_path": str(BASIC)}) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--hook"], input=payload + ) + assert res.exit_code == 0, res.output + + +def test_find_rollout_by_session_id(tmp_path: Path) -> None: + home = tmp_path / "codex-home" + day = home / "sessions" / "2026" / "07" / "01" + day.mkdir(parents=True) + target = day / "rollout-2026-07-01T09-00-00-sess-42.jsonl" + target.write_text("{}", encoding="utf-8") + (day / "rollout-2026-07-01T10-00-00-sess-43.jsonl").write_text( + "{}", encoding="utf-8" + ) + assert cr.find_rollout_by_session_id("sess-42", codex_home=home) == target + assert cr.find_rollout_by_session_id("sess-99", codex_home=home) is None + + +def test_find_rollout_by_session_id_treats_glob_chars_literally( + tmp_path: Path, +) -> None: + """A session id from a hook payload is matched as a literal suffix, so + glob metacharacters can't widen the search to unrelated rollouts.""" + home = tmp_path / "codex-home" + day = home / "sessions" / "2026" / "07" / "01" + day.mkdir(parents=True) + (day / "rollout-2026-07-01T09-00-00-sess-42.jsonl").write_text( + "{}", encoding="utf-8" + ) + # `*` must not match the real session above. + assert cr.find_rollout_by_session_id("sess-*", codex_home=home) is None + + # --- --latest resolution ---------------------------------------------------- diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index e4adad83..64fe097f 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -523,6 +523,57 @@ def test_install_codex_t3_is_idempotent(tmp_path: Path) -> None: assert f".codex/skills/{name}/SKILL.md" in second.skipped +# --- codex: T4 hooks.json capture wiring (vouchdev/vouch#388) --------------- + + +def test_codex_t4_writes_hooks_json(tmp_path: Path) -> None: + """Fresh T4 install wires the Stop hook so a completed codex session + lands as a PENDING summary proposal with no manual steps.""" + result = install("codex", target=tmp_path, tier="T4") + hooks_path = tmp_path / ".codex" / "hooks.json" + assert hooks_path.is_file() + assert ".codex/hooks.json" in result.written + data = json.loads(hooks_path.read_text(encoding="utf-8")) + cmds = [ + h["command"] + for g in data["hooks"]["Stop"] + for h in g["hooks"] + ] + assert "vouch capture ingest-codex --hook" in cmds + + +def test_codex_t4_merges_into_existing_hooks_json(tmp_path: Path) -> None: + """An existing user hook is never silently overwritten — ours merges in + next to it via the json_merge machinery.""" + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "my-own-stop-hook"}]} + ] + } + }), encoding="utf-8") + result = install("codex", target=tmp_path, tier="T4") + data = json.loads((codex_dir / "hooks.json").read_text(encoding="utf-8")) + cmds = [h["command"] for g in data["hooks"]["Stop"] for h in g["hooks"]] + assert "my-own-stop-hook" in cmds + assert "vouch capture ingest-codex --hook" in cmds + assert ".codex/hooks.json" in result.merged + + +def test_codex_t4_rerun_does_not_duplicate_hook(tmp_path: Path) -> None: + install("codex", target=tmp_path, tier="T4") + first = (tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8") + second = install("codex", target=tmp_path, tier="T4") + after = (tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8") + assert first == after + assert ".codex/hooks.json" in second.skipped + data = json.loads(after) + cmds = [h["command"] for g in data["hooks"]["Stop"] for h in g["hooks"]] + assert cmds.count("vouch capture ingest-codex --hook") == 1 + + # --- codex: config.toml deep-merge (vouchdev/vouch#384) --------------------- From f8020a2a695500eb1643c28ef1ab9d25033f43cf Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:49:18 +0900 Subject: [PATCH 15/34] test(adapters): live codex install gate against the real cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/test_openclaw_plugin_load_real.py set the pattern: exercise the real host cli when it's on PATH, skip cleanly where it isn't. the codex adapter had no equivalent, so regressions in the shipped config only surfaced when a user hit them — the old T1 silent-skip no-op is exactly the class of bug a live gate would have caught. the new suite installs the adapter into a temp project at T4, marks the project trusted inside an isolated CODEX_HOME, and asserts through `codex mcp list --json` that the vouch server is visible: next to a pre-seeded unrelated server on the merge path, alone on the fresh path, and absent for an untrusted project (the config must stay inert where codex says it does). a full-tier check covers the fenced AGENTS.md, the skills, and the Stop hook, and an isolation check pins every written artifact under the temp project. assertions target the observable contract — server listed, config parses, snippet present — not codex internals, so version bumps shouldn't break the suite. no network, no credentials, never touches the real ~/.codex; runs in the normal pytest invocation. closes #389 --- tests/test_codex_adapter_load_real.py | 199 ++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/test_codex_adapter_load_real.py diff --git a/tests/test_codex_adapter_load_real.py b/tests/test_codex_adapter_load_real.py new file mode 100644 index 00000000..de230b49 --- /dev/null +++ b/tests/test_codex_adapter_load_real.py @@ -0,0 +1,199 @@ +"""Tier-2 e2e: the real Codex CLI reads the installed adapter (#389). + +The unit tests in test_install_adapter.py assert what the installer writes; +nothing there proves the real codex CLI accepts it — the old T1 silent-skip +behavior (installer no-op whenever `.codex/config.toml` existed) is exactly +the class of bug only a live gate catches. This suite closes that gap, +following the pattern tests/test_openclaw_plugin_load_real.py set: install +the adapter into a temp project at the highest shipped tier, mark the +project trusted inside an isolated ``CODEX_HOME``, and assert through codex +itself (``codex mcp list --json``) that the vouch server is visible — next +to a pre-seeded unrelated server on the merge path, and alone on the +fresh-install path. + +Assertions target the observable contract (server listed, config parses, +snippet present), not codex internals, so codex version bumps shouldn't +break the suite. Skips when the ``codex`` CLI is not on PATH (e.g. GitHub +CI). Every codex invocation runs with a throwaway ``CODEX_HOME`` and a temp +project cwd, so the user's real ``~/.codex`` is never touched; listing +configured servers needs no network and no credentials. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest + +from vouch.install_adapter import install + +CODEX = shutil.which("codex") + +pytestmark = pytest.mark.skipif(CODEX is None, reason="codex CLI not on PATH") + + +def _run_codex( + env: dict[str, str], cwd: Path, *args: str +) -> subprocess.CompletedProcess[str]: + assert CODEX is not None + return subprocess.run( + [CODEX, *args], + env=env, + cwd=cwd, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + +def _isolated_env(home: Path) -> dict[str, str]: + home.mkdir(parents=True, exist_ok=True) + return {**os.environ, "CODEX_HOME": str(home)} + + +def _trust(home: Path, project: Path) -> None: + # Codex loads project-scoped .codex/ layers only for trusted projects; + # this is the non-interactive equivalent of answering the trust prompt. + # The path is a TOML quoted key, so escape it with json.dumps (TOML + # basic strings share JSON's escaping) — a backslash or quote in the + # path would otherwise produce invalid TOML. + key = json.dumps(str(project)) + with (home / "config.toml").open("a", encoding="utf-8") as fh: + fh.write(f'[projects.{key}]\ntrust_level = "trusted"\n') + + +def _mcp_servers(env: dict[str, str], cwd: Path) -> list[dict]: + result = _run_codex(env, cwd, "mcp", "list", "--json") + assert result.returncode == 0, ( + f"codex mcp list failed.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + # `codex mcp list --json` may emit a one-time notice before the JSON, + # so try a straight parse first and fall back to slicing from the first + # bracket; on failure surface stdout/stderr so the reason is actionable. + try: + servers = json.loads(result.stdout) + except json.JSONDecodeError: + start = result.stdout.find("[") + if start == -1: + raise AssertionError( + f"codex mcp list produced no JSON array.\n" + f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}" + ) from None + try: + servers = json.loads(result.stdout[start:]) + except json.JSONDecodeError as e: + raise AssertionError( + f"codex mcp list JSON did not parse: {e}\n" + f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}" + ) from e + assert isinstance(servers, list) + return servers + + +def test_merge_install_is_visible_to_codex(tmp_path: Path) -> None: + """The #384 regression at the live level: a project where codex is + already configured must end up with vouch wired next to the user's + existing server, as seen by codex itself.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "config.toml").write_text( + 'model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "other-server"\n', + encoding="utf-8", + ) + + install("codex", target=project, tier="T4") + + # the merged file is still valid toml with both servers side by side + merged = tomllib.loads( + (project / ".codex" / "config.toml").read_text(encoding="utf-8") + ) + assert merged["model"] == "gpt-5" + assert set(merged["mcp_servers"]) == {"other", "vouch"} + + env = _isolated_env(home) + _trust(home, project) + servers = {s["name"]: s for s in _mcp_servers(env, project)} + assert "vouch" in servers, f"vouch not listed: {sorted(servers)}" + assert "other" in servers, "user's pre-existing server disappeared" + transport = servers["vouch"]["transport"] + assert transport["command"] == "vouch" + assert transport["args"] == ["serve"] + + +def test_fresh_install_is_visible_to_codex(tmp_path: Path) -> None: + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + + install("codex", target=project, tier="T4") + + env = _isolated_env(home) + _trust(home, project) + names = [s["name"] for s in _mcp_servers(env, project)] + assert names == ["vouch"], names + + +def test_untrusted_project_config_stays_inert(tmp_path: Path) -> None: + """Codex ignores project-scoped config for untrusted projects — the + install must not leak into a session the user never trusted.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + install("codex", target=project, tier="T4") + + env = _isolated_env(home) # note: no _trust() call + names = [s["name"] for s in _mcp_servers(env, project)] + assert "vouch" not in names, names + + +def test_full_tier_artifacts_present(tmp_path: Path) -> None: + """The T4 install ships every codex-readable surface: merged config, + fenced AGENTS.md, the nine skills, and the Stop hook.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + install("codex", target=project, tier="T4") + + agents = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "" in agents + assert "VOUCH_AGENT=codex" in agents.replace("`", "") + + assert (project / ".codex" / "skills" / "vouch-recall" / "SKILL.md").is_file() + + hooks = json.loads( + (project / ".codex" / "hooks.json").read_text(encoding="utf-8") + ) + cmds = [h["command"] for g in hooks["hooks"]["Stop"] for h in g["hooks"]] + assert "vouch capture ingest-codex --hook" in cmds + + # and codex still accepts the whole tree + env = _isolated_env(home) + _trust(home, project) + assert [s["name"] for s in _mcp_servers(env, project)] == ["vouch"] + + +def test_nothing_written_outside_project_and_home(tmp_path: Path) -> None: + """#179 invariant at the live level: after an install plus a codex + invocation, the throwaway home holds only what we put there and the + project holds only adapter artifacts — the real ~/.codex is never in + play because CODEX_HOME is pinned for every invocation.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + result = install("codex", target=project, tier="T4") + for rel in (*result.written, *result.appended, *result.merged): + assert (project / rel).resolve().is_relative_to(project.resolve()) + + env = _isolated_env(home) + _trust(home, project) + _mcp_servers(env, project) + # the trust entry is the only file vouch's test wrote into the home; + # codex may add its own state (caches, logs) but only under that home. + assert (home / "config.toml").is_file() From 4b93764e6f99976db943dc9379fb3e393e1f111c Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:52:30 +0900 Subject: [PATCH 16/34] docs(adapters): codex adapter docs reflect the tiered install three surfaces went stale once the codex tier work landed. the adapters table row described codex as a one-file config.toml snippet; it now lists the tiered install. the codex adapter readme led with the manual ~/.codex edit; it now leads with `vouch install-mcp codex`, explains the project-local .codex/config.toml choice and the trust requirement, summarizes what each tier adds (mcp wire / AGENTS.md snippet / skills / capture hook) with the merge-safety guarantees, and keeps the manual edit as an explicit fallback section. getting-started treated codex as a one-liner aside inside the claude instructions; the install section now shows both hosts side by side. the prompts-location decision from the T3 ticket and the notify restriction from the T4 ticket stay documented in their sections. no code; placeholder examples only. closes #390 --- adapters/README.md | 2 +- adapters/codex/README.md | 96 +++++++++++++++++++++++----------------- docs/getting-started.md | 14 ++++-- 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/adapters/README.md b/adapters/README.md index f4edce8c..84bb8127 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -10,7 +10,7 @@ file you need into your project and edit it. |---|---|---| | [claude-code/](claude-code/) | Anthropic's Claude Code CLI | `.mcp.json` snippet, `CLAUDE.md` excerpt | | [cursor/](cursor/) | Cursor IDE | `mcp.json` snippet | -| [codex/](codex/) | OpenAI's Codex CLI | `config.toml` snippet | +| [codex/](codex/) | OpenAI's Codex CLI | tiered install: `.codex/config.toml` merge, `AGENTS.md` excerpt, skills, capture hook | | [continue/](continue/) | Continue.dev | `config.json` snippet | | [openclaw/](openclaw/) | OpenClaw plugin host | `.openclaw/plugins.json`, `AGENTS.md` excerpt | | [generic-mcp/](generic-mcp/) | Any MCP-speaking host | annotated reference | diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 562b7290..5b8666c4 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -1,45 +1,43 @@ # Codex CLI adapter -Wires `vouch serve` into [OpenAI's Codex CLI][codex] as an MCP server. +Wires vouch into [OpenAI's Codex CLI][codex]: the MCP server, standing +`AGENTS.md` instructions, the vouch guided flows as skills, and +automatic session capture. [codex]: https://github.com/openai/codex -## Setup +## Install ```bash -vouch install-mcp codex +vouch install-mcp codex # everything (T1–T4) +vouch install-mcp codex --tier T1 # just the MCP wire ``` -This writes the `vouch` MCP entry into the *project-local* -`/.codex/config.toml` (deep-merged into any existing one, so -your other servers and settings are preserved) — never into -`~/.codex/config.toml`, per the project-scoped install rule. Codex -loads project-local `.codex/` config for trusted projects, so trust -the project when codex asks. +Everything lands in the *project*, never in `~/.codex` — the same +scope rule every adapter follows. Codex loads project-local `.codex/` +config for trusted projects, so trust the project when codex asks. -Prefer a user-global setup, or no installer? Add the entry to -`~/.codex/config.toml` (or `/.codex/config.toml`) by hand: +What each tier adds (tiers stack): -```toml -[mcp_servers.vouch] -command = "vouch" -args = ["serve"] +| Tier | File | What it does | +|---|---|---| +| T1 | `.codex/config.toml` | registers the `vouch` MCP server (`kb_search`, `kb_propose_claim`, …) with `VOUCH_AGENT=codex` for audit attribution | +| T2 | `AGENTS.md` | fenced snippet with the standing rules: recall first, all writes via proposals, review stays human | +| T3 | `.codex/skills/vouch-*/SKILL.md` | the nine vouch guided flows as project-local skills | +| T4 | `.codex/hooks.json` | `Stop` hook that auto-captures each session into a pending, review-gated summary | -[mcp_servers.vouch.env] -VOUCH_AGENT = "codex" -``` - -Restart any running `codex` session. +The install is idempotent and merge-safe: an existing +`.codex/config.toml` or `.codex/hooks.json` is deep-merged into (your +entries always win on conflict), an existing `AGENTS.md` gets the +snippet appended inside fence markers, and re-runs are a flat no-op. ## Skills (T3) -`vouch install-mcp codex --tier T3` also drops the vouch guided flows -(`vouch-recall`, `vouch-status`, `vouch-resolve-issue`, -`vouch-propose-from-pr`, plus the company-brain set) into the -project-local `.codex/skills/` directory. Codex discovers skills from -`/.codex/skills/` in trusted projects, so they surface in the -session automatically — ask for a skill by name (e.g. "use the -vouch-recall skill for X") or let codex pick them up from context. +Codex discovers skills from `/.codex/skills/` in trusted +projects, so the flows (`vouch-recall`, `vouch-status`, +`vouch-resolve-issue`, `vouch-propose-from-pr`, plus the company-brain +set) surface in the session automatically — ask for one by name or let +codex pick them up from context. Why skills and not custom prompts: codex loads custom prompts only from `~/.codex/prompts/` (user-global) and has deprecated them in @@ -54,30 +52,48 @@ The skill bodies are identical to the claude-code slash commands ## Automatic session capture (T4) -`vouch install-mcp codex` (default tier T4) wires automatic capture -through codex's hooks system: `.codex/hooks.json` registers a `Stop` -hook that runs `vouch capture ingest-codex --hook` when a turn -completes. The handler reads the hook payload, resolves the session's -rollout file, and rolls it into ONE pending session-summary proposal — -the same review-gated summary a claude-code session produces. Because -`Stop` fires per turn, re-ingest is idempotent: an unchanged session -is a no-op, a session that grew refreshes its pending proposal in -place, and a proposal you've already reviewed is never resurrected. +`.codex/hooks.json` registers a `Stop` hook that runs `vouch capture +ingest-codex --hook` when a turn completes. The handler reads the hook +payload, resolves the session's rollout file, and rolls it into ONE +pending session-summary proposal — the same review-gated summary a +claude-code session produces. Because `Stop` fires per turn, re-ingest +is idempotent: an unchanged session is a no-op, a session that grew +refreshes its pending proposal in place, and a proposal you've already +reviewed is never resurrected. Failure semantics match `capture observe`: the `--hook` mode exits 0 no matter what, so a capture problem can never break your codex turn. Nothing is auto-approved — review with `vouch review`. -Hooks merge, not clobber: if you already have a `.codex/hooks.json`, -the installer deep-merges the vouch hook in next to yours (re-runs -don't duplicate it). Project-local hooks run in trusted projects only, -so trust the project when codex asks. +Past sessions can be ingested by hand too: + +```bash +vouch capture ingest-codex --latest # newest rollout for this project +vouch capture ingest-codex # a specific one +``` Why not codex's `notify` setting: codex honours `notify` only in user-global config (`~/.codex/config.toml`), which a project-scoped install never touches. If you prefer notify anyway, point it at a wrapper that calls `vouch capture ingest-codex --hook` yourself. +## Manual fallback + +No installer, or a user-global setup on purpose? Add the entry to +`~/.codex/config.toml` (or `/.codex/config.toml`) by hand: + +```toml +[mcp_servers.vouch] +command = "vouch" +args = ["serve"] + +[mcp_servers.vouch.env] +VOUCH_AGENT = "codex" +``` + +Restart any running `codex` session, then confirm with +`codex mcp list`. + ## Notes - Codex respects MCP tool naming verbatim, so the tools appear as diff --git a/docs/getting-started.md b/docs/getting-started.md index bb9ac038..b1003078 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -118,9 +118,17 @@ Add `-e VOUCH_AGENT=claude-code` to attribute the agent's proposals to it rather than your shell user. Confirm with `claude mcp list` (look for `vouch … ✓ Connected`). -Prefer a config file, or want the brain-first `CLAUDE.md`, slash commands, and -hooks too? Run `vouch install-mcp claude-code` — or drop this into `.mcp.json` -at the project root by hand: +Prefer a config file, or want the brain-first instructions, guided flows, and +capture hooks too? The installer ships the full tiered setup for either host: + +```bash +vouch install-mcp claude-code # .mcp.json + CLAUDE.md + slash commands + hooks +vouch install-mcp codex # .codex/config.toml + AGENTS.md + skills + capture +``` + +Both are idempotent and merge-safe — existing config files are deep-merged +into, never clobbered. Or drop this into `.mcp.json` at the project root by +hand: ```json { From 1ac6cbbc61549e49b5ee202f863fb079f3a91627 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:50:38 +0900 Subject: [PATCH 17/34] =?UTF-8?q?docs(mcp):=20design=20a=20friendlier=20mc?= =?UTF-8?q?p=20surface=20=E2=80=94=20profiles=20+=20auto-recall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the closest competitor (pmb) exposes 10 mcp tools by default and hides the rest behind a profile flag; vouch shows all 58 every turn, which is the main first-touch friendliness gap. this spec adds a minimal-by-default tool profile, fusion-by-default retrieval, a per-prompt auto-recall hook, and compact tool descriptions. all of it is presentation-only — the review gate, the yaml storage, and the protocol method surface are untouched. it also closes the current 2-of-3 surface-parity gap as a bonus. --- ...026-07-07-friendlier-mcp-surface-design.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md diff --git a/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md b/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md new file mode 100644 index 00000000..fc5b4e1d --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md @@ -0,0 +1,145 @@ +# friendlier mcp surface — tool profiles + per-prompt auto-recall — design + +date: 2026-07-07 +status: approved-direction, pending user review of this spec +decided with user: the main reason vouch feels less user-friendly than pmb is +its mcp surface — 58 tools shoved at the agent every turn. lead with slimming +that surface + auto-recall; search-fusion rides underneath as the quality layer. + +## context + +a code-grounded comparison against pmb (pmbai.dev, `oleksiijko/pmb`) — the +closest competitor — isolated *why* pmb feels smoother on first touch. it is +almost entirely the mcp surface: + +| | pmb | vouch (today) | +|---|---|---| +| tools the agent sees | **10** (default profile) | **58** (all, always) | +| gating mechanism | `PMB_TOOL_PROFILE`, minimal by default | **none** | +| tool names | intent-first (`recall`, `remember`) | mechanic-first (`kb_propose_claim`) | +| recall a memory | 0 tool calls (auto-injected each prompt) | agent must call `kb_context`/`kb_search` | +| context cost | compact one-liners, ~16kb saved | 58 verbose descriptions, paid every turn | + +pmb defines **69** tools too — it just exposes 10 by default and hides the +other 59 behind a profile flag (`_toolspec.py`, `server.py:446-478` registers +all then removes those outside the active profile). vouch surfaces its whole +propose→approve→supersede→contradict→synthesize→maintenance lifecycle at once +because the review gate is the product — but the *agent* rarely needs 50 of +those tools, and reading them every turn is the friendliness tax. + +the key insight: this is a **presentation** problem, not a capability problem. +the fix touches only which mcp tools are *shown* to a given agent. the protocol +surface, the jsonl and cli surfaces, and — critically — the review gate are all +unchanged. + +## scope + +one slice, four moves, in priority order: + +1. **mcp tool profiles** — a `minimal` default that shows ~7 core tools; the + rest move to `standard`/`full`. the biggest first-touch win, self-contained. +2. **fusion-by-default retrieval** — wire the already-built `rrf_fuse` into the + context path so recall quality is good (prerequisite for move 3 feeling + good). add recency preference + near-duplicate drop. +3. **per-prompt auto-recall** — a `UserPromptSubmit` hook that injects + relevant context every turn → recall becomes 0 tool calls, like pmb. +4. **compact tool descriptions** — one-line descriptions under non-`full` + profiles to stop burning context. nice-to-have; lands last. + +**out of scope** (later slices): the follow-rate "was it used?" loop and +reproducible A/B numbers (that's the *better-proven* slice); a warm daemon for +sub-50ms injection (only if cold-spawn latency bites); bundling a local +embedding model by default (a packaging decision with its own weight); +auto-*write* of claims from observed actions (pmb does this; for vouch it must +route through the gate as proposals — a separate design). + +## move 1 — tool profiles + +### the profiles + +- **`minimal` (default)** — the everyday knowledge loop, agent-facing: + `kb_context`, `kb_search`, `kb_read_page`, `kb_propose_claim`, + `kb_propose_page`, `kb_status`, `kb_list_pending`. (7 tools.) +- **`standard`** — minimal + the review lifecycle for unattended agents: + `kb_approve`, `kb_reject`, `kb_supersede`, `kb_contradict`, `kb_confirm`, + `kb_read_claim`, `kb_list_claims`, `kb_neighbors`, `kb_why`. (~16 tools.) +- **`full`** — all 58 (maintenance, provenance, eval, import/export, themes). + +approve/reject are deliberately **not** in `minimal`: approval is a human +action done at the cli or review ui, so hiding it from a first-run agent is +correct, not lossy. `standard` exists for the "let an agent run the whole loop" +case. + +### mechanism + +mirror pmb: register every `@mcp.tool()` as today, then, at server build time, +read the active profile and remove tools outside it from the fastmcp tool +manager (`mcp._tool_manager`). the profile → tool-name sets live in one place — +a new `src/vouch/mcp_profiles.py` (a dict of profile → frozenset of method +names), imported by `server.py`. config precedence: `VOUCH_TOOL_PROFILE` env +var overrides `mcp.tool_profile` in `config.yaml`, default `minimal`. + +`capabilities.METHODS` still lists all 58 — the profile filters *exposure*, not +the protocol. `mcp_profiles.py` must be exhaustive: a meta-test asserts every +name in every profile exists in `METHODS`, and that `full` == `METHODS`, so a +new tool can't silently fall out of `full`. + +## move 2 — fusion by default + +in `context.py:_retrieve`, add a `hybrid` branch that fuses semantic + fts +results via the existing `embeddings/fusion.py:rrf_fuse` instead of the current +first-non-empty waterfall; add `hybrid` to `_VALID_BACKENDS`; flip the +`storage.py` default backend to `hybrid` (gracefully degrading to fts when the +embeddings extra is absent). then, before the context-budget clip: a cheap +recency multiplier and a greedy near-duplicate (MMR-style) drop. the CI recall +eval (`eval/recall.py`, `eval.yml`, 0.05 regression floor) is the guardrail — +fusion should raise those numbers and can't regress them. + +## move 3 — per-prompt auto-recall + +add a `UserPromptSubmit` hook to `adapters/claude-code/.claude/settings.json` +that runs `vouch context "$PROMPT"` with a budget-capped, structured output the +harness injects as additional context. mirror into the cursor adapter and +`install_adapter.py` tiers. **approach: cold spawn first** — it runs once per +turn (not per keystroke), so a few hundred ms is usually invisible; measure it, +and only build the warm daemon (generalizing the existing openclaw rpc) if it +feels laggy. this replaces reliance on the session-start `recall` firehose, +which is not query-relevant. + +## move 4 — compact descriptions + +under `minimal`/`standard`, serve a one-line description per tool (a +`SHORT_DESC` map, or the docstring's first line) instead of the full docstring; +`full` keeps the complete docstrings. lands last; the tool-count cut is the +bulk of the context win. + +## invariants preserved + +- **review gate untouched** — no write-path change; propose stays gated; + approve/reject remain fully available (cli, review ui, `standard`/`full`). +- **git-native yaml, no saas, protocol surface unchanged** — `METHODS`, jsonl, + and cli keep all 58; only mcp *exposure* narrows. +- **3-surface parity** — the parity test is extended to enumerate the *full* + mcp tool set + cli commands against `METHODS` (closing the current 2-of-3 + gap as a bonus), checked against `full`, not the active profile. + +## verification + +- new `tests/test_mcp_profiles.py`: `minimal` exposes exactly the 7; `full` + exposes all 58; every profile name ⊆ `METHODS`; env var overrides config. +- extended `tests/test_capabilities.py`: full mcp tools + cli commands vs + `METHODS` (the real 3-surface check). +- recall eval green / improved with fusion (ci-gated). +- manual before/after: fresh claude-code install shows ~7 tools not 58; + a prompt gets relevant context injected with 0 tool calls; record the + cold-spawn hook latency. + +## risks + +- **hidden tool confusion** — an agent (or user) may look for a tool that's in + `full` but not `minimal`. mitigation: `kb_capabilities` (in `minimal`) and + the docs list the profiles and how to widen with `VOUCH_TOOL_PROFILE`. +- **fusion regressing recall on some queries** — mitigated by the ci eval + floor; keep `backend` pinnable in config for escape. +- **hook latency on large KBs** — measured in verification; warm-daemon + fast-follow is the known escape hatch. From b14cf0608ee345cbd932361c51be98180539a8f6 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:04:03 +0900 Subject: [PATCH 18/34] docs(mcp): plan the friendlier-mcp-surface build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit six TDD tasks: tool profiles (minimal default), real mcp↔methods parity, fusion-by-default retrieval, near-duplicate drop, per-prompt auto-recall hook, and compact tool descriptions. reconcile the spec: minimal is 8 tools (kb_capabilities kept as the discovery escape hatch) and the recency multiplier is deferred (per-hit timestamps aren't plumbed through _retrieve). --- .../2026-07-07-friendlier-mcp-surface.md | 930 ++++++++++++++++++ ...026-07-07-friendlier-mcp-surface-design.md | 24 +- 2 files changed, 945 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-07-friendlier-mcp-surface.md diff --git a/docs/superpowers/plans/2026-07-07-friendlier-mcp-surface.md b/docs/superpowers/plans/2026-07-07-friendlier-mcp-surface.md new file mode 100644 index 00000000..f68021cc --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-friendlier-mcp-surface.md @@ -0,0 +1,930 @@ +# Friendlier MCP Surface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make vouch feel as friendly as pmb by narrowing the MCP tool surface an agent sees (58 → ~8 by default), fusing retrieval by default, and injecting relevant context on every prompt — all without touching the review gate. + +**Architecture:** A new `mcp_profiles` module filters which `@mcp.tool()` tools the stdio server exposes (applied in `run_stdio`, not at import, so tests and the parity check still see all 58). `context._retrieve` is rewritten so `auto`/`hybrid` fuse embedding + FTS5 via the existing `rrf_fuse` instead of a first-non-empty waterfall, with a cheap near-duplicate drop. A tiny, always-safe `hooks` helper + a hidden `vouch context-hook` CLI command feed a Claude Code `UserPromptSubmit` hook so recall costs zero tool calls. + +**Tech Stack:** Python 3.11–3.13, `mcp.server.fastmcp.FastMCP`, click CLI, pytest, pydantic v2, yaml config, sqlite FTS5 + optional embeddings. + +## Global Constraints + +- **Review gate is untouched.** No task changes the write path (`proposals.py`, `lifecycle.py`, `storage.put_*`). Profiles change *exposure* only. +- **Protocol surface unchanged.** `capabilities.METHODS`, the JSONL `HANDLERS`, and the CLI keep all 58 methods. Only which MCP tools are *shown* narrows. +- **Default profile is `minimal`.** Resolution: `VOUCH_TOOL_PROFILE` env var > `config.yaml` `mcp.tool_profile` > `"minimal"`. Unknown → `"minimal"`. +- **CI gate must stay green:** `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings`, `.venv/bin/python -m mypy src`, `.venv/bin/python -m ruff check src tests`. Every new function is fully type-annotated (mypy `src` is strict). +- **Commits:** conventional-commit format, summary ≤72 chars, lowercase body, **no `Co-Authored-By` trailer**. This repo's hooks reject `git commit -m` with heredocs and may reject `-m` outright — write the message to `/tmp/msg.txt` with the Write tool (never a heredoc) and use `git commit -F /tmp/msg.txt`. Stage files by name, never `git add -A`. +- **Branch:** `feat/friendlier-mcp-surface` (already created off `origin/main`; the spec is already committed on it). +- **Profile tool sets (dotted `METHODS` names), authoritative:** + - `minimal` (8): `kb.capabilities`, `kb.status`, `kb.context`, `kb.search`, `kb.read_page`, `kb.propose_claim`, `kb.propose_page`, `kb.list_pending` + - `standard` (minimal + 9 = 17): + `kb.approve`, `kb.reject`, `kb.supersede`, `kb.contradict`, `kb.confirm`, `kb.read_claim`, `kb.list_claims`, `kb.neighbors`, `kb.why` + - `full`: all of `capabilities.METHODS` (58) +- MCP tool names use `_`; `METHODS` uses `.`. Transform: `"kb.foo_bar"` ↔ `"kb_foo_bar"` via the substring after the first separator. + +--- + +### Task 1: MCP tool profiles + +**Files:** +- Create: `src/vouch/mcp_profiles.py` +- Modify: `src/vouch/server.py` (add import; apply profile inside `run_stdio` at `server.py:1011-1015`) +- Test: `tests/test_mcp_profiles.py` + +**Interfaces:** +- Produces: `mcp_profiles.PROFILES: dict[str, frozenset[str]]`, `mcp_profiles.DEFAULT_PROFILE: str`, `resolve_profile_name(config: dict | None = None) -> str`, `tool_names_for(name: str) -> set[str]`, `apply_tool_profile(mcp, name: str) -> list[str]` (returns removed underscore tool names, sorted). +- Consumes (Task 6): `compact_descriptions(mcp) -> int` is added to this module later. + +- [ ] **Step 1: Write the failing test** — create `tests/test_mcp_profiles.py`: + +```python +"""MCP tool profiles narrow the surface an agent sees (friendlier-mcp slice).""" + +from __future__ import annotations + +import pytest +from mcp.server.fastmcp import FastMCP + +from vouch import mcp_profiles +from vouch.capabilities import METHODS + +_MINIMAL_TOOLS = { + "kb_capabilities", "kb_status", "kb_context", "kb_search", + "kb_read_page", "kb_propose_claim", "kb_propose_page", "kb_list_pending", +} + + +def _make(name: str): + def fn(x: int = 0) -> int: + return x + fn.__name__ = name + return fn + + +def _fresh_mcp() -> FastMCP: + m = FastMCP("probe") + for method in METHODS: + m.tool()(_make("kb_" + method.split(".", 1)[1])) + return m + + +def test_full_profile_removes_nothing() -> None: + m = _fresh_mcp() + removed = mcp_profiles.apply_tool_profile(m, "full") + assert removed == [] + assert len(m._tool_manager._tools) == len(METHODS) + + +def test_minimal_exposes_core_only() -> None: + m = _fresh_mcp() + mcp_profiles.apply_tool_profile(m, "minimal") + assert set(m._tool_manager._tools) == _MINIMAL_TOOLS + + +def test_standard_is_superset_of_minimal() -> None: + assert mcp_profiles.PROFILES["minimal"] <= mcp_profiles.PROFILES["standard"] + + +def test_every_profile_is_subset_of_methods() -> None: + allm = set(METHODS) + for name, methods in mcp_profiles.PROFILES.items(): + assert methods <= allm, f"{name} references non-methods: {methods - allm}" + assert mcp_profiles.PROFILES["full"] == allm + + +def test_resolve_env_beats_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_TOOL_PROFILE", "full") + assert mcp_profiles.resolve_profile_name({"mcp": {"tool_profile": "minimal"}}) == "full" + + +def test_resolve_default_is_minimal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VOUCH_TOOL_PROFILE", raising=False) + assert mcp_profiles.resolve_profile_name(None) == "minimal" + + +def test_unknown_profile_falls_back_to_minimal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_TOOL_PROFILE", "bogus") + assert mcp_profiles.resolve_profile_name(None) == "minimal" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_mcp_profiles.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'vouch.mcp_profiles'`. + +- [ ] **Step 3: Write minimal implementation** — create `src/vouch/mcp_profiles.py`: + +```python +"""MCP tool profiles — narrow the tool surface an agent sees by default. + +vouch exposes 58 kb.* methods. Handing all of them to an agent every turn is +the main first-touch friendliness cost (the closest competitor, pmb, exposes +~10 by default and hides the rest behind a profile flag). Profiles control +*exposure* only: the JSONL and CLI surfaces, the protocol method list +(capabilities.METHODS), and the review gate are unchanged. + +Resolution order: VOUCH_TOOL_PROFILE env var > config.yaml `mcp.tool_profile` +> "minimal" (the default). Unknown names fall back to "minimal". +""" + +from __future__ import annotations + +import os +from typing import Any + +from .capabilities import METHODS + +_MINIMAL: frozenset[str] = frozenset({ + "kb.capabilities", + "kb.status", + "kb.context", + "kb.search", + "kb.read_page", + "kb.propose_claim", + "kb.propose_page", + "kb.list_pending", +}) + +_STANDARD: frozenset[str] = _MINIMAL | frozenset({ + "kb.approve", + "kb.reject", + "kb.supersede", + "kb.contradict", + "kb.confirm", + "kb.read_claim", + "kb.list_claims", + "kb.neighbors", + "kb.why", +}) + +PROFILES: dict[str, frozenset[str]] = { + "minimal": _MINIMAL, + "standard": _STANDARD, + "full": frozenset(METHODS), +} + +DEFAULT_PROFILE = "minimal" + + +def _tool_name(method: str) -> str: + """`"kb.propose_claim"` -> `"kb_propose_claim"` (MCP tool names use `_`).""" + return "kb_" + method.split(".", 1)[1] + + +def resolve_profile_name(config: dict[str, Any] | None = None) -> str: + """Pick the active profile from env > config > default.""" + raw = os.environ.get("VOUCH_TOOL_PROFILE") + if not raw and config: + raw = config.get("mcp", {}).get("tool_profile") + name = str(raw).strip().lower() if raw else DEFAULT_PROFILE + return name if name in PROFILES else DEFAULT_PROFILE + + +def tool_names_for(name: str) -> set[str]: + """The MCP (underscore) tool names exposed by profile `name`.""" + return {_tool_name(m) for m in PROFILES.get(name, PROFILES[DEFAULT_PROFILE])} + + +def apply_tool_profile(mcp: Any, name: str) -> list[str]: + """Remove every registered `kb_*` MCP tool not in profile `name`. + + Returns the sorted list of removed tool names. Idempotent. `full` removes + nothing. Only `kb_*` tools are touched, so trust/diagnostic tools + registered elsewhere are never dropped. + """ + keep = tool_names_for(name) + tools = mcp._tool_manager._tools + removed: list[str] = [] + for tool_name in list(tools.keys()): + if tool_name.startswith("kb_") and tool_name not in keep: + tools.pop(tool_name, None) + removed.append(tool_name) + return sorted(removed) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_mcp_profiles.py -q` +Expected: PASS (7 tests). + +- [ ] **Step 5: Wire the profile into the stdio server** — modify `src/vouch/server.py`. Add to the imports near the top (with the other `from .` imports, e.g. after `from .synthesize import synthesize` at line 56): + +```python +from . import mcp_profiles +``` + +Then replace `run_stdio` (currently `server.py:1011-1015`): + +```python +def run_stdio() -> None: + """Entry point used by `vouch serve`.""" + configure_logging() + trust_mod.set_stdio_default(trust_mod.MCP_STDIO) + try: + cfg: dict[str, Any] | None = _load_cfg(_store()) + except Exception: + cfg = None + mcp_profiles.apply_tool_profile(mcp, mcp_profiles.resolve_profile_name(cfg)) + mcp.run() +``` + +(`Any` is already imported in `server.py`; `_load_cfg` is at `server.py:204` and `_store` at `server.py:61`.) + +- [ ] **Step 6: Verify server still imports and mypy/ruff pass** + +Run: `.venv/bin/python -c "import vouch.server"` → Expected: no output, exit 0. +Run: `.venv/bin/python -m mypy src/vouch/mcp_profiles.py src/vouch/server.py` → Expected: `Success`. +Run: `.venv/bin/python -m ruff check src/vouch/mcp_profiles.py src/vouch/server.py` → Expected: `All checks passed!`. + +- [ ] **Step 7: Commit** + +Write `/tmp/msg.txt`: +``` +feat(mcp): add tool profiles, expose a minimal surface by default + +agents saw all 58 kb.* tools every turn — the main first-touch friendliness +gap vs pmb, which exposes ~10 by default. add a profile layer applied in +run_stdio: minimal (8 core tools) by default, standard (16), or full (58), +selected by VOUCH_TOOL_PROFILE / config mcp.tool_profile. exposure only — the +protocol surface, jsonl/cli, and the review gate are unchanged. +``` +Run: +```bash +git add src/vouch/mcp_profiles.py src/vouch/server.py tests/test_mcp_profiles.py +git commit -F /tmp/msg.txt +``` + +--- + +### Task 2: Enforce true MCP↔METHODS parity + +**Files:** +- Modify: `tests/test_capabilities.py` + +**Interfaces:** +- Consumes: `vouch.server.mcp` (the module-level, unfiltered `FastMCP`), `vouch.capabilities.METHODS`. + +**Why:** `test_capabilities.py` today only checks `capabilities.METHODS == JSONL HANDLERS`. The 58 MCP tools were never compared to `METHODS`, so MCP drift passed CI (the CLAUDE.md "all three surfaces" claim was really 2-of-3). Importing `vouch.server` registers all tools but does **not** apply a profile (that happens only in `run_stdio`), so this sees the full surface. + +- [ ] **Step 1: Write the failing test** — append to `tests/test_capabilities.py`: + +```python +def test_mcp_tools_match_methods() -> None: + """Every MCP kb_* tool maps to a capabilities method and vice-versa. + + Closes the MCP half of the 3-surface parity invariant that the JSONL + check above did not cover. Uses the unfiltered server object (profiles + apply only in run_stdio). + """ + from vouch.server import mcp + + tool_names = {n for n in mcp._tool_manager._tools if n.startswith("kb_")} + as_methods = {"kb." + n.split("_", 1)[1] for n in tool_names} + declared = set(capabilities.METHODS) + assert as_methods == declared, ( + f"mcp/methods mismatch: " + f"missing tools={declared - as_methods}, " + f"undeclared tools={as_methods - declared}" + ) +``` + +- [ ] **Step 2: Run test to verify it passes or reveals real drift** + +Run: `.venv/bin/python -m pytest tests/test_capabilities.py -q` +Expected: PASS if the 58 tools already align with `METHODS`. If it FAILS, the message names the exact drift — fix `capabilities.METHODS` or the tool registration in `server.py` until it passes (this is a real bug the test just caught, not a test error). Do not weaken the assertion. + +- [ ] **Step 3: Commit** + +Write `/tmp/msg.txt`: +``` +test(capabilities): assert mcp tool set matches the method list + +the parity test only compared capabilities.METHODS to the jsonl handlers; +the 58 mcp tools were never checked, so mcp drift passed ci. enumerate the +unfiltered server tools and assert they equal METHODS — the real 3-surface +check for the mcp side. +``` +Run: +```bash +git add tests/test_capabilities.py +git commit -F /tmp/msg.txt +``` + +--- + +### Task 3: Fuse retrieval by default + +**Files:** +- Modify: `src/vouch/context.py` (`_VALID_BACKENDS` at line 45; rewrite `_retrieve` body at lines 90-120; add a top-level import) +- Modify: `src/vouch/storage.py` (default backend at line 96) +- Modify: `tests/test_retrieval_backend.py` (update the two `auto` tests whose behavior intentionally changes; add a hybrid-fusion test) + +**Interfaces:** +- Consumes: `vouch.embeddings.fusion.rrf_fuse(a, b, *, limit=10, k=60)` where `a`/`b` are `list[tuple[str, str, str, float]]` (kind, id, summary, score). +- Produces: `_retrieve` now tags fused hits with backend `"hybrid"`; `auto` and `hybrid` both fuse embedding + FTS5. + +- [ ] **Step 1: Update the two tests whose behavior changes + add the fusion test.** In `tests/test_retrieval_backend.py`, replace `test_backend_auto_prefers_embedding` (lines 82-89) and `test_unset_backend_defaults_to_auto` (lines 92-101) with: + +```python +def test_backend_auto_now_fuses( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """`auto` no longer waterfalls embedding-first; it fuses embedding + fts5 + (RRF) and tags hits `hybrid`.""" + _force_semantic_hit(monkeypatch) + _set_backend(store, "auto") + pack = context.build_context_pack(store, query="JWT") + assert pack["items"] + assert _backends(pack) == {"hybrid"} + + +def test_unset_backend_fuses( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A config with no retrieval.backend behaves like fused `auto`.""" + _force_semantic_hit(monkeypatch) + cfg = yaml.safe_load(store.config_path.read_text()) + cfg.get("retrieval", {}).pop("backend", None) + store.config_path.write_text(yaml.safe_dump(cfg)) + pack = context.build_context_pack(store, query="JWT") + assert _backends(pack) == {"hybrid"} + + +def test_backend_hybrid_merges_semantic_and_lexical( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """`hybrid` returns the union of both retrievers, not first-non-empty.""" + src = store.put_source(b"e2") + store.put_claim(Claim(id="c2", text="OAuth refresh flow", evidence=[src.id])) + health.rebuild_index(store) + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [("claim", "c1", "JWT token rotation", 0.99)], + ) + monkeypatch.setattr( + context.index_db, "search", + lambda *a, **k: [("claim", "c2", "OAuth refresh flow", 0.88)], + ) + _set_backend(store, "hybrid") + pack = context.build_context_pack(store, query="auth") + assert {item["id"] for item in pack["items"]} == {"c1", "c2"} + assert _backends(pack) == {"hybrid"} +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `.venv/bin/python -m pytest tests/test_retrieval_backend.py -q` +Expected: FAIL — `test_backend_auto_now_fuses` / `test_unset_backend_fuses` / `test_backend_hybrid_merges_semantic_and_lexical` fail because `hybrid` isn't accepted yet and `auto` still waterfalls (backend is `embedding`, not `hybrid`). + +- [ ] **Step 3: Implement fusion.** In `src/vouch/context.py`: + +(a) Add near the top imports (after the existing `from . import` block): +```python +from .embeddings.fusion import rrf_fuse +``` +(`fusion.py` is pure-Python with no heavy deps, so a top-level import is safe under the base install.) + +(b) Change `_VALID_BACKENDS` (line 45): +```python +_VALID_BACKENDS = ("auto", "hybrid", "embedding", "fts5", "substring") +``` + +(c) Replace the body of `_retrieve` from line 90 (`backend = _configured_backend(store)`) through line 120 (the final substring `return`) with: +```python + backend = _configured_backend(store) + fetch_limit = scoped_fetch_limit(limit, viewer) + + if backend in ("auto", "hybrid"): + sem = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) + try: + lex = index_db.search(store.kb_dir, query, limit=fetch_limit) + except sqlite3.Error: + lex = [] + fused = rrf_fuse(sem, lex, limit=fetch_limit) + if fused: + filtered = filter_hits(store, fused, viewer, limit=limit) + return [(k, i, s, sc, "hybrid") for k, i, s, sc in filtered] + # both retrievers empty -> fall through to the substring scan below. + + if backend == "embedding": + raw = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) + if raw: + filtered = filter_hits(store, raw, viewer, limit=limit) + return [(k, i, s, sc, "embedding") for k, i, s, sc in filtered] + return [] + + if backend == "fts5": + try: + hits = index_db.search(store.kb_dir, query, limit=fetch_limit) + if hits: + filtered = filter_hits(store, hits, viewer, limit=limit) + return [(k, i, s, sc, "fts5") for k, i, s, sc in filtered] + except sqlite3.Error: + pass + return [] + + substring_hits = store.search_substring(query, limit=fetch_limit) + filtered = filter_hits(store, substring_hits, viewer, limit=limit) + return [(k, i, s, sc, "substring") for k, i, s, sc in filtered] +``` + +(d) In `src/vouch/storage.py` line 96, change the init default so new KBs say `hybrid` explicitly: +```python + "backend": "hybrid", +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_retrieval_backend.py -q` +Expected: PASS. `test_backend_fts5_skips_embedding`, `test_backend_embedding_is_recognized`, `test_backend_substring_only` still pass (explicit pins unchanged). + +- [ ] **Step 5: Guard against retrieval-quality regression + mypy/ruff** + +Run: `.venv/bin/python -m pytest tests/test_context.py tests/test_index.py -q` → Expected: PASS. +Run: `.venv/bin/python -m vouch eval recall eval/queries.jsonl --kb eval/fixture-kb --baseline eval/baseline.json --max-regression 0.05` (the exact args `eval.yml` uses; if the CLI flags differ, run `.venv/bin/vouch eval recall --help` and match them) → Expected: no regression beyond 0.05. +Run: `.venv/bin/python -m mypy src/vouch/context.py src/vouch/storage.py` → Expected: `Success`. +Run: `.venv/bin/python -m ruff check src/vouch/context.py src/vouch/storage.py` → Expected: `All checks passed!`. + +- [ ] **Step 6: Commit** + +Write `/tmp/msg.txt`: +``` +feat(retrieval): fuse embedding + fts5 by default instead of a waterfall + +_retrieve tried embedding, then fts5, then substring, returning the first +non-empty list — so lexical and semantic hits never combined. auto and +hybrid now fuse both retrievers with the already-built rrf_fuse and tag hits +"hybrid"; explicit embedding/fts5/substring pins are unchanged. existing KBs +(config says "auto") benefit with no migration. +``` +Run: +```bash +git add src/vouch/context.py src/vouch/storage.py tests/test_retrieval_backend.py +git commit -F /tmp/msg.txt +``` + +--- + +### Task 4: Drop near-duplicate context items + +**Files:** +- Modify: `src/vouch/context.py` (add `_jaccard` + `_dedupe_near_duplicates`; call it in `build_context_pack` after graph expansion, ~line 256) +- Test: `tests/test_retrieval_backend.py` (add one test; reuses the existing `store` fixture) + +**Interfaces:** +- Produces: `build_context_pack` drops lower-scored items whose summary is near-identical (token-set Jaccard ≥ 0.85) to a higher-scored kept item. + +- [ ] **Step 1: Write the failing test** — append to `tests/test_retrieval_backend.py`: + +```python +def test_near_duplicate_summaries_are_dropped( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """An agent should not see the same fact twice.""" + src = store.put_source(b"z") + store.put_claim(Claim( + id="d1", text="the cache uses redis with a 60 second ttl", evidence=[src.id])) + store.put_claim(Claim( + id="d2", text="the cache uses redis with a 60 second ttl now", evidence=[src.id])) + health.rebuild_index(store) + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [ + ("claim", "d1", "the cache uses redis with a 60 second ttl", 0.90), + ("claim", "d2", "the cache uses redis with a 60 second ttl now", 0.89), + ], + ) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + _set_backend(store, "hybrid") + pack = context.build_context_pack(store, query="cache") + assert {item["id"] for item in pack["items"]} == {"d1"} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_retrieval_backend.py::test_near_duplicate_summaries_are_dropped -q` +Expected: FAIL — both `d1` and `d2` present. + +- [ ] **Step 3: Implement the dedupe pass.** In `src/vouch/context.py`, add these helpers above `build_context_pack` (e.g. after `_append_graph_neighbors`, ~line 198): + +```python +def _jaccard(a: set[str], b: set[str]) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]: + """Drop later items whose summary is near-identical to a kept one. + + Cheap greedy pass (token-set Jaccard >= 0.85 over the first 40 tokens). + `items` must arrive in descending-score order so the higher-scored + duplicate is the one kept. + """ + kept: list[ContextItem] = [] + kept_tokens: list[set[str]] = [] + for it in items: + toks = set(it.summary.lower().split()[:40]) + if any(_jaccard(toks, seen) >= 0.85 for seen in kept_tokens): + continue + kept.append(it) + kept_tokens.append(toks) + return kept +``` + +Then in `build_context_pack`, immediately after the `if expand_graph:` block closes (after line 256, before the `failed: list[str] = []` line at 257) insert: + +```python + items = _dedupe_near_duplicates(items) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_retrieval_backend.py -q` → Expected: PASS (all, including the earlier fusion tests — single non-duplicate hits are unaffected). + +- [ ] **Step 5: mypy/ruff** + +Run: `.venv/bin/python -m mypy src/vouch/context.py` → Expected: `Success`. +Run: `.venv/bin/python -m ruff check src/vouch/context.py` → Expected: `All checks passed!`. + +- [ ] **Step 6: Commit** + +Write `/tmp/msg.txt`: +``` +feat(retrieval): drop near-duplicate items from the context pack + +a fused pack could surface the same fact from two claims. add a cheap greedy +jaccard pass (>=0.85 over the first 40 tokens) that keeps the highest-scored +of a near-duplicate cluster, so an agent never reads the same thing twice. +``` +Run: +```bash +git add src/vouch/context.py tests/test_retrieval_backend.py +git commit -F /tmp/msg.txt +``` + +--- + +### Task 5: Per-prompt auto-recall hook + +**Files:** +- Create: `src/vouch/hooks.py` +- Modify: `src/vouch/cli.py` (add a hidden `context-hook` command near the `context` command at `cli.py:2272`) +- Modify: `adapters/claude-code/.claude/settings.json` (add a `UserPromptSubmit` hook) +- Test: `tests/test_hooks.py` + +**Interfaces:** +- Produces: `hooks.build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str` — returns the JSON envelope to inject, or `""` for nothing. Never raises. +- Consumes: `context.build_context_pack`, `cli._load_store`. + +- [ ] **Step 1: Write the failing test** — create `tests/test_hooks.py`: + +```python +"""The per-prompt hook injects relevant KB context with zero tool calls.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import context, health, hooks +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + src = s.put_source(b"e") + s.put_claim(Claim(id="c1", text="deploys run on tuesdays via ci", evidence=[src.id])) + health.rebuild_index(s) + return s + + +def _force_hit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [("claim", "c1", "deploys run on tuesdays via ci", 0.9)], + ) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + + +def test_empty_prompt_injects_nothing(store: KBStore) -> None: + assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": ""})) == "" + assert hooks.build_claude_prompt_hook(store, "") == "" + + +def test_relevant_prompt_yields_additional_context( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _force_hit(monkeypatch) + out = hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "when do deploys run"})) + env = json.loads(out) + assert env["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit" + assert "tuesdays" in env["hookSpecificOutput"]["additionalContext"] + + +def test_raw_non_json_stdin_is_tolerated( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _force_hit(monkeypatch) + out = hooks.build_claude_prompt_hook(store, "when do deploys run") + assert "tuesdays" in json.loads(out)["hookSpecificOutput"]["additionalContext"] + + +def test_no_hits_injects_nothing( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "zzznomatch"})) == "" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_hooks.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'vouch.hooks'`. + +- [ ] **Step 3: Implement the helper** — create `src/vouch/hooks.py`: + +```python +"""Host-hook helpers: translate an agent host's prompt hook into KB context. + +Claude Code's UserPromptSubmit hook passes a JSON payload on stdin and injects +whatever the hook prints (as an `additionalContext` envelope) before the model +runs. `build_claude_prompt_hook` turns that payload into a compact, relevant +context block drawn from *approved* KB knowledge — so recall costs the agent +zero tool calls. It never raises: on any problem it returns "" (inject +nothing), so a hook failure can never block the user's turn. +""" + +from __future__ import annotations + +import json +from typing import Any + +from .context import build_context_pack +from .storage import KBStore + +_MAX_ITEMS = 8 +_MAX_CHARS = 2000 + + +def _render(pack: dict[str, Any]) -> str: + lines: list[str] = [] + for item in pack.get("items", []): + summary = str(item.get("summary", "")).strip() + if not summary: + continue + cites = item.get("citations") or [] + suffix = f" [{', '.join(cites)}]" if cites else "" + lines.append(f"- {summary}{suffix}") + return "\n".join(lines) + + +def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: + """Return the stdout a host should inject for this prompt, or "" for none.""" + try: + payload = json.loads(stdin_text) if stdin_text.strip() else {} + except json.JSONDecodeError: + payload = {"prompt": stdin_text} + prompt = str(payload.get("prompt", "")).strip() + if not prompt: + return "" + try: + pack = build_context_pack( + store, query=prompt, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, + ) + except Exception: + return "" + body = _render(pack) if isinstance(pack, dict) else "" + if not body: + return "" + block = ( + "Relevant knowledge from the project's vouch KB " + "(approved & cited — consider it before answering):\n" + body + ) + return json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": block, + } + }) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_hooks.py -q` → Expected: PASS (4 tests). + +- [ ] **Step 5: Add the hidden CLI command.** In `src/vouch/cli.py`, immediately after the `context` command (after `_emit_json(pack)` at `cli.py:2299`, before the `synthesize` command at 2302) add: + +```python +@cli.command(name="context-hook", hidden=True) +def context_hook() -> None: + """Emit relevant KB context for a host UserPromptSubmit hook (reads stdin). + + Wired by the claude-code adapter; not meant to be run by hand. Reads the + host's JSON hook payload on stdin, prints an additionalContext envelope, + and always exits 0 so it can never block a turn. + """ + import sys + + from . import hooks + + stdin_text = sys.stdin.read() + try: + out = hooks.build_claude_prompt_hook(_load_store(), stdin_text) + except Exception: + out = "" + if out: + click.echo(out) +``` + +- [ ] **Step 6: Verify the command runs end-to-end** + +Run: `printf '{"prompt":"anything"}' | .venv/bin/vouch context-hook` from inside a KB dir (or `cd eval/fixture-kb && printf '{"prompt":"jwt"}' | ../../.venv/bin/vouch context-hook`). +Expected: either empty output (no hits) or a single line of JSON containing `"hookEventName": "UserPromptSubmit"`. Never a traceback, always exit 0 (`echo $?` → `0`). + +- [ ] **Step 7: Wire the hook into the claude-code adapter.** In `adapters/claude-code/.claude/settings.json`, add a `UserPromptSubmit` block inside `"hooks"` (after the `SessionStart` block, before `PostToolUse`). The file's `"hooks"` object becomes: + +```json + "hooks": { + "SessionStart": [ + { + "comment": "finalize old buffers from previous sessions; current session will be finalized here too on next session start (fallback: windowclose event not yet supported by claude-code extension)", + "matcher": "*", + "hooks": [ + { "type": "command", "command": "vouch capture finalize-all || true" }, + { "type": "command", "command": "vouch status --json || true" }, + { "type": "command", "command": "vouch capture banner || true" }, + { "type": "command", "command": "vouch recall || true" } + ] + } + ], + "UserPromptSubmit": [ + { + "comment": "inject KB context relevant to THIS prompt (per-prompt auto-recall, 0 tool calls); never blocks the turn", + "matcher": "*", + "hooks": [ + { "type": "command", "command": "vouch context-hook || true" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "vouch capture observe || true" } + ] + } + ], + "SessionEnd": [ + { + "matcher": "*", + "hooks": [ + { "type": "command", "command": "vouch capture finalize || true" } + ] + } + ] + } +``` + +(`kb_context` is already in the adapter's permission allowlist, and the hook uses the CLI, so no permission change is needed. `vouch recall` stays at SessionStart — it serves the session banner, a different purpose from per-prompt recall.) + +- [ ] **Step 8: Verify the adapter JSON stays valid + install-merge test passes** + +Run: `.venv/bin/python -c "import json; json.load(open('adapters/claude-code/.claude/settings.json'))"` → Expected: no output, exit 0. +Run: `.venv/bin/python -m pytest tests/test_install_adapter.py -q` → Expected: PASS. + +- [ ] **Step 9: Commit** + +Write `/tmp/msg.txt`: +``` +feat(hooks): inject per-prompt kb context via a userpromptsubmit hook + +recall used to be either a session-start firehose or an explicit tool call. +add a pure, never-raising helper + a hidden `vouch context-hook` command that +reads the host's prompt payload on stdin and prints an additionalContext +envelope, and wire it into the claude-code adapter's UserPromptSubmit hook — +so relevant, approved knowledge is injected every turn with zero tool calls. +``` +Run: +```bash +git add src/vouch/hooks.py src/vouch/cli.py adapters/claude-code/.claude/settings.json tests/test_hooks.py +git commit -F /tmp/msg.txt +``` + +--- + +### Task 6: Compact tool descriptions under non-full profiles + +**Files:** +- Modify: `src/vouch/mcp_profiles.py` (add `compact_descriptions`) +- Modify: `src/vouch/server.py` (call it in `run_stdio` when profile != `full`) +- Test: `tests/test_mcp_profiles.py` (add one test) + +**Interfaces:** +- Produces: `mcp_profiles.compact_descriptions(mcp) -> int` — trims each `kb_*` tool's description to its first line; returns count changed. + +- [ ] **Step 1: Write the failing test** — append to `tests/test_mcp_profiles.py`: + +```python +def test_compact_descriptions_trims_to_first_line() -> None: + m = FastMCP("probe") + + def kb_thing(x: int = 0) -> int: + """First line. + + Second paragraph with lots of detail the agent does not need. + """ + return x + + m.tool()(kb_thing) + changed = mcp_profiles.compact_descriptions(m) + assert changed == 1 + assert m._tool_manager._tools["kb_thing"].description == "First line." +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python -m pytest tests/test_mcp_profiles.py::test_compact_descriptions_trims_to_first_line -q` +Expected: FAIL — `AttributeError: module 'vouch.mcp_profiles' has no attribute 'compact_descriptions'`. + +- [ ] **Step 3: Implement.** Append to `src/vouch/mcp_profiles.py`: + +```python +def compact_descriptions(mcp: Any) -> int: + """Trim each kb_ tool's description to its first line to save context. + + Full docstrings are only needed under the `full` profile; the first line + is enough for an agent choosing a tool. Returns the number changed. + """ + changed = 0 + for tool in mcp._tool_manager._tools.values(): + if not tool.name.startswith("kb_"): + continue + desc = tool.description or "" + first = desc.strip().split("\n", 1)[0].strip() + if first and first != desc: + try: + tool.description = first + except (AttributeError, TypeError): + # pydantic frozen-model fallback + object.__setattr__(tool, "description", first) + changed += 1 + return changed +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `.venv/bin/python -m pytest tests/test_mcp_profiles.py -q` → Expected: PASS (8 tests). + +- [ ] **Step 5: Call it from the server.** In `src/vouch/server.py` `run_stdio`, insert after the `apply_tool_profile(...)` line and before `mcp.run()`: + +```python + if mcp_profiles.resolve_profile_name(cfg) != "full": + mcp_profiles.compact_descriptions(mcp) +``` + +(Resolve is cheap and pure; calling it twice is fine. Alternatively hoist the resolved name into a local `profile` var and reuse — either is acceptable.) + +- [ ] **Step 6: mypy/ruff + import check** + +Run: `.venv/bin/python -c "import vouch.server"` → Expected: exit 0. +Run: `.venv/bin/python -m mypy src/vouch/mcp_profiles.py src/vouch/server.py` → Expected: `Success`. +Run: `.venv/bin/python -m ruff check src/vouch/mcp_profiles.py src/vouch/server.py` → Expected: `All checks passed!`. + +- [ ] **Step 7: Commit** + +Write `/tmp/msg.txt`: +``` +feat(mcp): serve one-line tool descriptions under non-full profiles + +full docstrings for every exposed tool are paid on every turn. under minimal +and standard, trim each tool's description to its first line (full keeps the +complete docstrings), cutting the per-turn context cost. +``` +Run: +```bash +git add src/vouch/mcp_profiles.py src/vouch/server.py tests/test_mcp_profiles.py +git commit -F /tmp/msg.txt +``` + +--- + +## Final verification + +Run the full CI gate exactly as `.github/workflows/ci.yml` does, plus a manual end-to-end (per the "verify the shipped diff" rule — exercise the real behavior, not just unit tests): + +- [ ] `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings` → all pass. +- [ ] `.venv/bin/python -m mypy src` → `Success`. +- [ ] `.venv/bin/python -m ruff check src tests` → `All checks passed!`. +- [ ] **Surface, minimal (default):** in a KB dir, run the server and count tools — + `.venv/bin/python -c "import vouch.server as s, vouch.mcp_profiles as p; p.apply_tool_profile(s.mcp, 'minimal'); print(sorted(n for n in s.mcp._tool_manager._tools if n.startswith('kb_')))"` → exactly the 8 minimal tools. +- [ ] **Surface, full override:** same one-liner with `'full'` → all 58 present. +- [ ] **Auto-recall e2e:** `cd eval/fixture-kb && printf '{"prompt":"how is rate limiting done"}' | ../../.venv/bin/vouch context-hook` → one JSON line with `additionalContext` drawn from the fixture claims; `echo $?` → `0`. +- [ ] **Docs (light):** add one line documenting `VOUCH_TOOL_PROFILE` / `mcp.tool_profile` (default `minimal`, values `minimal|standard|full`) to `mintlify/reference/` (the config/MCP reference) and the claude-code guide. Commit as `docs(mcp): document tool profiles`. + +Then hand back to the user for review before any push (this branch is off `main`; pushing is a separate, explicit step). diff --git a/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md b/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md index fc5b4e1d..002c7228 100644 --- a/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md +++ b/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md @@ -58,8 +58,10 @@ route through the gate as proposals — a separate design). ### the profiles - **`minimal` (default)** — the everyday knowledge loop, agent-facing: - `kb_context`, `kb_search`, `kb_read_page`, `kb_propose_claim`, - `kb_propose_page`, `kb_status`, `kb_list_pending`. (7 tools.) + `kb_capabilities`, `kb_context`, `kb_search`, `kb_read_page`, + `kb_propose_claim`, `kb_propose_page`, `kb_status`, `kb_list_pending`. + (8 tools.) `kb_capabilities` stays in so the agent can always discover the + wider surface and how to widen its profile. - **`standard`** — minimal + the review lifecycle for unattended agents: `kb_approve`, `kb_reject`, `kb_supersede`, `kb_contradict`, `kb_confirm`, `kb_read_claim`, `kb_list_claims`, `kb_neighbors`, `kb_why`. (~16 tools.) @@ -90,9 +92,13 @@ in `context.py:_retrieve`, add a `hybrid` branch that fuses semantic + fts results via the existing `embeddings/fusion.py:rrf_fuse` instead of the current first-non-empty waterfall; add `hybrid` to `_VALID_BACKENDS`; flip the `storage.py` default backend to `hybrid` (gracefully degrading to fts when the -embeddings extra is absent). then, before the context-budget clip: a cheap -recency multiplier and a greedy near-duplicate (MMR-style) drop. the CI recall -eval (`eval/recall.py`, `eval.yml`, 0.05 regression floor) is the guardrail — +embeddings extra is absent). `auto` — what every already-initialised KB has in +its config — is redefined to mean the same fused path, so existing installs +benefit without a config migration. then, before the context-budget clip: a +greedy near-duplicate (MMR-style) drop so an agent never sees the same fact +twice. (a recency multiplier is deferred to a follow-up — it needs per-hit +timestamps that `_retrieve` does not currently carry.) the CI recall eval +(`eval/recall.py`, `eval.yml`, 0.05 regression floor) is the guardrail — fusion should raise those numbers and can't regress them. ## move 3 — per-prompt auto-recall @@ -125,12 +131,12 @@ bulk of the context win. ## verification -- new `tests/test_mcp_profiles.py`: `minimal` exposes exactly the 7; `full` +- new `tests/test_mcp_profiles.py`: `minimal` exposes exactly the 8; `full` exposes all 58; every profile name ⊆ `METHODS`; env var overrides config. -- extended `tests/test_capabilities.py`: full mcp tools + cli commands vs - `METHODS` (the real 3-surface check). +- extended `tests/test_capabilities.py`: full mcp tool set vs `METHODS` (the + real 3-surface check for the mcp surface). - recall eval green / improved with fusion (ci-gated). -- manual before/after: fresh claude-code install shows ~7 tools not 58; +- manual before/after: fresh claude-code install shows ~8 tools not 58; a prompt gets relevant context injected with 0 tool calls; record the cold-spawn hook latency. From add870f584ceeb8c246c9981eb45dfe87cf21565 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:18:33 +0900 Subject: [PATCH 19/34] feat(mcp): add tool profiles, expose a minimal surface by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents saw all 58 kb.* tools every turn — the main first-touch friendliness gap vs pmb, which exposes ~10 by default. add a profile layer applied in run_stdio: minimal (8 core tools) by default, standard (16), or full (58), selected by VOUCH_TOOL_PROFILE / config mcp.tool_profile. exposure only — the protocol surface, jsonl/cli, and the review gate are unchanged. --- src/vouch/mcp_profiles.py | 85 ++++++++++++++++++++++++++++++++++++++ src/vouch/server.py | 7 +++- tests/test_mcp_profiles.py | 67 ++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/vouch/mcp_profiles.py create mode 100644 tests/test_mcp_profiles.py diff --git a/src/vouch/mcp_profiles.py b/src/vouch/mcp_profiles.py new file mode 100644 index 00000000..381c66d6 --- /dev/null +++ b/src/vouch/mcp_profiles.py @@ -0,0 +1,85 @@ +"""MCP tool profiles — narrow the tool surface an agent sees by default. + +vouch exposes 58 kb.* methods. Handing all of them to an agent every turn is +the main first-touch friendliness cost (the closest competitor, pmb, exposes +~10 by default and hides the rest behind a profile flag). Profiles control +*exposure* only: the JSONL and CLI surfaces, the protocol method list +(capabilities.METHODS), and the review gate are unchanged. + +Resolution order: VOUCH_TOOL_PROFILE env var > config.yaml `mcp.tool_profile` +> "minimal" (the default). Unknown names fall back to "minimal". +""" + +from __future__ import annotations + +import os +from typing import Any + +from .capabilities import METHODS + +_MINIMAL: frozenset[str] = frozenset({ + "kb.capabilities", + "kb.status", + "kb.context", + "kb.search", + "kb.read_page", + "kb.propose_claim", + "kb.propose_page", + "kb.list_pending", +}) + +_STANDARD: frozenset[str] = _MINIMAL | frozenset({ + "kb.approve", + "kb.reject", + "kb.supersede", + "kb.contradict", + "kb.confirm", + "kb.read_claim", + "kb.list_claims", + "kb.neighbors", + "kb.why", +}) + +PROFILES: dict[str, frozenset[str]] = { + "minimal": _MINIMAL, + "standard": _STANDARD, + "full": frozenset(METHODS), +} + +DEFAULT_PROFILE = "minimal" + + +def _tool_name(method: str) -> str: + """`"kb.propose_claim"` -> `"kb_propose_claim"` (MCP tool names use `_`).""" + return "kb_" + method.split(".", 1)[1] + + +def resolve_profile_name(config: dict[str, Any] | None = None) -> str: + """Pick the active profile from env > config > default.""" + raw = os.environ.get("VOUCH_TOOL_PROFILE") + if not raw and config: + raw = config.get("mcp", {}).get("tool_profile") + name = str(raw).strip().lower() if raw else DEFAULT_PROFILE + return name if name in PROFILES else DEFAULT_PROFILE + + +def tool_names_for(name: str) -> set[str]: + """The MCP (underscore) tool names exposed by profile `name`.""" + return {_tool_name(m) for m in PROFILES.get(name, PROFILES[DEFAULT_PROFILE])} + + +def apply_tool_profile(mcp: Any, name: str) -> list[str]: + """Remove every registered `kb_*` MCP tool not in profile `name`. + + Returns the sorted list of removed tool names. Idempotent. `full` removes + nothing. Only `kb_*` tools are touched, so trust/diagnostic tools + registered elsewhere are never dropped. + """ + keep = tool_names_for(name) + tools = mcp._tool_manager._tools + removed: list[str] = [] + for tool_name in list(tools.keys()): + if tool_name.startswith("kb_") and tool_name not in keep: + tools.pop(tool_name, None) + removed.append(tool_name) + return sorted(removed) diff --git a/src/vouch/server.py b/src/vouch/server.py index 8cabee08..f4ab0d72 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -19,7 +19,7 @@ import yaml from mcp.server.fastmcp import FastMCP -from . import audit, bundle, health, volunteer_context +from . import audit, bundle, health, mcp_profiles, volunteer_context from . import compile as compile_mod from . import digest as digest_mod from . import lifecycle as life @@ -1012,4 +1012,9 @@ def run_stdio() -> None: """Entry point used by `vouch serve`.""" configure_logging() trust_mod.set_stdio_default(trust_mod.MCP_STDIO) + try: + cfg: dict[str, Any] | None = _load_cfg(_store()) + except Exception: + cfg = None + mcp_profiles.apply_tool_profile(mcp, mcp_profiles.resolve_profile_name(cfg)) mcp.run() diff --git a/tests/test_mcp_profiles.py b/tests/test_mcp_profiles.py new file mode 100644 index 00000000..2f7604a8 --- /dev/null +++ b/tests/test_mcp_profiles.py @@ -0,0 +1,67 @@ +"""MCP tool profiles narrow the surface an agent sees (friendlier-mcp slice).""" + +from __future__ import annotations + +import pytest +from mcp.server.fastmcp import FastMCP + +from vouch import mcp_profiles +from vouch.capabilities import METHODS + +_MINIMAL_TOOLS = { + "kb_capabilities", "kb_status", "kb_context", "kb_search", + "kb_read_page", "kb_propose_claim", "kb_propose_page", "kb_list_pending", +} + + +def _make(name: str): + def fn(x: int = 0) -> int: + return x + fn.__name__ = name + return fn + + +def _fresh_mcp() -> FastMCP: + m = FastMCP("probe") + for method in METHODS: + m.tool()(_make("kb_" + method.split(".", 1)[1])) + return m + + +def test_full_profile_removes_nothing() -> None: + m = _fresh_mcp() + removed = mcp_profiles.apply_tool_profile(m, "full") + assert removed == [] + assert len(m._tool_manager._tools) == len(METHODS) + + +def test_minimal_exposes_core_only() -> None: + m = _fresh_mcp() + mcp_profiles.apply_tool_profile(m, "minimal") + assert set(m._tool_manager._tools) == _MINIMAL_TOOLS + + +def test_standard_is_superset_of_minimal() -> None: + assert mcp_profiles.PROFILES["minimal"] <= mcp_profiles.PROFILES["standard"] + + +def test_every_profile_is_subset_of_methods() -> None: + allm = set(METHODS) + for name, methods in mcp_profiles.PROFILES.items(): + assert methods <= allm, f"{name} references non-methods: {methods - allm}" + assert mcp_profiles.PROFILES["full"] == allm + + +def test_resolve_env_beats_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_TOOL_PROFILE", "full") + assert mcp_profiles.resolve_profile_name({"mcp": {"tool_profile": "minimal"}}) == "full" + + +def test_resolve_default_is_minimal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VOUCH_TOOL_PROFILE", raising=False) + assert mcp_profiles.resolve_profile_name(None) == "minimal" + + +def test_unknown_profile_falls_back_to_minimal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOUCH_TOOL_PROFILE", "bogus") + assert mcp_profiles.resolve_profile_name(None) == "minimal" From de6310e97a2407faeb77e8d0a26a1693f21ab2de Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:26:15 +0900 Subject: [PATCH 20/34] fix(mcp): harden profile resolution against a non-dict config section a bare `mcp:` key in config.yaml parses to None in YAML, and a non-dict mcp section is also possible from hand-edited configs. the previous config.get("mcp", {}).get("tool_profile") chain only supplied the {} default when the key was absent, so a present-but-None or non-dict value passed through and the chained .get raised AttributeError. run_stdio calls resolve_profile_name outside its try/except, so this would crash vouch serve instead of degrading to the minimal default. guard each nesting level with isinstance, matching the established reflex_cfg pattern in salience.py. --- src/vouch/mcp_profiles.py | 6 ++++-- tests/test_mcp_profiles.py | 9 +++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/vouch/mcp_profiles.py b/src/vouch/mcp_profiles.py index 381c66d6..e3f02383 100644 --- a/src/vouch/mcp_profiles.py +++ b/src/vouch/mcp_profiles.py @@ -57,8 +57,10 @@ def _tool_name(method: str) -> str: def resolve_profile_name(config: dict[str, Any] | None = None) -> str: """Pick the active profile from env > config > default.""" raw = os.environ.get("VOUCH_TOOL_PROFILE") - if not raw and config: - raw = config.get("mcp", {}).get("tool_profile") + if not raw and isinstance(config, dict): + mcp_cfg = config.get("mcp") + if isinstance(mcp_cfg, dict): + raw = mcp_cfg.get("tool_profile") name = str(raw).strip().lower() if raw else DEFAULT_PROFILE return name if name in PROFILES else DEFAULT_PROFILE diff --git a/tests/test_mcp_profiles.py b/tests/test_mcp_profiles.py index 2f7604a8..5cb201ba 100644 --- a/tests/test_mcp_profiles.py +++ b/tests/test_mcp_profiles.py @@ -65,3 +65,12 @@ def test_resolve_default_is_minimal(monkeypatch: pytest.MonkeyPatch) -> None: def test_unknown_profile_falls_back_to_minimal(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("VOUCH_TOOL_PROFILE", "bogus") assert mcp_profiles.resolve_profile_name(None) == "minimal" + + +def test_resolve_tolerates_non_dict_mcp_section(monkeypatch: pytest.MonkeyPatch) -> None: + """A bare `mcp:` key (YAML -> None) or non-dict mcp section must degrade + to the default, not crash `vouch serve`.""" + monkeypatch.delenv("VOUCH_TOOL_PROFILE", raising=False) + assert mcp_profiles.resolve_profile_name({"mcp": None}) == "minimal" + assert mcp_profiles.resolve_profile_name({"mcp": "oops"}) == "minimal" + assert mcp_profiles.resolve_profile_name({"mcp": {}}) == "minimal" From cb291fd5dee1dd9cc203522cf0a58c87705567e7 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:28:35 +0900 Subject: [PATCH 21/34] test(capabilities): assert mcp tool set matches the method list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the parity test only compared capabilities.METHODS to the jsonl handlers; the 58 mcp tools were never checked, so mcp drift passed ci. enumerate the unfiltered server tools and assert they equal METHODS — the real 3-surface check for the mcp side. --- tests/test_capabilities.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 736b20ba..037b4a5c 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -15,3 +15,22 @@ def test_capabilities_matches_jsonl_handlers() -> None: f"missing handlers={declared - implemented}, " f"missing capabilities={implemented - declared}" ) + + +def test_mcp_tools_match_methods() -> None: + """Every MCP kb_* tool maps to a capabilities method and vice-versa. + + Closes the MCP half of the 3-surface parity invariant that the JSONL + check above did not cover. Uses the unfiltered server object (profiles + apply only in run_stdio). + """ + from vouch.server import mcp + + tool_names = {n for n in mcp._tool_manager._tools if n.startswith("kb_")} + as_methods = {"kb." + n.split("_", 1)[1] for n in tool_names} + declared = set(capabilities.METHODS) + assert as_methods == declared, ( + f"mcp/methods mismatch: " + f"missing tools={declared - as_methods}, " + f"undeclared tools={as_methods - declared}" + ) From a13cbfc0fba31ffa90e8111e42e352de7f85334b Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:44:23 +0900 Subject: [PATCH 22/34] feat(retrieval): fuse embedding + fts5 by default instead of a waterfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _retrieve tried embedding, then fts5, then substring, returning the first non-empty list — so lexical and semantic hits never combined. auto and hybrid now fuse both retrievers with the already-built rrf_fuse and tag hits "hybrid"; explicit embedding/fts5/substring pins are unchanged. existing KBs (config says "auto") benefit with no migration. --- src/vouch/context.py | 36 +++++++++++++++++++-------------- src/vouch/storage.py | 4 ++-- tests/test_retrieval_backend.py | 35 ++++++++++++++++++++++++++------ 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/vouch/context.py b/src/vouch/context.py index 6e9b08ce..2f92f23c 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -19,6 +19,7 @@ import yaml from . import graph, index_db +from .embeddings.fusion import rrf_fuse from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality from .scoping import ( ViewerContext, @@ -42,7 +43,7 @@ ContextItemKind = Literal["claim", "page", "entity", "relation", "source"] -_VALID_BACKENDS = ("auto", "embedding", "fts5", "substring") +_VALID_BACKENDS = ("auto", "hybrid", "embedding", "fts5", "substring") def _configured_backend(store: KBStore) -> str: @@ -82,7 +83,8 @@ def _retrieve( """Return list of (kind, id, summary, score, backend). The backend is chosen by `retrieval.backend` in config.yaml: - - "auto" (default): embedding -> FTS5 -> substring + - "auto" (default) / "hybrid": fuse embedding + FTS5 via RRF, falling + back to a substring scan only if both retrievers are empty - "embedding": semantic search only - "fts5": lexical FTS5 only - "substring": substring scan only @@ -90,34 +92,38 @@ def _retrieve( backend = _configured_backend(store) fetch_limit = scoped_fetch_limit(limit, viewer) - if backend in ("auto", "embedding"): + if backend in ("auto", "hybrid"): + sem = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) + try: + lex = index_db.search(store.kb_dir, query, limit=fetch_limit) + except sqlite3.Error: + lex = [] + fused = rrf_fuse(sem, lex, limit=fetch_limit) + if fused: + filtered = filter_hits(store, fused, viewer, limit=limit) + return [(k, i, s, sc, "hybrid") for k, i, s, sc in filtered] + # both retrievers empty -> fall through to the substring scan below. + + if backend == "embedding": raw = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) if raw: filtered = filter_hits(store, raw, viewer, limit=limit) return [(k, i, s, sc, "embedding") for k, i, s, sc in filtered] - if backend == "embedding": - return [] + return [] - if backend in ("auto", "fts5"): + if backend == "fts5": try: hits = index_db.search(store.kb_dir, query, limit=fetch_limit) if hits: filtered = filter_hits(store, hits, viewer, limit=limit) return [(k, i, s, sc, "fts5") for k, i, s, sc in filtered] except sqlite3.Error: - # FTS5 unavailable, db missing, or schema mismatch — fall through - # to substring scan (auto) or empty (explicit fts5). Other - # exceptions are real bugs and propagate. pass - if backend == "fts5": - return [] + return [] substring_hits = store.search_substring(query, limit=fetch_limit) filtered = filter_hits(store, substring_hits, viewer, limit=limit) - return [ - (k, i, s, sc, "substring") - for k, i, s, sc in filtered - ] + return [(k, i, s, sc, "substring") for k, i, s, sc in filtered] def _enrich_summary(store: KBStore, kind: str, artifact_id: str, summary: str) -> str: diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 653ea806..cfbd295e 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -91,9 +91,9 @@ def _starter_config() -> dict[str, Any]: "max_chars": 12000, }, "retrieval": { - # auto = embedding -> fts5 -> substring; or pin one of + # hybrid/auto = fuse embedding + fts5 via RRF; or pin one of # embedding | fts5 | substring. See context._retrieve. - "backend": "auto", + "backend": "hybrid", "default_limit": 10, }, "agents": { diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py index 2dc30cd6..8f5ee8bc 100644 --- a/tests/test_retrieval_backend.py +++ b/tests/test_retrieval_backend.py @@ -79,23 +79,46 @@ def test_backend_substring_only( assert _backends(pack) == {"substring"} -def test_backend_auto_prefers_embedding( +def test_backend_auto_now_fuses( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """Default `auto` tries embedding first when it returns hits.""" + """`auto` no longer waterfalls embedding-first; it fuses embedding + fts5 + (RRF) and tags hits `hybrid`.""" _force_semantic_hit(monkeypatch) _set_backend(store, "auto") pack = context.build_context_pack(store, query="JWT") - assert any(item["backend"] == "embedding" for item in pack["items"]) + assert pack["items"] + assert _backends(pack) == {"hybrid"} -def test_unset_backend_defaults_to_auto( +def test_unset_backend_fuses( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """A config with no retrieval.backend behaves like `auto`.""" + """A config with no retrieval.backend behaves like fused `auto`.""" _force_semantic_hit(monkeypatch) cfg = yaml.safe_load(store.config_path.read_text()) cfg.get("retrieval", {}).pop("backend", None) store.config_path.write_text(yaml.safe_dump(cfg)) pack = context.build_context_pack(store, query="JWT") - assert any(item["backend"] == "embedding" for item in pack["items"]) + assert _backends(pack) == {"hybrid"} + + +def test_backend_hybrid_merges_semantic_and_lexical( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """`hybrid` returns the union of both retrievers, not first-non-empty.""" + src = store.put_source(b"e2") + store.put_claim(Claim(id="c2", text="OAuth refresh flow", evidence=[src.id])) + health.rebuild_index(store) + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [("claim", "c1", "JWT token rotation", 0.99)], + ) + monkeypatch.setattr( + context.index_db, "search", + lambda *a, **k: [("claim", "c2", "OAuth refresh flow", 0.88)], + ) + _set_backend(store, "hybrid") + pack = context.build_context_pack(store, query="auth") + assert {item["id"] for item in pack["items"]} == {"c1", "c2"} + assert _backends(pack) == {"hybrid"} From 356c3a3a89d4030b4171ef86649b83615701d91e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:46:17 +0900 Subject: [PATCH 23/34] fix(volunteer): treat hybrid relevance as rank-relative, not pre-normalized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalize_relevance() grouped "hybrid" with "embedding", returning the raw score unclamped-by-batch on the assumption it was already a 0-1 similarity. now that _retrieve's auto/hybrid path tags fused hits "hybrid" with a rrf_fuse score (bounded by ~2/(k+rank), typically 0.01-0.03), that assumption stopped holding: every fused relevance landed far below DEFAULT_THRESHOLD (0.85), so kb.volunteer_context stopped firing for any KB using the new default backend. batch-normalize hybrid like fts5 and substring instead — restores the confidence-gated push channel (#236). --- src/vouch/volunteer_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vouch/volunteer_context.py b/src/vouch/volunteer_context.py index b1036e0f..33fd23c4 100644 --- a/src/vouch/volunteer_context.py +++ b/src/vouch/volunteer_context.py @@ -99,7 +99,7 @@ def session_query(sess: Session) -> str | None: def normalize_relevance(raw: float, backend: str, *, batch_max: float) -> float: - if backend in ("embedding", "hybrid"): + if backend == "embedding": return max(0.0, min(1.0, raw)) if batch_max <= 0.0: return 0.0 From 3cbf712cc9079e890431967083e587c41d194a25 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:59:31 +0900 Subject: [PATCH 24/34] feat(retrieval): drop near-duplicate items from the context pack a fused pack could surface the same fact from two claims. add a cheap greedy jaccard pass (>=0.85 over the first 40 tokens) that keeps the highest-scored of a near-duplicate cluster, so an agent never reads the same thing twice. --- src/vouch/context.py | 27 +++++++++++++++++++++++++++ tests/test_retrieval_backend.py | 23 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/vouch/context.py b/src/vouch/context.py index 2f92f23c..7812df52 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -203,6 +203,30 @@ def _append_graph_neighbors( return warnings +def _jaccard(a: set[str], b: set[str]) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]: + """Drop later items whose summary is near-identical to a kept one. + + Cheap greedy pass (token-set Jaccard >= 0.85 over the first 40 tokens). + `items` must arrive in descending-score order so the higher-scored + duplicate is the one kept. + """ + kept: list[ContextItem] = [] + kept_tokens: list[set[str]] = [] + for it in items: + toks = set(it.summary.lower().split()[:40]) + if any(_jaccard(toks, seen) >= 0.85 for seen in kept_tokens): + continue + kept.append(it) + kept_tokens.append(toks) + return kept + + def build_context_pack( store: KBStore, *, @@ -260,6 +284,9 @@ def build_context_pack( rel_types=graph_rel_types, ) ) + + items = _dedupe_near_duplicates(items) + failed: list[str] = [] uncited: list[str] = [] budget_truncated = False diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py index 8f5ee8bc..fc4a2f9f 100644 --- a/tests/test_retrieval_backend.py +++ b/tests/test_retrieval_backend.py @@ -122,3 +122,26 @@ def test_backend_hybrid_merges_semantic_and_lexical( pack = context.build_context_pack(store, query="auth") assert {item["id"] for item in pack["items"]} == {"c1", "c2"} assert _backends(pack) == {"hybrid"} + + +def test_near_duplicate_summaries_are_dropped( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """An agent should not see the same fact twice.""" + src = store.put_source(b"z") + store.put_claim(Claim( + id="d1", text="the cache uses redis with a 60 second ttl", evidence=[src.id])) + store.put_claim(Claim( + id="d2", text="the cache uses redis with a 60 second ttl now", evidence=[src.id])) + health.rebuild_index(store) + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [ + ("claim", "d1", "the cache uses redis with a 60 second ttl", 0.90), + ("claim", "d2", "the cache uses redis with a 60 second ttl now", 0.89), + ], + ) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + _set_backend(store, "hybrid") + pack = context.build_context_pack(store, query="cache") + assert {item["id"] for item in pack["items"]} == {"d1"} From 1468a7eda48e6550b83c2dd030c38109fa2838fd Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:10:03 +0900 Subject: [PATCH 25/34] fix(retrieval): dedupe by score so the highest-scored near-dup wins _dedupe_near_duplicates assumed its input arrived in descending-score order, which only holds for the plain retrieval path. when build_context_pack runs with expand_graph=True, graph neighbours are appended in bfs order with decayed scores after the sorted main items, so a higher-scored neighbour could be dropped in favor of an earlier, lower-scored main item. sort inside the function so the invariant holds regardless of caller order. --- src/vouch/context.py | 15 ++++++++++----- tests/test_retrieval_backend.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/vouch/context.py b/src/vouch/context.py index 7812df52..7215f95c 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -210,15 +210,20 @@ def _jaccard(a: set[str], b: set[str]) -> float: def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]: - """Drop later items whose summary is near-identical to a kept one. + """Drop items whose summary is near-identical to a higher-scored one. - Cheap greedy pass (token-set Jaccard >= 0.85 over the first 40 tokens). - `items` must arrive in descending-score order so the higher-scored - duplicate is the one kept. + Cheap greedy pass (token-set Jaccard >= 0.85 over the first 40 tokens), + keeping the highest-scored member of each near-duplicate cluster. Sorts + by score internally so the invariant holds even when callers append + lower-priority items out of order (e.g. graph-expansion neighbours). + + Note: the 0.85/40-token heuristic can over-merge long, near-templated + claims that differ by a single token (e.g. a version or status word); + it is a deliberate cheap filter, not a semantic dedup. """ kept: list[ContextItem] = [] kept_tokens: list[set[str]] = [] - for it in items: + for it in sorted(items, key=lambda i: i.score, reverse=True): toks = set(it.summary.lower().split()[:40]) if any(_jaccard(toks, seen) >= 0.85 for seen in kept_tokens): continue diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py index fc4a2f9f..2630765e 100644 --- a/tests/test_retrieval_backend.py +++ b/tests/test_retrieval_backend.py @@ -145,3 +145,20 @@ def test_near_duplicate_summaries_are_dropped( _set_backend(store, "hybrid") pack = context.build_context_pack(store, query="cache") assert {item["id"] for item in pack["items"]} == {"d1"} + + +def test_dedupe_keeps_highest_scored_regardless_of_input_order() -> None: + """Invariant: the highest-scored member of a near-duplicate cluster + survives even when items arrive out of score order (as graph-expansion + neighbours can).""" + from vouch.context import _dedupe_near_duplicates + from vouch.models import ContextItem + + lo = ContextItem(id="lo", type="claim", + summary="the cache uses redis with a 60 second ttl", + score=0.30, backend="hybrid", citations=[], freshness="unknown") + hi = ContextItem(id="hi", type="claim", + summary="the cache uses redis with a 60 second ttl now", + score=0.90, backend="hybrid", citations=[], freshness="unknown") + out = _dedupe_near_duplicates([lo, hi]) # deliberately low-score-first + assert [i.id for i in out] == ["hi"] From f698fd0658d91f9cc692ba15525c01b8df4bca68 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:18:44 +0900 Subject: [PATCH 26/34] feat(hooks): inject per-prompt kb context via a userpromptsubmit hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recall used to be either a session-start firehose or an explicit tool call. add a pure, never-raising helper + a hidden `vouch context-hook` command that reads the host's prompt payload on stdin and prints an additionalContext envelope, and wire it into the claude-code adapter's UserPromptSubmit hook — so relevant, approved knowledge is injected every turn with zero tool calls. --- adapters/claude-code/.claude/settings.json | 12 +++++ src/vouch/cli.py | 21 ++++++++ src/vouch/hooks.py | 62 ++++++++++++++++++++++ tests/test_hooks.py | 60 +++++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 src/vouch/hooks.py create mode 100644 tests/test_hooks.py diff --git a/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json index 5fd35a67..d77ad5ad 100644 --- a/adapters/claude-code/.claude/settings.json +++ b/adapters/claude-code/.claude/settings.json @@ -43,6 +43,18 @@ ] } ], + "UserPromptSubmit": [ + { + "comment": "inject KB context relevant to THIS prompt (per-prompt auto-recall, 0 tool calls); never blocks the turn", + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "vouch context-hook || true" + } + ] + } + ], "PostToolUse": [ { "matcher": "*", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a490fcf9..338e31dd 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2299,6 +2299,27 @@ def context( _emit_json(pack) +@cli.command(name="context-hook", hidden=True) +def context_hook() -> None: + """Emit relevant KB context for a host UserPromptSubmit hook (reads stdin). + + Wired by the claude-code adapter; not meant to be run by hand. Reads the + host's JSON hook payload on stdin, prints an additionalContext envelope, + and always exits 0 so it can never block a turn. + """ + import sys + + from . import hooks + + stdin_text = sys.stdin.read() + try: + out = hooks.build_claude_prompt_hook(_load_store(), stdin_text) + except Exception: + out = "" + if out: + click.echo(out) + + @cli.command() @click.argument("query") @click.option("--depth", default=3, show_default=True, type=int) diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py new file mode 100644 index 00000000..a9c47de6 --- /dev/null +++ b/src/vouch/hooks.py @@ -0,0 +1,62 @@ +"""Host-hook helpers: translate an agent host's prompt hook into KB context. + +Claude Code's UserPromptSubmit hook passes a JSON payload on stdin and injects +whatever the hook prints (as an `additionalContext` envelope) before the model +runs. `build_claude_prompt_hook` turns that payload into a compact, relevant +context block drawn from *approved* KB knowledge — so recall costs the agent +zero tool calls. It never raises: on any problem it returns "" (inject +nothing), so a hook failure can never block the user's turn. +""" + +from __future__ import annotations + +import json +from typing import Any + +from .context import build_context_pack +from .storage import KBStore + +_MAX_ITEMS = 8 +_MAX_CHARS = 2000 + + +def _render(pack: dict[str, Any]) -> str: + lines: list[str] = [] + for item in pack.get("items", []): + summary = str(item.get("summary", "")).strip() + if not summary: + continue + cites = item.get("citations") or [] + suffix = f" [{', '.join(cites)}]" if cites else "" + lines.append(f"- {summary}{suffix}") + return "\n".join(lines) + + +def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: + """Return the stdout a host should inject for this prompt, or "" for none.""" + try: + payload = json.loads(stdin_text) if stdin_text.strip() else {} + except json.JSONDecodeError: + payload = {"prompt": stdin_text} + prompt = str(payload.get("prompt", "")).strip() + if not prompt: + return "" + try: + pack = build_context_pack( + store, query=prompt, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, + ) + except Exception: + return "" + body = _render(pack) if isinstance(pack, dict) else "" + if not body: + return "" + block = ( + "Relevant knowledge from the project's vouch KB " + "(approved & cited — consider it before answering):\n" + body + ) + return json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": block, + } + }) diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 00000000..ce7ebf93 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,60 @@ +"""The per-prompt hook injects relevant KB context with zero tool calls.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch import context, health, hooks +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + src = s.put_source(b"e") + s.put_claim(Claim(id="c1", text="deploys run on tuesdays via ci", evidence=[src.id])) + health.rebuild_index(s) + return s + + +def _force_hit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + context.index_db, "search_semantic", + lambda *a, **k: [("claim", "c1", "deploys run on tuesdays via ci", 0.9)], + ) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + + +def test_empty_prompt_injects_nothing(store: KBStore) -> None: + assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": ""})) == "" + assert hooks.build_claude_prompt_hook(store, "") == "" + + +def test_relevant_prompt_yields_additional_context( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _force_hit(monkeypatch) + out = hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "when do deploys run"})) + env = json.loads(out) + assert env["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit" + assert "tuesdays" in env["hookSpecificOutput"]["additionalContext"] + + +def test_raw_non_json_stdin_is_tolerated( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _force_hit(monkeypatch) + out = hooks.build_claude_prompt_hook(store, "when do deploys run") + assert "tuesdays" in json.loads(out)["hookSpecificOutput"]["additionalContext"] + + +def test_no_hits_injects_nothing( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "zzznomatch"})) == "" From 63333c4705405de99a2cc490f072f79b10e40bf2 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:30:50 +0900 Subject: [PATCH 27/34] fix(hooks): never raise on non-dict payload or missing kb a reviewer reproduced two crash paths that violated task 5's own contract that the prompt hook must never block a turn: build_claude_prompt_hook raised AttributeError on non-dict JSON (null/number/bool/array/string all decode fine but lack .get), and the context-hook CLI command called _load_store(), whose sys.exit(2) on a missing KB is a SystemExit that slips past `except Exception` and exits the process nonzero. guard the payload with an isinstance check before .get, log a breadcrumb when build_context_pack fails so a broken KB isn't silently indistinguishable from "no hits", and swap _load_store() for the existing non-exiting _capture_store() helper so the command has no sys.exit on its path at all. --- src/vouch/cli.py | 11 +++++++---- src/vouch/hooks.py | 6 ++++++ tests/test_hooks.py | 25 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 338e31dd..f7383c7d 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2312,10 +2312,13 @@ def context_hook() -> None: from . import hooks stdin_text = sys.stdin.read() - try: - out = hooks.build_claude_prompt_hook(_load_store(), stdin_text) - except Exception: - out = "" + store = _capture_store() + out = "" + if store is not None: + try: + out = hooks.build_claude_prompt_hook(store, stdin_text) + except Exception: + out = "" if out: click.echo(out) diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index a9c47de6..e90c2f66 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -11,11 +11,14 @@ from __future__ import annotations import json +import logging from typing import Any from .context import build_context_pack from .storage import KBStore +_log = logging.getLogger("vouch") + _MAX_ITEMS = 8 _MAX_CHARS = 2000 @@ -38,6 +41,8 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: payload = json.loads(stdin_text) if stdin_text.strip() else {} except json.JSONDecodeError: payload = {"prompt": stdin_text} + if not isinstance(payload, dict): + payload = {} prompt = str(payload.get("prompt", "")).strip() if not prompt: return "" @@ -46,6 +51,7 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: store, query=prompt, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, ) except Exception: + _log.warning("context-hook: build_context_pack failed", exc_info=True) return "" body = _render(pack) if isinstance(pack, dict) else "" if not body: diff --git a/tests/test_hooks.py b/tests/test_hooks.py index ce7ebf93..9a096eff 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -58,3 +58,28 @@ def test_no_hits_injects_nothing( monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "zzznomatch"})) == "" + + +def test_non_dict_json_payload_is_safe(store: KBStore) -> None: + for raw in ("null", "42", "true", "[1,2,3]", '"a string"'): + assert hooks.build_claude_prompt_hook(store, raw) == "" + + +def test_build_context_pack_exception_is_swallowed( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + def _boom(*a: object, **k: object) -> object: + raise RuntimeError("boom") + monkeypatch.setattr(hooks, "build_context_pack", _boom) + assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "x"})) == "" + + +def test_context_hook_cli_always_exits_zero_without_kb( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + monkeypatch.chdir(tmp_path) # no .vouch here + result = CliRunner().invoke(cli, ["context-hook"], input='{"prompt":"anything"}') + assert result.exit_code == 0 From c33b48ebad8ec38e430c591a3cbdd8cac0b27b0e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:40:43 +0900 Subject: [PATCH 28/34] feat(mcp): serve one-line tool descriptions under non-full profiles full docstrings for every exposed tool are paid on every turn. under minimal and standard, trim each tool's description to its first line (full keeps the complete docstrings), cutting the per-turn context cost. --- src/vouch/mcp_profiles.py | 22 ++++++++++++++++++++++ src/vouch/server.py | 5 ++++- tests/test_mcp_profiles.py | 16 ++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/vouch/mcp_profiles.py b/src/vouch/mcp_profiles.py index e3f02383..6f79f7c3 100644 --- a/src/vouch/mcp_profiles.py +++ b/src/vouch/mcp_profiles.py @@ -85,3 +85,25 @@ def apply_tool_profile(mcp: Any, name: str) -> list[str]: tools.pop(tool_name, None) removed.append(tool_name) return sorted(removed) + + +def compact_descriptions(mcp: Any) -> int: + """Trim each kb_ tool's description to its first line to save context. + + Full docstrings are only needed under the `full` profile; the first line + is enough for an agent choosing a tool. Returns the number changed. + """ + changed = 0 + for tool in mcp._tool_manager._tools.values(): + if not tool.name.startswith("kb_"): + continue + desc = tool.description or "" + first = desc.strip().split("\n", 1)[0].strip() + if first and first != desc: + try: + tool.description = first + except (AttributeError, TypeError): + # pydantic frozen-model fallback + object.__setattr__(tool, "description", first) + changed += 1 + return changed diff --git a/src/vouch/server.py b/src/vouch/server.py index f4ab0d72..8ac1487b 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -1016,5 +1016,8 @@ def run_stdio() -> None: cfg: dict[str, Any] | None = _load_cfg(_store()) except Exception: cfg = None - mcp_profiles.apply_tool_profile(mcp, mcp_profiles.resolve_profile_name(cfg)) + profile = mcp_profiles.resolve_profile_name(cfg) + mcp_profiles.apply_tool_profile(mcp, profile) + if profile != "full": + mcp_profiles.compact_descriptions(mcp) mcp.run() diff --git a/tests/test_mcp_profiles.py b/tests/test_mcp_profiles.py index 5cb201ba..30bff123 100644 --- a/tests/test_mcp_profiles.py +++ b/tests/test_mcp_profiles.py @@ -74,3 +74,19 @@ def test_resolve_tolerates_non_dict_mcp_section(monkeypatch: pytest.MonkeyPatch) assert mcp_profiles.resolve_profile_name({"mcp": None}) == "minimal" assert mcp_profiles.resolve_profile_name({"mcp": "oops"}) == "minimal" assert mcp_profiles.resolve_profile_name({"mcp": {}}) == "minimal" + + +def test_compact_descriptions_trims_to_first_line() -> None: + m = FastMCP("probe") + + def kb_thing(x: int = 0) -> int: + """First line. + + Second paragraph with lots of detail the agent does not need. + """ + return x + + m.tool()(kb_thing) + changed = mcp_profiles.compact_descriptions(m) + assert changed == 1 + assert m._tool_manager._tools["kb_thing"].description == "First line." From 6ea2a94acffc2c5ba17830bcc8b5e6954005f6ba Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:41:11 +0900 Subject: [PATCH 29/34] docs(mcp): document tool profiles note VOUCH_TOOL_PROFILE / mcp.tool_profile (default minimal, values minimal|standard|full) in the transports reference and the claude-code adapter guide; this repo has no mintlify/ tree yet so both live under docs/ and adapters/claude-code/ instead. --- adapters/claude-code/README.md | 3 +++ docs/transports.md | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 8c3efe05..8739da73 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -112,3 +112,6 @@ To upgrade or check your extension version, see [Claude Code releases](https://g (`kb_supersede`, `kb_contradict`, …) without you asking each time, add: "When you find a stale claim, supersede it rather than proposing a contradicting one." +- Only a core set of `kb_*` tools is visible by default (`mcp.tool_profile: + minimal` in `.vouch/config.yaml`, or the `VOUCH_TOOL_PROFILE` env var). + Set it to `standard` or `full` to expose lifecycle/admin tools. diff --git a/docs/transports.md b/docs/transports.md index 64105fa3..16e16fa2 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -25,6 +25,13 @@ The server speaks MCP over stdin/stdout. Your host configures it as a subprocess. Method `kb.search` is exposed as MCP tool `kb_search` (dots aren't valid in MCP tool names). +By default only a core set of `kb_*` tools is exposed (profile +`minimal`) to keep the per-turn tool-choice cost down; set +`VOUCH_TOOL_PROFILE` (env var, wins) or `mcp.tool_profile` in +`config.yaml` to `standard` or `full` to widen the surface. Outside +`full`, each exposed tool's description is also trimmed to its first +line. + ### Resources vouch also exposes read-only views as MCP resources: From 0cf6b29b7482d34d094d1d11e55c126638a4dd91 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:19:01 +0900 Subject: [PATCH 30/34] fix(retrieval): dedupe by score but keep caller order for the pack _dedupe_near_duplicates sorted by score and returned that score-sorted order, which fought graph-expansion: build_context_pack appends decayed-score neighbours after the ranked hits, and fused primary hits now carry small rrf scores. re-sorting let an irrelevant depth-2 neighbour outrank a real match, and the max_chars tail-pop then evicted the real match instead of the neighbour. keep the keep-decision in descending-score order so the highest-scored member of a near-duplicate cluster still survives, but return survivors in the caller's original order so budget eviction drops the tail (appended neighbours), not the ranked hits. --- src/vouch/context.py | 27 ++++++++++++++------------- tests/test_retrieval_backend.py | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/vouch/context.py b/src/vouch/context.py index 7215f95c..fa7e3f16 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -212,24 +212,25 @@ def _jaccard(a: set[str], b: set[str]) -> float: def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]: """Drop items whose summary is near-identical to a higher-scored one. - Cheap greedy pass (token-set Jaccard >= 0.85 over the first 40 tokens), - keeping the highest-scored member of each near-duplicate cluster. Sorts - by score internally so the invariant holds even when callers append - lower-priority items out of order (e.g. graph-expansion neighbours). - - Note: the 0.85/40-token heuristic can over-merge long, near-templated - claims that differ by a single token (e.g. a version or status word); - it is a deliberate cheap filter, not a semantic dedup. + The *keep* decision runs in descending-score order so the highest-scored + member of a near-duplicate cluster survives; survivors are returned in the + caller's original order. build_context_pack appends lower-priority items + (graph-expansion neighbours) after the ranked hits and relies on that tail + ordering for budget eviction, so this pass must not re-rank the pack. + + Cheap greedy heuristic (token-set Jaccard >= 0.85 over the first 40 tokens); + it can over-merge long near-templated claims that differ by a single token. """ - kept: list[ContextItem] = [] + dropped: set[int] = set() kept_tokens: list[set[str]] = [] - for it in sorted(items, key=lambda i: i.score, reverse=True): - toks = set(it.summary.lower().split()[:40]) + order = sorted(range(len(items)), key=lambda i: items[i].score, reverse=True) + for idx in order: + toks = set(items[idx].summary.lower().split()[:40]) if any(_jaccard(toks, seen) >= 0.85 for seen in kept_tokens): + dropped.add(idx) continue - kept.append(it) kept_tokens.append(toks) - return kept + return [it for i, it in enumerate(items) if i not in dropped] def build_context_pack( diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py index 2630765e..dcff5372 100644 --- a/tests/test_retrieval_backend.py +++ b/tests/test_retrieval_backend.py @@ -162,3 +162,18 @@ def test_dedupe_keeps_highest_scored_regardless_of_input_order() -> None: score=0.90, backend="hybrid", citations=[], freshness="unknown") out = _dedupe_near_duplicates([lo, hi]) # deliberately low-score-first assert [i.id for i in out] == ["hi"] + + +def test_dedupe_preserves_input_order_not_score_order() -> None: + """Survivors keep the caller's order (ranked hits first, appended + neighbours last) even when a later distinct item outscores an earlier one, + so budget eviction drops the tail, not the real matches.""" + from vouch.context import _dedupe_near_duplicates + from vouch.models import ContextItem + + a = ContextItem(id="a", type="claim", summary="alpha topic one", + score=0.02, backend="hybrid", citations=[], freshness="unknown") + b = ContextItem(id="b", type="claim", summary="beta subject two", + score=0.32, backend="graph", citations=[], freshness="unknown") + out = _dedupe_near_duplicates([a, b]) # distinct summaries, a first but lower-scored + assert [i.id for i in out] == ["a", "b"] From 6246363fd80e3f8595f663ba0c71dd1e702c119e Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:19:05 +0900 Subject: [PATCH 31/34] docs(changelog): note minimal mcp profile, fusion, auto-recall summarize this branch's user-visible changes under [Unreleased]: the minimal-by-default mcp tool profile, rrf fusion for auto/hybrid retrieval, and the per-prompt auto-recall hook in the claude-code adapter. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71809356..5e396ef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- mcp now serves a **minimal tool profile by default** (8 core tools) + instead of the full 58-method surface; widen with + `VOUCH_TOOL_PROFILE=standard|full` or `mcp.tool_profile` in + `config.yaml`. approve/reject and maintenance tools live in + `standard`/`full` — they stay human/cli actions, not agent defaults. + the jsonl and cli surfaces are unaffected; all 58 methods remain + reachable there. +- per-prompt auto-recall: the claude-code adapter's `UserPromptSubmit` + hook (`vouch context-hook`) injects relevant kb context on every + prompt, so recall no longer depends on the agent remembering to ask. + +### Changed +- retrieval `auto`/`hybrid` now **fuses embedding + fts5** results via + reciprocal rank fusion instead of a waterfall (embedding-first, + fts5-fallback), with near-duplicate suppression over the fused list. + ## [1.2.1] — 2026-07-06 ### Fixed From c5c5282eeaac76d219893f3b5c9657b773d98f73 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:46:19 +0900 Subject: [PATCH 32/34] docs(readme): note the fourth hook, per-prompt recall, and lean mcp profile install-mcp now writes a fourth hook (UserPromptSubmit -> vouch context-hook), and recall fires per prompt, not only at session start. also note the kb.* mcp surface defaults to a lean profile that widens via VOUCH_TOOL_PROFILE. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 730d7867..ef8f261c 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ vouch init vouch install-mcp claude-code ``` -`init` creates `.vouch/` with a starter config; `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. +`init` creates `.vouch/` with a starter config; `install-mcp` writes `.mcp.json` (the `kb.*` MCP tools — a lean default surface you can widen with `VOUCH_TOOL_PROFILE`), the `/vouch-*` slash commands, and four hooks — `PostToolUse` capture, `SessionEnd` rollup, `SessionStart` recall, and `UserPromptSubmit` per-prompt context injection. Restart Claude Code so they load. **2. Point `compile` at an LLM** — the only step that needs a model. In `.vouch/config.yaml`: @@ -120,6 +120,8 @@ Detection is Claude Code's hook contract: whatever a `SessionStart` hook prints Only approved artifacts are ever emitted — archived, superseded, and still-pending items are excluded — and the digest is size-guarded (`recall.max_chars`) with an explicit truncation notice. +Recall isn't only a session-start event anymore: a `UserPromptSubmit` hook runs `vouch context-hook` on *every* prompt, fusing the most relevant approved claims and pages for what you just asked and injecting them with zero tool calls — so the model sees relevant knowledge even when the agent doesn't think to look it up. + How the approved pages actually get used from there: recall carries the *titles*, and the session pulls full content on demand through the `kb.*` MCP tools — `kb_search` matches page bodies, `kb_read_page` returns a page's markdown plus the claims it cites, and `kb_context` bundles the most relevant claims and pages for a stated task. To pull a topic in explicitly, use the `/vouch-recall ` slash command, or just ask Claude to check the KB. One thing to know: pages still sitting in `vouch review` are invisible to all of this — the gate applies to retrieval too, so a compiled page only starts informing sessions once you approve it. **7. Commit the knowledge with the code.** From 965215fd4b47d82b6b9a9ecac3da11799b96762d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:22:43 +0000 Subject: [PATCH 33/34] Initial plan From bda69c212df2beb50478c220e0b5fa27e75e8adc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:30:34 +0000 Subject: [PATCH 34/34] fix(capabilities): add openclaw.compat.pluginApi to manifest and surface in host_compat (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add `openclaw.compat.pluginApi` to `openclaw.plugin.json` so that `_load_host_compat()` in `capabilities.py` can read it and surface it in `kb.capabilities.host_compat`. this closes the host-compat drift detection introduced by the new `test_capabilities_host_compat_*` tests. also remove `"openclaw"` from the dead-dialect-fields list in `test_manifest_carries_no_dead_dialect_fields` — the `openclaw` key is now a live declaration (`compat.pluginApi`), not a stale pre-2026.6 relic. --- openclaw.plugin.json | 5 +++++ tests/test_openclaw_plugin_manifest.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index ffcd470d..381ec2bc 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -7,6 +7,11 @@ "skills": [ "adapters/openclaw/skills" ], + "openclaw": { + "compat": { + "pluginApi": ">=2026.6.0" + } + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/tests/test_openclaw_plugin_manifest.py b/tests/test_openclaw_plugin_manifest.py index d67b35bc..f111f726 100644 --- a/tests/test_openclaw_plugin_manifest.py +++ b/tests/test_openclaw_plugin_manifest.py @@ -138,13 +138,13 @@ def test_manifest_carries_no_dead_dialect_fields(manifest: dict) -> None: """The pre-2026.6 dialect's fields are silently ignored by the loader. Keeping them around would suggest they still do something; they don't. + Note: ``openclaw`` is now a live field carrying ``compat.pluginApi`` (#237). """ dead_fields = ( "family", "mcpServers", "shared_deps", "excluded_from_install", - "openclaw", "contracts", ) for dead in dead_fields: