diff --git a/.claude/commands/vouch-propose-from-pr.md b/.claude/commands/vouch-propose-from-pr.md deleted file mode 100644 index aca31fc7..00000000 --- a/.claude/commands/vouch-propose-from-pr.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -description: Distill a merged PR into vouch claim proposals ---- - -# /vouch-propose-from-pr - -A PR is a decision: someone proposed a change, the team accepted it. The -"why" gets compressed into the merge message and forgotten. This command -preserves the why as cited claims in the KB. - -Steps: - -1. Parse `$ARGUMENTS` as a PR URL or `/#`; default to the - most-recently-merged PR you authored. -2. Fetch the PR title, body, and the merged-commit SHA via `gh`. -3. Register the merge commit as a `kb_register_source` so subsequent claims - can cite it. -4. Read the diff. For each *behavioural* change (not formatting / renaming), - draft one `kb_propose_claim` whose text summarises the new invariant the - code now upholds, citing the source from step 3. -5. Propose at most five claims per PR. If a PR is that big, suggest the - contributor split it next time. - -Do not auto-approve. The KB's review gate is intentional; this command -only fills the queue. diff --git a/.claude/commands/vouch-recall.md b/.claude/commands/vouch-recall.md deleted file mode 100644 index d72d7168..00000000 --- a/.claude/commands/vouch-recall.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Recall what the project's vouch KB knows about a topic ---- - -# /vouch-recall - -Use vouch's `kb_context` MCP tool to assemble a working set of claims, sources, -and entities the KB already has on the current topic. Print them with their -ids and citations; do not write anything. - -Steps: - -1. Call `kb_context` with `query: "$ARGUMENTS"`. -2. List every returned claim by id + one-line text; for each, show the - source ids it cites. -3. End with a one-sentence summary of what's *missing* from the KB on this - topic — the gap the user can fill with `/vouch-propose-from-pr` or - `kb_propose_claim`. - -Be terse. The KB is meant to remove ambiguity, not pad it. diff --git a/.claude/commands/vouch-resolve-issue.md b/.claude/commands/vouch-resolve-issue.md deleted file mode 100644 index 1b5aaf63..00000000 --- a/.claude/commands/vouch-resolve-issue.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Use vouch's KB to ground a fix for a GitHub issue ---- - -# /vouch-resolve-issue - -Wire vouch's `kb_context` into an issue-resolution flow: the KB should -inform the fix, and the act of solving should propose new claims that -make the next contributor faster. - -Steps: - -1. Parse `$ARGUMENTS` as a GitHub issue URL or `/#` shorthand. - If neither, ask for clarification. -2. `kb_context` with the issue title + body — what does the KB already know - about this area? Show the top 5 claims. -3. Read the relevant code paths. -4. Propose the smallest fix (run the project's tests first to confirm the - bug reproduces). -5. After the fix is committed, propose **at most three** new claims via - `kb_propose_claim` that capture: - * the root cause in one sentence (cited by the offending file:line), - * the chosen fix pattern (cited by the patch commit), and - * any policy/precedent established (only if novel). - -Do not auto-approve. Leave the proposals in `.vouch/proposed/` for the -maintainer to review with `vouch approve` after the PR merges. diff --git a/.claude/commands/vouch-status.md b/.claude/commands/vouch-status.md deleted file mode 100644 index 3320c642..00000000 --- a/.claude/commands/vouch-status.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: Show the project's vouch KB at a glance ---- - -# /vouch-status - -Run vouch's `kb_status` MCP tool and surface the result. Use this when the -user asks "what's in our KB?" or before/after a long claim-proposal flow so -they can see the proposal count tick up. - -Format: - -``` -KB at - claims: - sources: - entities: - pending: ← review queue depth - last audit: -``` - -If `pending > 0`, suggest the user run `vouch approve ` (or `vouch lint` -if they want to inspect anything first). Do not propose anything yourself. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 3130289c..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__vouch__kb_status", - "mcp__vouch__kb_capabilities", - "mcp__vouch__kb_search", - "mcp__vouch__kb_context", - "mcp__vouch__kb_read_claim", - "mcp__vouch__kb_read_source", - "mcp__vouch__kb_read_page", - "mcp__vouch__kb_read_entity", - "mcp__vouch__kb_read_relation", - "mcp__vouch__kb_list_claims", - "mcp__vouch__kb_list_sources", - "mcp__vouch__kb_list_pages", - "mcp__vouch__kb_list_entities", - "mcp__vouch__kb_list_relations", - "mcp__vouch__kb_list_pending" - ] - }, - "hooks": { - "SessionStart": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "vouch status --json || true" - }, - { - "type": "command", - "command": "vouch capture banner || true" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "vouch capture observe || true" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "vouch capture finalize || true" - } - ] - } - ] - } -} diff --git a/.gitignore b/.gitignore index 9a61a8d8..ee98bd96 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .mypy_cache/ .ruff_cache/ .coverage +coverage.xml htmlcov/ bench.json .benchmarks/ @@ -13,3 +14,12 @@ dist/ build/ *.swp .DS_Store + +# local tool scratch +.netlify/ +.playwright-mcp/ + +# owner-local, never shipped (anchored to repo root so src/vouch/web +# and adapters/claude-code/.claude stay tracked) +/web/ +/.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 71809356..986dcb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +## [1.2.2] — 2026-07-07 + +### Packaging +- published to the mcp registry (`registry.modelcontextprotocol.io`, mirrored + at `github.com/mcp/vouchdev/vouch`) as `io.github.vouchdev/vouch`. a + `server.json` at the repo root carries the metadata; the pypi `vouch-kb` + package is the artifact, run over stdio via `uvx vouch-kb serve`. +- `vouch-kb` console-script alias (alongside `vouch`) so `uvx vouch-kb serve` + resolves — the registry launches a package by its pypi identifier, which + otherwise wouldn't match the `vouch` script name. +- README carries an `` marker; + the registry verifies package ownership by matching it against `server.json`. + ## [1.2.1] — 2026-07-06 ### Fixed diff --git a/README.md b/README.md index 730d7867..b2fde3f9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ **Git-native, review-gated knowledge base for LLM agents. MCP server + JSONL tool server + CLI.** + + + +

vouch — sessions auto-capture into a review-gated knowledge base: propose or capture → review → commit → retrieve

@@ -9,6 +14,7 @@

CI PyPI + MCP Registry Python versions MIT licensed Follow @vouch_dev on X 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/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/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/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/README.md b/adapters/codex/README.md index cfb324a4..5b8666c4 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -1,13 +1,86 @@ # 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 -Codex reads MCP server config from `~/.codex/config.toml`. Add a -`vouch` entry: +```bash +vouch install-mcp codex # everything (T1–T4) +vouch install-mcp codex --tier T1 # just the MCP wire +``` + +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. + +What each tier adds (tiers stack): + +| 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 | + +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) + +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 +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. + +## Automatic session capture (T4) + +`.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`. + +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] @@ -18,7 +91,8 @@ args = ["serve"] VOUCH_AGENT = "codex" ``` -Restart any running `codex` session. +Restart any running `codex` session, then confirm with +`codex mcp list`. ## Notes 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 7ba68b2f..9d4cb447 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -3,8 +3,49 @@ # 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 +fence: + begin: "" + end: "" tiers: T1: - - { src: config.toml, dst: .codex/config.toml } + - { 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 } + # 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 } + # 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/docs/banner.svg b/docs/banner.svg index 1ab425c9..8dbd52f9 100644 --- a/docs/banner.svg +++ b/docs/banner.svg @@ -1,8 +1,8 @@ - + - + - + @@ -42,50 +42,11 @@ auditable - - - - - self-capturing - - - - - - - Sessions capture themselves - - - no LLM · gated - - - - - PostToolUse - observe → buffer - - - - - SessionEnd - roll up + git diff - - - - - one pending summary - enters the same gate - - - - - - - + 1 @@ -119,31 +80,33 @@ - + - + 2 Review - the gate — manual + captured + the gate PENDING - prop-7f3c · claim · src-9b2e + prop-7f3c · claim · cites src-9b2e - + + approve - + + reject @@ -154,10 +117,10 @@ - + - + 3 @@ -171,7 +134,7 @@ pages/<id>.md - session summaries + write-ups + markdown + frontmatter @@ -187,10 +150,10 @@ - + - + 4 @@ -198,8 +161,8 @@ - $ vouch recall - injects approved knowledge + $ vouch serve + MCP · JSONL @@ -220,8 +183,8 @@ - - + + .vouch/ — your KB lives in git @@ -235,19 +198,17 @@ stable confidence: 0.9 - page: - session-summary (sess-8f2e) — captured, approved - audit: - proposed · approved by @alice · 2026-07-01 - proposed_by: - claude-code · vouch-capture (auto-capture) + audit: + proposed · approved by @alice · 2026-05-12 + proposed_by: + claude-code (session sess-2f81) - - + + - propose — or auto-capture a Claude Code session — then review → commit → retrieve · agents and sessions write proposals · humans approve · git is the source of truth + propose → review → commit → retrieve · agents write proposals · humans approve · git is the source of truth diff --git a/docs/demo.gif b/docs/demo.gif index 4719418a..617ea1cf 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape index 43390f3f..76cda59b 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,4 +1,4 @@ -# vouch session auto-capture demo — render with: +# vouch end-to-end demo — render with: # vhs docs/demo.tape # Produces docs/demo.gif. @@ -8,108 +8,79 @@ Require vouch Set Shell "bash" Set FontSize 14 -Set Width 1240 -Set Height 760 +Set Width 1100 +Set Height 700 Set Padding 18 Set Theme "Catppuccin Mocha" -Set TypingSpeed 30ms +Set TypingSpeed 35ms Set PlaybackSpeed 1.0 -# --- prepare a sandbox project with git history (hidden) ----------------- +# --- prepare a sandbox ------------------------------------------------- Hide -Type `D=$(mktemp -d)/acme-api && mkdir -p "$D/src" "$D/tests" && cd "$D"` -Enter -Type `git init -q` -Enter -Type `printf 'def verify(token):\n return decode(token)\n' > src/auth.py` -Enter -Type `printf 'def test_verify():\n assert verify("x")\n' > tests/test_auth.py` -Enter -Type `git add -A && git -c user.email=demo@x.com -c user.name=demo commit -q -m "chore: seed"` -Enter -# the "fix" the session makes — left uncommitted so finalize's git-diff backstop catches it -Type `printf 'def verify(token):\n # reject unsigned tokens\n return decode(token, verify_signature=True)\n' > src/auth.py` -Enter -Type "clear" +Type "cd $(mktemp -d) && git init -q && echo 'Authentication uses stateless JWTs signed with RS256.' > auth.md && git add -A && git -c user.email=demo@x.com -c user.name=demo commit -q -m init && clear" Enter +Sleep 500ms Show -# --- init ---------------------------------------------------------------- +# --- init ------------------------------------------------------------- Type "vouch init" Sleep 300ms Enter -Sleep 1500ms +Sleep 1200ms -# --- a claude code session runs; the PostToolUse hook feeds vouch -------- -Type "# a claude code session runs. after each tool, the PostToolUse hook fires:" +# --- register a source ------------------------------------------------ +Type "SID=$(vouch source add auth.md --title 'auth notes')" +Sleep 300ms Enter -Sleep 900ms -Type `echo '{"session_id":"sess-8f2e4c7a","tool_name":"Edit","tool_input":{"file_path":"src/auth.py"}}' | vouch capture observe` +Sleep 600ms +Type "echo $SID" Enter -Sleep 1300ms +Sleep 1000ms -# --- three more tool calls captured off-camera (Read / pytest / Grep) ---- -Hide -Type `echo '{"session_id":"sess-8f2e4c7a","tool_name":"Read","tool_input":{"file_path":"src/auth.py"}}' | vouch capture observe` -Enter -Sleep 500ms -Type `echo '{"session_id":"sess-8f2e4c7a","tool_name":"Bash","tool_input":{"command":"pytest -q"}}' | vouch capture observe` +# --- propose a claim as an agent -------------------------------------- +Type "PID=$(VOUCH_AGENT=claude-code vouch propose-claim \" Enter -Sleep 500ms -Type `echo '{"session_id":"sess-8f2e4c7a","tool_name":"Grep","tool_input":{"pattern":"verify_signature"}}' | vouch capture observe` -Enter -Sleep 500ms -Type "clear" +Type " --text 'Authentication uses stateless JWTs signed with RS256' \" Enter -Show - -# --- the gitignored scratch buffer, quietly accumulating ----------------- -Type "# vouch logged each to a gitignored scratch buffer — not the KB yet:" +Type " --source $SID --type fact --confidence 0.9)" Enter Sleep 800ms -Type "cat .vouch/captures/sess-8f2e4c7a.jsonl" +Type "echo $PID" Enter -Sleep 2600ms +Sleep 1000ms -# --- session ends → SessionEnd hook rolls it into ONE pending summary ---- -Type "# session ends → the SessionEnd hook rolls the buffer + git diff into one proposal:" -Enter -Sleep 900ms -Type `echo '{"session_id":"sess-8f2e4c7a"}' | vouch capture finalize` +# --- review ----------------------------------------------------------- +Type "vouch pending" Enter -Sleep 2400ms +Sleep 1500ms -# --- next session start: the nudge, then the review queue ---------------- -Type "vouch capture banner" +Type "vouch show $PID | head -20" Enter -Sleep 1700ms -Type "# nothing is auto-approved — you review it like any other write:" +Sleep 2500ms + +# --- approve ---------------------------------------------------------- +Type "vouch approve $PID --reason 'matches the code'" Enter -Sleep 800ms -Type "vouch pending" +Sleep 1500ms + +Type "vouch status" Enter Sleep 2000ms -# --- approve → durable page ---------------------------------------------- -Type `PID=$(vouch pending | grep -oE '[0-9]{8}-[0-9]{6}-[0-9a-f]{8}' | head -1)` +# --- retrieve --------------------------------------------------------- +Type 'vouch search "JWT"' Enter -Sleep 400ms -Type "vouch approve $PID --reason 'accurate summary'" -Enter -Sleep 1800ms +Sleep 2000ms -# --- the summary vouch kept, now durable markdown on disk ---------------- -Type "# the summary it kept — plain markdown, committed alongside your code:" +Type "vouch audit --tail 4" Enter -Sleep 900ms -Type `sed -n '/^# session summary/,$p' .vouch/pages/session-summary-*.md` -Enter -Sleep 3600ms +Sleep 3000ms -# --- the next session starts with it — no amnesia ------------------------ -Type "# the next session starts with it injected — no re-explaining:" +# --- the durable artifact on disk ------------------------------------- +Type "ls .vouch/claims/" Enter -Sleep 900ms -Type "vouch recall" +Sleep 1500ms + +Type "cat .vouch/claims/*.yaml" Enter Sleep 4000ms diff --git a/docs/example-session.md b/docs/example-session.md index 1a17de6c..230a37f3 100644 --- a/docs/example-session.md +++ b/docs/example-session.md @@ -1,206 +1,178 @@ -# Example session — automatic session capture +# Example session -![vouch auto-capture demo](demo.gif) +![vouch end-to-end demo](demo.gif) -A full **capture → review → commit → recall** loop, driven by Claude Code -hooks. A session works; vouch quietly harvests what it did into a gitignored -scratch buffer; at session end that buffer rolls up into a single **pending -summary proposal**; you approve it like any other write; the next session -starts with it injected. Nothing is auto-approved — the review gate stays -intact. Re-render the GIF with `vhs docs/demo.tape` (see [demo.tape](demo.tape)). - -The capture path is fully mechanical — no LLM, no network, no agent -discipline required. It is wired by the Claude Code adapter's hooks -(`adapters/claude-code/.claude/settings.json`): `PostToolUse → vouch capture -observe`, `SessionEnd → vouch capture finalize`, and a `SessionStart` banner. +A full propose → review → commit → retrieve loop, captured from a real +run on 2026-05-21. Reproduce by following along in any git repo, or +re-render the GIF with `vhs docs/demo.tape` (see [demo.tape](demo.tape)). ## Setup ```bash -$ mkdir acme-api && cd acme-api && git init -q -$ printf 'def verify(token):\n return decode(token)\n' > src/auth.py -$ git add -A && git commit -q -m "chore: seed" +$ mkdir demo && cd demo && git init -q +$ echo "Authentication uses stateless JWTs signed with RS256." > auth.md +$ git add -A && git commit -q -m "init" $ vouch init -Initialised KB at /tmp/acme-api/.vouch -Seeded starter claim: vouch-starter-reviewed-knowledge -Next steps: - vouch status - vouch search agent - vouch serve +Initialised KB at /tmp/demo/.vouch +Next: `vouch serve` to expose the MCP server to your agent. ``` -## 1. A session works — the PostToolUse hook harvests it +`vouch init` creates the `.vouch/` directory with empty subfolders for +claims, pages, entities, relations, sources, sessions, proposed, +decided, plus `audit.log.jsonl` and `state.db`. + +## 1. Register a source -After every tool call, Claude Code's `PostToolUse` hook pipes the tool -payload to `vouch capture observe`. You never type these — the hook does. -Each call appends one compact observation (`{ts, tool, summary, files?, -cmd?}`) to a **gitignored scratch buffer**, deduped within a short window: +The agent (or you) registers the file as evidence material. Sources +are content-addressed — the id is the sha256 of the file content, so +the same file registered twice de-duplicates. ```bash -# what the hook runs after a Read, an Edit, a test run, and a grep: -$ echo '{"session_id":"sess-8f2e4c7a","tool_name":"Read","tool_input":{"file_path":"src/auth.py"}}' | vouch capture observe -$ echo '{"session_id":"sess-8f2e4c7a","tool_name":"Edit","tool_input":{"file_path":"src/auth.py"}}' | vouch capture observe -$ echo '{"session_id":"sess-8f2e4c7a","tool_name":"Bash","tool_input":{"command":"pytest -q"}}' | vouch capture observe -$ echo '{"session_id":"sess-8f2e4c7a","tool_name":"Grep","tool_input":{"pattern":"verify_signature"}}' | vouch capture observe +$ vouch source add auth.md --title "auth notes" +816fec5eb02e8965df3197cdd622c394c8845364c584fe0fe0023dd0459e8982 ``` -The buffer lives at `.vouch/captures/.jsonl` — scratch, held -**outside** the KB, gitignored so it never pollutes history: +## 2. Propose a claim + +Agents call `kb.propose_claim` (over MCP/JSONL); from the CLI it looks +like this. The `VOUCH_AGENT` env var records *which* agent proposed, +so multi-agent setups stay attributable. ```bash -$ cat .vouch/captures/sess-8f2e4c7a.jsonl -{"files": ["src/auth.py"], "summary": "Read auth.py", "tool": "Read", "ts": 1782928132.1} -{"files": ["src/auth.py"], "summary": "Edited auth.py", "tool": "Edit", "ts": 1782928126.8} -{"cmd": "pytest -q", "summary": "Ran: pytest -q", "tool": "Bash", "ts": 1782928136.5} -{"summary": "Grep verify_signature", "tool": "Grep", "ts": 1782928141.1} +$ VOUCH_AGENT=claude-code vouch propose-claim \ + --text "Authentication uses stateless JWTs signed with RS256" \ + --source 816fec5e... \ + --type fact \ + --confidence 0.9 +20260521-065702-44a92aa8 ``` -Observations are one-line summaries and file names — **not** full tool -output — so credentials and large blobs never get buffered. - -## 2. Session ends — finalize rolls it into ONE pending proposal +The proposal lands in `.vouch/proposed/.yaml` (gitignored — it +shouldn't pollute history until you approve it). -At session end, the `SessionEnd` hook runs `vouch capture finalize`. It reads -the buffer, adds a `git diff` backstop (to catch edits `PostToolUse` missed), -and — if there are at least `capture.min_observations` (default 3) — files a -single **pending page proposal** through the normal review gate. It never -calls `approve()`. +## 3. Review the queue ```bash -$ echo '{"session_id":"sess-8f2e4c7a"}' | vouch capture finalize -{ - "_meta": { - "vouch_trust": { "auth_subject": null, "caller_kind": "cli", "remote": false } - }, - "captured": 5, - "summary_proposal_id": "20260701-174914-a97e6a4d" -} +$ vouch pending +• 20260521-065702-44a92aa8 [claim] by claude-code + Authentication uses stateless JWTs signed with RS256 + +$ vouch show 20260521-065702-44a92aa8 +id: 20260521-065702-44a92aa8 +kind: claim +proposed_by: claude-code +proposed_at: '2026-05-21T06:57:02.910715Z' +payload: + id: authentication-uses-stateless-jwts-signed-with-rs256 + text: Authentication uses stateless JWTs signed with RS256 + type: fact + confidence: 0.9 + evidence: + - 816fec5eb02e8965df3197cdd622c394c8845364c584fe0fe0023dd0459e8982 +status: pending ``` -`captured: 5` = four harvested observations + one file from the git-diff -backstop. The buffer file is deleted; its contents now live only inside the -pending proposal, awaiting your review. - -## 3. Next session start — the nudge - -The `SessionStart` hook runs `vouch capture banner`, so the next time you open -a session in this workspace you see how many captured summaries are queued: +## 4. Approve ```bash -$ vouch capture banner -🔔 1 auto-captured session summary(ies) awaiting review — run `vouch review`. +$ vouch approve 20260521-065702-44a92aa8 --reason "matches the code" +Approved → claim/authentication-uses-stateless-jwts-signed-with-rs256 ``` -## 4. Review the queue +What just happened (see [the approve flow](../spec/review-gate.md) for the +formal version): -Captured summaries are ordinary pending proposals — `vouch pending`, `vouch -review`, and the review-ui all show them, attributed to the `vouch-capture` -actor: +1. A durable artifact is written to `.vouch/claims/.yaml` with + `approved_by` stamped on it. +2. The FTS5 index in `state.db` is updated so the claim is searchable + immediately. +3. The proposal file moves from `proposed/` → `decided/` with + `status=approved`, `decided_by`, `decision_reason`. +4. An `audit.log.jsonl` line records the decision. ```bash -$ vouch pending -• 20260701-174914-a97e6a4d [page] by vouch-capture - session summary: acme-api (sess-8f2e4c7a) +$ vouch status +KB at /tmp/demo/.vouch + durable: 1 claims • 0 pages • 1 sources • 0 entities • 0 relations + pending: 0 proposals + audit: 4 events • index: present ``` -## 5. Approve → durable page +The on-disk claim: ```bash -$ vouch approve 20260701-174914-a97e6a4d --reason "accurate summary" -Approved → page/session-summary-acme-api-sess-8f2e4c7a +$ cat .vouch/claims/authentication-uses-stateless-jwts-signed-with-rs256.yaml +id: authentication-uses-stateless-jwts-signed-with-rs256 +text: Authentication uses stateless JWTs signed with RS256 +type: fact +status: working +confidence: 0.9 +evidence: +- 816fec5eb02e8965df3197cdd622c394c8845364c584fe0fe0023dd0459e8982 +scope: project +created_at: '2026-05-21T06:57:13.947450Z' +updated_at: '2026-05-21T06:57:13.947477Z' +approved_by: claude-code ``` -The summary vouch kept is now a plain-markdown page on disk, committed -alongside your code (shown here inside a fence so its own headings render -literally): - -````text -# session summary: acme-api (sess-8f2e4c7a) - -- generated: 2026-07-01T17:49:14.372133+00:00 -- session: `sess-8f2e4c7a` -- observations: 4 +## 5. Retrieve -## files modified this session +`vouch search` is for ranked snippets; `vouch context` builds a +prompt-ready bundle with a quality gate. -- src/auth.py - -## git changes +```bash +$ vouch search "JWT" +[claim] authentication-uses-stateless-jwts-signed-with-rs256 score=1.000 (substring) + Authentication uses stateless JWTs signed with RS256 +$ vouch context "JWT" +{ + "query": "JWT", + "items": [ + { + "id": "authentication-uses-stateless-jwts-signed-with-rs256", + "type": "claim", + "summary": "Authentication uses stateless JWTs signed with RS256", + "score": 1.0, + "backend": "substring", + "citations": ["816fec5eb02e8965df3197cdd622c394c8845364c584fe0fe0023dd0459e8982"], + "freshness": "unknown" + } + ], + "quality": { "ok": true, "items": 1, "warnings": 0, "uncited_items": [] } +} ``` -src/auth.py | 3 ++- - 1 file changed, 2 insertions(+), 1 deletion(-) -``` - -## activity - -- Bash: 1 -- Edit: 1 -- Grep: 1 -- Read: 1 - -## notable commands - -- `pytest -q` - -## observations -- Read auth.py -- Edited auth.py -- Ran: pytest -q -- Grep verify_signature -```` +> **Note.** The default search backend is literal (FTS5 + substring), +> so a query like `"how does auth work"` won't match `"Authentication +> uses..."`. Install the embeddings extra (`pip install -e +> '.[embeddings-mpnet]'`) for semantic matching. -## 6. The next session starts with it — no amnesia +## 6. Audit trail -The `SessionStart` hook also runs `vouch recall`, which emits a digest of all -approved knowledge for injection into the new session's context. The summary -you just approved is now in it — the next session opens already knowing what -the last one did: - -```text -$ vouch recall - -# approved KB knowledge for this repo — 1 claim(s), 2 page(s). reviewed, cited, durable. use kb_read_page / kb_search for detail; kb_propose_* (human-approved) to add more. - -## claims -- [vouch-starter-reviewed-knowledge] Vouch stores reviewed, cited knowledge in the repository so future agent sessions can retrieve agreed project context. - -## pages -- [edit-in-obsidian] Edit in Obsidian -- [session-summary-acme-api-sess-8f2e4c7a] session summary: acme-api (sess-8f2e4c7a) - +```bash +$ vouch audit --tail 5 +2026-05-21T06:56:54Z kb.init by claude-code objects=[] +2026-05-21T06:56:58Z source.add by claude-code objects=['816fec5e...'] +2026-05-21T06:57:02Z proposal.claim.create by claude-code objects=['20260521-065702-44a92aa8'] +2026-05-21T06:57:14Z proposal.claim.approve by claude-code objects=['20260521-065702-44a92aa8', 'authentication-uses-stateless-jwts-signed-with-rs256'] ``` +Every mutation is on the record, with actor and object ids. + ## 7. Commit ```bash -$ git add .vouch/ && git commit -m "kb: approve session summary" +$ git add .vouch/ && git commit -m "kb: approve auth-uses-jwt" ``` -What lands in git: the durable page, its decision record, the audit line. -What doesn't: the `.vouch/captures/` scratch buffer (gitignored) and -`state.db` (a derivable cache — `vouch index` rebuilds it). - -## Notes - -- **The review gate holds.** Capture harvests and rolls up automatically, but - the only durable artifact is a `PENDING` proposal a human approves. There is - no auto-approve and no trusted-agent shortcut on this path. -- **No LLM anywhere.** The rollup is pure heuristics — type counts, file - names, a git-diff stat — so the hook stays fast and offline. -- **One summary per session**, not one per turn, so the queue doesn't flood. - Individual claims an agent files via MCP during a session still coexist as - their own pending items. -- **Config** lives under `capture.*` in `.vouch/config.yaml`: - `capture.enabled` (default `true`), `capture.min_observations` (default `3`). - Set `capture.enabled: false` to turn the whole path off. +What lands in git: the durable artifact, the decision record, the +audit line. What doesn't: the `proposed/` draft (gitignored, since +unreviewed agent output shouldn't pollute history) and `state.db` (a +derivable cache — `vouch index` rebuilds it). ## Next steps -- Wire vouch into Claude Code via `.mcp.json` and the adapter hooks (see - [README](../README.md#wiring-into-claude-code)). -- Prefer filing knowledge as you go? Agents can still call - `kb.propose_claim` / `kb.propose_page` directly over MCP — the manual write - path and the automatic capture path share the same review gate. +- Wire vouch into Claude Code via `.mcp.json` (see [README](../README.md#wiring-into-claude-code)). +- Open a session for a longer task: `vouch session start --task "build the deploy pipeline"`, then `vouch crystallize ` at the end to promote the session's work into proposals. - Export the KB as a portable bundle: `vouch export --out kb.tar.gz`. 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 { 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-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/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..002c7228 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-friendlier-mcp-surface-design.md @@ -0,0 +1,151 @@ +# 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_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.) +- **`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). `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 + +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 8; `full` + exposes all 58; every profile name ⊆ `METHODS`; env var overrides config. +- 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 ~8 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. 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: diff --git a/pyproject.toml b/pyproject.toml index bdbc46fd..624cf336 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "vouch-kb" -version = "1.2.1" +version = "1.2.2" description = "Git-native, review-gated knowledge base for LLM agents. MCP server + CLI." readme = "README.md" requires-python = ">=3.11" @@ -60,6 +60,10 @@ rerank = [ [project.scripts] vouch = "vouch.cli:cli" +# alias so `uvx vouch-kb serve` resolves — the mcp registry runs the package +# by its pypi identifier (vouch-kb), which otherwise wouldn't match the `vouch` +# console script. keep both pointing at the same entry point. +vouch-kb = "vouch.cli:cli" [project.urls] Homepage = "https://github.com/plind-junior/vouch" 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/spec/2026-05-21/methods.md b/spec/2026-05-21/methods.md index f9405250..6e6daf4e 100644 --- a/spec/2026-05-21/methods.md +++ b/spec/2026-05-21/methods.md @@ -227,9 +227,8 @@ flags promotable claims for review. ### `kb.audit` -**Params:** `{ "tail": int?, "project": str?, "agent": str?, "viewer_scope": {"project": str?, "agent": str?}? }`. -**Result:** `{ "viewer": {"project": str?, "agent": str?}, "events": [AuditEvent] }`. -Events whose `object_ids` reference scoped artifacts outside the viewer context are omitted; events with no `object_ids` (e.g. `kb.init`) are always included. +**Params:** `{ "tail": int?, "filter": {"event": str?, "actor": str?}? }`. +**Result:** array of `AuditEvent`. ### `kb.export` diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index 51e2f0d9..8573c42f 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -35,23 +35,10 @@ SPEC_VERSION = "vouch-bundle-0.1" EXPORT_SUBDIRS = ( - "claims", - "pages", - "sources", - "entities", - "relations", - "evidence", - "sessions", - "decided", + "claims", "pages", "sources", "entities", "relations", + "evidence", "sessions", "decided", ) -IMPORT_ROOT_FILES = {"config.yaml"} -FORBIDDEN_SAFETY_FLAGS = { - "has_proposed": "proposed/", - "has_state_db": "state.db", - "has_audit_log": "audit.log.jsonl", -} - VALIDATORS: dict[str, Any] = { "claims": lambda data: Claim.model_validate(yaml.safe_load(data)), "pages": lambda data: _deserialize_page(data.decode()), @@ -85,16 +72,11 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: files: list[dict[str, Any]] = [] for rel, abs_path in _iter_export_files(kb_dir): data = abs_path.read_bytes() - files.append( - { - # tarfile member names use POSIX `/` on every platform; the - # manifest path must match so set lookups and the per-subdir - # counter below work on Windows too. - "path": rel.as_posix(), - "size": len(data), - "sha256": sha256_hex(data), - } - ) + files.append({ + "path": str(rel), + "size": len(data), + "sha256": sha256_hex(data), + }) # Bundle id is the sha256 of the sorted per-file hashes — same inputs # always produce the same id, so duplicate exports are recognisable. h = hashlib.sha256() @@ -105,7 +87,8 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: "bundle_id": h.hexdigest(), "files": files, "counts": { - sub: sum(1 for f in files if f["path"].startswith(f"{sub}/")) for sub in EXPORT_SUBDIRS + sub: sum(1 for f in files if f["path"].startswith(f"{sub}/")) + for sub in EXPORT_SUBDIRS }, "safety": { "has_proposed": False, @@ -120,15 +103,13 @@ def export(kb_dir: Path, *, dest: Path, actor: str = "vouch-export") -> dict[str dest.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(dest, "w:gz") as tar: for rel, abs_path in _iter_export_files(kb_dir): - tar.add(abs_path, arcname=rel.as_posix()) + tar.add(abs_path, arcname=str(rel)) manifest_bytes = json.dumps(manifest, indent=2, sort_keys=True).encode() info = tarfile.TarInfo(MANIFEST_NAME) info.size = len(manifest_bytes) tar.addfile(info, io.BytesIO(manifest_bytes)) audit.log_event( - kb_dir, - event="bundle.export", - actor=actor, + kb_dir, event="bundle.export", actor=actor, object_ids=[manifest["bundle_id"]], data={"dest": str(dest), "files": len(manifest["files"])}, ) @@ -155,35 +136,6 @@ def _unsafe_name_reason(name: str) -> str | None: return None -def _import_path_issue(name: str) -> str | None: - reason = _unsafe_name_reason(name) - if reason is not None: - return reason - if name in IMPORT_ROOT_FILES: - return None - if name == "audit.log.jsonl": - return "forbidden path in bundle: 'audit.log.jsonl'" - if name == "state.db" or name.startswith("state.db-"): - return f"forbidden path in bundle: {name!r}" - if name == "proposed" or name.startswith("proposed/"): - return f"forbidden path in bundle: {name!r}" - subdir = name.split("/", 1)[0] - if subdir not in EXPORT_SUBDIRS: - return f"path outside importable bundle artifacts: {name!r}" - return None - - -def _manifest_safety_issues(manifest: dict[str, Any]) -> list[str]: - safety = manifest.get("safety") or {} - if not isinstance(safety, dict): - return ["manifest safety must be an object"] - issues: list[str] = [] - for flag, path_desc in FORBIDDEN_SAFETY_FLAGS.items(): - if safety.get(flag) is True: - issues.append(f"manifest safety flag {flag}=true includes forbidden {path_desc}") - return issues - - def export_check(bundle_path: Path) -> ExportCheckResult: """Verify every file in the bundle matches its manifest hash.""" issues: list[str] = [] @@ -225,10 +177,8 @@ def export_check(bundle_path: Path) -> ExportCheckResult: except KeyError: issues.append(f"manifest lists missing file: {path}") return ExportCheckResult( - ok=not issues, - bundle_id=bundle_id, - files_checked=files_checked, - issues=issues, + ok=not issues, bundle_id=bundle_id, + files_checked=files_checked, issues=issues, ) @@ -236,7 +186,7 @@ def export_check(bundle_path: Path) -> ExportCheckResult: def _safe_member_path(kb_dir: Path, member_name: str) -> Path: - reason = _import_path_issue(member_name) + reason = _unsafe_name_reason(member_name) if reason is not None: raise RuntimeError(reason) kb_root = kb_dir.resolve() @@ -270,236 +220,12 @@ def _validate_content(path: str, data: bytes, issues: list[str]) -> None: validator = VALIDATORS.get(subdir) if validator is None: return - if not any(path.lower().endswith(ext) for ext in (".yaml", ".yml", ".md")): - return try: validator(data) except Exception as e: issues.append(f"schema validation failed: {path}: {e}") -def _artifact_id_from_path(path: str) -> tuple[str, str] | None: - """Return (kind, id) for a manifest path, or None for unrelated files. - - Mirrors `sync._artifact_kind` semantics but only emits the entries the - referential pass needs. `sources//...` collapses to ("source", - "") regardless of whether the path is meta.yaml or content. - """ - parts = path.split("/") - if len(parts) < 2: - return None - top = parts[0] - if top == "sources" and len(parts) >= 3: - return "source", parts[1] - singular = { - "claims": "claim", - "pages": "page", - "entities": "entity", - "relations": "relation", - "evidence": "evidence", - }.get(top) - if singular is None: - return None - return singular, Path(parts[-1]).stem - - -def _existing_ids(kb_dir: Path) -> dict[str, set[str]]: - """Snapshot the destination KB's artifact ids per kind. - - Used by the post-merge referential pass: an incoming relation / - page reference is satisfied if the target id is either already on - disk OR is being delivered by this same bundle. - """ - out: dict[str, set[str]] = { - "claim": set(), "page": set(), "entity": set(), - "source": set(), "evidence": set(), - } - for sub, kind, suffix in ( - ("claims", "claim", ".yaml"), - ("entities", "entity", ".yaml"), - ("relations", "relation", ".yaml"), - ("evidence", "evidence", ".yaml"), - ): - d = kb_dir / sub - if not d.is_dir(): - continue - for p in d.glob(f"*{suffix}"): - out[kind].add(p.stem) - pages_dir = kb_dir / "pages" - if pages_dir.is_dir(): - for p in pages_dir.glob("*.md"): - out["page"].add(p.stem) - sources_dir = kb_dir / "sources" - if sources_dir.is_dir(): - for sdir in sources_dir.iterdir(): - if (sdir / "meta.yaml").exists(): - out["source"].add(sdir.name) - return out - - -def _check_graph_integrity( - kb_dir: Path, - incoming: dict[str, bytes], - issues: list[str], -) -> None: - """Verify every Relation/Page/Claim in `incoming` resolves its refs. - - Closes the bundle / sync equivalent of `put_relation` + `put_page` - validation. References are satisfied against the post-merge id set - (destination KB ids plus incoming bundle ids), so a self-contained - bundle that ships an entity alongside the relation that points at - it still imports cleanly. Skips files whose bytes already failed - schema validation upstream so a malformed file does not produce a - second cryptic error. - """ - ids = _existing_ids(kb_dir) - incoming_meta: list[tuple[str, str, bytes]] = [] - for path, body in incoming.items(): - kind_id = _artifact_id_from_path(path) - if kind_id is None: - continue - kind, aid = kind_id - # Only artifacts that can satisfy a reference go into `ids`. - # `relation` is intentionally absent — relations aren't valid - # Relation endpoints. The incoming relation itself is still - # appended below so its refs get checked. - if kind in ids: - ids[kind].add(aid) - incoming_meta.append((path, kind, body)) - node_kinds = ("claim", "page", "entity", "source") - evidence_kinds = ("source", "evidence") - failed_schema = { - i.split(": ", 2)[1] for i in issues - if i.startswith("schema validation failed: ") - } - for path, kind, body in incoming_meta: - if path in failed_schema: - continue - try: - if kind == "relation": - rel = Relation.model_validate(yaml.safe_load(body)) - if not any(rel.source in ids[k] for k in node_kinds): - issues.append( - f"dangling reference: {path}: relation source " - f"{rel.source!r} not in bundle or destination" - ) - if not any(rel.target in ids[k] for k in node_kinds): - issues.append( - f"dangling reference: {path}: relation target " - f"{rel.target!r} not in bundle or destination" - ) - for eid in rel.evidence: - if not any(eid in ids[k] for k in evidence_kinds): - issues.append( - f"dangling reference: {path}: relation " - f"evidence {eid!r} not in bundle or destination" - ) - elif kind == "page": - page = _deserialize_page(body.decode()) - for cid in page.claims: - if cid not in ids["claim"]: - issues.append( - f"dangling reference: {path}: page claim " - f"{cid!r} not in bundle or destination" - ) - for eid in page.entities: - if eid not in ids["entity"]: - issues.append( - f"dangling reference: {path}: page entity " - f"{eid!r} not in bundle or destination" - ) - for sid in page.sources: - if sid not in ids["source"]: - issues.append( - f"dangling reference: {path}: page source " - f"{sid!r} not in bundle or destination" - ) - elif kind == "claim": - claim = Claim.model_validate(yaml.safe_load(body)) - for ref in claim.evidence: - if not any(ref in ids[k] for k in evidence_kinds): - issues.append( - f"dangling reference: {path}: claim citation " - f"{ref!r} not in bundle or destination" - ) - # The Claim's own graph refs mirror the page checks above: - # entities -> entity ids, supersedes/contradicts/superseded_by - # -> claim ids. Without this, import_apply writes the claim - # YAML straight to disk (no put_claim guard) carrying links to - # artifacts that exist in neither the bundle nor the - # destination — the dangling_* errors fsck reports after the - # fact (see storage._validate_claim_refs). - for eid in claim.entities: - if eid not in ids["entity"]: - issues.append( - f"dangling reference: {path}: claim entity " - f"{eid!r} not in bundle or destination" - ) - claim_refs = [*claim.supersedes, *claim.contradicts] - if claim.superseded_by is not None: - claim_refs.append(claim.superseded_by) - for cid in claim_refs: - if cid not in ids["claim"]: - issues.append( - f"dangling reference: {path}: claim graph ref " - f"{cid!r} not in bundle or destination" - ) - except Exception: - # Schema validation already ran on `body` in `_validate_content` - # and recorded any structural issue. Swallow here so a single - # malformed file doesn't mask the more useful upstream error. - continue - - -def _check_source_content_address(path: str, body: bytes, issues: list[str]) -> None: - """Enforce the Source content-addressing invariant on import. - - A Source's id is the sha256 of its content (README: "content-addressed - by sha256"; `storage.put_source` derives the id from the bytes, and - `verify.verify_source` re-checks `sha256(content) == id`). But - `import_apply` writes `sources//{meta.yaml,content}` straight from - the tarball, so without this check a hand-built bundle can land a - source whose content does not hash to its claimed id. The per-file - sha256 gate (#74) only proves the bytes match the manifest, not that - they match the content-address — so a manifest-consistent bundle could - substitute the evidence behind a legitimate-looking source id. A claim - that "cites source X" would then point at bytes that were never hashed - to X; `verify_source` would report `stored_ok=False` only after the - import already succeeded with a clean `bundle.import` audit event. - """ - parts = path.split("/") - if len(parts) < 3 or parts[0] != "sources": - return - claimed_id = parts[1] - leaf = parts[-1] - if leaf == "content": - actual = sha256_hex(body) - if actual != claimed_id: - issues.append( - f"source content-address mismatch: {path}: content hashes " - f"to {actual} but is stored under id {claimed_id}" - ) - elif leaf == "meta.yaml": - try: - meta = yaml.safe_load(body) - except Exception: - return # a parse failure is already surfaced by _validate_content - if not isinstance(meta, dict): - return - mid = meta.get("id") - if mid is not None and mid != claimed_id: - issues.append( - f"source id mismatch: {path}: meta.yaml id {mid!r} does not " - f"match its content-address directory {claimed_id!r}" - ) - mhash = meta.get("hash") - if mhash is not None and mhash != claimed_id: - issues.append( - f"source hash mismatch: {path}: meta.yaml hash {mhash!r} does " - f"not match its content-address directory {claimed_id!r}" - ) - - def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: """Diff a bundle against the destination KB without writing anything.""" new_files: list[str] = [] @@ -507,18 +233,17 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: identical: list[str] = [] issues: list[str] = [] bundle_id = "" - incoming: dict[str, bytes] = {} with tarfile.open(bundle_path, "r:gz") as tar: try: mf_member = tar.getmember(MANIFEST_NAME) except KeyError: - return ImportCheckResult(False, "", [], [], [], ["bundle missing manifest.json"]) + return ImportCheckResult( + False, "", [], [], [], ["bundle missing manifest.json"] + ) manifest = json.loads(tar.extractfile(mf_member).read().decode()) # type: ignore[union-attr] bundle_id = manifest.get("bundle_id", "") - issues.extend(_manifest_safety_issues(manifest)) - recorded = {f["path"]: f for f in manifest["files"]} - manifest_paths = set(recorded) + manifest_paths = {f["path"] for f in manifest["files"]} for f in manifest["files"]: try: dest = _safe_member_path(kb_dir, f["path"]) @@ -527,7 +252,7 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: continue if not dest.exists(): new_files.append(f["path"]) - elif sha256_hex(dest.read_bytes()) == f.get("sha256"): + elif sha256_hex(dest.read_bytes()) == f["sha256"]: identical.append(f["path"]) else: conflicts.append(f["path"]) @@ -537,39 +262,12 @@ def import_check(kb_dir: Path, bundle_path: Path) -> ImportCheckResult: if member.name not in manifest_paths: continue body = tar.extractfile(member).read() # type: ignore[union-attr] - # Manifest integrity: without this, a tampered tar member with an - # unchanged manifest.json would pass import_check and land in the - # KB via import_apply — defeating the per-file sha256 guarantee - # that export_check already enforces. `.get("sha256")` so a - # hand-crafted manifest entry missing the field is reported as a - # mismatch rather than raising a bare KeyError on import. - if sha256_hex(body) != recorded[member.name].get("sha256"): - issues.append(f"hash mismatch: {member.name}") - continue _validate_content(member.name, body, issues) - _check_source_content_address(member.name, body, issues) - incoming[member.name] = body - for path in manifest_paths: - try: - tar.getmember(path) - except KeyError: - issues.append(f"manifest lists missing file: {path}") - # Graph-integrity pass: the storage layer's put_relation / put_page - # validators run on direct writes, but import_apply writes member - # bytes straight to disk, so the only place to enforce referential - # integrity for a bundle is here. Refs are resolved against the - # post-merge id set (destination KB plus incoming bundle), so a - # self-contained bundle that ships an entity alongside the relation - # pointing at it still imports cleanly. - _check_graph_integrity(kb_dir, incoming, issues) return ImportCheckResult( - ok=not issues, - bundle_id=bundle_id, - new_files=new_files, - conflicts=conflicts, - identical=identical, - issues=issues, + ok=not issues, bundle_id=bundle_id, + new_files=new_files, conflicts=conflicts, + identical=identical, issues=issues, ) @@ -605,27 +303,16 @@ def import_apply( if member.name not in recorded: continue dest = _safe_member_path(kb_dir, member.name) - expected_sha = recorded[member.name].get("sha256") if ( dest.exists() and on_conflict == "skip" - and sha256_hex(dest.read_bytes()) != expected_sha + and sha256_hex(dest.read_bytes()) + != recorded[member.name]["sha256"] ): skipped.append(member.name) continue dest.parent.mkdir(parents=True, exist_ok=True) body = tar.extractfile(member).read() # type: ignore[union-attr] - # Re-verify the manifest sha256 at write time as defence in - # depth against a TOCTOU between import_check (which already - # ran above) and this re-open of the tarball. The only way to - # reach here is mid-import tampering — raise rather than skip - # so the audit log doesn't record a `bundle.import` event for - # an import that silently dropped a member. This is exactly - # the audit-truthfulness anti-pattern #74 was about. - if sha256_hex(body) != expected_sha: - raise RuntimeError( - f"refusing to import: hash mismatch at write time: {member.name}" - ) val_issues: list[str] = [] _validate_content(member.name, body, val_issues) if val_issues: @@ -641,9 +328,7 @@ def import_apply( "on_conflict": on_conflict, } audit.log_event( - kb_dir, - event="bundle.import", - actor=actor, + kb_dir, event="bundle.import", actor=actor, object_ids=[check.bundle_id], data={ "written": len(written), diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 3fd21c75..e051115d 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -7,10 +7,23 @@ 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 package.json, relative to this module. capabilities.py lives at +# src/vouch/capabilities.py; package.json lives at the repo root, three levels +# up (src/vouch/ -> src/ -> repo root). The openclaw.compat block lives here, +# not in openclaw.plugin.json — the manifest bans openclaw.* dead dialect +# fields (see test_manifest_carries_no_dead_dialect_fields). +_PACKAGE_JSON_PATH = Path(__file__).resolve().parent.parent.parent / "package.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. @@ -27,12 +40,14 @@ "kb.read_claim", "kb.read_entity", "kb.read_relation", + "kb.diff", "kb.list_pages", "kb.list_claims", "kb.list_entities", "kb.list_relations", "kb.list_sources", "kb.list_pending", + "kb.triage_pending", "kb.register_source", "kb.register_source_from_path", "kb.propose_claim", @@ -76,6 +91,27 @@ ] +def _load_host_compat() -> dict[str, dict[str, str]]: + """Read the `openclaw.compat` block from package.json (#237). + + Surfaced in `kb.capabilities` as `host_compat` so non-OpenClaw clients + can detect compat without parsing package.json themselves. Returns an + empty dict (rather than raising) if package.json is missing or + malformed — capabilities() must never fail to report basic info just + because the file moved or this is installed as a standalone wheel + without package.json packaged alongside it. + """ + try: + manifest = json.loads(_PACKAGE_JSON_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + _log.debug("package.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: @@ -98,4 +134,5 @@ def capabilities() -> Capabilities: "config_path": "retrieval.scope", }, context_engines=[describe_engine()], + host_compat=_load_host_compat(), ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a490fcf9..4f1078cc 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -8,65 +8,31 @@ from __future__ import annotations import getpass -import io import json import os import sys from collections.abc import Iterator -from contextlib import contextmanager, suppress -from dataclasses import asdict -from datetime import UTC, datetime +from contextlib import contextmanager from pathlib import Path -from typing import Any, Literal import click import yaml -from . import __version__, bundle, health, volunteer_context +from . import __version__, bundle, health from . import audit as audit_mod -from . import capture as capture_mod -from . import compile as compile_mod -from . import digest as digest_mod -from . import fetch as fetch_mod -from . import inbox as inbox_mod -from . import install_adapter as install_mod from . import lifecycle as life -from . import metrics as metrics_mod -from . import migrations as migrations_mod -from . import notify as notify_mod -from . import pr_cache as prc_mod -from . import provenance as prov_mod -from . import recall as recall_mod from . import sessions as sess_mod -from . import stats as stats_mod -from . import sync as sync_mod -from . import synthesize as synth -from . import trust as trust_mod -from . import vault_sync as vault_sync_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps from .context import build_context_pack from .lifecycle import LifecycleError -from .logging_config import configure_logging -from .models import Proposal, ProposalKind, ProposalStatus -from .onboarding import ( - DEFAULT_TEMPLATE, - TEMPLATES, - available_templates, - seed_starter_kb, -) -from .page_filters import filter_pages, parse_kv -from .page_kinds import PageKindError, load_page_kind_registry +from .models import ProposalStatus from .proposals import ( - EXPIRE_ACTOR, ProposalError, - check_approvable, - expire_pending, propose_claim, propose_entity, propose_page, propose_relation, - reject_auto_extracted, ) from .proposals import ( approve as do_approve, @@ -92,13 +58,7 @@ def _cli_errors() -> Iterator[None]: # their own request envelopes. try: yield - except ( - ArtifactNotFoundError, - ValueError, - ProposalError, - LifecycleError, - migrations_mod.MigrationError, - ) as e: + except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e: raise click.ClickException(str(e)) from e @@ -116,65 +76,21 @@ def _whoami() -> str: # agent invokes the CLI it sets VOUCH_AGENT; honour it as the actor so # multi-agent attribution stays consistent across transports. VOUCH_USER # remains an escape hatch; OS user is the friendly default for humans. - return os.environ.get("VOUCH_AGENT") or os.environ.get("VOUCH_USER") or getpass.getuser() + return ( + os.environ.get("VOUCH_AGENT") + or os.environ.get("VOUCH_USER") + or getpass.getuser() + ) def _emit_json(obj) -> None: - with trust_mod.trust_context(trust_mod.CLI): - if isinstance(obj, dict): - obj = trust_mod.attach_trust(obj) click.echo(json.dumps(obj, indent=2, default=str, sort_keys=True)) -def _color_enabled() -> bool: - # Honour the de-facto conventions: NO_COLOR disables, FORCE_COLOR forces, - # otherwise colour only when stdout is an interactive terminal. Keeps pipes, - # CI, and `--json` output clean while still being pretty in a shell. - if os.environ.get("NO_COLOR"): - return False - if os.environ.get("FORCE_COLOR"): - return True - return sys.stdout.isatty() - - -def _style(text: str, **kwargs: Any) -> str: - return click.style(text, **kwargs) if _color_enabled() else text - - -def _echo(message: str = "", *, err: bool = False) -> None: - # click.echo strips ANSI when the stream isn't a TTY unless told otherwise; - # pass an explicit color flag so FORCE_COLOR into a pipe keeps the styling - # and NO_COLOR / plain pipes stay clean. - click.echo(message, err=err, color=_color_enabled()) - - -def _force_utf8_stdio() -> None: - # On non-UTF-8 locales (e.g. LANG=en_US.ISO-8859-1) Python encodes stdio - # with the locale codec, so the '•' / '…' in CLI output — and any - # non-ASCII KB content flowing through the stdio servers — raises - # UnicodeEncodeError. Artifacts are UTF-8 on disk; speak UTF-8 on the - # wire too, replacing rather than crashing for terminals that can't. - for stream in (sys.stdin, sys.stdout, sys.stderr): - if ( - isinstance(stream, io.TextIOWrapper) - and (stream.encoding or "").lower().replace("-", "") != "utf8" - ): - with suppress(ValueError, OSError): - stream.reconfigure(encoding="utf-8", errors="replace") - - -# At import, not in the cli() callback: click renders eager --help / -# --version output during argument parsing, before any group callback -# runs — and the group docstring's em dash already crashes a latin-1 -# stdout. Idempotent and a no-op on utf-8 streams. -_force_utf8_stdio() - - @click.group() @click.version_option(__version__, prog_name="vouch") def cli() -> None: """vouch — git-native, review-gated knowledge base for LLM agents.""" - configure_logging() # --- bootstrap ------------------------------------------------------------ @@ -182,41 +98,14 @@ def cli() -> None: @cli.command() @click.option("--path", default=".", type=click.Path(file_okay=False), show_default=True) -@click.option( - "--template", - default=DEFAULT_TEMPLATE, - show_default=True, - type=click.Choice(available_templates()), - help="Seed preset applied on top of the starter KB.", -) -def init(path: str, template: str) -> None: +def init(path: str) -> None: """Initialise a .vouch/ knowledge base at PATH.""" root = Path(path).resolve() root.mkdir(parents=True, exist_ok=True) store = KBStore.init(root) - seed = seed_starter_kb(store, approved_by=_whoami()) - template_result = None - if template != DEFAULT_TEMPLATE: - template_result = TEMPLATES[template](store, approved_by=_whoami()) - health.rebuild_index(store) audit_mod.log_event(store.kb_dir, event="kb.init", actor=_whoami()) click.echo(f"Initialised KB at {store.kb_dir}") - if seed.created_anything: - click.echo(f"Seeded starter claim: {seed.claim_id}") - else: - click.echo("Starter claim already present.") - if template_result is not None: - if template_result.created_anything: - click.echo( - f"Applied template '{template_result.template}': " - f"{len(template_result.created)} item(s) created" - ) - else: - click.echo(f"Template '{template_result.template}' already applied.") - click.echo("Next steps:") - click.echo(" vouch status") - click.echo(" vouch search agent") - click.echo(" vouch serve") + click.echo("Next: `vouch serve` to expose the MCP server to your agent.") @cli.command() @@ -255,124 +144,9 @@ def status(as_json: bool) -> None: f"{s['sources']} sources • {s['entities']} entities • " f"{s['relations']} relations" ) - pending = s["pending_proposals"] - pending_str = _style(str(pending), fg="yellow" if pending else "green") - _echo(f" pending: {pending_str} proposals") - present = s["index_present"] - index_str = _style("present", fg="green") if present else _style("missing", fg="red") - _echo(f" audit: {_style(str(s['audit_events']), fg='cyan')} events • index: {index_str}") - - -@cli.command() -@click.option( - "--days", - default=30, - show_default=True, - type=int, - help="Review decision window (days). Use 0 for all-time.", -) -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.") -def stats(days: int, as_json: bool) -> None: - """KB observability: pending queue, review rates, citation coverage.""" - store = _load_store() - since = None if days == 0 else days - body = stats_mod.collect_stats(store, since_days=since) - if as_json: - _emit_json(body) - return - _echo(f"KB at {_style(str(body['kb_dir']), bold=True)}") - pending = body["pending"] - _echo( - f" pending: {_style(str(pending['total']), fg='yellow' if pending['total'] else 'green')} " - f"proposal(s)" - ) - if pending["by_agent"]: - for agent, count in pending["by_agent"].items(): - _echo(f" {agent}: {count}") - age = pending["age_days"] - if age["median"] is not None: - _echo( - f" pending age (days): median {age['median']}, max {age['max']}" - + (f" ({age['oldest_id']})" if age["oldest_id"] else "") - ) - review = body["review"] - window = "all time" if review["window_days"] is None else f"last {review['window_days']}d" - _echo( - f" review ({window}): " - f"{review['approved']} approved, {review['rejected']} rejected, " - f"{review['expired']} expired" - ) - rate = review["approval_rate"] - if rate is not None: - _echo(f" approval rate: {_style(f'{rate * 100:.1f}%', fg='cyan')}") - cites = body["citations"] - cov = cites["coverage_rate"] - cov_str = f"{cov * 100:.1f}%" if cov is not None else "n/a" - _echo( - f" citations: {cites['claims_with_valid_citation']}/{cites['claims_total']} " - f"claims with valid citations ({cov_str})" - ) - if cites["invalid_claim"] or cites["broken_citation"]: - _echo(f" invalid: {cites['invalid_claim']}, broken: {cites['broken_citation']}") - - -@cli.command(name="digest") -@click.option( - "--since", - default=digest_mod.DEFAULT_SINCE_SPEC, - show_default=True, - help="Window: a duration (7d, 12h), an ISO date, or 'all'.", -) -@click.option( - "--stale-days", - default=metrics_mod.DEFAULT_STALE_DAYS, - show_default=True, - type=int, - help="Freshness threshold for the stale-claims section.", -) -@click.option( - "--limit", - default=digest_mod.DEFAULT_LIMIT, - show_default=True, - type=int, - help="Cap per section (pending, decisions, stale, followups).", -) -@click.option( - "--format", - "fmt", - default="text", - show_default=True, - type=click.Choice(["text", "json", "markdown"]), -) -def digest_cmd(since: str, stale_days: int, limit: int, fmt: str) -> None: - """Read-only briefing: pending queue, recent decisions, stale claims, - followups due. Writes nothing — safe to run from cron.""" - store = _load_store() - try: - since_dt = metrics_mod.parse_since(since) - except metrics_mod.MetricsError as e: - raise click.UsageError(str(e)) from e - d = digest_mod.build( - store, since=since_dt, stale_after_days=stale_days, limit=limit, - ) - if fmt == "json": - _emit_json(d.to_dict()) - elif fmt == "markdown": - click.echo(digest_mod.render_markdown(d)) - else: - click.echo(digest_mod.render_text(d)) - - -def _findings_json(report) -> list[dict[str, Any]]: - return [ - { - "severity": f.severity, - "code": f.code, - "message": f.message, - "object_ids": list(getattr(f, "object_ids", []) or []), - } - for f in report.findings - ] + click.echo(f" pending: {s['pending_proposals']} proposals") + click.echo(f" audit: {s['audit_events']} events • " + f"index: {'present' if s['index_present'] else 'missing'}") @cli.command() @@ -401,535 +175,28 @@ def doctor() -> None: sys.exit(0 if report.ok else 1) -@cli.command() -def fsck() -> None: - """Deep consistency check: orphan embeddings, dangling supersede/contradict - chains, decided-proposal ↔ artifact mismatches, index-vs-file drift. - """ - store = _load_store() - report = health.fsck(store) - for f in report.findings: - marker = {"error": "✗", "warning": "!", "info": "·"}.get(f.severity, "?") - line = f"{marker} [{f.code}] {f.message}" - if f.object_ids: - line += f" (objects: {', '.join(f.object_ids)})" - click.echo(line) - if not report.findings: - click.echo("clean") - sys.exit(0 if report.ok else 1) - - -@cli.group(invoke_without_command=True) -@click.option("--check", "check_only", is_flag=True, help="Only check if a migration is needed.") -@click.option("--dry-run", is_flag=True, help="Show planned format changes without writing.") -@click.option("--to-version", type=int, default=None, help="Target KB format (integer) version.") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -@click.pass_context -def migrate( - ctx: click.Context, - check_only: bool, - dry_run: bool, - to_version: int | None, - as_json: bool, -) -> None: - """Migrate the KB. - - With no subcommand, runs the legacy integer *format* migration of the - .vouch/ directory layout. The subcommands (status / plan / apply / rollback - / verify) drive the semver model-schema migrations keyed off - .vouch/schema_version. - """ - if ctx.invoked_subcommand is not None: - return - if check_only and dry_run: - raise click.ClickException("--check and --dry-run are mutually exclusive") - - store = _load_store() - with _cli_errors(): - result = migrations_mod.migrate( - store, - to_version=to_version, - dry_run=check_only or dry_run, - ) - if as_json: - _emit_json(asdict(result)) - else: - if result.steps: - click.echo( - f"KB format: {result.from_version} -> {result.to_version} " - f"({'dry run' if result.dry_run else 'applied'})" - ) - for step in result.steps: - click.echo(f"- {step}") - for change in result.changes: - click.echo(f" * {change}") - else: - click.echo(f"KB format: {result.from_version} (up to date)") - - if result.applied: - health.rebuild_index(store) - audit_mod.log_event( - store.kb_dir, - event="kb.migrate", - actor=_whoami(), - reversible=False, - data={ - "from_version": result.from_version, - "to_version": result.to_version, - "steps": result.steps, - "changes": result.changes, - }, - ) - - if check_only and result.steps: - sys.exit(1) - - -@migrate.command("status") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def migrate_status(as_json: bool) -> None: - """Show the KB schema version, target, and pending migrations.""" - store = _load_store() - with _cli_errors(): - result = migrations_mod.schema_status(store) - if as_json: - _emit_json(result) - return - click.echo(f"schema: {result['schema_version']} -> {result['target_version']}") - if result["up_to_date"]: - click.echo("up to date") - else: - click.echo(f"pending: {', '.join(result['pending'])}") - - -@migrate.command("plan") -@click.option("--to", "to_version", default=None, help="Target schema version (semver).") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def migrate_plan(to_version: str | None, as_json: bool) -> None: - """Dry-run: list every file each pending migration would change.""" - store = _load_store() - with _cli_errors(): - result = migrations_mod.schema_plan(store, to_version=to_version) - if as_json: - _emit_json(result) - return - if not result["needed"]: - click.echo(f"schema: {result['current_version']} (up to date)") - return - click.echo(f"schema: {result['current_version']} -> {result['target_version']}") - for step in result["steps"]: - click.echo( - f"- {step['manifest_id']}: {step['from_version']} -> {step['to_version']} " - f"({step['artifact']}, {step['file_count']} file(s))" - ) - for rel in step["changed"]: - click.echo(f" * {rel}") - click.echo(f"total: {result['total_files']} file(s)") - - -@migrate.command("apply") -@click.option("--to", "to_version", default=None, help="Target schema version (semver).") -@click.option("--yes", is_flag=True, help="Skip the confirmation prompt (CI).") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def migrate_apply(to_version: str | None, yes: bool, as_json: bool) -> None: - """Apply pending schema migrations (audit-logged, atomic, reversible).""" - store = _load_store() - with _cli_errors(): - preview = migrations_mod.schema_plan(store, to_version=to_version) - if not preview["needed"]: - if as_json: - _emit_json( - { - "applied": False, - "from_version": preview["current_version"], - "to_version": preview["current_version"], - "manifests": [], - "files": 0, - } - ) - else: - click.echo(f"schema: {preview['current_version']} (up to date)") - return - if not yes: - click.confirm( - f"Apply {len(preview['steps'])} migration(s) " - f"({preview['current_version']} -> {preview['target_version']}, " - f"{preview['total_files']} file(s))?", - abort=True, - ) - result = migrations_mod.schema_apply(store, to_version=to_version, actor=_whoami()) - # state.db is derived; rebuild it under the new layout. - health.rebuild_index(store) - if as_json: - _emit_json(result) - else: - click.echo( - f"schema: {result['from_version']} -> {result['to_version']} " - f"({result['files']} file(s), {len(result['manifests'])} manifest(s))" - ) - - -@migrate.command("rollback") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def migrate_rollback(as_json: bool) -> None: - """Reverse the most recently applied schema migration.""" - store = _load_store() - with _cli_errors(): - result = migrations_mod.schema_rollback(store, actor=_whoami()) - health.rebuild_index(store) - if as_json: - _emit_json(result) - else: - click.echo( - f"schema: {result['from_version']} -> {result['to_version']} " - f"(rolled back {result['files']} file(s))" - ) - - -@migrate.command("verify") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def migrate_verify(as_json: bool) -> None: - """Parse-load every artifact under the current schema version.""" - store = _load_store() - with _cli_errors(): - result = migrations_mod.schema_verify(store) - if as_json: - _emit_json(result) - elif result["ok"]: - click.echo(f"verified {result['checked']} artifact(s) at schema {result['schema_version']}") - else: - click.echo(f"FAILED: {len(result['errors'])} of {result['checked']} artifact(s)") - for err in result["errors"]: - click.echo(f" ✗ {err['path']}: {err['error']}") - if not result["ok"]: - sys.exit(1) - - -# --- metrics -------------------------------------------------------------- - - -def _fmt_pct(x: float | None) -> str: - return "—" if x is None else f"{x * 100:.1f}%" - - -def _fmt_secs(x: float | None) -> str: - """Human-friendly duration for the table; raw seconds stay in --json.""" - if x is None: - return "—" - if x < 90: - return f"{x:.0f}s" - if x < 5400: - return f"{x / 60:.1f}m" - if x < 172800: - return f"{x / 3600:.1f}h" - return f"{x / 86400:.1f}d" - - -@cli.command() -@click.option( - "--json", "as_json", is_flag=True, help="Emit the stable JSON schema (see docs/metrics.md)." -) -@click.option( - "--prometheus", - "as_prom", - is_flag=True, - help="Emit Prometheus textfile-collector format " - "(write to /vouch.prom from a sidecar).", -) -@click.option( - "--since", - default=None, - help="Window the audit log: a duration like 30d / 12h / 2w, " - "an ISO date like 2026-01-01, or 'all' (default: all).", -) -@click.option("--until", default=None, help="Upper bound for the window (same formats as --since).") -@click.option( - "--stale-days", - default=metrics_mod.DEFAULT_STALE_DAYS, - show_default=True, - type=int, - help="A claim un-confirmed for this many days counts as stale " - "(matches `vouch lint --stale-days`).", -) -@click.option( - "--top", - "top_actors", - default=metrics_mod.DEFAULT_TOP_ACTORS, - show_default=True, - type=int, - help="How many actors to show in the leaderboard (0 = all).", -) -def metrics( - as_json: bool, - as_prom: bool, - since: str | None, - until: str | None, - stale_days: int, - top_actors: int, -) -> None: - """Observability for the review gate + corpus (vouchdev/vouch#192). - - \b - Examples: - vouch metrics # human table, all of history - vouch metrics --json # stable schema for a Prometheus sidecar - vouch metrics --prometheus # textfile-collector exposition - vouch metrics --since 30d # only the last 30 days of the audit log - vouch metrics --since 2026-01-01 --until 2026-02-01 - - All numbers derive purely from .vouch/audit.log.jsonl + the artifact files - — no new on-disk state. The --json shape is documented and stable. - """ - if as_json and as_prom: - raise click.ClickException("choose one of --json / --prometheus, not both") - - store = _load_store() - try: - since_dt = metrics_mod.parse_since(since) - until_dt = metrics_mod.parse_since(until) - m = metrics_mod.compute( - store, - since=since_dt, - until=until_dt, - stale_after_days=stale_days, - top_actors=top_actors, - ) - except metrics_mod.MetricsError as e: - raise click.ClickException(str(e)) from e - - if as_json: - _emit_json(m.to_dict()) - return - if as_prom: - click.echo(metrics_mod.render_prometheus(m), nl=False) - return - - # --- human table --- - window = "all history" if m.since is None else f"since {m.since.isoformat()}" - if m.until is not None: - window += f" until {m.until.isoformat()}" - click.echo(f"vouch metrics ({window})") - click.echo("") - click.echo(" review gate") - click.echo(f" proposals created {m.proposals_created}") - click.echo(f" approved / rejected {m.approvals} / {m.rejections}") - click.echo(f" approval rate {_fmt_pct(m.approval_rate)}") - if m.approval_rate_by_kind: - per_kind = " ".join( - f"{k}={_fmt_pct(v)}" for k, v in sorted(m.approval_rate_by_kind.items()) - ) - click.echo(f" by kind {per_kind}") - click.echo(f" pending now {m.pending_now}") - click.echo("") - click.echo(" corpus") - click.echo(f" claims {m.claims_total} ({m.claims_active} active)") - click.echo( - f" citation coverage {_fmt_pct(m.citation_coverage)} " - f"({m.claims_cited}/{m.claims_total} cited, " - f"{m.citation_broken} broken)" - ) - click.echo( - f" stale ratio {_fmt_pct(m.stale_ratio)} " - f"({m.stale_claims} past {m.stale_after_days}d)" - ) - if m.claims_by_status: - hist = " ".join(f"{k}={v}" for k, v in sorted(m.claims_by_status.items())) - click.echo(f" by status {hist}") - click.echo("") - click.echo(" proposal lag (create → approve)") - lag = m.proposal_lag - click.echo(f" samples {lag.count}") - click.echo( - f" p50 / p90 / p99 " - f"{_fmt_secs(lag.p50)} / {_fmt_secs(lag.p90)} / {_fmt_secs(lag.p99)}" - ) - click.echo(f" mean / max {_fmt_secs(lag.mean)} / {_fmt_secs(lag.max)}") - if m.actors: - click.echo("") - click.echo(" actors (proposed / approved / rejected / confirmed)") - for a in m.actors: - click.echo( - f" {a.actor:<18} {a.proposed} / {a.approved} / {a.rejected} / {a.confirmed}" - ) - click.echo("") - click.echo( - f" audit: {m.audit_events_in_window} events in window ({m.audit_events_total} total)" - ) - - -# --- pages ------------------------------------------------------------------ - - -@cli.command(name="pages") -@click.option("--kind", default=None, help="Filter by page kind (built-in or config-declared).") -@click.option( - "--meta", "meta", multiple=True, metavar="K=V", - help="Frontmatter equality filter (repeatable).", -) -@click.option( - "--before", multiple=True, metavar="K=V", - help="Inclusive upper bound on a frontmatter field (dates/numbers).", -) -@click.option( - "--after", multiple=True, metavar="K=V", - help="Inclusive lower bound on a frontmatter field (dates/numbers).", -) -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def pages_cmd( - kind: str | None, - meta: tuple[str, ...], - before: tuple[str, ...], - after: tuple[str, ...], - as_json: bool, -) -> None: - """List pages, optionally filtered by kind and frontmatter. - - Examples: `vouch pages --kind followup --meta followup_status=open - --before due_at=2026-07-10` lists open followups due by july 10. - """ - store = _load_store() - try: - equals, lo, hi = parse_kv(meta), parse_kv(after), parse_kv(before) - except ValueError as e: - raise click.UsageError(str(e)) from e - hits = filter_pages( - store.list_pages(), kind=kind, equals=equals, before=hi, after=lo, - ) - if as_json: - _emit_json( - [ - { - "id": p.id, "title": p.title, "type": p.type, - "tags": p.tags, "metadata": p.metadata, - } - for p in hits - ] - ) - return - for p in hits: - extras = " ".join(f"{k}={v}" for k, v in sorted(p.metadata.items())) - suffix = f" ({extras})" if extras else "" - click.echo(f"{p.id} [{p.type}] {p.title}{suffix}") - if not hits: - click.echo("no matching pages") - - # --- proposals ------------------------------------------------------------ @cli.command() -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def pending(as_json: bool) -> None: +def pending() -> None: """List proposals awaiting review.""" store = _load_store() pending = store.list_proposals(ProposalStatus.PENDING) - if as_json: - _emit_json([pr.model_dump(mode="json") for pr in pending]) - return if not pending: click.echo("no pending proposals") return for pr in pending: - preview = pr.payload.get("text") or pr.payload.get("title") or pr.payload.get("name") or "—" + preview = ( + pr.payload.get("text") + or pr.payload.get("title") + or pr.payload.get("name") + or "—" + ) click.echo(f"• {pr.id} [{pr.kind.value}] by {pr.proposed_by}") click.echo(f" {str(preview).strip()[:120]}") -def _proposal_preview(pr: Proposal) -> str: - preview = ( - pr.payload.get("text") - or pr.payload.get("title") - or pr.payload.get("name") - or pr.payload.get("id") - or "-" - ) - return str(preview).strip() - - -def _show_review_proposal(pr: Proposal, index: int, total: int) -> None: - click.echo(f"\n[{index}/{total}] {pr.id} [{pr.kind.value}] by {pr.proposed_by}") - click.echo(_proposal_preview(pr)) - if pr.rationale: - click.echo(f"rationale: {pr.rationale}") - click.echo() - click.echo(yaml.safe_dump(pr.model_dump(mode="json"), sort_keys=False).rstrip()) - - -@cli.command() -@click.option( - "--limit", - type=click.IntRange(min=1), - default=None, - help="Review at most N proposals.", -) -@click.option( - "--type", - "kind", - type=click.Choice([k.value for k in ProposalKind]), - default=None, - help="Only review proposals of this kind.", -) -@click.option("--dry-run", is_flag=True, help="Show decisions without mutating proposals.") -def review(limit: int | None, kind: str | None, dry_run: bool) -> None: - """Walk pending proposals one at a time for approval or rejection.""" - store = _load_store() - proposals = store.list_proposals(ProposalStatus.PENDING) - if kind is not None: - proposals = [pr for pr in proposals if pr.kind.value == kind] - if limit is not None: - proposals = proposals[:limit] - if not proposals: - click.echo("no pending proposals") - return - - decided = 0 - skipped = 0 - actor = _whoami() - total = len(proposals) - for index, pr in enumerate(proposals, start=1): - _show_review_proposal(pr, index, total) - action = click.prompt( - "Action [a=approve, r=reject, s=skip, q=quit]", - type=click.Choice(["a", "r", "s", "q"], case_sensitive=False), - default="s", - show_choices=False, - ).lower() - if action == "q": - click.echo("Stopped review") - break - if action == "s": - click.echo(f"Skipped {pr.id}") - skipped += 1 - continue - if action == "r": - reason = click.prompt("Rejection reason").strip() - with _cli_errors(): - if not reason: - raise ProposalError("rejection must include a reason (future agent context)") - if not dry_run: - do_reject(store, pr.id, rejected_by=actor, reason=reason) - if dry_run: - click.echo(f"Would reject {pr.id}") - else: - click.echo(f"Rejected {pr.id}") - decided += 1 - continue - - reason = click.prompt("Approval reason", default="", show_default=False).strip() or None - if dry_run: - click.echo(f"Would approve {pr.id}") - else: - with _cli_errors(): - artifact = do_approve(store, pr.id, approved_by=actor, reason=reason) - click.echo(f"Approved -> {type(artifact).__name__.lower()}/{artifact.id}") - decided += 1 - - if dry_run: - click.echo(f"Review complete: {decided} selected, {skipped} skipped, no changes made") - else: - click.echo(f"Review complete: {decided} decided, {skipped} skipped") - - @cli.command() @click.argument("proposal_id") def show(proposal_id: str) -> None: @@ -940,152 +207,15 @@ def show(proposal_id: str) -> None: click.echo(yaml.safe_dump(pr.model_dump(mode="json"), sort_keys=False)) -@cli.command(name="read-claim") -@click.argument("claim_id") -def read_claim(claim_id: str) -> None: - """Read an approved claim by id.""" - store = _load_store() - with _cli_errors(): - claim = store.get_claim(claim_id) - click.echo(yaml.safe_dump(claim.model_dump(mode="json"), sort_keys=False)) - - -@cli.command(name="read-page") -@click.argument("page_id") -def read_page(page_id: str) -> None: - """Read an approved page by id.""" - store = _load_store() - with _cli_errors(): - page = store.get_page(page_id) - click.echo(yaml.safe_dump(page.model_dump(mode="json"), sort_keys=False)) - - -@cli.command(name="read-entity") -@click.argument("entity_id") -def read_entity(entity_id: str) -> None: - """Read an approved entity by id.""" - store = _load_store() - with _cli_errors(): - entity = store.get_entity(entity_id) - click.echo(yaml.safe_dump(entity.model_dump(mode="json"), sort_keys=False)) - - -@cli.command(name="read-relation") -@click.argument("relation_id") -def read_relation(relation_id: str) -> None: - """Read an approved relation by id.""" - store = _load_store() - with _cli_errors(): - relation = store.get_relation(relation_id) - click.echo(yaml.safe_dump(relation.model_dump(mode="json"), sort_keys=False)) - - -@cli.command(name="list-claims") -def list_claims() -> None: - """List all approved claims.""" - store = _load_store() - claims = store.list_claims() - if not claims: - click.echo("no claims found") - return - for claim in claims: - click.echo(f"{claim.id:50} {claim.text}") - - -@cli.command(name="list-pages") -def list_pages() -> None: - """List all approved pages.""" - store = _load_store() - pages = store.list_pages() - if not pages: - click.echo("no pages found") - return - for page in pages: - click.echo(f"{page.id:50} {page.title}") - - -@cli.command(name="list-entities") -def list_entities() -> None: - """List all approved entities.""" - store = _load_store() - entities = store.list_entities() - if not entities: - click.echo("no entities found") - return - for entity in entities: - click.echo(f"{entity.id:50} {entity.name} ({entity.type})") - - -@cli.command(name="list-relations") -def list_relations() -> None: - """List all approved relations.""" - store = _load_store() - relations = store.list_relations() - if not relations: - click.echo("no relations found") - return - for relation in relations: - output = f"{relation.id:50} {relation.source} -> {relation.relation} -> " - output += relation.target - click.echo(output) - - @cli.command() -@click.argument("proposal_ids", nargs=-1, required=True) +@click.argument("proposal_id") @click.option("--reason", default=None) -@click.option( - "--keep-going", - is_flag=True, - help="Best-effort: approve every id that can be approved and report the " - "rest, instead of the default all-or-nothing precheck.", -) -def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) -> None: - """Approve one or more proposals — converts each into a durable artifact. - - Pass several ids to approve a batch in one call (useful for CI and - clearing a review backlog). One audit event is recorded per approved - artifact. - - Semantics: - - \b - - default (all-or-nothing): every id is validated as an approvable - pending proposal before any is written; a typo or already-decided id - aborts the whole batch and nothing is approved. - - --keep-going (best-effort): approve each id independently, report the - failures, and exit non-zero if any failed. - """ +def approve(proposal_id: str, reason: str | None) -> None: + """Approve a proposal — converts it into a durable artifact.""" store = _load_store() - approver = _whoami() - - if not keep_going: - blocked = [ - (pid, reason_blocked) - for pid in proposal_ids - if (reason_blocked := check_approvable(store, pid, approved_by=approver)) - ] - if blocked: - for pid, why in blocked: - click.echo(f"✗ {pid}: {why}", err=True) - raise click.ClickException( - f"refusing to approve: {len(blocked)} of {len(proposal_ids)} not " - "approvable — nothing was approved (use --keep-going for best-effort)" - ) - - failures = 0 - for pid in proposal_ids: - try: - artifact = do_approve(store, pid, approved_by=approver, reason=reason) - except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e: - failures += 1 - click.echo(f"✗ {pid}: {e}", err=True) - continue - click.echo(f"Approved → {type(artifact).__name__.lower()}/{artifact.id}") - - if failures: - raise click.ClickException( - f"{failures} of {len(proposal_ids)} proposal(s) failed to approve" - ) + with _cli_errors(): + artifact = do_approve(store, proposal_id, approved_by=_whoami(), reason=reason) + click.echo(f"Approved → {type(artifact).__name__.lower()}/{artifact.id}") @cli.command() @@ -1099,526 +229,62 @@ def reject(proposal_id: str, reason: str) -> None: click.echo(f"Rejected {proposal_id}") -@cli.command("reject-extracted") -@click.option("--page", "page_id", default=None, help="Limit to edges extracted from one page id.") -@click.option("--reason", default="auto-extracted edge rejected in bulk") -def reject_extracted(page_id: str | None, reason: str) -> None: - """Mass-reject pending edges the auto-extractor filed (issue #224).""" - store = _load_store() - with _cli_errors(): - rejected = reject_auto_extracted( - store, rejected_by=_whoami(), page_id=page_id, reason=reason, - ) - if not rejected: - click.echo("no pending auto-extracted edges to reject") - return - click.echo(f"Rejected {len(rejected)} auto-extracted edge proposal(s)") - - -def _expire_row(proposal: Proposal) -> dict[str, Any]: - return { - "id": proposal.id, - "kind": proposal.kind.value, - "proposed_by": proposal.proposed_by, - "proposed_at": proposal.proposed_at.isoformat(), - } - - -@cli.command() -@click.option("--apply", is_flag=True, help="Expire stale proposals (default is dry-run).") -@click.option( - "--days", type=int, default=None, help="Override review.expire_pending_after_days for this run." -) -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def expire(apply: bool, days: int | None, as_json: bool) -> None: - """Garbage-collect pending proposals older than the configured threshold.""" - store = _load_store() - result = expire_pending( - store, - apply=apply, - expired_by=EXPIRE_ACTOR, - days=days, - ) - if as_json: - payload: dict[str, Any] = { - "threshold_days": result.threshold_days, - "enabled": result.threshold_days > 0, - "dry_run": not apply, - "would_expire": [_expire_row(p) for p in result.would_expire], - "expired": [_expire_row(p) for p in result.expired], - } - _emit_json(payload) - return - - if result.threshold_days <= 0: - click.echo("expire disabled (review.expire_pending_after_days is 0)") - return - - if not result.would_expire: - click.echo(f"no stale pending proposals (threshold: {result.threshold_days} days)") - return - - if not apply: - click.echo( - f"dry-run: {len(result.would_expire)} proposal(s) would expire " - f"(threshold: {result.threshold_days} days)" - ) - for pr in result.would_expire: - click.echo( - f" {pr.id} [{pr.kind.value}] by {pr.proposed_by} " - f"proposed {pr.proposed_at.date().isoformat()}" - ) - click.echo("rerun with --apply to expire") - return - - click.echo( - f"expired {len(result.expired)} proposal(s) (threshold: {result.threshold_days} days)" - ) - for pr in result.expired: - click.echo(f" {pr.id} [{pr.kind.value}]") - - # --- proposal-from-CLI shortcuts ----------------------------------------- -def _format_similarity_warning(w: dict) -> str: - label = w.get("code", "similar") - kind = w.get("artifact_kind", "?") - aid = w.get("artifact_id", "?") - cos = w.get("cosine", 0) - snip = w.get("snippet", "") - return f"warning: {label} {kind} {aid} (cosine {cos}) — {snip}" - - @cli.command(name="propose-claim") @click.option("--text", required=True) -@click.option( - "--source", "sources", multiple=True, required=True, help="Source or evidence id. Repeatable." -) +@click.option("--source", "sources", multiple=True, required=True, + help="Source or evidence id. Repeatable.") @click.option("--type", "claim_type", default="observation", show_default=True) @click.option("--confidence", default=0.7, show_default=True, type=float) @click.option("--rationale", default=None) @click.option("--tag", "tags", multiple=True) -def propose_claim_cmd( - text: str, - sources: tuple[str, ...], - claim_type: str, - confidence: float, - rationale: str | None, - tags: tuple[str, ...], -) -> None: +def propose_claim_cmd(text: str, sources: tuple[str, ...], claim_type: str, + confidence: float, rationale: str | None, + tags: tuple[str, ...]) -> None: store = _load_store() with _cli_errors(): - result = propose_claim( - store, - text=text, - evidence=list(sources), - proposed_by=_whoami(), - claim_type=claim_type, - confidence=confidence, - tags=list(tags), - rationale=rationale, + pr = propose_claim( + store, text=text, evidence=list(sources), + proposed_by=_whoami(), claim_type=claim_type, + confidence=confidence, tags=list(tags), rationale=rationale, ) - click.echo(result.id) - for w in result.warnings: - _echo(_format_similarity_warning(w), err=True) + click.echo(pr.id) @cli.command(name="propose-page") @click.option("--title", required=True) @click.option("--body", default="", help="Page body. Use `-` to read from stdin.") @click.option("--type", "page_type", default="concept", show_default=True) -@click.option("--kind", "kind", default=None, help="alias for --type (config-declared page kind).") -@click.option( - "--meta", - "meta", - multiple=True, - help="per-kind frontmatter field as key=value (repeatable). Value parsed as YAML.", -) @click.option("--claim", "claims", multiple=True) @click.option("--entity", "entities", multiple=True) -def propose_page_cmd( - title: str, - body: str, - page_type: str, - kind: str | None, - meta: tuple[str, ...], - claims: tuple[str, ...], - entities: tuple[str, ...], -) -> None: +def propose_page_cmd(title: str, body: str, page_type: str, + claims: tuple[str, ...], entities: tuple[str, ...]) -> None: store = _load_store() if body == "-": body = sys.stdin.read() - metadata = _parse_meta(meta) with _cli_errors(): pr = propose_page( - store, - title=title, - body=body, - page_type=kind or page_type, - claim_ids=list(claims), - entity_ids=list(entities), - metadata=metadata, + store, title=title, body=body, page_type=page_type, + claim_ids=list(claims), entity_ids=list(entities), proposed_by=_whoami(), ) click.echo(pr.id) -def _parse_meta(pairs: tuple[str, ...], *, flag: str = "--meta") -> dict[str, Any]: - """Parse repeated ``key=value`` pairs into a frontmatter dict. - - Values run through ``yaml.safe_load`` so ``attendees=[a, b]`` and - ``count=3`` arrive as a list / int rather than strings. - """ - out: dict[str, Any] = {} - for pair in pairs: - if "=" not in pair: - raise click.BadParameter(f"{flag} expects key=value, got {pair!r}") - key, _, raw = pair.partition("=") - key = key.strip() - try: - out[key] = yaml.safe_load(raw) - except yaml.YAMLError as e: - raise click.BadParameter( - f"{flag} value for {key!r} is invalid YAML: {e}", - ) from e - return out - - -# Keep entity scaffolding intentionally narrow because `propose_entity` accepts -# arbitrary type strings; only well-known EntityType names are routed here. -_SCAFFOLD_ENTITY_TYPES: frozenset[str] = frozenset( - {"person", "project", "repo", "company", "concept", "decision", "workflow"} -) - -_CITATION_REMINDER = ( - "\n\n\n" -) - - -def _field_missing(value: Any) -> bool: - return value is None or value == "" or value == [] or value == {} - - -def _resolve_new_kind( - kind: str, - registry: Any, - *, - force_entity: bool, -) -> tuple[Literal["page", "entity"], str]: - if force_entity: - if kind not in _SCAFFOLD_ENTITY_TYPES: - known = ", ".join(sorted(_SCAFFOLD_ENTITY_TYPES)) - raise click.ClickException(f"unknown entity type {kind!r} (known: {known})") - return "entity", kind - if registry.is_known(kind): - return "page", kind - if kind in _SCAFFOLD_ENTITY_TYPES: - return "entity", kind - page_kinds = ", ".join(sorted(registry.known())) - entity_kinds = ", ".join(sorted(_SCAFFOLD_ENTITY_TYPES)) - raise click.ClickException( - f"unknown kind {kind!r}; page kinds: {page_kinds}; entity kinds: {entity_kinds}" - ) - - -def _stub_page_frontmatter( - registry: Any, - kind: str, - prefilled: dict[str, Any], -) -> tuple[dict[str, Any], list[str], bool]: - required, _schema, required_citations = registry.resolve(kind) - metadata = dict(prefilled) - for field in required: - metadata.setdefault(field, "") - missing = [f for f in required if _field_missing(metadata.get(f))] - return metadata, missing, required_citations - - -def _prompt_missing_fields( - missing: list[str], - metadata: dict[str, Any], -) -> list[str]: - still_missing: list[str] = [] - for field in missing: - raw = click.prompt(field, default="", show_default=False) - if raw: - try: - metadata[field] = yaml.safe_load(raw) - except yaml.YAMLError as e: - raise click.BadParameter( - f"interactive value for {field!r} is invalid YAML: {e}", - ) from e - else: - metadata[field] = "" - if _field_missing(metadata.get(field)): - still_missing.append(field) - return still_missing - - -def _print_new_page_draft(draft: dict[str, Any]) -> None: - click.echo(f"kind: {draft['kind']} (page)") - click.echo(f"title: {draft['title']}") - fm = yaml.safe_dump(draft["frontmatter"], default_flow_style=True).strip() - click.echo(f"frontmatter: {fm}") - missing = draft["missing_required_fields"] - if missing: - click.echo(f"missing required fields: {', '.join(missing)}") - else: - click.echo("missing required fields: (none)") - if draft["citation_reminder"]: - click.echo("citations: required (reminder appended to body)") - if draft.get("body"): - click.echo(f"body:\n{draft['body']}") - if draft.get("id"): - click.echo(f"proposal id (dry-run): {draft['id']}") - - -def _print_new_entity_draft(draft: dict[str, Any]) -> None: - click.echo(f"kind: {draft['kind']} (entity)") - click.echo(f"name: {draft['name']}") - if draft.get("id"): - click.echo(f"proposal id (dry-run): {draft['id']}") - - -@cli.command(name="new") -@click.argument("kind") -@click.option("--title", default=None, help="Page title (required for page kinds).") -@click.option("--name", default=None, help="Entity name (required for entity kinds).") -@click.option( - "--field", - "fields", - multiple=True, - help="Pre-fill a frontmatter field as key=value (repeatable). Value parsed as YAML.", -) -@click.option("--interactive", "-i", is_flag=True, help="Prompt for unfilled required fields.") -@click.option("--body", default="", help="Page body. Use `-` to read from stdin.") -@click.option("--claim", "claims", multiple=True) -@click.option("--source", "sources", multiple=True) -@click.option("--entity", "force_entity", is_flag=True, help="Force entity scaffold path.") -@click.option("--dry-run", is_flag=True, help="Print assembled draft without creating a proposal.") -@click.option("--json", "as_json", is_flag=True, help="Emit machine-readable JSON.") -def new_cmd( - kind: str, - title: str | None, - name: str | None, - fields: tuple[str, ...], - interactive: bool, - body: str, - claims: tuple[str, ...], - sources: tuple[str, ...], - force_entity: bool, - dry_run: bool, - as_json: bool, -) -> None: - """Scaffold a typed page or entity proposal from the page-kind registry.""" - store = _load_store() - registry = load_page_kind_registry(store) - target, resolved_kind = _resolve_new_kind(kind, registry, force_entity=force_entity) - - if target == "entity": - if not name or not name.strip(): - raise click.ClickException("--name is required for entity kinds") - draft: dict[str, Any] = { - "dry_run": dry_run, - "target": "entity", - "kind": resolved_kind, - "name": name.strip(), - } - if dry_run: - with _cli_errors(): - pr = propose_entity( - store, - name=name, - entity_type=resolved_kind, - proposed_by=_whoami(), - dry_run=True, - ) - draft["id"] = pr.id - if as_json: - _emit_json(draft) - else: - _print_new_entity_draft(draft) - return - with _cli_errors(): - pr = propose_entity( - store, - name=name, - entity_type=resolved_kind, - proposed_by=_whoami(), - ) - if as_json: - _emit_json({"id": pr.id}) - return - click.echo(pr.id) - return - - if not title or not title.strip(): - raise click.ClickException("--title is required for page kinds") - if body == "-": - body = sys.stdin.read() - - metadata = _parse_meta(fields, flag="--field") - metadata, missing, requires_citations = _stub_page_frontmatter( - registry, resolved_kind, metadata, - ) - if interactive and missing: - missing = _prompt_missing_fields(missing, metadata) - - citation_reminder = requires_citations and not (claims or sources) - if citation_reminder and not dry_run: - raise click.ClickException( - "this page kind requires citations; pass --claim/--source, " - "or rerun with --dry-run to print a draft with the citation reminder" - ) - if citation_reminder: - body = body + _CITATION_REMINDER - - page_draft: dict[str, Any] = { - "dry_run": dry_run, - "target": "page", - "kind": resolved_kind, - "title": title.strip(), - "frontmatter": metadata, - "body": body, - "missing_required_fields": missing, - "citation_reminder": citation_reminder, - } - - if dry_run: - if not missing and not (requires_citations and not (claims or sources)): - with _cli_errors(): - pr = propose_page( - store, - title=title, - body=body, - page_type=resolved_kind, - claim_ids=list(claims), - source_ids=list(sources), - metadata=metadata, - proposed_by=_whoami(), - dry_run=True, - ) - page_draft["id"] = pr.id - if as_json: - _emit_json(page_draft) - else: - _print_new_page_draft(page_draft) - return - - with _cli_errors(): - pr = propose_page( - store, - title=title, - body=body, - page_type=resolved_kind, - claim_ids=list(claims), - source_ids=list(sources), - metadata=metadata, - proposed_by=_whoami(), - ) - if as_json: - _emit_json({"id": pr.id}) - return - click.echo(pr.id) - - -@cli.group(name="schema") -def schema() -> None: - """inspect and validate config-declared page kinds (issue #234).""" - - -@schema.command(name="list") -@click.option("--json", "as_json", is_flag=True, help="emit machine-readable JSON.") -def schema_list_cmd(as_json: bool) -> None: - """list the page kinds this KB recognizes (built-in + config-declared).""" - store = _load_store() - registry = load_page_kind_registry(store) - rows: list[dict[str, Any]] = [] - lines: list[str] = [] - for name in sorted(registry.known()): - required, fm_schema, citations = registry.resolve(name) - rows.append( - { - "kind": name, - "required_fields": required, - "required_citations": citations, - "has_frontmatter_schema": bool(fm_schema), - "protected": registry.is_protected(name), - } - ) - extras: list[str] = [] - if required: - extras.append(f"required={','.join(required)}") - if citations: - extras.append("citations-required") - if fm_schema: - extras.append("schema") - if registry.is_protected(name): - extras.append("protected") - suffix = f" ({'; '.join(extras)})" if extras else "" - lines.append(f"{name}{suffix}") - if as_json: - click.echo(json.dumps(rows, indent=2)) - return - for line in lines: - click.echo(line) - - -@schema.command(name="sync") -@click.option("--json", "as_json", is_flag=True, help="emit machine-readable JSON.") -def schema_sync_cmd(as_json: bool) -> None: - """validate every page against its declared kind; report conflicts. - - Read-only: it never rewrites pages (that would bypass the review gate). - Exits non-zero when any page conflicts, so it doubles as a CI guard after - a `page_kinds` change. Resolve conflicts by re-proposing the page through - the normal review flow. - """ - store = _load_store() - registry = load_page_kind_registry(store) - conflicts: list[dict[str, Any]] = [] - checked = 0 - for page in store.list_pages(): - checked += 1 - try: - registry.validate( - page.type, - page.metadata, - has_citations=bool(page.claims or page.sources), - ) - except PageKindError as e: - conflicts.append({"page": page.id, "kind": page.type, "problems": e.problems}) - if as_json: - click.echo(json.dumps({"checked": checked, "conflicts": conflicts}, indent=2)) - else: - click.echo(f"checked {checked} page(s)") - for c in conflicts: - click.echo(f" {c['page']} [{c['kind']}]: {'; '.join(c['problems'])}") - if not conflicts: - click.echo("no conflicts") - if conflicts: - raise SystemExit(1) - - @cli.command(name="propose-entity") @click.option("--name", required=True) @click.option("--type", "entity_type", required=True) @click.option("--alias", "aliases", multiple=True) @click.option("--description", default=None) -def propose_entity_cmd( - name: str, entity_type: str, aliases: tuple[str, ...], description: str | None -) -> None: +def propose_entity_cmd(name: str, entity_type: str, aliases: tuple[str, ...], + description: str | None) -> None: store = _load_store() with _cli_errors(): pr = propose_entity( - store, - name=name, - entity_type=entity_type, - aliases=list(aliases), - description=description, - proposed_by=_whoami(), + store, name=name, entity_type=entity_type, + aliases=list(aliases), description=description, proposed_by=_whoami(), ) click.echo(pr.id) @@ -1632,12 +298,8 @@ def propose_relation_cmd(src: str, relation: str, target: str, confidence: float store = _load_store() with _cli_errors(): pr = propose_relation( - store, - src=src, - relation=relation, - target=target, - confidence=confidence, - proposed_by=_whoami(), + store, src=src, relation=relation, target=target, + confidence=confidence, proposed_by=_whoami(), ) click.echo(pr.id) @@ -1655,7 +317,8 @@ def source() -> None: @click.option("--title", default=None) @click.option("--url", default=None) @click.option("--type", "source_type", default="file", show_default=True) -def source_add(path: str, title: str | None, url: str | None, source_type: str) -> None: +def source_add(path: str, title: str | None, url: str | None, + source_type: str) -> None: """Register a file as a Source; prints its sha256 id.""" store = _load_store() data = Path(path).read_bytes() @@ -1668,51 +331,7 @@ def source_add(path: str, title: str | None, url: str | None, source_type: str) source_type=source_type, ) audit_mod.log_event( - store.kb_dir, - event="source.add", - actor=_whoami(), - object_ids=[src.id], - ) - click.echo(src.id) - - -@source.command("fetch") -@click.argument("url") -@click.option("--title", default=None) -@click.option( - "--max-bytes", - default=fetch_mod.DEFAULT_MAX_BYTES, - show_default=True, - type=int, - help="Snapshot size cap.", -) -@click.option("--timeout", default=fetch_mod.DEFAULT_TIMEOUT, show_default=True, type=float) -@click.option("--tag", "tags", multiple=True) -def source_fetch( - url: str, title: str | None, max_bytes: int, timeout: float, tags: tuple[str, ...], -) -> None: - """Fetch URL and register the exact bytes as a content-addressed Source. - - Claims cite the immutable snapshot id, so the evidence a reviewer - approved against survives the live page drifting. http/https only; - hosts must resolve to public addresses; redirects are re-validated. - """ - store = _load_store() - with _cli_errors(): - src = fetch_mod.snapshot_url( - store, - url, - title=title, - tags=list(tags) or None, - max_bytes=max_bytes, - timeout=timeout, - ) - audit_mod.log_event( - store.kb_dir, - event="source.fetch", - actor=_whoami(), - object_ids=[src.id], - data={"url": url}, + store.kb_dir, event="source.add", actor=_whoami(), object_ids=[src.id], ) click.echo(src.id) @@ -1735,77 +354,6 @@ def source_verify(fail_on_issue: bool) -> None: sys.exit(1) -@cli.command(name="inbox") -@click.option( - "--dir", "directory", required=True, - type=click.Path(exists=True, file_okay=False), - help="Folder to scan (must live under the project root).", -) -@click.option("--watch", "watch_mode", is_flag=True, help="Poll instead of a single pass.") -@click.option( - "--poll-interval", - default=inbox_mod.DEFAULT_POLL_INTERVAL, - show_default=True, - type=float, -) -@click.option("--once", is_flag=True, help="Single tick even under --watch (test/ci bound).") -def inbox_cmd(directory: str, watch_mode: bool, poll_interval: float, once: bool) -> None: - """Scan an inbox folder: each new file becomes a registered source plus - one pending page proposal. Proposes only — a human still approves.""" - store = _load_store() - path = Path(directory) - with _cli_errors(): - if watch_mode and not once: - def _report(res: inbox_mod.ScanResult) -> None: - if res.proposed: - click.echo(f"filed {len(res.proposed)} proposal(s): {', '.join(res.proposed)}") - - with suppress(KeyboardInterrupt): - inbox_mod.watch( - store, path, poll_interval=poll_interval, on_result=_report, - ) - return - res = inbox_mod.scan(store, path) - click.echo(f"filed {len(res.proposed)} proposal(s); skipped {len(res.skipped)} file(s)") - for pid in res.proposed: - click.echo(f" {pid}") - - -@cli.group() -def notify() -> None: - """Outbound reviewer notification webhooks (config: notify.webhooks).""" - - -@notify.command("sweep") -def notify_sweep() -> None: - """Evaluate pending-queue triggers and fire configured webhooks. - - Idempotent per (event, proposal) — safe to run from cron. Read-and- - notify only: nothing here can propose, approve, or edit.""" - store = _load_store() - with _cli_errors(): - fired = notify_mod.sweep(store) - if fired: - click.echo(f"fired {len(fired)} event(s): {', '.join(fired)}") - else: - click.echo("nothing to fire") - - -@notify.command("test") -@click.option("--url", required=True) -@click.option("--secret", default=None, help="Optional hmac secret (or env:VAR).") -def notify_test(url: str, secret: str | None) -> None: - """Send a synthetic event to URL and report delivery.""" - resolved = None - if secret: - with _cli_errors(): - resolved = notify_mod._resolve_env(secret, what="--secret") - ok = notify_mod.send_test(url, secret=resolved) - click.echo("delivered" if ok else "delivery failed") - if not ok: - sys.exit(1) - - # --- lifecycle ------------------------------------------------------------ @@ -1816,7 +364,8 @@ def supersede(old_claim_id: str, new_claim_id: str) -> None: """Mark OLD as superseded by NEW.""" store = _load_store() with _cli_errors(): - life.supersede(store, old_claim_id=old_claim_id, new_claim_id=new_claim_id, actor=_whoami()) + life.supersede(store, old_claim_id=old_claim_id, + new_claim_id=new_claim_id, actor=_whoami()) click.echo(f"superseded {old_claim_id} -> {new_claim_id}") @@ -1873,8 +422,7 @@ def session() -> None: @session.command("start") @click.option( - "--agent", - default=None, + "--agent", default=None, help="Agent id (defaults to $VOUCH_AGENT or current user).", ) @click.option("--task", default=None) @@ -1882,34 +430,12 @@ def session() -> None: def session_start_cmd(agent: str | None, task: str | None, note: str | None) -> None: store = _load_store() sess = sess_mod.session_start( - store, - agent=agent or os.environ.get("VOUCH_AGENT") or _whoami(), - task=task, - note=note, + store, agent=agent or os.environ.get("VOUCH_AGENT") or _whoami(), + task=task, note=note, ) click.echo(sess.id) -@session.command("volunteer") -@click.argument("session_id") -@click.option("--no-clear", is_flag=True, help="Peek without draining the queue.") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON.") -def session_volunteer_cmd(session_id: str, no_clear: bool, as_json: bool) -> None: - """Poll volunteered context for an active session.""" - offers = volunteer_context.drain_pending(session_id, clear=not no_clear) - payload = {"volunteers": [o.to_dict() for o in offers]} - if as_json: - _emit_json(payload) - return - if not offers: - click.echo("(no volunteered context)") - return - for offer in offers: - click.echo( - f"{offer.claim_id} relevance={offer.relevance:.2f} {offer.why}" - ) - - @session.command("end") @click.argument("session_id") @click.option("--note", default=None) @@ -1920,176 +446,6 @@ def session_end_cmd(session_id: str, note: str | None) -> None: _emit_json({"session": sess.id, "proposals": sess.proposal_ids}) -@cli.group() -def capture() -> None: - """Automatic session capture (driven by claude code hooks).""" - - -def _capture_store() -> KBStore | None: - """Locate the KB without the sys.exit(2) that _load_store does — hooks - must never abort the host.""" - try: - return KBStore(discover_root()) - except KBNotFoundError: - return None - - -@capture.command("observe") -def capture_observe_cmd() -> None: - """Append one observation from a PostToolUse hook payload (stdin JSON).""" - if sys.stdin.isatty(): - return - try: - raw = sys.stdin.read() - payload = json.loads(raw) if raw.strip() else {} - if not isinstance(payload, dict): - return - session_id = str(payload.get("session_id") or "") - if not session_id: - return - tool_input = payload.get("tool_input") - obs = capture_mod.summarize_tool( - payload.get("tool_name"), - tool_input if isinstance(tool_input, dict) else {}, - payload.get("tool_response"), - ) - if obs is None: - return - store = _capture_store() - if store is None: - return - capture_mod.observe( - store, session_id, - tool=obs["tool"], summary=obs["summary"], - files=obs.get("files"), cmd=obs.get("cmd"), - ) - except Exception: - # a capture failure must never break the user's tool call. - return - - -@capture.command("finalize") -@click.option("--session-id", default=None, help="Session id (else read from stdin payload).") -def capture_finalize_cmd(session_id: str | None) -> None: - """Roll a session buffer into a PENDING summary (SessionEnd hook payload on stdin).""" - payload: dict[str, Any] = {} - if not sys.stdin.isatty(): - raw = sys.stdin.read() - if raw.strip(): - try: - loaded = json.loads(raw) - if isinstance(loaded, dict): - payload = loaded - except json.JSONDecodeError: - payload = {} - sid = session_id or str(payload.get("session_id") or "") - if not sid: - return - store = _capture_store() - if store is None: - return - cwd = Path(str(payload.get("cwd") or ".")).resolve() - transcript_raw = payload.get("transcript_path") - transcript = Path(str(transcript_raw)) if transcript_raw else None - result = capture_mod.finalize( - store, sid, cwd=cwd, project=cwd.name, - generated_at=datetime.now(UTC).isoformat(), - transcript_path=transcript, - ) - _emit_json(result) - - -@capture.command("finalize-all") -@click.option("--session-id", default=None, help="Current session id (else env VOUCH_SESSION_ID).") -@click.option("--max-age-seconds", type=float, default=3600.0, help="Max age in seconds.") -def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> None: - """Finalize all capture buffers except current session (SessionStart cleanup).""" - sid = session_id or os.environ.get("VOUCH_SESSION_ID") or "" - if not sid: - # No session ID provided; silently succeed - _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) - return - - store = _capture_store() - if store is None: - # No KB; silently succeed - _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) - return - - result = capture_mod.finalize_all_except( - store, sid, max_age_seconds=max_age_seconds, - ) - _emit_json(result) - - -@capture.command("banner") -def capture_banner_cmd() -> None: - """Emit a SessionStart nudge if captured summaries await review.""" - store = _capture_store() - if store is None: - return - n = capture_mod.pending_count(store) - if n: - click.echo( - f"🔔 {n} auto-captured session summary(ies) awaiting review — " - f"run `vouch review`." - ) - - -@cli.command(name="recall") -def recall_cmd() -> None: - """Emit a digest of all approved knowledge for session-start injection.""" - store = _capture_store() - if store is None: - return - cfg = recall_mod.load_config(store) - if not cfg.enabled: - return - digest = recall_mod.build_digest(store, max_chars=cfg.max_chars) - if digest.strip(): - click.echo(digest) - - -@cli.command(name="compile") -@click.option("--dry-run", is_flag=True, help="Draft and validate; file nothing.") -@click.option("--max-pages", type=int, default=None, - help="Cap drafted pages (default: compile.max_pages, 5).") -@click.option("--llm-cmd", default=None, - help="Override compile.llm_cmd from config.yaml for this run.") -@click.option("--json", "as_json", is_flag=True, help="Machine-readable report.") -def compile_cmd(dry_run: bool, max_pages: int | None, - llm_cmd: str | None, as_json: bool) -> None: - """Compile approved claims into topic-page proposals (llm-wiki ingest). - - Runs the deployment-configured LLM (compile.llm_cmd) over the live - approved claims, validates every citation in the drafts, and files the - survivors as pending page proposals. Approval stays a separate human - step (`vouch review`). - """ - store = _load_store() - actor = os.environ.get("VOUCH_AGENT") or compile_mod.COMPILE_ACTOR - try: - report = compile_mod.compile_kb( - store, actor=actor, triggered_by=_whoami(), llm_cmd=llm_cmd, - max_pages=max_pages, dry_run=dry_run, - ) - except compile_mod.CompileError as e: - raise click.ClickException(str(e)) from e - if as_json: - _emit_json(report.to_dict()) - return - verb = "would propose" if dry_run else "proposed" - _echo(f"{verb} {len(report.proposed)} page draft(s):") - for row in report.proposed: - _echo(f" • {row['proposal_id']} {row['title']}") - if report.dropped: - _echo(f"dropped {len(report.dropped)}:") - for row in report.dropped: - _echo(f" • {row['title']} — {row['reason']}") - if report.proposed and not dry_run: - _echo("run `vouch review` to decide.") - - @cli.command() @click.argument("session_id") @click.option("--no-page", is_flag=True, help="Skip the session-summary page.") @@ -2098,27 +454,9 @@ def crystallize(session_id: str, no_page: bool) -> None: store = _load_store() with _cli_errors(): result = sess_mod.crystallize( - store, - session_id, - approver=_whoami(), - write_summary_page=not no_page, + store, session_id, approver=_whoami(), write_summary_page=not no_page, ) _emit_json(result) - n_approved = len(result["approved"]) - n_failed = len(result["failures"]) - total = n_approved + n_failed - if total > 0 and n_failed == total: - click.echo( - f"error: all {total} proposal(s) failed to approve — crystallize aborted", - err=True, - ) - raise SystemExit(1) - if n_failed > 0: - click.echo( - f"warning: {n_failed}/{total} proposal(s) failed to approve " - f"(see failures in JSON above)", - err=True, - ) # --- retrieval ------------------------------------------------------------ @@ -2126,135 +464,24 @@ def crystallize(session_id: str, no_page: bool) -> None: @cli.command() @click.argument("query") -@click.option("--limit", "-n", default=10, show_default=True, type=int) -@click.option("--top-k", default=None, type=int, help="Alias for --limit.") -@click.option( - "--semantic/--no-semantic", - default=None, - help="Force semantic backend (alias for --backend embedding).", -) -@click.option( - "--backend", - type=click.Choice(["auto", "embedding", "fts5", "substring", "hybrid"]), - default="auto", - show_default=True, -) -@click.option("--min-score", default=0.0, show_default=True, type=float) -@click.option("--rerank/--no-rerank", default=False) -@click.option("--hyde/--no-hyde", default=False) -@click.option("--explain/--no-explain", default=False) -@click.option("--json", "as_json", is_flag=True, help="Emit hits as JSON.") -@click.option("--project", default=None, help="Viewer project for scope filtering.") -@click.option("--agent", default=None, help="Viewer agent for scope filtering.") -def search( - query: str, - limit: int, - top_k: int | None, - semantic: bool | None, - backend: str, - min_score: float, - rerank: bool, - hyde: bool, - explain: bool, - as_json: bool, - project: str | None, - agent: str | None, -) -> None: - """Search the KB.""" +@click.option("--limit", default=10, show_default=True, type=int) +def search(query: str, limit: int) -> None: + """FTS5 search over claims, pages, and entities.""" from . import index_db - from .embeddings.fusion import rrf_fuse - from .scoping import filter_hits, scoped_fetch_limit, viewer_from - store = _load_store() - viewer = viewer_from( - config_path=store.config_path, - project=project, - agent=agent, - ) - fetch_limit = scoped_fetch_limit(limit, viewer) - if top_k is not None: - limit = top_k - if semantic is True: - backend = "embedding" - elif semantic is False: - backend = "fts5" - q = query - if hyde: - from .embeddings.hyde import expand_query_template - - q = expand_query_template(query) - - hits: list[tuple[str, str, str, float]] = [] - used = backend - if backend in ("auto", "embedding"): - hits = index_db.search_semantic( - store.kb_dir, - q, - limit=fetch_limit, - min_score=min_score, - ) - used = "embedding" if hits else used - if not hits and backend in ("auto", "fts5"): - hits = index_db.search(store.kb_dir, q, limit=fetch_limit) - used = "fts5" if hits else used - if not hits and backend in ("auto", "substring"): - hits = store.search_substring(q, limit=fetch_limit) - used = "substring" - if backend == "hybrid": - emb = index_db.search_semantic(store.kb_dir, q, limit=fetch_limit * 2) - fts = index_db.search(store.kb_dir, q, limit=fetch_limit * 2) - hits = rrf_fuse(emb, fts, limit=fetch_limit) - used = "hybrid" - - hits = filter_hits(store, hits, viewer, limit=limit) - - if rerank and hits: - try: - from .embeddings.rerank import default_reranker - from .embeddings.rerank import rerank as do_rerank - - hits = do_rerank(query=query, hits=hits, reranker=default_reranker(), top_k=limit) - except ImportError: - click.echo("warning: rerank extras not installed; skipping rerank", err=True) - - if as_json: - _emit_json({ - "backend": used, - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "hits": [ - {"kind": k, "id": i, "snippet": snip, "score": score, - "backend": used} - for k, i, snip, score in hits - ], - }) - return - - for k, i, snip, score in hits: - if explain: - click.echo(f"[{used}] {k}/{i}\tscore={score:.4f}\t{snip} ({used})") + try: + hits = index_db.search(store.kb_dir, query, limit=limit) + if not hits: + hits = store.search_substring(query, limit=limit) + backend = "substring" else: - click.echo(f"{k}/{i}\t{snip} ({used})") - - -@cli.command() -@click.argument("node_id") -@click.option("--depth", default=1, show_default=True, type=int) -@click.option("--rel-type", "rel_types", multiple=True, - help="Filter to relation types (repeatable).") -@click.option("--max-nodes", default=50, show_default=True, type=int) -def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...], - max_nodes: int) -> None: - """List graph neighbors of a claim, page, entity, or source.""" - from .graph import find_neighbors - - store = _load_store() - with _cli_errors(): - result = find_neighbors( - store, node_id, depth=depth, - rel_types=list(rel_types) or None, - max_nodes=max_nodes, - ) - _emit_json(result) + backend = "fts5" + except Exception: + hits = store.search_substring(query, limit=limit) + backend = "substring" + for kind, hid, snippet, score in hits: + click.echo(f"[{kind}] {hid} score={score:.3f} ({backend})") + click.echo(f" {snippet[:200]}") @cli.command() @@ -2263,54 +490,15 @@ def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...], @click.option("--max-chars", default=None, type=int) @click.option("--require-citations", is_flag=True) @click.option("--min-items", default=0, type=int) -@click.option("--project", default=None, help="Viewer project for scope filtering.") -@click.option("--agent", default=None, help="Viewer agent for scope filtering.") -@click.option("--expand-graph", is_flag=True, - help="Include 1-hop graph neighbors of search hits.") -@click.option("--graph-depth", default=1, show_default=True, type=int) -@click.option("--graph-limit", default=20, show_default=True, type=int) -def context( - task: str, - limit: int, - max_chars: int | None, - require_citations: bool, - min_items: int, - project: str | None, - agent: str | None, - expand_graph: bool, - graph_depth: int, - graph_limit: int, -) -> None: +def context(task: str, limit: int, max_chars: int | None, + require_citations: bool, min_items: int) -> None: """Build a ContextPack ready to inject into an agent prompt.""" store = _load_store() pack = build_context_pack( - store, - query=task, - limit=limit, - max_chars=max_chars, - min_items=min_items, - require_citations=require_citations, - project=project, - agent=agent, - expand_graph=expand_graph, - graph_depth=graph_depth, - graph_limit=graph_limit, + store, query=task, limit=limit, max_chars=max_chars, + min_items=min_items, require_citations=require_citations, ) - _emit_json(pack) - - -@cli.command() -@click.argument("query") -@click.option("--depth", default=3, show_default=True, type=int) -@click.option("--max-chars", default=4000, show_default=True, type=int) -def synthesize(query: str, depth: int, max_chars: int) -> None: - """Answer a query from approved claims only, with inline citations.""" - store = _load_store() - with _cli_errors(): - result = synth.synthesize( - store, query=query, depth=depth, max_chars=max_chars, - ) - _emit_json(result) + _emit_json(pack.model_dump(mode="json")) @cli.command() @@ -2321,351 +509,20 @@ def index() -> None: click.echo(f"indexed: {stats}") -# --- provenance: why / trace / impact / graph ----------------------------- - - -@cli.command() -@click.argument("claim_id") -@click.option( - "--depth", default=3, show_default=True, type=int, help="How many hops of provenance to expand." -) -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a tree.") -def why(claim_id: str, depth: int, as_json: bool) -> None: - """Explain why a claim exists: cites, session, supersedes chain, approval. - - \b - Examples: - vouch why my-claim-id - vouch why my-claim-id --depth 5 --json - """ - store = _load_store() - with _cli_errors(): - result = prov_mod.why(store, claim_id=claim_id, depth=depth) - if as_json: - _emit_json(result) - return - _echo(prov_mod.render_why(result)) - - -@cli.command() -@click.argument("from_id") -@click.option("--to", "to_id", required=True, help="The artifact to trace a path to.") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def trace(from_id: str, to_id: str, as_json: bool) -> None: - """Find the shortest typed-edge path between two artifacts. - - Exits non-zero with `no path` when the two artifacts are disconnected. - """ - store = _load_store() - with _cli_errors(): - result = prov_mod.trace(store, from_id=from_id, to_id=to_id) - if as_json: - _emit_json(result) - else: - _echo(prov_mod.render_trace(result)) - if not result["found"]: - sys.exit(1) - - -@cli.command() -@click.argument("claim_id") -@click.option( - "--depth", default=1, show_default=True, type=int, help="How many hops of dependents to expand." -) -@click.option( - "--if", - "if_op", - default=None, - type=click.Choice([op.value for op in prov_mod.LifecycleOp]), - help="Dry-run a lifecycle op and report breakage (exit non-zero if any).", -) -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a tree.") -def impact(claim_id: str, depth: int, if_op: str | None, as_json: bool) -> None: - """Show what depends on a claim, and what breaks if you change it. - - \b - Examples: - vouch impact my-claim-id - vouch impact my-claim-id --if archive - """ - store = _load_store() - with _cli_errors(): - result = prov_mod.impact(store, claim_id=claim_id, depth=depth, op=if_op) - if as_json: - _emit_json(result) - else: - _echo(prov_mod.render_impact(result)) - if if_op is not None and result["blocking"]: - sys.exit(1) - - -@cli.command() -@click.option("--session", default=None, help="Restrict to one agent run's subgraph.") -@click.option( - "--format", - "fmt", - default="dot", - show_default=True, - type=click.Choice(["dot", "mermaid"]), - help="Output format for the DAG.", -) -def graph(session: str | None, fmt: str) -> None: - """Render the provenance DAG as Graphviz dot or a mermaid flowchart.""" - store = _load_store() - with _cli_errors(): - text = prov_mod.graph_export(store, session=session, fmt=fmt) - click.echo(text, nl=False) - - -@cli.group() -def provenance() -> None: - """Provenance graph cache operations.""" - - -@provenance.command("rebuild") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") -def provenance_rebuild(as_json: bool) -> None: - """Rebuild the prov_edges cache from durable files (treated as derived state).""" - store = _load_store() - with _cli_errors(): - count = prov_mod.rebuild_prov_edges(store) - if as_json: - _emit_json({"edges": count}) - return - click.echo(f"provenance: rebuilt {count} edges") - - -@cli.command() -@click.option("--threshold", default=0.95, show_default=True, type=float) -@click.option("--dry-run/--no-dry-run", default=False) -def dedup(threshold: float, dry_run: bool) -> None: - """Scan embeddings for cross-artifact near-duplicates.""" - from .embeddings.dedup import scan_all - - store = _load_store() - rows = scan_all(store.kb_dir, threshold=threshold, dry_run=dry_run) - if not rows: - click.echo("dedup: no duplicates found") - return - for r in rows: - click.echo(f"{r['kind']}/{r['id']} ~ {r['kind']}/{r['near_id']} cos={r['cosine']:.4f}") - - -@cli.group() -def embeddings() -> None: - """Embedding maintenance commands.""" - - -@embeddings.command("stats") -def embeddings_stats() -> None: - """Print model identity, per-kind counts, and cache hit rate.""" - from . import index_db - from .embeddings.cache import query_cache_stats - - store = _load_store() - meta = index_db.get_embedding_meta(store.kb_dir) - for k, v in sorted(meta.items()): - click.echo(f"{k}\t{v}") - with index_db.open_db(store.kb_dir) as conn: - rows = conn.execute("SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind").fetchall() - for k, n in rows: - click.echo(f"embedding_count_{k}\t{n}") - cs = query_cache_stats(store.kb_dir) - click.echo(f"query_cache_entries\t{cs['entries']}") - click.echo(f"query_cache_hits\t{cs['hits']}") - - -@cli.group(name="eval") -def eval_group() -> None: - """Evaluation harnesses.""" - - -@eval_group.command("embedding") -@click.option("--queries", required=True, type=click.Path(exists=True)) -@click.option("--metric", default="recall@10,mrr,ndcg") -def eval_embedding(queries: str, metric: str) -> None: - """Run retrieval-quality metrics over a JSONL query set.""" - from pathlib import Path as _Path - - from .embeddings.scorer import evaluate - - store = _load_store() - metrics = tuple(m.strip() for m in metric.split(",")) - canonical = tuple("recall@k" if m.startswith("recall@") else m for m in metrics) - import contextlib - - k = 10 - for m in metrics: - if m.startswith("recall@"): - with contextlib.suppress(ValueError): - k = int(m.split("@", 1)[1]) - out = evaluate( - kb_dir=store.kb_dir, - queries_file=_Path(queries), - k=k, - metrics=canonical, - ) - for m_name, v in out.items(): - click.echo(f"{m_name}\t{v:.4f}") - - -@eval_group.command("recall") -@click.argument("queries", type=click.Path(exists=True, dir_okay=False)) -@click.option("--k", default=5, show_default=True, type=int) -@click.option("--baseline", default=None, type=click.Path(exists=True, dir_okay=False), - help="Baseline report JSON; fail on a P@k regression beyond tolerance.") -@click.option("--max-regression", default=0.05, show_default=True, type=float) -def eval_recall(queries: str, k: int, baseline: str | None, - max_regression: float) -> None: - """Score kb.context retrieval against a labeled query set (P@k/R@k/MRR/nDCG).""" - from .eval.recall import compare_baseline, run_recall - store = _load_store() - with _cli_errors(): - report = run_recall(store, queries, k=k) - click.echo(json.dumps(report, indent=2)) - if baseline is not None: - base = json.loads(Path(baseline).read_text(encoding="utf-8")) - ok, message = compare_baseline(report, base, max_regression=max_regression) - click.echo(message, err=True) - if not ok: - raise click.ClickException(message) - - -@cli.command() -@click.option( - "--embeddings/--no-embeddings", - default=False, - help="Rebuild the embedding index in addition to FTS5.", -) -@click.option( - "--backfill/--no-backfill", - default=False, - help="Re-encode every artifact under the current model.", -) -@click.option("--force/--no-force", default=False, help="Re-encode even if content hash unchanged.") -@click.option("--model", default=None, help="Adapter name; defaults to the registered default.") -def reindex(embeddings: bool, backfill: bool, force: bool, model: str | None) -> None: - """Rebuild derived indexes from on-disk artifacts.""" - store = _load_store() - health.rebuild_index(store) - if embeddings or backfill: - from .embeddings.migration import backfill_embeddings - - if model: - from .embeddings import get_embedder - - get_embedder(model) - n = backfill_embeddings(store, force=force) - click.echo(f"reindex: embeddings backfilled = {n}") - else: - click.echo("reindex: FTS5 rebuilt") - - @cli.command() @click.option("--tail", default=20, show_default=True, type=int) @click.option("--json", "as_json", is_flag=True) -@click.option("--project", default=None, help="Viewer project for audit scope filtering.") -@click.option("--agent", default=None, help="Viewer agent for audit scope filtering.") -def audit(tail: int, as_json: bool, project: str | None, agent: str | None) -> None: +def audit(tail: int, as_json: bool) -> None: """Read the audit log.""" - from .scoping import viewer_from - store = _load_store() - viewer = viewer_from( - config_path=store.config_path, - project=project, - agent=agent, - ) - events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:] + events = list(audit_mod.read_events(store.kb_dir))[-tail:] if as_json: - _emit_json({ - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "events": [e.model_dump(mode="json") for e in events], - }) + _emit_json([e.model_dump(mode="json") for e in events]) return - if viewer.project or viewer.agent: - click.echo( - f"viewer: project={viewer.project!r} agent={viewer.agent!r}", - err=True, - ) for e in events: click.echo( - f"{e.created_at.isoformat()} {e.event:30s} by {e.actor} objects={e.object_ids}" - ) - - -# --- cross-session themes ------------------------------------------------- - - -@cli.command(name="detect-themes") -@click.option("--min-sessions", default=None, type=int, help="Minimum sessions for a cluster.") -@click.option("--min-claims", default=None, type=int, help="Minimum claims for a cluster.") -@click.option("--top-k", default=None, type=int, help="Max clusters to return.") -@click.option("--json", "as_json", is_flag=True, help="Emit JSON.") -@click.option("--propose", is_flag=True, help="Propose theme pages for each cluster.") -@click.option("--agent", default=None, help="Agent name for proposals.") -def detect_themes_cmd( - min_sessions: int | None, - min_claims: int | None, - top_k: int | None, - as_json: bool, - propose: bool, - agent: str | None, -) -> None: - """Detect recurring entity clusters across completed sessions.""" - from . import themes - - store = _load_store() - result = themes.detect_themes( - store, - min_sessions=min_sessions, - min_claims=min_claims, - top_k=top_k, - ) - if as_json and not propose: - _emit_json({ - "clusters": [ - { - "entities": c.entities, - "claim_ids": c.claim_ids, - "session_ids": c.session_ids, - "score": c.score, - "session_count": c.session_count, - "claim_count": c.claim_count, - } - for c in result.clusters - ], - "config": result.config_used, - }) - return - if not result.clusters: - click.echo("no themes detected") - return - if propose: - actor = agent or _whoami() - proposed: list[dict] = [] - for cluster in result.clusters: - try: - p = themes.propose_theme(store, cluster, proposed_by=actor) - proposed.append(p) - if not as_json: - click.echo( - f"proposed: {p['theme_page_id']} " - f"({p['claim_count']} claims, " - f"{p['session_count']} sessions)" - ) - except Exception as e: - click.echo( - f"skip: {', '.join(cluster.entities)} — {e}", - err=True, - ) - if as_json: - _emit_json({"proposed": proposed}) - return - for i, c in enumerate(result.clusters, 1): - click.echo( - f"{i}. {', '.join(c.entities)} " - f"score={c.score} sessions={c.session_count} claims={c.claim_count}" + f"{e.created_at.isoformat()} {e.event:30s} by {e.actor} " + f"objects={e.object_ids}" ) @@ -2678,13 +535,11 @@ def export(out_path: str) -> None: """Bundle the durable KB into a portable .tar.gz.""" store = _load_store() manifest = bundle.export(store.kb_dir, dest=Path(out_path), actor=_whoami()) - _emit_json( - { - "bundle_id": manifest["bundle_id"], - "files": len(manifest["files"]), - "out": out_path, - } - ) + _emit_json({ + "bundle_id": manifest["bundle_id"], + "files": len(manifest["files"]), + "out": out_path, + }) @cli.command("export-check") @@ -2692,14 +547,10 @@ def export(out_path: str) -> None: def export_check_cmd(bundle_path: str) -> None: """Verify every file in a bundle matches its manifest hash.""" r = bundle.export_check(Path(bundle_path)) - _emit_json( - { - "ok": r.ok, - "bundle_id": r.bundle_id, - "files_checked": r.files_checked, - "issues": r.issues, - } - ) + _emit_json({ + "ok": r.ok, "bundle_id": r.bundle_id, + "files_checked": r.files_checked, "issues": r.issues, + }) sys.exit(0 if r.ok else 1) @@ -2709,35 +560,24 @@ def import_check_cmd(bundle_path: str) -> None: """Diff a bundle against the destination KB without writing.""" store = _load_store() r = bundle.import_check(store.kb_dir, Path(bundle_path)) - _emit_json( - { - "ok": r.ok, - "bundle_id": r.bundle_id, - "new_files": r.new_files, - "conflicts": r.conflicts, - "identical_files": len(r.identical), - "issues": r.issues, - } - ) + _emit_json({ + "ok": r.ok, "bundle_id": r.bundle_id, + "new_files": r.new_files, "conflicts": r.conflicts, + "identical_files": len(r.identical), "issues": r.issues, + }) @cli.command("import-apply") @click.argument("bundle_path", type=click.Path(exists=True, dir_okay=False)) -@click.option( - "--on-conflict", - default="skip", - show_default=True, - type=click.Choice(["skip", "overwrite", "fail"]), -) +@click.option("--on-conflict", default="skip", show_default=True, + type=click.Choice(["skip", "overwrite", "fail"])) def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: """Apply a bundle. Default policy is skip — never destructive without explicit overwrite.""" store = _load_store() try: r = bundle.import_apply( - store.kb_dir, - Path(bundle_path), - on_conflict=on_conflict, - actor=_whoami(), + store.kb_dir, Path(bundle_path), + on_conflict=on_conflict, actor=_whoami(), ) except RuntimeError as e: raise click.ClickException(str(e)) from e @@ -2746,936 +586,20 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: _emit_json(r) -# --- auto-pr: open N mergeable PRs against any github repo ----------------- - - -@cli.command(name="auto-pr") -@click.argument("repo_url") -@click.option("--workspace", required=True, type=click.Path(), - help="directory holding (or to hold) the clone/fork.") -@click.option("--count", default=1, show_default=True, type=int, - help="how many PRs to attempt.") -@click.option("--claude-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--codex-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--issue-label", "issue_labels", multiple=True, - help="restrict the open-issue source to these labels (repeatable).") -@click.option("--fork-owner", default=None, - help="fork owner login (default: the authenticated gh user).") -@click.option("--max-revise", default=2, show_default=True, type=int, - help="max fixer<->verifier revise rounds per item.") -@click.option("--autonomy", default="edit", show_default=True, - type=click.Choice(["edit", "full"]), - help="'edit' auto-accepts file edits only (safer default); " - "'full' lets the fixer run arbitrary commands " - "(bypasses claude's permission prompts).") -@click.option("--dry-run", is_flag=True, - help="run every stage except git push / gh pr create.") -@click.option("--json", "as_json", is_flag=True) -def auto_pr_cmd(repo_url: str, workspace: str, count: int, claude_effort: str, - codex_effort: str, issue_labels: tuple[str, ...], - fork_owner: str | None, max_revise: int, autonomy: str, - dry_run: bool, as_json: bool) -> None: - """Open N mergeable PRs against REPO_URL, cross-verified by claude + codex. - - Sources open issues first (then agent-discovered improvements), bootstraps - a contribution skill from the repo's merged PRs when it ships no guidance, - and opens a PR only when the repo's own test gate is green and the - reviewing engine signs off. A sibling tool — it never writes to the KB. - """ - from . import auto_pr as ap_mod - try: - results = ap_mod.run_auto_pr( - repo_url, workspace, count, claude_effort, codex_effort, - labels=tuple(issue_labels), fork_owner=fork_owner, - max_revise=max_revise, autonomy=autonomy, dry_run=dry_run, - ) - except (ValueError, RuntimeError) as e: - # surface auto-pr failures as `Error: ...` like the rest of the cli, - # not a bare traceback. (auto_pr raises ValueError/RuntimeError, not the - # KB domain types that _cli_errors handles.) - raise click.ClickException(str(e)) from e - if as_json: - _emit_json([ - {"status": r.status, "url": r.url, "fixer": r.fixer, - "verifier": r.verifier, "title": r.item.title, - "reason": r.reason, "rounds": r.rounds} - for r in results - ]) - return - if not results: - click.echo(f"no work items found for {repo_url}", err=True) - return - opened = 0 - for r in results: - if r.status == "opened": - opened += 1 - click.echo(r.url or "(dry-run: would open)") - else: - click.echo(f"skipped: {r.item.title} — {r.reason}", err=True) - click.echo(f"opened {opened}/{len(results)} PRs", err=True) - - -# --- dual-solve: run claude + codex on one issue; operator picks a winner --- - - -@cli.command(name="dual-solve") -@click.argument("issue_url") -@click.option("--claude-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--codex-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--autonomy", default="edit", show_default=True, - type=click.Choice(["edit", "full"]), - help="'edit' auto-accepts file edits only (safer default); " - "'full' lets engines run arbitrary commands.") -@click.option("--reason", default=None, - help="why you picked the winner (skips the interactive prompt).") -@click.option("--no-record", is_flag=True, - help="keep the chosen branch but propose nothing to the kb.") -@click.option("--dry-run", is_flag=True, - help="run both engines but make no commits / kb writes.") -@click.option("--sandbox", is_flag=True, - help="Run claude/codex inside a Docker sandbox image instead of on the host.") -@click.option("--sandbox-image", default=None, - help="Docker image for --sandbox (default: vouch/coder:latest).") -@click.option("--json", "as_json", is_flag=True, - help="non-interactive: emit both diffs + metadata, no prompt.") -def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, - autonomy: str, reason: str | None, no_record: bool, - dry_run: bool, sandbox: bool, sandbox_image: str | None, - as_json: bool) -> None: - """Run claude + codex on ISSUE_URL; you pick the winning diff. - - Each engine works in its own git worktree on a fresh branch. You compare - the two diffs, keep one branch, and (unless --no-record) the rationale is - proposed into the kb for review. A sibling tool to auto-pr; the review - gate is untouched -- nothing is auto-approved. - """ - from . import dual_solve as ds_mod - from .auto_pr import SubprocessRunner - from .sandbox import DEFAULT_SANDBOX_IMAGE, DockerAgentRunner - store = _load_store() - base_runner = SubprocessRunner() - sandbox_image = sandbox_image or DEFAULT_SANDBOX_IMAGE - try: - if sandbox: - ds_mod._require_engines( - sandboxed=True, sandbox_image=sandbox_image, runner=base_runner) - else: - ds_mod._require_engines() - root = ds_mod.repo_root(base_runner, Path.cwd()) - runner = ( - DockerAgentRunner(repo_root=root, runner=base_runner, image=sandbox_image) - if sandbox else base_runner - ) - issue, candidates, engines = ds_mod.prepare( - store, issue_url, root, runner, - claude_effort=claude_effort, codex_effort=codex_effort, - autonomy=autonomy, dry_run=dry_run, - on_progress=lambda m: click.echo(m, err=True), - ) - except (ValueError, RuntimeError) as e: - raise click.ClickException(str(e)) from e - - if as_json: - _emit_json({ - "issue": {"number": issue.number, "title": issue.title, - "url": issue.url}, - "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 - ], - }) - return - - for c in candidates: - click.echo(f"\n=== {c.engine} ({c.branch}) ===", err=True) - if c.ok: - click.echo(c.diff) - else: - click.echo(f"(failed: {c.error})", err=True) - - ok = [c for c in candidates if c.ok] - if not ok: - raise click.ClickException("both engines failed; nothing to choose") - choice: str | None - if len(ok) == 1: - survivor = ok[0] - if not click.confirm( - f"only {survivor.engine} produced a usable diff; proceed with it?", - default=True): - ds_mod.finalize(store, root, issue, None, engines, candidates, "", - runner, record=False, proposed_by=_whoami()) - raise click.ClickException("aborted; both branches discarded") - choice = survivor.engine - else: - letter = click.prompt("pick a winner [c]laude / [x]codex / [n]either", - type=click.Choice(["c", "x", "n"]), default="c") - choice = {"c": "claude", "x": "codex", "n": None}[letter] - - chosen = next((c for c in candidates if c.engine == choice), None) - if reason is None and chosen is not None and not no_record and not dry_run: - reason = click.prompt("one line: why this solution", default="") - - try: - ids = ds_mod.finalize( - store, root, issue, chosen, engines, candidates, reason or "", runner, - record=not no_record and not dry_run, proposed_by=_whoami(), - ) - except (ValueError, RuntimeError) as e: - raise click.ClickException(f"failed to record/clean up: {e}") from e - if chosen is None: - click.echo("kept neither; both branches discarded", err=True) - return - click.echo(f"kept {chosen.branch}", err=True) - for pid in ids: - click.echo(f"proposed {pid} -- review with `vouch approve {pid}`", err=True) - - -# --- sync ------------------------------------------------------------------ - - -@cli.command("sync-check") -@click.argument("source_path", type=click.Path(exists=True)) -def sync_check_cmd(source_path: str) -> None: - """Compare another .vouch directory or bundle without writing.""" - store = _load_store() - try: - r = sync_mod.sync_check(store.kb_dir, Path(source_path)) - except RuntimeError as e: - raise click.ClickException(str(e)) from e - _emit_json(asdict(r)) - - -@cli.command("sync-apply") -@click.argument("source_path", type=click.Path(exists=True)) -@click.option( - "--on-conflict", - default="fail", - show_default=True, - type=click.Choice(["fail", "skip", "propose"]), -) -def sync_apply_cmd(source_path: str, on_conflict: str) -> None: - """Apply non-conflicting files from another .vouch directory or bundle.""" - store = _load_store() - try: - r = sync_mod.sync_apply( - store.kb_dir, - Path(source_path), - on_conflict=on_conflict, - actor=_whoami(), - ) - except (RuntimeError, ValueError) as e: - raise click.ClickException(str(e)) from e - health.rebuild_index(store) - _emit_json(r) - - -# --- diff ----------------------------------------------------------------- - - -@cli.command() -@click.argument("old_id") -@click.argument("new_id") -@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.""" - from .diff import diff_artifacts - - store = _load_store() - with _cli_errors(): - d = diff_artifacts(store, old_id, new_id) - if as_json: - _emit_json(asdict(d)) - return - if not d.changes and not d.text_diff: - click.echo("no differences") - return - click.echo(f"diff {d.kind} {d.old_id} → {d.new_id}") - for c in d.changes: - click.echo(f" {c.field}: {c.old} → {c.new}") - if d.text_diff: - label = "body" if d.kind == "page" else "text" - click.echo(f" {label}:") - for line in d.text_diff: - click.echo(f" {line}") - - # --- serve ---------------------------------------------------------------- @cli.command() -@click.option( - "--transport", default="stdio", show_default=True, type=click.Choice(["stdio", "jsonl", "http"]) -) -@click.option( - "--host", default="127.0.0.1", show_default=True, help="HTTP bind host (transport=http)." -) -@click.option( - "--port", default=None, type=int, help="HTTP bind port (transport=http; default 8731)." -) -@click.option( - "--token", - default=None, - envvar="VOUCH_HTTP_TOKEN", - help="Bearer token for HTTP /rpc + /mcp (or env VOUCH_HTTP_TOKEN). " - "Combine with --config for a multi-token accept-list. " - "Required to bind a non-loopback host.", -) -@click.option( - "--config", - "config_path", - default=None, - type=click.Path(dir_okay=False), - help="Path to a config.yaml with a `serve:` section " - "(default: .vouch/config.yaml if present, then ./config.yaml). " - "Supplies `bearer_tokens:` (list) or `bearer_token: env:VAR`.", -) -@click.option( - "--allow-public", - is_flag=True, - help="Permit binding a non-loopback host (requires at least one token).", -) -def serve( - transport: str, - host: str, - port: int | None, - token: str | None, - config_path: str | None, - allow_public: bool, -) -> None: - """Run the MCP server (stdio), the JSONL tool server, or the HTTP server. - - HTTP transport surfaces three protocols against the same kb.* surface: - - \b - POST /mcp MCP-over-Streamable-HTTP (Claude.ai Custom Connector, - Claude mobile, Managed Agents, Messages-API - mcp_servers, Computer Use) - POST /messages alias for /mcp (older Claude surfaces) - POST /rpc vouch-native JSONL envelope (legacy clients) - - GET /health, /healthz, and /capabilities are always unauthenticated. - """ - _load_store() # fail fast with a clear message if no .vouch/ KB is present - +@click.option("--transport", default="stdio", show_default=True, + type=click.Choice(["stdio", "jsonl"])) +def serve(transport: str) -> None: + """Run the MCP server (stdio) or the JSONL tool server.""" if transport == "stdio": from .server import run_stdio - run_stdio() - return - if transport == "jsonl": + else: from .jsonl_server import run_jsonl - run_jsonl() - return - - from .http_server import DEFAULT_PORT, ServeConfigError, load_serve_config, run_http - - bind_port = port if port is not None else DEFAULT_PORT - - # Locate config.yaml: explicit --config wins, else look for project-local - # .vouch/config.yaml, then ./config.yaml. Missing file is fine — the CLI - # is fully usable with --token alone. - cfg_candidates: list[Path] - if config_path: - cfg_candidates = [Path(config_path)] - else: - cfg_candidates = [Path(".vouch/config.yaml"), Path("config.yaml")] - tokens: list[str] = [] - for cand in cfg_candidates: - if cand.exists(): - try: - serve_cfg = load_serve_config(cand) - except ServeConfigError as e: - raise click.ClickException(f"serve config {cand}: {e}") from e - tokens = list(serve_cfg.tokens) - break - - try: - run_http(host, bind_port, token=token, tokens=tokens, allow_public=allow_public) - except RuntimeError as e: - # e.g. the non-loopback bind guard — show a clean Error: line. - raise click.ClickException(str(e)) from e - - -# --- pr-cache: dedup PR raises against a target repo ---------------------- - - -@cli.group(name="pr-cache") -def pr_cache_group() -> None: - """Cache a target repo's merged/closed PRs to prevent duplicate PR raises. - - Workflow: - - \b - vouch pr-cache build https://github.com/owner/repo --analyze-closed - vouch pr-cache check owner/repo --topic "fix doc preview accessible" - vouch pr-cache show owner/repo --state closed --json - """ - - -@pr_cache_group.command("build") -@click.argument("repo") -@click.option( - "--state", - type=click.Choice(["merged", "closed", "all"]), - default="all", - show_default=True, - help="Which PR states to fetch.", -) -@click.option( - "--limit", type=int, default=200, show_default=True, help="Max PRs per state to fetch from gh." -) -@click.option( - "--analyze-closed", - is_flag=True, - help="Run Claude/Anthropic to summarise WHY each closed-not-merged " - "PR was closed (uses local `claude` CLI if present, else " - "ANTHROPIC_API_KEY). Skipped silently when neither is set.", -) -@click.option( - "--reanalyze", - is_flag=True, - help="Re-run close-reason analysis even if a previous result is cached.", -) -@click.option( - "--analyzer", - type=click.Choice(["auto", "claude-cli", "anthropic-api", "none"]), - default="auto", - show_default=True, - help="Which close-reason analyzer to prefer.", -) -@click.option( - "--no-fetch-files", - is_flag=True, - help="Skip per-PR file-list fetch (faster, but dedup by file overlap stops working).", -) -@click.option( - "--cache-dir", - default=None, - type=click.Path(file_okay=False), - help="Override cache directory (also env VOUCH_PR_CACHE_DIR).", -) -def pr_cache_build( - repo: str, - state: str, - limit: int, - analyze_closed: bool, - reanalyze: bool, - analyzer: str, - no_fetch_files: bool, - cache_dir: str | None, -) -> None: - """Fetch merged/closed PRs for REPO and upsert into the local cache.""" - with _cli_errors(): - ref = prc_mod.parse_repo(repo) - try: - result = prc_mod.build( - ref, - state=state, - limit=limit, - analyze_closed=analyze_closed, - reanalyze=reanalyze, - analyzer=analyzer, - cache_dir=Path(cache_dir) if cache_dir else None, - fetch_files=not no_fetch_files, - ) - except prc_mod.GHError as e: - raise click.ClickException(str(e)) from e - _emit_json( - { - "repo": ref.slug, - "fetched": result.fetched, - "new": result.new, - "updated": result.updated, - "analyzed": result.analyzed, - "skipped_analysis": result.skipped_analysis, - "cache_path": str(result.path), - } - ) - - -@pr_cache_group.command("check") -@click.argument("repo") -@click.option( - "--topic", - required=True, - help="Short description of the PR you're about to raise (title-like text).", -) -@click.option( - "--files", - default="", - help="Comma-separated list of paths the planned PR would touch (boosts dedup precision).", -) -@click.option( - "--min-score", - default=0.15, - show_default=True, - type=float, - help="Minimum similarity (0..1) for a cached PR to count as a duplicate signal.", -) -@click.option("--top-k", default=5, show_default=True, type=int) -@click.option( - "--cache-dir", - default=None, - type=click.Path(file_okay=False), - help="Override cache directory (also env VOUCH_PR_CACHE_DIR).", -) -def pr_cache_check( - repo: str, topic: str, files: str, min_score: float, top_k: int, cache_dir: str | None -) -> None: - """Look up cached PRs similar to TOPIC; warns of likely-duplicate raises.""" - with _cli_errors(): - ref = prc_mod.parse_repo(repo) - path = prc_mod.cache_path_for(ref, Path(cache_dir) if cache_dir else None) - cache = prc_mod.load_cache(path) - file_list = [f.strip() for f in files.split(",") if f.strip()] - cands = prc_mod.check_duplicates( - cache, - topic=topic, - files=file_list, - min_score=min_score, - top_k=top_k, - ) - _emit_json( - { - "repo": ref.slug, - "cache_path": str(path), - "cache_size": len(cache), - "topic": topic, - "files": file_list, - "candidates": [c.as_json() for c in cands], - # 0.7 (= 70 % of topic tokens contained in a cached PR's title+body) - # is the threshold for "almost certainly the same idea." Below that, - # surface as a soft signal the caller should eyeball before raising. - "verdict": "likely_duplicate" - if any(c.score >= 0.70 for c in cands) - else "review_candidates" - if cands - else "no_match", - } - ) - - -@pr_cache_group.command("show") -@click.argument("repo") -@click.option( - "--state", type=click.Choice(["merged", "closed", "all"]), default="all", show_default=True -) -@click.option("--limit", type=int, default=50, show_default=True) -@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.") -@click.option( - "--cache-dir", - default=None, - type=click.Path(file_okay=False), - help="Override cache directory (also env VOUCH_PR_CACHE_DIR).", -) -def pr_cache_show(repo: str, state: str, limit: int, as_json: bool, cache_dir: str | None) -> None: - """List the cached PRs for REPO.""" - with _cli_errors(): - ref = prc_mod.parse_repo(repo) - path = prc_mod.cache_path_for(ref, Path(cache_dir) if cache_dir else None) - cache = prc_mod.load_cache(path) - records = sorted(cache.values(), key=lambda r: r.number, reverse=True) - if state != "all": - records = [r for r in records if r.state == state] - records = records[:limit] - if as_json: - _emit_json( - { - "repo": ref.slug, - "cache_path": str(path), - "count": len(records), - "prs": [ - { - "number": r.number, - "state": r.state, - "title": r.title, - "url": r.url, - "merged_at": r.merged_at, - "closed_at": r.closed_at, - "files": r.files, - "labels": r.labels, - "close_analysis": (asdict(r.close_analysis) if r.close_analysis else None), - } - for r in records - ], - } - ) - return - if not records: - click.echo(f"no cached PRs for {ref.slug} in {path}") - return - click.echo(f"{ref.slug} ({len(records)} PRs from {path})") - for r in records: - when = r.merged_at or r.closed_at or "" - marker = "✓" if r.state == "merged" else "✗" - click.echo(f" {marker} #{r.number:<6} {r.state:<7} {when[:10]:<10} {r.title}") - if r.close_analysis and r.close_analysis.reason: - click.echo(f" reason: {r.close_analysis.reason}") - for nrep in r.close_analysis.do_not_repeat[:3]: - click.echo(f" ✗ avoid: {nrep}") - - -# --- install-mcp: drop the right adapter files into a project tree -------- - - -@cli.command(name="install-mcp", context_settings={"ignore_unknown_options": False}) -@click.argument("host", required=False) -@click.option("--list", "list_hosts", is_flag=True, help="List available hosts and exit.") -@click.option( - "--path", - default=".", - show_default=True, - type=click.Path(file_okay=False), - help="Target project root.", -) -@click.option( - "--target", - "target_alias", - default=None, - type=click.Path(file_okay=False), - help="Alias for --path (per issue #179 spec).", -) -@click.option( - "--tier", - default="T4", - show_default=True, - type=click.Choice(["T1", "T2", "T3", "T4"]), - help="Adoption tier: T1 = MCP wire only, " - "T2 = +CLAUDE.md/AGENTS.md, T3 = +slash commands, " - "T4 = +host hooks/settings. Tiers stack.", -) -def install_mcp( - host: str | None, list_hosts: bool, path: str, target_alias: str | None, tier: str -) -> None: - """Install vouch into HOST (claude-code, cursor, …) idempotently. - - \b - Examples: - vouch install-mcp --list # show known hosts - vouch install-mcp claude-code # write T1..T4 into cwd - vouch install-mcp cursor --tier T2 # stop at AGENTS.md - vouch install-mcp claude-desktop # drop a paste-ready config - vouch install-mcp windsurf --path /abs/path/to/project - """ - hosts = install_mod.available_adapters() - if list_hosts: - if not hosts: - click.echo("(no adapters installed alongside this vouch install)") - return - click.echo("Available MCP host adapters:") - for h in hosts: - click.echo(f" - {h}") - click.echo("") - click.echo("Install one with: vouch install-mcp [--tier T1|T2|T3|T4]") - return - - if host is None: - raise click.ClickException( - "missing HOST; run `vouch install-mcp --list` to see the catalogue" - ) - - target = Path(target_alias or path).resolve() - try: - result = install_mod.install(host, target=target, tier=tier) - except install_mod.AdapterError as e: - raise click.ClickException(str(e)) from e - - for f in result.written: - click.echo(f" + {f}") - for f in result.appended: - click.echo(f" ~ {f} (appended fenced block)") - for f in result.merged: - click.echo(f" ~ {f} (merged into existing)") - for f in result.skipped: - click.echo(f" · {f} (already present)") - click.echo( - f"Done — {len(result.written)} written, " - f"{len(result.appended)} appended, {len(result.merged)} merged, " - f"{len(result.skipped)} skipped " - f"under {target}" - ) - - -# --- sync: bidirectional vouch <-> Obsidian-style vault ------------------- - - -@cli.command(name="sync") -@click.option( - "--vault", - "vault_dir", - required=True, - type=click.Path(file_okay=False), - help="Path to an Obsidian-style markdown vault. Mirroring happens under /vouch/.", -) -@click.option( - "--direction", - default="both", - show_default=True, - type=click.Choice(["both", "forward", "backward"]), - help="forward = vault→KB (file page-edit proposals), " - "backward = KB→vault (mirror approved pages + claim stubs).", -) -@click.option( - "--actor", - default="vault-sync", - show_default=True, - help="Proposer name recorded on every page-edit proposal.", -) -@click.option( - "--watch", is_flag=True, help="Stay alive and re-sync the vault every --poll seconds." -) -@click.option( - "--poll", - default=2.0, - show_default=True, - type=float, - help="Polling interval (seconds) when --watch is set.", -) -def sync_cmd(vault_dir: str, direction: str, actor: str, watch: bool, poll: float) -> None: - """Sync the KB with an Obsidian-compatible markdown vault (VEP-style #181). - - \b - Forward (vault→KB): - Edits to /vouch/pages/.md become page-edit proposals - in .vouch/proposed/, citing a vault: source so the review - gate can see exactly which bytes triggered the proposal. - - \b - Backward (KB→vault): - Approved pages mirror into /vouch/pages/. Approved claims - get a markdown stub under /vouch/claims/ with Obsidian - wikilink backlinks to citing pages so the graph view connects them. - - \b - Re-runs are idempotent — only real edits become proposals, the rest is - a no-op. Add --watch to keep a polling loop alive while you edit. - """ - store = _load_store() - vault_path = Path(vault_dir).resolve() - try: - if watch: - click.echo( - f"Watching {vault_path} every {poll}s " - f"(direction={direction}, actor={actor}); Ctrl-C to stop." - ) - ticks = vault_sync_mod.watch_vault( - store, - vault_path, - direction=direction, - actor=actor, - poll_interval=poll, - ) - click.echo(f"Stopped after {ticks} tick(s).") - return - result = vault_sync_mod.sync_vault( - store, - vault_path, - direction=direction, - actor=actor, - ) - except vault_sync_mod.VaultSyncError as e: - raise click.ClickException(str(e)) from e - - for pid in result.pages_mirrored: - click.echo(f" ↓ pages/{pid}.md (mirrored)") - for cid in result.claims_mirrored: - click.echo(f" ↓ claims/{cid}.md (mirrored)") - for pid in result.pages_proposed: - click.echo(f" ↑ pages/{pid} (proposal filed)") - for rel in result.pages_skipped_unchanged: - click.echo(f" · {rel} (unchanged)") - for rel in result.pages_skipped_unknown_id: - click.echo(f" ! {rel} (skipped — could not parse page id)") - click.echo( - f"Done — {len(result.pages_mirrored)} pages and " - f"{len(result.claims_mirrored)} claims mirrored, " - f"{len(result.pages_proposed)} proposals filed." - ) - - -# --- review-ui: browser-based review console ----------------------------- - - -_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", ""}) - - -def _resolve_auth_token(auth: str | None) -> str | None: - """Turn the ``--auth`` option into a concrete token (or ``None``). - - * ``None`` -> no auth (only allowed on a loopback bind). - * ``"generate"`` -> mint a random token and print it once. - * ``"env"`` -> read ``VOUCH_REVIEW_TOKEN`` from the environment. - * any other string -> use it verbatim as the bearer token. - """ - if auth is None: - return None - if auth == "generate": - import secrets - - token = secrets.token_urlsafe(24) - click.echo(f"Generated review token: {token}") - return token - if auth == "env": - env_token = os.environ.get("VOUCH_REVIEW_TOKEN") - if not env_token: - raise click.ClickException( - "--auth env: VOUCH_REVIEW_TOKEN is not set in the environment" - ) - return env_token - return auth - - -@cli.command(name="review-ui") -@click.option( - "--bind", - "bind", - default="127.0.0.1:7780", - show_default=True, - help="host:port to bind. A non-loopback host (e.g. 0.0.0.0) " - "requires --auth so the approve surface isn't exposed " - "unauthenticated.", -) -@click.option( - "--auth", - default=None, - help="Bearer-token mode: a literal token, 'generate' (mint a " - "random one and print it), or 'env' (read " - "VOUCH_REVIEW_TOKEN). Required for non-loopback binds.", -) -@click.option( - "--reviewer", - "reviewer", - default="web-reviewer", - show_default=True, - help="Identity recorded in the audit log for token-authed approve/reject decisions.", -) -@click.option( - "--page-size", default=None, type=int, help="Queue page size (server-side pagination)." -) -@click.option( - "--kb", - "kb_root", - default=None, - type=click.Path(exists=True, file_okay=False), - help="KB root (defaults to the nearest .vouch/ above cwd).", -) -@click.option( - "--open-browser/--no-open-browser", - default=True, - show_default=True, - help="Open the browser to the queue on startup.", -) -@click.option( - "--allow-dual-solve", - is_flag=True, - help="Mount the dual-solve runner SPA (spawns claude+codex; edit-only). " - "Off by default; the server must run inside the target git repo.", -) -@click.option( - "--dual-solve-sandbox", - is_flag=True, - help="Run dual-solve claude/codex invocations inside a Docker sandbox image.", -) -@click.option( - "--dual-solve-sandbox-image", - default=None, - help="Docker image for --dual-solve-sandbox (default: vouch/coder:latest).", -) -def review_ui( - bind: str, - auth: str | None, - reviewer: str, - page_size: int | None, - kb_root: str | None, - open_browser: bool, - allow_dual_solve: bool, - dual_solve_sandbox: bool, - dual_solve_sandbox_image: str | None, -) -> None: - """Run the browser-based review console (issue #194). - - \b - Examples: - vouch review-ui # 127.0.0.1:7780, open browser - vouch review-ui --bind 127.0.0.1:8000 - vouch review-ui --no-open-browser # ssh / headless friendly - vouch review-ui --bind 0.0.0.0:7780 --auth generate # team mode - VOUCH_REVIEW_TOKEN=… vouch review-ui --bind 0.0.0.0:7780 --auth env - """ - if ":" not in bind: - raise click.ClickException(f"--bind must be host:port (got {bind!r})") - host, _, port_str = bind.rpartition(":") - try: - port = int(port_str) - except ValueError as e: - raise click.ClickException(f"invalid port in --bind: {port_str!r}") from e - - token = _resolve_auth_token(auth) - - # Refuse a non-loopback bind without a bearer token — exposing an - # unauthenticated approve surface on the network would let anyone on the - # LAN mutate the KB. Same posture as the HTTP transport (#1). - is_loopback = host in _LOOPBACK_HOSTS - if not is_loopback and token is None: - raise click.ClickException( - f"--bind {bind!r} is non-loopback; pass --auth (a token, " - "'generate', or 'env') so the approve surface requires a " - "Bearer token. Refusing to expose an unauthenticated gate." - ) - - try: - from . import web as web_pkg - except ImportError as e: - raise click.ClickException(str(e)) from e - - try: - app = web_pkg.create_app( - kb_root, auth_token=token, auth_label=reviewer, page_size=page_size, - allow_dual_solve=allow_dual_solve, - dual_solve_sandbox=dual_solve_sandbox, - dual_solve_sandbox_image=dual_solve_sandbox_image, - ) - except (FileNotFoundError, RuntimeError) as e: - raise click.ClickException(str(e)) from e - - try: - import uvicorn - except ImportError as e: - raise click.ClickException( - "vouch review-ui needs the [web] extra. Install with: pip install 'vouch-kb[web]'" - ) from e - - auth_note = " (Bearer auth on)" if token else "" - if open_browser and is_loopback: - # Lazy-import webbrowser; some CI envs (headless) don't have a default - # browser configured and webbrowser.open(encoding="utf-8") returns False rather than - # raising — that's fine, the URL is also printed to stdout. When auth - # is on, hand the browser the token once via ?token= so it can stash it. - import threading - import webbrowser - - suffix = f"?token={token}" if token else "" - url = f"http://{host}:{port}/{suffix}" - click.echo(f"vouch review-ui running at http://{host}:{port}/{auth_note}") - threading.Timer(0.5, lambda: webbrowser.open(url)).start() - else: - click.echo(f"vouch review-ui running at http://{host}:{port}/{auth_note}") - - uvicorn.run(app, host=host, port=port, log_level="info") - - -@cli.command("openclaw-rpc") -def openclaw_rpc() -> None: - """OpenClaw bridge: one JSON envelope on stdin, one on stdout (context engine).""" - from .openclaw import rpc as openclaw_rpc_mod - - raise SystemExit(openclaw_rpc_mod.run_stdio()) if __name__ == "__main__": diff --git a/src/vouch/codex_rollout.py b/src/vouch/codex_rollout.py new file mode 100644 index 00000000..b23d409a --- /dev/null +++ b/src/vouch/codex_rollout.py @@ -0,0 +1,475 @@ +"""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 audit, capture +from .models import Proposal, ProposalStatus +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) -> 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 + 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, + *, + 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: 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) + 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 and existing.status != ProposalStatus.PENDING: + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": existing.id, + "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, + ) + 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=resolved_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, + } + + +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/context.py b/src/vouch/context.py index 6e9b08ce..16ddde2a 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -14,187 +14,38 @@ from __future__ import annotations import sqlite3 -from typing import Any, Literal, cast +from typing import Literal, cast -import yaml - -from . import graph, index_db -from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality -from .scoping import ( - ViewerContext, - filter_hits, - scoped_fetch_limit, - viewer_from, -) +from . import index_db +from .models import ContextItem, ContextPack, ContextQuality from .storage import ArtifactNotFoundError, KBStore -# Claim statuses that have been explicitly retracted from active circulation. -# Any retrieval surface that hands knowledge back to an agent must exclude -# these — otherwise the archive/supersede/redact controls are decorative. -# CONTESTED is intentionally not in this set: contested claims are still -# part of the conversation, just disputed; lint / context callers can -# decide what to do with them. -_RETRACTED_CLAIM_STATUSES = frozenset({ - ClaimStatus.ARCHIVED, - ClaimStatus.SUPERSEDED, - ClaimStatus.REDACTED, -}) - ContextItemKind = Literal["claim", "page", "entity", "relation", "source"] -_VALID_BACKENDS = ("auto", "embedding", "fts5", "substring") - - -def _configured_backend(store: KBStore) -> str: - """Resolve the retrieval backend from `config.yaml`, defaulting to "auto". - Reads the singular `retrieval.backend` string. For KBs initialised - before this knob existed, a legacy `retrieval.backends` list is honoured - by taking its first recognised entry. Anything unreadable or unrecognised - falls back to "auto". - """ +def _retrieve(store: KBStore, query: str, limit: int + ) -> list[tuple[str, str, str, float, str]]: + """Return list of (kind, id, summary, score, backend).""" try: - loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError): - return "auto" - if not isinstance(loaded, dict): - return "auto" - retrieval = loaded.get("retrieval") - if not isinstance(retrieval, dict): - return "auto" - backend = retrieval.get("backend") - if isinstance(backend, str) and backend in _VALID_BACKENDS: - return backend - legacy = retrieval.get("backends") - if isinstance(legacy, list): - for entry in legacy: - if isinstance(entry, str) and entry in _VALID_BACKENDS: - return entry - return "auto" - - -def _retrieve( - store: KBStore, - query: str, - limit: int, - viewer: ViewerContext, -) -> list[tuple[str, str, str, float, str]]: - """Return list of (kind, id, summary, score, backend). - - The backend is chosen by `retrieval.backend` in config.yaml: - - "auto" (default): embedding -> FTS5 -> substring - - "embedding": semantic search only - - "fts5": lexical FTS5 only - - "substring": substring scan only - """ - backend = _configured_backend(store) - fetch_limit = scoped_fetch_limit(limit, viewer) - - if backend in ("auto", "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 [] - - if backend in ("auto", "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 [] - - substring_hits = store.search_substring(query, limit=fetch_limit) - filtered = filter_hits(store, substring_hits, viewer, limit=limit) + hits = index_db.search(store.kb_dir, query, limit=limit) + if hits: + return [(k, i, s, sc, "fts5") for k, i, s, sc in hits] + except sqlite3.Error: + # FTS5 unavailable, db missing, or schema mismatch — fall through + # to substring scan. Other exceptions are real bugs and propagate. + pass return [ (k, i, s, sc, "substring") - for k, i, s, sc in filtered + for k, i, s, sc in store.search_substring(query, limit=limit) ] -def _enrich_summary(store: KBStore, kind: str, artifact_id: str, summary: str) -> str: - """Return a non-empty summary, falling back to the stored artifact text.""" - if summary: - return summary +def _citations_for_claim(store: KBStore, claim_id: str) -> list[str]: try: - if kind == "claim": - return store.get_claim(artifact_id).text - if kind == "page": - p = store.get_page(artifact_id) - return p.title or p.body[:200] - if kind == "entity": - e = store.get_entity(artifact_id) - return e.name or (e.description or "")[:200] - except Exception: - pass - return summary - - -def _append_graph_neighbors( - store: KBStore, - items: list[ContextItem], - *, - depth: int, - limit: int, - rel_types: list[str] | None, -) -> list[str]: - """Expand `items` with 1-hop (or deeper) graph neighbors. Returns warnings.""" - warnings: list[str] = [] - if not items: - return warnings - seed_scores = {it.id: it.score for it in items} - neighbors = graph.graph_neighbors_for_seeds( - store, - [it.id for it in items], - depth=depth, - rel_types=rel_types, - max_nodes=limit, - ) - existing = {it.id for it in items} - added = 0 - for node in neighbors: - nid = node["id"] - if nid in existing: - continue - kind = node["kind"] - cites: list[str] = [] - if kind == "claim": - try: - claim = store.get_claim(nid) - except ArtifactNotFoundError: - continue - if claim.status in _RETRACTED_CLAIM_STATUSES: - continue - cites = list(claim.evidence) - via = node.get("via", "") - parent_score = seed_scores.get(via, 0.5) - distance = int(node.get("distance", 1)) - score = parent_score * (0.8 ** distance) - summary = node.get("summary") or _enrich_summary(store, kind, nid, "") - items.append( - ContextItem( - id=nid, - type=cast(ContextItemKind, kind), - summary=summary, - score=score, - backend="graph", - citations=cites, - freshness="unknown", - ) - ) - existing.add(nid) - added += 1 - if added: - warnings.append(f"graph expansion added {added} neighbor(s)") - return warnings + claim = store.get_claim(claim_id) + except ArtifactNotFoundError: + return [] + return list(claim.evidence) def build_context_pack( @@ -207,37 +58,13 @@ def build_context_pack( require_citations: bool = False, fail_on_warnings: bool = False, fail_on_budget_truncation: bool = False, - explain: bool = False, - project: str | None = None, - agent: str | None = None, - expand_graph: bool = False, - graph_depth: int = 1, - graph_limit: int = 20, - graph_rel_types: list[str] | None = None, -) -> ContextPack | dict[str, Any]: - viewer = viewer_from( - config_path=store.config_path, - project=project, - agent=agent, - ) - hits = _retrieve(store, query, limit, viewer) +) -> ContextPack: + hits = _retrieve(store, query, limit) items: list[ContextItem] = [] for kind, hid, summary, score, backend in hits: cites: list[str] = [] if kind == "claim": - # Exclude retracted claims even if the underlying index still - # matches them (the FTS5 row's status column can lag — see #78 - # and the companion update_claim reindex). A missing claim is - # also treated as retracted: the YAML may have been deleted - # while the index row survived. - try: - claim = store.get_claim(hid) - except ArtifactNotFoundError: - continue - if claim.status in _RETRACTED_CLAIM_STATUSES: - continue - cites = list(claim.evidence) - summary = _enrich_summary(store, kind, hid, summary) + cites = _citations_for_claim(store, hid) items.append( ContextItem( id=hid, type=cast(ContextItemKind, kind), summary=summary, score=score, @@ -247,13 +74,6 @@ def build_context_pack( ) warnings: list[str] = [] - if expand_graph: - warnings.extend( - _append_graph_neighbors( - store, items, depth=graph_depth, limit=graph_limit, - rel_types=graph_rel_types, - ) - ) failed: list[str] = [] uncited: list[str] = [] budget_truncated = False @@ -304,17 +124,4 @@ def build_context_pack( failed=failed, ) - pack = ContextPack(query=query, items=items, quality=quality, warnings=warnings) - result: dict[str, Any] = pack.model_dump() - result["viewer"] = { - "project": viewer.project, - "agent": viewer.agent, - } - # Determine the backend used (all hits share the same backend in _retrieve). - result["backend"] = hits[0][4] if hits else "none" - if explain: - result["explain"] = [ - {"kind": k, "id": i, "score": sc, "backend": hits[0][4] if hits else "none"} - for k, i, _sn, sc, _be in hits - ] - return result + return ContextPack(query=query, items=items, quality=quality, warnings=warnings) 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/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/embeddings/base.py b/src/vouch/embeddings/base.py index 0c39f70e..c5af06ed 100644 --- a/src/vouch/embeddings/base.py +++ b/src/vouch/embeddings/base.py @@ -12,10 +12,9 @@ import hashlib from abc import ABC, abstractmethod from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar -if TYPE_CHECKING: - import numpy as np +import numpy as np DEFAULT_MODEL_NAME = "sentence-transformers/all-mpnet-base-v2" @@ -33,7 +32,6 @@ def encode(self, text: str) -> np.ndarray: def encode_batch(self, texts: Sequence[str]) -> np.ndarray: """Default batched encode -- subclasses override for true batching.""" - import numpy as np if not texts: return np.zeros((0, self.dim), dtype=np.float32) return np.stack([self.encode(t) for t in texts]) diff --git a/src/vouch/health.py b/src/vouch/health.py index 44861b0e..0bc684cb 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -7,18 +7,14 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any - -from pydantic import ValidationError from . import index_db -from .audit import count_events, verify_chain -from .models import Claim, ClaimStatus, Entity, Page, ProposalKind, ProposalStatus, Source -from .storage import KBStore, _yaml_load, sha256_hex +from .audit import count_events +from .models import ClaimStatus, ProposalStatus +from .storage import KBStore, sha256_hex from .verify import verify_all @@ -34,15 +30,10 @@ class Finding: class HealthReport: ok: bool findings: list[Finding] = field(default_factory=list) - # Mixed value types (str/int/bool) — `claims` etc. are ints, - # `kb_dir` is a str, `index_present` is a bool. Was `dict[str, int]` - # but `status()` already returned the mixed dict via an untyped - # `dict` return annotation; the narrow type was effectively never - # checked. Widened to match runtime reality. - counts: dict[str, Any] = field(default_factory=dict) + counts: dict[str, int] = field(default_factory=dict) -def status(store: KBStore) -> dict[str, Any]: +def status(store: KBStore) -> dict: """Quick, machine-readable summary. No deep checks.""" return { "kb_dir": str(store.kb_dir), @@ -59,600 +50,124 @@ def status(store: KBStore) -> dict[str, Any]: } -def _safe_counts(store: KBStore, claim_count: int) -> dict: - """status()-shaped counts without strictly re-loading claims. - - status() calls store.list_claims(), which re-raises on the invalid - YAMLs that lint/fsck deliberately surface as findings. Pass the - already-safely-loaded claim count so the report stays self-consistent. - """ - return { - "kb_dir": str(store.kb_dir), - "claims": claim_count, - "pages": len(store.list_pages()), - "sources": len(store.list_sources()), - "entities": len(store.list_entities()), - "relations": len(store.list_relations()), - "evidence": len(store.list_evidence()), - "sessions": len(store.list_sessions()), - "pending_proposals": len(store.list_proposals(ProposalStatus.PENDING)), - "audit_events": count_events(store.kb_dir), - "index_present": (store.kb_dir / index_db.DB_FILENAME).exists(), - } - - -def _load_claims_for_lint(store: KBStore) -> tuple[list[Claim], list[Finding]]: - """Iterate `claims/*.yaml` one file at a time so a single invalid - YAML can't crash the whole lint sweep — surface it as a finding - and keep going. This is the repair hint for KBs that have legacy - uncited claims from before the Claim.evidence min-citation - validator landed (#81): `vouch lint` lists them as - `invalid_claim` findings so the user can fix or delete the file - rather than seeing a bare `pydantic.ValidationError` traceback.""" - valid: list[Claim] = [] - findings: list[Finding] = [] - cdir = store.kb_dir / "claims" - if not cdir.is_dir(): - return valid, findings - for p in sorted(cdir.glob("*.yaml")): - cid = p.stem - try: - valid.append(Claim.model_validate(_yaml_load(p.read_text(encoding="utf-8")))) - except ValidationError as e: - tail = str(e).splitlines()[-1].strip() if str(e) else "validation failed" - findings.append( - Finding( - "error", - "invalid_claim", - f"claim {cid} ({p}) fails model validation: {tail} — " - "edit the YAML to add a citation, or delete the file", - [cid], - ) - ) - except Exception as e: - findings.append( - Finding( - "error", - "unreadable_claim", - f"claim {cid} ({p}) could not be loaded: {e}", - [cid], - ) - ) - return valid, findings - - -def _collect_sources_for_lint(store: KBStore) -> tuple[set[str], list[Finding]]: - """Source ids for citation checks, with unreadable metas surfaced. - - `storage.list_sources()` deliberately skips files `_load_or_skip` - can't parse so bulk listings survive one bad artifact; for lint that - silence is the bug — a corrupt meta.yaml (e.g. a mojibake title - carrying a raw control character that pyyaml rejects) must become an - `unreadable_source` finding. The source id is its directory name, so - it still counts as present for citation checks: the claim's citation - isn't broken, the meta is, and reporting `broken_citation` on top - would point the repair at the wrong file.""" - present: set[str] = set() - findings: list[Finding] = [] - sources_dir = store.kb_dir / "sources" - if not sources_dir.is_dir(): - return present, findings - for sdir in sorted(sources_dir.iterdir()): - meta = sdir / "meta.yaml" - if not meta.exists(): - continue - sid = sdir.name - present.add(sid) - try: - Source.model_validate(_yaml_load(meta.read_text(encoding="utf-8"))) - except Exception as e: - findings.append( - Finding( - "error", - "unreadable_source", - f"source {sid} ({meta}) could not be loaded: {e} — " - "fix the meta.yaml by hand, or delete the source " - "directory and re-register it", - [sid], - ) - ) - return present, findings - - def lint(store: KBStore, *, stale_after_days: int = 180) -> HealthReport: - claims, findings = _load_claims_for_lint(store) - sources_present, source_findings = _collect_sources_for_lint(store) - findings.extend(source_findings) + findings: list[Finding] = [] + claims = store.list_claims() + sources_present = {s.id for s in store.list_sources()} evidence_present = {e.id for e in store.list_evidence()} for c in claims: # Citation integrity. for ref in c.evidence: if ref not in sources_present and ref not in evidence_present: - findings.append( - Finding( - "error", - "broken_citation", - f"claim {c.id} cites missing {ref}", - [c.id, ref], - ) - ) + findings.append(Finding( + "error", "broken_citation", + f"claim {c.id} cites missing {ref}", [c.id, ref], + )) # Stale: not confirmed in N days. anchor = c.last_confirmed_at or c.updated_at or c.created_at if anchor and anchor.tzinfo is None: anchor = anchor.replace(tzinfo=UTC) if anchor and (datetime.now(UTC) - anchor) > timedelta(days=stale_after_days): - findings.append( - Finding( - "warning", - "stale_claim", - f"claim {c.id} not confirmed in >{stale_after_days}d", - [c.id], - ) - ) + findings.append(Finding( + "warning", "stale_claim", + f"claim {c.id} not confirmed in >{stale_after_days}d", + [c.id], + )) # Active claims should not be marked contested at the same time. if c.status == ClaimStatus.CONTESTED and not c.contradicts: - findings.append( - Finding( - "warning", - "contested_no_contradiction", - f"claim {c.id} status=contested but no contradicts[] set", - [c.id], - ) - ) + findings.append(Finding( + "warning", "contested_no_contradiction", + f"claim {c.id} status=contested but no contradicts[] set", [c.id], + )) # Orphan pages (reference a claim that no longer exists). claim_ids = {c.id for c in claims} for page in store.list_pages(): for cid in page.claims: if cid not in claim_ids: - findings.append( - Finding( - "warning", - "orphan_page_ref", - f"page {page.id} references missing claim {cid}", - [page.id, cid], - ) - ) + findings.append(Finding( + "warning", "orphan_page_ref", + f"page {page.id} references missing claim {cid}", + [page.id, cid], + )) # Dangling relations. - referable = ( - claim_ids - | sources_present - | {e.id for e in store.list_entities()} - | {p.id for p in store.list_pages()} - ) + referable = claim_ids | sources_present | {e.id for e in store.list_entities()} | { + p.id for p in store.list_pages() + } for rel in store.list_relations(): for endpoint in (rel.source, rel.target): if endpoint not in referable: - findings.append( - Finding( - "error", - "dangling_relation", - f"relation {rel.id} endpoint {endpoint} not found", - [rel.id, endpoint], - ) - ) + findings.append(Finding( + "error", "dangling_relation", + f"relation {rel.id} endpoint {endpoint} not found", + [rel.id, endpoint], + )) ok = not any(f.severity == "error" for f in findings) - return HealthReport(ok=ok, findings=findings, counts=_safe_counts(store, len(claims))) + return HealthReport(ok=ok, findings=findings, counts=status(store)) def doctor(store: KBStore) -> HealthReport: """Lint + source verification + index consistency. Slow but thorough.""" report = lint(store) - chain = verify_chain(store.kb_dir) - if not chain.ok: - detail = f": {chain.reason}" if chain.reason else "" - report.findings.append(Finding( - "error", "audit_chain_broken", - f"audit chain broken at line {chain.line}{detail}", - )) - # Source integrity (content hash). for vr in verify_all(store): if not vr.stored_ok: - report.findings.append( - Finding( - "error", - "source_corrupt", - f"source {vr.source.id} content hash mismatch", - [vr.source.id], - ) - ) + report.findings.append(Finding( + "error", "source_corrupt", + f"source {vr.source.id} content hash mismatch", + [vr.source.id], + )) if vr.external_status == "drift": - report.findings.append( - Finding( - "warning", - "source_drift", - f"external file {vr.source.locator} changed since registration", - [vr.source.id], - ) - ) + report.findings.append(Finding( + "warning", "source_drift", + f"external file {vr.source.locator} changed since registration", + [vr.source.id], + )) # Config sanity. if not store.config_path.exists(): - report.findings.append( - Finding( - "error", - "missing_config", - "config.yaml is missing", - ) - ) + report.findings.append(Finding( + "error", "missing_config", "config.yaml is missing", + )) # Index presence (warning only — the index is derivable). if not (store.kb_dir / index_db.DB_FILENAME).exists(): - report.findings.append( - Finding( - "info", - "index_missing", - "state.db not present — run `vouch index` to build it", - ) - ) + report.findings.append(Finding( + "info", "index_missing", + "state.db not present — run `vouch index` to build it", + )) report.ok = not any(f.severity == "error" for f in report.findings) return report -def fsck(store: KBStore) -> HealthReport: - """Deep consistency check — orphaned embeddings, dangling lifecycle - chains, decided-proposal ↔ artifact mismatches, index-vs-file drift. - - Read-only; report findings only. `--fix` is intentionally out of scope. - """ - # Load claims one file at a time (like lint) so a single invalid YAML — - # e.g. a legacy uncited claim from before #81 — becomes an `invalid_claim` - # finding instead of aborting the whole check with a traceback. That bad - # YAML is exactly the kind of inconsistency a deep checker should surface. - claim_list, findings = _load_claims_for_lint(store) - claims: dict[str, Claim] = {c.id: c for c in claim_list} - pages: dict[str, Page] = {p.id: p for p in store.list_pages()} - entities: dict[str, Entity] = {e.id: e for e in store.list_entities()} - - _check_lifecycle_chains(claims, findings) - _check_claim_graph_refs(claims, entities, findings) - _check_decided_proposals(store, claims, pages, entities, findings) - - db_present = (store.kb_dir / index_db.DB_FILENAME).exists() - if not db_present: - findings.append( - Finding( - "info", - "index_missing", - "state.db not present — run `vouch index` to build it", - ) - ) - else: - _check_index_drift(store, claims, pages, entities, findings) - _check_orphan_embeddings(store, claims, pages, entities, findings) - - ok = not any(f.severity == "error" for f in findings) - return HealthReport(ok=ok, findings=findings, counts=_safe_counts(store, len(claims))) - - -def _check_lifecycle_chains( - claims: dict[str, Claim], - findings: list[Finding], -) -> None: - """Detect supersede / contradict pointers into the void or out-of-sync. - - Each claim records its lifecycle links inline (`supersedes`, - `superseded_by`, `contradicts`). Those lists can drift if a referenced - claim was deleted or if a `contradict` was only written from one side. - """ - for cid, c in claims.items(): - for target in c.supersedes: - if target not in claims: - findings.append( - Finding( - "error", - "dangling_supersedes", - f"claim {cid} supersedes missing claim {target}", - [cid, target], - ) - ) - if c.superseded_by is not None and c.superseded_by not in claims: - findings.append( - Finding( - "error", - "dangling_superseded_by", - f"claim {cid} superseded_by missing claim {c.superseded_by}", - [cid, c.superseded_by], - ) - ) - for other in c.contradicts: - if other not in claims: - findings.append( - Finding( - "error", - "dangling_contradicts", - f"claim {cid} contradicts missing claim {other}", - [cid, other], - ) - ) - continue - if cid not in claims[other].contradicts: - findings.append( - Finding( - "warning", - "asymmetric_contradicts", - f"claim {cid} contradicts {other} but {other} does not " - f"contradict {cid} back", - [cid, other], - ) - ) - - -def _check_claim_graph_refs( - claims: dict[str, Claim], - entities: dict[str, Entity], - findings: list[Finding], -) -> None: - """Detect `claim.entities` pointing at a missing entity. - - Sibling of `_check_lifecycle_chains` for the entity-ref field, so - legacy KBs surface the blocker before `update_claim` rejects it. - """ - entity_ids = entities.keys() - for cid, c in claims.items(): - for eid in c.entities: - if eid not in entity_ids: - findings.append( - Finding( - "error", - "dangling_claim_entity", - f"claim {cid} references missing entity {eid}", - [cid, eid], - ) - ) - - -def _check_decided_proposals( - store: KBStore, - claims: dict[str, Claim], - pages: dict[str, Page], - entities: dict[str, Entity], - findings: list[Finding], -) -> None: - """Every approved proposal should have its artifact on disk. - - A crash between `put_()` and `move_proposal_to_decided()` would - leave a `decided/` entry without a matching artifact (or vice versa); - surface the artifact-missing case so an operator can investigate. - """ - relations = {r.id for r in store.list_relations()} - presence: dict[ProposalKind, set[str]] = { - ProposalKind.CLAIM: set(claims), - ProposalKind.PAGE: set(pages), - ProposalKind.ENTITY: set(entities), - ProposalKind.RELATION: relations, - } - for pr in store.list_proposals(ProposalStatus.APPROVED): - artifact_id = pr.payload.get("id") if isinstance(pr.payload, dict) else None - if not artifact_id: - findings.append( - Finding( - "error", - "decided_no_artifact_id", - f"approved proposal {pr.id} has no payload id", - [pr.id], - ) - ) - continue - if artifact_id not in presence[pr.kind]: - findings.append( - Finding( - "error", - "decided_missing_artifact", - f"approved proposal {pr.id} promised " - f"{pr.kind.value} {artifact_id} but artifact is missing", - [pr.id, artifact_id], - ) - ) - - -def _check_index_drift( - store: KBStore, - claims: dict[str, Claim], - pages: dict[str, Page], - entities: dict[str, Entity], - findings: list[Finding], -) -> None: - """FTS5 must match disk for every searchable artifact. - - Three drift shapes matter: an indexed row whose artifact is gone, an - artifact missing from the index entirely (write-hook failure), and a - `claims_fts.status` value that disagrees with the on-disk - `claim.status` (the #78 failure mode that leaks archived claims). - """ - with index_db.open_db(store.kb_dir) as conn: - indexed_claims = { - (row[0], row[1]) for row in conn.execute("SELECT id, status FROM claims_fts").fetchall() - } - indexed_pages = {row[0] for row in conn.execute("SELECT id FROM pages_fts").fetchall()} - indexed_entities = { - row[0] for row in conn.execute("SELECT id FROM entities_fts").fetchall() - } - - indexed_claim_ids = {cid for cid, _ in indexed_claims} - _drift_findings("claim", indexed_claim_ids, set(claims), findings) - _drift_findings("page", indexed_pages, set(pages), findings) - _drift_findings("entity", indexed_entities, set(entities), findings) - - # Status drift is claim-specific: claims_fts carries a status column that - # must agree with the on-disk claim (orphans are already reported above). - for cid, status_in_index in indexed_claims: - if cid not in claims: - continue - on_disk = claims[cid].status.value - if status_in_index != on_disk: - findings.append( - Finding( - "error", - "index_status_drift", - f"claim {cid} status on disk is {on_disk!r} but " - f"claims_fts has {status_in_index!r}", - [cid], - ) - ) - - -def _drift_findings( - kind: str, - indexed_ids: set[str], - on_disk_ids: set[str], - findings: list[Finding], -) -> None: - """Emit the orphan + missing-row findings for one indexed kind. - - `index_orphan_` = an FTS5 row whose artifact is gone from disk; - `index_missing_row` = a durable artifact with no FTS5 row. The shape is - identical for claims, pages, and entities, so a new kind is a one-liner. - The FTS5 table is `{kind}s_fts` for every kind. - """ - for oid in indexed_ids - on_disk_ids: - findings.append( - Finding( - "error", - f"index_orphan_{kind}", - f"{kind}s_fts row {oid} has no {kind} on disk", - [oid], - ) - ) - for oid in on_disk_ids - indexed_ids: - findings.append( - Finding( - "error", - "index_missing_row", - f"{kind} {oid} on disk but missing from {kind}s_fts", - [oid], - ) - ) - - -def _check_orphan_embeddings( - store: KBStore, - claims: dict[str, Claim], - pages: dict[str, Page], - entities: dict[str, Entity], - findings: list[Finding], -) -> None: - """Flag embedding rows whose artifact has been deleted. - - Stale vectors are silent: semantic search still returns them, snippets - just fall back to the bare id. Both the legacy `embeddings` table and - the newer `embedding_index` table are checked. - """ - presence = {"claim": set(claims), "page": set(pages), "entity": set(entities)} - with index_db.open_db(store.kb_dir) as conn: - # Two tables exist: `embeddings` (legacy) and `embedding_index` - # (current). Check both — either one drifting silently breaks - # semantic retrieval. - for table in ("embeddings", "embedding_index"): - rows = conn.execute(f"SELECT kind, id FROM {table}").fetchall() - for kind, eid in rows: - live = presence.get(kind) - if live is None or eid in live: - continue - findings.append( - Finding( - "warning", - "orphan_embedding", - f"{table} row for {kind} {eid} has no artifact on disk", - [eid], - ) - ) - - -def rebuild_index(store: KBStore, *, on_progress: Callable[[str], None] | None = None) -> dict: - """Drop and rebuild state.db from the durable files. Idempotent. - - `on_progress`, if given, is called with a short phase label ("claims", - "pages", "entities", "embeddings") as each stage starts — for CLI - progress display. It never affects the result. - """ - - def _tick(phase: str) -> None: - if on_progress is not None: - on_progress(phase) - - # Detect a stale embedding-model identity before reset() wipes the meta. - try: - from . import audit - from .embeddings.migration import detect_mismatch - - m = detect_mismatch(store.kb_dir) - if m is not None: - audit.log_event( - store.kb_dir, - event="embedding.model_mismatch", - actor="vouch-health", - object_ids=[], - data=m, - ) - except ImportError: - pass +def rebuild_index(store: KBStore) -> dict: + """Drop and rebuild state.db from the durable files. Idempotent.""" index_db.reset(store.kb_dir) with index_db.open_db(store.kb_dir) as conn: for c in store.list_claims(): index_db.index_claim( - conn, - id=c.id, - text=c.text, - type=c.type.value, - status=c.status.value, - tags=c.tags, + conn, id=c.id, text=c.text, + type=c.type.value, status=c.status.value, tags=c.tags, ) for p in store.list_pages(): index_db.index_page( - conn, - id=p.id, - title=p.title, - body=p.body, - type=p.type, - tags=p.tags, + conn, id=p.id, title=p.title, body=p.body, + type=p.type.value, tags=p.tags, ) for e in store.list_entities(): index_db.index_entity( - conn, - id=e.id, - name=e.name, - description=e.description, - type=e.type.value, - aliases=e.aliases, + conn, id=e.id, name=e.name, description=e.description, + type=e.type.value, aliases=e.aliases, ) - _rebuild_embeddings(store) return index_db.stats(store.kb_dir) -def _rebuild_embeddings(store: KBStore) -> None: - try: - from .embeddings import get_embedder - - embedder = get_embedder() - except Exception: - return - with index_db.open_db(store.kb_dir) as conn: - texts: list[tuple[str, str, str]] = [] - for c in store.list_claims(): - texts.append(("claim", c.id, c.text)) - for p in store.list_pages(): - texts.append(("page", p.id, f"{p.title} {p.body}")) - for e in store.list_entities(): - texts.append(("entity", e.id, f"{e.name} {e.description or ''}")) - if not texts: - return - batch_size = 64 - for i in range(0, len(texts), batch_size): - batch = texts[i : i + batch_size] - vecs = embedder.encode_batch([t[2] for t in batch]) - for (kind, eid, _), row in zip(batch, vecs, strict=True): - index_db.index_embedding(conn, kind=kind, id=eid, vec=row.tolist()) - - # --- helpers used by `vouch discover` (CLI) ------------------------------- - def hash_path(p: Path) -> str: return sha256_hex(p.read_bytes()) if p.is_file() else "" diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py new file mode 100644 index 00000000..e90c2f66 --- /dev/null +++ b/src/vouch/hooks.py @@ -0,0 +1,68 @@ +"""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 +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 + + +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} + if not isinstance(payload, dict): + payload = {} + 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: + _log.warning("context-hook: build_context_pack failed", exc_info=True) + 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/src/vouch/index_db.py b/src/vouch/index_db.py index 146d9f52..71c7734a 100644 --- a/src/vouch/index_db.py +++ b/src/vouch/index_db.py @@ -12,7 +12,6 @@ from __future__ import annotations import datetime as _dt -import json import sqlite3 from collections.abc import Iterable from contextlib import contextmanager, suppress @@ -34,14 +33,6 @@ id UNINDEXED, name, description, type UNINDEXED, aliases ); -CREATE TABLE IF NOT EXISTS embeddings ( - kind TEXT NOT NULL, - id TEXT NOT NULL, - vec BLOB NOT NULL, - model TEXT NOT NULL DEFAULT 'all-MiniLM-L6-v2', - PRIMARY KEY (kind, id) -); - CREATE TABLE IF NOT EXISTS index_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); @@ -74,18 +65,6 @@ cosine REAL NOT NULL, detected_at TEXT NOT NULL ); - -CREATE TABLE IF NOT EXISTS prov_edges ( - src_id TEXT NOT NULL, - dst_id TEXT NOT NULL, - kind TEXT NOT NULL, - event_ts TEXT NOT NULL DEFAULT '', - session_id TEXT, - PRIMARY KEY (src_id, dst_id, kind) -); - -CREATE INDEX IF NOT EXISTS prov_edges_dst ON prov_edges(dst_id); -CREATE INDEX IF NOT EXISTS prov_edges_kind ON prov_edges(kind); """ @@ -125,58 +104,10 @@ def reset(kb_dir: Path) -> None: "DELETE FROM embedding_index;" "DELETE FROM query_embedding_cache;" "DELETE FROM embedding_dupes;" - "DELETE FROM prov_edges;" "DELETE FROM index_meta WHERE key LIKE 'embedding_%';" - "DELETE FROM index_meta WHERE key LIKE 'prov_%';" ) -def index_embedding(conn: sqlite3.Connection, *, kind: str, id: str, - vec: list[float]) -> None: - conn.execute( - "INSERT OR REPLACE INTO embeddings (kind, id, vec) VALUES (?, ?, ?)", - (kind, id, json.dumps(vec)), - ) - - -def search_embeddings(kb_dir: Path, query_vec: list[float], *, - limit: int = 10 - ) -> list[tuple[str, str, str, float]]: - """Return (kind, id, snippet, cosine_score) via brute-force NumPy scan.""" - import numpy as np # type: ignore[import-not-found] - out: list[tuple[str, str, str, float]] = [] - if not query_vec: - return out - q = np.array(query_vec, dtype=np.float32) - q_norm = np.linalg.norm(q) - if q_norm == 0.0: - return out - q = q / q_norm - with open_db(kb_dir) as conn: - rows = conn.execute( - "SELECT kind, id, vec FROM embeddings" - ).fetchall() - for kind, eid, vec_json in rows: - v = np.array(json.loads(vec_json), dtype=np.float32) - if v.ndim != 1 or v.shape[0] != q.shape[0]: - continue - score = float(np.dot(q, v)) - snippet = _snippet_for(kb_dir, kind, eid) - out.append((kind, eid, snippet, score)) - out.sort(key=lambda x: x[3], reverse=True) - return out[:limit] - - -def _snippet_for(kb_dir: Path, kind: str, eid: str) -> str: - path = kb_dir / kind / f"{eid}.yaml" - if not path.exists(): - path = kb_dir / kind / f"{eid}.md" - if not path.exists(): - return eid - text = path.read_text(encoding="utf-8") - return text[:200].replace("\n", " ") - - def index_claim(conn: sqlite3.Connection, *, id: str, text: str, type: str, status: str, tags: Iterable[str]) -> None: conn.execute("DELETE FROM claims_fts WHERE id = ?", (id,)) @@ -186,49 +117,6 @@ def index_claim(conn: sqlite3.Connection, *, id: str, text: str, ) -# --- provenance edges (derived cache for `vouch why/trace/impact`) -------- - - -def clear_prov_edges(conn: sqlite3.Connection) -> None: - conn.execute("DELETE FROM prov_edges") - - -def index_prov_edge( - conn: sqlite3.Connection, *, src_id: str, dst_id: str, kind: str, - event_ts: str = "", session_id: str | None = None, -) -> None: - conn.execute( - "INSERT OR REPLACE INTO prov_edges " - "(src_id, dst_id, kind, event_ts, session_id) VALUES (?, ?, ?, ?, ?)", - (src_id, dst_id, kind, event_ts, session_id), - ) - - -def read_prov_edges(kb_dir: Path) -> list[tuple[str, str, str, str, str | None]]: - """Return all cached provenance edges, deterministically ordered.""" - with open_db(kb_dir) as conn: - rows = conn.execute( - "SELECT src_id, dst_id, kind, event_ts, session_id FROM prov_edges " - "ORDER BY src_id, dst_id, kind" - ).fetchall() - return [(r[0], r[1], r[2], r[3], r[4]) for r in rows] - - -def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None: - conn.execute( - "INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)", - (key, value), - ) - - -def get_meta(kb_dir: Path, key: str) -> str | None: - with open_db(kb_dir) as conn: - row = conn.execute( - "SELECT value FROM index_meta WHERE key = ?", (key,) - ).fetchone() - return row[0] if row else None - - def index_page(conn: sqlite3.Connection, *, id: str, title: str, body: str, type: str, tags: Iterable[str]) -> None: conn.execute("DELETE FROM pages_fts WHERE id = ?", (id,)) @@ -471,9 +359,7 @@ def search_semantic( return [] try: embedder = get_embedder() - except (KeyError, ImportError): - # No embedder registered, or the adapter's heavy deps aren't - # installed -- semantic search is unavailable; let callers fall back. + except KeyError: return [] qvec = lookup_query_vec(kb_dir, query=query) # The query cache is keyed by text only; if the embedder model or diff --git a/src/vouch/install_adapter.py b/src/vouch/install_adapter.py index ba4f0b1c..4fce5cd0 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 @@ -247,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" @@ -262,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() @@ -389,3 +482,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/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5e3b62b7..f6f6a293 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -22,53 +22,31 @@ import sys import traceback from collections.abc import Callable -from contextvars import ContextVar from pathlib import Path from typing import Any -import yaml - -from . import audit, bundle, health, volunteer_context -from . import compile as compile_mod -from . import digest as digest_mod +from . import audit, bundle, health from . import lifecycle as life -from . import metrics as metrics_mod -from . import salience as salience_mod from . import sessions as sess_mod -from . import trust as trust_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps from .context import build_context_pack -from .logging_config import configure_logging from .models import ProposalStatus -from .page_filters import filter_pages from .proposals import ( - EXPIRE_ACTOR, ProposalError, approve, - expire_pending, propose_claim, propose_entity, propose_page, propose_relation, reject, - reject_auto_extracted, ) -from .stats import collect_stats from .storage import ( ArtifactNotFoundError, KBNotFoundError, KBStore, discover_root, ) -from .synthesize import synthesize - -# 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 -# context, so this is concurrency-safe). stdio/JSONL leave it unset and -# fall back to VOUCH_AGENT. -_actor: ContextVar[str | None] = ContextVar("vouch_actor", default=None) def _store() -> KBStore: @@ -79,7 +57,7 @@ def _store() -> KBStore: def _agent() -> str: - return _actor.get() or os.environ.get("VOUCH_AGENT", "unknown-agent") + return os.environ.get("VOUCH_AGENT", "unknown-agent") # --- per-method handlers --------------------------------------------------- @@ -93,40 +71,19 @@ def _h_status(_: dict) -> dict: return health.status(_store()) -def _h_stats(p: dict) -> dict: - days = int(p.get("days", 30)) - since = None if days == 0 else days - return collect_stats(_store(), since_days=since) - - -def _h_digest(p: dict) -> dict: - d = digest_mod.build( - _store(), - since=metrics_mod.parse_since(str(p.get("since", digest_mod.DEFAULT_SINCE_SPEC))), - stale_after_days=int(p.get("stale_days", metrics_mod.DEFAULT_STALE_DAYS)), - limit=int(p.get("limit", digest_mod.DEFAULT_LIMIT)), - ) - return d.to_dict() - - -def _h_search(p: dict) -> dict: +def _h_search(p: dict) -> list[dict]: from . import index_db - from .scoping import filter_hits, scoped_fetch_limit, viewer_from - s = _store() q = p["query"] limit = int(p.get("limit", 10)) backend_arg = p.get("backend", "auto") min_score = float(p.get("min_score", 0.0)) - viewer = viewer_from( - config_path=s.config_path, - project=p.get("project"), - agent=p.get("agent"), - ) - fetch_limit = scoped_fetch_limit(limit, viewer) hits: list[tuple[str, str, str, float]] = [] used = backend_arg + # Reject unknown backends with a clear error rather than silently + # returning []. Falling through hides client typos and diverges from + # the MCP transport, which raises ValueError on the same input. valid_backends = {"auto", "embedding", "fts5", "substring", "hybrid"} if backend_arg not in valid_backends: raise ValueError( @@ -136,18 +93,18 @@ def _h_search(p: dict) -> dict: if backend_arg in ("auto", "embedding"): hits = index_db.search_semantic( - s.kb_dir, q, limit=fetch_limit, min_score=min_score, + s.kb_dir, q, limit=limit, min_score=min_score, ) if hits: used = "embedding" if not hits and backend_arg in ("auto", "fts5"): try: - hits = index_db.search(s.kb_dir, q, limit=fetch_limit) + hits = index_db.search(s.kb_dir, q, limit=limit) used = "fts5" if hits else used except Exception: hits = [] if not hits and backend_arg in ("auto", "substring"): - hits = s.search_substring(q, limit=fetch_limit) + hits = s.search_substring(q, limit=limit) used = "substring" if backend_arg == "hybrid": from .embeddings.fusion import ( # type: ignore[import-not-found,import-untyped,unused-ignore] @@ -156,81 +113,32 @@ def _h_search(p: dict) -> dict: # Hybrid must honour min_score and survive FTS failures the same # way the dedicated fts5 branch does. emb = index_db.search_semantic( - s.kb_dir, q, limit=fetch_limit * 2, min_score=min_score, + s.kb_dir, q, limit=limit * 2, min_score=min_score, ) try: - fts = index_db.search(s.kb_dir, q, limit=fetch_limit * 2) + fts = index_db.search(s.kb_dir, q, limit=limit * 2) except Exception: fts = [] - hits = rrf_fuse(emb, fts, limit=fetch_limit) + hits = rrf_fuse(emb, fts, limit=limit) used = "hybrid" - scoped = filter_hits(s, hits, viewer, limit=limit) - return { - "backend": used, - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "hits": [ - {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} - for k, i, sn, sc in scoped - ], - } - - -def _load_cfg(store: KBStore) -> dict: - try: - loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8")) - except Exception: - return {} - return loaded if isinstance(loaded, dict) else {} - - -def _h_neighbors(p: dict) -> dict: - from .graph import find_neighbors - - return find_neighbors( - _store(), - p["node_id"], - depth=int(p.get("depth", 1)), - rel_types=p.get("rel_types"), - max_nodes=int(p.get("max_nodes", 50)), - ) + return [ + {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} + for k, i, sn, sc in hits + ] def _h_context(p: dict) -> dict: - store = _store() - query = p["task"] - cfg = _load_cfg(store) - session_id = p.get("session_id") - if session_id: - _, window, _ = salience_mod.reflex_cfg(cfg) - salience_mod.record_query(session_id, query, window=window) - result: dict = build_context_pack( # type: ignore[assignment] - store, - query=query, + return build_context_pack( + _store(), + query=p["task"], limit=int(p.get("limit", 10)), - max_chars=int(p["max_chars"]) if p.get("max_chars") is not None else None, + max_chars=p.get("max_chars"), min_items=int(p.get("min_items", 0)), require_citations=bool(p.get("require_citations", False)), fail_on_warnings=bool(p.get("fail_on_warnings", False)), fail_on_budget_truncation=bool(p.get("fail_on_budget_truncation", False)), - project=p.get("project"), - agent=p.get("agent"), - expand_graph=bool(p.get("expand_graph", False)), - graph_depth=int(p.get("graph_depth", 1)), - graph_limit=int(p.get("graph_limit", 20)), - graph_rel_types=p.get("graph_rel_types"), - ) - return salience_mod.attach_salience(result, store, session_id, cfg) - - -def _h_synthesize(p: dict) -> dict: - return synthesize( - _store(), - query=p["query"], - depth=int(p.get("depth", 3)), - max_chars=int(p.get("max_chars", 4000)), - llm=bool(p.get("llm", False)), - ) + ).model_dump(mode="json") def _h_read_page(p: dict) -> dict: @@ -249,15 +157,8 @@ def _h_read_relation(p: dict) -> dict: return _store().get_relation(p["relation_id"]).model_dump(mode="json") -def _h_list_pages(p: dict) -> list[dict]: - pages = filter_pages( - _store().list_pages(), - kind=p.get("type"), - equals=p.get("meta"), - before=p.get("meta_before"), - after=p.get("meta_after"), - ) - return [pg.model_dump(mode="json") for pg in pages] +def _h_list_pages(_: dict) -> list[dict]: + return [p.model_dump(mode="json") for p in _store().list_pages()] def _h_list_claims(p: dict) -> list[dict]: @@ -323,7 +224,7 @@ def _h_register_source_from_path(p: dict) -> dict: def _h_propose_claim(p: dict) -> dict: - result = propose_claim( + pr = propose_claim( _store(), text=p["text"], evidence=list(p["evidence"]), @@ -337,16 +238,8 @@ def _h_propose_claim(p: dict) -> dict: dry_run=bool(p.get("dry_run", False)), proposed_by=_agent(), ) - pr = result.proposal - out: dict = { - "proposal_id": pr.id, - "status": pr.status.value, - "kind": pr.kind.value, - "dry_run": bool(p.get("dry_run", False)), - } - if result.warnings: - out["warnings"] = result.warnings - return out + return {"proposal_id": pr.id, "status": pr.status.value, "kind": pr.kind.value, + "dry_run": bool(p.get("dry_run", False))} def _h_propose_page(p: dict) -> dict: @@ -357,7 +250,6 @@ def _h_propose_page(p: dict) -> dict: claim_ids=p.get("claim_ids"), entity_ids=p.get("entity_ids"), source_ids=p.get("source_ids"), - metadata=p.get("metadata"), rationale=p.get("rationale"), slug_hint=p.get("slug_hint"), session_id=p.get("session_id"), @@ -367,22 +259,6 @@ def _h_propose_page(p: dict) -> dict: return {"proposal_id": pr.id, "status": pr.status.value, "kind": pr.kind.value} -def _h_compile(p: dict) -> dict: - try: - report = compile_mod.compile_kb( - _store(), triggered_by=_agent(), - max_pages=p.get("max_pages"), - dry_run=bool(p.get("dry_run", False)), - session_id=p.get("session_id"), - ) - except compile_mod.CompileError as e: - # config/LLM/output-shape failures are caller-visible conditions, - # not server faults — surface them on the ValueError path so the - # envelope carries a clean message instead of internal_error. - raise ValueError(str(e)) from e - return report.to_dict() - - def _h_propose_entity(p: dict) -> dict: pr = propose_entity( _store(), @@ -421,30 +297,6 @@ def _h_reject(p: dict) -> dict: return {"proposal_id": p["proposal_id"], "status": "rejected"} -def _h_reject_extracted(p: dict) -> dict: - kwargs: dict[str, Any] = {"page_id": p.get("page_id")} - if p.get("reason"): - kwargs["reason"] = p["reason"] - rejected = reject_auto_extracted(_store(), rejected_by=_agent(), **kwargs) - return {"rejected": [pr.id for pr in rejected]} - - -def _h_expire(p: dict) -> dict: - result = expire_pending( - _store(), - apply=bool(p.get("apply")), - expired_by=EXPIRE_ACTOR, - days=p.get("days"), - ) - return { - "threshold_days": result.threshold_days, - "enabled": result.threshold_days > 0, - "dry_run": not bool(p.get("apply")), - "would_expire": [pr.id for pr in result.would_expire], - "expired": [pr.id for pr in result.expired], - } - - def _h_supersede(p: dict) -> dict: old, new = life.supersede( _store(), old_claim_id=p["old_claim_id"], @@ -505,14 +357,6 @@ def _h_crystallize(p: dict) -> dict: ) -def _h_volunteer_context(p: dict) -> dict: - offers = volunteer_context.drain_pending( - p["session_id"], - clear=bool(p.get("clear", True)), - ) - return {"volunteers": [o.to_dict() for o in offers]} - - def _h_index_rebuild(_: dict) -> dict: return health.rebuild_index(_store()) @@ -573,152 +417,18 @@ def _h_import_apply(p: dict) -> dict: return r -def _h_audit(p: dict) -> dict: - from .scoping import viewer_from_params - - s = _store() - viewer = viewer_from_params(s, p) - tail = int(p.get("tail", 50)) - events = list(audit.read_events(s.kb_dir, store=s, viewer=viewer))[-tail:] - return { - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "events": [e.model_dump(mode="json") for e in events], - } - - -def _h_reindex_embeddings(p: dict) -> dict: - from .embeddings.migration import backfill_embeddings - n = backfill_embeddings(_store(), force=bool(p.get("force", False))) - return {"touched": n} - - -def _h_dedup_scan(p: dict) -> dict: - from .embeddings.dedup import scan_all - return { - "duplicates": scan_all( - _store().kb_dir, - threshold=float(p.get("threshold", 0.95)), - dry_run=bool(p.get("dry_run", False)), - ), - } - - -def _h_eval_embeddings(p: dict) -> dict: - from pathlib import Path - - from .embeddings.scorer import evaluate - return evaluate( - kb_dir=_store().kb_dir, - queries_file=Path(p["queries_path"]), - k=int(p.get("k", 10)), - ) - - -def _h_embeddings_stats(_: dict) -> dict: - from . import index_db - from .embeddings.cache import query_cache_stats - store = _store() - meta = index_db.get_embedding_meta(store.kb_dir) - with index_db.open_db(store.kb_dir) as conn: - counts = { - k: int(n) for k, n in conn.execute( - "SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind" - ) - } - return { - "model": meta.get("embedding_model"), - "counts": counts, - "query_cache": query_cache_stats(store.kb_dir), - } - - -def _h_why(p: dict) -> dict: - from . import provenance as prov - - return prov.why(_store(), claim_id=p["claim_id"], depth=int(p.get("depth", 3))) - - -def _h_trace(p: dict) -> dict: - from . import provenance as prov - - return prov.trace(_store(), from_id=p["from"], to_id=p["to"]) - - -def _h_impact(p: dict) -> dict: - from . import provenance as prov - - return prov.impact( - _store(), - claim_id=p["claim_id"], - depth=int(p.get("depth", 1)), - op=p.get("op"), - ) - - -def _h_graph_export(p: dict) -> dict: - from . import provenance as prov - - fmt = p.get("format", "dot") - graph = prov.graph_export(_store(), session=p.get("session"), fmt=fmt) - return {"format": fmt, "graph": graph} - - -def _h_provenance_rebuild(_: dict) -> dict: - from . import provenance as prov - - return {"edges": prov.rebuild_prov_edges(_store())} - - -def _h_detect_themes(p: dict) -> dict: - from . import themes - - result = themes.detect_themes( - _store(), - min_sessions=p.get("min_sessions"), - min_claims=p.get("min_claims"), - top_k=p.get("top_k"), - ) - return { - "clusters": [ - { - "entities": c.entities, - "claim_ids": c.claim_ids, - "session_ids": c.session_ids, - "score": c.score, - "session_count": c.session_count, - "claim_count": c.claim_count, - } - for c in result.clusters - ], - "config": result.config_used, - } - - -def _h_propose_theme(p: dict) -> dict: - from . import themes - - store = _store() - actor = p.get("agent") or os.environ.get("VOUCH_AGENT", "unknown-agent") - cluster = themes.ThemeCluster( - entities=p["entities"], - claim_ids=p["claim_ids"], - session_ids=p.get("session_ids", []), - score=float(p.get("score", 0.0)), - session_count=len(p.get("session_ids", [])), - claim_count=len(p["claim_ids"]), - ) - return themes.propose_theme(store, cluster, proposed_by=actor) +def _h_audit(p: dict) -> list[dict]: + return [ + e.model_dump(mode="json") + for e in list(audit.read_events(_store().kb_dir))[-int(p.get("tail", 50)):] + ] HANDLERS: dict[str, Callable[[dict], Any]] = { "kb.capabilities": _h_capabilities, "kb.status": _h_status, - "kb.stats": _h_stats, - "kb.digest": _h_digest, "kb.search": _h_search, - "kb.neighbors": _h_neighbors, "kb.context": _h_context, - "kb.synthesize": _h_synthesize, "kb.read_page": _h_read_page, "kb.read_claim": _h_read_claim, "kb.read_entity": _h_read_entity, @@ -733,13 +443,10 @@ def _h_propose_theme(p: dict) -> dict: "kb.register_source_from_path": _h_register_source_from_path, "kb.propose_claim": _h_propose_claim, "kb.propose_page": _h_propose_page, - "kb.compile": _h_compile, "kb.propose_entity": _h_propose_entity, "kb.propose_relation": _h_propose_relation, "kb.approve": _h_approve, "kb.reject": _h_reject, - "kb.reject_extracted": _h_reject_extracted, - "kb.expire": _h_expire, "kb.supersede": _h_supersede, "kb.contradict": _h_contradict, "kb.archive": _h_archive, @@ -748,7 +455,6 @@ def _h_propose_theme(p: dict) -> dict: "kb.source_verify": _h_source_verify, "kb.session_start": _h_session_start, "kb.session_end": _h_session_end, - "kb.volunteer_context": _h_volunteer_context, "kb.crystallize": _h_crystallize, "kb.index_rebuild": _h_index_rebuild, "kb.lint": _h_lint, @@ -758,17 +464,6 @@ def _h_propose_theme(p: dict) -> dict: "kb.import_check": _h_import_check, "kb.import_apply": _h_import_apply, "kb.audit": _h_audit, - "kb.reindex_embeddings": _h_reindex_embeddings, - "kb.dedup_scan": _h_dedup_scan, - "kb.eval_embeddings": _h_eval_embeddings, - "kb.embeddings_stats": _h_embeddings_stats, - "kb.why": _h_why, - "kb.trace": _h_trace, - "kb.impact": _h_impact, - "kb.graph_export": _h_graph_export, - "kb.provenance_rebuild": _h_provenance_rebuild, - "kb.detect_themes": _h_detect_themes, - "kb.propose_theme": _h_propose_theme, } @@ -784,11 +479,7 @@ def handle_request(envelope: dict) -> dict: } try: result = HANDLERS[method](params) - return { - "id": req_id, - "ok": True, - "result": trust_mod.finish_kb_result(result), - } + return {"id": req_id, "ok": True, "result": result} except KeyError as e: return { "id": req_id, "ok": False, @@ -812,8 +503,6 @@ def handle_request(envelope: dict) -> dict: def run_jsonl(stdin=None, stdout=None) -> None: """Read one request per line, write one response per line.""" - configure_logging() - trust_mod.set_stdio_default(trust_mod.JSONL_STDIO) stdin = stdin or sys.stdin stdout = stdout or sys.stdout for line in stdin: diff --git a/src/vouch/mcp_profiles.py b/src/vouch/mcp_profiles.py new file mode 100644 index 00000000..6f79f7c3 --- /dev/null +++ b/src/vouch/mcp_profiles.py @@ -0,0 +1,109 @@ +"""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 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 + + +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) + + +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/models.py b/src/vouch/models.py index dccf4453..5159d8ff 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.""" @@ -232,6 +244,16 @@ 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. + 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 @@ -267,6 +289,13 @@ 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. + return _require_non_empty(v, "entity name") + class Relation(BaseModel): """Typed edge between entities / claims / pages.""" @@ -315,6 +344,13 @@ 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. + return _require_non_empty(v, "page title") + # --- audit + sessions ----------------------------------------------------- @@ -457,3 +493,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/src/vouch/server.py b/src/vouch/server.py index 8cabee08..0665dc11 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -11,49 +11,34 @@ from __future__ import annotations -import asyncio import os from pathlib import Path from typing import Any -import yaml from mcp.server.fastmcp import FastMCP -from . import audit, bundle, health, volunteer_context -from . import compile as compile_mod -from . import digest as digest_mod +from . import audit, bundle, health from . import lifecycle as life -from . import metrics as metrics_mod -from . import salience as salience_mod from . import sessions as sess_mod -from . import trust as trust_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps from .context import build_context_pack -from .logging_config import configure_logging from .models import ProposalStatus -from .page_filters import filter_pages from .proposals import ( - EXPIRE_ACTOR, ProposalError, approve, - expire_pending, propose_claim, propose_entity, propose_page, propose_relation, reject, - reject_auto_extracted, ) -from .scoping import filter_hits, scoped_fetch_limit, viewer_from -from .stats import collect_stats from .storage import ( ArtifactNotFoundError, KBNotFoundError, KBStore, discover_root, ) -from .synthesize import synthesize mcp = FastMCP("vouch") @@ -86,38 +71,6 @@ def kb_status() -> dict[str, Any]: return health.status(_store()) -@mcp.tool() -def kb_stats(*, days: int = 30) -> dict[str, Any]: - """Observability: pending by agent, review rates, citation coverage. - - days: decision window in days; 0 means all-time. - """ - since = None if days == 0 else days - return collect_stats(_store(), since_days=since) - - -@mcp.tool() -def kb_digest( - *, - since: str = digest_mod.DEFAULT_SINCE_SPEC, - stale_days: int = metrics_mod.DEFAULT_STALE_DAYS, - limit: int = digest_mod.DEFAULT_LIMIT, -) -> dict[str, Any]: - """Read-only reviewer briefing: pending proposals oldest-first, recent - decisions, stale claims, and followups due. - - since: window spec — a duration ("7d", "12h"), an ISO date, or "all". - """ - store = _store() - d = digest_mod.build( - store, - since=metrics_mod.parse_since(since), - stale_after_days=stale_days, - limit=limit, - ) - return d.to_dict() - - # === read tools (unrestricted) ============================================ @@ -128,39 +81,28 @@ def kb_search( limit: int = 10, backend: str = "auto", min_score: float = 0.0, - project: str | None = None, - agent: str | None = None, ) -> dict[str, Any]: """Search the KB. backend: "auto" (default, embedding then fts5 then substring), "embedding", "fts5", "substring", or "hybrid". - project/agent: optional viewer context for scope filtering. """ from . import index_db store = _store() - viewer = viewer_from( - config_path=store.config_path, - project=project, - agent=agent, - ) - fetch_limit = scoped_fetch_limit(limit, viewer) hits: list[tuple[str, str, str, float]] = [] def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any]: - scoped = filter_hits(store, h, viewer, limit=limit) return { "backend": used, - "viewer": {"project": viewer.project, "agent": viewer.agent}, "hits": [ {"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used} - for k, i, sn, sc in scoped + for k, i, sn, sc in h ], } if backend in ("auto", "embedding"): hits = index_db.search_semantic( - store.kb_dir, query, limit=fetch_limit, min_score=min_score, + store.kb_dir, query, limit=limit, min_score=min_score, ) if hits: return _to_dicts(hits, "embedding") @@ -169,7 +111,7 @@ def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any] if backend in ("auto", "fts5"): try: - hits = index_db.search(store.kb_dir, query, limit=fetch_limit) + hits = index_db.search(store.kb_dir, query, limit=limit) except Exception: hits = [] if hits: @@ -178,7 +120,7 @@ def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any] return _to_dicts([], "fts5") if backend in ("auto", "substring"): - hits = store.search_substring(query, limit=fetch_limit) + hits = store.search_substring(query, limit=limit) return _to_dicts(hits, "substring") if backend == "hybrid": @@ -189,44 +131,18 @@ def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any] # low-relevance noise otherwise) and survive FTS failures the same # way the dedicated fts5 branch does. emb = index_db.search_semantic( - store.kb_dir, query, limit=fetch_limit * 2, min_score=min_score, + store.kb_dir, query, limit=limit * 2, min_score=min_score, ) try: - fts = index_db.search(store.kb_dir, query, limit=fetch_limit * 2) + fts = index_db.search(store.kb_dir, query, limit=limit * 2) except Exception: fts = [] - hits = rrf_fuse(emb, fts, limit=fetch_limit) + hits = rrf_fuse(emb, fts, limit=limit) return _to_dicts(hits, "hybrid") raise ValueError(f"unknown backend: {backend}") -def _load_cfg(store: KBStore) -> dict[str, Any]: - try: - loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8")) - except Exception: - return {} - return loaded if isinstance(loaded, dict) else {} - - -@mcp.tool() -def kb_neighbors( - node_id: str, - depth: int = 1, - rel_types: list[str] | None = None, - max_nodes: int = 50, -) -> dict[str, Any]: - """Return graph neighbors of a claim, page, entity, or source.""" - from .graph import find_neighbors - - try: - return find_neighbors( - _store(), node_id, depth=depth, rel_types=rel_types, max_nodes=max_nodes, - ) - except ArtifactNotFoundError as e: - raise ValueError(str(e)) from e - - @mcp.tool() def kb_context( task: str, @@ -234,45 +150,12 @@ def kb_context( max_chars: int | None = None, min_items: int = 0, require_citations: bool = False, - session_id: str | None = None, - project: str | None = None, - agent: str | None = None, - expand_graph: bool = False, - graph_depth: int = 1, - graph_limit: int = 20, ) -> dict[str, Any]: - """Build a ContextPack ready to inject into an agent prompt. - - When ``session_id`` is given, the entity-salience reflex records the query - and may attach ``_meta.vouch_salience`` (see ``salience`` module). - """ - store = _store() - cfg = _load_cfg(store) - if session_id: - _, window, _ = salience_mod.reflex_cfg(cfg) - salience_mod.record_query(session_id, task, window=window) - result: dict[str, Any] = build_context_pack( # type: ignore[assignment] - store, query=task, limit=limit, max_chars=max_chars, + """Build a ContextPack ready to inject into an agent prompt.""" + return build_context_pack( + _store(), query=task, limit=limit, max_chars=max_chars, min_items=min_items, require_citations=require_citations, - project=project, agent=agent, - expand_graph=expand_graph, graph_depth=graph_depth, graph_limit=graph_limit, - ) - return salience_mod.attach_salience(result, store, session_id, cfg) - - -@mcp.tool() -def kb_synthesize( - query: str, - depth: int = 3, - max_chars: int = 4000, -) -> dict[str, Any]: - """Answer a query from approved claims only, with inline `[claim_id]` - citations, an explicit gaps block, and a synthesis_confidence grade. - - Unlike `kb_context` (a ranked list), this returns prose where every - sentence is traceable to an approved claim. - """ - return synthesize(_store(), query=query, depth=depth, max_chars=max_chars) + ).model_dump(mode="json") @mcp.tool() @@ -310,30 +193,10 @@ def kb_read_relation(relation_id: str) -> dict[str, Any]: @mcp.tool() -def kb_list_pages( - *, - type: str | None = None, - meta: dict[str, str] | None = None, - meta_before: dict[str, str] | None = None, - meta_after: dict[str, str] | None = None, -) -> list[dict[str, Any]]: - """List pages, optionally filtered by kind and frontmatter. - - type: page kind (built-in or config-declared, e.g. "followup"). - meta: frontmatter equality, e.g. {"followup_status": "open"}. - meta_before / meta_after: inclusive bounds — numbers or ISO dates, - e.g. meta_before={"due_at": "2026-07-10"} for followups due by then. - """ - pages = filter_pages( - _store().list_pages(), - kind=type, - equals=meta, - before=meta_before, - after=meta_after, - ) +def kb_list_pages() -> list[dict[str, Any]]: return [ - {"id": p.id, "title": p.title, "type": p.type, "tags": p.tags, "metadata": p.metadata} - for p in pages + {"id": p.id, "title": p.title, "type": p.type.value, "tags": p.tags} + for p in _store().list_pages() ] @@ -437,7 +300,7 @@ def kb_propose_claim( ) -> dict[str, Any]: """Propose a new claim. Becomes durable only after `kb_approve`.""" try: - result = propose_claim( + pr = propose_claim( _store(), text=text, evidence=evidence, claim_type=claim_type, confidence=confidence, entities=entities, tags=tags, rationale=rationale, @@ -446,7 +309,7 @@ def kb_propose_claim( ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e - return _proposal_response(result, dry_run) + return _proposal_response(pr, dry_run) @mcp.tool() @@ -457,7 +320,6 @@ def kb_propose_page( claim_ids: list[str] | None = None, entity_ids: list[str] | None = None, source_ids: list[str] | None = None, - metadata: dict[str, Any] | None = None, rationale: str | None = None, slug_hint: str | None = None, session_id: str | None = None, @@ -467,37 +329,14 @@ def kb_propose_page( pr = propose_page( _store(), title=title, body=body, page_type=page_type, claim_ids=claim_ids, entity_ids=entity_ids, source_ids=source_ids, - metadata=metadata, rationale=rationale, slug_hint=slug_hint, - session_id=session_id, dry_run=dry_run, proposed_by=_agent(), + rationale=rationale, slug_hint=slug_hint, session_id=session_id, + dry_run=dry_run, proposed_by=_agent(), ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e return _proposal_response(pr, dry_run) -@mcp.tool() -def kb_compile( - max_pages: int | None = None, - dry_run: bool = False, - session_id: str | None = None, -) -> dict[str, Any]: - """Compile live approved claims into topic-page proposals. - - Runs the deployment-configured LLM (compile.llm_cmd in config.yaml), - validates every citation in the drafts, and files survivors as PENDING - page proposals by the wiki-compiler actor. Long-running (the LLM call is - synchronous); never approves. - """ - try: - report = compile_mod.compile_kb( - _store(), triggered_by=_agent(), - max_pages=max_pages, dry_run=dry_run, session_id=session_id, - ) - except compile_mod.CompileError as e: - raise ValueError(str(e)) from e - return report.to_dict() - - @mcp.tool() def kb_propose_entity( name: str, @@ -545,9 +384,8 @@ def kb_propose_relation( return _proposal_response(pr, dry_run) -def _proposal_response(result, dry_run: bool) -> dict[str, Any]: - pr = result.proposal if hasattr(result, "proposal") else result - out: dict[str, Any] = { +def _proposal_response(pr, dry_run: bool) -> dict[str, Any]: + return { "proposal_id": pr.id, "status": pr.status.value, "kind": pr.kind.value, @@ -557,10 +395,6 @@ def _proposal_response(result, dry_run: bool) -> dict[str, Any]: if dry_run else "pending human approval via `vouch approve `" ), } - warnings = getattr(result, "warnings", None) - if warnings: - out["warnings"] = warnings - return out # === review-gate decisions (agents can approve on their own KBs if the @@ -587,43 +421,6 @@ def kb_reject(proposal_id: str, reason: str) -> dict[str, Any]: return {"proposal_id": proposal_id, "status": "rejected", "reason": reason} -@mcp.tool() -def kb_reject_extracted( - page_id: str | None = None, reason: str | None = None, -) -> dict[str, Any]: - """Mass-reject pending edges the auto-extractor filed (issue #224). - - Scope to one page's edges with `page_id`, or omit it to clear every - pending auto-extracted edge across the KB. - """ - try: - rejected = reject_auto_extracted( - _store(), rejected_by=_agent(), page_id=page_id, - **({"reason": reason} if reason else {}), - ) - except (ArtifactNotFoundError, ValueError, ProposalError) as e: - raise ValueError(str(e)) from e - return {"rejected": [p.id for p in rejected]} - - -@mcp.tool() -def kb_expire(apply: bool = False, days: int | None = None) -> dict[str, Any]: - """Expire stale pending proposals (dry-run unless apply=True).""" - try: - result = expire_pending( - _store(), apply=apply, expired_by=EXPIRE_ACTOR, days=days, - ) - except (ArtifactNotFoundError, ValueError, ProposalError) as e: - raise ValueError(str(e)) from e - return { - "threshold_days": result.threshold_days, - "enabled": result.threshold_days > 0, - "dry_run": not apply, - "would_expire": [p.id for p in result.would_expire], - "expired": [p.id for p in result.expired], - } - - # === lifecycle ============================================================ @@ -687,24 +484,11 @@ def kb_source_verify() -> list[dict[str, Any]]: @mcp.tool() -async def kb_session_start(task: str | None = None, note: str | None = None) -> dict[str, Any]: - store = _store() - sess = sess_mod.session_start(store, agent=_agent(), task=task, note=note) - ctx = mcp.get_context() - if ctx.session is not None: - volunteer_context.register_mcp_push( - sess.id, ctx.session, asyncio.get_running_loop(), - ) +def kb_session_start(task: str | None = None, note: str | None = None) -> dict[str, Any]: + sess = sess_mod.session_start(_store(), agent=_agent(), task=task, note=note) return sess.model_dump(mode="json") -@mcp.tool() -def kb_volunteer_context(session_id: str, *, clear: bool = True) -> dict[str, Any]: - """Poll confidence-gated context volunteered for an active session.""" - offers = volunteer_context.drain_pending(session_id, clear=clear) - return {"volunteers": [o.to_dict() for o in offers]} - - @mcp.tool() def kb_session_end(session_id: str, note: str | None = None) -> dict[str, Any]: sess = sess_mod.session_end(_store(), session_id, note=note) @@ -801,215 +585,12 @@ def kb_import_apply(bundle_path: str, on_conflict: str = "skip") -> dict[str, An @mcp.tool() -def kb_audit( - tail: int = 50, - *, - project: str | None = None, - agent: str | None = None, -) -> dict[str, Any]: - """Return the last N audit events, filtered to the viewer scope.""" - store = _store() - viewer = viewer_from( - config_path=store.config_path, - project=project, - agent=agent, - ) - events = list(audit.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:] - return { - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "events": [e.model_dump(mode="json") for e in events], - } - - -@mcp.tool() -def kb_reindex_embeddings( - *, backfill: bool = False, force: bool = False, model: str | None = None, -) -> dict[str, Any]: - """Re-encode every artifact under the current embedding adapter.""" - from .embeddings.migration import backfill_embeddings - store = _store() - if model: - from .embeddings import get_embedder - get_embedder(model) - n = backfill_embeddings(store, force=force) - return {"touched": n, "model": _current_model_name()} - - -@mcp.tool() -def kb_dedup_scan( - *, threshold: float = 0.95, dry_run: bool = False, -) -> dict[str, Any]: - """Find near-duplicate artifacts via embedding cosine.""" - from .embeddings.dedup import scan_all - store = _store() - rows = scan_all(store.kb_dir, threshold=threshold, dry_run=dry_run) - return {"duplicates": rows, "threshold": threshold} - - -@mcp.tool() -def kb_eval_embeddings(*, queries_path: str, k: int = 10) -> dict[str, Any]: - """Run retrieval eval over a JSONL queries file.""" - from pathlib import Path - - from .embeddings.scorer import evaluate - store = _store() - return evaluate( - kb_dir=store.kb_dir, - queries_file=Path(queries_path), - k=k, - ) - - -@mcp.tool() -def kb_embeddings_stats() -> dict[str, Any]: - """Model identity, per-kind counts, query cache stats.""" - from . import index_db - from .embeddings.cache import query_cache_stats - store = _store() - meta = index_db.get_embedding_meta(store.kb_dir) - counts: dict[str, int] = {} - with index_db.open_db(store.kb_dir) as conn: - for k, n in conn.execute( - "SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind" - ): - counts[k] = int(n) - return { - "model": meta.get("embedding_model"), - "model_version": meta.get("embedding_model_version"), - "dim": meta.get("embedding_dim"), - "counts": counts, - "query_cache": query_cache_stats(store.kb_dir), - } - - -# === provenance (why / trace / impact / graph) =========================== - - -@mcp.tool() -def kb_why(claim_id: str, *, depth: int = 3) -> dict[str, Any]: - """Backward provenance for a claim: cites, session, supersedes, approval. - - depth: how many hops of provenance to expand (default 3). - """ - from . import provenance as prov - return prov.why(_store(), claim_id=claim_id, depth=depth) - - -@mcp.tool() -def kb_trace(from_id: str, to_id: str) -> dict[str, Any]: - """Shortest typed-edge path between two artifacts (or found=false).""" - from . import provenance as prov - return prov.trace(_store(), from_id=from_id, to_id=to_id) - - -@mcp.tool() -def kb_impact( - claim_id: str, *, depth: int = 1, op: str | None = None -) -> dict[str, Any]: - """Forward impact: dependents, and breakage if op (archive/contradict/supersede).""" - from . import provenance as prov - return prov.impact(_store(), claim_id=claim_id, depth=depth, op=op) - - -@mcp.tool() -def kb_graph_export(*, session: str | None = None, format: str = "dot") -> dict[str, Any]: - """Render the provenance DAG (or one session's subgraph) as dot/mermaid.""" - from . import provenance as prov - graph = prov.graph_export(_store(), session=session, fmt=format) - return {"format": format, "graph": graph} - - -@mcp.tool() -def kb_provenance_rebuild() -> dict[str, Any]: - """Rebuild the prov_edges cache from durable files; returns edge count.""" - from . import provenance as prov - return {"edges": prov.rebuild_prov_edges(_store())} - - -# === cross-session themes ================================================= - - -@mcp.tool() -def kb_detect_themes( - *, - min_sessions: int | None = None, - min_claims: int | None = None, - top_k: int | None = None, -) -> dict[str, Any]: - """Detect recurring entity clusters across completed sessions. - - Read-only — returns ranked clusters without persisting anything. - Scoring is deterministic (entity co-occurrence, no LLM). - - min_sessions: minimum sessions an entity pair must span (default from config or 2). - min_claims: minimum claims supporting the cluster (default from config or 3). - top_k: maximum clusters to return (default from config or 10). - """ - from . import themes - store = _store() - result = themes.detect_themes( - store, - min_sessions=min_sessions, - min_claims=min_claims, - top_k=top_k, - ) - return { - "clusters": [ - { - "entities": c.entities, - "claim_ids": c.claim_ids, - "session_ids": c.session_ids, - "score": c.score, - "session_count": c.session_count, - "claim_count": c.claim_count, - } - for c in result.clusters - ], - "config": result.config_used, - } - - -@mcp.tool() -def kb_propose_theme( - *, - entities: list[str], - claim_ids: list[str], - session_ids: list[str], - score: float = 0.0, - agent: str | None = None, -) -> dict[str, Any]: - """Propose a theme synthesis page from a detected cluster. - - Routes through the review gate — appears in kb.list_pending. - Pass a cluster from kb.detect_themes directly. - """ - from . import themes - store = _store() - actor = agent or os.environ.get("VOUCH_AGENT", "unknown-agent") - cluster = themes.ThemeCluster( - entities=entities, - claim_ids=claim_ids, - session_ids=session_ids, - score=score, - session_count=len(session_ids), - claim_count=len(claim_ids), - ) - return themes.propose_theme(store, cluster, proposed_by=actor) - - -def _current_model_name() -> str: - try: - from .embeddings import get_embedder - return get_embedder().name - except Exception: - return "" - - -trust_mod.install_mcp_trust_wrappers(mcp) +def kb_audit(tail: int = 50) -> list[dict[str, Any]]: + """Return the last N audit events.""" + events = list(audit.read_events(_store().kb_dir))[-tail:] + return [e.model_dump(mode="json") for e in events] def run_stdio() -> None: """Entry point used by `vouch serve`.""" - configure_logging() - trust_mod.set_stdio_default(trust_mod.MCP_STDIO) mcp.run() diff --git a/src/vouch/sessions.py b/src/vouch/sessions.py index 44e8b285..5b4d6354 100644 --- a/src/vouch/sessions.py +++ b/src/vouch/sessions.py @@ -12,8 +12,7 @@ import uuid from datetime import UTC, datetime -from . import audit, index_db, volunteer_context -from . import salience as salience_mod +from . import audit from .models import Page, PageType, ProposalStatus, Session from .proposals import approve from .storage import KBStore @@ -34,13 +33,11 @@ def session_start(store: KBStore, *, agent: str, task: str | None = None, store.kb_dir, event="session.start", actor=agent, object_ids=[sess.id], data={"task": task}, ) - volunteer_context.on_session_start(store, sess) return sess def session_end(store: KBStore, session_id: str, *, note: str | None = None) -> Session: sess = store.get_session(session_id) - salience_mod.reset_session(session_id) if sess.ended_at is not None: return sess # idempotent sess.ended_at = datetime.now(UTC) @@ -55,7 +52,6 @@ def session_end(store: KBStore, session_id: str, *, note: str | None = None) -> store.kb_dir, event="session.end", actor=sess.agent, object_ids=[sess.id], data={"proposals": len(sess.proposal_ids)}, ) - volunteer_context.on_session_end(session_id) return sess @@ -111,19 +107,11 @@ def crystallize( ], ) store.put_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, - type=page.type, tags=page.tags, - ) summary_page_id = page.id - crystallize_object_ids = [sess.id, *approved_artifact_ids] - if summary_page_id is not None: - crystallize_object_ids.append(summary_page_id) audit.log_event( store.kb_dir, event="session.crystallize", actor=approver, - object_ids=crystallize_object_ids, + object_ids=[sess.id, *approved_artifact_ids], data={"approved": len(approved_artifact_ids), "failed": len(failures)}, ) return { @@ -135,17 +123,11 @@ def crystallize( def _build_summary_body(sess: Session, ids: list[str]) -> str: - # The summary page is durable and surfaces in kb.read_page / kb.search / - # kb.context, but never goes through propose_page + approve. The body is - # therefore restricted to fields the proposing agent cannot influence — - # session id (server-generated), timestamps (set from server clock at - # session_start / session_end), and the list of artifact ids that did go - # through the review gate. Anything agent-controlled (sess.task, - # sess.note, sess.agent) is omitted to keep the review-gate guarantee - # intact for the Page artifact kind. See #76. - lines = [ - f"# Session {sess.id}", - "", + lines = [f"# Session {sess.id}", ""] + if sess.task: + lines += [f"**Task:** {sess.task}", ""] + lines += [ + f"**Agent:** {sess.agent}", f"**Started:** {sess.started_at.isoformat()}", f"**Ended:** {(sess.ended_at or datetime.now(UTC)).isoformat()}", "", diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 653ea806..ab3085c4 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -27,13 +27,11 @@ import logging import os import re -import sqlite3 import stat from pathlib import Path -from typing import Any, TypeVar +from typing import Any import yaml -from pydantic import BaseModel, ValidationError from .models import ( Claim, @@ -51,13 +49,6 @@ KB_DIRNAME = ".vouch" CONFIG_FILENAME = "config.yaml" -KB_FORMAT_VERSION = 1 - -# Semver model-schema version stamped on bootstrap; the migration runner -# (vouch.migrations) advances it. Distinct from KB_FORMAT_VERSION, which is the -# integer directory-layout version in config.yaml. -SCHEMA_VERSION = "0.1.0" -SCHEMA_VERSION_FILENAME = "schema_version" SUBDIRS = ( "claims", "pages", "sources", "entities", "relations", @@ -73,43 +64,6 @@ class ArtifactNotFoundError(KeyError): pass -def _starter_config() -> dict[str, Any]: - return { - "version": KB_FORMAT_VERSION, - "review": { - "require_human_approval": True, - "expire_pending_after_days": 90, - }, - "capture": { - # auto-capture claude code sessions into pending summaries. - "enabled": True, - "min_observations": 3, - }, - "recall": { - # inject a digest of all approved knowledge at session start. - "enabled": True, - "max_chars": 12000, - }, - "retrieval": { - # auto = embedding -> fts5 -> substring; or pin one of - # embedding | fts5 | substring. See context._retrieve. - "backend": "auto", - "default_limit": 10, - }, - "agents": { - "recommended_loop": [ - "kb.search before writing", - "kb.propose_* with citations", - "human review via vouch pending/show/approve", - ], - }, - # Extra page kinds beyond the built-in PageType enum. Each maps a kind - # name to {required_fields, frontmatter_schema, required_citations, - # extends}. See `vouch schema list` / docs for the shape. (issue #234) - "page_kinds": {}, - } - - def sha256_hex(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -117,25 +71,8 @@ def sha256_hex(data: bytes) -> str: def discover_root(start: Path | None = None) -> Path: """Walk up from `start` looking for a `.vouch` directory. - Mirrors how git locates its repo root. The walk can be skipped entirely - by setting `VOUCH_KB_PATH=/abs/path/.vouch` (documented in - `adapters/generic-mcp/README.md`) — useful when the host launches the - server from a default cwd (e.g. Claude Desktop on macOS / Windows). + Mirrors how git locates its repo root. """ - forced = os.environ.get("VOUCH_KB_PATH") - if forced: - kb = Path(forced).resolve() - if not kb.is_dir(): - raise KBNotFoundError( - f"VOUCH_KB_PATH={forced!r} is not an existing directory" - ) - if kb.name != KB_DIRNAME: - raise KBNotFoundError( - f"VOUCH_KB_PATH must point at a {KB_DIRNAME!r} directory, " - f"got {forced!r}" - ) - return kb.parent - cur = (start or Path.cwd()).resolve() while True: if (cur / KB_DIRNAME).is_dir(): @@ -155,26 +92,6 @@ def _yaml_load(text: str) -> Any: return yaml.safe_load(text) -_log = logging.getLogger("vouch.storage") - -_ModelT = TypeVar("_ModelT", bound=BaseModel) - - -def _load_or_skip(path: Path, model: type[_ModelT], kind: str) -> _ModelT | None: - """Parse one durable artifact file into ``model``. - - On a corrupt or unreadable file — e.g. a hand-edited yaml or mojibake - carrying a control character that pyyaml's loader rejects — log a warning - and return ``None`` instead of raising, so a single bad file cannot take - down a whole bulk listing (``vouch pending`` and friends). - """ - try: - return model.model_validate(_yaml_load(path.read_text(encoding="utf-8"))) - except (yaml.YAMLError, ValidationError, UnicodeDecodeError, OSError) as e: - _log.warning("skipping unreadable %s %s: %s", kind, path.name, e) - return None - - _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL) @@ -184,7 +101,6 @@ def _serialize_page(page: Page) -> str: def _deserialize_page(text: str) -> Page: - text = text.replace("\r\n", "\n") m = _FRONTMATTER_RE.match(text) if not m: raise ValueError("page file missing YAML frontmatter") @@ -217,14 +133,8 @@ def read_under_root(self, path: str | Path) -> tuple[Path, bytes]: raise ValueError( f"path must be inside project root ({self.root}): {resolved}" ) - if resolved.is_dir(): - raise ValueError(f"not a regular file: {resolved}") - flags = os.O_RDONLY - # POSIX can reject a symlink swapped in after resolve(); Windows has - # no O_NOFOLLOW, so it falls back to the regular-file check below. - flags |= getattr(os, "O_NOFOLLOW", 0) try: - fd = os.open(resolved, flags) + fd = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW) except OSError as e: raise ValueError(f"cannot read {resolved}: {e}") from e try: @@ -246,14 +156,19 @@ def init(cls, root: Path) -> KBStore: for sub in SUBDIRS: (kb.kb_dir / sub).mkdir(exist_ok=True) if not kb.config_path.exists(): - kb.config_path.write_text(_yaml_dump(_starter_config()), encoding="utf-8") - schema_version_file = kb.kb_dir / SCHEMA_VERSION_FILENAME - if not schema_version_file.exists(): - schema_version_file.write_text(SCHEMA_VERSION + "\n", encoding="utf-8") + kb.config_path.write_text( + _yaml_dump( + { + "version": 1, + "review": {"require_human_approval": True}, + "retrieval": {"backends": ["fts5", "substring"]}, + } + ) + ) gi = kb.kb_dir / ".gitignore" if not gi.exists(): # state.db is derived; proposed/ is the agent's scratch space. - gi.write_text("proposed/\ncaptures/\nstate.db\nstate.db-*\n", encoding="utf-8") + gi.write_text("proposed/\nstate.db\nstate.db-*\n") return kb # --- paths ------------------------------------------------------------- @@ -314,7 +229,7 @@ def put_source( content_path.write_bytes(content) meta_path = sdir / "meta.yaml" if meta_path.exists(): - return Source.model_validate(_yaml_load(meta_path.read_text(encoding="utf-8"))) + return Source.model_validate(_yaml_load(meta_path.read_text())) src = Source( id=sid, type=source_type, # type: ignore[arg-type] @@ -326,7 +241,7 @@ def put_source( tags=tags or [], metadata=metadata or {}, ) - meta_path.write_text(_yaml_dump(src.model_dump(mode="json")), encoding="utf-8") + meta_path.write_text(_yaml_dump(src.model_dump(mode="json"))) self._embed_and_store(kind="source", id=src.id, text=src.title or src.locator or "") return src @@ -334,7 +249,7 @@ def get_source(self, source_id: str) -> Source: meta_path = self._source_dir(source_id) / "meta.yaml" if not meta_path.exists(): raise ArtifactNotFoundError(f"source {source_id}") - return Source.model_validate(_yaml_load(meta_path.read_text(encoding="utf-8"))) + return Source.model_validate(_yaml_load(meta_path.read_text())) def read_source_content(self, source_id: str) -> bytes: p = self._source_dir(source_id) / "content" @@ -350,77 +265,11 @@ def list_sources(self) -> list[Source]: for sdir in sorted(sources_dir.iterdir()): meta = sdir / "meta.yaml" if meta.exists(): - src = _load_or_skip(meta, Source, "source") - if src is not None: - out.append(src) + out.append(Source.model_validate(_yaml_load(meta.read_text()))) return out - # --- graph-integrity helpers ------------------------------------------ - - # Closes the structural counterpart of the #81 fix: every graph artifact - # (Relation source/target/evidence, Page entities/sources) must resolve - # to a known artifact in the KB before it lands on disk. The invariant - # was already articulated as a `dangling_relation` finding in - # `health.lint` (`src/vouch/health.py:135-145`) but no write path - # enforced it, so every approve / lifecycle / bundle / sync surface - # silently landed graph edges and pages pointing at nothing. - - def _node_exists(self, node_id: str) -> bool: - """True if `node_id` resolves to a Claim, Page, Entity, or Source.""" - if not node_id: - return False - if self._claim_path(node_id).exists(): - return True - if self._page_path(node_id).exists(): - return True - if self._entity_path(node_id).exists(): - return True - return (self._source_dir(node_id) / "meta.yaml").exists() - - def _evidence_ref_exists(self, ref_id: str) -> bool: - """True if `ref_id` resolves to a Source or Evidence record. - - Matches the citation surface that `put_claim` already accepts: - either a content-hash Source id or an Evidence id. - """ - if not ref_id: - return False - if (self._source_dir(ref_id) / "meta.yaml").exists(): - return True - return self._evidence_path(ref_id).exists() - # --- claims ------------------------------------------------------------ - def _validate_claim_refs(self, claim: Claim) -> None: - """Reject dangling graph references on a Claim before it lands. - - The #124 graph-integrity fix closed `Relation.source/target/evidence` - and `Page.entities/sources` (see the note above `_node_exists`) but - left the Claim's own four reference fields — `entities`, - `supersedes`, `superseded_by`, `contradicts` — unchecked on every - write path. `fsck._check_lifecycle_chains` already declares three of - them as `error`-severity findings (`dangling_supersedes`, - `dangling_superseded_by`, `dangling_contradicts`), so the invariant - was articulated but enforced by no writer. Enforce it here, the same - way `_validate_relation_refs` guards relation endpoints. - - `evidence` is validated separately by `put_claim` (it accepts either - a Source id or an Evidence id, a different resolution surface). - """ - for eid in claim.entities: - if not self._entity_path(eid).exists(): - raise ValueError( - f"claim {claim.id} references unknown entity {eid!r}" - ) - claim_refs = [*claim.supersedes, *claim.contradicts] - if claim.superseded_by is not None: - claim_refs.append(claim.superseded_by) - for cid in claim_refs: - if not self._claim_path(cid).exists(): - raise ValueError( - f"claim {claim.id} references unknown claim {cid!r}" - ) - def put_claim(self, claim: Claim) -> Claim: # Evidence entries can be Source IDs or Evidence IDs -- accept either. for cid_or_sid in claim.evidence: @@ -431,9 +280,8 @@ def put_claim(self, claim: Claim) -> Claim: raise ValueError( f"claim {claim.id} cites unknown source/evidence {cid_or_sid}" ) - self._validate_claim_refs(claim) try: - with self._claim_path(claim.id).open("x", encoding="utf-8") as f: + with self._claim_path(claim.id).open("x") as f: f.write(_yaml_dump(claim.model_dump(mode="json"))) except FileExistsError as e: raise ValueError( @@ -446,53 +294,22 @@ def get_claim(self, claim_id: str) -> Claim: p = self._claim_path(claim_id) if not p.exists(): raise ArtifactNotFoundError(f"claim {claim_id}") - return Claim.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + return Claim.model_validate(_yaml_load(p.read_text())) def list_claims(self) -> list[Claim]: cdir = self.kb_dir / "claims" if not cdir.is_dir(): return [] return [ - c + Claim.model_validate(_yaml_load(p.read_text())) for p in sorted(cdir.glob("*.yaml")) - if (c := _load_or_skip(p, Claim, "claim")) is not None ] def update_claim(self, claim: Claim) -> Claim: if not self._claim_path(claim.id).exists(): raise ArtifactNotFoundError(f"claim {claim.id}") - # Re-validate the in-memory Claim before persisting so model - # invariants (e.g. evidence must be non-empty — see #81) hold - # even when a caller mutated fields in place after get_claim(). - # The Claim model's field validators only run at construction - # time; mutation alone bypasses them unless we round-trip. - Claim.model_validate(claim.model_dump(mode="json")) - # Re-check graph references too: an in-place mutation can introduce a - # dangling entities/supersedes/superseded_by/contradicts link that the - # model validator can't catch (it has no KB access). Mirrors the - # put_claim guard so the update path can't reintroduce the gap. - self._validate_claim_refs(claim) - self._claim_path(claim.id).write_text( - _yaml_dump(claim.model_dump(mode="json")), encoding="utf-8") + self._claim_path(claim.id).write_text(_yaml_dump(claim.model_dump(mode="json"))) self._embed_and_store(kind="claim", id=claim.id, text=claim.text) - # Keep the FTS5 row in sync with the on-disk claim so lifecycle - # mutations (archive, supersede, contradict, confirm) are reflected - # in retrieval immediately. Without this, claims_fts.status stays - # frozen at first-index time and retracted claims keep matching - # kb.search / kb.context. - from . import index_db as _index_db - - try: - with _index_db.open_db(self.kb_dir) as conn: - _index_db.index_claim( - conn, id=claim.id, text=claim.text, - type=claim.type.value, status=claim.status.value, - tags=list(claim.tags), - ) - except sqlite3.Error as e: - _embed_log.warning( - "claim %s: FTS5 reindex skipped on update (%s)", claim.id, e, - ) return claim # --- pages ------------------------------------------------------------- @@ -501,18 +318,8 @@ def put_page(self, page: Page) -> Page: for cid in page.claims: if not self._claim_path(cid).exists(): raise ValueError(f"page {page.id} references unknown claim {cid}") - for eid in page.entities: - if not self._entity_path(eid).exists(): - raise ValueError(f"page {page.id} references unknown entity {eid}") - for sid in page.sources: - if not (self._source_dir(sid) / "meta.yaml").exists(): - raise ValueError(f"page {page.id} references unknown source {sid}") try: - # Explicit UTF-8: page bodies are user / agent prose and routinely - # contain non-ASCII (em-dashes, smart quotes, unicode in claims). - # The default text-mode encoding follows the locale (Latin-1 on a - # bare Linux container), which would mangle anything past 0x7F. - with self._page_path(page.id).open("x", encoding="utf-8") as f: + with self._page_path(page.id).open("x") as f: f.write(_serialize_page(page)) except FileExistsError as e: raise ValueError( @@ -525,40 +332,19 @@ def get_page(self, page_id: str) -> Page: p = self._page_path(page_id) if not p.exists(): raise ArtifactNotFoundError(f"page {page_id}") - return _deserialize_page(p.read_text(encoding="utf-8")) - - def update_page(self, page: Page) -> Page: - """Overwrite an existing page on disk. Used by the vault-edit approve path. - - Parallel to `update_claim`: the caller is responsible for ensuring the - page id already exists (raises ArtifactNotFoundError otherwise). The - embedding index is refreshed so search reflects the new body. - """ - if not self._page_path(page.id).exists(): - raise ArtifactNotFoundError(f"page {page.id}") - self._page_path(page.id).write_text( - _serialize_page(page), encoding="utf-8" - ) - self._embed_and_store( - kind="page", id=page.id, - text=f"{page.title}\n\n{page.body}", - ) - return page + return _deserialize_page(p.read_text()) def list_pages(self) -> list[Page]: pdir = self.kb_dir / "pages" if not pdir.is_dir(): return [] - return [ - _deserialize_page(p.read_text(encoding="utf-8")) - for p in sorted(pdir.glob("*.md")) - ] + return [_deserialize_page(p.read_text()) for p in sorted(pdir.glob("*.md"))] # --- entities ---------------------------------------------------------- def put_entity(self, entity: Entity) -> Entity: try: - with self._entity_path(entity.id).open("x", encoding="utf-8") as f: + with self._entity_path(entity.id).open("x") as f: f.write(_yaml_dump(entity.model_dump(mode="json"))) except FileExistsError as e: raise ValueError( @@ -574,40 +360,20 @@ def get_entity(self, eid: str) -> Entity: p = self._entity_path(eid) if not p.exists(): raise ArtifactNotFoundError(f"entity {eid}") - return Entity.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + return Entity.model_validate(_yaml_load(p.read_text())) def list_entities(self) -> list[Entity]: d = self.kb_dir / "entities" if not d.is_dir(): return [] - return [e for p in sorted(d.glob("*.yaml")) - if (e := _load_or_skip(p, Entity, "entity")) is not None] + return [Entity.model_validate(_yaml_load(p.read_text())) + for p in sorted(d.glob("*.yaml"))] # --- relations --------------------------------------------------------- - def _validate_relation_refs(self, rel: Relation) -> None: - if not self._node_exists(rel.source): - raise ValueError( - f"relation {rel.id} references unknown source endpoint " - f"{rel.source!r} (must be an existing claim, page, entity, " - f"or source id)" - ) - if not self._node_exists(rel.target): - raise ValueError( - f"relation {rel.id} references unknown target endpoint " - f"{rel.target!r} (must be an existing claim, page, entity, " - f"or source id)" - ) - for eid in rel.evidence: - if not self._evidence_ref_exists(eid): - raise ValueError( - f"relation {rel.id} cites unknown source/evidence {eid!r}" - ) - def put_relation(self, rel: Relation) -> Relation: - self._validate_relation_refs(rel) try: - with self._relation_path(rel.id).open("x", encoding="utf-8") as f: + with self._relation_path(rel.id).open("x") as f: f.write(_yaml_dump(rel.model_dump(mode="json"))) except FileExistsError as e: raise ValueError( @@ -619,53 +385,18 @@ def put_relation(self, rel: Relation) -> Relation: ) return rel - def put_relation_idempotent(self, rel: Relation) -> Relation: - """Write a relation only if it does not already exist. - - Used by lifecycle ops (supersede, contradict) that need to converge - to a consistent state on retry without raising if the relation file - was already written in a previous partial execution. - """ - path = self._relation_path(rel.id) - if path.exists(): - self._embed_and_store( - kind="relation", id=rel.id, - text=f"{rel.source} {rel.relation.value} {rel.target}", - ) - return rel - # Validate before the exclusive create. Skipping validation for the - # "already on disk" branch above is deliberate — a relation that's - # already durable was validated when it landed; re-checking would - # turn supersede/contradict retries into spurious failures whenever - # the linked claim was subsequently archived or retracted. - self._validate_relation_refs(rel) - try: - with path.open("x", encoding="utf-8") as f: - f.write(_yaml_dump(rel.model_dump(mode="json"))) - except FileExistsError: - self._embed_and_store( - kind="relation", id=rel.id, - text=f"{rel.source} {rel.relation.value} {rel.target}", - ) - return rel # lost the race — already written, that's fine - self._embed_and_store( - kind="relation", id=rel.id, - text=f"{rel.source} {rel.relation.value} {rel.target}", - ) - return rel - def get_relation(self, rid: str) -> Relation: p = self._relation_path(rid) if not p.exists(): raise ArtifactNotFoundError(f"relation {rid}") - return Relation.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + return Relation.model_validate(_yaml_load(p.read_text())) def list_relations(self) -> list[Relation]: d = self.kb_dir / "relations" if not d.is_dir(): return [] - return [r for p in sorted(d.glob("*.yaml")) - if (r := _load_or_skip(p, Relation, "relation")) is not None] + return [Relation.model_validate(_yaml_load(p.read_text())) + for p in sorted(d.glob("*.yaml"))] def relations_from(self, node_id: str) -> list[Relation]: return [r for r in self.list_relations() if r.source == node_id] @@ -679,7 +410,7 @@ def put_evidence(self, ev: Evidence) -> Evidence: if not (self._source_dir(ev.source_id) / "meta.yaml").exists(): raise ValueError(f"evidence {ev.id} cites unknown source {ev.source_id}") try: - with self._evidence_path(ev.id).open("x", encoding="utf-8") as f: + with self._evidence_path(ev.id).open("x") as f: f.write(_yaml_dump(ev.model_dump(mode="json"))) except FileExistsError as e: raise ValueError( @@ -692,20 +423,20 @@ def get_evidence(self, eid: str) -> Evidence: p = self._evidence_path(eid) if not p.exists(): raise ArtifactNotFoundError(f"evidence {eid}") - return Evidence.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + return Evidence.model_validate(_yaml_load(p.read_text())) def list_evidence(self) -> list[Evidence]: d = self.kb_dir / "evidence" if not d.is_dir(): return [] - return [ev for p in sorted(d.glob("*.yaml")) - if (ev := _load_or_skip(p, Evidence, "evidence")) is not None] + return [Evidence.model_validate(_yaml_load(p.read_text())) + for p in sorted(d.glob("*.yaml"))] # --- sessions ---------------------------------------------------------- def put_session(self, sess: Session) -> Session: try: - with self._session_path(sess.id).open("x", encoding="utf-8") as f: + with self._session_path(sess.id).open("x") as f: f.write(_yaml_dump(sess.model_dump(mode="json"))) except FileExistsError as e: raise ValueError( @@ -719,28 +450,25 @@ def update_session(self, sess: Session) -> Session: # guard against duplicate ids, so updates need a separate path. if not self._session_path(sess.id).exists(): raise ArtifactNotFoundError(f"session {sess.id}") - self._session_path(sess.id).write_text( - _yaml_dump(sess.model_dump(mode="json")), encoding="utf-8") + self._session_path(sess.id).write_text(_yaml_dump(sess.model_dump(mode="json"))) return sess def get_session(self, sid: str) -> Session: p = self._session_path(sid) if not p.exists(): raise ArtifactNotFoundError(f"session {sid}") - return Session.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + return Session.model_validate(_yaml_load(p.read_text())) def list_sessions(self) -> list[Session]: d = self.kb_dir / "sessions" if not d.is_dir(): return [] - return [s for p in sorted(d.glob("*.yaml")) - if (s := _load_or_skip(p, Session, "session")) is not None] + return [Session.model_validate(_yaml_load(p.read_text())) + for p in sorted(d.glob("*.yaml"))] # --- embedding hook ------------------------------------------------------ - def _embed_and_store( - self, *, kind: str, id: str, text: str, force: bool = False - ) -> None: + def _embed_and_store(self, *, kind: str, id: str, text: str) -> None: """Compute and persist an embedding for an artifact. Skipped only if (kind, id) already has an embedding produced by @@ -761,9 +489,7 @@ def _embed_and_store( return try: embedder = get_embedder() - except (KeyError, ImportError): - # No embedder registered, or the registered adapter's heavy deps - # (e.g. sentence-transformers) aren't installed. Best-effort hook. + except KeyError: return try: h = content_hash(text) @@ -771,8 +497,7 @@ def _embed_and_store( # existing is (vec, content_hash, model); skip only when both the # content AND the embedder model match what's on disk. if ( - not force - and existing is not None + existing is not None and existing[1] == h and existing[2] == embedder.name ): @@ -807,7 +532,7 @@ def _embed_and_store( def put_proposal(self, proposal: Proposal) -> Proposal: try: - with self._proposal_path(proposal.id).open("x", encoding="utf-8") as f: + with self._proposal_path(proposal.id).open("x") as f: f.write(_yaml_dump(proposal.model_dump(mode="json"))) except FileExistsError as e: raise ValueError( @@ -818,16 +543,14 @@ def put_proposal(self, proposal: Proposal) -> 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(): - return Proposal.model_validate(_yaml_load(path.read_text(encoding="utf-8"))) + return Proposal.model_validate(_yaml_load(path.read_text())) raise ArtifactNotFoundError(f"proposal {proposal_id}") def list_proposals(self, status: ProposalStatus | None = None) -> list[Proposal]: out: list[Proposal] = [] for sub in ("proposed", "decided"): for p in sorted((self.kb_dir / sub).glob("*.yaml")): - pr = _load_or_skip(p, Proposal, "proposal") - if pr is None: - continue + pr = Proposal.model_validate(_yaml_load(p.read_text())) if status is None or pr.status == status: out.append(pr) return out @@ -835,7 +558,7 @@ def list_proposals(self, status: ProposalStatus | None = None) -> list[Proposal] def move_proposal_to_decided(self, proposal: Proposal) -> None: src = self._proposal_path(proposal.id) dst = self._decided_path(proposal.id) - dst.write_text(_yaml_dump(proposal.model_dump(mode="json")), encoding="utf-8") + dst.write_text(_yaml_dump(proposal.model_dump(mode="json"))) if src.exists(): src.unlink() 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/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 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/embeddings/conftest.py b/tests/embeddings/conftest.py index f37cab0d..3d9fec49 100644 --- a/tests/embeddings/conftest.py +++ b/tests/embeddings/conftest.py @@ -12,26 +12,6 @@ are installed, the skip is a no-op and the tests run normally. """ -from collections.abc import Iterator - import pytest pytest.importorskip("numpy") - - -@pytest.fixture(autouse=True) -def _isolate_embedder_registry() -> Iterator[None]: - """Snapshot and restore the global adapter registry around each test. - - Tests here register a MockEmbedder as the default adapter. `_REGISTRY` - is module-global, so without this the registration leaks into later - top-level tests (e.g. test_cli search-backend-label tests) and flips - their expected backend from fts5/substring to embedding. - """ - from vouch.embeddings import base - saved = dict(base._REGISTRY) - try: - yield - finally: - base._REGISTRY.clear() - base._REGISTRY.update(saved) diff --git a/tests/embeddings/test_integration.py b/tests/embeddings/test_integration.py index 6f685503..77429236 100644 --- a/tests/embeddings/test_integration.py +++ b/tests/embeddings/test_integration.py @@ -6,8 +6,6 @@ from __future__ import annotations -from pathlib import Path - import numpy as np import pytest @@ -45,31 +43,6 @@ def test_st_minilm_loads_and_encodes() -> None: assert vec.dtype == np.float32 -@pytest.mark.integration -def test_semantic_search_finds_lexically_disjoint_claim(tmp_path: Path) -> None: - """The headline acceptance criterion from the spec.""" - from vouch import index_db - from vouch.models import Claim - from vouch.storage import KBStore - - store = KBStore.init(tmp_path) - src = store.put_source(b"e") - store.put_claim(Claim( - id="auth-claim", - text="login flow uses session cookies signed by the API", - evidence=[src.id], - )) - store.put_claim(Claim( - id="unrelated", - text="the sun is large and hot", - evidence=[src.id], - )) - hits = index_db.search_semantic( - store.kb_dir, "how do we authenticate users", limit=5, - ) - assert hits[0][1] == "auth-claim" - - @pytest.mark.integration def test_fastembed_bge_loads_and_encodes() -> None: pytest.importorskip("fastembed") diff --git a/tests/embeddings/test_search.py b/tests/embeddings/test_search.py index 9b6a053f..a25d4ed0 100644 --- a/tests/embeddings/test_search.py +++ b/tests/embeddings/test_search.py @@ -109,8 +109,8 @@ def test_jsonl_search_uses_embedding_backend( "params": {"query": "claim about logins"}, }) assert resp["ok"] is True - assert resp["result"]["hits"] - assert resp["result"]["backend"] in ("embedding", "fts5", "substring") + assert resp["result"] + assert resp["result"][0]["backend"] in ("embedding", "fts5", "substring") def test_kb_search_defaults_to_semantic_then_fts5( @@ -128,41 +128,6 @@ def test_kb_search_defaults_to_semantic_then_fts5( assert result["hits"][0]["id"] == "c1" -def test_mcp_kb_reindex_embeddings(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - from vouch import server - monkeypatch.setattr(server, "_store", lambda: store) - src = store.put_source(b"e") - store.put_claim(Claim(id="c1", text="x", evidence=[src.id])) - out = server.kb_reindex_embeddings(backfill=True) - assert out["touched"] >= 1 - - -def test_mcp_kb_dedup_scan(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - from vouch import server - monkeypatch.setattr(server, "_store", lambda: store) - out = server.kb_dedup_scan(threshold=0.95, dry_run=True) - assert "duplicates" in out - - -def test_mcp_kb_embeddings_stats(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - from vouch import server - monkeypatch.setattr(server, "_store", lambda: store) - out = server.kb_embeddings_stats() - assert "model" in out - - -def test_jsonl_kb_reindex_embeddings(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - from vouch import jsonl_server - monkeypatch.setattr(jsonl_server, "_store", lambda: store) - src = store.put_source(b"e") - store.put_claim(Claim(id="c1", text="x", evidence=[src.id])) - resp = jsonl_server.handle_request({ - "id": "1", "method": "kb.reindex_embeddings", - "params": {"backfill": True}, - }) - assert resp["ok"] is True - - def test_search_semantic_returns_top_hits(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="alpha alpha alpha", evidence=[src.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_capabilities.py b/tests/test_capabilities.py index 736b20ba..4d4dcc27 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,88 @@ 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 package.json (openclaw.plugin.json +# bans openclaw.* dead dialect fields). The same value must surface in +# kb.capabilities so non-OpenClaw clients can detect compat without parsing +# package.json. These tests fail CI with a clear message if the two +# declarations drift apart. + +_PACKAGE_JSON_PATH = ( + Path(__file__).resolve().parent.parent / "package.json" +) + + +def _package_plugin_api() -> str: + manifest = json.loads(_PACKAGE_JSON_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 package.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 = _package_plugin_api() + capabilities_range = caps.host_compat.get("openclaw", {}).get("pluginApi") + assert capabilities_range == manifest_range, ( + f"host compat drift: package.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 + package.json is absent -- e.g. installed as a standalone wheel without + package.json packaged alongside it.""" + monkeypatch.setattr( + capabilities, "_PACKAGE_JSON_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 package.json must not crash capabilities() -- it's + reporting diagnostic info, not validating the install.""" + bad = tmp_path / "package.json" + bad.write_text("{not valid json", encoding="utf-8") + monkeypatch.setattr(capabilities, "_PACKAGE_JSON_PATH", bad) + assert capabilities._load_host_compat() == {} + + +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}" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d95c277..6fa10343 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,21 +10,12 @@ from __future__ import annotations -import json -import os -import subprocess -import sys from pathlib import Path -from typing import Any -from unittest.mock import patch import pytest -import yaml from click.testing import CliRunner -from vouch import sessions as sess_mod from vouch.cli import cli -from vouch.models import ProposalKind, ProposalStatus from vouch.proposals import propose_claim from vouch.storage import KBStore @@ -64,586 +55,26 @@ def test_reject_empty_reason_shows_clean_error(store: KBStore) -> None: def test_propose_claim_empty_text_shows_clean_error(store: KBStore) -> None: src = store.put_source(b"e") - result = CliRunner().invoke(cli, ["propose-claim", "--text", " ", "--source", src.id]) + result = CliRunner().invoke( + cli, ["propose-claim", "--text", " ", "--source", src.id] + ) _assert_clean_error(result, "claim text") def test_propose_claim_unknown_source_shows_clean_error(store: KBStore) -> None: - result = CliRunner().invoke(cli, ["propose-claim", "--text", "ok", "--source", "deadbeef"]) + result = CliRunner().invoke( + cli, ["propose-claim", "--text", "ok", "--source", "deadbeef"] + ) _assert_clean_error(result, "unknown source") def test_propose_entity_empty_name_shows_clean_error(store: KBStore) -> None: - result = CliRunner().invoke(cli, ["propose-entity", "--name", " ", "--type", "project"]) + result = CliRunner().invoke( + cli, ["propose-entity", "--name", " ", "--type", "project"] + ) _assert_clean_error(result, "entity name") def test_show_missing_proposal_shows_clean_error(store: KBStore) -> None: result = CliRunner().invoke(cli, ["show", "no-such-proposal"]) _assert_clean_error(result, "proposal no-such-proposal") - - -def test_fsck_clean_kb_prints_clean_and_exits_zero(store: KBStore) -> None: - """`vouch fsck` on a fresh KB exits 0 and only emits info-level findings.""" - from vouch.models import Claim - - src = store.put_source(b"e") - store.put_claim(Claim(id="c1", text="t", evidence=[src.id])) - result = CliRunner().invoke(cli, ["fsck"]) - # No state.db yet → info finding, but report.ok stays True. - assert result.exit_code == 0, result.output - assert "[index_missing]" in result.output - - -def test_fsck_reports_dangling_chain_and_exits_nonzero(store: KBStore) -> None: - """`vouch fsck` exits 1 on error findings and prints affected object ids.""" - from vouch.models import Claim - from vouch.storage import _yaml_dump - - src = store.put_source(b"e") - # Write straight to disk: put_claim now rejects dangling graph refs - # (_validate_claim_refs), so this reproduces the legacy/poisoned claim - # YAML that fsck must still surface after the write path is tightened. - bad = Claim(id="c1", text="t", evidence=[src.id], supersedes=["ghost"]) - (store.kb_dir / "claims" / "c1.yaml").write_text( - _yaml_dump(bad.model_dump(mode="json")) - ) - result = CliRunner().invoke(cli, ["fsck"]) - assert result.exit_code == 1, result.output - assert "dangling_supersedes" in result.output - # The affected object ids are surfaced inline so users can grep / pipe. - assert "(objects: c1, ghost)" in result.output - - -def test_pending_json_empty_queue(store: KBStore) -> None: - result = CliRunner().invoke(cli, ["pending", "--json"]) - - assert result.exit_code == 0, result.output - assert json.loads(result.output) == [] - - -def test_pending_json_lists_pending_proposals(store: KBStore) -> None: - src = store.put_source(b"e") - pr = propose_claim(store, text="pending json claim", evidence=[src.id], proposed_by="agent") - - result = CliRunner().invoke(cli, ["pending", "--json"]) - - assert result.exit_code == 0, result.output - rows = json.loads(result.output) - assert len(rows) == 1 - assert rows[0]["id"] == pr.id - assert rows[0]["kind"] == "claim" - assert rows[0]["proposed_by"] == "agent" - assert rows[0]["status"] == "pending" - assert rows[0]["payload"]["text"] == "pending json claim" - - -def test_pending_human_output_remains_text(store: KBStore) -> None: - src = store.put_source(b"e") - pr = propose_claim(store, text="pending text claim", evidence=[src.id], proposed_by="agent") - - result = CliRunner().invoke(cli, ["pending"]) - - assert result.exit_code == 0, result.output - assert pr.id in result.output - assert "[claim] by agent" in result.output - assert "pending text claim" in result.output - - -def test_review_approves_pending_proposal(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("VOUCH_AGENT", raising=False) - monkeypatch.setenv("VOUCH_USER", "reviewer") - src = store.put_source(b"e") - pr = propose_claim( - store, - text="review can approve", - evidence=[src.id], - proposed_by="agent", - ) - - result = CliRunner().invoke(cli, ["review"], input="a\n\n") - - assert result.exit_code == 0, result.output - assert "Approved -> claim/review-can-approve" in result.output - assert store.get_claim("review-can-approve").text == "review can approve" - assert store.get_proposal(pr.id).status == ProposalStatus.APPROVED - - -def test_review_rejects_with_reason(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("VOUCH_AGENT", raising=False) - monkeypatch.setenv("VOUCH_USER", "reviewer") - src = store.put_source(b"e") - pr = propose_claim( - store, - text="review can reject", - evidence=[src.id], - proposed_by="agent", - ) - - result = CliRunner().invoke(cli, ["review"], input="r\nnot true\n") - - assert result.exit_code == 0, result.output - assert f"Rejected {pr.id}" in result.output - decided = store.get_proposal(pr.id) - assert decided.status == ProposalStatus.REJECTED - assert decided.decision_reason == "not true" - - -def test_review_skip_and_quit_leave_proposals_pending(store: KBStore) -> None: - src = store.put_source(b"e") - first = propose_claim( - store, - text="review can skip", - evidence=[src.id], - proposed_by="agent", - ) - second = propose_claim( - store, - text="review can quit", - evidence=[src.id], - proposed_by="agent", - ) - - result = CliRunner().invoke(cli, ["review"], input="s\nq\n") - - assert result.exit_code == 0, result.output - assert "Skipped " in result.output - assert "Stopped review" in result.output - assert store.get_proposal(first.id).status == ProposalStatus.PENDING - assert store.get_proposal(second.id).status == ProposalStatus.PENDING - - -def test_review_dry_run_does_not_mutate(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("VOUCH_AGENT", raising=False) - monkeypatch.setenv("VOUCH_USER", "reviewer") - src = store.put_source(b"e") - pr = propose_claim( - store, - text="review dry run", - evidence=[src.id], - proposed_by="agent", - ) - - result = CliRunner().invoke(cli, ["review", "--dry-run"], input="a\n\n") - - assert result.exit_code == 0, result.output - assert f"Would approve {pr.id}" in result.output - assert store.get_proposal(pr.id).status == ProposalStatus.PENDING - with pytest.raises(KeyError): - store.get_claim("review-dry-run") - - -def test_review_dry_run_reject_does_not_mutate(store: KBStore) -> None: - src = store.put_source(b"e") - pr = propose_claim( - store, - text="review dry run reject", - evidence=[src.id], - proposed_by="agent", - ) - - result = CliRunner().invoke(cli, ["review", "--dry-run"], input="r\nnot true\n") - - assert result.exit_code == 0, result.output - assert f"Would reject {pr.id}" in result.output - pending = store.get_proposal(pr.id) - assert pending.status == ProposalStatus.PENDING - assert pending.decision_reason is None - - -def test_search_fts5_backend_label(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - """vouch search prints (fts5) when FTS5 returns hits.""" - from vouch import index_db - from vouch.proposals import approve as do_approve - from vouch.proposals import propose_claim - - src = store.put_source(b"e") - pr = propose_claim(store, text="findable token", evidence=[src.id], proposed_by="agent") - do_approve(store, pr.id, approved_by="reviewer") - # Index only the FTS5 tables directly — no embedding stack needed - with index_db.open_db(store.kb_dir) as conn: - index_db.index_claim( - conn, - id="c-findable", - text="findable token", - type="observation", - status="actionable", - tags=[], - ) - runner = CliRunner() - result = runner.invoke(cli, ["search", "findable"]) - assert result.exit_code == 0, result.output - assert "(fts5)" in result.output - - -def test_search_substring_backend_label(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - """vouch search prints (substring) when FTS5 raises and fallback runs.""" - from vouch.proposals import approve as do_approve - from vouch.proposals import propose_claim - - src = store.put_source(b"e") - pr = propose_claim(store, text="findable token", evidence=[src.id], proposed_by="agent") - do_approve(store, pr.id, approved_by="reviewer") - # Remove state.db so FTS5 raises and substring fallback runs - state_db = store.kb_dir / "state.db" - if state_db.exists(): - state_db.unlink() - runner = CliRunner() - result = runner.invoke(cli, ["search", "findable"]) - assert result.exit_code == 0, result.output - assert "(substring)" in result.output - - -def test_crystallize_cli_all_failures_exits_1(store: KBStore) -> None: - with patch.object(KBStore, "_embed_and_store"): - src = store.put_source(b"e") - sess = sess_mod.session_start(store, agent="a", task="t") - propose_claim(store, text="t1", evidence=[src.id], proposed_by="a", session_id=sess.id) - propose_claim(store, text="t2", evidence=[src.id], proposed_by="a", session_id=sess.id) - sess_mod.session_end(store, sess.id) - - with patch("vouch.sessions.approve", side_effect=ValueError("storage full")): - result = CliRunner().invoke(cli, ["crystallize", sess.id]) - - assert result.exit_code == 1 - assert "error:" in result.stderr - assert "all 2 proposal(s) failed" in result.stderr - - -def test_crystallize_cli_partial_failures_shows_warning(store: KBStore, monkeypatch) -> None: - from vouch.proposals import approve as real_approve - - # the approver comes from _whoami() (VOUCH_AGENT/VOUCH_USER/getpass); pin it - # to a reviewer distinct from the proposer so the non-injected approval isn't - # blocked as a self-approval on machines whose OS username happens to be "a". - monkeypatch.delenv("VOUCH_AGENT", raising=False) - monkeypatch.setenv("VOUCH_USER", "reviewer") - with patch.object(KBStore, "_embed_and_store"): - src = store.put_source(b"e") - sess = sess_mod.session_start(store, agent="a", task="t") - propose_claim(store, text="t1", evidence=[src.id], proposed_by="a", session_id=sess.id) - propose_claim(store, text="t2", evidence=[src.id], proposed_by="a", session_id=sess.id) - sess_mod.session_end(store, sess.id) - - call_count = 0 - - def _side_effect(store, proposal_id, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise ValueError("storage full") - return real_approve(store, proposal_id, **kwargs) - - with ( - patch("vouch.sessions.approve", side_effect=_side_effect), - patch.object(KBStore, "_embed_and_store"), - ): - result = CliRunner().invoke(cli, ["crystallize", sess.id]) - - assert result.exit_code == 0 - assert "warning:" in result.stderr - assert "1/2 proposal(s) failed" in result.stderr - - -# --- batch approval (#93) ------------------------------------------------- - - -def _propose_n(store: KBStore, n: int) -> list[str]: - src = store.put_source(b"e") - ids = [] - for i in range(n): - pr = propose_claim(store, text=f"batch claim {i}", evidence=[src.id], proposed_by="agent") - ids.append(pr.id) - return ids - - -def test_approve_batch_approves_all(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("VOUCH_AGENT", raising=False) - ids = _propose_n(store, 3) - result = CliRunner().invoke(cli, ["approve", *ids]) - assert result.exit_code == 0, result.output - pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} - for pid in ids: - assert pid not in pending - assert result.output.count("Approved") == 3 - - -def test_approve_batch_one_audit_event_per_artifact( - store: KBStore, monkeypatch: pytest.MonkeyPatch -) -> None: - from vouch import audit - - monkeypatch.delenv("VOUCH_AGENT", raising=False) - ids = _propose_n(store, 2) - CliRunner().invoke(cli, ["approve", *ids]) - approve_events = [e for e in audit.read_events(store.kb_dir) if e.event.endswith(".approve")] - assert len(approve_events) == 2 - - -def test_approve_batch_atomic_aborts_on_bad_id( - store: KBStore, monkeypatch: pytest.MonkeyPatch -) -> None: - """Default is all-or-nothing: one bad id approves nothing.""" - monkeypatch.delenv("VOUCH_AGENT", raising=False) - good = _propose_n(store, 2) - result = CliRunner().invoke(cli, ["approve", good[0], "no-such-id", good[1]]) - assert result.exit_code != 0, result.output - assert "Traceback" not in result.output - # Nothing approved — both good proposals are still pending. - for cid in good: - assert cid in {p.id for p in store.list_proposals(ProposalStatus.PENDING)} - - -def test_approve_batch_keep_going_best_effort( - store: KBStore, monkeypatch: pytest.MonkeyPatch -) -> None: - """--keep-going approves what it can and exits non-zero on partial failure.""" - monkeypatch.delenv("VOUCH_AGENT", raising=False) - good = _propose_n(store, 2) - result = CliRunner().invoke(cli, ["approve", "--keep-going", good[0], "no-such-id", good[1]]) - assert result.exit_code != 0, result.output - # Both valid proposals were approved despite the bad id in the middle. - pending = {p.id for p in store.list_proposals(ProposalStatus.PENDING)} - assert good[0] not in pending - assert good[1] not in pending - - -def test_serve_fails_fast_without_kb(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """vouch serve must exit non-zero with a clear message when no .vouch/ KB exists.""" - monkeypatch.chdir(tmp_path) - result = CliRunner().invoke(cli, ["serve", "--transport", "jsonl"]) - assert result.exit_code != 0 - assert "vouch init" in result.output or "vouch init" in (result.stderr or "") - - -def test_propose_claim_corrupt_source_surfaces_real_error(store: KBStore) -> None: - """Corrupt meta.yaml must surface a parse error, not 'unknown source/evidence id'.""" - import pytest - - from vouch.proposals import ProposalError, propose_claim - - src = store.put_source(b"evidence content") - meta = store.kb_dir / "sources" / src.id / "meta.yaml" - meta.write_text("{ invalid: yaml: [") - - with pytest.raises(Exception) as excinfo: - propose_claim(store, text="some claim", evidence=[src.id], proposed_by="agent") - assert not ( - isinstance(excinfo.value, ProposalError) - and "unknown source/evidence id" in str(excinfo.value) - ), f"real parse error was masked: {excinfo.value}" - - -# --- vouch new (issue #330) ------------------------------------------------ - - -def _declare_page_kinds(store: KBStore, kinds: dict[str, Any]) -> None: - cfg = yaml.safe_load(store.config_path.read_text()) - assert isinstance(cfg, dict) - cfg["page_kinds"] = kinds - store.config_path.write_text(yaml.safe_dump(cfg)) - - -def test_new_decision_page_stubs_frontmatter(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "decision", "--title", "pick X"]) - assert res.exit_code == 0, res.output - proposal_id = res.output.strip() - pr = store.get_proposal(proposal_id) - assert pr.kind == ProposalKind.PAGE - assert pr.status == ProposalStatus.PENDING - assert pr.payload["type"] == "decision" - assert pr.payload["title"] == "pick X" - assert pr.payload["metadata"] == {} - - -def test_new_person_entity(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "person", "--name", "alice-example"]) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.ENTITY - assert pr.payload["type"] == "person" - assert pr.payload["name"] == "alice-example" - - -def test_new_field_prefill_parsed_as_yaml(store: KBStore) -> None: - _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) - runner = CliRunner() - res = runner.invoke( - cli, - [ - "new", - "meeting-notes", - "--title", - "Sync", - "--field", - "attendees=[alice-example, bob-example]", - "--field", - "date=2026-07-01", - ], - ) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] - assert pr.payload["metadata"]["date"] == "2026-07-01" - - -def test_new_interactive_prompts_required_fields(store: KBStore) -> None: - _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees", "date"]}}) - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "meeting-notes", "--title", "Sync", "--interactive"], - input="[alice-example, bob-example]\n2026-07-01\n", - ) - assert res.exit_code == 0, res.output - pending = store.list_proposals(ProposalStatus.PENDING) - assert len(pending) == 1 - pr = pending[0] - assert pr.payload["metadata"]["attendees"] == ["alice-example", "bob-example"] - assert pr.payload["metadata"]["date"] == "2026-07-01" - - -def test_new_dry_run_writes_nothing(store: KBStore) -> None: - _declare_page_kinds(store, {"meeting-notes": {"required_fields": ["attendees"]}}) - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "meeting-notes", "--title", "Sync", "--dry-run"], - ) - assert res.exit_code == 0, res.output - assert "missing required fields: attendees" in res.output - assert store.list_proposals(ProposalStatus.PENDING) == [] - - -def test_new_dry_run_json(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "decision", "--title", "pick X", "--dry-run", "--json"], - ) - assert res.exit_code == 0, res.output - body = json.loads(res.output) - assert body["dry_run"] is True - assert body["target"] == "page" - assert body["kind"] == "decision" - assert body["title"] == "pick X" - assert "id" in body - assert store.list_proposals(ProposalStatus.PENDING) == [] - - -def test_new_collision_decision_defaults_to_page(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "decision", "--title", "pick Y"]) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.PAGE - - -def test_new_collision_decision_entity_flag(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "decision", "--entity", "--name", "pick-z"], - ) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.ENTITY - assert pr.payload["type"] == "decision" - - -def test_new_project_routes_to_entity(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "project", "--name", "vouch-example"]) - assert res.exit_code == 0, res.output - pr = store.get_proposal(res.output.strip()) - assert pr.kind == ProposalKind.ENTITY - assert pr.payload["type"] == "project" - - -def test_new_unknown_kind_lists_known(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "not-a-kind", "--title", "X"]) - assert res.exit_code != 0, res.output - assert "unknown kind" in res.output - assert "decision" in res.output - assert "person" in res.output - - -def test_new_required_citations_reminder_in_body(store: KBStore) -> None: - _declare_page_kinds(store, {"cited": {"required_citations": True}}) - runner = CliRunner() - res = runner.invoke(cli, ["new", "cited", "--title", "needs cites", "--dry-run"]) - assert res.exit_code == 0, res.output - assert "citations required" in res.output - assert "attach at least one claim or source" in res.output - - -def test_new_required_citations_non_dry_run_errors(store: KBStore) -> None: - _declare_page_kinds(store, {"cited": {"required_citations": True}}) - runner = CliRunner() - res = runner.invoke(cli, ["new", "cited", "--title", "needs cites"]) - assert res.exit_code != 0, res.output - assert "requires citations" in res.output - - -def test_new_invalid_field_yaml(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke( - cli, - ["new", "decision", "--title", "X", "--field", "x=["], - ) - assert res.exit_code != 0, res.output - assert "invalid YAML" in res.output - - -def test_new_pending_not_approved(store: KBStore) -> None: - runner = CliRunner() - res = runner.invoke(cli, ["new", "concept", "--title", "draft page"]) - assert res.exit_code == 0, res.output - assert len(store.list_pages()) == 0 - pending = store.list_proposals(ProposalStatus.PENDING) - assert len(pending) == 1 - - -# --- non-utf-8 stdio -------------------------------------------------------- - - -@pytest.mark.parametrize( - ("args", "needle"), - [ - # '•' separators in the status summary - (["status"], "durable"), - # eager help renders the em-dash group docstring BEFORE the group - # callback runs, so a callback-scoped reconfigure can't save it — - # this is why _force_utf8_stdio() runs at module import. - (["--help"], "review-gated"), - ], -) -def test_cli_survives_non_utf8_stdio(tmp_path: Path, args: list[str], needle: str) -> None: - """1.1.0 regression: under a non-utf-8 locale (LANG=en_US.ISO-8859-1) - click encoded stdout with the locale codec, so any non-ascii glyph in - CLI output raised UnicodeEncodeError. The CLI forces utf-8 stdio at - import (replacing where the stream can't), so these must exit 0. - """ - KBStore.init(tmp_path) - env = { - k: v for k, v in os.environ.items() if k not in ("PYTHONUTF8", "PYTHONIOENCODING") - } - # PYTHONIOENCODING pins the stream codec no matter which locales the - # host has generated — same failure mode as LANG=en_US.ISO-8859-1. - env["PYTHONIOENCODING"] = "latin-1" - proc = subprocess.run( - [sys.executable, "-m", "vouch", *args], - cwd=tmp_path, - env=env, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=120, - ) - assert proc.returncode == 0, proc.stderr - assert "UnicodeEncodeError" not in proc.stderr, proc.stderr - assert needle in proc.stdout, proc.stdout 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() diff --git a/tests/test_codex_rollout.py b/tests/test_codex_rollout.py new file mode 100644 index 00000000..2969008c --- /dev/null +++ b/tests/test_codex_rollout.py @@ -0,0 +1,461 @@ +"""`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", encoding="utf-8" + ) + 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", encoding="utf-8" + ) + 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() == [] + + +# --- 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 ---------------------------------------------------- + + +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 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" 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_hooks.py b/tests/test_hooks.py new file mode 100644 index 00000000..9a096eff --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,85 @@ +"""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"})) == "" + + +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 diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index 83036e8c..64fe097f 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -319,6 +319,444 @@ 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: 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: 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) --------------------- + + +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 ---------------------------------------------------------- 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"] diff --git a/tests/test_mcp_profiles.py b/tests/test_mcp_profiles.py new file mode 100644 index 00000000..30bff123 --- /dev/null +++ b/tests/test_mcp_profiles.py @@ -0,0 +1,92 @@ +"""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" + + +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" + + +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." diff --git a/tests/test_retrieval_backend.py b/tests/test_retrieval_backend.py index 2dc30cd6..dcff5372 100644 --- a/tests/test_retrieval_backend.py +++ b/tests/test_retrieval_backend.py @@ -79,23 +79,101 @@ 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"} + + +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"} + + +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"] + + +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"] 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()]) diff --git a/tests/test_storage.py b/tests/test_storage.py index 03ce0fba..7e0ccdd6 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)) 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) 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" diff --git a/web/app.css b/web/app.css deleted file mode 100644 index 3d710b86..00000000 --- a/web/app.css +++ /dev/null @@ -1,366 +0,0 @@ -/* ============================================================ - app.css — the interactive layer for the Gittensor fascicle. - - Loaded after the inline