Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-old> <id-new>` (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.
`<id-new>` 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
Expand Down
42 changes: 37 additions & 5 deletions docs/superpowers/specs/2026-05-25-vouch-diff-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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: <id>")`. If one is
Expand All @@ -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`).

Expand All @@ -74,6 +84,20 @@ diff claim <old> → <new>
- `--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.
Expand All @@ -90,6 +114,14 @@ diff claim <old> → <new>

## 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.
1 change: 1 addition & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"kb.read_claim",
"kb.read_entity",
"kb.read_relation",
"kb.diff",
"kb.list_pages",
"kb.list_claims",
"kb.list_entities",
Expand Down
10 changes: 7 additions & 3 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 19 additions & 2 deletions src/vouch/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
9 changes: 9 additions & 0 deletions src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
*,
Expand Down
98 changes: 98 additions & 0 deletions tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ------------------------------------------------------------------


Expand Down Expand Up @@ -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"
Loading