diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f50ca4cc..22dbaa64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: install run: | python -m pip install --upgrade pip - pip install -e '.[dev]' + pip install -e '.[dev,web]' - name: lint run: python -m ruff check src tests diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml new file mode 100644 index 00000000..150b6f22 --- /dev/null +++ b/.github/workflows/eval.yml @@ -0,0 +1,39 @@ +name: eval + +# Gate retrieval quality: score kb.context against the committed labeled set +# and fail on a P@5 regression beyond tolerance vs eval/baseline.json. Runs +# only when retrieval code changes. +on: + pull_request: + paths: + - "src/vouch/embeddings/**" + - "src/vouch/context.py" + - "src/vouch/eval/**" + - "eval/**" + +jobs: + recall: + name: recall eval + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + + - name: build fixture index + working-directory: eval/fixture-kb + run: python -m vouch.cli reindex + + - name: recall eval (fail on P@5 regression > 5%) + working-directory: eval/fixture-kb + run: >- + python -m vouch.cli eval recall ../queries.jsonl + --k 5 --baseline ../baseline.json --max-regression 0.05 diff --git a/.github/workflows/install-sh.yml b/.github/workflows/install-sh.yml new file mode 100644 index 00000000..066ea25e --- /dev/null +++ b/.github/workflows/install-sh.yml @@ -0,0 +1,86 @@ +name: install-sh + +# Validates install.sh on every push that touches it: +# * shellcheck (lint) +# * POSIX-syntax check via dash +# * end-to-end smoke run on a fresh ubuntu-latest — installs the published +# vouch-kb wheel via pipx and verifies `vouch --version` +# +# The smoke run intentionally exercises the published PyPI artifact, not the +# in-repo source, so we catch installation breakage that doesn't show up in +# the regular pytest suite (e.g. a stale [web] extra reference). +# +# Supply-chain notes: this workflow pins third-party actions to their full +# commit SHA (the comment marks the human-readable tag). The rest of the +# repo's workflows still use tag pins — a sweep of ci.yml / release.yml / +# schema-check.yml to match this pattern is a worthwhile follow-up but +# kept out of this PR to avoid churning unrelated CI. + +on: + push: + branches: [main, release/*] + paths: + - "install.sh" + - ".github/workflows/install-sh.yml" + pull_request: + paths: + - "install.sh" + - ".github/workflows/install-sh.yml" + workflow_dispatch: + +# Least-privilege at the workflow level; the smoke job needs nothing +# beyond reading the checked-out repo. Jobs that need more bump it up +# explicitly. +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # Don't leave the token sitting in .git/config after checkout — + # a leaked credentials file would otherwise allow pushing back. + persist-credentials: false + + - name: shellcheck + run: | + sudo apt-get update -qq + sudo apt-get install -y shellcheck dash + shellcheck --version + shellcheck install.sh + + - name: posix syntax (dash -n) + run: dash -n install.sh + + smoke: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: "3.12" + + - name: --help works + run: sh ./install.sh --help + + - name: end-to-end install + smoke + run: | + set -e + sh ./install.sh --no-claude + # pipx's bin dir isn't necessarily on PATH for the verification + # step — re-export from pipx itself. Query via the pipx CLI: the + # runner ships pipx as a standalone binary that's on PATH but NOT + # importable as `python -m pipx`, so that form fails with + # "No module named pipx". Fall back to the conventional + # ~/.local/bin (pipx's default PIPX_BIN_DIR) if the query fails. + PIPX_BIN=$(pipx environment --value PIPX_BIN_DIR 2>/dev/null || true) + [ -n "$PIPX_BIN" ] || PIPX_BIN="$HOME/.local/bin" + export PATH="$PIPX_BIN:$PATH" + vouch --version + vouch capabilities | head -20 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f5735c66 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,181 @@ +# Agents working with vouch + +Entry document for any AI coding agent reading this repository — Cursor, Codex, +OpenClaw, Aider, Continue, JetBrains AI, an LLM fetching the raw URL, anything +that isn't Claude Code. (Claude Code reads [`CLAUDE.md`](./CLAUDE.md) instead; +the two files complement each other.) + +If your task is to **work on the vouch codebase itself**, read this file and +then `CLAUDE.md`. If your task is to **use vouch from inside another +project**, read [`README.md`](./README.md) for install + concepts and +[`docs/getting-started.md`](./docs/getting-started.md) for the agent-side +loop. + +If you're an **OpenClaw** plugin loader, the plugin manifest is at the repo +root: [`openclaw.plugin.json`](./openclaw.plugin.json). It declares vouch's +MCP wiring, the four slash commands, the trust boundary (write tools +review-gated, lifecycle ops audit-logged, remote-caller filesystem +confined), and the config schema (`kb_path`, `agent`, `transport` — no +secrets). No additional wiring is required to surface vouch's `kb.*` +surface inside an OpenClaw deployment. + +## What vouch is, in one paragraph + +Vouch is a git-native, review-gated knowledge base for LLM agents. Agents +propose writes via an MCP server (or a JSONL pipe); a human approves each +proposal with `vouch approve`. Approved artifacts land as YAML claims and +markdown pages under `.vouch/` — plain files that diff cleanly in PRs and +travel as a tarball bundle. The CLI is `vouch`; the PyPI distribution is +`vouch-kb`; supported Python versions are 3.11, 3.12, 3.13. + +## Install (1 minute) + +```bash +curl -fsSL https://raw.githubusercontent.com/vouchdev/vouch/main/install.sh | sh +``` + +Or, deterministically from a clone (the path you want when contributing): + +```bash +git clone https://github.com/vouchdev/vouch.git +cd vouch +python3 -m venv .venv && . .venv/bin/activate +pip install -e '.[dev]' +``` + +## Read in this order + +1. **This file** — entry, install, trust boundary, common tasks. +2. [`CLAUDE.md`](./CLAUDE.md) — orientation for working on the repo: + architecture, conventions, ship rules, voice. Read even if you aren't + Claude Code; the conventions are universal. +3. [`README.md`](./README.md) — the user-facing pitch, install, quick start, + full CLI surface, MCP / JSONL method list. +4. [`SPEC.md`](./SPEC.md) — the canonical protocol description: `.vouch/` + layout, object model, `kb.*` method shapes, review-gate state machine. + Authoritative when in doubt. +5. [`ROADMAP.md`](./ROADMAP.md) — what's planned for 0.2 and 0.3. Don't + propose features that are already scoped here. + +For everything else, see [`llms.txt`](./llms.txt) — the LLM-readable map of +every document in the repo. + +## Trust boundary + +Every `kb.*` write tool goes through the review gate. There is no "trusted +agent shortcut" except an opt-in `review.approver_role: trusted-agent` in +the KB's `config.yaml` (off by default). Concretely: + +* `kb.propose_*` writes a YAML file to `.vouch/proposed/` — never to + `.vouch/claims/` directly. +* `kb.approve` requires `approved_by != proposed_by` unless the trusted + shortcut is on. +* Lifecycle ops (`kb.supersede`, `kb.contradict`, `kb.archive`, + `kb.confirm`) mutate durable artifacts because they're metadata about + reviewed knowledge, not new assertions. They still land an audit event. +* The MCP server, the JSONL server, and the CLI all share the same + storage + proposals + audit code path. The web review-ui (PR #195, + pending) is a *viewport*, not a parallel data path. + +If you're proposing a fix that bypasses the gate, you're proposing the +wrong fix. + +## Common agent tasks + +### Use vouch from another project (the normal case) + +```bash +cd /path/to/your/project +vouch init # create .vouch/ +vouch install-mcp claude-code # …or cursor, codex, continue, cline, windsurf, zed +# Restart the agent host. kb.* tools + slash commands now available. +``` + +`vouch install-mcp ` is manifest-driven: every supported host has a +single-file declaration under `adapters//install.yaml`. To add a +host, copy an existing manifest, point it at the host's config paths, and +open a PR. + +### Contribute a bug fix or feature + +```bash +git fetch origin main +git switch -c / origin/main +# … edit … +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings +.venv/bin/python -m mypy src +.venv/bin/python -m ruff check src tests +git add +git commit -m "(): <≤72-char summary>" +git push -u origin +``` + +Commit type vocabulary: `feat | fix | refactor | test | docs | chore | +perf | ci | style | build | revert`. Body in lowercase prose, multi-line +ok, **no `Co-Authored-By: Claude` trailer** — see +[`CONTRIBUTING.md`](./CONTRIBUTING.md). CI runs the same three commands. + +### Add a new `kb.*` method + +Every kb method must be registered in **four** places — the +`test_capabilities` test catches drift: + +1. `src/vouch/server.py` — the MCP tool function +2. `src/vouch/jsonl_server.py` — the matching handler + `HANDLERS` map entry +3. `src/vouch/capabilities.py` — append to `METHODS` +4. `src/vouch/cli.py` — the human-facing CLI mirror + +Add a test in `tests/test_.py` that asserts the JSONL envelope +shape (`{id, ok, result}` for success, `{id, ok: false, error}` for +failure). + +### Add a new install-mcp host + +``` +adapters// + install.yaml # tier T1..T4 declarations + .mcp.json # T1 — MCP wire (project-local) + CLAUDE.md.snippet # T2 — fenced append into the host's instruction file + .claude/commands/* # T3 — slash commands (when the host supports them) + .claude/settings.json # T4 — hooks + auto-allow lists +``` + +The writer is `src/vouch/install_adapter.py`. Strict YAML manifest +validation; semantic validation (does the `dst` path make sense for that +host?) is deliberately deferred. + +## Before shipping + +```bash +make check # the convenience wrapper +# expanded: +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings +.venv/bin/python -m mypy src +.venv/bin/python -m ruff check src tests +``` + +`mypy src` is the gate that misses locally and fails CI; never push +without it. + +## Privacy / disclosure + +* Never paste a real Bearer token, GitHub PAT, or PyPI token into a + commit, an issue, or a PR body. Use environment variables or + `.env.local` (gitignored). +* Never propose claims with real customer names, internal URLs, or secret + identifiers in a public KB. Use generic placeholders. +* Security issues go to the contact listed in [`SECURITY.md`](./SECURITY.md); + do not file public issues for vulnerabilities. + +## Hard rules + +* **The review gate is non-negotiable.** Bypassing it is a rejected PR, + not a feature. +* **Tests must be added with the change**, not after. CI red on a PR is a + reviewer-blocking condition. +* **No `Co-Authored-By: ` trailers** in commits — the user has + been explicit about this. +* **No silent CHANGELOG omissions** when you add a user-visible feature — + update `CHANGELOG.md` under `[Unreleased]` in the same PR. + +If your task contradicts any rule above, stop and ask before continuing. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d4fff95..2c780d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,193 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +## [1.0.0] — 2026-06-26 + +### Added +- `vouch dual-solve ` — run claude + codex on one github issue in + isolated git worktrees, compare the two diffs, keep the branch you pick, and + propose the chosen solution's rationale into the KB. A sibling tool to + `auto-pr`, and the first that writes to the KB — but only ever as review-gated + proposals: the winning commit is registered as a `Source` and the decision + plus up to three approach claims land in `proposed/`, so approval still + requires a human `vouch approve`. Nothing is auto-approved. `--json` is + non-interactive (emits both diffs, keeps both branches); `--no-record` and + `--dry-run` propose nothing. Each phase (fetch, ground, and per-engine run + with elapsed time and diff size) reports progress to stderr while it works. +- `vouch dual-solve --sandbox` and + `vouch review-ui --dual-solve-sandbox` — run Claude Code and Codex inside a + Docker image (default `amika/coder:latest`) while leaving git/GitHub commands + on the host. The sandbox runner mounts only each candidate worktree plus a + temporary copied home containing known Claude/Codex credential files, so agent + writes stay in the throwaway dual-solve branches and host credential files are + not modified. +- `vouch review-ui --allow-dual-solve` — a browser SPA that runs `dual-solve` + on a github issue link, streams progress over the review-ui's websocket, shows + both engines' diffs side by side, and lets you pick the winner. Off by default; + localhost-first; edit-only over http; the pick keeps the branch and proposes + the rationale into the KB through the existing review gate (nothing + auto-approves). See `proposals/VEP-0006-dual-solve-web.md`. +- `vouch auto-pr ` — open N mergeable PRs against any github repo. + Sources open issues first then agent-discovered improvements, bootstraps a + contribution skill from the repo's merged PRs when it ships no guidance, and + cross-verifies each diff by alternating claude/codex as fixer and the other + as reviewer; a PR opens only when the repo's own test gate is green and the + reviewer signs off. A sibling tool — it never writes to the KB or the review + gate. Paired with the `auto-pr` skill. +- typed page kinds (#234): a KB can declare extra page kinds in + `.vouch/config.yaml` under `page_kinds`, each with `required_fields`, a + JSON-Schema-subset `frontmatter_schema`, `required_citations`, and one level + of `extends`. `kb.propose_page` now takes a `metadata` frontmatter dict and + validates the kind at both the propose and approve gates, surfacing one error + per offending field. The built-in `PageType` kinds keep working unchanged. + New `vouch schema list` and `vouch schema sync` commands inspect declared + kinds and audit existing pages against them; `propose-page` gains `--kind` + and repeatable `--meta key=value`. +- `kb.synthesize` — answer-mode retrieval over the review-gated KB. Answers a + query in prose from approved claims only, with an inline `[claim_id]` + citation behind every sentence, an explicit `gaps` block listing query + topics no approved claim covered, and a `synthesis_confidence` grade derived + from the cited claims' lifecycle status. Deterministic in v1 (no LLM in the + loop). Exposed across the CLI (`vouch synthesize`), MCP (`kb_synthesize`), + and JSONL (`kb.synthesize`) surfaces (#222). +- `_meta.vouch_trust` on every dict-shaped kb.* response: `{remote, caller_kind, + auth_subject}` so clients can detect remote confinement and surface it in + their UI. HTTP MCP calls report `remote: true, caller_kind: mcp_http`; CLI + `--json` reports `remote: false, caller_kind: cli`. Bearer-authenticated + HTTP calls include a stable token fingerprint as `auth_subject` (#233). +- `vouch-context` OpenClaw context engine (#228): `src/vouch/openclaw/context_engine.py` + wraps `kb.context` retrieval, the entity-salience reflex, and session hot + memory into a cited `systemPromptAddition` on every `assemble()`. The plugin + manifest declares `contracts.contextEngines: ["vouch-context"]` and registers + `adapters/openclaw/vouch-context-engine.mjs`; engine identity is advertised + on `kb.capabilities.context_engines`. Compaction stays delegated to the + legacy OpenClaw runtime (`ownsCompaction: false`). +- Entity-salience retrieval reflex: a per-session, in-memory ring buffer of + recent caller queries drives a zero-LLM substring/FTS entity pass that + attaches top-K matched claim candidates as `_meta.vouch_salience` on + `kb_context` read responses. Config-gated via `retrieval.reflex` + (`enabled`/`window`/`top_k`); the buffer is never persisted and resets on + `session_end` (#223). +- `vouch eval recall ` — score `kb.context` retrieval against a + labeled query set with pure-Python P@k / R@k / MRR / nDCG, compare against a + committed `eval/baseline.json`, and fail CI on a P@5 regression beyond + tolerance (default 5%). Ships a starter labeled set, a reproducible fixture + KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes + (#226). +### Fixed +- `parse_since` (the `--since` parser behind `vouch metrics`/`vouch audit`) now raises a clean `MetricsError` for a duration too large to represent (e.g. `--since 1000000000000d`), instead of letting an uncaught `OverflowError` traceback escape — restoring the documented "clean error, not a traceback" contract. +- `sync_apply` now loads the sync source exactly once and passes the same `_SyncSource` instance into `sync_check`, closing a TOCTOU window where a bundle replaced on disk between the two `_load_source` calls could cause the validation and write phases to operate on different snapshots. Also eliminates redundant directory walks (KB sources) and triple tarball opens (bundle sources). Fixes #217. +- `vault_to_kb` now passes `slug_hint=page_id` to `propose_page` so vault edit proposals target the existing page id from frontmatter instead of a slugified copy of the title (fixes #219). +- `vault_to_kb` skips mirror files whose page no longer exists in the KB, preventing ghost-page proposals that would fail on approve (fixes #219). +- `vault_to_kb` skips filing a second proposal when a pending proposal already targets the same page id (with differing body), preventing duplicate proposals on repeated sync runs before approval (fixes #219). +- `vault_to_kb` now warns when a user edits a claim stub instead of silently dropping the edit, directing the user to edit the citing page instead, and reports it via the dedicated `claim_stubs_edited` field on `VaultSyncResult` (fixes #219). +- `approve()` now supports updating an existing page via `KBStore.update_page` when a PAGE proposal's id matches an existing artifact (the vault-edit flow), instead of raising `cannot approve: page already exists` for every vault edit (fixes #219). + +### Fixed +- `vouch serve` now fails fast with a clear `vouch init` hint when no `.vouch/` KB is present, instead of starting a server that immediately misbehaves (#95). + +### Added +- `kb.volunteer_context` — confidence-gated push context for active sessions. + `kb.session_start(task=…)` opens a background watch on retrieval salience; + when an approved claim's normalized relevance exceeds the configured + threshold (default `0.85`), vouch queues `{claim_id, relevance, why}` and + emits an MCP notification (`kb.volunteer_context`). JSONL and CLI clients + poll via `kb.volunteer_context` / `vouch session volunteer`. Pushes are + throttled (default 30s) and respect scope visibility (#236). +- Auto-extracted typed edges: approving a page now files `mentions` (wiki-links), + `relates_to` (entity frontmatter), and `derived_from` (source frontmatter) + relation proposals automatically, tagged `proposed_by: vouch-extractor`. + They land in `proposed/` like any hand-filed relation and need the usual + review; `vouch reject-extracted [--page ]` mass-rejects them (#224). +- Visibility-aware `kb.audit` / `vouch audit`: audit reads accept optional + `project` / `agent` viewer context (or nested `viewer_scope` on JSONL). + Events whose `object_ids` reference scoped claims, sources, or claim + proposals outside the viewer context are filtered out; events with no + `object_ids` remain visible to everyone (#232). +- `vouch install-mcp openclaw` — ninth host in the adapter catalogue. + Declares plugin enablement (`.openclaw/plugins.json`), an `AGENTS.md` + fenced snippet, the four slash commands reused in place from the + `claude-code` adapter, and a project-local trust-boundary policy + (`.openclaw/policy.json`). Complements the repo-root + `openclaw.plugin.json` bundle manifest, which covers loading vouch into + an OpenClaw deployment rather than into one managed project (#230). +- `vouch sync --vault ` — bidirectional sync between the KB and an + Obsidian/Logseq-style markdown vault. Forward (vault → KB): edits to + `/vouch/pages/.md` become page-edit proposals citing a + `vault:` source. Backward (KB → vault): approved pages mirror + to `/vouch/pages/` and approved claims surface as markdown stubs + in `/vouch/claims/` with Obsidian wikilink backlinks to citing + pages. `--watch` keeps a polling loop alive; `--direction` lets you + run forward-only or backward-only. The starter KB now seeds an + approved "Edit in Obsidian" walkthrough page so new users discover the + workflow the moment they `vouch init` (#181). +- `vouch install-mcp ` — one-command adapter writer that drops the + right MCP config templates into a project tree, idempotently. Eight hosts + ship in the catalogue: `claude-code`, `claude-desktop`, `cursor`, + `continue`, `codex`, `windsurf`, `cline`, `zed`. Each adapter is described + by a declarative `adapters//install.yaml` manifest so adding a new + host is a single-file PR. `--tier T1..T4` stacks adoption layers (T1 = MCP + wire only, T2 = CLAUDE.md/AGENTS.md fenced snippet, T3 = optional slash + commands, T4 = optional host hooks/settings). `--list` enumerates the + catalogue; `--path` (or `--target`) installs into a project other than + cwd. Existing files are left alone; CLAUDE.md gets a fenced append so + re-runs stay flat-noop (#179). +- Propose-time similarity warnings: `propose_claim` / `kb.propose_claim` return + non-blocking `warnings` (`similar_approved`, `similar_pending`) when the + embeddings extra is installed. Configurable via `review.similarity_threshold` + (default `0.95`). CLI prints warnings to stderr; dry-run included. +- `vouch stats` and `kb.stats` expose read-only KB observability: pending + proposals by agent (with median/max age), review decision counts and + approval rate over a configurable window (`--days`, default 30; `0` for + all-time), citation coverage (valid / invalid / broken), plus audit-log + cross-check totals. Available on MCP, JSONL, and HTTP transports. +- `vouch fsck` performs deep consistency checks beyond `vouch doctor`: + orphaned embeddings, dangling supersede/contradict chains, decided + proposals whose artifact is missing, and FTS5 index-vs-file drift + (orphan rows, missing rows, status drift). Read-only; reports findings + with object ids. `--fix` is intentionally out of scope (#96). +- `vouch migrate` checks, dry-runs, and applies on-disk KB format migrations, + preserving audit history and rebuilding derived indexes after successful + upgrades. +- `vouch expire` garbage-collects stale pending proposals: dry-run by default, + `--apply` moves them to `decided/` with `decision_reason: expired`, emits + `proposal.expire` audit events, and honors `review.expire_pending_after_days` + in `config.yaml` (default 90; `0` disables). `kb.expire` on MCP/JSONL. +- `vouch init --template ` seeds a domain starter pack. The default `starter` template is unchanged; the new `gittensor` template seeds a small, cited, approved KB about Gittensor (SN74) contribution scoring (1 source, 1 entity, 7 claims — merged-PR rewards, PAT verification, scoring factors, sybil-resistance, repo allow-list policy, issue-solving multiplier, and emission split) so a fresh KB in a Gittensor repo has retrievable context on day one. Templates are an in-code registry — future packs plug in the same way. +- Structured JSON logging via `VOUCH_LOG_FORMAT=json`. When set, the + `vouch` logger emits one JSON object per line with `level`, `logger`, + `event`, and any structured extras (e.g. `actor`, `object_ids`) passed + through stdlib `extra=`. Unset (or any other value) keeps the existing + human-readable format — no behaviour change beyond formatting. Wired + into the CLI, MCP server, and JSONL server entry points. `VOUCH_LOG_FORMAT` + was already documented in `ROADMAP.md` and `adapters/generic-mcp/README.md` + but had no implementation (#97). +- Performance benchmark suite in `benchmarks/` covering search latency, proposal write throughput, bundle export/import/verify round-trips, and index rebuild time at 1k/10k claim sizes. Run with `pytest benchmarks/ --benchmark-only`. + +### Fixed +- `put_claim` / `update_claim` now reject a Claim whose `entities`, + `supersedes`, `superseded_by`, or `contradicts` reference an artifact that + is not in the KB, via a new `KBStore._validate_claim_refs`. `bundle.import_check` + gains the matching check so a bundle can no longer land a claim with dangling + graph refs through `import_apply`'s direct write. Previously only `claim.evidence` + was checked: the graph-integrity fix for Relations/Pages (#124) skipped the + Claim model's own four reference fields, even though `fsck` already declared + `dangling_supersedes` / `dangling_superseded_by` / `dangling_contradicts` as + error-severity findings — the invariant was articulated but enforced by no + writer. Same model-layer/storage pattern as #81 / #123. Closes #196. +- `lifecycle.supersede` / `lifecycle.contradict` pre-validate both touched + claims before the first disk write so a legacy dangling ref on either + side can't half-apply the operation (one update written without the + reciprocal, no relation, no audit event). +- `proposals.check_approvable` dry-runs the put_*-side ref guards so the + default `vouch approve a b` batch flow (#93) catches a dangling + `claim.entities` (or relation endpoint / page reference) before any + disk write, preserving the all-or-nothing contract. +- `vouch fsck` reports `claim.entities` pointing at a missing entity as a + `dangling_claim_entity` error finding, alongside the existing + `dangling_supersedes` / `_superseded_by` / `_contradicts` checks. +- `discover_root()` now honours `VOUCH_KB_PATH=/abs/path/.vouch` and returns the parent root, instead of always walking up from cwd. The env var was already documented in `adapters/generic-mcp/README.md` but wasn't wired into the code — closing the doc-vs-code drift removes the `"cwd": "..."` ceremony hosts like Claude Desktop need today to point at a specific KB. + ## [0.1.0] — 2026-05-26 ### Packaging @@ -15,13 +202,49 @@ All notable changes to vouch are documented here. Format follows Trusted Publishing (OIDC). ### Added +- HTTP transport: `vouch serve --transport http` exposes the full `kb.*` + surface over HTTP, reusing the same dispatch table as the MCP/JSONL + transports (#94, implements VEP-0004). `POST /rpc` carries the JSONL + envelope; `GET /capabilities` and `GET /healthz` are unauthenticated. + Binds `127.0.0.1` by default and refuses any non-loopback bind without + both `--allow-public` and a bearer token (`--token` / `VOUCH_HTTP_TOKEN`, + constant-time compared). The `X-Vouch-Agent` header sets the audit actor + per request. Zero new runtime dependencies (stdlib `http.server`); no TLS + in-process — terminate at a reverse proxy. `kb.capabilities.transports` + now includes `http`. +- `vouch approve …` approves multiple proposals in one + non-interactive call for CI and backlog clearing (#93). Default is + all-or-nothing: every id is validated as an approvable pending proposal + before any is written, so a typo or already-decided id aborts the batch + without approving anything. `--keep-going` switches to best-effort + (approve what you can, report the rest, exit non-zero on partial failure). + One audit event is still recorded per approved artifact. Complements the + interactive `vouch review` queue. +- Friendlier CLI output (#54, track 2): colourised `vouch status` / `lint` / + `doctor` / `search` (honours `NO_COLOR`, `FORCE_COLOR`, and TTY detection); + `--json` on `vouch lint` and `vouch search` for machine-readable output + while the default stays human-readable; progress callbacks on the long ops + (`rebuild_index`, `doctor`, bundle `export`/`import_apply`) surfaced as + status lines on interactive terminals; and `vouch index` / `vouch export` + now report a clean `Error:` instead of a traceback on a malformed artifact. +- `vouch sync-check` and `vouch sync-apply` reconcile another `.vouch` + directory or bundle by importing only non-conflicting durable artifacts and + reporting conflicts without overwriting reviewed knowledge. +- `vouch pending --json` emits pending proposals as structured JSON for shell + scripts, CI checks, and multi-agent review dashboards. +- `vouch diff ` shows what changed between two claim revisions or two page revisions — field-level changes plus a line-diff of the long text/body. Auto-detects the artifact kind and hides always-churning metadata. Read-only; supports `--json`. - Seed a cited starter source and claim during `vouch init`, print first-run next steps, and document a 30-second onboarding tour (#54). +- Add `vouch review`, a guided CLI queue for approving, rejecting, skipping, + or dry-running pending proposals without bypassing the review gate. ### Fixed +- `store.put_relation`, `store.put_relation_idempotent`, and `store.put_page` now reject artifacts whose foreign-id references don't resolve in the KB (relation `source` / `target` / `evidence`; page `entities` / `sources`). `proposals.propose_relation` and `proposals.propose_page` surface the same checks at proposal time as `ProposalError`. `bundle.import_check` and `sync.sync_check` run an equivalent cross-artifact pass against the post-merge id set so manifest-consistent bundles can't smuggle relations / pages whose references resolve to nothing — closes the write-time counterpart of the after-the-fact `dangling_relation` finding in `health.lint` (`src/vouch/health.py:135-145`). +- `bundle.import_check` / `import_apply` and `sync.sync_check` / `sync_apply` now enforce the Source content-addressing invariant: a `sources//content` member must hash to ``, and a `sources//meta.yaml` must carry a matching `id`/`hash`. Previously the import side trusted the bundle's directory layout, so a manifest-consistent bundle could land a Source whose content did not match its claimed id — `verify_source` would report `stored_ok=False` only after the import had already succeeded with a clean `bundle.import` audit event. The per-file sha256 gate (#74) only proves bytes match the manifest; this closes the write-time counterpart of the `verify.verify_source` detection. - Add `put_relation_idempotent()` to `KBStore` and use it in `supersede()` and `contradict()` so retrying after a partial failure converges to a consistent state instead of raising `ValueError`. - Raise `ProposalError("forbidden_self_approval")` in `proposals.approve()` when `approved_by == proposal.proposed_by`, enforcing the review-gate guarantee documented in the README and CONTRIBUTING. - `crystallize()` now sets `review.approver_role: trusted-agent` context so single-agent sessions can be crystallized without hitting the `forbidden_self_approval` guard (#47). +- Narrow `except Exception` to `except ArtifactNotFoundError` in `propose_claim()` evidence validation so I/O and parse errors propagate with their original type instead of being masked as `unknown source/evidence id` (#48). - Bundle import rejects tar members whose path escapes `kb_dir` (CVE-2007-4559, #9). Previously a crafted `.tar.gz` with a member named `../../evil.txt` could write outside `.vouch/`; the manifest @@ -29,6 +252,12 @@ All notable changes to vouch are documented here. Format follows the same tarball. `import_apply`, `import_check`, and `export_check` now validate every member path and raise on unsafe names. - Fix `vouch search` CLI: assign backend label per code path so substring fallback results are no longer mislabelled as `fts5`; update stale docstring to reflect multi-backend search surface (#52). +- `context._retrieve` now honors `retrieval.backend` in `config.yaml` + instead of always running embeddings first (#92). Accepts `auto` + (default — embedding → FTS5 → substring), `embedding`, `fts5`, or + `substring`; a legacy `retrieval.backends` list is still read for + back-compat. `vouch init` now writes `retrieval.backend: auto`, and the + README/ROADMAP describe the actual behavior. - `vouch crystallize` now indexes its session-summary page into FTS5 so it surfaces from `vouch search` / `kb.search` / `kb.context` without a `vouch index` rebuild. Previously the summary was written via @@ -42,6 +271,18 @@ All notable changes to vouch are documented here. Format follows a silent no-op. Existing Linux/macOS bundles are unchanged (their paths were already POSIX); Windows bundles produced before this fix should be re-exported. +- `kb.context` no longer returns claims whose status is `archived`, + `superseded`, or `redacted` (#78). Two compounding bugs were combining + to leak retracted knowledge back to agents: `build_context_pack` had + no status filter, and `store.update_claim` only refreshed the + embedding cache without keeping `claims_fts.status` in sync — so even + after `lifecycle.archive` / `supersede` / `contradict`, the FTS5 row + kept its first-index status and `kb.search` / `kb.context` matched the + retracted claim. `update_claim` now re-indexes the FTS5 row (mirroring + what `proposals.approve` does on first index), and + `build_context_pack` drops retracted claims from the assembled pack. + `CONTESTED` claims continue to surface so contradictions remain + visible. - `bundle.import_check` and `bundle.import_apply` now verify each tar member's `sha256` against `manifest.json` (#74). Previously the per-file hash was only enforced by `export_check`; the import side @@ -53,6 +294,37 @@ All notable changes to vouch are documented here. Format follows with between `import_check` and the apply re-open is rejected before anything reaches disk and the audit log does not record a `bundle.import` event. +- `Claim.evidence` now enforces "at least one citation" at the model + layer via a `@field_validator` (#81). Previously the + README-documented guarantee ("Claims must cite sources … a claim + without at least one Source/Evidence id is a validation error") + was enforced only in `proposals.propose_claim`, so every other + write path — direct `store.put_claim`, `store.update_claim`, and + `bundle.import_apply` via `_validate_content` — silently accepted + `evidence: []` and landed an uncited claim. The validator closes + all three paths at once; `store.update_claim` additionally + re-validates via `Claim.model_validate(...)` before persisting so + in-place mutation (`c.evidence = []; store.update_claim(c)`) + also raises before the YAML hits disk. **Migration note:** because + the validator also fires when claims are read back, a KB that + already has an uncited `claims/.yaml` on disk from before this + fix would otherwise crash `vouch lint` / `vouch doctor` with a + `pydantic.ValidationError`. `vouch lint` now iterates `claims/` + per-file and surfaces unparseable / uncited YAMLs as + `invalid_claim` findings ("edit the YAML to add a citation, or + delete the file") instead of bailing out — so existing KBs get a + clean repair list rather than a traceback. +- Close the review-gate bypass in `sessions.crystallize` (#76). The + durable session-summary page wrote `sess.task`, `sess.note`, and + `sess.agent` verbatim into rendered markdown, letting an agent + land arbitrary content into `pages/` by calling + `kb.session_start(task=...)` and getting any one claim approved + via crystallize. The summary body now contains only fields the + proposing agent cannot influence (session id, server-clock + timestamps, list of approved artifact ids). The + `session.crystallize` audit event now also includes the summary + page id in `object_ids` when a page is written, so `vouch audit` + truthfully attributes the write. ## [0.0.1] — 2026-05-17 @@ -80,6 +352,5 @@ Initial alpha. Surface intentionally small; expect breaking changes pre-1.0. - Claim validation: at least one source/evidence citation required. - Per-agent attribution via `VOUCH_AGENT` env var. -[Unreleased]: https://github.com/vouchdev/vouch/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/vouchdev/vouch/compare/v0.0.1...v0.1.0 -[0.0.1]: https://github.com/vouchdev/vouch/releases/tag/v0.0.1 +[Unreleased]: https://github.com/plind-junior/vouch/compare/v0.0.1...HEAD +[0.0.1]: https://github.com/plind-junior/vouch/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d5dc673a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,259 @@ +# CLAUDE.md — orientation for Claude Code working on vouch + +This file is read automatically by Claude Code when you open the vouch +repository. It exists to make a fresh session productive without you +having to re-onboard it every time. + +If you're working *with* vouch from inside a different project (proposing +claims, approving them), read [`README.md`](./README.md) instead. This +file is for working *on* vouch — fixing a bug, adding a feature, +shipping a release. + +For any non-Claude-Code agent — start with [`AGENTS.md`](./AGENTS.md). + +## North star + +Vouch is a knowledge base where every write goes through a review gate. +That's the load-bearing invariant. Every other design choice — files on +disk, append-only audit log, manifest-driven adapters, thin viewports +over the storage layer — is downstream of "writes must be reviewed." + +If a PR adds a parallel data path that bypasses `proposals.approve()`, +the PR is wrong. Push back. Find the right factoring. + +## Architecture (90 seconds) + +``` + ┌──────────────┐ + Claude Code ─MCP──▶ │ │ + Cursor ─MCP──▶ │ server.py │ + Codex ─MCP──▶ │ jsonl_ │ ─┐ + CLI human ──────▶ │ server.py │ │ + │ cli.py │ │ + └──────┬───────┘ │ + │ │ + ▼ │ + ┌──────────────┐ │ + │ proposals.py │ │ review gate + │ lifecycle.py │ │ (kb.approve etc.) + └──────┬───────┘ │ + │ │ + ▼ │ + ┌──────────────┐ │ + │ storage.py │ ◀──┘ + │ audit.py │ + │ index_db.py │ + └──────┬───────┘ + │ + ▼ + .vouch/ ─── filesystem (yaml + md + jsonl) + ─── state.db (FTS5 + optional embeddings, derived) +``` + +Three rules that fall out of the layout: + +1. **`src/vouch/storage.py` is pure I/O.** No business logic. If you find + yourself doing scope filtering or status transitions inside `put_claim`, + you're in the wrong file. +2. **All three surfaces (MCP, JSONL, CLI) call the same `proposals.*` and + `lifecycle.*` functions.** Drift between surfaces is the most common + contributor mistake. `test_capabilities` enforces method-list parity; + you still need to keep behaviour aligned by reading the existing + handlers before you add a new one. +3. **The audit log is the only authoritative history.** `decided/` is the + queryable summary; `audit.log.jsonl` is the legally-authoritative event + stream. Both are committed. Never edit either by hand. + +## Build + test + ship + +```bash +# from a clone +python3 -m venv .venv && . .venv/bin/activate +pip install -e '.[dev]' + +# the CI gate — exactly what .github/workflows/ci.yml runs +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings +.venv/bin/python -m mypy src +.venv/bin/python -m ruff check src tests + +# the convenience wrapper +make check +``` + +`mypy src` is the gate that gets missed locally and turns CI red. + +For the embedding-heavy tests (separate job in CI): `pip install +-e '.[embeddings]'` then drop the `--ignore=tests/embeddings` flag. + +## Ship a feature branch + +```bash +git fetch origin main +git switch -c / origin/main +# … work … +make check +git add # never `git add -A` — leaks .claude/, web/, etc. +git commit -m "(): <≤72-char summary> + +multi-line lowercase body explaining the why. +no Co-Authored-By trailer." +git push -u origin +``` + +If there's a pre-existing `M src/vouch/storage.py` edit in the working +tree on `release/0.1.0` (the user's WIP), stash it first: + +```bash +git stash push -m "preserve user wip" src/vouch/storage.py +``` + +…work on the feature branch, then `git stash pop` after switching back. +The `.claude/skills/vouch-ship/SKILL.md` skill encodes this dance. + +## Commit messages + +Conventional commits, enforced by a pre-commit hook: + +``` +(): + + +``` + +Types: `feat | fix | refactor | test | docs | chore | perf | ci | style | +build | revert`. Scope optional. Anchor voice against `git log --oneline +-10` before drafting a new one. + +**No `Co-Authored-By: ` trailer.** The user has been explicit +about this; it's checked in PR review. + +## Conventions you'll trip on otherwise + +* **Lowercase prose** in PR bodies, commit bodies, and review comments. + Match the existing voice. +* **No inline `##` headers inside a postable PR-review comment block** — + reviews are 4-6 short paragraphs in vouch's house style. See + `.claude/skills/vouch-pr-comment/SKILL.md` if it's installed. +* **No "we" / "let's" marketing tone in code comments.** Comments + explain why, not what. +* **Specific files only when staging.** `git add -A` will pull in + `.claude/`, `web/`, `proposed-features.md`, etc. that are local scratch. +* **Stash + worktree** for doc-only changes: branch off + `origin/main` in `/tmp/vouch--wt`, do the work, push, remove the + worktree. + +## Where things live + +| Concern | File | +|---|---| +| MCP tool surface | `src/vouch/server.py` | +| JSONL handler map | `src/vouch/jsonl_server.py` | +| CLI commands | `src/vouch/cli.py` | +| Pure file I/O | `src/vouch/storage.py` | +| Proposal lifecycle | `src/vouch/proposals.py` | +| Claim lifecycle (supersede, etc.) | `src/vouch/lifecycle.py` | +| Audit log writer | `src/vouch/audit.py` | +| Pydantic models | `src/vouch/models.py` | +| Capabilities + method list | `src/vouch/capabilities.py` | +| Context-pack builder | `src/vouch/context.py` | +| SQLite FTS5 + embeddings | `src/vouch/index_db.py` | +| Sessions | `src/vouch/sessions.py` | +| Manifest-driven adapter writer | `src/vouch/install_adapter.py` | +| Web review-ui (when PR #195 lands) | `src/vouch/web/` | +| OpenClaw plugin manifest | `openclaw.plugin.json` (repo root) | +| Claude Code / Cursor / etc. install templates | `adapters//` | + +Tests mirror module names (`tests/test_.py`); the convention is +strict. + +## The OpenClaw plugin manifest + +[`openclaw.plugin.json`](./openclaw.plugin.json) at the repo root makes the +vouch repo loadable directly as an OpenClaw plugin: drop the repo into a +deployment, and the loader picks up the MCP server, the four slash commands +under `adapters/claude-code/.claude/commands/`, and the trust-boundary +declaration. Touch it whenever you: + +* bump the package version (`version` field must stay in step with + `pyproject.toml`), +* add or rename a slash command (sync the `skills` array), +* add a new MCP method that's safe to expose to remote callers (consider + whether to list it under `contracts.mcpMethods`), +* change the trust boundary (e.g. a new "must-be-confined" surface that + arrives with the HTTP transport). + +Keep it small. Anything that would require a runtime decision (which kb to +use, whose audit log to write to) belongs in the deployment's own config, +not in the plugin manifest. + +## When you add a new `kb.*` method + +Four registration sites — `test_capabilities` will fail if you miss one: + +1. **MCP tool** in `src/vouch/server.py` (decorated with `@mcp.tool()`) +2. **JSONL handler** in `src/vouch/jsonl_server.py` (`_h_` + + `HANDLERS["kb."]`) +3. **`METHODS` list** in `src/vouch/capabilities.py` +4. **CLI command** in `src/vouch/cli.py` (the human mirror) + +Plus a test under `tests/test_.py`. + +If the method *reads* the KB, consider whether it should attach the +`_meta.vouch_hot_memory` sidebar from `src/vouch/hot_memory.py`. The +sidebar is added per-tool — there's no global decorator. + +## Release flow + +`release.yml` cuts a tagged PyPI release via Trusted Publishing on every +`v*` tag push. + +Pre-release checklist (also in `CONTRIBUTING.md`): + +1. Bump `version = "X.Y.Z"` in `pyproject.toml`. +2. Move everything under `[Unreleased]` in `CHANGELOG.md` into a dated + `[X.Y.Z]` section. +3. `make check` green. +4. PR titled `chore(release): prepare X.Y.Z`, merge to `main`. +5. `git tag vX.Y.Z && git push --tags` — the workflow does the rest. +6. After CI finishes, draft the GitHub release with the CHANGELOG section + as the body. + +## What's not in scope right now + +Don't propose: + +* A SaaS mode / hosted vouch (explicitly out of scope; vouch is + local-first by design) +* Removing the review gate "for trusted agents" (the `trusted-agent` + config flag exists; the gate stays) +* Replacing yaml with json/sqlite as the storage format (the diff-in-PRs + property requires plaintext) +* A custom config DSL (yaml + pydantic is sufficient) + +Roadmap items that ARE in scope live in [`ROADMAP.md`](./ROADMAP.md) and +[`proposed-features.md`](./proposed-features.md) (local scratch — not on +`main`). + +## Privacy + +Same rule as gbrain's: never bake real customer names, internal URLs, or +PII into public artifacts. Test fixtures should use generic placeholders +(`alice-example`, `acme-example`). + +## Hooks installed in this repo + +Pre-commit checks the conventional-commit format. If you trigger it via +shell substitution in a `git commit -m "$(cat <" [--depth N] [--max-chars N]`. +- `CHANGELOG.md` — `### Added` bullet under `## [Unreleased]`. + +## Why / root cause + +`kb.context` is a retrieval primitive: it ranks and budgets items but leaves +answer composition (and the discipline of *only* using approved knowledge) to +the caller. There was no first-class way to ask the KB a question and get a +prose answer whose every clause is provably backed by a reviewed claim, with +the uncovered parts of the question surfaced rather than silently dropped. +`kb.synthesize` fills that gap deterministically — citation-gated by +construction, so it cannot fabricate an unbacked sentence — and grades its own +confidence from the lifecycle status of the claims it actually cited. + +## Test plan + +`tests/test_synthesize.py` covers: + +- 3 approved `auth` claims → non-empty answer citing all 3 ids by `[id]`, + confidence `high`. +- A query the KB doesn't cover → `answer == ""`, `claims == []`, `gaps` + populated with the query's salient terms. +- Fuzz/traceability: every sentence in a non-empty answer carries at least one + `[id]` citation whose id is in `claims` and resolves via `store.get_claim`. +- `max_chars` drops trailing claims without cutting a citation + (citation count == cited-claim count). +- Confidence reflects claim status (`working` → medium, `contested` → low). +- `llm=True` raises the reserved-backend `ValueError`. +- `kb.synthesize` is in `capabilities().methods` and in the JSONL `HANDLERS`, + and is callable via `handle_request` end-to-end. + +Verification gate (fresh venv, editable install of this worktree): + +``` +$ ./.venv/bin/ruff check src tests +All checks passed! + +$ ./.venv/bin/mypy src +Success: no issues found in 30 source files + +$ ./.venv/bin/python -m pytest -q +94 passed, 6 skipped in 0.81s +``` + +(The 6 skips are pre-existing numpy/embedding-optional tests, unrelated to this +change.) + +Closes #222 diff --git a/README.md b/README.md index a3f298c9..05d2078a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ vouch — propose → review → commit → retrieve

+

+ CI + PyPI + Python versions + MIT licensed +

> Agents should not start every session with amnesia — but they shouldn't get to write whatever they want either. @@ -13,6 +19,8 @@ Still alpha — surface is small on purpose; expect breaking changes pre-1.0. +> **Featured for Gittensor (SN74).** vouch ships a one-command starter pack for **Gittensor** — Bittensor subnet 74. `vouch init --template gittensor` seeds a cited, already-approved decision-memory of SN74's scoring model: merged-PR rewards, PAT verification, sybil-resistance, the repo allow-list, the issue multiplier, and the emission split. It's the durable *why* behind each rule — reviewed, cited, and committed alongside your code. → **[docs/gittensor.md](docs/gittensor.md)** + ## Why this exists Three opinionated choices distinguish vouch from the neighbours: @@ -25,6 +33,7 @@ Three opinionated choices distinguish vouch from the neighbours: Worth it when: +- **You run or contribute to a Gittensor (SN74) repo.** Scoring weights, the repo allow-list, anti-sybil thresholds, and emission splits get debated across PRs, Discord, and validator changes — then settle into nobody's notes. `vouch init --template gittensor` gives you a cited, reviewed record of *why* each rule exists and what it superseded. See [docs/gittensor.md](docs/gittensor.md). - **Multiple agents share a repo** (Claude Code + Cursor + a CI bot). Per-agent attribution in the audit log makes "which agent claimed what" answerable. - **Sessions keep re-explaining the same context.** Curated, cited claims let new sessions start from your team's agreed answer instead of re-grepping. - **You want decision records without the ADR ceremony.** `vouch crystallize` promotes a session's durable parts into proposals; one approve and they're permanent. @@ -40,13 +49,21 @@ Skip it if: ## Install ```bash -# from PyPI (published as vouch-kb; the command is still `vouch`) +# one-liner (Linux + macOS) — picks a Python, ensures pipx, installs vouch-kb +curl -fsSL https://raw.githubusercontent.com/vouchdev/vouch/main/install.sh | sh + +# …or directly via pipx (vouch-kb on PyPI; the command stays `vouch`) pipx install vouch-kb # …or from the cloned repo, in a venv pip install -e '.[dev]' ``` +The one-liner is POSIX `sh`, never needs `sudo`, and detects an existing +Claude Code install to point you at the next step (`vouch install-mcp +claude-code`). Inspect it first if you'd like — it's [`install.sh`](install.sh) +at the repo root. + ## Quick start ```bash @@ -63,6 +80,7 @@ vouch pending # list pending proposals vouch show # full details vouch approve # → durable artifact vouch reject --reason "..." +vouch expire --apply # optional: clear stale pending proposals # 4. commit git add .vouch/ && git commit -m "kb: approve auth-uses-jwt" @@ -88,6 +106,24 @@ with your project's first real source and claim when you are ready. The full captured walkthrough lives at [docs/example-session.md](docs/example-session.md); re-render the GIF from [docs/demo.tape](docs/demo.tape) with `vhs docs/demo.tape`. +## Gittensor (SN74) + +vouch's first domain template targets **Gittensor** — Bittensor subnet 74, which rewards open-source contribution by rule. Its scoring model evolves across PRs, Discord, and validator changes, and the rationale usually lives in people's heads. vouch is the durable, cited memory for it: + +```bash +cd your-gittensor-repo +vouch init --template gittensor # seeds 1 source, 1 entity, 7 cited claims about SN74 scoring +vouch status # durable: 7 claims · 1 source · 1 entity +vouch search "emission split" +git add .vouch && git commit -m "chore: add vouch decision-memory KB" +``` + +The seeded pack covers merged-PR rewards, PAT verification, scoring factors, sybil-resistance, the repo allow-list, the issue-solving multiplier, and the emission split — each a cited, approved, supersede-able claim. When a rule changes, `vouch supersede` the old claim with the new one so the history of *what changed* stays queryable. + +vouch stores **no** live signals — it is not a validator or miner client and never reads on-chain scores. It is the institutional memory that sits beside the live layer (Gittensory). The seeded claims are starter-grade; `vouch supersede` them with the real spec or PR once you confirm the live rule. + +Full adoption guide — install, seed, wire the MCP server, capture decisions as cited claims: **[docs/gittensor.md](docs/gittensor.md)**. + ## Object model ``` @@ -141,13 +177,18 @@ vouch init # set up .vouch/ at PATH vouch discover [--path P] # find the nearest .vouch/ root vouch capabilities # emit the JSON capabilities descriptor vouch status [--json] # KB counts + pending proposals +vouch stats [--days N] [--json] # observability: queue, review rates, citations vouch lint [--stale-days N] # user-actionable problems vouch doctor # full sweep incl. source verification +vouch fsck # deep consistency: indexes, lifecycle, decided +vouch migrate [--check] [--dry-run] # upgrade .vouch/ format safely vouch pending # list pending proposals +vouch review [--limit N] [--type KIND] # guided proposal review queue vouch show vouch approve [--reason ...] vouch reject --reason "..." +vouch expire [--apply] [--days N] [--json] # GC stale pending proposals vouch propose-claim --text ... --source ... [--type ...] [--confidence X] vouch propose-page --title ... [--body -] [--claim ID ...] @@ -176,6 +217,8 @@ vouch export --out path.tar.gz vouch export-check path.tar.gz vouch import-check path.tar.gz vouch import-apply path.tar.gz [--on-conflict skip|overwrite|fail] +vouch sync-check PATH_OR_BUNDLE +vouch sync-apply PATH_OR_BUNDLE [--on-conflict fail|skip|propose] vouch serve [--transport stdio|jsonl] ``` @@ -214,6 +257,31 @@ In your project's `.mcp.json`: `VOUCH_AGENT` is recorded as `proposed_by` and as the actor on every audit event, so multi-agent setups can attribute writes correctly. +## Running vouch as an OpenClaw plugin + +Vouch ships an [OpenClaw](https://github.com/dripsmvcp/openclaw) plugin manifest at the +repo root — [`openclaw.plugin.json`](openclaw.plugin.json). Drop the vouch repo +into an OpenClaw deployment and the plugin loader picks it up automatically: +the MCP server, the four slash commands (`/vouch-recall`, `/vouch-status`, +`/vouch-resolve-issue`, `/vouch-propose-from-pr`), and the CLAUDE.md fenced +snippet become available as one bundle. + +The manifest declares vouch's trust boundary explicitly — remote callers' +filesystem access is confined, every write tool routes through the review +gate, every lifecycle op is audit-logged. The `configSchema` exposes only +`kb_path`, `agent`, and `transport` — no API keys, no secrets; vouch is +local-first. + +```bash +# Inside an OpenClaw deployment that vendors plugin repos: +openclaw plugin add vouchdev/vouch +openclaw plugin enable vouch +``` + +The plugin's `mcpServers.vouch` block matches the same `.mcp.json` shape +Claude Code uses — both platforms drive the same `vouch serve` process, +so the kb.* surface is identical regardless of host. + ## JSONL request/response shape The JSONL transport reads one envelope per line on stdin, writes one per line on stdout: @@ -225,6 +293,36 @@ The JSONL transport reads one envelope per line on stdin, writes one per line on Errors come back with `ok:false` and a structured `error.code` (`method_not_found`, `missing_param`, `invalid_request`, `internal_error`). +Every successful `kb.*` result that is object-shaped carries read-only trust metadata so clients can detect remote confinement: + +```json +{ + "id": "r1", + "ok": true, + "result": { + "backend": "fts5", + "hits": [], + "_meta": { + "vouch_trust": { + "remote": false, + "caller_kind": "jsonl", + "auth_subject": null + } + } + } +} +``` + +| Transport | `remote` | `caller_kind` | `auth_subject` | +|-----------|----------|---------------|----------------| +| JSONL stdio | `false` | `jsonl` | `null` | +| HTTP `/rpc` | `true` | `jsonl_http` | bearer fingerprint when authenticated | +| MCP stdio | `false` | `mcp_stdio` | `null` | +| HTTP `/mcp` | `true` | `mcp_http` | bearer fingerprint when authenticated | +| CLI `--json` | `false` | `cli` | `null` | + +The block is server-attached metadata — client mutations are ignored. Array-shaped read results (e.g. `kb.list_claims`) pass through unchanged; trust rides on dict-shaped responses only (#233). + ## Portable bundles ```bash @@ -254,11 +352,11 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de | Area | Current support | |------|-----------------| | Knowledge base | `.vouch/` folder, YAML claims/entities/relations/evidence/sessions, markdown pages with frontmatter, JSONL audit log, content-addressed sources | -| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` | +| CLI | `init`, `discover`, `capabilities`, `status`, `lint`, `doctor`, `fsck`, `pending`, `show`, `approve`, `reject`, `propose-{claim,page,entity,relation}`, `source add`, `source verify`, `supersede`, `contradict`, `archive`, `confirm`, `cite`, `session {start,end}`, `crystallize`, `search`, `context`, `index`, `audit`, `export`, `export-check`, `import-check`, `import-apply`, `serve` | | Tool servers | MCP over stdio + JSONL over stdin/stdout, same `kb.*` surface across both transports, capabilities + knowledge-capability descriptor | | Schemas | 13 JSON Schemas (Draft 2020-12) generated from pydantic in [schemas/](schemas/), plus hand-maintained `bundle.manifest` and `jsonl-envelope` schemas | | Write safety | review-gated writes via [proposed/](spec/review-gate.md), `dry_run:true` previews, host trust required for `approve`/`reject`, atomic exclusive-create storage, path-traversal blocked on source intake and bundle import | -| Retrieval | SQLite FTS5 + substring fallback; optional semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) behind install extras; context packs with citations + quality gate | +| Retrieval | `retrieval.backend` in `config.yaml` selects the path: `auto` (default — embedding → FTS5 → substring), `embedding`, `fts5`, or `substring`. Semantic backends (`all-mpnet-base-v2`, `MiniLM-L6`, fastembed-BGE) ship behind install extras; `auto` degrades to FTS5 when they aren't installed. Context packs with citations + quality gate | | Lifecycle | `supersede`, `contradict`, `archive`, `confirm`, `cite` — direct mutations, all audited | | Portability | tar.gz bundles with per-file sha256 `manifest.json`, `export-check`, `import-check`, `import-apply` with skip/overwrite/fail conflict modes | | Audit | append-only `audit.log.jsonl`, per-event actor (`VOUCH_AGENT`), object ids, dry-run flag, reversible flag | @@ -268,7 +366,7 @@ vouch import-apply kb.tar.gz --on-conflict skip # apply (default skip; never de ## Status -Pre-1.0. What's *not* in this implementation: vector embeddings (BM25/FTS5 only), per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue. +Pre-1.0. What's *not* in this implementation: per-runtime adapter templates, benchmark fixtures, multi-agent sync, scopes beyond a single field on Claim/Source. If a hole matters to you, file an issue. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 4e5e661f..46101d1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,8 +6,11 @@ promise. Items marked **[VEP]** require a written proposal in ## 0.1 — surface stabilises (next) -- Vector embeddings as an *optional* retrieval backend alongside FTS5. - Default stays FTS5; embeddings opt-in via `config.yaml`. **[VEP]** +- Vector embeddings as a retrieval backend alongside FTS5 (shipped). + Retrieval is controlled by `retrieval.backend` in `config.yaml`: + `auto` (default) tries embedding → FTS5 → substring, gracefully + degrading to FTS5 when the embeddings extras aren't installed; set + `embedding`, `fts5`, or `substring` to pin a single path. - `vouch diff ` for claim/page revisions. - `vouch approve --batch` for reviewing N proposals in one transaction. - HTTP transport (`vouch serve --transport http`) behind a localhost diff --git a/adapters/README.md b/adapters/README.md index c9e15b42..f4edce8c 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -12,6 +12,7 @@ file you need into your project and edit it. | [cursor/](cursor/) | Cursor IDE | `mcp.json` snippet | | [codex/](codex/) | OpenAI's Codex CLI | `config.toml` snippet | | [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 | | [jsonl-shell/](jsonl-shell/) | bash scripts via the JSONL transport | example pipeline | diff --git a/adapters/claude-code/.claude/commands/vouch-propose-from-pr.md b/adapters/claude-code/.claude/commands/vouch-propose-from-pr.md new file mode 100644 index 00000000..aca31fc7 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-propose-from-pr.md @@ -0,0 +1,25 @@ +--- +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/adapters/claude-code/.claude/commands/vouch-recall.md b/adapters/claude-code/.claude/commands/vouch-recall.md new file mode 100644 index 00000000..d72d7168 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-recall.md @@ -0,0 +1,20 @@ +--- +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/adapters/claude-code/.claude/commands/vouch-resolve-issue.md b/adapters/claude-code/.claude/commands/vouch-resolve-issue.md new file mode 100644 index 00000000..1b5aaf63 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-resolve-issue.md @@ -0,0 +1,27 @@ +--- +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/adapters/claude-code/.claude/commands/vouch-status.md b/adapters/claude-code/.claude/commands/vouch-status.md new file mode 100644 index 00000000..3320c642 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-status.md @@ -0,0 +1,23 @@ +--- +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/adapters/claude-code/.claude/settings.json b/adapters/claude-code/.claude/settings.json new file mode 100644 index 00000000..11a1949a --- /dev/null +++ b/adapters/claude-code/.claude/settings.json @@ -0,0 +1,34 @@ +{ + "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" + } + ] + } + ] + } +} diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml new file mode 100644 index 00000000..fb063cee --- /dev/null +++ b/adapters/claude-code/install.yaml @@ -0,0 +1,24 @@ +# Claude Code adapter manifest. +# +# T1 = MCP wire (project-local `.mcp.json` picked up by `claude` on launch). +# T2 = CLAUDE.md fenced snippet (idempotent append, see install_adapter._install_fenced). +# T3 = four custom slash commands (`/vouch-recall`, `/vouch-status`, +# `/vouch-resolve-issue`, `/vouch-propose-from-pr`). +# T4 = `.claude/settings.json` with a SessionStart hook + read-only kb_* auto-allow. +host: claude-code +pretty: Claude Code +fence: + begin: "" + end: "" +tiers: + T1: + - { src: .mcp.json, dst: .mcp.json } + T2: + - { src: CLAUDE.md.snippet, dst: CLAUDE.md, fenced_append: true } + T3: + - { src: .claude/commands/vouch-recall.md, dst: .claude/commands/vouch-recall.md } + - { src: .claude/commands/vouch-status.md, dst: .claude/commands/vouch-status.md } + - { src: .claude/commands/vouch-resolve-issue.md, dst: .claude/commands/vouch-resolve-issue.md } + - { src: .claude/commands/vouch-propose-from-pr.md, dst: .claude/commands/vouch-propose-from-pr.md } + T4: + - { src: .claude/settings.json, dst: .claude/settings.json } diff --git a/adapters/claude-desktop/README.md b/adapters/claude-desktop/README.md new file mode 100644 index 00000000..e02fe5f0 --- /dev/null +++ b/adapters/claude-desktop/README.md @@ -0,0 +1,12 @@ +# claude-desktop adapter + +Wires vouch into Anthropic's Claude Desktop app via Claude Desktop's MCP +config file. Unlike Claude Code (project-local `.mcp.json`), Claude Desktop +reads a single user-global JSON, so the writer drops a reviewable copy +under `/claude-desktop/` and the user copies it into place — see +the snippet for OS-specific paths. + +```sh +vouch install-mcp claude-desktop --path . +# then follow the printed README.md to finish the install +``` diff --git a/adapters/claude-desktop/README.snippet.md b/adapters/claude-desktop/README.snippet.md new file mode 100644 index 00000000..b4d3673a --- /dev/null +++ b/adapters/claude-desktop/README.snippet.md @@ -0,0 +1,18 @@ +# Claude Desktop install + +This template is **not** auto-installed into Claude Desktop because the +config file is user-global, not project-local. To finish the install: + +1. Locate Claude Desktop's config: + - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` + - Windows: `%APPDATA%\Claude\claude_desktop_config.json` + - Linux: `~/.config/Claude/claude_desktop_config.json` +2. If the file doesn't exist, copy `claude_desktop_config.json` from this + directory to that path. +3. If it does exist, merge the `mcpServers.vouch` entry into the existing + `mcpServers` map. +4. Restart Claude Desktop. Then test with `kb_status` in a new chat. + +Set `VOUCH_KB_PATH=/abs/path/to/your-project/.vouch` in the `env:` block to +point Claude Desktop at a specific KB (otherwise vouch walks up from the +working directory it was launched in, which is rarely useful for a GUI app). diff --git a/adapters/claude-desktop/claude_desktop_config.json b/adapters/claude-desktop/claude_desktop_config.json new file mode 100644 index 00000000..be5b737b --- /dev/null +++ b/adapters/claude-desktop/claude_desktop_config.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "vouch": { + "command": "vouch", + "args": ["serve"], + "env": { + "VOUCH_AGENT": "claude-desktop" + } + } + } +} diff --git a/adapters/claude-desktop/install.yaml b/adapters/claude-desktop/install.yaml new file mode 100644 index 00000000..4ef13769 --- /dev/null +++ b/adapters/claude-desktop/install.yaml @@ -0,0 +1,20 @@ +# Claude Desktop adapter manifest. +# +# Claude Desktop's MCP config is user-global, NOT project-local: +# macOS: ~/Library/Application Support/Claude/claude_desktop_config.json +# Windows: %APPDATA%\Claude\claude_desktop_config.json +# Linux: ~/.config/Claude/claude_desktop_config.json +# +# `vouch install-mcp claude-desktop --path .` therefore drops the template +# into `/claude-desktop/claude_desktop_config.json` so the user can +# review the JSON and copy it themselves -- we intentionally don't reach +# into the user's home directory from a project-scoped command (issue #179 +# explicitly defers uninstall + auto-detection; touching user-global state +# would conflict with both). The README walks through the copy step. +host: claude-desktop +pretty: Claude Desktop +tiers: + T1: + - { src: claude_desktop_config.json, dst: claude-desktop/claude_desktop_config.json } + T2: + - { src: README.snippet.md, dst: claude-desktop/README.md, fenced_append: true } diff --git a/adapters/cline/README.md b/adapters/cline/README.md new file mode 100644 index 00000000..7b24db34 --- /dev/null +++ b/adapters/cline/README.md @@ -0,0 +1,19 @@ +# cline adapter + +Wires vouch into Cline (the VSCode extension formerly known as +Claude Dev). Cline reads MCP servers from two places: + +- **Workspace-scoped:** `.vscode/settings.json` under `cline.mcpServers`. +- **User-global:** `~/.cline/mcp_servers.json`. + +```sh +vouch install-mcp cline --path . +``` + +T1 drops a project-local `.cline/mcp_servers.json` (paste-ready into the +user-global path), and T2 drops a `.vscode/settings.json` fragment that +takes effect for the current workspace as soon as Cline reloads. + +If you have an existing `.vscode/settings.json`, the writer skips T2 — merge +the `cline.mcpServers` block from this directory's `vscode_settings.json` +into your existing file by hand. diff --git a/adapters/cline/cline_mcp_servers.json b/adapters/cline/cline_mcp_servers.json new file mode 100644 index 00000000..1961dd1b --- /dev/null +++ b/adapters/cline/cline_mcp_servers.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "vouch": { + "command": "vouch", + "args": ["serve"], + "env": { + "VOUCH_AGENT": "cline" + } + } + } +} diff --git a/adapters/cline/install.yaml b/adapters/cline/install.yaml new file mode 100644 index 00000000..74c531f8 --- /dev/null +++ b/adapters/cline/install.yaml @@ -0,0 +1,15 @@ +# Cline (VSCode extension) adapter manifest. +# +# Cline supports per-workspace MCP servers via the VSCode settings JSON +# (`.vscode/settings.json` under the `cline.mcpServers` key) and a +# user-global form under `~/.cline/mcp_servers.json`. Workspace settings +# win for that workspace, so the writer drops both: +# - the standalone `cline_mcp_servers.json` (paste-ready into the global file) +# - a `.vscode/settings.json` fragment (workspace-scoped, ready as-is). +host: cline +pretty: Cline (VSCode) +tiers: + T1: + - { src: cline_mcp_servers.json, dst: .cline/mcp_servers.json } + T2: + - { src: vscode_settings.json, dst: .vscode/settings.json } diff --git a/adapters/cline/vscode_settings.json b/adapters/cline/vscode_settings.json new file mode 100644 index 00000000..3c422fa4 --- /dev/null +++ b/adapters/cline/vscode_settings.json @@ -0,0 +1,11 @@ +{ + "cline.mcpServers": { + "vouch": { + "command": "vouch", + "args": ["serve"], + "env": { + "VOUCH_AGENT": "cline" + } + } + } +} diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml new file mode 100644 index 00000000..7ba68b2f --- /dev/null +++ b/adapters/codex/install.yaml @@ -0,0 +1,10 @@ +# OpenAI Codex CLI adapter manifest. +# +# 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. +host: codex +pretty: OpenAI Codex CLI +tiers: + T1: + - { src: config.toml, dst: .codex/config.toml } diff --git a/adapters/continue/install.yaml b/adapters/continue/install.yaml new file mode 100644 index 00000000..65573851 --- /dev/null +++ b/adapters/continue/install.yaml @@ -0,0 +1,12 @@ +# Continue.dev adapter manifest. +# +# Continue reads `/.continue/config.yaml` -- our template carries +# only the `mcpServers:` block, so installs into a project that already has +# a config.yaml are intentionally skipped (the dest exists -> ``skipped``). +# Users with a pre-existing Continue config should merge by hand: the +# mcpServers entry is one block; eyeballing it is trivial. +host: continue +pretty: Continue.dev +tiers: + T1: + - { src: config.yaml, dst: .continue/config.yaml } diff --git a/adapters/cursor/AGENTS.md.snippet b/adapters/cursor/AGENTS.md.snippet new file mode 100644 index 00000000..7f309e72 --- /dev/null +++ b/adapters/cursor/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=cursor` in your env (or the MCP entry's `env:` block) so the +audit log can attribute writes to this host. diff --git a/adapters/cursor/install.yaml b/adapters/cursor/install.yaml new file mode 100644 index 00000000..900a752f --- /dev/null +++ b/adapters/cursor/install.yaml @@ -0,0 +1,17 @@ +# Cursor adapter manifest. +# +# T1 = Cursor's project-local MCP config at `/.cursor/mcp.json`. +# Cursor honours this on workspace open; the user-level form at +# `~/.cursor/mcp.json` is intentionally out of scope (see #179 acceptance — +# we don't touch user-global config from a project-scoped install). +# T2 = AGENTS.md fenced snippet (Cursor reads AGENTS.md the way Claude Code reads CLAUDE.md). +host: cursor +pretty: Cursor +fence: + begin: "" + end: "" +tiers: + T1: + - { src: mcp.json, dst: .cursor/mcp.json } + T2: + - { src: AGENTS.md.snippet, dst: AGENTS.md, fenced_append: true } diff --git a/adapters/http-tunnel/Dockerfile b/adapters/http-tunnel/Dockerfile new file mode 100644 index 00000000..893afb5e --- /dev/null +++ b/adapters/http-tunnel/Dockerfile @@ -0,0 +1,46 @@ +# Reference container for `vouch serve --transport http`. +# +# Build context expected to be the repo root, OR a directory containing the +# vouch-kb package on PyPI (uncomment the alternate install line below). +# Either way, the resulting image listens on :8731 and reads the KB at +# /data/.vouch — mount the host's .vouch/ into that path. + +FROM python:3.13-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + VOUCH_HTTP_PORT=8731 \ + VOUCH_KB_PATH=/data + +WORKDIR /app + +# Option A (default): install from this checkout. Allows local dev images +# without a PyPI release in the loop. +COPY pyproject.toml README.md ./ +COPY src ./src +RUN pip install --no-cache-dir . + +# Option B (uncomment if you'd rather pin a released version): +# RUN pip install --no-cache-dir vouch-kb>=0.2 + +# /data is the KB volume mount point. Hosts that bind-mount a directory +# containing .vouch/ here will be served as-is. +VOLUME ["/data"] +WORKDIR /data + +# Drop privileges -- the server reads/writes only inside /data/.vouch and +# never spawns child processes, so a non-root UID is safe and prevents a +# bug from rooting the container. +RUN useradd --system --uid 10001 vouch && chown -R vouch:vouch /data +USER vouch + +EXPOSE 8731 + +# Bind 0.0.0.0 + --allow-public is what makes the container externally +# reachable, but vouch refuses to start that combination unless at least one +# bearer token is configured (VOUCH_HTTP_TOKEN env, --token flag, or +# config.yaml serve.bearer_tokens). The deployment templates set +# VOUCH_HTTP_TOKEN via secrets — see README.md. +ENTRYPOINT ["vouch", "serve", "--transport", "http", \ + "--host", "0.0.0.0", "--port", "8731", \ + "--allow-public"] diff --git a/adapters/http-tunnel/README.md b/adapters/http-tunnel/README.md new file mode 100644 index 00000000..f6f62fca --- /dev/null +++ b/adapters/http-tunnel/README.md @@ -0,0 +1,106 @@ +# http-tunnel — public-internet reference deployments for `vouch serve --transport http` + +`vouch serve --transport http` is loopback-only by default. To expose it to +Claude.ai Custom Connectors (or any other off-host MCP client), you need: + +1. A public hostname with TLS. +2. The vouch HTTP server bound on a non-loopback host, **with at least one + bearer token** (vouch refuses to run that combination without it — see + `--allow-public` in `vouch serve --help`). +3. A way to keep the kb file-tree on disk reachable by the server process. + +This directory ships three drop-in templates for the same outcome — pick the +one that matches your hosting: + +| File | Where | What you get | +|------|-------|--------------| +| [`Dockerfile`](Dockerfile) | anywhere with a container runtime | A minimal Python image that runs `vouch serve --transport http --host 0.0.0.0 --allow-public` against the `/data/.vouch` volume. | +| [`fly.toml`](fly.toml) | fly.io | TLS-terminated public URL at `https://.fly.dev`, the Dockerfile is built and deployed by `fly deploy`. | +| [`cloudflare-tunnel/compose.yml`](cloudflare-tunnel/compose.yml) | self-hosted | The same Dockerfile, fronted by a Cloudflare Tunnel sidecar so the vouch instance never opens an inbound port. | + +Every template treats the **bearer token as the trust boundary** — there is +no public deployment of vouch without one. The CLI's own +`--allow-public + --token` (or `serve.bearer_tokens` in `config.yaml`) gate is +the last line of defence; the tunnel just makes the box reachable. + +## Common setup (every deployment) + +```bash +# In the directory holding your .vouch/ knowledge base: +export VOUCH_TOKEN=$(openssl rand -hex 32) +cat > .vouch/config.yaml </mcp` (or `/messages` — both work) +3. Auth: **Bearer**, paste your `VOUCH_TOKEN` +4. Hit "Test" — Claude's validator probes `/health` (unauthenticated) and + then runs an MCP `initialize` handshake against `/mcp` (with the bearer). + Both should be green. + +## Wiring Claude mobile, Managed Agents, Computer Use, Messages API + +All four take an `mcp_servers` list with `{"url": "...", "type": "url", +"authorization_token": "..."}`. Use the same URL + token you just gave +Claude.ai. + +## Why a reference deployment lives in the repo + +PRs that add a new public-internet surface to a credential-bearing service +are easier to land when the deployment scaffolding ships at the same time as +the code. Reviewers can see *exactly* how the trust boundary is meant to be +configured — there's no "and now you must do this safely off-screen" gap +where a contributor follows a Medium tutorial and ends up with an open +relay. See also #176 ("five Claude surfaces blocked") for the deployment +contexts these templates were designed for. diff --git a/adapters/http-tunnel/cloudflare-tunnel/compose.yml b/adapters/http-tunnel/cloudflare-tunnel/compose.yml new file mode 100644 index 00000000..1e8a958c --- /dev/null +++ b/adapters/http-tunnel/cloudflare-tunnel/compose.yml @@ -0,0 +1,55 @@ +# Cloudflare Tunnel reference deployment for `vouch serve --transport http`. +# +# Why this exists: the vouch instance opens NO inbound port -- the +# `cloudflared` sidecar dials Cloudflare's edge from inside and proxies +# requests in. That eliminates an entire class of "is my home network +# safe?" worries and gives you a stable https://.cloudflare URL. +# +# Run from this directory: +# export VOUCH_TOKEN="$(openssl rand -hex 32)" +# export CLOUDFLARE_TUNNEL_TOKEN="" +# docker compose up -d +# +# Then in the Cloudflare Zero Trust dashboard, set the tunnel's hostname to +# point at `http://vouch:8731` (the in-compose service name). + +services: + vouch: + build: + # Build context is the repo root so the Dockerfile can COPY src/. + context: ../../.. + dockerfile: adapters/http-tunnel/Dockerfile + container_name: vouch-http + environment: + # vouch's CLI flag --token reads VOUCH_HTTP_TOKEN by default; the + # config.yaml form (preferred for multi-token rotation) reads whatever + # name you reference. We use VOUCH_TOKEN here -- match it in your + # .vouch/config.yaml `bearer_token: env:VOUCH_TOKEN`. + VOUCH_HTTP_TOKEN: "${VOUCH_TOKEN:?set VOUCH_TOKEN before starting compose}" + volumes: + # Mount the host's .vouch/ knowledge base read-write into the container. + - "${VOUCH_KB_HOST_PATH:-./data}:/data" + restart: unless-stopped + # Internal network only -- nothing exposed to the host. + expose: + - "8731" + healthcheck: + # /healthz is unauthenticated by design (see VEP-0004). + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8731/healthz').read()"] + interval: 30s + timeout: 5s + retries: 3 + + cloudflared: + image: cloudflare/cloudflared:2024.10.0 + container_name: vouch-cloudflared + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + # Token from the Cloudflare Zero Trust dashboard for the tunnel you + # created. cloudflared dials *out* with this token -- nothing inbound. + TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN:?set CLOUDFLARE_TUNNEL_TOKEN before starting compose}" + depends_on: + vouch: + condition: service_healthy diff --git a/adapters/http-tunnel/fly.toml b/adapters/http-tunnel/fly.toml new file mode 100644 index 00000000..bcc9aab6 --- /dev/null +++ b/adapters/http-tunnel/fly.toml @@ -0,0 +1,50 @@ +# fly.io deployment for `vouch serve --transport http`. +# +# Run from this directory: +# fly launch --copy-config --name +# fly secrets set VOUCH_TOKEN="$(openssl rand -hex 32)" +# fly volumes create vouchdata --size 1 +# fly deploy +# +# Then point Claude.ai's Custom Connector at https://.fly.dev/mcp +# with the same VOUCH_TOKEN as the bearer. + +# REPLACE with your app name before `fly deploy`. Either run +# `fly launch --name --copy-config` (which rewrites this line for +# you), or edit it here. `fly deploy` will refuse the literal "REPLACEME-vouch". +app = "REPLACEME-vouch" +primary_region = "iad" + +[build] +dockerfile = "Dockerfile" + +[env] +# vouch reads VOUCH_HTTP_TOKEN as a single-value fallback for serve's --token. +# We re-bind it to VOUCH_TOKEN (the convention used by config.yaml's +# `bearer_token: env:VOUCH_TOKEN`) at deploy time via `fly secrets`. +VOUCH_HTTP_PORT = "8731" + +[[mounts]] +source = "vouchdata" +destination = "/data" + +[[services]] +internal_port = 8731 +protocol = "tcp" +auto_stop_machines = "stop" +auto_start_machines = true +min_machines_running = 0 + +# fly's edge terminates TLS and forwards the connection over a private mesh. +[[services.ports]] +port = 443 +handlers = ["tls", "http"] + +[[services.http_checks]] +# Liveness probe -- /healthz is unauthenticated by design so fly can probe +# without needing the bearer token. +path = "/healthz" +interval = "15s" +timeout = "5s" +grace_period = "5s" +method = "get" diff --git a/adapters/openclaw/AGENTS.md.snippet b/adapters/openclaw/AGENTS.md.snippet new file mode 100644 index 00000000..68852c4c --- /dev/null +++ b/adapters/openclaw/AGENTS.md.snippet @@ -0,0 +1,21 @@ +# Vouch — knowledge base (OpenClaw plugin) + +This project loads vouch via the OpenClaw plugin manifest at +[`openclaw.plugin.json`](https://github.com/vouchdev/vouch/blob/main/openclaw.plugin.json) +in the vouch repo. The plugin exports the `vouch serve` MCP surface, +the four bundled slash commands (`/vouch-recall`, `/vouch-status`, +`/vouch-resolve-issue`, `/vouch-propose-from-pr`), and a trust +boundary: every write tool is review-gated, every lifecycle op is +audit-logged, and remote callers' filesystem access is confined. + +You **cannot** write durable knowledge directly. Proposals land in +`.vouch/proposed/` and require human approval via `vouch approve`. +This is intentional. + +- `kb_search` / `kb_context` to read. +- `kb_propose_claim` / `kb_propose_page` to suggest additions — every + claim MUST cite at least one source or evidence id. +- `kb_supersede` to replace a stale claim, `kb_contradict` to flag a + conflict for a human to resolve. + +You are recorded as `proposed_by: openclaw` in the audit log. diff --git a/adapters/openclaw/README.md b/adapters/openclaw/README.md new file mode 100644 index 00000000..d4f5b4cd --- /dev/null +++ b/adapters/openclaw/README.md @@ -0,0 +1,33 @@ +# openclaw adapter + +Two different things both go by "the OpenClaw integration": + +1. **Loading vouch into an OpenClaw deployment.** That's the repo-root + [`openclaw.plugin.json`](../../openclaw.plugin.json) manifest — drop + the vouch repo into a deployment that vendors plugin repos and the + loader picks up the MCP server, the four slash commands, and the + trust-boundary declaration automatically. See the README section + "Running vouch as an OpenClaw plugin". +2. **Enabling vouch in one OpenClaw-managed project.** That's this + adapter, run with `vouch install-mcp openclaw --path `. + +The writer drops: + +- `.openclaw/plugins.json` — declares the vouch plugin enabled for this + project (T1). +- `AGENTS.md` — a fenced snippet pointing at the plugin manifest and + summarizing the review-gate contract (T2). +- `.claude/commands/vouch-*.md` — the same four slash commands the + claude-code adapter ships, referenced in place rather than duplicated + (T3). +- `.openclaw/policy.json` — the trust boundary as project-local policy: + review-gated writes, audit-logged lifecycle ops, confined filesystem + access for remote callers (T4). + +```sh +vouch install-mcp openclaw --path . +``` + +Re-running is idempotent: existing files are left alone, and `AGENTS.md` +gets the vouch block appended once inside a fence so reruns don't +duplicate it. diff --git a/adapters/openclaw/install.yaml b/adapters/openclaw/install.yaml new file mode 100644 index 00000000..efd2ff8a --- /dev/null +++ b/adapters/openclaw/install.yaml @@ -0,0 +1,37 @@ +# OpenClaw adapter manifest. +# +# OpenClaw loads vouch as a bundle plugin via the repo-root +# `openclaw.plugin.json` manifest (see README.md "Running vouch as an +# OpenClaw plugin"). That covers the *deployment* side -- dropping the +# vouch repo into an OpenClaw install. This adapter covers the +# per-*project* side: a project that wants vouch enabled gets a small +# set of OpenClaw-native files, mirroring the claude-code adapter's tiers. +# +# T1 = `.openclaw/plugins.json` -- declares the vouch plugin enabled for +# this project (the project-local analogue of running +# `openclaw plugin enable vouch`). +# T2 = AGENTS.md fenced snippet pointing at the plugin manifest and +# summarizing the review-gate contract. +# T3 = the same four slash commands the claude-code adapter ships, +# referenced in place rather than duplicated (see +# install_adapter.py's docstring; OpenClaw's loader bundles these +# from adapters/claude-code/ directly). +# T4 = `.openclaw/policy.json` -- the trust boundary as project-local +# policy (review-gated writes, audit-logged lifecycle, confined fs). +host: openclaw +pretty: OpenClaw +fence: + begin: "" + end: "" +tiers: + T1: + - { src: plugins.json, dst: .openclaw/plugins.json } + T2: + - { src: AGENTS.md.snippet, dst: AGENTS.md, fenced_append: true } + T3: + - { src: ../claude-code/.claude/commands/vouch-recall.md, dst: .claude/commands/vouch-recall.md } + - { src: ../claude-code/.claude/commands/vouch-status.md, dst: .claude/commands/vouch-status.md } + - { src: ../claude-code/.claude/commands/vouch-resolve-issue.md, dst: .claude/commands/vouch-resolve-issue.md } + - { src: ../claude-code/.claude/commands/vouch-propose-from-pr.md, dst: .claude/commands/vouch-propose-from-pr.md } + T4: + - { src: policy.json, dst: .openclaw/policy.json } diff --git a/adapters/openclaw/plugins.json b/adapters/openclaw/plugins.json new file mode 100644 index 00000000..6525f04f --- /dev/null +++ b/adapters/openclaw/plugins.json @@ -0,0 +1,11 @@ +{ + "plugins": { + "vouch": { + "source": "github:vouchdev/vouch", + "enabled": true, + "config": { + "agent": "openclaw" + } + } + } +} diff --git a/adapters/openclaw/policy.json b/adapters/openclaw/policy.json new file mode 100644 index 00000000..b646e77f --- /dev/null +++ b/adapters/openclaw/policy.json @@ -0,0 +1,8 @@ +{ + "vouch": { + "review_gated_writes": true, + "audit_logged_lifecycle": true, + "remote_callers_filesystem": "confined", + "agent": "openclaw" + } +} diff --git a/adapters/openclaw/vouch-context-engine.mjs b/adapters/openclaw/vouch-context-engine.mjs new file mode 100644 index 00000000..81c15eac --- /dev/null +++ b/adapters/openclaw/vouch-context-engine.mjs @@ -0,0 +1,115 @@ +/** + * OpenClaw plugin entry for the vouch-context engine (#228). + * + * The host loads this module via openclaw.plugin.json → openclaw.extensions. + * Runtime assembly delegates to the Python engine through `vouch openclaw-rpc` + * so the cited synthesis path stays identical to unit tests and kb.context. + * + * Enable in openclaw.json: + * plugins.slots.contextEngine: "vouch-context" + */ + +import { spawnSync } from 'node:child_process'; + +export const ENGINE_ID = 'vouch-context'; +export const ENGINE_NAME = 'Vouch Context Engine'; + +/** @typedef {import('node:child_process').SpawnSyncReturns} SpawnResult */ + +/** + * @param {string} method + * @param {Record} params + * @returns {Record} + */ +function callPythonEngine(method, params) { + const envelope = JSON.stringify({ + id: 'openclaw', + method, + params, + }); + /** @type {SpawnResult} */ + const proc = spawnSync('vouch', ['openclaw-rpc'], { + input: envelope, + encoding: 'utf8', + env: process.env, + maxBuffer: 16 * 1024 * 1024, + }); + if (proc.error) { + throw proc.error; + } + if (proc.status !== 0) { + const detail = (proc.stderr || proc.stdout || '').trim(); + throw new Error( + `vouch openclaw-rpc exited ${proc.status}${detail ? `: ${detail}` : ''}`, + ); + } + let parsed; + try { + parsed = JSON.parse(String(proc.stdout || '{}')); + } catch (err) { + throw new Error(`vouch openclaw-rpc returned invalid json: ${err}`); + } + if (!parsed.ok) { + const msg = parsed.error?.message || 'engine rpc failed'; + throw new Error(msg); + } + return parsed.result; +} + +/** @type {{ id: string; name: string; description: string; register: (api: any) => void }} */ +const entry = { + id: 'vouch-context-engine', + name: 'Vouch Context Engine', + description: + 'Review-gated KB context: cited retrieval + salience reflex + hot memory on every assemble()', + + register(api) { + api.registerContextEngine(ENGINE_ID, (ctx) => { + const workspaceDir = ctx.workspaceDir; + const kbPath = ctx.kbPath ?? ctx.kb_path; + const agent = ctx.agent; + const project = ctx.project; + + const baseParams = () => ({ + workspaceDir, + kbPath, + agent, + project, + }); + + return { + info: { + id: ENGINE_ID, + name: ENGINE_NAME, + version: '0.1.0', + ownsCompaction: false, + }, + + async ingest({ sessionId, message, isHeartbeat }) { + return callPythonEngine('ingest', { + ...baseParams(), + sessionId, + message, + isHeartbeat, + }); + }, + + async assemble(params) { + return callPythonEngine('assemble', { + ...baseParams(), + ...params, + }); + }, + + async compact(params) { + return callPythonEngine('compact', { + ...baseParams(), + ...params, + }); + }, + }; + }); + }, +}; + +export default entry; diff --git a/adapters/windsurf/README.md b/adapters/windsurf/README.md new file mode 100644 index 00000000..50de9fda --- /dev/null +++ b/adapters/windsurf/README.md @@ -0,0 +1,14 @@ +# windsurf adapter + +Codeium's Windsurf IDE reads MCP servers from +`/.codeium/windsurf/mcp_config.json` (workspace-scoped) or +`~/.codeium/windsurf/mcp_config.json` (user-global). The writer installs +the workspace form; if you want vouch available in every Windsurf workspace, +copy the same JSON into the user-global path manually. + +```sh +vouch install-mcp windsurf --path . +``` + +Then reload the Windsurf workspace. `kb_status` should appear in the MCP +tools list. diff --git a/adapters/windsurf/install.yaml b/adapters/windsurf/install.yaml new file mode 100644 index 00000000..a868869c --- /dev/null +++ b/adapters/windsurf/install.yaml @@ -0,0 +1,10 @@ +# Codeium Windsurf adapter manifest. +# +# Windsurf reads `/.codeium/windsurf/mcp_config.json` for +# workspace-scoped MCP servers (also `~/.codeium/windsurf/mcp_config.json` +# globally, which we leave alone per #179 scope). +host: windsurf +pretty: Codeium Windsurf +tiers: + T1: + - { src: mcp_config.json, dst: .codeium/windsurf/mcp_config.json } diff --git a/adapters/windsurf/mcp_config.json b/adapters/windsurf/mcp_config.json new file mode 100644 index 00000000..a9adc2a8 --- /dev/null +++ b/adapters/windsurf/mcp_config.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "vouch": { + "command": "vouch", + "args": ["serve"], + "env": { + "VOUCH_AGENT": "windsurf" + } + } + } +} diff --git a/adapters/zed/README.md b/adapters/zed/README.md new file mode 100644 index 00000000..26172f16 --- /dev/null +++ b/adapters/zed/README.md @@ -0,0 +1,14 @@ +# zed adapter + +Zed treats MCP servers as "context servers" under the `context_servers` +key in its settings JSON. The writer ships the workspace form +(`.zed/settings.json`); to enable vouch in every Zed workspace, paste the +same block into `~/.config/zed/settings.json` instead. + +```sh +vouch install-mcp zed --path . +``` + +If `.zed/settings.json` already exists, the writer skips it — merge the +`context_servers.vouch` block from this directory's `settings.json` into +your existing file by hand. diff --git a/adapters/zed/install.yaml b/adapters/zed/install.yaml new file mode 100644 index 00000000..1e8dab44 --- /dev/null +++ b/adapters/zed/install.yaml @@ -0,0 +1,11 @@ +# Zed adapter manifest. +# +# Zed embeds MCP server config under the `context_servers` key in +# `.zed/settings.json` (workspace) or `~/.config/zed/settings.json` (user). +# We ship the workspace form; the file is small enough that the user can +# paste into their global settings if they prefer that scope. +host: zed +pretty: Zed +tiers: + T1: + - { src: settings.json, dst: .zed/settings.json } diff --git a/adapters/zed/settings.json b/adapters/zed/settings.json new file mode 100644 index 00000000..48fe6045 --- /dev/null +++ b/adapters/zed/settings.json @@ -0,0 +1,14 @@ +{ + "context_servers": { + "vouch": { + "command": { + "path": "vouch", + "args": ["serve"], + "env": { + "VOUCH_AGENT": "zed" + } + }, + "settings": {} + } + } +} diff --git a/benchmarks/bench_bundle.py b/benchmarks/bench_bundle.py new file mode 100644 index 00000000..6bee6382 --- /dev/null +++ b/benchmarks/bench_bundle.py @@ -0,0 +1,60 @@ +"""Bundle export + import + verify round-trip benchmarks.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import bundle +from vouch.health import rebuild_index +from vouch.storage import KBStore + + +@pytest.fixture(scope="module") +def exported_bundle_1k(kb_1k, tmp_path_factory): + store = KBStore(kb_1k) + rebuild_index(store) + dest = tmp_path_factory.mktemp("bundles") / "bench_1k.tar.gz" + bundle.export(store.kb_dir, dest=dest, actor="bench") + return dest + + +@pytest.fixture(scope="module") +def exported_bundle_10k(kb_10k, tmp_path_factory): + store = KBStore(kb_10k) + rebuild_index(store) + dest = tmp_path_factory.mktemp("bundles") / "bench_10k.tar.gz" + bundle.export(store.kb_dir, dest=dest, actor="bench") + return dest + + +def test_bundle_export_1k(benchmark, kb_1k, tmp_path): + store = KBStore(kb_1k) + dest = tmp_path / "out.tar.gz" + benchmark(bundle.export, store.kb_dir, dest=dest, actor="bench") + + +def test_bundle_import_1k(benchmark, exported_bundle_1k, tmp_path): + dest_kb = KBStore.init(tmp_path) + benchmark( + bundle.import_apply, + dest_kb.kb_dir, + exported_bundle_1k, + on_conflict="overwrite", + actor="bench", + ) + + +def test_bundle_export_check_1k(benchmark, exported_bundle_1k): + benchmark(bundle.export_check, exported_bundle_1k) + + +def test_bundle_import_10k(benchmark, exported_bundle_10k, tmp_path): + dest_kb = KBStore.init(tmp_path) + benchmark( + bundle.import_apply, + dest_kb.kb_dir, + exported_bundle_10k, + on_conflict="overwrite", + actor="bench", + ) diff --git a/benchmarks/bench_index_rebuild.py b/benchmarks/bench_index_rebuild.py new file mode 100644 index 00000000..7dcf8da8 --- /dev/null +++ b/benchmarks/bench_index_rebuild.py @@ -0,0 +1,19 @@ +"""Index rebuild latency benchmarks at varying KB sizes.""" +from __future__ import annotations + +import pytest + +from vouch.health import rebuild_index +from vouch.storage import KBStore + + +def test_index_rebuild_1k(benchmark, kb_1k): + """Index rebuild time at 1k claims.""" + store = KBStore(kb_1k) + benchmark(rebuild_index, store) + + +def test_index_rebuild_10k(benchmark, kb_10k): + """Index rebuild time at 10k claims.""" + store = KBStore(kb_10k) + benchmark(rebuild_index, store) diff --git a/benchmarks/bench_propose.py b/benchmarks/bench_propose.py new file mode 100644 index 00000000..03a1bba1 --- /dev/null +++ b/benchmarks/bench_propose.py @@ -0,0 +1,35 @@ +"""Proposal write latency benchmarks.""" +from __future__ import annotations + +import pytest + +from vouch.proposals import propose_claim +from vouch.storage import KBStore + + +@pytest.fixture +def fresh_store(tmp_path): + return KBStore.init(tmp_path) + + +@pytest.fixture +def source_id(fresh_store): + src = fresh_store.put_source(b"benchmark evidence") + return src.id + + +def test_propose_claim_latency(benchmark, fresh_store, source_id): + """propose_claim() write latency — sits in the agent hot loop.""" + counter = [0] + + def _propose(): + counter[0] += 1 + return propose_claim( + fresh_store, + text=f"benchmark claim {counter[0]}", + evidence=[source_id], + proposed_by="bench-agent", + ) + + result = benchmark(_propose) + assert result is not None diff --git a/benchmarks/bench_search.py b/benchmarks/bench_search.py new file mode 100644 index 00000000..d0866801 --- /dev/null +++ b/benchmarks/bench_search.py @@ -0,0 +1,46 @@ +"""Search latency benchmarks at varying KB sizes.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import index_db +from vouch.health import rebuild_index +from vouch.storage import KBStore + + +def _store(kb_path: Path) -> KBStore: + store = KBStore(kb_path) + rebuild_index(store) + return store + + +@pytest.fixture(scope="module") +def store_1k(kb_1k): + return _store(kb_1k) + + +@pytest.fixture(scope="module") +def store_10k(kb_10k): + return _store(kb_10k) + + +def test_search_fts5_1k(benchmark, store_1k): + """FTS5 search latency on a 1k-claim KB.""" + benchmark(index_db.search, store_1k.kb_dir, "auth uses JWT", limit=10) + + +def test_search_fts5_10k(benchmark, store_10k): + """FTS5 search latency on a 10k-claim KB.""" + benchmark(index_db.search, store_10k.kb_dir, "auth uses JWT", limit=10) + + +def test_search_substring_1k(benchmark, store_1k): + """Substring fallback search latency on a 1k-claim KB.""" + benchmark(store_1k.search_substring, "auth", limit=10) + + +def test_search_substring_10k(benchmark, store_10k): + """Substring fallback search latency on a 10k-claim KB.""" + benchmark(store_10k.search_substring, "auth", limit=10) diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py new file mode 100644 index 00000000..8951f10f --- /dev/null +++ b/benchmarks/conftest.py @@ -0,0 +1,43 @@ +"""pytest-benchmark configuration for vouch benchmarks.""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + + +def pytest_configure(config): + """Require pytest-benchmark.""" + try: + import pytest_benchmark # noqa: F401 + except ImportError: + pytest.exit( + "pytest-benchmark is required: pip install pytest-benchmark", + returncode=1, + ) + + +@pytest.fixture(scope="session") +def kb_1k(tmp_path_factory): + return _gen_kb(tmp_path_factory, claims=1_000) + + +@pytest.fixture(scope="session") +def kb_10k(tmp_path_factory): + return _gen_kb(tmp_path_factory, claims=10_000) + + +@pytest.fixture(scope="session") +def kb_100k(tmp_path_factory): + return _gen_kb(tmp_path_factory, claims=100_000) + + +def _gen_kb(tmp_path_factory, *, claims: int) -> Path: + out = tmp_path_factory.mktemp(f"kb{claims}") + subprocess.check_call( + [sys.executable, "benchmarks/fixtures/gen_kb.py", + "--out", str(out), "--claims", str(claims)], + ) + return out diff --git a/docs/README.md b/docs/README.md index 0025fb74..12f4e8fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ If you're new, read in this order: one KB without stepping on each other. 8. [faq.md](faq.md) — questions we keep getting. 9. [embeddings.md](embeddings.md) — semantic retrieval (primary backend) +10. [gittensor.md](gittensor.md) — adopting vouch as a Gittensor repo's + review-gated decision memory. ### How docs relate to other directories diff --git a/docs/gittensor.md b/docs/gittensor.md new file mode 100644 index 00000000..9db2abab --- /dev/null +++ b/docs/gittensor.md @@ -0,0 +1,157 @@ +# Adopting vouch for a Gittensor repo + +Gittensor (Bittensor subnet 74) rewards open-source contributions, and its +scoring rules, repo allow-list, anti-sybil measures, and emission-split +decisions evolve and get debated across PRs, Discord, and validator changes. +That rationale usually lives in people's heads and scattered threads — so when +a weight changes or a repo is de-listed, there's no durable, cited answer to +*"why did we decide this, and what did it replace?"* + +vouch is a good fit for that gap: a small, **review-gated, cited** knowledge +base committed to the repo as the durable memory layer for maintainer and +validator decisions. + +## vouch vs. Gittensory — different layers + +These are complementary, not competing: + +| Layer | Owner | Holds | +|---|---|---| +| Chain / scoring | Gittensor (SN74) | the actual weights and emissions | +| **Live signals** | **Gittensory** | scores, queues, collision/reviewability | +| **Durable decisions** | **vouch** | *why* a rule exists, what it superseded — cited & reviewed | + +vouch deliberately stores **no** live signals. It is not a validator or miner +client; it doesn't read on-chain scores, verify PATs, or submit weights. It is +the institutional memory that sits alongside the live layer. + +## 1. Install + +```bash +pipx install vouch-kb # the installed command is `vouch` +vouch --version +``` + +## 2. Seed a KB with the gittensor pack + +From the root of the Gittensor repo: + +```bash +vouch init --template gittensor +``` + +This creates `.vouch/` and seeds a cited, approved starter pack about SN74 +scoring — **1 source, 1 entity, 7 claims** (merged-PR rewards, PAT +verification, scoring factors, sybil-resistance, repo allow-list policy, +issue-solving multiplier, and emission split): + +```bash +vouch status +# durable: 7 claims • 1 sources • 1 entities • … +vouch search "scoring" +# claim/gittensor-merged-pr-base-reward …primary OSS reward signal… +# claim/gittensor-sybil-resistance …GitHub verification + merged-PR… +vouch doctor +# index present, citations resolve, sources verify → clean +``` + +Commit it so the whole team shares one memory: + +```bash +git add .vouch && git commit -m "chore: add vouch decision-memory KB" +``` + +`.vouch/.gitignore` keeps `proposed/` (drafts) and `state.db` (the derived +index) out of history automatically. + +> **The seeded claims are starter-grade.** They summarize the scoring model as +> understood when the template was authored. Before you rely on a specific +> rule or number, `vouch show ` it and `vouch supersede` it with the +> real spec/PR citation (see §4) so the KB reflects the live rules. + +## 3. Wire the MCP server for agents + +Add `.mcp.json` at the repo root so any MCP host (Claude Code, Cursor, Codex) +can query the KB and get cited answers instead of guessing: + +```json +{ + "mcpServers": { + "vouch": { "command": "vouch", "args": ["serve"] } + } +} +``` + +An agent working in the repo can now call `kb.search` / `kb.context` ("how does +scoring work today?") and `kb.propose_claim` to draft new knowledge (still +gated — see below). + +## 4. Capture decisions as cited claims + +The whole value is that every scoring/policy decision is **proposed, reviewed, +cited, and supersede-able**. When a change lands: + +```bash +# 1) register the thing you're citing — the PR, a spec file, a thread export +vouch source add docs/validator-change-pr-200.md # → a source id + +# 2) propose a claim that cites it +vouch propose-claim \ + --text "SN74 raised the maintainer issue-solving multiplier from 1.66 to 1.75." \ + --source --type fact --confidence 0.9 --tag gittensor --tag scoring +# → proposal id + +# 3) a *different* maintainer approves (the proposer can't self-approve) +vouch pending +vouch approve +git add .vouch && git commit -m "kb: record maintainer-multiplier change (PR #200)" +``` + +If you try to approve your own proposal you'll get +`forbidden_self_approval` — that's the gate working. A maintainer with a +different identity must approve. + +**When a rule changes, supersede — don't overwrite.** Propose and approve the +replacement claim (steps 1–3 above), then link the old one to it by id: + +```bash +vouch supersede +``` + +The old claim is kept (marked superseded) so the history of what changed stays +intact and queryable. + +Every write is in `.vouch/audit.log.jsonl` — `vouch audit` shows exactly who +proposed and who approved each change, so the history of *why* is queryable, +not lost. + +## 5. A CONTRIBUTING note for the repo + +Drop a short note into the Gittensor repo's `CONTRIBUTING.md` so the habit +sticks: + +```markdown +### Recording scoring / policy decisions + +When a change alters scoring, the repo allow-list, anti-sybil thresholds, or +emission split, record it in vouch as a cited claim: + +1. `vouch source add` the PR or spec that drives it. +2. `vouch propose-claim --source --type fact|decision` (or + `vouch supersede` the claim it replaces). +3. A maintainer reviews with `vouch pending` / `vouch approve`. + +Cite the PR. Don't bury the rationale in a thread. +``` + +## 6. Day-to-day + +```bash +vouch context "how are merged PRs scored and what stops sybil mining" +# → a ranked, cited pack ready to paste into an agent prompt +vouch search "emission" --semantic # if installed with the [embeddings] extra +vouch lint # broken citations / stale claims +``` + +That's the loop: live signals come from Gittensory; the durable *why* lives in +vouch, one cited and reviewed claim at a time. diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 00000000..c7f6f0c4 --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,155 @@ +# `vouch metrics` — observability + +`vouch status` tells you the KB is *alive* (artifact counts). `vouch metrics` +tells you the **review gate** and **corpus** are *healthy*: how often proposals +get approved, how stale the corpus is, how long claims sit pending, and who is +doing the proposing and approving. + +Everything is computed **read-only** from two sources that already exist on +disk — there is **no new state**, no schema migration, nothing to back up: + +- `.vouch/audit.log.jsonl` — the append-only event stream (proposal + create/approve/reject, claim lifecycle), each event carrying a timestamp and + an actor. +- the artifact files (`claims/*.yaml`, `sources/*`, `evidence/*`), read through + the normal `KBStore` API. + +## Usage + +```bash +vouch metrics # human-readable table, all of history +vouch metrics --json # stable JSON schema (this document) +vouch metrics --prometheus # 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 +vouch metrics --stale-days 90 # tighten the "stale claim" threshold +vouch metrics --top 10 # show 10 actors in the leaderboard (0 = all) +``` + +### `--since` / `--until` formats + +| Form | Example | Meaning | +|------|---------|---------| +| duration | `30d`, `12h`, `2w`, `90m`, `45s` | counted back from now | +| ISO date | `2026-01-01` | midnight UTC on that date | +| ISO datetime | `2026-01-01T06:30:00+00:00` | exact instant (naive → UTC) | +| `all` / omitted | — | no bound | + +The window applies to **audit-derived** metrics (the review gate, lag, +actors). **Corpus** metrics (citation coverage, stale ratio, status histogram) +always reflect *current* on-disk state — a claim is stale now regardless of the +window you ask for. + +## Metrics + +| Metric | Meaning | +|--------|---------| +| `approval_rate` | `approvals / (approvals + rejections)` over the window. `null` when there were no decisions. | +| `approval_rate_by_kind` | the same ratio split per `ProposalKind` (`claim`, `page`, `entity`, `relation`). | +| `citation_coverage` | fraction of claims whose every `evidence` id resolves to a live Source or Evidence. | +| `citation_broken` | count of claims with at least one unresolved citation. | +| `stale_ratio` | `stale_claims / claims_active`, where a claim is stale if its freshness anchor (`last_confirmed_at`, else `updated_at`, else `created_at`) is older than `--stale-days` (default 180). Retired claims (superseded/archived/redacted) are exempt. | +| `proposal_lag_seconds` | latency from a proposal's `*.create` event to its matching `*.approve`, as `p50` / `p90` / `p99` / `mean` / `max` (nearest-rank percentiles, matching Prometheus `histogram_quantile`). | +| `actors` | top-N actors by total activity, each with `proposed` / `approved` / `rejected` / `confirmed` counts. | + +A create event older than `--since` still pairs with an in-window approve, so +the left edge of the window does not systematically undercount lag. + +## JSON schema (stable) + +`--json` emits the following shape. Treat field renames or removals as +breaking; `schema_version` is bumped when that happens. + +```json +{ + "schema_version": 1, + "window": { + "since": "2026-05-11T12:00:00+00:00", + "until": null, + "generated_at": "2026-06-10T12:00:00+00:00" + }, + "review_gate": { + "proposals_created": 4, + "approvals": 3, + "rejections": 1, + "approval_rate": 0.75, + "approval_rate_by_kind": { "claim": 0.6666666666666666, "page": 1.0 }, + "decisions_by_kind": { + "claim": { "approve": 2, "reject": 1 }, + "page": { "approve": 1, "reject": 0 } + }, + "pending_now": 0 + }, + "corpus": { + "claims_total": 5, + "claims_active": 4, + "claims_cited": 4, + "citation_coverage": 0.8, + "citation_broken": 1, + "stale_claims": 1, + "stale_ratio": 0.25, + "stale_after_days": 180, + "claims_by_status": { "working": 4, "archived": 1 } + }, + "proposal_lag_seconds": { + "count": 3, + "p50": 20.0, + "p90": 30.0, + "p99": 30.0, + "mean": 20.0, + "max": 30.0 + }, + "actors": [ + { "actor": "bob", "proposed": 2, "approved": 2, "rejected": 0, "confirmed": 0, "total": 4 }, + { "actor": "alice", "proposed": 2, "approved": 1, "rejected": 1, "confirmed": 1, "total": 5 } + ], + "audit": { "events_total": 9, "events_in_window": 9 } +} +``` + +### `null` semantics + +Ratios are `null`, **not** `0`, when their denominator is empty +(`approval_rate` with no decisions, `citation_coverage` with no claims, +`stale_ratio` with no active claims). This lets a consumer distinguish "no +data" from a genuine zero. The Prometheus exposition **omits** null gauges +entirely for the same reason — emitting `0` would lie about the denominator. + +## Prometheus textfile collector + +`--prometheus` emits gauges with `# HELP` / `# TYPE` headers, prefixed +`vouch_`. Wire it up with a cron sidecar writing to the node_exporter textfile +directory: + +```bash +# /etc/cron.d/vouch-metrics — every 5 minutes +*/5 * * * * app cd /srv/project && vouch metrics --prometheus \ + > /var/lib/node_exporter/textfile/vouch.prom.$$ \ + && mv /var/lib/node_exporter/textfile/vouch.prom.$$ \ + /var/lib/node_exporter/textfile/vouch.prom +``` + +(The temp-file-then-`mv` makes the write atomic so the collector never reads a +half-written file.) + +Example exposition: + +```text +# HELP vouch_approval_rate approve / (approve + reject) over window. +# TYPE vouch_approval_rate gauge +vouch_approval_rate 0.75 +# HELP vouch_citation_coverage Fraction of claims fully cited. +# TYPE vouch_citation_coverage gauge +vouch_citation_coverage 0.8 +# HELP vouch_claims_by_status Claim count per status. +# TYPE vouch_claims_by_status gauge +vouch_claims_by_status{status="archived"} 1 +vouch_claims_by_status{status="working"} 4 +``` + +## Out of scope + +- **Pushing** to Prometheus/Datadog — use the `--json` or `--prometheus` + output with a sidecar; `vouch metrics` never makes a network call. +- **Long-term retention / TSDB** — the audit log is the source of truth; + windowing happens at read time. diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 00000000..79627f15 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,75 @@ +# Schema migrations — `vouch migrate` + +The `.vouch/` layout is the durable contract: yaml claims, markdown pages with +frontmatter, json sessions, the jsonl audit log. As the pydantic models in +`src/vouch/models.py` evolve, a KB created against an older model would become a +load-time error. `vouch migrate` gives that evolution a versioned, reversible, +audit-logged upgrade path so a schema change never silently breaks a KB in the +wild. + +## Two version axes + +vouch tracks two independent versions, and `vouch migrate` covers both: + +| | Stamp | What it governs | Reached by | +|---|---|---|---| +| **Format** (integer) | `config.yaml` `version` | the `.vouch/` directory layout (subdirs, `.gitignore`) | `vouch migrate` *(no subcommand)* | +| **Schema** (semver) | `.vouch/schema_version` | the model schema of each artifact | `vouch migrate ` | + +A KB with no `.vouch/schema_version` file is treated as the baseline (`0.1.0`), +so existing KBs keep loading until their first migrate. `vouch init` stamps the +current version on bootstrap. + +## Commands + +```bash +vouch migrate status # current schema version, target, pending migrations +vouch migrate plan # dry-run: every file each pending migration would change +vouch migrate plan --to 0.3.0 # plan against a specific target +vouch migrate apply # apply pending migrations (audit-logged, atomic) +vouch migrate apply --yes # skip the confirmation prompt (CI) +vouch migrate rollback # reverse the most recently applied migration +vouch migrate verify # parse-load every artifact under the current version +``` + +## Manifests + +Migrations are **data, not code**: yaml manifests in the repo-root `migrations/` +directory, one consecutive version step each. See +[`migrations/README.md`](../migrations/README.md) for the full format and the +transform verbs (`rename`, `default`, `drop`, `split`, `merge`). Only consecutive +manifests apply — a `0.1 → 0.5` KB walks through `0.2`, `0.3`, `0.4` in order. + +## Safety model + +- **Atomic per file.** Each artifact is rewritten to a temp file, `fsync`-ed, + then `os.replace`-d into place. A file is always its old bytes or its new + bytes — never a torn mix. +- **Reversible.** Before any rewrite, the prior content of every file the step + touches is journalled to `.vouch/migrations/rollback-.jsonl`. `vouch + migrate rollback` replays it, restoring the exact prior bytes — so + apply → rollback is byte-equivalent (modulo the audit-log entries recording + the round trip). +- **Crash-safe.** The journal is written and fsynced *before* the first rewrite, + and `.vouch/schema_version` is bumped *last*. An interrupted apply therefore + leaves the KB reporting its prior version, with a journal to recover from. +- **Precondition.** Apply refuses if the KB has pending proposals — it won't + rewrite a reviewer's in-flight queue out from under them. Resolve them with + `vouch list-pending` first. +- **`state.db` is disposable.** The runner does not migrate the FTS5 / embedding + cache; the CLI rebuilds it after a successful apply. + +## Audit trail + +Each applied manifest logs one `kb.migrate.apply` event (manifest id, file count, +rollback-journal id); a rollback logs `kb.migrate.rollback`. The legacy format +migration continues to log `kb.migrate`. + +## Out of scope (v1) + +- Cross-major jumps in a single manifest (consecutive only). +- `custom: path/to/migration.py` script transforms (lands once the verb + taxonomy stabilises). +- Migrating `state.db` (disposable; rebuilt by `vouch index`). +- Bundle version embedding / refusing mismatched imports — a follow-up. +- Concurrent multi-process migrate runs (single-writer assumption). diff --git a/docs/multi-agent.md b/docs/multi-agent.md index b4918dea..2ea14edd 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -66,7 +66,9 @@ Two agents will eventually disagree. What it looks like: - **Duplicate proposals.** Two agents independently propose the same fact. The reviewer sees two pending proposals with similar text. - Approve one, reject the other with reason "duplicate of prop-XYZ". + `kb.propose_claim` / `vouch propose-claim` return non-blocking + `warnings` (`similar_approved`, `similar_pending`) when embeddings are + available — approve one, reject the other with reason "duplicate of prop-XYZ". - **Contradicting claims approved.** Two reviewers approved conflicting claims at different times. Use `vouch contradict A B` to link them; pick a survivor with `vouch supersede`. @@ -76,12 +78,18 @@ Two agents will eventually disagree. What it looks like: ## Tracking who's busy +```bash +vouch stats # pending by agent, review rates, citation coverage +vouch stats --json # same, for dashboards / CI +``` + +Or, if you only need the queue breakdown: + ```bash vouch pending --json | jq -r '.[] | "\(.proposed_by)\t\(.id)"' | sort | uniq -c ``` -Tells you which agent has the most pending work. Useful when one -agent has been spammy or is asleep at the wheel. +Useful when one agent has been spammy or is asleep at the wheel. ## Crystallisation per agent @@ -94,13 +102,26 @@ vouch session start --task "implement password reset" \ --note "tag:agent:claude-code-anna" ``` +## Distributed sync + +When two teammates each have their own `.vouch/` directory, use the +sync workflow to reconcile them deterministically: + +```bash +vouch sync-check ../other-repo +vouch sync-apply ../other-repo --on-conflict fail +``` + +`sync-check` accepts either another repo / `.vouch` directory or a +bundle. It reports new files, identical files, and conflicts without +writing anything. `sync-apply` imports non-conflicting files only; it +never overwrites reviewed knowledge. Use `--on-conflict skip` to leave +conflicts untouched, or `--on-conflict propose` to write a local conflict +report under `proposed/sync-reports/` for human review. `config.yaml` +stays local to each KB and is not synced. + ## What doesn't work yet -- **Distributed `.vouch/` directories that sync.** Today it's one - filesystem. If two teammates each have their own `.vouch/` and want - them to merge, the path is bundle export + import-check, manually, - for now. See [bundles.md](bundles.md) and the multi-agent-sync - roadmap item. - **Live merge conflicts.** Two agents editing the same proposal at once isn't a scenario vouch addresses — agents create proposals, they don't edit existing ones. diff --git a/docs/provenance.md b/docs/provenance.md new file mode 100644 index 00000000..915aa68b --- /dev/null +++ b/docs/provenance.md @@ -0,0 +1,97 @@ +# Provenance — `vouch why` / `trace` / `impact` + +vouch already records everything needed to explain a claim's existence — the +session that proposed it, the source it cites, the supersedes chain it sits on, +the contradiction that demoted it, the page that embeds it as evidence — but it +is scattered across `audit.log.jsonl`, `relations/`, `evidence/`, `sessions/` +and the claim file itself. The provenance layer reconstructs a single typed +directed graph from those artifacts so a reviewer in front of a 500-item queue +can ask the two obvious questions: *why is this claim here, and what depends on +it?* + +Provenance is **derived state**. Nothing here is a source of truth: every edge +is rebuilt from durable files, and the `prov_edges` table in `state.db` is a +disposable cache that `vouch provenance rebuild` reconstructs byte-for-byte. All +mutations still flow through the existing proposal + lifecycle code paths. + +## Edge kinds + +The graph is typed. An edge `A --kind--> B` means *A is explained by / depends +on B*, so `why` walks outward and `impact` walks inward. + +| Kind | From → To | Source artifact | +|------|-----------|-----------------| +| `cites` | claim → source/evidence | `claim.evidence` | +| `derivedFrom` | evidence span → source | `evidence.source_id` | +| `supersedes` | newer claim → older claim | `claim.supersedes` | +| `supersededBy` | older claim → newer claim | reverse view (query-time) | +| `contradicts` | claim → claim | `claim.contradicts` | +| `contradictedBy` | claim → claim | reverse view (query-time) | +| `embeds` | page → claim | `page.claims` | +| `proposedIn` | claim → session | approved proposal `session_id` | +| `approvedBy` | claim → audit event | `proposal.*.approve` log entry | + +The two `*By` mirrors are computed at query time by walking inbound, so the +`prov_edges` cache stays free of duplicate rows — only the seven canonical kinds +are persisted. + +## Commands + +```bash +vouch why # backward: cites, session, supersedes, approval +vouch why --depth 5 --json # machine-readable provenance tree +vouch trace --to # shortest typed path between two artifacts +vouch impact # forward: pages, downstream claims that depend on it +vouch impact --if archive # dry-run a lifecycle op; exits non-zero if it breaks something +vouch graph --session # render the DAG for one agent run as dot/mermaid +vouch provenance rebuild # rebuild the prov_edges cache from durable files +``` + +- `vouch why` walks edges outward from a claim and prints a tree grouped by edge + kind, each leaf carrying its citation target and originating audit timestamp; + `--json` emits a stable shape (`schema_version` pinned) suitable for tooling. +- `vouch impact` walks inward and lists every artifact that points at the + target. `--if ` dry-runs the lifecycle op + against the in-memory graph and reports the breakage list — the **active** + pages that would carry a stale reference — without writing. It exits non-zero + when the list is non-empty, so it composes into a pre-flight check. +- `vouch trace` finds the shortest typed-edge path between two artifacts + (edges are crossable either way) and prints it, or exits non-zero with + `no path` when they are disconnected. + +Reviewer output is bare prose + indentation — no curses, no colours by default — +so it diffs cleanly into a `gh pr comment` or a session log. + +## `kb.*` methods + +The same surface is reachable over every transport (MCP stdio, JSONL, HTTP): + +| Method | Params | +|--------|--------| +| `kb.why` | `claim_id`, `depth` | +| `kb.trace` | `from`, `to` | +| `kb.impact` | `claim_id`, `depth`, `op` | +| `kb.graph_export` | `session`, `format` | +| `kb.provenance_rebuild` | — | + +They appear in `kb.capabilities` and pass the JSONL capabilities cross-check. + +## The cache + +The `prov_edges(src_id, dst_id, kind, event_ts, session_id)` table in `state.db` +is a derived index, gitignored alongside the rest of the cache. A freshness +stamp (claim count + page count + audit-event count) lets a cold query decide +whether the cache can be trusted; when stale, `load_graph` rebuilds it +transparently. Correctness never depends on the cache — a rebuild is always an +exact reconstruction of the live in-memory build, which a CI test asserts. + +## Out of scope + +- A graphical web visualization of the DAG — a natural extension of the + `review-ui`, not this. +- Mutating the graph directly; provenance is derived state. +- Cross-KB / federated provenance. +- Embedding-based "semantic neighbors" — provenance edges are strictly the + typed, audit-grounded ones. +- Auto-blocking lifecycle ops based on impact size — `vouch impact` advises, the + human still decides. diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 00000000..b99c1abc --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,86 @@ +# vouch v1.0.0 + +the first stable release. everything since 0.1.0, integrated — agent coding +tools, answer-mode retrieval, a typed knowledge model, vault sync, and a broad +adapter catalogue. the load-bearing invariant is unchanged: **every durable +write still goes through the review gate** (`propose → approve`); nothing in +this release auto-approves. + +> **install:** `pipx install vouch-kb` (the command is `vouch`; the PyPI +> distribution is `vouch-kb`) +> **upgrade:** `pipx upgrade vouch-kb` + +## highlights + +**competitive agent coding** +- `vouch dual-solve ` — run Claude Code and Codex on one GitHub issue + in isolated worktrees, compare both diffs, keep the one you pick, and propose + its rationale into the KB (winning commit registered as a `Source`; decision + + up to 3 approach claims land in `proposed/`). `--json`, `--no-record`, + `--dry-run`, live per-phase progress. +- `vouch dual-solve --sandbox` / `vouch review-ui --dual-solve-sandbox` — run the + engines inside a Docker image while keeping git/GitHub on the host; agent + writes stay confined to the throwaway branches. +- `vouch review-ui --allow-dual-solve` — a browser SPA that runs dual-solve from + an issue link, streams progress over the review-ui websocket, shows both diffs + side by side, and lets you pick the winner. off by default, localhost-first, + edit-only over http. +- `vouch auto-pr ` — open N mergeable PRs against any GitHub repo, + cross-verifying each diff by alternating Claude/Codex as fixer and reviewer; + opens only when the repo's own tests pass and the reviewer signs off. + +**answer-mode retrieval** +- `kb.synthesize` — prose answers from approved claims only, with an inline + `[claim_id]` citation behind every sentence, an explicit `gaps` block, and a + `synthesis_confidence` grade (deterministic; CLI/MCP/JSONL). +- entity-salience reflex — a per-session, zero-LLM pass that attaches candidate + claims as `_meta.vouch_salience` on context reads. +- `kb.volunteer_context` — confidence-gated push context: surfaces a + highly-relevant approved claim to an active session. + +**typed knowledge model** +- typed page kinds (`#234`) — declare extra page kinds in `config.yaml` with + required fields, a frontmatter schema, and `extends`; validated at propose + **and** approve. new `vouch schema list` / `vouch schema sync`. +- auto-extracted typed edges — approving a page files `mentions` / `relates_to` + / `derived_from` relation proposals (still review-gated; `vouch + reject-extracted` mass-rejects). +- propose-time similarity warnings (`similar_approved` / `similar_pending`) when + the embeddings extra is installed. + +**vault sync & adapters** +- `vouch sync --vault ` — bidirectional sync with an Obsidian/Logseq + markdown vault (`--watch`, `--direction`). +- `vouch install-mcp ` — one-command adapter writer for 9 hosts + (claude-code, claude-desktop, cursor, continue, codex, windsurf, cline, zed, + openclaw) with `--tier T1..T4`. +- `vouch-context` OpenClaw context engine — cited `systemPromptAddition` on + every assemble. + +**ops & observability** +- `vouch stats` / `kb.stats` — pending-by-agent, approval rate, citation + coverage over a window. +- `vouch fsck` (deep consistency checks), `vouch migrate` (on-disk format + migrations), `vouch expire` (GC stale proposals). +- `vouch eval recall` — P@k / R@k / MRR / nDCG against a labeled set, gating + retrieval changes in CI. +- structured JSON logging (`VOUCH_LOG_FORMAT=json`), a `benchmarks/` suite, + `_meta.vouch_trust` on responses, visibility-aware `kb.audit`, and a + `gittensor` starter template (`vouch init --template gittensor`). + +## fixes +- graph-ref integrity: `put_claim` / `update_claim` / `import_check` now reject + claims with dangling `entities` / `supersedes` / `superseded_by` / + `contradicts` refs; `supersede` / `contradict` pre-validate before the first + write (`#196`). +- `discover_root()` honours `VOUCH_KB_PATH=/abs/.vouch` instead of always walking + up from cwd. +- `vouch serve` fails fast with a `vouch init` hint when no KB is present + (`#95`). +- vault-edit flow: `approve()` updates an existing page instead of erroring; + ghost-page, duplicate-proposal, and claim-stub edge cases fixed (`#219`). +- `sync_apply` TOCTOU window closed; `parse_since` raises a clean `MetricsError` + instead of an `OverflowError` traceback. + +**Full Changelog:** https://github.com/vouchdev/vouch/blob/main/CHANGELOG.md · +compare https://github.com/vouchdev/vouch/compare/v0.1.0...v1.0.0 diff --git a/docs/review-ui.md b/docs/review-ui.md new file mode 100644 index 00000000..fd75f211 --- /dev/null +++ b/docs/review-ui.md @@ -0,0 +1,105 @@ +# `vouch review-ui` — browser-based review console + +The review gate is vouch's load-bearing primitive, but the terminal +(`vouch pending`, `vouch approve `) only scales to a solo reviewer and a +small queue. `vouch review-ui` adds a browser viewport over the **same** +review surface — every approve/reject/contradict goes through the identical +`vouch.proposals` / `vouch.lifecycle` code path as the CLI, so the audit log is +the same regardless of which surface you used. The CLI is untouched. + +Zero new on-disk schema. Zero new `kb.*` RPC methods. The web layer reads and +mutates through the existing `KBStore`. + +## Run it + +```bash +vouch review-ui # 127.0.0.1:7780, opens browser +vouch review-ui --bind 127.0.0.1:8000 +vouch review-ui --no-open-browser # ssh / headless friendly + +# team mode — a non-loopback bind REQUIRES a Bearer token: +vouch review-ui --bind 0.0.0.0:7780 --auth generate # mints + prints a token +VOUCH_REVIEW_TOKEN=… vouch review-ui --bind 0.0.0.0:7780 --auth env +vouch review-ui --bind 0.0.0.0:7780 --auth my-shared-secret --reviewer alice +``` + +A non-loopback bind without `--auth` is refused outright — we won't expose an +unauthenticated approve surface on the network. + +## Authentication + +When `--auth` is set, every route except `/healthz` and `/static` requires the +token. Credentials are accepted in three places, in priority order: + +1. **`Authorization: Bearer `** header — for the CLI, scripts, and API + callers. +2. **HttpOnly cookie** (`vouch_review_token`) — the steady-state browser path. +3. **`?token=` query string** — a *one-time bootstrap* only. On a `GET`, a + valid query token is moved into an HttpOnly, `SameSite=Strict` cookie and + the request is `303`-redirected to the same path with `?token=` stripped, + so the bare token never lingers in a bookmarkable URL or in access logs. + +The token is never exposed to JavaScript (the cookie is HttpOnly), so an XSS +can't read it. Token comparisons are constant-time (`secrets.compare_digest`) +to avoid a timing oracle. `secure` is not set on the cookie by default so the +localhost-first (plain-http) flow works; terminate TLS at a proxy and have the +proxy mark the cookie `Secure` for an internet-facing deployment. + +## Install + +The web stack lives behind an optional extra so the base install stays light: + +```bash +pip install 'vouch-kb[web]' +``` + +All HTML/CSS/JS ships **inside the wheel** — no `npm install`, no build step, +no CDN. + +## Views + +| Route | What it shows | +|-------|---------------| +| `/` | the pending queue, server-side paginated | +| `/claim/` | one proposal's full payload + approve/reject | +| `/session/` | every proposal from one agent run, grouped, with status | +| `/sources/` | reverse index: which durable claims cite this source | +| `/audit` | the review-decision timeline | +| `/api/pending?page=N` | machine-readable paginated queue (`{count,page,pages,items}`) | +| `/healthz` | liveness + pending count + connected client count (always open) | +| `/ws` | realtime channel (see below) | + +## Realtime sync + +A single WebSocket channel per KB (`/ws`) keeps two reviewers in sync: when a +mutation lands, the handling route broadcasts a small `{"type":"refresh"}` +frame and every connected browser re-pulls the affected view within a second. +The frame is a *signal*, not data — the client re-fetches through the same +paginated routes, so there's exactly one rendering path. With `--auth` on, the +socket authenticates on the same-origin HttpOnly cookie the browser sends with +the handshake (a `?token=` query param is also accepted for non-browser +clients like the CLI). + +## Progressive enhancement + +Every action is a plain `
`, so the gate works with +**JavaScript disabled** — you can read claims and approve/reject without it. +The WebSocket live-refresh and the keyboard shortcuts (`j`/`k` to move, `a` +to approve, `r` to focus the reject reason, `?` for help) are an additive +layer on top. + +## Performance + +The queue is paginated at the storage layer: only the requested page of +proposal files is parsed, not the whole queue. A 500-item queue's first page +renders in well under the 200 ms budget (~30 ms locally) because the other 450 +files are never deserialised for that request. + +## Out of scope + +- Hosted "vouch cloud" — this is local/self-hosted only. +- Free-form claim editing in the browser; the gate is approve / reject / + contradict. +- Deleting durable claims from the UI — that stays CLI-only so a misclick + can't blow away history. +- Auth beyond Bearer (OAuth/SSO can layer on later). diff --git a/docs/superpowers/plans/2026-05-27-claude-code-adapter.md b/docs/superpowers/plans/2026-05-27-claude-code-adapter.md new file mode 100644 index 00000000..51d0d57f --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-claude-code-adapter.md @@ -0,0 +1,832 @@ +# Claude Code adapter — 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:** Ship a complete Claude Code adoption story under `adapters/claude-code/` (four composable tiers: MCP config, CLAUDE.md, slash commands, settings/hooks) plus a `vouch install-mcp claude-code` CLI helper that writes the missing pieces into a target project, idempotently. + +**Architecture:** Files-only adapter (no new runtime code in vouch core) + one CLI writer command. The four tiers stack: T1 alone equals today's behavior; T4 is the fully-integrated review-gated workflow with hooks and read-only auto-allow. The writer command resolves a project root and writes only files that don't already exist. + +**Tech Stack:** Python 3.11+, Click, pytest, ruff, mypy. Markdown for templates/slash commands. JSON for `.mcp.json` and `settings.json`. + +--- + +## Scope Check +Single subsystem — the Claude Code adapter and its installer. One plan, one PR. + +## File Structure + +Files created or modified: + +``` +adapters/claude-code/ + README.md ← MODIFY: tiered adoption guide, vouch-kb install + CLAUDE.md.snippet ← MODIFY (light edits): keep, normalize header + .mcp.json ← CREATE: T1 template + .claude/ + commands/ + vouch-recall.md ← CREATE: T3 + vouch-status.md ← CREATE: T3 + vouch-resolve-issue.md ← CREATE: T3 + vouch-propose-from-pr.md ← CREATE: T3 + settings.json ← CREATE: T4 hooks + read-only auto-allow + +src/vouch/install_adapter.py ← CREATE: pure file-writer (no Click) +src/vouch/cli.py ← MODIFY: add `install-mcp` group + claude-code cmd +tests/test_install_adapter.py ← CREATE: TDD for the writer +CHANGELOG.md ← MODIFY: [Unreleased] Added entry +``` + +Adapter files live under `adapters/claude-code/` and are the *source of truth* templates committed to the vouch repo. The writer command copies them into a user's project under matching paths. + +--- + +## Task 0: Branch setup + +**Files:** +- Modify: none + +- [ ] **Step 1: Stash the pre-existing storage.py edit** (it predates this work) + +```bash +git stash push -m "pre-existing WIP storage.py" src/vouch/storage.py 2>/dev/null || true +``` + +- [ ] **Step 2: Branch off latest main** + +```bash +git fetch origin main +git switch -c feat/claude-code-adapter origin/main +git status --porcelain | grep -v '^??' || echo "(clean)" +``` + +Expected: working tree clean except `?? .claude/` and other untracked workdir files. + +--- + +## Task 1: T1 — `.mcp.json` template + +**Files:** +- Create: `adapters/claude-code/.mcp.json` + +- [ ] **Step 1: Write the file** verbatim + +```json +{ + "mcpServers": { + "vouch": { + "command": "vouch", + "args": ["serve"], + "env": { + "PYTHONUTF8": "1", + "VOUCH_AGENT": "claude-code" + } + } + } +} +``` + +- [ ] **Step 2: Validate it parses as JSON** + +```bash +python3 -c "import json; json.load(open('adapters/claude-code/.mcp.json'))" +``` + +Expected: no output (success). + +- [ ] **Step 3: Commit** + +```bash +git add adapters/claude-code/.mcp.json +git commit -m "feat(adapter/claude-code): T1 — .mcp.json template" +``` + +--- + +## Task 2: T2 — normalize `CLAUDE.md.snippet` + +**Files:** +- Modify: `adapters/claude-code/CLAUDE.md.snippet` + +The existing snippet is good content. Only change: rename intent — it's the *template* you append to an existing CLAUDE.md, not a standalone file. Add a one-line `` fence so the installer can detect prior installs and avoid duplicating. + +- [ ] **Step 1: Read the current snippet** to confirm content + +```bash +wc -l adapters/claude-code/CLAUDE.md.snippet +``` + +- [ ] **Step 2: Prepend the fence + appendix-style header** + +Open `adapters/claude-code/CLAUDE.md.snippet` and replace the file with: + +```markdown + +## 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. + +### Lifecycle hygiene + +When you find a claim that's wrong or out-of-date: + +- If you can replace it with a corrected version, use `kb_supersede` rather + than proposing a contradicting claim. +- If two existing claims conflict, mark them with `kb_contradict` so the + human can choose. +- Re-cite a claim you used recently with `kb_confirm` — it bumps + `last_confirmed_at` so lint doesn't flag it as stale. + +### Identity + +You are recorded as `proposed_by: claude-code` in the audit log. Everything +you propose is visible to whoever runs `vouch pending`. + +``` + +- [ ] **Step 3: Verify fence + 9-section structure** + +```bash +grep -c "" adapters/claude-code/CLAUDE.md.snippet +grep -c "" adapters/claude-code/CLAUDE.md.snippet +``` + +Expected: `1` and `1`. + +- [ ] **Step 4: Commit** + +```bash +git add adapters/claude-code/CLAUDE.md.snippet +git commit -m "feat(adapter/claude-code): T2 — fence CLAUDE.md snippet for idempotent install" +``` + +--- + +## Task 3: T3 — `vouch-recall` slash command + +**Files:** +- Create: `adapters/claude-code/.claude/commands/vouch-recall.md` + +- [ ] **Step 1: Create the directory and file** + +```bash +mkdir -p adapters/claude-code/.claude/commands +``` + +- [ ] **Step 2: Write `vouch-recall.md`** verbatim + +```markdown +--- +description: Recall cited knowledge from the vouch KB about the given topic before answering. +--- + +Before answering, fetch a context pack from vouch: + +1. Call `mcp__vouch__kb_context` with `query: "$ARGUMENTS"`, `limit: 8`, + `require_citations: true`. +2. Read each returned item; quote the cited source id on any claim you reuse. +3. If the pack is empty or quality.ok is false, say so explicitly before + answering — don't fabricate citations. + +Then continue with the user's question, grounded in what the KB returned. +``` + +- [ ] **Step 3: Commit** + +```bash +git add adapters/claude-code/.claude/commands/vouch-recall.md +git commit -m "feat(adapter/claude-code): T3 — /vouch-recall command" +``` + +--- + +## Task 4: T3 — `vouch-status` slash command + +**Files:** +- Create: `adapters/claude-code/.claude/commands/vouch-status.md` + +- [ ] **Step 1: Write the file** + +```markdown +--- +description: Show vouch KB health — counts, pending proposals, audit/index state. +--- + +Run this single shell command and show me its raw output: + +```bash +vouch status +``` + +Then call `mcp__vouch__kb_list_pending` and summarise the queue in one line +(how many pending, who proposed each). +``` + +- [ ] **Step 2: Commit** + +```bash +git add adapters/claude-code/.claude/commands/vouch-status.md +git commit -m "feat(adapter/claude-code): T3 — /vouch-status command" +``` + +--- + +## Task 5: T3 — `vouch-resolve-issue` slash command + +**Files:** +- Create: `adapters/claude-code/.claude/commands/vouch-resolve-issue.md` + +- [ ] **Step 1: Write the file** + +```markdown +--- +description: Resolve a GitHub issue end-to-end with a vouch session bracketing the work. +--- + +You are resolving the issue at $ARGUMENTS. + +Run this loop: + +1. `gh issue view $ARGUMENTS` — read the issue. +2. `mcp__vouch__kb_context` with the issue title — surface prior decisions. +3. `mcp__vouch__kb_session_start` with `task: " (#)"` and + note the returned `session_id`. +4. Do the work (read code, propose fix). Each meaningful finding goes in as + `mcp__vouch__kb_propose_claim` citing the source that justifies it. +5. `mcp__vouch__kb_session_end` with the session id. +6. Tell the user the session id and that `vouch crystallize ` + will approve the proposed claims after they review with `vouch pending`. + +Do not call `kb_approve`; the human reviews and approves. +``` + +- [ ] **Step 2: Commit** + +```bash +git add adapters/claude-code/.claude/commands/vouch-resolve-issue.md +git commit -m "feat(adapter/claude-code): T3 — /vouch-resolve-issue command" +``` + +--- + +## Task 6: T3 — `vouch-propose-from-pr` slash command + +**Files:** +- Create: `adapters/claude-code/.claude/commands/vouch-propose-from-pr.md` + +- [ ] **Step 1: Write the file** + +```markdown +--- +description: Capture a merged PR's decision as a proposed, cited vouch claim. +--- + +You are capturing the durable decision from the PR at $ARGUMENTS. + +1. `gh pr view $ARGUMENTS --json title,body,mergedAt,mergeCommit` — read it. + If `mergedAt` is null, stop and tell the user the PR isn't merged yet. +2. `mcp__vouch__kb_register_source` with the PR URL as `locator` and the + PR title as `title`. Note the returned source id. +3. Draft one sentence that captures the decision the PR establishes — the + *why* future agents need to remember, not a summary of the diff. +4. `mcp__vouch__kb_propose_claim` with that sentence, citing the source id + from step 2. Type `decision`. +5. Tell the user the proposal id; they review with `vouch show ` and + approve with `vouch approve `. + +Never approve your own proposal. The review gate is the point. +``` + +- [ ] **Step 2: Commit** + +```bash +git add adapters/claude-code/.claude/commands/vouch-propose-from-pr.md +git commit -m "feat(adapter/claude-code): T3 — /vouch-propose-from-pr command" +``` + +--- + +## Task 7: T4 — `settings.json` (hooks + auto-allow) + +**Files:** +- Create: `adapters/claude-code/.claude/settings.json` + +- [ ] **Step 1: Write the file** + +```json +{ + "permissions": { + "alwaysAllow": [ + "mcp__vouch__kb_status", + "mcp__vouch__kb_search", + "mcp__vouch__kb_context", + "mcp__vouch__kb_read_claim", + "mcp__vouch__kb_read_page", + "mcp__vouch__kb_read_entity", + "mcp__vouch__kb_read_relation", + "mcp__vouch__kb_list_claims", + "mcp__vouch__kb_list_pages", + "mcp__vouch__kb_list_entities", + "mcp__vouch__kb_list_relations", + "mcp__vouch__kb_list_sources", + "mcp__vouch__kb_list_pending", + "mcp__vouch__kb_capabilities" + ] + }, + "hooks": { + "SessionStart": [ + { + "command": "vouch status 2>/dev/null || true", + "comment": "Show KB counts + pending proposals at the start of every session." + } + ] + } +} +``` + +- [ ] **Step 2: Validate JSON parses** + +```bash +python3 -c "import json; json.load(open('adapters/claude-code/.claude/settings.json'))" +``` + +Expected: no output. + +- [ ] **Step 3: Commit** + +```bash +git add adapters/claude-code/.claude/settings.json +git commit -m "feat(adapter/claude-code): T4 — settings.json (SessionStart hook + read-only auto-allow)" +``` + +--- + +## Task 8: Adapter README — tiered adoption guide + +**Files:** +- Modify: `adapters/claude-code/README.md` + +- [ ] **Step 1: Replace the file** with the tiered guide + +```markdown +# Claude Code adapter + +Wires [vouch][v] (an MCP server) into [Claude Code][cc] in four composable +tiers. Stop at any tier — each one works on its own. + +[v]: https://github.com/vouchdev/vouch +[cc]: https://claude.com/claude-code + +## Prerequisite + +```bash +pipx install vouch-kb # the command is `vouch` +vouch init # create .vouch/ in your project +``` + +## The four tiers + +| Tier | File | What it does | +|---|---|---| +| T1 | `.mcp.json` | Registers the `vouch` MCP server so the agent has the `kb_*` tools. | +| T2 | `CLAUDE.md` (append the snippet) | Teaches the agent the review-gate workflow. | +| T3 | `.claude/commands/vouch-*.md` | Four slash commands: `/vouch-recall`, `/vouch-status`, `/vouch-resolve-issue`, `/vouch-propose-from-pr`. | +| T4 | `.claude/settings.json` | `SessionStart` hook prints `vouch status`; reads/`list_*` are auto-allowed (writes still prompt). | + +## One-shot install + +```bash +vouch install-mcp claude-code # writes T1..T4 into the current project +vouch install-mcp claude-code --tier T2 # stop at T2 (skip slash commands + settings) +``` + +The command is idempotent — files that already exist are left alone (verbose +mode lists them). + +## Manual install (if you prefer) + +1. Copy `adapters/claude-code/.mcp.json` to your project root. +2. Append `adapters/claude-code/CLAUDE.md.snippet` to your project's + `CLAUDE.md` (or `AGENTS.md`). The snippet is fenced with + `` / `` so it's safe to re-append. +3. Copy `adapters/claude-code/.claude/commands/*.md` to + `.claude/commands/` in your project. +4. Merge `adapters/claude-code/.claude/settings.json` into your project's + `.claude/settings.json` (or commit it as-is if you have none). + +## Verify + +```bash +vouch status # KB present? +claude --debug-mcp 2>&1 | grep vouch # MCP server visible to Claude Code? +``` + +In a fresh Claude session, ask "what knowledge-base tools do you have?" — +it should enumerate `kb_search`, `kb_propose_claim`, etc. + +## Notes + +- `VOUCH_AGENT=claude-code` is the default in the bundled `.mcp.json`; + change it if you run multiple Claude Code seats against the same KB. +- The auto-allow rules in T4 only cover read-only `kb_*` methods — writes + (`kb_approve`, `kb_reject`, `kb_crystallize`, `kb_propose_*`) still + prompt, protecting the review gate. +- For hosts that launch from a default cwd (e.g. Claude Desktop), set + `VOUCH_KB_PATH=/abs/path/.vouch` in the `env` block of `.mcp.json`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add adapters/claude-code/README.md +git commit -m "docs(adapter/claude-code): tiered adoption guide + install-mcp reference" +``` + +--- + +## Task 9: Writer module — TDD (test-first) + +**Files:** +- Create: `tests/test_install_adapter.py` + +- [ ] **Step 1: Write the failing tests** + +```python +"""Tests for the Claude Code adapter installer.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from vouch.install_adapter import ( + AdapterError, + available_adapters, + install, +) + +ADAPTER_ROOT = Path(__file__).resolve().parent.parent / "adapters" / "claude-code" + + +def test_available_adapters_lists_claude_code() -> None: + assert "claude-code" in available_adapters() + + +def test_install_t1_writes_only_mcp_json(tmp_path: Path) -> None: + result = install("claude-code", target=tmp_path, tier="T1") + assert (tmp_path / ".mcp.json").is_file() + assert not (tmp_path / "CLAUDE.md").exists() + assert not (tmp_path / ".claude" / "commands").exists() + assert not (tmp_path / ".claude" / "settings.json").exists() + body = json.loads((tmp_path / ".mcp.json").read_text()) + assert body["mcpServers"]["vouch"]["command"] == "vouch" + assert sorted(result.written) == [".mcp.json"] + + +def test_install_t4_writes_all_tiers(tmp_path: Path) -> None: + result = install("claude-code", target=tmp_path, tier="T4") + assert (tmp_path / ".mcp.json").is_file() + assert (tmp_path / "CLAUDE.md").is_file() + cmds = tmp_path / ".claude" / "commands" + assert (cmds / "vouch-recall.md").is_file() + assert (cmds / "vouch-status.md").is_file() + assert (cmds / "vouch-resolve-issue.md").is_file() + assert (cmds / "vouch-propose-from-pr.md").is_file() + assert (tmp_path / ".claude" / "settings.json").is_file() + assert len(result.written) == 7 # mcp + claude.md + 4 commands + settings + + +def test_install_is_idempotent(tmp_path: Path) -> None: + install("claude-code", target=tmp_path, tier="T4") + second = install("claude-code", target=tmp_path, tier="T4") + assert second.written == [] + assert sorted(second.skipped) == sorted([ + ".mcp.json", + "CLAUDE.md", + ".claude/commands/vouch-recall.md", + ".claude/commands/vouch-status.md", + ".claude/commands/vouch-resolve-issue.md", + ".claude/commands/vouch-propose-from-pr.md", + ".claude/settings.json", + ]) + + +def test_install_claude_md_appends_when_existing(tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("# My project\n\nExisting content.\n") + result = install("claude-code", target=tmp_path, tier="T2") + final = (tmp_path / "CLAUDE.md").read_text() + assert "Existing content." in final + assert "" in final + assert "" in final + assert "CLAUDE.md" in result.appended + + +def test_install_claude_md_skips_when_already_fenced(tmp_path: Path) -> None: + snippet = (ADAPTER_ROOT / "CLAUDE.md.snippet").read_text() + (tmp_path / "CLAUDE.md").write_text("# Existing\n\n" + snippet) + result = install("claude-code", target=tmp_path, tier="T2") + assert "CLAUDE.md" in result.skipped + assert "CLAUDE.md" not in result.appended + + +def test_install_unknown_adapter_raises() -> None: + with pytest.raises(AdapterError, match="unknown adapter"): + install("bogus", target=Path("/tmp"), tier="T1") + + +def test_install_unknown_tier_raises(tmp_path: Path) -> None: + with pytest.raises(AdapterError, match="unknown tier"): + install("claude-code", target=tmp_path, tier="T9") +``` + +- [ ] **Step 2: Run to confirm RED** + +```bash +.venv/bin/python -m pytest tests/test_install_adapter.py -q +``` + +Expected: collection error — `ModuleNotFoundError: No module named 'vouch.install_adapter'`. + +--- + +## Task 10: Writer module — GREEN + +**Files:** +- Create: `src/vouch/install_adapter.py` + +- [ ] **Step 1: Write the minimal implementation** + +```python +"""Idempotently install a host adapter (e.g. Claude Code) into a target project. + +The adapter templates live under `adapters//` in the vouch repo. The +installer copies them into `target` paths, skipping files that already exist +verbatim and appending fenced blocks into existing files (CLAUDE.md). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +ADAPTERS_DIR = Path(__file__).resolve().parent.parent.parent / "adapters" + +_TIER_FILES: dict[str, list[tuple[str, str]]] = { + # (adapter-relative source, target-relative dest) + "T1": [(".mcp.json", ".mcp.json")], + "T2": [("CLAUDE.md.snippet", "CLAUDE.md")], + "T3": [ + (".claude/commands/vouch-recall.md", ".claude/commands/vouch-recall.md"), + (".claude/commands/vouch-status.md", ".claude/commands/vouch-status.md"), + (".claude/commands/vouch-resolve-issue.md", ".claude/commands/vouch-resolve-issue.md"), + (".claude/commands/vouch-propose-from-pr.md", ".claude/commands/vouch-propose-from-pr.md"), + ], + "T4": [(".claude/settings.json", ".claude/settings.json")], +} + +_TIER_ORDER = ("T1", "T2", "T3", "T4") +_FENCE_BEGIN = "" + + +class AdapterError(RuntimeError): + pass + + +@dataclass +class InstallResult: + written: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + appended: list[str] = field(default_factory=list) + + +def available_adapters() -> list[str]: + if not ADAPTERS_DIR.is_dir(): + return [] + return sorted( + p.name for p in ADAPTERS_DIR.iterdir() + if p.is_dir() and (p / ".mcp.json").is_file() + ) + + +def install(adapter: str, *, target: Path, tier: str = "T4") -> InstallResult: + if adapter not in available_adapters(): + raise AdapterError( + f"unknown adapter {adapter!r} (available: {', '.join(available_adapters())})" + ) + if tier not in _TIER_ORDER: + raise AdapterError( + f"unknown tier {tier!r} (available: {', '.join(_TIER_ORDER)})" + ) + + src_root = ADAPTERS_DIR / adapter + target = target.resolve() + target.mkdir(parents=True, exist_ok=True) + + result = InstallResult() + selected = _TIER_ORDER[: _TIER_ORDER.index(tier) + 1] + for t in selected: + for src_rel, dst_rel in _TIER_FILES[t]: + src = src_root / src_rel + dst = target / dst_rel + if dst_rel == "CLAUDE.md": + _install_claude_md(src, dst, result) + else: + _install_plain(src, dst, dst_rel, result) + return result + + +def _install_plain(src: Path, dst: Path, dst_rel: str, result: InstallResult) -> None: + if dst.exists(): + result.skipped.append(dst_rel) + return + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + result.written.append(dst_rel) + + +def _install_claude_md(src: Path, dst: Path, result: InstallResult) -> None: + snippet = src.read_text(encoding="utf-8") + if not dst.exists(): + dst.write_text(snippet, encoding="utf-8") + result.written.append("CLAUDE.md") + return + existing = dst.read_text(encoding="utf-8") + if _FENCE_BEGIN in existing: + result.skipped.append("CLAUDE.md") + return + sep = "" if existing.endswith("\n") else "\n" + dst.write_text(existing + sep + "\n" + snippet, encoding="utf-8") + result.appended.append("CLAUDE.md") +``` + +- [ ] **Step 2: Run tests to confirm GREEN** + +```bash +.venv/bin/python -m pytest tests/test_install_adapter.py -q +``` + +Expected: all 7 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/vouch/install_adapter.py tests/test_install_adapter.py +git commit -m "feat(install): adapter installer with tier selection + idempotent CLAUDE.md fence" +``` + +--- + +## Task 11: CLI wiring — `vouch install-mcp claude-code` + +**Files:** +- Modify: `src/vouch/cli.py` + +- [ ] **Step 1: Add the import** near the other onboarding-style imports (top of cli.py) + +Edit: in the import section, add a line: + +```python +from .install_adapter import AdapterError, available_adapters, install as install_adapter +``` + +- [ ] **Step 2: Add the `install-mcp` command group** at the end of cli.py, before `if __name__ == "__main__":` + +```python +# --- install-mcp ---------------------------------------------------------- + + +@cli.group(name="install-mcp") +def install_mcp_group() -> None: + """Install vouch into a Claude Code-style project (idempotently).""" + + +@install_mcp_group.command(name="claude-code") +@click.option("--path", default=".", show_default=True, + type=click.Path(file_okay=False), + help="Target project root.") +@click.option("--tier", default="T4", show_default=True, + type=click.Choice(["T1", "T2", "T3", "T4"]), + help="Stop at the given tier (T1=mcp only; T4=full integration).") +def install_mcp_claude_code(path: str, tier: str) -> None: + """Drop .mcp.json + CLAUDE.md + slash commands + settings into PATH.""" + target = Path(path).resolve() + try: + result = install_adapter("claude-code", target=target, tier=tier) + except 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.skipped: + click.echo(f" · {f} (already present)") + click.echo( + f"Done — {len(result.written)} written, " + f"{len(result.appended)} appended, {len(result.skipped)} skipped." + ) +``` + +- [ ] **Step 3: Run a smoke test against a tmp dir** + +```bash +tmp=$(mktemp -d) && .venv/bin/vouch install-mcp claude-code --path "$tmp" --tier T1 +ls -1 "$tmp" +rm -rf "$tmp" +``` + +Expected: prints `+ .mcp.json` and a `Done — 1 written, ...` summary; tmp dir contains `.mcp.json`. + +- [ ] **Step 4: Run the full suite + mypy + ruff** + +```bash +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings +.venv/bin/python -m mypy src +.venv/bin/python -m ruff check src tests +``` + +Expected: all green; mypy "Success: no issues found"; ruff "All checks passed!". + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/cli.py +git commit -m "feat(cli): vouch install-mcp claude-code [--tier T1..T4]" +``` + +--- + +## Task 12: CHANGELOG + push + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add the `[Unreleased] → Added` entry** + +Edit `CHANGELOG.md`. Under `## [Unreleased]`, add (or extend) an `### Added` section: + +```markdown +### Added +- Claude Code adapter at `adapters/claude-code/` ships four composable tiers: `.mcp.json` (T1), a fenced `CLAUDE.md` snippet (T2), four slash commands `/vouch-recall`, `/vouch-status`, `/vouch-resolve-issue`, `/vouch-propose-from-pr` (T3), and `.claude/settings.json` with a `SessionStart` hook + read-only `kb_*` auto-allow (T4). New `vouch install-mcp claude-code [--tier T1..T4]` writes them into a project idempotently — existing files are left alone, and `CLAUDE.md` gets a fenced appended block so it's safe to re-run. +``` + +- [ ] **Step 2: Final verify** + +```bash +.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings +.venv/bin/python -m mypy src +.venv/bin/python -m ruff check src tests +``` + +Expected: all three green. + +- [ ] **Step 3: Commit + push** + +```bash +git add CHANGELOG.md +git commit -m "docs: CHANGELOG entry for Claude Code adapter + install-mcp" +GIT_SSH_COMMAND="ssh -i ~/.ssh/plind-junior -o IdentitiesOnly=yes" \ + git push -u origin feat/claude-code-adapter +``` + +- [ ] **Step 4: Restore the pre-existing storage.py edit on `release/0.1.0`** + +```bash +git switch release/0.1.0 +git stash pop +git status --porcelain src/vouch/storage.py +``` + +Expected: ` M src/vouch/storage.py` (the pre-existing edit is back). + +--- + +## Self-Review Checklist (run before handoff) + +1. **Spec coverage** — every tier (T1–T4) has its own task; the CLI front door has its own; idempotency is tested. +2. **Placeholder scan** — every code/template step contains the literal file contents; no "TODO / fill in details". +3. **Type consistency** — `install()`, `InstallResult`, `available_adapters()`, `AdapterError` names match across the tests, the implementation, and the CLI. +4. **Idempotency** — `_install_plain` checks `dst.exists()`; `_install_claude_md` checks for the fence; tests cover both. diff --git a/docs/superpowers/plans/2026-06-25-auto-pr.md b/docs/superpowers/plans/2026-06-25-auto-pr.md new file mode 100644 index 00000000..f75fb273 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-auto-pr.md @@ -0,0 +1,1058 @@ +# auto-pr 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:** ship `vouch auto-pr ` — a tool that opens N mergeable PRs against any github repo, learning the repo's contribution norms first and cross-verifying each diff with claude and codex. + +**Architecture:** one new module `src/vouch/auto_pr.py` whose entire subprocess boundary (`git`/`gh`/`claude`/`codex`) goes through an injectable `Runner`, so every stage is unit-testable against a `FakeRunner`. A thin `vouch auto-pr` click group in `cli.py` wires CLI flags to `run_auto_pr`. A `skills/auto-pr/SKILL.md` is the agent entry point. It never touches the KB layer. + +**Tech Stack:** python ≥3.11, click, stdlib `subprocess`/`json`/`re`/`pathlib`. claude code CLI (`claude -p`), codex CLI (`codex exec`), GitHub CLI (`gh`). + +## Global Constraints + +- python ≥ 3.11; `from __future__ import annotations` at top of every new module. +- ruff rules E,F,I,B,UP,SIM,RUF; line-length 100. Use `X | None`, lowercase generics. +- Full type annotations on every function (mypy `src` gate). +- No `Co-Authored-By` / AI-attribution trailer in any commit this tool *generates* or that we author. +- Conventional-commit titles; lowercase commit/PR bodies. +- auto_pr.py must not import `storage` / `proposals` / `lifecycle` / `audit` (KB isolation). +- Tests use a `FakeRunner` — no network, no real claude/codex/gh. + +--- + +### Task 1: effort mapping + data model + runner protocol + +**Files:** +- Create: `src/vouch/auto_pr.py` +- Test: `tests/test_auto_pr.py` + +**Interfaces:** +- Produces: `EFFORT_LEVELS`, `claude_flags(effort)->list[str]`, `codex_flags(effort)->list[str]`, `slugify(str)->str`, `parse_repo(str)->str`, `RunResult`, `Runner` (Protocol), `SubprocessRunner`, `WorkItem`, `ReviewVerdict`, `PRResult`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_auto_pr.py +from vouch import auto_pr as ap + + +def test_claude_flags_by_effort(): + assert ap.claude_flags("low") == ["--model", "claude-haiku-4-5"] + assert ap.claude_flags("max") == ["--model", "claude-opus-4-8"] + + +def test_codex_flags_by_effort(): + assert ap.codex_flags("medium") == ["-c", "model_reasoning_effort=medium"] + assert ap.codex_flags("max") == ["-c", "model_reasoning_effort=high"] + + +def test_flags_reject_bad_effort(): + import pytest + with pytest.raises(ValueError): + ap.claude_flags("turbo") + + +def test_slugify(): + assert ap.slugify("Fix the Thing!") == "fix-the-thing" + assert ap.slugify("") == "change" + + +def test_parse_repo_variants(): + assert ap.parse_repo("https://github.com/owner/name") == "owner/name" + assert ap.parse_repo("https://github.com/owner/name.git") == "owner/name" + assert ap.parse_repo("git@github.com:owner/name.git") == "owner/name" + assert ap.parse_repo("owner/name") == "owner/name" +``` + +- [ ] **Step 2: Run to verify failure** — `pytest tests/test_auto_pr.py -q` → FAIL (module missing). + +- [ ] **Step 3: Implement** + +```python +# src/vouch/auto_pr.py +"""vouch auto-pr: open N mergeable PRs against any github repo. + +a sibling tool to the KB — it never writes to storage/proposals/the audit +log. it clones (or forks) a target repo, learns its contribution norms +(from shipped guidance, else synthesized from merged PRs), sources work +items (open issues first, then agent-discovered improvements), and drives +claude/codex to fix each — alternating which engine fixes and which +reviews — opening a PR only when the repo's own test gate is green and the +reviewing engine signs off. +""" +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol + +EFFORT_LEVELS = ("low", "medium", "high", "max") + +_CLAUDE_MODEL = { + "low": "claude-haiku-4-5", + "medium": "claude-sonnet-4-6", + "high": "claude-opus-4-8", + "max": "claude-opus-4-8", +} +_CODEX_REASONING = { + "low": "low", "medium": "medium", "high": "high", "max": "high", +} + + +def claude_flags(effort: str) -> list[str]: + if effort not in _CLAUDE_MODEL: + raise ValueError(f"unknown effort level: {effort!r}") + return ["--model", _CLAUDE_MODEL[effort]] + + +def codex_flags(effort: str) -> list[str]: + if effort not in _CODEX_REASONING: + raise ValueError(f"unknown effort level: {effort!r}") + return ["-c", f"model_reasoning_effort={_CODEX_REASONING[effort]}"] + + +def slugify(title: str, maxlen: int = 48) -> str: + s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + return s[:maxlen].strip("-") or "change" + + +def parse_repo(url: str) -> str: + u = url.strip().removesuffix(".git") + m = re.search(r"github\.com[:/]+([^/]+)/([^/]+?)/?$", u) + if m: + return f"{m.group(1)}/{m.group(2)}" + if re.fullmatch(r"[^/\s]+/[^/\s]+", u): + return u + raise ValueError(f"cannot parse github repo from {url!r}") + + +@dataclass(frozen=True) +class RunResult: + code: int + stdout: str + stderr: str + + +class Runner(Protocol): + def run(self, argv: list[str], *, cwd: str | None = None, + stdin: str | None = None, timeout: int | None = None) -> RunResult: ... + + +class SubprocessRunner: + def run(self, argv: list[str], *, cwd: str | None = None, + stdin: str | None = None, timeout: int | None = None) -> RunResult: + proc = subprocess.run( # noqa: S603 + argv, cwd=cwd, input=stdin, capture_output=True, text=True, + timeout=timeout, + ) + return RunResult(proc.returncode, proc.stdout, proc.stderr) + + +@dataclass(frozen=True) +class WorkItem: + kind: str # "issue" | "discovered" + title: str + body: str + slug: str + number: int | None = None + url: str | None = None + + +@dataclass(frozen=True) +class ReviewVerdict: + approved: bool + notes: str + + +@dataclass +class PRResult: + item: WorkItem + status: str # "opened" | "skipped" + fixer: str + verifier: str + url: str | None = None + reason: str | None = None + rounds: int = 0 +``` + +- [ ] **Step 4: Run** — `pytest tests/test_auto_pr.py -q` → PASS. +- [ ] **Step 5: Commit** — `git add src/vouch/auto_pr.py tests/test_auto_pr.py && git commit -m "feat(auto-pr): effort mapping, data model, runner protocol"` + +--- + +### Task 2: engine adapter (fix/review command construction + verdict parsing) + +**Files:** +- Modify: `src/vouch/auto_pr.py` +- Test: `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: `Runner`, `RunResult`, `ReviewVerdict`, `claude_flags`, `codex_flags`. +- Produces: `engine_text(name, stdout)->str`, `parse_verdict(text)->ReviewVerdict`, `Engine` with `.fix(cwd, prompt)->RunResult` and `.review(cwd, diff, prompt)->ReviewVerdict`. + +- [ ] **Step 1: Write failing tests** + +```python +class FakeRunner: + """matches argv prefixes to canned RunResults; records calls.""" + def __init__(self, script=None): + self.script = list(script or []) # list of (match:list[str], RunResult) + self.calls: list[list[str]] = [] + + def run(self, argv, *, cwd=None, stdin=None, timeout=None): + self.calls.append(argv) + for match, result in self.script: + if argv[: len(match)] == match: + return result + return ap.RunResult(0, "", "") + + +def test_parse_verdict_approve(): + v = ap.parse_verdict("APPROVE looks good") + assert v.approved is True + + +def test_parse_verdict_request_changes(): + v = ap.parse_verdict("REQUEST_CHANGES: missing a test") + assert v.approved is False + assert "missing a test" in v.notes + + +def test_engine_text_claude_unwraps_json(): + assert ap.engine_text("claude", '{"result": "APPROVE"}') == "APPROVE" + assert ap.engine_text("codex", "APPROVE") == "APPROVE" + + +def test_engine_fix_builds_claude_argv(): + fr = FakeRunner() + eng = ap.Engine("claude", "high", fr) + eng.fix(cwd="/w", prompt="do it") + argv = fr.calls[0] + assert argv[:2] == ["claude", "-p"] + assert "--permission-mode" in argv and "acceptEdits" in argv + assert "claude-opus-4-8" in argv + + +def test_engine_fix_builds_codex_argv(): + fr = FakeRunner() + eng = ap.Engine("codex", "low", fr) + eng.fix(cwd="/w", prompt="do it") + argv = fr.calls[0] + assert argv[:2] == ["codex", "exec"] + assert "--full-auto" in argv + assert "model_reasoning_effort=low" in " ".join(argv) + + +def test_engine_review_returns_verdict(): + fr = FakeRunner([(["claude"], ap.RunResult(0, '{"result": "APPROVE ok"}', ""))]) + eng = ap.Engine("claude", "high", fr) + v = eng.review(cwd="/w", diff="diff", prompt="review this") + assert v.approved is True +``` + +- [ ] **Step 2: Run** → FAIL (`parse_verdict`/`Engine` missing). +- [ ] **Step 3: Implement (append to auto_pr.py)** + +```python +def engine_text(name: str, stdout: str) -> str: + if name == "claude": + try: + obj = json.loads(stdout) + except json.JSONDecodeError: + return stdout + if isinstance(obj, dict) and "result" in obj: + return str(obj["result"]) + return stdout + + +def parse_verdict(text: str) -> ReviewVerdict: + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + upper = line.upper() + if upper.startswith("APPROVE"): + return ReviewVerdict(True, line) + if upper.startswith("REQUEST_CHANGES"): + notes = line.split(":", 1)[1].strip() if ":" in line else line + return ReviewVerdict(False, notes or "changes requested") + return ReviewVerdict(False, text.strip()) + return ReviewVerdict(False, "empty review output") + + +@dataclass +class Engine: + name: str # "claude" | "codex" + effort: str + runner: Runner + timeout: int | None = None + + def fix(self, *, cwd: str, prompt: str) -> RunResult: + if self.name == "claude": + argv = ["claude", "-p", prompt, "--permission-mode", "acceptEdits", + "--output-format", "json", *claude_flags(self.effort)] + else: + argv = ["codex", "exec", prompt, "--full-auto", "--cd", cwd, + *codex_flags(self.effort)] + return self.runner.run(argv, cwd=cwd, timeout=self.timeout) + + def review(self, *, cwd: str, diff: str, prompt: str) -> ReviewVerdict: + full = ( + f"{prompt}\n\nrespond with `APPROVE` or `REQUEST_CHANGES: ` " + f"as the very first line.\n\n--- DIFF ---\n{diff}\n" + ) + if self.name == "claude": + argv = ["claude", "-p", full, "--permission-mode", "plan", + "--output-format", "json", *claude_flags(self.effort)] + else: + argv = ["codex", "exec", full, "--sandbox", "read-only", "--cd", cwd, + *codex_flags(self.effort)] + res = self.runner.run(argv, cwd=cwd, timeout=self.timeout) + return parse_verdict(engine_text(self.name, res.stdout)) +``` + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): claude/codex engine adapter + verdict parsing"` + +--- + +### Task 3: git/gh helpers + workspace resolution + +**Files:** Modify `src/vouch/auto_pr.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: `Runner`, `RunResult`, `parse_repo`, `slugify`. +- Produces: `RepoCtx` (repo, url, clone_dir:Path, default_branch, fork_owner), `gh_json(runner, argv)->object`, `resolve_workspace(url, workspace, runner, *, fork_owner=None, has_push=False)->RepoCtx`. + +- [ ] **Step 1: Failing tests** + +```python +def test_resolve_existing_clone_reads_default_branch(tmp_path): + (tmp_path / ".git").mkdir() + fr = FakeRunner([ + (["git", "-C", str(tmp_path), "symbolic-ref"], + ap.RunResult(0, "origin/main\n", "")), + ]) + ctx = ap.resolve_workspace("owner/name", str(tmp_path), fr) + assert ctx.repo == "owner/name" + assert ctx.default_branch == "main" + # an existing clone is NOT re-forked + assert not any(c[:3] == ["gh", "repo", "fork"] for c in fr.calls) + + +def test_resolve_missing_clone_forks(tmp_path): + target = tmp_path / "wkdir" + fr = FakeRunner([ + (["git", "-C", str(target), "symbolic-ref"], + ap.RunResult(0, "origin/main\n", "")), + ]) + ap.resolve_workspace("https://github.com/owner/name", str(target), fr, + fork_owner="me") + assert any(c[:3] == ["gh", "repo", "fork"] for c in fr.calls) +``` + +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** + +```python +@dataclass +class RepoCtx: + repo: str + url: str + clone_dir: Path + default_branch: str + fork_owner: str | None = None + + +def gh_json(runner: Runner, argv: list[str]) -> object: + res = runner.run(argv) + if res.code != 0: + raise RuntimeError(f"gh failed: {' '.join(argv)}\n{res.stderr}") + return json.loads(res.stdout or "null") + + +def resolve_workspace(url: str, workspace: str, runner: Runner, *, + fork_owner: str | None = None, + has_push: bool = False) -> RepoCtx: + repo = parse_repo(url) + clone_dir = Path(workspace) + if not (clone_dir / ".git").exists(): + clone_dir.parent.mkdir(parents=True, exist_ok=True) + if has_push: + res = runner.run(["gh", "repo", "clone", repo, "--", str(clone_dir)]) + else: + res = runner.run(["gh", "repo", "fork", repo, "--clone", + "--default-branch-only", "--", str(clone_dir)]) + if res.code != 0: + raise RuntimeError(f"could not obtain workspace: {res.stderr}") + res = runner.run(["git", "-C", str(clone_dir), "symbolic-ref", "--short", + "refs/remotes/origin/HEAD"]) + default_branch = (res.stdout.strip().rsplit("/", 1)[-1] or "main") + return RepoCtx(repo, url, clone_dir, default_branch, fork_owner) +``` + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): workspace resolution (fork/clone + default branch)"` + +--- + +### Task 4: contribution-guidance detection + bootstrap + +**Files:** Modify `src/vouch/auto_pr.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: `RepoCtx`, `Engine`, `Runner`. +- Produces: `GUIDANCE_FILES` (tuple), `find_guidance(clone_dir)->list[Path]`, `detect_or_bootstrap_guidance(ctx, engine, runner)->str` (returns guidance text; writes a synthesized skill when none found). + +- [ ] **Step 1: Failing tests** + +```python +def test_find_guidance_picks_up_contributing(tmp_path): + (tmp_path / "CONTRIBUTING.md").write_text("be nice") + found = ap.find_guidance(tmp_path) + assert any(p.name == "CONTRIBUTING.md" for p in found) + + +def test_bootstrap_writes_skill_when_absent(tmp_path): + (tmp_path / ".git").mkdir() + ctx = ap.RepoCtx("o/n", "o/n", tmp_path, "main") + fr = FakeRunner([ + (["gh", "pr", "list"], ap.RunResult(0, "[]", "")), + (["claude"], ap.RunResult(0, '{"result": "## contributing\\nrun make check"}', "")), + ]) + eng = ap.Engine("claude", "high", fr) + text = ap.detect_or_bootstrap_guidance(ctx, eng, fr) + skill = tmp_path / ".claude" / "skills" / "auto-pr-contributing" / "SKILL.md" + assert skill.exists() + assert "contributing" in text.lower() + + +def test_bootstrap_skipped_when_present(tmp_path): + (tmp_path / ".git").mkdir() + (tmp_path / "CONTRIBUTING.md").write_text("house rules") + ctx = ap.RepoCtx("o/n", "o/n", tmp_path, "main") + fr = FakeRunner() + eng = ap.Engine("claude", "high", fr) + text = ap.detect_or_bootstrap_guidance(ctx, eng, fr) + assert "house rules" in text + assert not any(c[:2] == ["gh", "pr"] for c in fr.calls) # no merged-PR fetch +``` + +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** + +```python +GUIDANCE_FILES = ( + "CONTRIBUTING.md", "AGENTS.md", "CLAUDE.md", + ".github/PULL_REQUEST_TEMPLATE.md", +) + + +def find_guidance(clone_dir: Path) -> list[Path]: + found: list[Path] = [] + for rel in GUIDANCE_FILES: + p = clone_dir / rel + if p.exists(): + found.append(p) + skills_dir = clone_dir / ".claude" / "skills" + if skills_dir.exists(): + found.extend(sorted(skills_dir.glob("**/SKILL.md"))) + if (clone_dir / ".codex").exists(): + found.extend(sorted((clone_dir / ".codex").glob("**/*.md"))) + return found + + +_BOOTSTRAP_PROMPT = ( + "you are documenting how to contribute a mergeable PR to the github repo " + "{repo}. below are recent merged PRs (titles, bodies, and a few diffs). " + "synthesize a concise contribution guide: branch naming, commit/PR-title " + "conventions, how to run the test/build gate, PR-body format, and review " + "norms. output github-flavored markdown only.\n\n{prs}" +) + + +def detect_or_bootstrap_guidance(ctx: RepoCtx, engine: Engine, + runner: Runner) -> str: + found = find_guidance(ctx.clone_dir) + if found: + return "\n\n".join( + f"# {p.relative_to(ctx.clone_dir)}\n{p.read_text(errors='replace')}" + for p in found[:6] + ) + res = runner.run([ + "gh", "pr", "list", "--repo", ctx.repo, "--state", "merged", + "--limit", "30", "--json", "title,body,url", + ]) + prs = res.stdout or "[]" + prompt = _BOOTSTRAP_PROMPT.format(repo=ctx.repo, prs=prs) + out = engine.fix.__self__ if False else None # noqa: F841 (keep mypy happy; real call below) + review = engine.runner.run( + ["claude", "-p", prompt, "--output-format", "json", + *claude_flags(engine.effort)] + if engine.name == "claude" + else ["codex", "exec", prompt, "--sandbox", "read-only", + "--cd", str(ctx.clone_dir), *codex_flags(engine.effort)] + ) + guide = engine_text(engine.name, review.stdout) or "# contributing\n" + skill = (ctx.clone_dir / ".claude" / "skills" / "auto-pr-contributing" + / "SKILL.md") + skill.parent.mkdir(parents=True, exist_ok=True) + front = ("---\nname: auto-pr-contributing\ndescription: synthesized " + f"contribution guide for {ctx.repo}, derived from merged PRs.\n---\n\n") + skill.write_text(front + guide) + codex_mirror = ctx.clone_dir / ".codex" / "auto-pr-contributing.md" + codex_mirror.parent.mkdir(parents=True, exist_ok=True) + codex_mirror.write_text(guide) + return guide +``` + +(Implementer note: drop the dead `out = ...` line; it's only here to flag that +the synthesis call must go through `runner`, not a second Engine method. The +real call is the `runner.run([...])` below it.) + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): contribution-guidance detection + merged-PR bootstrap"` + +--- + +### Task 5: work-item sourcing (issues-first, discovery fill, dedup) + +**Files:** Modify `src/vouch/auto_pr.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: `RepoCtx`, `Engine`, `Runner`, `WorkItem`, `slugify`. +- Produces: `is_duplicate(ctx, topic, runner)->bool`, `open_issues(ctx, runner, *, labels=())->list[WorkItem]`, `discover_items(ctx, engine, n)->list[WorkItem]`, `source_work_items(ctx, count, runner, fixer_engine, *, labels=())->list[WorkItem]`. + +- [ ] **Step 1: Failing tests** + +```python +def test_open_issues_maps_to_workitems(): + ctx = ap.RepoCtx("o/n", "o/n", Path("/w"), "main") + issues = '[{"number": 7, "title": "Fix bug", "body": "b", "url": "u"}]' + fr = FakeRunner([(["gh", "issue", "list"], ap.RunResult(0, issues, ""))]) + items = ap.open_issues(ctx, fr) + assert items[0].kind == "issue" and items[0].number == 7 + assert items[0].slug == "fix-bug" + + +def test_is_duplicate_true_when_search_hits(): + ctx = ap.RepoCtx("o/n", "o/n", Path("/w"), "main") + fr = FakeRunner([(["gh", "pr", "list"], + ap.RunResult(0, '[{"title": "Fix bug", "url": "x"}]', ""))]) + assert ap.is_duplicate(ctx, "Fix bug", fr) is True + + +def test_source_fills_remainder_with_discovery(monkeypatch): + ctx = ap.RepoCtx("o/n", "o/n", Path("/w"), "main") + # one open issue, none duplicate; discovery supplies one more → count=2 + fr = FakeRunner([ + (["gh", "issue", "list"], + ap.RunResult(0, '[{"number":1,"title":"a","body":"","url":"u"}]', "")), + (["gh", "pr", "list"], ap.RunResult(0, "[]", "")), # dedup: no match + ]) + eng = ap.Engine("claude", "high", fr) + monkeypatch.setattr(ap, "discover_items", + lambda c, e, k: [ap.WorkItem("discovered", "b", "", "b")][:k]) + items = ap.source_work_items(ctx, 2, fr, eng) + assert len(items) == 2 + assert items[0].kind == "issue" and items[1].kind == "discovered" +``` + +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** + +```python +def is_duplicate(ctx: RepoCtx, topic: str, runner: Runner) -> bool: + res = runner.run([ + "gh", "pr", "list", "--repo", ctx.repo, "--state", "all", + "--search", topic, "--limit", "20", "--json", "title,url", + ]) + try: + rows = json.loads(res.stdout or "[]") + except json.JSONDecodeError: + return False + needle = topic.lower().strip() + for row in rows: + title = str(row.get("title", "")).lower() + if needle and (needle in title or title in needle): + return True + return False + + +def open_issues(ctx: RepoCtx, runner: Runner, *, + labels: tuple[str, ...] = ()) -> list[WorkItem]: + argv = ["gh", "issue", "list", "--repo", ctx.repo, "--state", "open", + "--search", "no:assignee sort:created-desc", "--limit", "50", + "--json", "number,title,body,url"] + for lab in labels: + argv += ["--label", lab] + res = runner.run(argv) + rows = json.loads(res.stdout or "[]") + items: list[WorkItem] = [] + for row in rows: + title = str(row.get("title", "")).strip() + items.append(WorkItem( + kind="issue", title=title, body=str(row.get("body", "")), + slug=slugify(title), number=row.get("number"), + url=row.get("url"), + )) + return items + + +_DISCOVER_PROMPT = ( + "find exactly ONE small, real, mergeable improvement in this repo " + "(a genuine bug, a missing test, a doc error — not a stylistic nitpick). " + "respond with a single line: `` then a blank line " + "then a one-paragraph rationale. do not edit any files." +) + + +def discover_items(ctx: RepoCtx, engine: Engine, n: int) -> list[WorkItem]: + items: list[WorkItem] = [] + for _ in range(n): + res = engine.runner.run( + ["claude", "-p", _DISCOVER_PROMPT, "--permission-mode", "plan", + "--output-format", "json", *claude_flags(engine.effort)] + if engine.name == "claude" + else ["codex", "exec", _DISCOVER_PROMPT, "--sandbox", "read-only", + "--cd", str(ctx.clone_dir), *codex_flags(engine.effort)] + ) + text = engine_text(engine.name, res.stdout).strip() + if not text: + continue + title, _, body = text.partition("\n") + title = title.strip().lstrip("#").strip() or "improvement" + items.append(WorkItem("discovered", title, body.strip(), slugify(title))) + return items + + +def source_work_items(ctx: RepoCtx, count: int, runner: Runner, + fixer_engine: Engine, *, + labels: tuple[str, ...] = ()) -> list[WorkItem]: + chosen: list[WorkItem] = [] + for it in open_issues(ctx, runner, labels=labels): + if len(chosen) >= count: + break + if not is_duplicate(ctx, it.title, runner): + chosen.append(it) + if len(chosen) < count: + for it in discover_items(ctx, fixer_engine, count - len(chosen)): + if not is_duplicate(ctx, it.title, runner): + chosen.append(it) + if len(chosen) >= count: + break + return chosen[:count] +``` + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): work-item sourcing (issues-first, discovery, dedup)"` + +--- + +### Task 6: local test-gate detection + runner + +**Files:** Modify `src/vouch/auto_pr.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: `Runner`, `RunResult`. +- Produces: `detect_gate(clone_dir)->list[str] | None`, `run_gate(clone_dir, runner)->tuple[bool, str]` (ok, log; ok=True when no gate detected, with a warning log). + +- [ ] **Step 1: Failing tests** + +```python +def test_detect_gate_make(tmp_path): + (tmp_path / "Makefile").write_text("check:\n\techo ok\n") + assert ap.detect_gate(tmp_path) == ["make", "check"] + + +def test_detect_gate_pytest(tmp_path): + (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") + assert ap.detect_gate(tmp_path) == ["python", "-m", "pytest", "-q"] + + +def test_detect_gate_none(tmp_path): + assert ap.detect_gate(tmp_path) is None + + +def test_run_gate_no_gate_is_ok(tmp_path): + ok, log = ap.run_gate(tmp_path, FakeRunner()) + assert ok is True and "no test gate" in log.lower() + + +def test_run_gate_red_blocks(tmp_path): + (tmp_path / "Makefile").write_text("check:\n\tfalse\n") + fr = FakeRunner([(["make", "check"], ap.RunResult(1, "", "boom"))]) + ok, log = ap.run_gate(tmp_path, fr) + assert ok is False and "boom" in log +``` + +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** + +```python +def detect_gate(clone_dir: Path) -> list[str] | None: + mk = clone_dir / "Makefile" + if mk.exists() and re.search(r"^check:", mk.read_text(errors="replace"), + re.MULTILINE): + return ["make", "check"] + if (clone_dir / "pyproject.toml").exists() or (clone_dir / "setup.cfg").exists(): + return ["python", "-m", "pytest", "-q"] + pkg = clone_dir / "package.json" + if pkg.exists() and '"test"' in pkg.read_text(errors="replace"): + return ["npm", "test", "--silent"] + if (clone_dir / "Cargo.toml").exists(): + return ["cargo", "test"] + if (clone_dir / "go.mod").exists(): + return ["go", "test", "./..."] + return None + + +def run_gate(clone_dir: Path, runner: Runner) -> tuple[bool, str]: + cmd = detect_gate(clone_dir) + if cmd is None: + return True, "no test gate detected; skipping (proceed with caution)" + res = runner.run(cmd, cwd=str(clone_dir), timeout=1800) + ok = res.code == 0 + log = (res.stdout + "\n" + res.stderr).strip()[-4000:] + return ok, log +``` + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): local test-gate detection + runner"` + +--- + +### Task 7: per-item processing (fix → gate → verify → revise → open) + +**Files:** Modify `src/vouch/auto_pr.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: everything above. +- Produces: `git_branch(ctx, slug, runner)`, `git_diff(ctx, runner)->str`, `commit_all(ctx, title, runner)`, `open_pr(ctx, item, runner, *, dry_run)->str | None`, `process_item(ctx, item, fixer, verifier, runner, guidance, *, max_revise=2, dry_run=False)->PRResult`. + +- [ ] **Step 1: Failing tests** + +```python +def _ctx(tmp_path): + (tmp_path / ".git").mkdir(exist_ok=True) + return ap.RepoCtx("o/n", "o/n", tmp_path, "main") + + +def test_process_item_opens_on_approve(tmp_path): + ctx = _ctx(tmp_path) + fr = FakeRunner([ + (["git", "-C", str(tmp_path), "diff"], ap.RunResult(0, "patch", "")), + (["gh", "pr", "create"], ap.RunResult(0, "https://github.com/o/n/pull/1", "")), + (["claude"], ap.RunResult(0, '{"result": "APPROVE good"}', "")), + ]) + fixer = ap.Engine("codex", "high", fr) # codex fixes + verifier = ap.Engine("claude", "high", fr) # claude reviews → APPROVE + item = ap.WorkItem("issue", "Fix bug", "b", "fix-bug", number=3, url="u") + res = ap.process_item(ctx, item, fixer, verifier, fr, "guide") + assert res.status == "opened" + assert res.url == "https://github.com/o/n/pull/1" + + +def test_process_item_skips_after_max_revise(tmp_path): + ctx = _ctx(tmp_path) + fr = FakeRunner([ + (["git", "-C", str(tmp_path), "diff"], ap.RunResult(0, "patch", "")), + (["claude"], ap.RunResult(0, '{"result": "REQUEST_CHANGES: nope"}', "")), + ]) + fixer = ap.Engine("codex", "high", fr) + verifier = ap.Engine("claude", "high", fr) + item = ap.WorkItem("issue", "Fix bug", "b", "fix-bug") + res = ap.process_item(ctx, item, fixer, verifier, fr, "guide", max_revise=1) + assert res.status == "skipped" + assert not any(c[:3] == ["gh", "pr", "create"] for c in fr.calls) + + +def test_process_item_dry_run_never_creates_pr(tmp_path): + ctx = _ctx(tmp_path) + fr = FakeRunner([ + (["git", "-C", str(tmp_path), "diff"], ap.RunResult(0, "patch", "")), + (["claude"], ap.RunResult(0, '{"result": "APPROVE"}', "")), + ]) + fixer = ap.Engine("codex", "high", fr) + verifier = ap.Engine("claude", "high", fr) + item = ap.WorkItem("issue", "Fix bug", "b", "fix-bug") + res = ap.process_item(ctx, item, fixer, verifier, fr, "g", dry_run=True) + assert res.status == "opened" and res.url is None + assert not any(c[:3] == ["gh", "pr", "create"] for c in fr.calls) +``` + +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** + +```python +_FIX_PROMPT = ( + "you are contributing a single mergeable PR to {repo}. work item:\n" + "title: {title}\n{issue_ref}\nbody:\n{body}\n\n" + "contribution guidance:\n{guidance}\n\n" + "make the smallest correct change that resolves this, including a " + "regression test if the repo has tests. do not add any AI-attribution " + "trailer. keep it to one logical change.{revise}" +) +_REVIEW_PROMPT = ( + "review this diff as a maintainer of {repo}. does it correctly and " + "minimally resolve `{title}`, follow the repo's conventions, and include " + "a test where appropriate? it must merge cleanly." +) + + +def git_branch(ctx: RepoCtx, slug: str, runner: Runner) -> str: + branch = f"auto-pr/{slug}" + runner.run(["git", "-C", str(ctx.clone_dir), "switch", "-c", branch, + f"origin/{ctx.default_branch}"]) + return branch + + +def git_diff(ctx: RepoCtx, runner: Runner) -> str: + res = runner.run(["git", "-C", str(ctx.clone_dir), "diff", "HEAD"]) + return res.stdout + + +def commit_all(ctx: RepoCtx, title: str, runner: Runner) -> None: + runner.run(["git", "-C", str(ctx.clone_dir), "add", "-A"]) + runner.run(["git", "-C", str(ctx.clone_dir), "commit", "-m", title]) + + +def open_pr(ctx: RepoCtx, item: WorkItem, branch: str, runner: Runner, *, + dry_run: bool) -> str | None: + closes = f"\n\ncloses #{item.number}" if item.number else "" + body = f"{item.body.strip()[:600]}{closes}".strip() or "see linked issue." + if dry_run: + return None + runner.run(["git", "-C", str(ctx.clone_dir), "push", "-u", "origin", branch]) + res = runner.run([ + "gh", "pr", "create", "--repo", ctx.repo, "--head", + f"{ctx.fork_owner or 'HEAD'}:{branch}" if ctx.fork_owner else branch, + "--base", ctx.default_branch, "--title", item.title, "--body", body, + ]) + url = res.stdout.strip().splitlines()[-1] if res.stdout.strip() else None + return url + + +def process_item(ctx: RepoCtx, item: WorkItem, fixer: Engine, verifier: Engine, + runner: Runner, guidance: str, *, max_revise: int = 2, + dry_run: bool = False) -> PRResult: + result = PRResult(item=item, status="skipped", fixer=fixer.name, + verifier=verifier.name) + branch = git_branch(ctx, item.slug, runner) + issue_ref = f"issue: {item.url}" if item.url else "(no tracked issue)" + revise_note = "" + for attempt in range(max_revise + 1): + result.rounds = attempt + 1 + prompt = _FIX_PROMPT.format( + repo=ctx.repo, title=item.title, issue_ref=issue_ref, + body=item.body, guidance=guidance[:4000], revise=revise_note, + ) + fixer.fix(cwd=str(ctx.clone_dir), prompt=prompt) + gate_ok, gate_log = run_gate(ctx.clone_dir, runner) + diff = git_diff(ctx, runner) + if not diff.strip(): + result.reason = "fixer produced no diff" + return result + if not gate_ok: + revise_note = f"\n\nthe test gate FAILED:\n{gate_log[-1500:]}\nfix it." + continue + verdict = verifier.review( + cwd=str(ctx.clone_dir), diff=diff, + prompt=_REVIEW_PROMPT.format(repo=ctx.repo, title=item.title), + ) + if verdict.approved: + commit_all(ctx, item.title, runner) + url = open_pr(ctx, item, branch, runner, dry_run=dry_run) + result.status = "opened" + result.url = url + result.reason = verdict.notes + return result + revise_note = f"\n\nreviewer requested changes:\n{verdict.notes}" + result.reason = f"failed verification after {max_revise + 1} rounds" + return result +``` + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): per-item fix/gate/verify/revise pipeline"` + +--- + +### Task 8: top-level orchestrator + +**Files:** Modify `src/vouch/auto_pr.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Produces: `run_auto_pr(repo_url, workspace, count, claude_effort, codex_effort, *, runner=None, labels=(), fork_owner=None, max_revise=2, dry_run=False)->list[PRResult]`. + +- [ ] **Step 1: Failing test** + +```python +def test_run_auto_pr_alternates_and_collects(tmp_path, monkeypatch): + (tmp_path / ".git").mkdir() + captured = [] + + def fake_process(ctx, item, fixer, verifier, runner, guidance, **kw): + captured.append((fixer.name, verifier.name)) + return ap.PRResult(item, "opened", fixer.name, verifier.name, + url=f"https://github.com/o/n/pull/{len(captured)}") + + monkeypatch.setattr(ap, "resolve_workspace", + lambda *a, **k: ap.RepoCtx("o/n", "o/n", tmp_path, "main", "me")) + monkeypatch.setattr(ap, "detect_or_bootstrap_guidance", lambda *a, **k: "g") + monkeypatch.setattr(ap, "source_work_items", + lambda *a, **k: [ap.WorkItem("issue", f"t{i}", "", f"t{i}") for i in range(2)]) + monkeypatch.setattr(ap, "process_item", fake_process) + + out = ap.run_auto_pr("o/n", str(tmp_path), 2, "high", "high", + runner=FakeRunner()) + assert [r.url for r in out] == ["https://github.com/o/n/pull/1", + "https://github.com/o/n/pull/2"] + assert captured == [("claude", "codex"), ("codex", "claude")] +``` + +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** + +```python +def run_auto_pr(repo_url: str, workspace: str, count: int, + claude_effort: str, codex_effort: str, *, + runner: Runner | None = None, + labels: tuple[str, ...] = (), + fork_owner: str | None = None, + max_revise: int = 2, + dry_run: bool = False) -> list[PRResult]: + runner = runner or SubprocessRunner() + claude = Engine("claude", claude_effort, runner) + codex = Engine("codex", codex_effort, runner) + engines = (claude, codex) + + ctx = resolve_workspace(repo_url, workspace, runner, fork_owner=fork_owner) + guidance = detect_or_bootstrap_guidance(ctx, claude, runner) + items = source_work_items(ctx, count, runner, claude, labels=labels) + + results: list[PRResult] = [] + for i, item in enumerate(items): + fixer = engines[i % 2] + verifier = engines[(i + 1) % 2] + results.append(process_item( + ctx, item, fixer, verifier, runner, guidance, + max_revise=max_revise, dry_run=dry_run, + )) + return results +``` + +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** — `git commit -am "feat(auto-pr): top-level run_auto_pr orchestrator"` + +--- + +### Task 9: `vouch auto-pr` CLI command + +**Files:** Modify `src/vouch/cli.py`; Test `tests/test_auto_pr.py` + +**Interfaces:** +- Consumes: `run_auto_pr`, `PRResult`, `EFFORT_LEVELS`. +- Produces: `vouch auto-pr` click command printing opened URLs (or `--json`). + +- [ ] **Step 1: Failing test** (uses click's CliRunner + monkeypatch) + +```python +def test_cli_auto_pr_prints_urls(monkeypatch, tmp_path): + from click.testing import CliRunner + from vouch.cli import cli + item = ap.WorkItem("issue", "t", "", "t") + monkeypatch.setattr("vouch.auto_pr.run_auto_pr", lambda *a, **k: [ + ap.PRResult(item, "opened", "claude", "codex", + url="https://github.com/o/n/pull/9"), + ap.PRResult(item, "skipped", "codex", "claude", reason="gate red"), + ]) + r = CliRunner().invoke(cli, [ + "auto-pr", "owner/name", "--workspace", str(tmp_path), + "--count", "2", "--claude-effort", "high", "--codex-effort", "high", + ]) + assert r.exit_code == 0 + assert "https://github.com/o/n/pull/9" in r.output + assert "skipped" in r.output +``` + +- [ ] **Step 2: Run** → FAIL (no command). +- [ ] **Step 3: Implement — add to `src/vouch/cli.py`** (after the `embeddings`/`eval` groups, before `main`): + +```python +@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", + type=click.Choice(["low", "medium", "high", "max"])) +@click.option("--codex-effort", default="high", + type=click.Choice(["low", "medium", "high", "max"])) +@click.option("--issue-label", "issue_labels", multiple=True, + help="restrict the open-issue source to these labels.") +@click.option("--fork-owner", default=None, + help="fork owner login (default: authenticated gh user).") +@click.option("--max-revise", default=2, show_default=True, type=int) +@click.option("--dry-run", is_flag=True, help="do everything except push/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, dry_run: bool, + as_json: bool) -> None: + """Open N mergeable PRs against REPO_URL, cross-verified by claude + codex.""" + from . import auto_pr as ap_mod + 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, dry_run=dry_run, + ) + 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 + 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) +``` + +- [ ] **Step 4: Run** → PASS. Also `vouch auto-pr --help` renders. +- [ ] **Step 5: Commit** — `git add src/vouch/cli.py tests/test_auto_pr.py && git commit -m "feat(auto-pr): vouch auto-pr CLI command"` + +--- + +### Task 10: skills/auto-pr/SKILL.md + CHANGELOG + +**Files:** Create `skills/auto-pr/SKILL.md`; Modify `CHANGELOG.md` + +- [ ] **Step 1:** Write `skills/auto-pr/SKILL.md` — frontmatter (`name: auto-pr`, description covering "open N mergeable PRs against any repo, bootstrap a contribution skill from merged PRs, cross-verify with claude+codex"), then sections: invocation (`vouch auto-pr --workspace … --count … --claude-effort … --codex-effort …`), prerequisites (`gh` authed, `claude` + `codex` on PATH), how the pipeline works (resolve → guidance → source → fix/gate/verify/revise → open), the house rules it enforces, and failure semantics (skip-not-open). Lowercase prose, vouch house style. +- [ ] **Step 2:** Add a `### auto-pr` bullet under `[Unreleased]` in `CHANGELOG.md`. +- [ ] **Step 3: Commit** — `git add skills/auto-pr/SKILL.md CHANGELOG.md && git commit -m "docs(auto-pr): add auto-pr skill + changelog entry"` + +--- + +### Task 11: green gate + +- [ ] **Step 1:** `.venv/bin/python -m pytest tests/test_auto_pr.py -q` → all pass. +- [ ] **Step 2:** `.venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings` → no regressions. +- [ ] **Step 3:** `.venv/bin/python -m mypy src` → clean. +- [ ] **Step 4:** `.venv/bin/python -m ruff check src tests` → clean. +- [ ] **Step 5:** Fix anything red, recommit. + +## Self-Review + +- **Spec coverage:** inputs/output → Task 9 CLI; placement/isolation → Task 1 module + Global Constraints; guidance bootstrap → Task 4; issues-first+discovery+dedup → Task 5; effort mapping → Task 1; cross-verify alternation → Tasks 7–8; local gate → Task 6; house rules → Task 7 prompts + Task 10 skill; testing → every task. All spec sections map to a task. +- **Placeholder scan:** the only non-literal is Task 10's SKILL.md prose (intentionally a writing task with explicit required sections) and the flagged dead-line note in Task 4. No TBD/TODO logic. +- **Type consistency:** `RunResult`, `WorkItem`, `ReviewVerdict`, `PRResult`, `RepoCtx`, `Engine` names/fields are used identically across Tasks 1–9. `run_auto_pr`/`process_item`/`source_work_items` signatures match between definition and call sites. diff --git a/docs/superpowers/plans/2026-06-25-vouch-dual-solve.md b/docs/superpowers/plans/2026-06-25-vouch-dual-solve.md new file mode 100644 index 00000000..78089757 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-vouch-dual-solve.md @@ -0,0 +1,1068 @@ +# vouch dual-solve 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:** Add a `vouch dual-solve ` CLI command that runs Claude Code and Codex on one GitHub issue in isolated git worktrees, lets the operator pick the winning diff, keeps that branch, and proposes the chosen solution's rationale into the KB through the gated proposal flow. + +**Architecture:** A new `src/vouch/dual_solve.py` orchestration module plus one thin click command in `src/vouch/cli.py`, mirroring the existing `auto_pr.py` + `auto_pr_cmd` split. The subprocess boundary (git / gh / claude / codex) is funnelled through `auto_pr`'s injectable `Runner`, so every stage is unit-testable against a `FakeRunner`. `dual_solve` reuses `Engine`, `Runner`, `SubprocessRunner`, and `slugify` from `auto_pr` (import, not re-extract — see Global Constraints), and unlike `auto_pr` it *does* call the KB layer, but only via `proposals.propose_claim` so the review gate is preserved. + +**Tech Stack:** Python 3, click, pytest, mypy, ruff. Reuses `vouch.auto_pr`, `vouch.storage.KBStore`, `vouch.proposals.propose_claim`, `vouch.context.build_context_pack`, `vouch.models`. + +## Global Constraints + +- This is a **CLI-only sibling tool**, exactly like `auto-pr`. It is NOT a `kb.*` method: do **not** add it to `server.py`, `jsonl_server.py`, `capabilities.py`, or `openclaw.plugin.json`. `test_capabilities` does not apply. +- **Review gate is sacred.** The only KB writes are `store.put_source(...)` (source intake — ungated by design) and `proposals.propose_claim(...)` (lands in `proposed/`). Never call `proposals.approve` or any direct durable-claim write. Nothing is auto-approved. +- **Reuse, don't re-extract.** Import `Engine, Runner, SubprocessRunner, slugify` from `vouch.auto_pr`. Do NOT move them into a new `_engines.py` — `tests/test_auto_pr.py` monkeypatches `ap.shutil.which` and depends on those symbols living in `auto_pr`, so extraction is disruptive and out of scope. +- **Import where first used — do NOT front-load.** ruff `F401` (unused import) is part of the gate and is enforced at *every* commit, not just the final one. Each task adds `from .X import Y` (and `import json` / `import tempfile`) only when its own code references the symbol; the task's **Interfaces → Consumes** block names the source module for every symbol it needs. Keep `__all__` isort-sorted (ruff `RUF022`) when a task adds a public name. Earlier tasks therefore import less than the full surface: e.g. `build_context_pack`/`ContextPack` arrive with Task 3, `Engine`/`Runner`/`slugify` with Task 4, `SourceType`/`propose_claim` with Task 5, `tempfile`/`SubprocessRunner` with Task 6. +- **Conventional commits**, lowercase summary ≤72 chars, lowercase body. **No `Co-Authored-By` trailer.** Types: `feat | fix | refactor | test | docs | chore`. +- **Stage by name** in every commit step — never `git add -A` / `git add .` (it leaks `.claude/`, `web/`, local scratch). +- **CI gate** that must stay green: `.venv/bin/pytest tests/ -q --ignore=tests/embeddings && .venv/bin/mypy src && .venv/bin/ruff check src tests` (equivalent to `make check`). +- **Run tools via the venv entry points** — `.venv/bin/pytest`, `.venv/bin/mypy`, `.venv/bin/ruff` — never the `python -m ` form. A local PreToolUse hook mis-parses the `-m` in `python -m pytest` as a commit `-m` flag and blocks the command; the entry points avoid it. +- All new functions carry type annotations (mypy `src` is strict and is the gate most often missed). +- Branch off `test` (the active integration branch) before starting: `git switch -c feat/dual-solve test`. NOTE: `origin/main` is 211 commits behind and does NOT contain `auto_pr.py`, which this plan imports from — `test` is the only viable base. (The repo's usual `origin/main` ship-flow doesn't apply until `test` merges down.) + +--- + +### Task 1: Module scaffold — data model, `_require_engines`, `parse_issue_ref` + +**Files:** +- Create: `src/vouch/dual_solve.py` +- Create: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: `Engine, Runner, SubprocessRunner, slugify` from `vouch.auto_pr`. +- Produces: + - `Issue(title: str, body: str, number: int | None = None, url: str | None = None)` — frozen dataclass. + - `Candidate(engine: str, branch: str, worktree: Path, diff: str = "", sha: str = "", ok: bool = False, error: str | None = None)` — mutable dataclass. + - `_require_engines() -> None` + - `parse_issue_ref(ref: str) -> tuple[str | None, str]` — returns `(repo_or_None, gh_locator)`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_dual_solve.py +"""tests for vouch dual-solve. + +every test runs against a FakeRunner: no network, no real claude/codex/gh. +only the subprocess boundary is mocked; all stage logic is real. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import auto_pr as ap +from vouch import dual_solve as ds + + +class FakeRunner: + """matches argv prefixes to canned RunResults and records every call.""" + + def __init__(self, script: list[tuple[list[str], ap.RunResult]] | None = None): + self.script = list(script or []) + self.calls: list[list[str]] = [] + + def run(self, argv: list[str], *, cwd: str | None = None, + stdin: str | None = None, timeout: int | None = None) -> ap.RunResult: + self.calls.append(argv) + for match, result in self.script: + if argv[: len(match)] == match: + return result + return ap.RunResult(0, "", "") + + +def test_parse_issue_ref_owner_repo_shorthand(): + assert ds.parse_issue_ref("owner/name#42") == ("owner/name", "42") + + +def test_parse_issue_ref_url_passes_through(): + url = "https://github.com/owner/name/issues/42" + assert ds.parse_issue_ref(url) == (None, url) + + +def test_parse_issue_ref_rejects_garbage(): + with pytest.raises(ValueError): + ds.parse_issue_ref("not an issue") + + +def test_require_engines_raises_when_missing(monkeypatch): + monkeypatch.setattr(ds.shutil, "which", lambda b: None) + with pytest.raises(RuntimeError, match="not on PATH"): + ds._require_engines() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'vouch.dual_solve'`. + +- [ ] **Step 3: Write the module scaffold** + +```python +# src/vouch/dual_solve.py +"""vouch dual-solve: run claude + codex on one issue; the operator picks a winner. + +a sibling tool in the spirit of auto_pr -- it orchestrates the two coding +engines through the same injectable Runner so every stage is unit-testable +against a fake. unlike auto_pr it DOES touch the KB, but only ever via +``proposals.propose_claim`` (writes land in ``proposed/``), so the review gate +is preserved: nothing is auto-approved. +""" +from __future__ import annotations + +import re +import shutil +from dataclasses import dataclass +from pathlib import Path + +__all__ = ["Candidate", "Issue", "parse_issue_ref"] + + +def _require_engines() -> None: + """Fail fast before any worktree is created if a required binary is absent.""" + missing = [b for b in ("git", "gh", "claude", "codex") if shutil.which(b) is None] + if missing: + raise RuntimeError( + f"required CLI not on PATH: {', '.join(missing)} " + "(dual-solve needs git, gh, and both engines claude and codex)" + ) + + +@dataclass(frozen=True) +class Issue: + title: str + body: str + number: int | None = None + url: str | None = None + + +@dataclass +class Candidate: + engine: str # "claude" | "codex" + branch: str + worktree: Path + diff: str = "" + sha: str = "" + ok: bool = False + error: str | None = None + + +def parse_issue_ref(ref: str) -> tuple[str | None, str]: + """Normalize an issue reference for ``gh issue view``. + + ``owner/name#42`` -> ``("owner/name", "42")`` (needs ``--repo``). + a github issue URL -> ``(None, url)`` (gh accepts the URL directly). + anything else is a hard error rather than a silent bad gh call. + """ + r = ref.strip() + m = re.fullmatch(r"([^/\s]+/[^/\s]+)#(\d+)", r) + if m: + return m.group(1), m.group(2) + if re.search(r"github\.com/[^/\s]+/[^/\s]+/issues/\d+", r): + return None, r + raise ValueError(f"cannot parse github issue from {ref!r}") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/dual_solve.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): scaffold module, issue-ref parsing, engine check" +``` + +--- + +### Task 2: `fetch_issue` + +**Files:** +- Modify: `src/vouch/dual_solve.py` +- Modify: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: `parse_issue_ref`, `Issue`, `Runner` (Task 1). +- Produces: `fetch_issue(ref: str, runner: Runner) -> Issue`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_dual_solve.py (append) +def test_fetch_issue_url_no_repo_flag(): + payload = '{"number": 7, "title": "Bug in parser", "body": "boom", "url": "u"}' + fr = FakeRunner([(["gh", "issue", "view"], ap.RunResult(0, payload, ""))]) + issue = ds.fetch_issue("https://github.com/o/n/issues/7", fr) + assert issue.number == 7 and issue.title == "Bug in parser" + view = next(c for c in fr.calls if c[:3] == ["gh", "issue", "view"]) + assert "--repo" not in view + + +def test_fetch_issue_shorthand_adds_repo_flag(): + payload = '{"number": 9, "title": "t", "body": "", "url": "u"}' + fr = FakeRunner([(["gh", "issue", "view"], ap.RunResult(0, payload, ""))]) + ds.fetch_issue("o/n#9", fr) + view = next(c for c in fr.calls if c[:3] == ["gh", "issue", "view"]) + assert "--repo" in view and "o/n" in view and "9" in view + + +def test_fetch_issue_raises_on_gh_error(): + fr = FakeRunner([(["gh", "issue", "view"], ap.RunResult(1, "", "not found"))]) + with pytest.raises(RuntimeError, match="could not fetch issue"): + ds.fetch_issue("https://github.com/o/n/issues/1", fr) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k fetch_issue -q` +Expected: FAIL with `AttributeError: module 'vouch.dual_solve' has no attribute 'fetch_issue'`. + +- [ ] **Step 3: Add `fetch_issue`** + +```python +# src/vouch/dual_solve.py (append; also add "fetch_issue" to __all__) +def fetch_issue(ref: str, runner: Runner) -> Issue: + repo, locator = parse_issue_ref(ref) + argv = ["gh", "issue", "view", locator, "--json", "number,title,body,url"] + if repo: + argv += ["--repo", repo] + res = runner.run(argv) + if res.code != 0: + raise RuntimeError( + f"could not fetch issue {ref!r}: {res.stderr.strip()[:300]}" + ) + try: + data = json.loads(res.stdout or "{}") + except json.JSONDecodeError as e: + raise RuntimeError(f"gh returned non-json for {ref!r}") from e + return Issue( + title=str(data.get("title", "")).strip(), + body=str(data.get("body", "") or ""), + number=data.get("number"), + url=data.get("url"), + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k fetch_issue -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/dual_solve.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): fetch issue title/body via gh" +``` + +--- + +### Task 3: `repo_root`, `ground_prompt`, `build_prompt` + +**Files:** +- Modify: `src/vouch/dual_solve.py` +- Modify: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: `Issue` (Task 1); `build_context_pack`, `ContextPack`, `KBStore`. +- Produces: + - `repo_root(runner: Runner, cwd: Path) -> Path` + - `ground_prompt(store: KBStore, query: str, *, limit: int = 8) -> str` + - `build_prompt(issue: Issue, grounding: str) -> str` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_dual_solve.py (append) +from vouch.models import ContextItem, ContextPack +from vouch.storage import KBStore + + +def test_repo_root_returns_toplevel(): + fr = FakeRunner([(["git", "-C", "/w", "rev-parse", "--show-toplevel"], + ap.RunResult(0, "/repo/root\n", ""))]) + assert ds.repo_root(fr, Path("/w")) == Path("/repo/root") + + +def test_repo_root_raises_outside_git(): + fr = FakeRunner([(["git", "-C", "/w", "rev-parse"], + ap.RunResult(128, "", "not a git repo"))]) + with pytest.raises(RuntimeError, match="not inside a git repository"): + ds.repo_root(fr, Path("/w")) + + +def test_ground_prompt_renders_items(tmp_path, monkeypatch): + store = KBStore.init(tmp_path) + pack = ContextPack(query="q", items=[ + ContextItem(id="c1", type="claim", summary="auth uses jwt"), + ]) + monkeypatch.setattr(ds, "build_context_pack", lambda *a, **k: pack) + out = ds.ground_prompt(store, "auth") + assert "[c1]" in out and "auth uses jwt" in out + + +def test_ground_prompt_empty_is_explicit(tmp_path, monkeypatch): + store = KBStore.init(tmp_path) + monkeypatch.setattr(ds, "build_context_pack", + lambda *a, **k: ContextPack(query="q", items=[])) + assert "nothing" in ds.ground_prompt(store, "x").lower() + + +def test_build_prompt_includes_issue_and_grounding(): + issue = ds.Issue(title="Fix the lexer", body="it crashes", number=5) + p = ds.build_prompt(issue, "- [c1] relevant claim") + assert "Fix the lexer" in p and "it crashes" in p + assert "[c1] relevant claim" in p + assert "smallest correct change" in p +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k "repo_root or ground or build_prompt" -q` +Expected: FAIL — missing attributes `repo_root` / `ground_prompt` / `build_prompt`. + +- [ ] **Step 3: Add the three functions** + +```python +# src/vouch/dual_solve.py (append; add the three names to __all__) +_FIX_PROMPT = ( + "you are resolving a github issue in the current repository.\n\n" + "issue #{num}: {title}\n\n{body}\n\n" + "what the project's knowledge base already knows about this area:\n" + "{grounding}\n\n" + "make the smallest correct change that resolves this issue, including a " + "regression test if the repo has tests. keep it to one logical change. " + "do not add any AI-attribution trailer to commits." +) + + +def repo_root(runner: Runner, cwd: Path) -> Path: + res = runner.run(["git", "-C", str(cwd), "rev-parse", "--show-toplevel"]) + if res.code != 0: + raise RuntimeError("not inside a git repository") + return Path(res.stdout.strip()) + + +def ground_prompt(store: KBStore, query: str, *, limit: int = 8) -> str: + pack = build_context_pack(store, query=query, limit=limit) + items = pack.items if isinstance(pack, ContextPack) else [] + if not items: + return "(the knowledge base has nothing on this topic yet.)" + return "\n".join(f"- [{it.id}] {it.summary}" for it in items) + + +def build_prompt(issue: Issue, grounding: str) -> str: + return _FIX_PROMPT.format( + num=issue.number if issue.number is not None else "?", + title=issue.title, + body=issue.body or "(no description)", + grounding=grounding, + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k "repo_root or ground or build_prompt" -q` +Expected: PASS (5 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/dual_solve.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): repo-root, kb grounding, shared fix prompt" +``` + +--- + +### Task 4: `run_candidate` + +**Files:** +- Modify: `src/vouch/dual_solve.py` +- Modify: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: `Engine`, `Issue`, `Candidate`, `slugify`, `Runner`. +- Produces: `run_candidate(engine: Engine, issue: Issue, prompt: str, root: Path, base: str, worktree: Path, runner: Runner, *, commit: bool = True) -> Candidate`. + + Branch name: `vouch-dual/--`. Sets `ok=True` only when a non-empty diff was produced (and, when `commit=True`, committed). On any failure sets `ok=False` and a human-readable `error`; never raises. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_dual_solve.py (append) +def _issue(): + return ds.Issue(title="Fix bug", body="b", number=3, url="u") + + +def test_run_candidate_success_commits_and_captures_sha(tmp_path): + root, wt = tmp_path, tmp_path / "wt-claude" + fr = FakeRunner([ + (["git", "-C", str(wt), "diff", "HEAD"], ap.RunResult(0, "patch text", "")), + (["git", "-C", str(wt), "rev-parse", "HEAD"], ap.RunResult(0, "abc123\n", "")), + (["claude"], ap.RunResult(0, '{"result": "done"}', "")), + ]) + eng = ds.Engine("claude", "high", fr) + cand = ds.run_candidate(eng, _issue(), "do it", root, "HEAD", wt, fr) + assert cand.ok is True + assert cand.engine == "claude" + assert cand.branch == "vouch-dual/3-fix-bug-claude" + assert cand.diff == "patch text" and cand.sha == "abc123" + 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) + + +def test_run_candidate_worktree_add_failure(tmp_path): + root, wt = tmp_path, tmp_path / "wt" + fr = FakeRunner([(["git", "-C", str(root), "worktree", "add"], + ap.RunResult(1, "", "branch exists"))]) + cand = ds.run_candidate(ds.Engine("codex", "high", fr), _issue(), + "p", root, "HEAD", wt, fr) + assert cand.ok is False and "worktree add failed" in (cand.error or "") + + +def test_run_candidate_empty_diff(tmp_path): + root, wt = tmp_path, tmp_path / "wt" + fr = FakeRunner([(["git", "-C", str(wt), "diff", "HEAD"], + ap.RunResult(0, " \n", ""))]) + cand = ds.run_candidate(ds.Engine("codex", "high", fr), _issue(), + "p", root, "HEAD", wt, fr) + assert cand.ok is False and "no diff" in (cand.error or "") + assert not any(c[:4] == ["git", "-C", str(wt), "commit"] for c in fr.calls) + + +def test_run_candidate_dry_run_skips_commit(tmp_path): + root, wt = tmp_path, tmp_path / "wt" + fr = FakeRunner([(["git", "-C", str(wt), "diff", "HEAD"], + ap.RunResult(0, "patch", ""))]) + cand = ds.run_candidate(ds.Engine("codex", "high", fr), _issue(), + "p", root, "HEAD", wt, fr, commit=False) + assert cand.ok is True and cand.diff == "patch" and cand.sha == "" + assert not any(c[:4] == ["git", "-C", str(wt), "commit"] for c in fr.calls) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k run_candidate -q` +Expected: FAIL — `module 'vouch.dual_solve' has no attribute 'run_candidate'`. + +- [ ] **Step 3: Add `run_candidate`** + +```python +# src/vouch/dual_solve.py (append; add "run_candidate" to __all__) +def run_candidate(engine: Engine, issue: Issue, prompt: str, root: Path, + base: str, worktree: Path, runner: Runner, *, + commit: bool = True) -> Candidate: + slug = slugify(issue.title) + num = issue.number if issue.number is not None else "x" + branch = f"vouch-dual/{num}-{slug}-{engine.name}" + cand = Candidate(engine=engine.name, branch=branch, worktree=worktree) + + add = runner.run(["git", "-C", str(root), "worktree", "add", "-b", branch, + str(worktree), base]) + if add.code != 0: + cand.error = f"worktree add failed: {add.stderr.strip()[:200]}" + return cand + + engine.fix(cwd=str(worktree), prompt=prompt) + + # intent-to-add so a fix that only *creates* files still shows in `diff HEAD`. + runner.run(["git", "-C", str(worktree), "add", "-A", "-N"]) + diff = runner.run(["git", "-C", str(worktree), "diff", "HEAD"]).stdout + if not diff.strip(): + cand.error = "engine produced no diff" + return cand + cand.diff = diff + + if commit: + runner.run(["git", "-C", str(worktree), "add", "-A"]) + title = f"resolve #{issue.number}: {issue.title}" if issue.number \ + else f"resolve: {issue.title}" + c = runner.run(["git", "-C", str(worktree), "commit", "-m", title]) + if c.code != 0: + cand.error = f"commit failed: {c.stderr.strip()[:200]}" + return cand + cand.sha = runner.run( + ["git", "-C", str(worktree), "rev-parse", "HEAD"]).stdout.strip() + + cand.ok = True + return cand +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k run_candidate -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/dual_solve.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): run one engine in an isolated worktree" +``` + +--- + +### Task 5: `parse_summary` + `record_to_kb` + +**Files:** +- Modify: `src/vouch/dual_solve.py` +- Modify: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: `Issue`, `Candidate`, `Engine`, `KBStore`, `SourceType`, `propose_claim`. +- Produces: + - `parse_summary(text: str) -> tuple[str, str]` — `(root_cause, fix_pattern)`, each may be `""`. + - `record_to_kb(store: KBStore, issue: Issue, chosen: Candidate, engine: Engine, reason: str, *, proposed_by: str) -> list[str]` — registers the winning commit as a `Source`, asks `engine` (read-only) for a one-shot root-cause/fix summary, proposes ≤3 claims citing the source, returns the proposal ids. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_dual_solve.py (append) +def test_parse_summary_splits_lines(): + text = "ROOT CAUSE: off-by-one in scan\nFIX: clamp the index" + rc, fix = ds.parse_summary(text) + assert rc == "off-by-one in scan" and fix == "clamp the index" + + +def test_parse_summary_tolerates_missing(): + rc, fix = ds.parse_summary("nothing structured here") + assert rc == "" and fix == "" + + +def test_record_to_kb_proposes_cited_claims(tmp_path): + store = KBStore.init(tmp_path) + # codex engine returns a structured summary when asked read-only. + fr = FakeRunner([(["codex"], ap.RunResult( + 0, "ROOT CAUSE: bad regex\nFIX: anchor the pattern", ""))]) + eng = ds.Engine("codex", "high", fr) + issue = ds.Issue(title="Crash on empty input", body="b", number=12, url="u") + chosen = ds.Candidate(engine="codex", branch="vouch-dual/12-x-codex", + worktree=tmp_path / "wt", diff="patch", sha="deadbeef", + ok=True) + ids = ds.record_to_kb(store, issue, chosen, eng, "cleaner diff", + proposed_by="dual-solve") + assert len(ids) == 3 # decision + root cause + fix pattern + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 3 + # every proposed claim cites the one registered source (validation passes). + src_ids = {p.payload["evidence"][0] for p in pending} + assert len(src_ids) == 1 + assert any("chose codex" in p.payload["text"] for p in pending) + + +def test_record_to_kb_decision_only_when_summary_blank(tmp_path): + store = KBStore.init(tmp_path) + fr = FakeRunner([(["codex"], ap.RunResult(0, "no structure", ""))]) + eng = ds.Engine("codex", "high", fr) + issue = ds.Issue(title="t", body="", number=1) + chosen = ds.Candidate(engine="codex", branch="b", worktree=tmp_path / "wt", + diff="d", sha="s", ok=True) + ids = ds.record_to_kb(store, issue, chosen, eng, "", proposed_by="dual-solve") + assert len(ids) == 1 # only the decision claim +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k "parse_summary or record_to_kb" -q` +Expected: FAIL — missing `parse_summary` / `record_to_kb`. + +- [ ] **Step 3: Add `parse_summary` and `record_to_kb`** + +```python +# src/vouch/dual_solve.py (append; add both names to __all__) +_SUMMARY_PROMPT = ( + "in at most two lines, summarise the change you just made.\n" + "line 1 must start with `ROOT CAUSE:` then one sentence.\n" + "line 2 must start with `FIX:` then one sentence on the fix pattern." +) + + +def parse_summary(text: str) -> tuple[str, str]: + root_cause, fix = "", "" + for raw in text.splitlines(): + line = raw.strip() + up = line.upper() + if up.startswith("ROOT CAUSE") and ":" in line: + root_cause = line.split(":", 1)[1].strip() + elif up.startswith("FIX") and ":" in line: + fix = line.split(":", 1)[1].strip() + return root_cause, fix + + +def record_to_kb(store: KBStore, issue: Issue, chosen: Candidate, engine: Engine, + reason: str, *, proposed_by: str) -> list[str]: + n = issue.number if issue.number is not None else "?" + # the winning commit becomes a Source so every claim cites real evidence. + content = ( + f"dual-solve winner ({chosen.engine}) for issue #{n}: {issue.title}\n" + f"commit {chosen.sha or '(uncommitted)'}\n\n{chosen.diff}" + ).encode() + src = store.put_source( + content, + title=f"dual-solve patch for #{n} ({chosen.engine})", + url=issue.url, + locator=chosen.sha or chosen.branch, + source_type=SourceType.COMMIT, + media_type="text/x-diff", + ) + + root_cause, fix = parse_summary( + engine.ask(cwd=str(chosen.worktree), prompt=_SUMMARY_PROMPT) + ) + + decision = f"for issue #{n} ({issue.title}), chose {chosen.engine}'s solution" + # claim text is written to a yaml file via storage.py, which encodes with + # the locale default (latin-1 here) -- keep it ascii (no em dash) or the + # write raises UnicodeEncodeError (same pre-existing bug as test_volunteer_context). + decision += f" -- {reason}" if reason.strip() else "." + drafts: list[tuple[str, str, float]] = [(decision, "decision", 0.8)] + if root_cause: + drafts.append( + (f"root cause of issue #{n} ({issue.title}): {root_cause}", "fact", 0.7)) + if fix: + drafts.append( + (f"fix pattern for issue #{n} ({issue.title}): {fix}", "workflow", 0.7)) + + ids: list[str] = [] + for text, ctype, conf in drafts[:3]: + res = propose_claim( + store, text=text, evidence=[src.id], proposed_by=proposed_by, + claim_type=ctype, confidence=conf, + tags=["dual-solve", f"issue-{n}"], + rationale=f"recorded by vouch dual-solve; winner={chosen.engine}", + ) + ids.append(res.id) + return ids +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k "parse_summary or record_to_kb" -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/dual_solve.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): record the winning solution as gated proposals" +``` + +--- + +### Task 6: `cleanup`, `prepare`, `finalize` + +**Files:** +- Modify: `src/vouch/dual_solve.py` +- Modify: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: everything from Tasks 1–5; `Engine`, `SubprocessRunner`, `tempfile`. +- Produces: + - `cleanup(root: Path, candidates: list[Candidate], keep_branches: set[str], runner: Runner) -> None` — removes every candidate worktree (force) and deletes every candidate branch not in `keep_branches`. + - `prepare(store: KBStore, issue_ref: str, root: Path, runner: Runner, *, claude_effort: str = "high", codex_effort: str = "high", autonomy: str = "edit", dry_run: bool = False, workdir: Path | None = None) -> tuple[Issue, list[Candidate], dict[str, Engine]]` — fetches the issue, grounds the prompt, runs both engines (claude then codex) in sibling worktrees, returns the issue, both candidates, and the engines keyed by name. No KB write, no interaction. + - `finalize(store: KBStore, root: Path, issue: Issue, chosen: Candidate | None, engines: dict[str, Engine], candidates: list[Candidate], reason: str, runner: Runner, *, record: bool, proposed_by: str) -> list[str]` — if `chosen` and `record`, record to KB; then clean up (keeping only the chosen branch); returns proposal ids (empty when not recording). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_dual_solve.py (append) +def test_cleanup_removes_worktrees_and_loser_branch(tmp_path): + fr = FakeRunner() + c1 = ds.Candidate("claude", "vouch-dual/win", tmp_path / "a", ok=True) + c2 = ds.Candidate("codex", "vouch-dual/lose", tmp_path / "b", ok=True) + ds.cleanup(tmp_path, [c1, c2], {"vouch-dual/win"}, fr) + removes = [c for c in fr.calls if c[3:5] == ["worktree", "remove"]] + assert len(removes) == 2 + deletes = [c for c in fr.calls if c[3:5] == ["branch", "-D"]] + assert deletes == [["git", "-C", str(tmp_path), "branch", "-D", + "vouch-dual/lose"]] + + +def test_prepare_runs_both_engines(tmp_path, monkeypatch): + store = KBStore.init(tmp_path) + monkeypatch.setattr(ds, "fetch_issue", + lambda ref, runner: ds.Issue("t", "b", number=1)) + monkeypatch.setattr(ds, "ground_prompt", lambda store, q, **k: "ctx") + seen: list[str] = [] + + def fake_rc(engine, issue, prompt, root, base, worktree, runner, *, commit=True): + seen.append(engine.name) + return ds.Candidate(engine.name, f"b-{engine.name}", worktree, + diff="d", ok=True) + + monkeypatch.setattr(ds, "run_candidate", fake_rc) + issue, cands, engines = ds.prepare(store, "o/n#1", tmp_path, FakeRunner(), + workdir=tmp_path / "wd") + assert seen == ["claude", "codex"] + assert [c.engine for c in cands] == ["claude", "codex"] + assert set(engines) == {"claude", "codex"} + + +def test_finalize_records_and_keeps_winner(tmp_path, monkeypatch): + store = KBStore.init(tmp_path) + monkeypatch.setattr(ds, "record_to_kb", + lambda *a, **k: ["prop-1", "prop-2"]) + fr = FakeRunner() + win = ds.Candidate("codex", "vouch-dual/win", tmp_path / "a", ok=True) + lose = ds.Candidate("claude", "vouch-dual/lose", tmp_path / "b", ok=True) + engines = {"codex": ds.Engine("codex", "high", fr)} + ids = ds.finalize(store, tmp_path, ds.Issue("t", "b", 1), win, engines, + [win, lose], "reason", fr, record=True, proposed_by="x") + assert ids == ["prop-1", "prop-2"] + assert any(c[3:5] == ["branch", "-D"] and "vouch-dual/lose" in c + for c in fr.calls) + assert not any(c[3:5] == ["branch", "-D"] and "vouch-dual/win" in c + for c in fr.calls) + + +def test_finalize_no_record_proposes_nothing(tmp_path, monkeypatch): + store = KBStore.init(tmp_path) + called = {"n": 0} + monkeypatch.setattr(ds, "record_to_kb", + lambda *a, **k: called.__setitem__("n", called["n"] + 1)) + win = ds.Candidate("codex", "b", tmp_path / "a", ok=True) + ids = ds.finalize(store, tmp_path, ds.Issue("t", "b", 1), win, {}, [win], + "", FakeRunner(), record=False, proposed_by="x") + assert ids == [] and called["n"] == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k "cleanup or prepare or finalize" -q` +Expected: FAIL — missing `cleanup` / `prepare` / `finalize`. + +- [ ] **Step 3: Add `cleanup`, `prepare`, `finalize`** + +```python +# src/vouch/dual_solve.py (append; add the three names to __all__) +def cleanup(root: Path, candidates: list[Candidate], keep_branches: set[str], + runner: Runner) -> None: + for c in candidates: + runner.run(["git", "-C", str(root), "worktree", "remove", "--force", + str(c.worktree)]) + if c.branch not in keep_branches: + runner.run(["git", "-C", str(root), "branch", "-D", c.branch]) + + +def prepare(store: KBStore, issue_ref: str, root: Path, runner: Runner, *, + claude_effort: str = "high", codex_effort: str = "high", + autonomy: str = "edit", dry_run: bool = False, + workdir: Path | None = None + ) -> tuple[Issue, list[Candidate], dict[str, Engine]]: + full = autonomy == "full" + engines: dict[str, Engine] = { + "claude": Engine("claude", claude_effort, runner, full_autonomy=full), + "codex": Engine("codex", codex_effort, runner, full_autonomy=full), + } + issue = fetch_issue(issue_ref, runner) + grounding = ground_prompt(store, f"{issue.title}\n{issue.body}") + prompt = build_prompt(issue, grounding) + wd = workdir if workdir is not None else Path( + tempfile.mkdtemp(prefix="vouch-dual-")) + candidates: list[Candidate] = [] + for name in ("claude", "codex"): + candidates.append(run_candidate( + engines[name], issue, prompt, root, "HEAD", wd / name, runner, + commit=not dry_run, + )) + return issue, candidates, engines + + +def finalize(store: KBStore, root: Path, issue: Issue, chosen: Candidate | None, + engines: dict[str, Engine], candidates: list[Candidate], reason: str, + runner: Runner, *, record: bool, proposed_by: str) -> list[str]: + ids: list[str] = [] + if chosen is not None and record: + ids = record_to_kb(store, issue, chosen, engines[chosen.engine], reason, + proposed_by=proposed_by) + keep = {chosen.branch} if chosen is not None else set() + cleanup(root, candidates, keep, runner) + return ids +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k "cleanup or prepare or finalize" -q` +Expected: PASS (4 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/dual_solve.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): orchestrate both engines, finalize the winner" +``` + +--- + +### Task 7: CLI command `vouch dual-solve` + +**Files:** +- Modify: `src/vouch/cli.py` (add the command near `auto_pr_cmd`, ~line 1965) +- Modify: `tests/test_dual_solve.py` + +**Interfaces:** +- Consumes: `dual_solve.prepare`, `dual_solve.finalize`, `dual_solve._require_engines`, `dual_solve.repo_root`, `dual_solve.SubprocessRunner`; CLI helpers `_load_store`, `_whoami`, `_emit_json`. +- Produces: a `dual-solve` click command. Interactive flow: show each candidate's `--stat`-style header + diff, then prompt `[c]laude / [x]codex / [n]either`. `--json` skips the prompt and emits both diffs + metadata (keeping both successful branches). Failure handling: if exactly one candidate is `ok`, ask whether to proceed with the survivor or abort; if none, abort non-zero. + +- [ ] **Step 1: Write the failing tests (CliRunner, fully mocked engine layer)** + +```python +# tests/test_dual_solve.py (append) +def test_cli_dual_solve_choose_claude(monkeypatch, tmp_path): + from click.testing import CliRunner + + from vouch.cli import cli + + issue = ds.Issue("Fix bug", "b", number=4, url="u") + c_claude = ds.Candidate("claude", "vouch-dual/4-fix-bug-claude", + tmp_path / "a", diff="DIFF-CLAUDE", sha="s1", ok=True) + c_codex = ds.Candidate("codex", "vouch-dual/4-fix-bug-codex", + tmp_path / "b", diff="DIFF-CODEX", sha="s2", 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", + lambda *a, **k: (issue, [c_claude, c_codex], {})) + captured = {} + monkeypatch.setattr( + "vouch.dual_solve.finalize", + lambda store, root, iss, chosen, engines, cands, reason, runner, *, record, proposed_by: + captured.update(chosen=chosen, record=record, reason=reason) or ["prop-1"]) + monkeypatch.setattr("vouch.cli._load_store", lambda *a, **k: object()) + + # input: choose claude, then a one-line reason for the decision claim. + r = CliRunner().invoke(cli, ["dual-solve", "o/n#4"], input="c\ncleaner\n") + assert r.exit_code == 0, r.output + assert "DIFF-CLAUDE" in r.output and "DIFF-CODEX" in r.output + assert captured["chosen"].engine == "claude" + assert captured["record"] is True + assert "prop-1" in r.output + + +def test_cli_dual_solve_json_is_noninteractive(monkeypatch, tmp_path): + from click.testing import CliRunner + + 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)] + 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", + lambda *a, **k: (issue, cands, {})) + monkeypatch.setattr("vouch.cli._load_store", lambda *a, **k: object()) + finalize_called = {"n": 0} + monkeypatch.setattr("vouch.dual_solve.finalize", + lambda *a, **k: finalize_called.__setitem__("n", 1)) + + 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 + # --json must not prompt and must not finalize/record. + assert finalize_called["n"] == 0 + + +def test_cli_dual_solve_aborts_when_both_fail(monkeypatch, tmp_path): + from click.testing import CliRunner + + from vouch.cli import cli + + issue = ds.Issue("t", "b", number=1) + cands = [ds.Candidate("claude", "b1", tmp_path / "a", ok=False, error="boom"), + ds.Candidate("codex", "b2", tmp_path / "b", ok=False, error="boom")] + 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", + lambda *a, **k: (issue, cands, {})) + monkeypatch.setattr("vouch.cli._load_store", lambda *a, **k: object()) + r = CliRunner().invoke(cli, ["dual-solve", "o/n#1"]) + assert r.exit_code != 0 + assert "both engines failed" in r.output.lower() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k cli_dual_solve -q` +Expected: FAIL — `No such command 'dual-solve'`. + +- [ ] **Step 3: Add the CLI command in `src/vouch/cli.py`** + +Insert after `auto_pr_cmd` (before the `# --- sync ---` banner, ~line 1965): + +```python +# --- 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("--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, 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 # lives in auto_pr, not dual_solve + store = _load_store() + runner = SubprocessRunner() + try: + ds_mod._require_engines() + root = ds_mod.repo_root(runner, Path.cwd()) + 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, + ) + 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, "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 # the [n]either branch assigns None; mypy needs the union + 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="") + + ids = ds_mod.finalize( + store, root, issue, chosen, engines, candidates, reason or "", runner, + record=not no_record and not dry_run, proposed_by=_whoami(), + ) + 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) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/pytest tests/test_dual_solve.py -k cli_dual_solve -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/vouch/cli.py tests/test_dual_solve.py +git commit -m "feat(dual-solve): add the dual-solve cli command" +``` + +--- + +### Task 8: Changelog + full gate green + +**Files:** +- Modify: `CHANGELOG.md` +- (verify only) all of the above + +**Interfaces:** none (documentation + gate). + +- [ ] **Step 1: Add a changelog entry** + +Open `CHANGELOG.md`. Under the `## [Unreleased]` heading (add an `### Added` subsection if the file's existing style uses them; otherwise match whatever bullet style is already there), add: + +```markdown +- `vouch dual-solve `: run claude + codex on one github issue in + isolated worktrees, pick the winning diff, and propose the chosen solution's + rationale into the kb (review-gated; nothing auto-approved). a sibling tool + to `auto-pr`. +``` + +- [ ] **Step 2: Run the full suite for the new module** + +Run: `.venv/bin/pytest tests/test_dual_solve.py tests/test_auto_pr.py -q` +Expected: PASS — the dual-solve suite is green and `auto_pr` is unaffected (no symbols were moved). + +- [ ] **Step 3: Run the whole CI gate** + +Run: `.venv/bin/pytest tests/ -q --ignore=tests/embeddings && .venv/bin/mypy src && .venv/bin/ruff check src tests` +Expected: all green. Imports were added per-task as symbols came into use (see Global Constraints), so `ruff` should report no `F401`; if it flags a leftover unused import in `dual_solve.py`, the task that was meant to use it skipped it — investigate rather than blindly deleting. If `mypy` flags `build_context_pack`'s `ContextPack | dict` union, confirm the `isinstance(pack, ContextPack)` guard in `ground_prompt` is present (it narrows the type). + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): note vouch dual-solve" +``` + +- [ ] **Step 5: Push and open the PR** + +```bash +git push -u origin feat/dual-solve +``` + +PR body (lowercase, no `Co-Authored-By` trailer) must call out the one review-worthy point: **this is the first sibling tool that writes to the kb.** State plainly that it only ever *proposes* (writes land in `proposed/`, approval still requires a human `vouch approve`), so the review-gate invariant is preserved — `auto-pr`'s "never writes to the kb" rule is widened, not broken. + +--- + +## Self-Review + +**Spec coverage:** +- Real `vouch` CLI subcommand → Task 7. ✓ +- Worktree + branch + diff execution → Task 4 (`run_candidate`), Task 6 (`prepare`). ✓ +- KB record: decision + ≤3 approach claims, gated → Task 5 (`record_to_kb`, `propose_claim` only). ✓ +- Grounding: inject identical `vouch context` into both prompts → Task 3 (`ground_prompt`) + Task 6 (one shared `prompt` passed to both engines). ✓ +- Failure handling: prompt on single failure, abort on double → Task 7 CLI (`click.confirm` survivor path; `both engines failed` abort). ✓ +- Competitors independent (no cross-critique) → `prepare` runs each engine on the same prompt with no review step. ✓ +- Invariants (review gate, storage purity, citations enforced) → Global Constraints + Task 5 registers the commit `Source` before proposing, so every claim cites evidence. ✓ +- Testing list from spec → Tasks 1–7 cover both-succeed (Task 7), one-empty-diff prompt (Task 7 survivor), both-fail abort (Task 7), `--no-record` (Task 6 `finalize`), claims cite the commit source (Task 5), `--dry-run` no commits (Task 4 `commit=False`). ✓ + +**Placeholder scan:** every code step shows complete code; commands have expected output; no "TBD"/"add error handling". The only deliberately defensive step is the `CHANGELOG.md` style match (Task 8 Step 1), which adapts to the file's existing format — concrete bullet text is given. + +**Type consistency:** `Candidate`/`Issue` field names are used identically across Tasks 1, 4, 5, 6, 7. `run_candidate`/`prepare`/`finalize` signatures match their call sites. `propose_claim` is called with the real keyword args verified from `proposals.py` (`text`, `evidence`, `proposed_by`, `claim_type`, `confidence`, `tags`, `rationale`). `put_source` args match `storage.py`. `SourceType.COMMIT` exists in `models.py`. diff --git a/docs/superpowers/plans/2026-06-26-dual-solve-web-spa.md b/docs/superpowers/plans/2026-06-26-dual-solve-web-spa.md new file mode 100644 index 00000000..f55b7146 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-dual-solve-web-spa.md @@ -0,0 +1,1011 @@ +# dual-solve web SPA 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:** ship a single-page app inside vouch's review-ui that takes a github issue link, runs `dual-solve` against the repo the server lives in, streams progress live, shows both engines' diffs side by side, and lets a human pick the winner — keeping the branch and proposing the rationale into the kb through the existing review gate. + +**Architecture:** a new `/dual-solve*` route group mounted into the existing FastAPI app (`src/vouch/web/server.py:build_app`), only when launched with `vouch review-ui --allow-dual-solve`. The blocking `dual_solve.prepare`/`finalize` run in `run_in_threadpool`; phase progress (the existing `on_progress` hook) is bridged to the existing `_Hub` websocket via `asyncio.run_coroutine_threadsafe`. One in-process job at a time. The frontend is a buildless Vue 3 SPA served as static files. + +**Tech Stack:** Python 3 / FastAPI / Starlette (`run_in_threadpool`, websockets) — all already in the `[web]` extra. Frontend: Vue 3 ESM browser build, **vendored**, no npm/bundler. + +## Global Constraints + +- Shipped feature in `src/vouch/web`; the **review-gate invariant is preserved** — web finalize only calls `proposals.propose_claim` (lands in `proposed/`), never `approve`. +- **Buildless Vue 3 only** — vendored `static/vendor/vue.esm-browser.prod.js` (full build, with template compiler). No npm, bundler, or build step enters the repo. +- The `/dual-solve*` routes mount **only** under `vouch review-ui --allow-dual-solve`; otherwise every such path is `404`. +- **`autonomy` is hard-forced to `"edit"`** in every web call to `dual_solve.prepare`; the `full`/bypassPermissions path is unreachable over HTTP. +- All dual-solve routes use the existing `guarded` dependency (Bearer-token auth) — no new auth path. +- Routes call `dual_solve.prepare`/`finalize`/`repo_root`/`cleanup` **through the `vouch.dual_solve` module object** (`from .. import dual_solve as ds`; `ds.prepare(...)`), so tests monkeypatch `vouch.dual_solve.*`. +- **import-as-used**: each task imports only the symbols its own code references, so `ruff` (F401) stays green at every commit; keep `__all__` isort-sorted (RUF022). +- Tooling: run `.venv/bin/pytest tests/ -q --ignore=tests/embeddings`, `.venv/bin/mypy src`, `.venv/bin/ruff check src tests`. **Never** the `python -m` form (a commit-validate hook mis-parses `-m`). +- Web tests live behind the `[web]` extra: start the module with `pytest.importorskip("fastapi", ...)` and import `TestClient` after it. +- Stage files **by name** (never `git add -A`). Conventional commits, lowercase body, **no `Co-Authored-By` trailer**. +- No new Python dependency. Vue is a vendored static asset, not a Python dep. + +## File Structure + +| kind | path | responsibility | +|---|---|---| +| new | `src/vouch/web/dual_solve_api.py` | `register(app, *, store, hub, auth, guarded, render, reviewer, enabled)`; `DualSolveJob`; the route group | +| new | `src/vouch/web/templates/dual_solve.html` | SPA shell (extends `base.html`, mounts Vue) | +| new | `src/vouch/web/static/dual_solve.js` | the Vue 3 app | +| new | `src/vouch/web/static/dual_solve.css` | two-pane diff layout | +| new | `src/vouch/web/static/vendor/vue.esm-browser.prod.js` | pinned Vue 3 (vendored) | +| new | `src/vouch/web/static/vendor/VENDOR.md` | vendored version + sha256 | +| modify | `src/vouch/web/server.py` | add `allow_dual_solve` param; call `register(...)`; inject `dual_solve_enabled` into templates | +| modify | `src/vouch/web/__init__.py` | thread `allow_dual_solve` through `create_app` | +| modify | `src/vouch/web/templates/base.html` | conditional `dual-solve` nav link | +| modify | `src/vouch/cli.py` (`review-ui`) | add `--allow-dual-solve` flag | +| new | `proposals/0001-dual-solve-web.md` | the VEP (governance) | +| new | `tests/test_web_dual_solve.py` | TestClient lifecycle + auth + gate + ws | +| modify | `CHANGELOG.md` | `[Unreleased] / ### Added` | + +Reference signatures (verified against source, do not re-derive): +- `dual_solve.prepare(store, issue_ref, root, runner, *, claude_effort="high", codex_effort="high", autonomy="edit", dry_run=False, workdir=None, on_progress=None) -> tuple[Issue, list[Candidate], dict[str, Engine]]` +- `dual_solve.finalize(store, root, issue, chosen, engines, candidates, reason, runner, *, record, proposed_by) -> list[str]` +- `dual_solve.repo_root(runner, cwd) -> Path` (raises `RuntimeError` outside a git repo) +- `dual_solve.cleanup(root, candidates, keep_branches, runner) -> None` +- `Candidate(engine, branch, worktree, diff="", sha="", ok=False, error=None)` +- `SubprocessRunner` lives in `vouch.auto_pr`. +- `build_app(kb_root=None, *, auth=None, page_size=DEFAULT_PAGE_SIZE) -> FastAPI`; inside it: `store`, `hub` (`_Hub`), `auth`, `guarded` (`[Depends(require_auth)]`), `_tmpl(request, name, ctx)`, `reviewer()`. + +--- + +### Task 1: VEP governance doc + +**Files:** +- Create: `proposals/0001-dual-solve-web.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: the accepted-surface rationale the PR links to. No code symbols. + +This is a documentation deliverable (no pytest). The reviewer checks content coverage, not a test run. + +- [ ] **Step 1: Look at the proposals format** + +Run: `ls proposals/ && sed -n '1,40p' proposals/README.md` +Expected: see the house format for a VEP (title, status, motivation, design, trust-boundary). + +- [ ] **Step 2: Write the VEP** + +Create `proposals/0001-dual-solve-web.md` (match the README's headings; lowercase prose). It MUST cover: +- **motivation**: a browser surface to run dual-solve and pick a winner without the terminal. +- **surface added**: the `/dual-solve`, `/dual-solve/run`, `/dual-solve/job/{id}`, `/dual-solve/choose` routes + reuse of `/ws`. +- **why it's safe**: off by default (`--allow-dual-solve`), localhost-first, Bearer-token guarded, **edit-only over HTTP** (no `full` autonomy), and the **review-gate invariant holds** — web finalize only `propose`s, nothing auto-approves. +- **out of scope**: on-demand cloning, `full` autonomy, multi-job concurrency. + +- [ ] **Step 3: Commit** + +```bash +git add proposals/0001-dual-solve-web.md +git commit -F # docs(dual-solve-web): vep for the web runner surface +``` + +--- + +### Task 2: gate + plumbing + SPA shell route + +**Files:** +- Create: `src/vouch/web/dual_solve_api.py` +- Modify: `src/vouch/web/server.py`, `src/vouch/web/__init__.py`, `src/vouch/web/templates/base.html`, `src/vouch/cli.py` +- Create: `src/vouch/web/templates/dual_solve.html` +- Test: `tests/test_web_dual_solve.py` + +**Interfaces:** +- Consumes: `build_app` internals (`store`, `hub`, `auth`, `guarded`, `_tmpl`, `reviewer`); `dual_solve.repo_root`; `SubprocessRunner`. +- Produces: `register(app, *, store, hub, auth, guarded, render, reviewer, enabled) -> None`. When `enabled` is false it returns immediately (mounts nothing). When true it derives `git_root = ds.repo_root(SubprocessRunner(), store.root)` and mounts `GET /dual-solve` (the SPA shell). `create_app(..., allow_dual_solve=False)` and `build_app(..., allow_dual_solve=False)` gain the keyword. `vouch review-ui --allow-dual-solve` sets it. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_web_dual_solve.py`: + +```python +"""Tests for the dual-solve web runner (the SPA backend).""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from vouch.storage import KBStore +from vouch.web import create_app + +pytest.importorskip("fastapi", reason="dual-solve web needs the [web] extra") + +from fastapi.testclient import TestClient + + +@pytest.fixture +def git_kb(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + # dual-solve needs a git repo; the kb lives at its root. + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +def _client(git_kb: KBStore, *, enabled: bool = True) -> TestClient: + app = create_app(str(git_kb.root), allow_dual_solve=enabled) + return TestClient(app) + + +def test_dual_solve_page_renders_when_enabled(git_kb): + r = _client(git_kb).get("/dual-solve") + assert r.status_code == 200 + assert "dual-solve-app" in r.text # the Vue mount point + + +def test_dual_solve_routes_absent_when_disabled(git_kb): + r = _client(git_kb, enabled=False).get("/dual-solve") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/pytest tests/test_web_dual_solve.py -q` +Expected: FAIL — `create_app()` has no `allow_dual_solve` keyword. + +- [ ] **Step 3: Create the route module (shell route only)** + +Create `src/vouch/web/dual_solve_api.py`: + +```python +"""dual-solve web runner: routes mounted into the review-ui under an explicit +opt-in flag. The blocking engine work runs in a threadpool; progress streams +over the review-ui's existing websocket. The review gate is preserved -- the +choose step only ever proposes to the kb. +""" +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse + +from .. import dual_solve as ds +from ..auto_pr import SubprocessRunner + + +def register( + app: FastAPI, + *, + store: Any, + hub: Any, + auth: Any, + guarded: list, + render: Callable[[Request, str, dict[str, Any]], Any], + reviewer: Callable[[], str], + enabled: bool, +) -> None: + """Mount the dual-solve routes. No-op unless ``enabled``.""" + if not enabled: + return + runner = SubprocessRunner() + # fail fast at app-build time if we're not in a git repo: dual-solve can't + # create worktrees otherwise. + git_root = ds.repo_root(runner, store.root) + app.state.dual_solve_git_root = str(git_root) + + @app.get("/dual-solve", response_class=HTMLResponse, dependencies=guarded) + def dual_solve_page(request: Request) -> Any: + return render(request, "dual_solve.html", {"active": "dual-solve"}) +``` + +- [ ] **Step 4: Create the SPA shell template** + +Create `src/vouch/web/templates/dual_solve.html`: + +```html +{% extends "base.html" %} +{% block title %}dual-solve · vouch{% endblock %} +{% block content %} +
+ +{% endblock %} +``` + +(The referenced `/static/dual_solve.js` + vendored Vue are created in Task 5; the route still renders the shell HTML now.) + +- [ ] **Step 5: Wire the flag through build_app** + +In `src/vouch/web/server.py`, change the `build_app` signature to add the keyword: + +```python +def build_app( + kb_root: str | None = None, + *, + auth: AuthConfig | None = None, + page_size: int = DEFAULT_PAGE_SIZE, + allow_dual_solve: bool = False, +) -> FastAPI: +``` + +Add `from .dual_solve_api import register as _register_dual_solve` to the module imports. Inject the nav flag into every template by editing the `_tmpl` helper: + +```python + def _tmpl(request: Request, name: str, ctx: dict[str, Any]) -> Any: + ctx.setdefault("auth_enabled", auth.enabled) + ctx.setdefault("dual_solve_enabled", allow_dual_solve) + return templates.TemplateResponse(request, name, ctx) +``` + +Immediately before `return app`, mount the routes: + +```python + _register_dual_solve( + app, store=store, hub=hub, auth=auth, guarded=guarded, + render=_tmpl, reviewer=reviewer, enabled=allow_dual_solve, + ) + return app +``` + +- [ ] **Step 6: Thread it through create_app** + +In `src/vouch/web/__init__.py`, add the keyword to `create_app` and pass it down: + +```python +def create_app( # type: ignore[no-untyped-def] + kb_root: str | None = None, + *, + auth_token: str | None = None, + auth_label: str = "web-reviewer", + page_size: int | None = None, + allow_dual_solve: bool = False, +): + _require_web_extra() + from .server import DEFAULT_PAGE_SIZE, AuthConfig, build_app + + auth = AuthConfig(token=auth_token, label=auth_label) + return build_app( + kb_root, + auth=auth, + page_size=page_size if page_size is not None else DEFAULT_PAGE_SIZE, + allow_dual_solve=allow_dual_solve, + ) +``` + +- [ ] **Step 7: Add the nav link** + +In `src/vouch/web/templates/base.html`, inside `