diff --git a/CHANGELOG.md b/CHANGELOG.md index 71809356..2f70d287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,14 @@ All notable changes to vouch are documented here. Format follows ## [1.1.0] — 2026-07-03 ### Added +- `kb.diff` — `vouch diff ` (0.1.0) now has full `kb.*` + parity: an MCP tool and a JSONL `kb.diff` handler alongside the existing + CLI, registered in `capabilities.METHODS` like every other read method. + `` is now optional for a claim: omitting it resolves the diff + against `superseded_by`, with a clear error when the claim has no + successor (pages still require an explicit `new_id` — they have no + successor pointer). Read-only throughout; still no writes, proposals, or + audit events (#327). - auto-capture: claude code sessions are harvested via hooks and filed as a single pending session-summary proposal for human approval. a `PostToolUse` hook (`vouch capture observe`) appends compact tool-use observations to an diff --git a/docs/superpowers/specs/2026-05-25-vouch-diff-design.md b/docs/superpowers/specs/2026-05-25-vouch-diff-design.md index f5bb4ea0..5bd1f862 100644 --- a/docs/superpowers/specs/2026-05-25-vouch-diff-design.md +++ b/docs/superpowers/specs/2026-05-25-vouch-diff-design.md @@ -23,7 +23,14 @@ field. Read-only: no writes, no proposals, no audit events. `last_confirmed_at`, `approved_by`). - **Line-diff the long text.** `claim.text` / `page.body` render as a `difflib` unified diff; everything else as `field: old → new`. -- **CLI-only.** Read-only inspection; does not touch the `kb.*` capability set. +- **Full `kb.*` parity.** Registered as `kb.diff` at all four sites (MCP tool, + JSONL handler, `capabilities.METHODS`, CLI) like any other read method — + see "MCP/JSONL parity" under Non-goals below for the superseded original + call on this. +- **Omitted `new_id` resolves via `superseded_by`.** For a claim, `new_id` is + optional; when omitted it resolves to `old_claim.superseded_by`, erroring + clearly if that's unset. Pages have no successor pointer, so `new_id` is + required for a page. ## Components — `src/vouch/diff.py` @@ -37,7 +44,10 @@ Raised for unknown ids and mismatched kinds. `kind: str, old_id: str, new_id: str, changes: list[FieldChange], text_diff: list[str]`. -### `diff_artifacts(store, old_id, new_id) -> ArtifactDiff` +### `diff_artifacts(store, old_id, new_id=None) -> ArtifactDiff` +- **`new_id` resolution:** if omitted, `old_id` must resolve to a claim with + `superseded_by` set — that becomes `new_id`. A page, or a claim without a + successor, raises `DiffError` naming the id. - **Kind resolution:** try `store.get_claim` on both ids → both succeed ⇒ `kind="claim"`. Otherwise try `store.get_page` on both → `kind="page"`. If an id resolves to neither, raise `DiffError("unknown artifact: ")`. If one is @@ -55,7 +65,7 @@ Field sets (long text field rendered as `text_diff`, the rest as changes): - **Page** — body *(diff)*; title, type, status, claims, entities, sources, tags. -## CLI — `vouch diff OLD NEW [--json]` +## CLI — `vouch diff OLD [NEW] [--json]` Follows existing patterns (`_load_store`, `_cli_errors`, `_emit_json`). @@ -74,6 +84,20 @@ diff claim - `--json` → `_emit_json` of the `ArtifactDiff` as a dict. - No differences → prints `no differences`. +## `kb.diff` — MCP + JSONL + +Same read as the CLI, exposed for agents: + +- **MCP** `kb_diff(old_id, new_id=None) -> dict` in `server.py`, next to + `kb_read_claim`/`kb_read_page` in the unrestricted-read section. +- **JSONL** `_h_diff` reads `params["old_id"]` (required) and + `params["new_id"]` (optional) — `kb.diff` in `HANDLERS`. +- **capabilities** `kb.diff` in `METHODS`, next to `kb.read_relation`. +- Both return `dataclasses.asdict(ArtifactDiff)`. +- Unrestricted like the other by-id read tools (`kb_read_claim`, + `kb_read_page`) — no `ViewerContext`/scope filtering, since resolving a + *specific known id* carries the same exposure either way. + ## Error handling - Unknown id (neither claim nor page) → `DiffError` → clean CLI `Error:` line. @@ -90,6 +114,14 @@ diff claim ## Non-goals -- Following supersede chains automatically (caller passes both ids). +- Following supersede chains more than one hop (omitted `new_id` resolves one + `superseded_by` link, not the full chain to the latest revision). - Diffing entities/relations/sources (claims and pages only, per ROADMAP). -- MCP/JSONL parity (`kb.*` surface unchanged). +- `ViewerContext` scope filtering on `kb.diff` (see "MCP + JSONL" above — + matches the other by-id read tools). + +Superseded decision from the original design: "MCP/JSONL parity" was +initially scoped out ("CLI-only... does not touch the `kb.*` capability +set"). Issue #327 pointed out this leaves `kb.diff` as the only read method +skipping the four-site registration convention (`CLAUDE.md` §"When you add a +new kb.* method"), so it was added — see "MCP + JSONL" above. diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 3fd21c75..749c10a6 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -27,6 +27,7 @@ "kb.read_claim", "kb.read_entity", "kb.read_relation", + "kb.diff", "kb.list_pages", "kb.list_claims", "kb.list_entities", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index a490fcf9..992c8a6a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2979,10 +2979,14 @@ def sync_apply_cmd(source_path: str, on_conflict: str) -> None: @cli.command() @click.argument("old_id") -@click.argument("new_id") +@click.argument("new_id", required=False) @click.option("--json", "as_json", is_flag=True, default=False, help="Emit the diff as JSON.") -def diff(old_id: str, new_id: str, as_json: bool) -> None: - """Show what changed between two claim or two page revisions.""" +def diff(old_id: str, new_id: str | None, as_json: bool) -> None: + """Show what changed between two claim or two page revisions. + + NEW_ID is optional for a claim that has been superseded: it resolves to + ``superseded_by`` automatically. + """ from .diff import diff_artifacts store = _load_store() diff --git a/src/vouch/diff.py b/src/vouch/diff.py index 8387afa0..392072e2 100644 --- a/src/vouch/diff.py +++ b/src/vouch/diff.py @@ -72,11 +72,28 @@ def _line_diff(old: str, new: str) -> list[str]: )) -def diff_artifacts(store: KBStore, old_id: str, new_id: str) -> ArtifactDiff: - """Diff two same-kind artifacts (both claims or both pages) by id.""" +def diff_artifacts(store: KBStore, old_id: str, new_id: str | None = None) -> ArtifactDiff: + """Diff two same-kind artifacts (both claims or both pages) by id. + + ``new_id`` is optional for claims: when omitted, it resolves to + ``old_id``'s ``superseded_by`` field. Pages have no successor pointer, so + omitting ``new_id`` for a page is an error. + """ old_kind = _kind_of(store, old_id) if old_kind is None: raise DiffError(f"unknown artifact: {old_id}") + + if new_id is None: + if old_kind != "claim": + raise DiffError( + f"{old_id} is a {old_kind}; pages have no successor pointer, " + "pass new_id explicitly" + ) + old_claim = store.get_claim(old_id) + if not old_claim.superseded_by: + raise DiffError(f"{old_id} has not been superseded; pass new_id explicitly") + new_id = old_claim.superseded_by + new_kind = _kind_of(store, new_id) if new_kind is None: raise DiffError(f"unknown artifact: {new_id}") diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5e3b62b7..b1dbbc50 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -249,6 +249,14 @@ def _h_read_relation(p: dict) -> dict: return _store().get_relation(p["relation_id"]).model_dump(mode="json") +def _h_diff(p: dict) -> dict: + from dataclasses import asdict + + from .diff import diff_artifacts + + return asdict(diff_artifacts(_store(), p["old_id"], p.get("new_id"))) + + def _h_list_pages(p: dict) -> list[dict]: pages = filter_pages( _store().list_pages(), @@ -723,6 +731,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.read_claim": _h_read_claim, "kb.read_entity": _h_read_entity, "kb.read_relation": _h_read_relation, + "kb.diff": _h_diff, "kb.list_pages": _h_list_pages, "kb.list_claims": _h_list_claims, "kb.list_entities": _h_list_entities, diff --git a/src/vouch/server.py b/src/vouch/server.py index 8cabee08..d4aff659 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -309,6 +309,18 @@ def kb_read_relation(relation_id: str) -> dict[str, Any]: raise ValueError(str(e)) from e +@mcp.tool() +def kb_diff(old_id: str, new_id: str | None = None) -> dict[str, Any]: + """Field-level diff between two claim revisions or two page revisions. + + new_id is optional for a superseded claim: resolves to superseded_by. + """ + from dataclasses import asdict + + from .diff import diff_artifacts + return asdict(diff_artifacts(_store(), old_id, new_id)) + + @mcp.tool() def kb_list_pages( *, diff --git a/tests/test_diff.py b/tests/test_diff.py index 4189d806..f26c063a 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -7,8 +7,11 @@ import pytest from click.testing import CliRunner +from vouch import audit +from vouch.capabilities import capabilities from vouch.cli import cli from vouch.diff import ArtifactDiff, DiffError, diff_artifacts +from vouch.jsonl_server import HANDLERS, handle_request from vouch.models import Claim, ClaimStatus, Page from vouch.storage import KBStore @@ -80,6 +83,36 @@ def test_diff_mismatched_kinds_raises(store: KBStore) -> None: diff_artifacts(store, "c1", "p1") +def test_diff_omitted_new_id_resolves_via_superseded_by(store: KBStore) -> None: + _claim(store, "c2", text="new wording") + _claim(store, "c1", text="old wording", superseded_by="c2") + d = diff_artifacts(store, "c1") + assert d.new_id == "c2" + assert any(line.startswith("+new wording") for line in d.text_diff) + + +def test_diff_omitted_new_id_without_successor_raises(store: KBStore) -> None: + _claim(store, "c1") + with pytest.raises(DiffError, match="has not been superseded"): + diff_artifacts(store, "c1") + + +def test_diff_omitted_new_id_for_page_raises(store: KBStore) -> None: + store.put_page(Page(id="p1", title="P", body="b")) + with pytest.raises(DiffError, match="pages have no successor pointer"): + diff_artifacts(store, "p1") + + +def test_diff_read_only_writes_no_audit_event_or_proposal(store: KBStore) -> None: + _claim(store, "c1", text="old") + _claim(store, "c2", text="new") + before = list(audit.read_events(store.kb_dir)) + diff_artifacts(store, "c1", "c2") + after = list(audit.read_events(store.kb_dir)) + assert after == before + assert store.list_proposals() == [] + + # --- CLI ------------------------------------------------------------------ @@ -127,3 +160,68 @@ def test_cli_diff_unknown_id_clean_error( assert res.exit_code != 0 assert "Traceback" not in res.output assert "unknown artifact: nope" in res.output + + +def test_cli_diff_omitted_new_id_resolves_successor( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c2", text="new") + _claim(store, "c1", text="old", superseded_by="c2") + res = CliRunner().invoke(cli, ["diff", "c1"]) + assert res.exit_code == 0, res.output + assert "diff claim c1 → c2" in res.output + + +def test_cli_diff_omitted_new_id_clean_error( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + res = CliRunner().invoke(cli, ["diff", "c1"]) + assert res.exit_code != 0 + assert "Traceback" not in res.output + assert "has not been superseded" in res.output + + +# --- kb.* RPC surface ------------------------------------------------------- + + +def test_diff_method_in_capabilities() -> None: + methods = set(capabilities().methods) + assert "kb.diff" in methods + assert set(capabilities().methods) == set(HANDLERS.keys()) + + +def test_kb_diff_over_jsonl(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1", status=ClaimStatus.WORKING) + _claim(store, "c2", status=ClaimStatus.STABLE) + resp = handle_request( + {"id": "1", "method": "kb.diff", "params": {"old_id": "c1", "new_id": "c2"}} + ) + assert resp["ok"] is True, resp + assert resp["result"]["kind"] == "claim" + changed = {c["field"]: (c["old"], c["new"]) for c in resp["result"]["changes"]} + assert changed["status"] == ("working", "stable") + + +def test_kb_diff_missing_param_over_jsonl( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + resp = handle_request({"id": "2", "method": "kb.diff", "params": {}}) + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" + + +def test_kb_diff_unknown_id_over_jsonl( + store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(store.root) + _claim(store, "c1") + resp = handle_request( + {"id": "3", "method": "kb.diff", "params": {"old_id": "c1", "new_id": "nope"}} + ) + assert resp["ok"] is False + assert resp["error"]["code"] == "invalid_request"