From b74f8ddcb51ab0ae27e75448e9633c6ad4844a8a Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:54:06 +0900 Subject: [PATCH 01/63] feat(bundle): exclude filter + knowledge-only preset for export --- src/vouch/bundle.py | 41 ++++++++++++++++++++++++------ tests/test_bundle.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index 51e2f0d9..13c48ea5 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -45,6 +45,10 @@ "decided", ) +# The knowledge-only preset: what syncs to a hub. Sessions (agent/LLM +# transcripts) and decided-proposal records never leave the machine. +KNOWLEDGE_EXCLUDE = ("decided", "sessions") + IMPORT_ROOT_FILES = {"config.yaml"} FORBIDDEN_SAFETY_FLAGS = { "has_proposed": "proposed/", @@ -67,9 +71,21 @@ # --- export --------------------------------------------------------------- -def _iter_export_files(kb_dir: Path): +_EXCLUDABLE = set(EXPORT_SUBDIRS) | {"config.yaml"} + + +def _check_exclude(exclude: tuple[str, ...]) -> tuple[str, ...]: + bad = [e for e in exclude if e not in _EXCLUDABLE] + if bad: + raise ValueError(f"unknown exclude entries: {bad!r} (allowed: {sorted(_EXCLUDABLE)})") + return tuple(sorted(set(exclude))) + + +def _iter_export_files(kb_dir: Path, exclude: tuple[str, ...] = ()): """Yield (relative path, absolute path) for every committable file.""" for sub in EXPORT_SUBDIRS: + if sub in exclude: + continue root = kb_dir / sub if not root.is_dir(): continue @@ -77,13 +93,14 @@ def _iter_export_files(kb_dir: Path): if p.is_file(): yield p.relative_to(kb_dir), p cfg = kb_dir / "config.yaml" - if cfg.exists(): + if cfg.exists() and "config.yaml" not in exclude: yield cfg.relative_to(kb_dir), cfg -def build_manifest(kb_dir: Path) -> dict[str, Any]: +def build_manifest(kb_dir: Path, exclude: tuple[str, ...] = ()) -> dict[str, Any]: + exclude = _check_exclude(exclude) files: list[dict[str, Any]] = [] - for rel, abs_path in _iter_export_files(kb_dir): + for rel, abs_path in _iter_export_files(kb_dir, exclude): data = abs_path.read_bytes() files.append( { @@ -107,6 +124,7 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: "counts": { sub: sum(1 for f in files if f["path"].startswith(f"{sub}/")) for sub in EXPORT_SUBDIRS }, + "excluded": list(exclude), "safety": { "has_proposed": False, "has_state_db": False, @@ -115,11 +133,18 @@ def build_manifest(kb_dir: Path) -> dict[str, Any]: } -def export(kb_dir: Path, *, dest: Path, actor: str = "vouch-export") -> dict[str, Any]: - manifest = build_manifest(kb_dir) +def export( + kb_dir: Path, + *, + dest: Path, + actor: str = "vouch-export", + exclude: tuple[str, ...] = (), +) -> dict[str, Any]: + exclude = _check_exclude(exclude) + manifest = build_manifest(kb_dir, exclude) dest.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(dest, "w:gz") as tar: - for rel, abs_path in _iter_export_files(kb_dir): + for rel, abs_path in _iter_export_files(kb_dir, exclude): tar.add(abs_path, arcname=rel.as_posix()) manifest_bytes = json.dumps(manifest, indent=2, sort_keys=True).encode() info = tarfile.TarInfo(MANIFEST_NAME) @@ -130,7 +155,7 @@ def export(kb_dir: Path, *, dest: Path, actor: str = "vouch-export") -> dict[str event="bundle.export", actor=actor, object_ids=[manifest["bundle_id"]], - data={"dest": str(dest), "files": len(manifest["files"])}, + data={"dest": str(dest), "files": len(manifest["files"]), "excluded": list(exclude)}, ) return manifest diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 391cdc6c..0fa62495 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -863,3 +863,63 @@ def test_export_then_import_roundtrip_passes_content_address_check( assert diff.ok, diff.issues bundle.import_apply(dest.kb_dir, bundle_path) assert dest.read_source_content(src.id) == b"round-trip bytes" + + +# --- exclude filter (knowledge-only bundles) -------------------------------- + + +def _write_session_file(store: KBStore) -> None: + sess_dir = store.kb_dir / "sessions" + sess_dir.mkdir(exist_ok=True) + (sess_dir / "s1.yaml").write_text("id: s1\n") + + +def test_export_exclude_filters_subdirs(store: KBStore, tmp_path: Path) -> None: + src = store.put_source(b"e", title="doc") + store.put_claim(Claim(id="c1", text="alpha", evidence=[src.id])) + _write_session_file(store) + + full = tmp_path / "full.tar.gz" + filtered = tmp_path / "knowledge.tar.gz" + m_full = bundle.export(store.kb_dir, dest=full) + m_filt = bundle.export(store.kb_dir, dest=filtered, exclude=bundle.KNOWLEDGE_EXCLUDE) + + with tarfile.open(full, "r:gz") as tar: + assert any(m.name.startswith("sessions/") for m in tar.getmembers()) + with tarfile.open(filtered, "r:gz") as tar: + names = [m.name for m in tar.getmembers()] + assert not any(n.startswith("sessions/") for n in names) + assert not any(n.startswith("decided/") for n in names) + assert any(n.startswith("claims/") for n in names) + + assert m_filt["excluded"] == ["decided", "sessions"] + assert m_filt["counts"]["sessions"] == 0 + assert m_filt["bundle_id"] != m_full["bundle_id"] + + +def test_filtered_bundle_imports_cleanly(store: KBStore, tmp_path: Path) -> None: + src = store.put_source(b"e", title="doc") + store.put_claim(Claim(id="c1", text="alpha", evidence=[src.id])) + _write_session_file(store) + bundle_path = tmp_path / "k.tar.gz" + bundle.export(store.kb_dir, dest=bundle_path, exclude=bundle.KNOWLEDGE_EXCLUDE) + assert bundle.export_check(bundle_path).ok + + dest = KBStore.init(tmp_path / "dest") + diff = bundle.import_check(dest.kb_dir, bundle_path) + assert diff.ok and diff.conflicts == [] + bundle.import_apply(dest.kb_dir, bundle_path) + assert dest.get_claim("c1").text == "alpha" + assert not (dest.kb_dir / "sessions" / "s1.yaml").exists() + + +def test_export_exclude_rejects_unknown_name(store: KBStore, tmp_path: Path) -> None: + with pytest.raises(ValueError): + bundle.export(store.kb_dir, dest=tmp_path / "x.tar.gz", exclude=("claims/../etc",)) + + +def test_export_exclude_config_yaml(store: KBStore, tmp_path: Path) -> None: + bundle_path = tmp_path / "nc.tar.gz" + bundle.export(store.kb_dir, dest=bundle_path, exclude=("config.yaml",)) + with tarfile.open(bundle_path, "r:gz") as tar: + assert "config.yaml" not in [m.name for m in tar.getmembers()] From bd98f8a0d65ed106e680f48882afb78a0b5dac60 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:56:09 +0900 Subject: [PATCH 02/63] feat(bundle): export_check surfaces manifest counts and excluded --- src/vouch/bundle.py | 9 ++++++++- tests/test_bundle.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index 13c48ea5..dbd6efed 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -21,7 +21,7 @@ import io import json import tarfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -166,6 +166,9 @@ class ExportCheckResult: bundle_id: str files_checked: int issues: list[str] + # From the manifest (empty for pre-filter bundles without the keys): + counts: dict[str, int] = field(default_factory=dict) + excluded: list[str] = field(default_factory=list) def _unsafe_name_reason(name: str) -> str | None: @@ -221,6 +224,8 @@ def export_check(bundle_path: Path) -> ExportCheckResult: return ExportCheckResult(False, "", 0, ["missing manifest.json"]) manifest = json.loads(tar.extractfile(mf_member).read().decode()) # type: ignore[union-attr] bundle_id = manifest.get("bundle_id", "") + counts = manifest.get("counts", {}) + excluded = manifest.get("excluded", []) recorded = {f["path"]: f for f in manifest["files"]} for path in recorded: reason = _unsafe_name_reason(path) @@ -254,6 +259,8 @@ def export_check(bundle_path: Path) -> ExportCheckResult: bundle_id=bundle_id, files_checked=files_checked, issues=issues, + counts=counts, + excluded=excluded, ) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 0fa62495..1237045a 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -923,3 +923,21 @@ def test_export_exclude_config_yaml(store: KBStore, tmp_path: Path) -> None: bundle.export(store.kb_dir, dest=bundle_path, exclude=("config.yaml",)) with tarfile.open(bundle_path, "r:gz") as tar: assert "config.yaml" not in [m.name for m in tar.getmembers()] + + +def test_export_check_reports_counts_and_excluded(store: KBStore, tmp_path: Path) -> None: + src = store.put_source(b"e", title="doc") + store.put_claim(Claim(id="c1", text="alpha", evidence=[src.id])) + _write_session_file(store) + + full = tmp_path / "full.tar.gz" + bundle.export(store.kb_dir, dest=full) + chk = bundle.export_check(full) + assert chk.counts["sessions"] == 1 + assert chk.excluded == [] + + filt = tmp_path / "filt.tar.gz" + bundle.export(store.kb_dir, dest=filt, exclude=bundle.KNOWLEDGE_EXCLUDE) + chk2 = bundle.export_check(filt) + assert chk2.counts["sessions"] == 0 + assert chk2.excluded == ["decided", "sessions"] From fdf152088f3b9d9ee5f0cc8d4083a73ce7688aaa Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:57:45 +0900 Subject: [PATCH 03/63] feat(bundle): expose exclude filter on kb.export rpc and CLI --- src/vouch/cli.py | 9 +++++++-- src/vouch/jsonl_server.py | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5034ea8b..10195769 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2867,15 +2867,20 @@ def detect_themes_cmd( @cli.command() @click.option("--out", "out_path", required=True, type=click.Path(dir_okay=False)) -def export(out_path: str) -> None: +@click.option("--exclude", "exclude_csv", default="", + help="Comma-separated artifact dirs to omit (e.g. sessions,decided).") +def export(out_path: str, exclude_csv: str) -> None: """Bundle the durable KB into a portable .tar.gz.""" store = _load_store() - manifest = bundle.export(store.kb_dir, dest=Path(out_path), actor=_whoami()) + exclude = tuple(e.strip() for e in exclude_csv.split(",") if e.strip()) + manifest = bundle.export(store.kb_dir, dest=Path(out_path), actor=_whoami(), + exclude=exclude) _emit_json( { "bundle_id": manifest["bundle_id"], "files": len(manifest["files"]), "out": out_path, + "excluded": manifest["excluded"], } ) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index e52b6b3f..6fd129fb 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -592,16 +592,19 @@ def _h_doctor(_: dict) -> dict: def _h_export(p: dict) -> dict: + exclude = tuple(p.get("exclude") or ()) manifest = bundle.export(_store().kb_dir, dest=Path(p["out_path"]), - actor=_agent()) + actor=_agent(), exclude=exclude) return {"bundle_id": manifest["bundle_id"], - "files": len(manifest["files"]), "out": p["out_path"]} + "files": len(manifest["files"]), "out": p["out_path"], + "excluded": manifest["excluded"]} def _h_export_check(p: dict) -> dict: r = bundle.export_check(Path(p["bundle_path"])) return {"ok": r.ok, "bundle_id": r.bundle_id, - "files_checked": r.files_checked, "issues": r.issues} + "files_checked": r.files_checked, "issues": r.issues, + "counts": r.counts, "excluded": r.excluded} def _h_import_check(p: dict) -> dict: From 0181cc5e81927b9651f2c1b36b07c0a8101c2b20 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:56:04 +0900 Subject: [PATCH 04/63] feat(hub): client module - link config, token store, push/pull/status --- src/vouch/hub_client.py | 248 ++++++++++++++++++++++++++++++++++++++ tests/test_hub_client.py | 253 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 src/vouch/hub_client.py create mode 100644 tests/test_hub_client.py diff --git a/src/vouch/hub_client.py b/src/vouch/hub_client.py new file mode 100644 index 00000000..0c572463 --- /dev/null +++ b/src/vouch/hub_client.py @@ -0,0 +1,248 @@ +"""Client for VouchHub — the authorization + sync option for a local KB. + +The hub stores only approved knowledge; this client never sends sessions, +decided proposals, or config.yaml (`SYNC_EXCLUDE`). The secret token lives +OUTSIDE the KB (vouch KBs are meant to be committed to git): + + - link metadata (url, remote kb, last seen bundle id) → .vouch/hub.yaml + (never exported: bundles carry only artifact subdirs + config.yaml) + - token → $XDG_CONFIG_HOME|~/.config/vouch/hub.yaml, chmod 0600, + keyed by hub url; the VOUCH_HUB_TOKEN env var overrides. + +Pulls are gated: a bundle is applied only when conflict-free, unless the +caller explicitly chooses --on-conflict skip|overwrite. The hub is additive +and conflict-free on push; conflicts must be resolved here, at the owner's +gate, never server-side. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from . import bundle +from .storage import KBStore + +SYNC_EXCLUDE = ("config.yaml", *bundle.KNOWLEDGE_EXCLUDE) +LINK_FILE = "hub.yaml" +_TIMEOUT = 30.0 + + +class HubError(RuntimeError): + """Any hub interaction failure with a human-readable message.""" + + +class HubConflict(HubError): + def __init__(self, conflicts: list[str]): + super().__init__( + f"{len(conflicts)} conflicting artifact(s) on the hub — pull, resolve locally, push again" + ) + self.conflicts = conflicts + + +@dataclass +class HubLink: + url: str + kb: str # "user/slug" + last_bundle_id: str | None = None + + +# --- link metadata (inside the KB, no secrets) --------------------------------- + + +def load_link(kb_dir: Path) -> HubLink | None: + path = kb_dir / LINK_FILE + if not path.exists(): + return None + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not data.get("url") or not data.get("kb"): + return None + return HubLink( + url=str(data["url"]).rstrip("/"), + kb=str(data["kb"]), + last_bundle_id=data.get("last_bundle_id"), + ) + + +def save_link(kb_dir: Path, link: HubLink) -> None: + path = kb_dir / LINK_FILE + path.write_text( + yaml.safe_dump( + {"url": link.url.rstrip("/"), "kb": link.kb, "last_bundle_id": link.last_bundle_id}, + sort_keys=False, + ), + encoding="utf-8", + ) + + +# --- token store (outside the KB) ----------------------------------------------- + + +def _creds_path() -> Path: + base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + return Path(base) / "vouch" / "hub.yaml" + + +def resolve_token(url: str) -> str | None: + env = os.environ.get("VOUCH_HUB_TOKEN") + if env: + return env + path = _creds_path() + if not path.exists(): + return None + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + token = (data.get("tokens") or {}).get(url.rstrip("/")) + return str(token) if token else None + + +def save_token(url: str, token: str) -> None: + path = _creds_path() + path.parent.mkdir(parents=True, exist_ok=True) + data: dict[str, Any] = {} + if path.exists(): + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + data.setdefault("tokens", {})[url.rstrip("/")] = token + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + path.chmod(0o600) + + +# --- http ------------------------------------------------------------------------ + + +def _bundle_url(link: HubLink) -> str: + return f"{link.url}/api/u/{link.kb}/bundle" + + +def _request( + method: str, + url: str, + token: str, + *, + body: bytes | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, dict[str, str], bytes]: + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"Bearer {token}") + req.add_header("User-Agent", "vouch-hub-client") + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: # noqa: S310 — user-configured hub url + return resp.status, dict(resp.headers.items()), resp.read() + except urllib.error.HTTPError as e: + return e.code, dict(e.headers.items()), e.read() + except urllib.error.URLError as e: + raise HubError(f"cannot reach hub at {url}: {e.reason}") from e + + +def _error_message(status: int, payload: bytes) -> str: + try: + msg = json.loads(payload.decode()).get("error", "") + except (ValueError, UnicodeDecodeError): + msg = "" + hints = { + 401: "token invalid or revoked — run `vouch hub link` again", + 403: "token lacks the sync scope", + 404: "kb not found on the hub (or you are not its owner)", + } + return msg or hints.get(status, f"hub returned HTTP {status}") + + +# --- operations ------------------------------------------------------------------- + + +def push(store: KBStore, link: HubLink, token: str) -> dict[str, Any]: + with tempfile.TemporaryDirectory(prefix="vouch-hub-") as tmp: + out = Path(tmp) / "knowledge.tar.gz" + bundle.export(store.kb_dir, dest=out, actor="hub-push", exclude=SYNC_EXCLUDE) + status, _headers, payload = _request( + "PUT", + _bundle_url(link), + token, + body=out.read_bytes(), + headers={"Content-Type": "application/gzip"}, + ) + if status == 409: + try: + conflicts = json.loads(payload.decode()).get("conflicts", []) + except (ValueError, UnicodeDecodeError): + conflicts = [] + raise HubConflict(list(conflicts)) + if status != 200: + raise HubError(_error_message(status, payload)) + result = json.loads(payload.decode()) + link.last_bundle_id = result.get("bundle_id") + save_link(store.kb_dir, link) + written = int(result.get("written", 0)) + return { + "status": "pushed" if written else "up_to_date", + "bundle_id": result.get("bundle_id"), + "written": written, + "identical": int(result.get("identical", 0)), + } + + +def pull( + store: KBStore, + link: HubLink, + token: str, + *, + on_conflict: str | None = None, +) -> dict[str, Any]: + headers: dict[str, str] = {} + if link.last_bundle_id: + headers["If-None-Match"] = f'"{link.last_bundle_id}"' + status, resp_headers, payload = _request("GET", _bundle_url(link), token, headers=headers) + if status == 304: + return {"status": "up_to_date", "bundle_id": link.last_bundle_id} + if status != 200: + raise HubError(_error_message(status, payload)) + remote_id = (resp_headers.get("ETag") or "").strip('"') or None + + with tempfile.TemporaryDirectory(prefix="vouch-hub-") as tmp: + bundle_path = Path(tmp) / "pulled.tar.gz" + bundle_path.write_bytes(payload) + check = bundle.import_check(store.kb_dir, bundle_path) + if check.issues: + raise HubError(f"pulled bundle failed validation: {check.issues[0]}") + if check.conflicts and on_conflict is None: + return {"status": "conflicts", "conflicts": check.conflicts} + result = bundle.import_apply( + store.kb_dir, + bundle_path, + on_conflict=on_conflict or "fail", + actor="hub-pull", + ) + link.last_bundle_id = remote_id + save_link(store.kb_dir, link) + return { + "status": "applied", + "bundle_id": remote_id, + "written": len(result.get("written", [])), + "skipped_conflicts": result.get("skipped_conflicts", []), + } + + +def status(store: KBStore, link: HubLink, token: str | None) -> dict[str, Any]: + local_id = bundle.build_manifest(store.kb_dir, SYNC_EXCLUDE)["bundle_id"] + out: dict[str, Any] = { + "linked": True, + "url": link.url, + "kb": link.kb, + "local_bundle_id": local_id, + "in_sync": None, + } + if token: + code, _headers, _payload = _request( + "GET", _bundle_url(link), token, headers={"If-None-Match": f'"{local_id}"'} + ) + out["in_sync"] = code == 304 + return out diff --git a/tests/test_hub_client.py b/tests/test_hub_client.py new file mode 100644 index 00000000..eb750ee4 --- /dev/null +++ b/tests/test_hub_client.py @@ -0,0 +1,253 @@ +"""`vouch hub` client: link config, token store, push/pull/status.""" + +from __future__ import annotations + +import hashlib +import io +import json +import tarfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from vouch import bundle, hub_client +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path / "kb") + + +@pytest.fixture +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + cfg = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(cfg)) + monkeypatch.delenv("VOUCH_HUB_TOKEN", raising=False) + return cfg + + +def make_bundle(files: dict[str, bytes]) -> tuple[bytes, str]: + """Build a spec-conformant bundle in memory (mirror of the hub's builder).""" + hashes = {p: hashlib.sha256(d).hexdigest() for p, d in files.items()} + h = hashlib.sha256() + for p in sorted(files): + h.update(hashes[p].encode()) + bundle_id = h.hexdigest() + manifest = { + "spec": "vouch-bundle-0.1", + "bundle_id": bundle_id, + "files": [{"path": p, "size": len(d), "sha256": hashes[p]} for p, d in sorted(files.items())], + "counts": {}, + "excluded": ["config.yaml", "decided", "sessions"], + "safety": {"has_proposed": False, "has_state_db": False, "has_audit_log": False}, + } + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for p, d in sorted(files.items()): + info = tarfile.TarInfo(p) + info.size = len(d) + tar.addfile(info, io.BytesIO(d)) + mbytes = json.dumps(manifest).encode() + info = tarfile.TarInfo("manifest.json") + info.size = len(mbytes) + tar.addfile(info, io.BytesIO(mbytes)) + return buf.getvalue(), bundle_id + + +def exported_files(kb_dir: Path, tmp_path: Path) -> dict[str, bytes]: + """The KB's knowledge-only export, as {path: bytes}.""" + dest = tmp_path / "exp.tar.gz" + bundle.export(kb_dir, dest=dest, exclude=hub_client.SYNC_EXCLUDE) + out: dict[str, bytes] = {} + with tarfile.open(dest, "r:gz") as tar: + for m in tar.getmembers(): + if m.isfile() and m.name != "manifest.json": + out[m.name] = tar.extractfile(m).read() # type: ignore[union-attr] + return out + + +class FakeHub(BaseHTTPRequestHandler): + """Scripted hub speaking the v2 wire contract for one KB.""" + + files: dict[str, bytes] = {} + token = "vhp_test" + conflicts_on_push: list[str] = [] + + def _authed(self) -> bool: + return self.headers.get("Authorization") == f"Bearer {self.token}" + + def do_GET(self) -> None: # noqa: N802 — BaseHTTPRequestHandler API + if not self._authed(): + self.send_response(401) + self.end_headers() + return + gz, bundle_id = make_bundle(self.files) + etag = f'"{bundle_id}"' + if self.headers.get("If-None-Match") == etag: + self.send_response(304) + self.send_header("ETag", etag) + self.end_headers() + return + self.send_response(200) + self.send_header("ETag", etag) + self.send_header("Content-Type", "application/gzip") + self.end_headers() + self.wfile.write(gz) + + def do_PUT(self) -> None: # noqa: N802 + if not self._authed(): + self.send_response(401) + self.end_headers() + return + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.conflicts_on_push: + self._json( + 409, + { + "error": "conflicting artifacts", + "conflicts": self.conflicts_on_push, + "new_files": [], + }, + ) + return + self._json(200, {"ok": True, "bundle_id": "b" * 64, "written": 3, "identical": 0}) + + def _json(self, status: int, obj: object) -> None: + data = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(data) + + def log_message(self, *args: object) -> None: # silence + del args + + +@pytest.fixture +def fake_hub(): + FakeHub.files = {} + FakeHub.conflicts_on_push = [] + srv = ThreadingHTTPServer(("127.0.0.1", 0), FakeHub) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + yield f"http://127.0.0.1:{srv.server_port}" + srv.shutdown() + + +def _link(store: KBStore, url: str) -> hub_client.HubLink: + link = hub_client.HubLink(url=url, kb="alice/proj", last_bundle_id=None) + hub_client.save_link(store.kb_dir, link) + return link + + +# --- config + tokens --------------------------------------------------------- + + +def test_link_round_trip(store: KBStore) -> None: + assert hub_client.load_link(store.kb_dir) is None + _link(store, "http://h") + loaded = hub_client.load_link(store.kb_dir) + assert loaded is not None and loaded.kb == "alice/proj" and loaded.url == "http://h" + + +def test_link_file_is_never_exported(store: KBStore, tmp_path: Path) -> None: + _link(store, "http://h") + assert "hub.yaml" not in exported_files(store.kb_dir, tmp_path) + + +def test_token_env_beats_file_and_file_is_0600( + home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + hub_client.save_token("http://h", "vhp_file") + cred = home / "vouch" / "hub.yaml" + assert cred.exists() + assert (cred.stat().st_mode & 0o777) == 0o600 + assert hub_client.resolve_token("http://h") == "vhp_file" + assert hub_client.resolve_token("http://other") is None + monkeypatch.setenv("VOUCH_HUB_TOKEN", "vhp_env") + assert hub_client.resolve_token("http://h") == "vhp_env" + + +# --- push --------------------------------------------------------------------- + + +def test_push_happy_path(store: KBStore, fake_hub: str, home: Path) -> None: + link = _link(store, fake_hub) + r = hub_client.push(store, link, "vhp_test") + assert r["status"] == "pushed" + assert r["written"] == 3 + reloaded = hub_client.load_link(store.kb_dir) + assert reloaded is not None and reloaded.last_bundle_id + + +def test_push_conflict_maps_to_HubConflict(store: KBStore, fake_hub: str, home: Path) -> None: + FakeHub.conflicts_on_push = ["claims/c1.yaml"] + link = _link(store, fake_hub) + with pytest.raises(hub_client.HubConflict) as e: + hub_client.push(store, link, "vhp_test") + assert e.value.conflicts == ["claims/c1.yaml"] + + +def test_push_bad_token_raises_HubError(store: KBStore, fake_hub: str, home: Path) -> None: + link = _link(store, fake_hub) + with pytest.raises(hub_client.HubError): + hub_client.push(store, link, "vhp_wrong") + + +# --- pull --------------------------------------------------------------------- + + +def _remote_claim() -> dict[str, bytes]: + return {"claims/r1.yaml": b"id: r1\ntext: from the hub\n"} + + +def test_pull_applies_clean_bundle(store: KBStore, fake_hub: str, home: Path) -> None: + FakeHub.files = _remote_claim() + link = _link(store, fake_hub) + r = hub_client.pull(store, link, "vhp_test", on_conflict=None) + assert r["status"] == "applied" + assert store.get_claim("r1").text == "from the hub" + reloaded = hub_client.load_link(store.kb_dir) + assert reloaded is not None + r2 = hub_client.pull(store, reloaded, "vhp_test", on_conflict=None) + assert r2["status"] == "up_to_date" + + +def test_pull_refuses_conflicts_without_flag(store: KBStore, fake_hub: str, home: Path) -> None: + FakeHub.files = _remote_claim() + src = store.put_source(b"local evidence") + store.put_claim(Claim(id="r1", text="local version", evidence=[src.id])) + link = _link(store, fake_hub) + r = hub_client.pull(store, link, "vhp_test", on_conflict=None) + assert r["status"] == "conflicts" + assert r["conflicts"] == ["claims/r1.yaml"] + assert store.get_claim("r1").text == "local version" # untouched + + +def test_pull_overwrite_applies_conflicts(store: KBStore, fake_hub: str, home: Path) -> None: + FakeHub.files = _remote_claim() + src = store.put_source(b"local evidence") + store.put_claim(Claim(id="r1", text="local version", evidence=[src.id])) + link = _link(store, fake_hub) + r = hub_client.pull(store, link, "vhp_test", on_conflict="overwrite") + assert r["status"] == "applied" + assert store.get_claim("r1").text == "from the hub" + + +# --- status --------------------------------------------------------------------- + + +def test_status_reports_sync_state(store: KBStore, fake_hub: str, home: Path, tmp_path: Path) -> None: + src = store.put_source(b"e") + store.put_claim(Claim(id="c1", text="local knowledge", evidence=[src.id])) + link = _link(store, fake_hub) + s = hub_client.status(store, link, "vhp_test") + assert s["linked"] is True + assert s["in_sync"] is False # remote empty, local has knowledge + FakeHub.files = exported_files(store.kb_dir, tmp_path) + s2 = hub_client.status(store, link, "vhp_test") + assert s2["in_sync"] is True From 438092086fcc252c9ece26f0530bfc854356a858 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:58:00 +0900 Subject: [PATCH 05/63] feat(cli): vouch hub link/push/pull/status --- src/vouch/cli.py | 98 +++++++++++++++++++++++++++++++++++++++- tests/test_hub_client.py | 62 +++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 10195769..30cfb2fc 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -22,7 +22,7 @@ import click import yaml -from . import __version__, bundle, health, session_split, volunteer_context +from . import __version__, bundle, health, hub_client, session_split, volunteer_context from . import audit as audit_mod from . import capture as capture_mod from . import codex_rollout as codex_rollout_mod @@ -2862,6 +2862,102 @@ def detect_themes_cmd( ) +# --- hub sync --------------------------------------------------------------- + + +@cli.group() +def hub() -> None: + """Sync this KB's approved knowledge with a VouchHub.""" + + +def _hub_link_or_die(store) -> "hub_client.HubLink": + link = hub_client.load_link(store.kb_dir) + if link is None: + raise click.ClickException( + "this KB is not linked — run: vouch hub link / --url https://hub…" + ) + return link + + +def _hub_token_or_die(url: str) -> str: + token = hub_client.resolve_token(url) + if not token: + raise click.ClickException( + f"no token for {url} — run `vouch hub link` again with --token, " + "or set VOUCH_HUB_TOKEN" + ) + return token + + +@hub.command("link") +@click.argument("kb") # user/slug on the hub +@click.option("--url", required=True, help="Hub base url, e.g. https://hub.example.com") +@click.option("--token", "token_opt", default=None, help="Sync token (vhp_…); prompted if absent.") +def hub_link(kb: str, url: str, token_opt: str | None) -> None: + """Link this KB to USER/KB on a VouchHub and store the sync token.""" + if kb.count("/") != 1: + raise click.ClickException("KB must be user/slug, e.g. alice/myproj") + store = _load_store() + token = token_opt or os.environ.get("VOUCH_HUB_TOKEN") or click.prompt( + "sync token (vhp_…)", hide_input=True + ) + hub_client.save_token(url, token) + link = hub_client.HubLink(url=url, kb=kb) + hub_client.save_link(store.kb_dir, link) + _emit_json({"linked": True, "url": link.url, "kb": link.kb}) + + +@hub.command("push") +def hub_push() -> None: + """Export approved knowledge (no sessions/config) and push it to the hub.""" + store = _load_store() + link = _hub_link_or_die(store) + token = _hub_token_or_die(link.url) + try: + _emit_json(hub_client.push(store, link, token)) + except hub_client.HubConflict as e: + _emit_json({"status": "conflicts", "conflicts": e.conflicts, "error": str(e)}) + sys.exit(1) + except hub_client.HubError as e: + raise click.ClickException(str(e)) from e + + +@hub.command("pull") +@click.option( + "--on-conflict", + type=click.Choice(["skip", "overwrite"]), + default=None, + help="How to apply conflicting artifacts; default refuses and lists them.", +) +def hub_pull(on_conflict: str | None) -> None: + """Pull the hub copy and import it through this KB's gate.""" + store = _load_store() + link = _hub_link_or_die(store) + token = _hub_token_or_die(link.url) + try: + result = hub_client.pull(store, link, token, on_conflict=on_conflict) + except hub_client.HubError as e: + raise click.ClickException(str(e)) from e + _emit_json(result) + if result.get("status") == "conflicts": + sys.exit(1) + + +@hub.command("status") +def hub_status() -> None: + """Show the hub link and whether local knowledge matches the hub copy.""" + store = _load_store() + link = hub_client.load_link(store.kb_dir) + if link is None: + _emit_json({"linked": False}) + return + token = hub_client.resolve_token(link.url) + try: + _emit_json(hub_client.status(store, link, token)) + except hub_client.HubError as e: + raise click.ClickException(str(e)) from e + + # --- export / import ------------------------------------------------------ diff --git a/tests/test_hub_client.py b/tests/test_hub_client.py index eb750ee4..7807c9bc 100644 --- a/tests/test_hub_client.py +++ b/tests/test_hub_client.py @@ -238,6 +238,68 @@ def test_pull_overwrite_applies_conflicts(store: KBStore, fake_hub: str, home: P assert store.get_claim("r1").text == "from the hub" +# --- cli ------------------------------------------------------------------------ + + +def test_cli_link_push_status_pull( + store: KBStore, fake_hub: str, home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + monkeypatch.chdir(store.kb_dir.parent) + runner = CliRunner() + + r = runner.invoke(cli, ["hub", "link", "alice/proj", "--url", fake_hub, "--token", "vhp_test"]) + assert r.exit_code == 0, r.output + assert json.loads(r.output)["linked"] is True + + r = runner.invoke(cli, ["hub", "push"]) + assert r.exit_code == 0, r.output + assert json.loads(r.output)["status"] in {"pushed", "up_to_date"} + + r = runner.invoke(cli, ["hub", "status"]) + assert r.exit_code == 0, r.output + assert json.loads(r.output)["linked"] is True + + FakeHub.files = _remote_claim() + r = runner.invoke(cli, ["hub", "pull"]) + assert r.exit_code == 0, r.output + assert json.loads(r.output)["status"] == "applied" + + +def test_cli_unlinked_errors_clearly( + store: KBStore, home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + monkeypatch.chdir(store.kb_dir.parent) + r = CliRunner().invoke(cli, ["hub", "push"]) + assert r.exit_code != 0 + assert "vouch hub link" in r.output + + +def test_cli_pull_conflict_exits_nonzero( + store: KBStore, fake_hub: str, home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + FakeHub.files = _remote_claim() + src = store.put_source(b"local evidence") + store.put_claim(Claim(id="r1", text="local version", evidence=[src.id])) + monkeypatch.chdir(store.kb_dir.parent) + runner = CliRunner() + runner.invoke(cli, ["hub", "link", "alice/proj", "--url", fake_hub, "--token", "vhp_test"]) + r = runner.invoke(cli, ["hub", "pull"]) + assert r.exit_code == 1 + assert "conflicts" in r.output + + # --- status --------------------------------------------------------------------- From 3ecde106d678e3923ff454027bc9aeb3b7d267a3 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:55:47 +0900 Subject: [PATCH 06/63] feat(hub): personal catch-all kb, fallback capture, vouch adopt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase 3 of global vouch. `vouch hub init-personal` creates + registers a personal kb at ~/.local/share/vouch/personal (XDG_DATA_HOME honoured, VOUCH_PERSONAL_KB overrides). with its own config's personal.fallback_capture flag on (opt-in: one question on a terminal at `install-mcp --global`, or --personal-fallback / `vouch hub fallback on`), sessions in folders WITHOUT a project kb capture into it — origin folder stamped on every captured source — and recall reads it back from the same folders. the session banner announces the routing; a guard refusal (discovery landing on a personal kb from below, the hijack shape) never falls through to fallback. `vouch adopt`, run inside a project that now has its own kb, drains those captures home THROUGH the project's review gate: sources copy byte-identically (content-addressed ids survive), each live personal claim re-proposes against the copied source, receipts re-verify mechanically, and the project's own review config decides durability. idempotent; --retire archives the personal copies; both kbs log a kb.adopt event carrying the other side's id. --- src/vouch/adopt.py | 309 ++++++++++++++++++++++++++++++ src/vouch/capture.py | 16 +- src/vouch/cli.py | 346 +++++++++++++++++++++++++++++++--- src/vouch/hub.py | 208 ++++++++++++++++++++ tests/test_adopt.py | 272 ++++++++++++++++++++++++++ tests/test_capture.py | 149 +++++++++++++++ tests/test_hub.py | 184 ++++++++++++++++++ tests/test_install_adapter.py | 86 +++++++++ 8 files changed, 1537 insertions(+), 33 deletions(-) create mode 100644 src/vouch/adopt.py create mode 100644 tests/test_adopt.py diff --git a/src/vouch/adopt.py b/src/vouch/adopt.py new file mode 100644 index 00000000..7ee4e23a --- /dev/null +++ b/src/vouch/adopt.py @@ -0,0 +1,309 @@ +"""Drain personal-KB fallback captures into a project's own KB. + +A machine-wide install plus an opted-in personal KB means sessions in +folders without a project ``.vouch/`` still capture — into the personal +catch-all, with the folder they came from stamped on each captured source +(``metadata.origin_path``). ``vouch adopt``, run inside a project that now +HAS a KB, finds those strays and moves the knowledge home. + +Through the gate, never around it: sources are copied byte-identically +(content addressing keeps their ids stable across KBs), each live personal +claim citing them is RE-PROPOSED against the copied source, its byte-offset +receipt re-verifies mechanically, and the project KB's own review config +decides durability exactly as it would for a fresh capture — auto-approve +on receipt where enabled, pending for a human otherwise. Claims without a +verifying receipt are proposed citing the copied source and always wait for +a human. + +The personal copies stay put by default (audit history is append-only and +the personal KB's history of "what I learned where" has value of its own); +``retire=True`` archives the adopted claims there so they stop surfacing in +personal recall. Both KBs log a ``kb.adopt`` audit event carrying the other +side's id — the cross-KB attribution the instance identity exists for. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from . import audit as audit_mod +from . import lifecycle +from . import proposals as proposals_mod +from .models import Claim, ClaimStatus, Evidence, ProposalStatus, Source +from .storage import ArtifactNotFoundError, KBStore + +ADOPT_ACTOR = "vouch-adopt" + +# Claim statuses that never travel: superseded/archived knowledge was +# retired on purpose, redacted knowledge must not propagate. +_DEAD_STATUSES = frozenset( + {ClaimStatus.SUPERSEDED, ClaimStatus.ARCHIVED, ClaimStatus.REDACTED} +) + + +@dataclass +class AdoptReport: + """What one adopt pass did (or, under dry_run, would do).""" + + origin: str + from_kb: str | None + to_kb: str | None + dry_run: bool + sources: list[str] = field(default_factory=list) + claims_durable: list[str] = field(default_factory=list) + claims_pending: list[str] = field(default_factory=list) + claims_skipped: list[str] = field(default_factory=list) + retired: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, object]: + return { + "origin": self.origin, + "from_kb": self.from_kb, + "to_kb": self.to_kb, + "dry_run": self.dry_run, + "sources": self.sources, + "claims_durable": self.claims_durable, + "claims_pending": self.claims_pending, + "claims_skipped": self.claims_skipped, + "retired": self.retired, + } + + +def _origin_matches(origin_path: str, match_root: Path) -> bool: + """True when the capture's recorded origin folder is match_root or below.""" + try: + origin = Path(origin_path).resolve() + except OSError: + return False + return origin == match_root or match_root in origin.parents + + +def find_adoptable_sources(personal: KBStore, match_root: Path) -> list[Source]: + """Personal-KB sources captured in ``match_root`` (or a subfolder).""" + root = match_root.resolve() + out: list[Source] = [] + for src in personal.list_sources(): + origin_path = src.metadata.get("origin_path") + if isinstance(origin_path, str) and origin_path and _origin_matches( + origin_path, root + ): + out.append(src) + return out + + +def _claims_citing( + personal: KBStore, source_ids: set[str] +) -> list[tuple[Claim, Evidence | None]]: + """Live personal claims citing any of ``source_ids``, with a receipt if any. + + A claim cites a source either directly (a bare source id in evidence) or + through a receipt-carrying Evidence whose ``source_id`` points there. The + first evidence with a verifiable quote wins as the receipt to re-propose + with; a claim with only bare citations travels receipt-less (pending). + """ + pairs: list[tuple[Claim, Evidence | None]] = [] + for claim in personal.list_claims(): + if claim.status in _DEAD_STATUSES: + continue + receipt: Evidence | None = None + cited = False + for eid in claim.evidence: + if eid in source_ids: + cited = True + continue + try: + ev = personal.get_evidence(eid) + except ArtifactNotFoundError: + continue + if ev.source_id in source_ids: + cited = True + if receipt is None and ev.quote: + receipt = ev + if cited: + pairs.append((claim, receipt)) + return pairs + + +def adopt( + project: KBStore, + personal: KBStore, + *, + match_root: Path, + actor: str = ADOPT_ACTOR, + retire: bool = False, + dry_run: bool = False, +) -> AdoptReport: + """One adopt pass: copy matching sources, re-propose their live claims. + + Idempotent: sources are content-addressed (a re-copy is a no-op), claims + whose id is already durable in the project KB are skipped up front, and a + re-proposal that decodes to an identical durable claim is mechanically + rejected by the receipt resolver. ``dry_run`` reports without writing. + """ + root = match_root.resolve() + personal_identity = personal.identity() + project_identity = project.identity() + report = AdoptReport( + origin=str(root), + from_kb=personal_identity[0] if personal_identity else None, + to_kb=project_identity[0] if project_identity else None, + dry_run=dry_run, + ) + sources = find_adoptable_sources(personal, root) + if not sources: + return report + source_ids = {s.id for s in sources} + pairs = _claims_citing(personal, source_ids) + + if dry_run: + report.sources = sorted( + sid for sid in source_ids if not _source_exists(project, sid) + ) + for claim, receipt in pairs: + if _already_durable(project, claim): + report.claims_skipped.append(claim.id) + elif receipt is not None: + report.claims_durable.append(claim.id) # candidate, gate decides + else: + report.claims_pending.append(claim.id) + return report + + for src in sources: + if _source_exists(project, src.id): + continue # content-addressed: already here from a prior pass + content = personal.read_source_content(src.id) + project.put_source( + content, + title=src.title, + source_type=str(src.type), + media_type=src.media_type, + tags=_with_tag(src.tags, "adopted"), + metadata={ + **src.metadata, + "adopted_from": report.from_kb, + }, + # The project's own stamp, not the personal KB's: from here on + # this knowledge belongs to this project. + scope=proposals_mod.default_scope(project), + ) + report.sources.append(src.id) + + adopted_claim_ids: list[str] = [] + for claim, receipt in pairs: + if _already_durable(project, claim): + report.claims_skipped.append(claim.id) + continue + rationale = ( + f"adopted from personal KB {report.from_kb or '(no id)'} — " + f"captured in {root}" + ) + if receipt is not None and receipt.quote: + result = proposals_mod.propose_quoted_claim( + project, + text=claim.text, + source_id=receipt.source_id, + quote=receipt.quote, + proposed_by=actor, + claim_type=str(claim.type), + confidence=claim.confidence, + tags=_with_tag(claim.tags, "adopted"), + rationale=rationale, + slug_hint=claim.id, + ) + if result is None: + # The quote no longer locates in the copied bytes — should be + # impossible (same bytes), but never adopt what cannot verify. + report.claims_skipped.append(claim.id) + continue + durable = proposals_mod.resolve_pending_receipt_claim( + project, + result.proposal, + actor=actor, + reason="adopted from personal KB (receipt re-verified)", + ) + if durable is not None: + report.claims_durable.append(durable.id) + adopted_claim_ids.append(claim.id) + else: + try: + filed = project.get_proposal(result.proposal.id) + except ArtifactNotFoundError: + filed = None + if filed is not None and filed.status == ProposalStatus.PENDING: + report.claims_pending.append(claim.id) + adopted_claim_ids.append(claim.id) + else: + # Rejected as a duplicate of an already-durable claim. + report.claims_skipped.append(claim.id) + else: + evidence = [eid for eid in claim.evidence if eid in source_ids] + if not evidence: + report.claims_skipped.append(claim.id) + continue + proposals_mod.propose_claim( + project, + text=claim.text, + evidence=evidence, + proposed_by=actor, + claim_type=str(claim.type), + confidence=claim.confidence, + tags=_with_tag(claim.tags, "adopted"), + rationale=rationale, + slug_hint=claim.id, + ) + report.claims_pending.append(claim.id) + adopted_claim_ids.append(claim.id) + + if retire: + for claim_id in adopted_claim_ids: + try: + lifecycle.archive(personal, claim_id=claim_id, actor=actor) + except Exception: + # Retiring is best-effort tidying of the personal KB; a claim + # that cannot be archived must not fail the adoption. + continue + report.retired.append(claim_id) + + moved = bool(report.sources or report.claims_durable or report.claims_pending) + if moved: + data = { + "origin": report.origin, + "sources": len(report.sources), + "claims_durable": len(report.claims_durable), + "claims_pending": len(report.claims_pending), + "retired": len(report.retired), + } + audit_mod.log_event( + project.kb_dir, + event="kb.adopt", + actor=actor, + data={**data, "direction": "in", "from_kb": report.from_kb}, + ) + audit_mod.log_event( + personal.kb_dir, + event="kb.adopt", + actor=actor, + data={**data, "direction": "out", "to_kb": report.to_kb}, + ) + return report + + +def _already_durable(project: KBStore, claim: Claim) -> bool: + try: + project.get_claim(claim.id) + except ArtifactNotFoundError: + return False + return True + + +def _source_exists(project: KBStore, source_id: str) -> bool: + try: + project.get_source(source_id) + except ArtifactNotFoundError: + return False + return True + + +def _with_tag(tags: list[str], tag: str) -> list[str]: + return tags if tag in tags else [*tags, tag] diff --git a/src/vouch/capture.py b/src/vouch/capture.py index eb9e8c11..7b5744d8 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -486,6 +486,7 @@ def capture_answer( min_answer_chars: int = DEFAULT_MIN_ANSWER_CHARS, max_claims: int = DEFAULT_MAX_ANSWER_CLAIMS, config: CaptureConfig | None = None, + origin: Path | None = None, ) -> dict[str, Any]: """Turn a session's latest Q&A into durable, recallable knowledge. @@ -502,6 +503,12 @@ def capture_answer( skipped, and answers shorter than ``min_answer_chars`` (acknowledgements) are ignored, so a Stop hook firing every turn does not fill the KB with noise or duplicates. + + ``origin`` marks a personal-KB *fallback* capture: the session ran in a + folder with no project KB and ``store`` is the machine's personal + catch-all. The folder is recorded on the source + (``metadata.origin_path``, tag ``personal-fallback``) so `vouch adopt` + can later drain this knowledge into that folder's own KB. """ import os @@ -532,12 +539,17 @@ def capture_answer( except ArtifactNotFoundError: pass + tags = ["session-answer"] + metadata: dict[str, Any] = {"session_id": session_id, "question": question} + if origin is not None: + tags.append("personal-fallback") + metadata["origin_path"] = str(origin) source = store.put_source( content, title=question or f"session {session_id} answer", source_type="message", - tags=["session-answer"], - metadata={"session_id": session_id, "question": question}, + tags=tags, + metadata=metadata, # same stamp the extracted claims get at the propose gate: captured # knowledge records its project at write time (unretrofittable later) scope=proposals_mod.default_scope(store), diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 61af9e77..35ab069b 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -25,6 +25,7 @@ import yaml from . import __version__, bundle, health, volunteer_context +from . import adopt as adopt_mod from . import audit as audit_mod from . import capture as capture_mod from . import codex_rollout as codex_rollout_mod @@ -364,6 +365,195 @@ def hub_unregister(token: str) -> None: click.echo(f"unregistered {removed.name} ({removed.kb_id})") +def _init_personal_kb(fallback: bool | None) -> Path: + """Create + register the personal catch-all KB; shared by the two entry + points (`vouch hub init-personal` and `install-mcp --global`'s opt-in) + so they cannot drift. + + ``fallback`` None means "don't touch the flag": a fresh KB starts off + (capture into it stays opt-in), an existing KB keeps whatever it says. + """ + root = hub_mod.personal_kb_root() + if root is None: + raise click.ClickException( + "cannot determine a home directory for the personal KB — " + "set VOUCH_PERSONAL_KB to a writable folder" + ) + created = not (root / ".vouch").is_dir() + if created: + with _cli_errors(): + _bootstrap_kb(root) + click.echo(f"Initialised personal KB at {root / '.vouch'}") + else: + click.echo(f"Personal KB already present at {root / '.vouch'}") + with _cli_errors(): + entry = hub_mod.register_kb( + root, role="personal", name="personal", actor=_whoami() + ) + click.echo(f"Registered in the machine registry: {entry.name} ({entry.kb_id})") + if fallback is not None: + try: + hub_mod.set_personal_fallback(root, fallback) + except (OSError, ValueError) as e: + raise click.ClickException(str(e)) from e + enabled = hub_mod.personal_fallback_enabled(root) + if enabled: + click.echo( + "Fallback capture: on — sessions in folders WITHOUT a project KB " + "capture here; `vouch init` + `vouch adopt` moves that knowledge " + "into a project later." + ) + else: + click.echo( + "Fallback capture: off — folders without a project KB capture " + "nowhere (enable with `vouch hub fallback on`)." + ) + return root + + +@hub.command("init-personal") +@click.option( + "--fallback/--no-fallback", + "fallback", + default=None, + help="Also opt in (or out of) capturing KB-less folders' sessions into " + "this KB. Without the flag: a prompt on a terminal, otherwise off.", +) +def hub_init_personal(fallback: bool | None) -> None: + """Create + register this machine's personal catch-all KB. + + Lives at ~/.local/share/vouch/personal (XDG_DATA_HOME honoured; + override with VOUCH_PERSONAL_KB). Idempotent: re-running refreshes the + registry row and leaves the fallback flag alone unless you pass one. + """ + created = True + root = hub_mod.personal_kb_root() + if root is not None and (root / ".vouch").is_dir(): + created = False + if fallback is None and created and sys.stdin.isatty(): + fallback = click.confirm( + "Capture sessions in folders WITHOUT a project KB into this " + "personal KB? (you can adopt that knowledge into a project later)", + default=False, + ) + _init_personal_kb(fallback) + + +@hub.command("fallback") +@click.argument( + "state", required=False, type=click.Choice(["on", "off", "status"]) +) +def hub_fallback(state: str | None) -> None: + """Show or flip fallback capture on the registered personal KB.""" + entry = hub_mod.personal_entry() + if entry is None: + raise click.ClickException( + "no personal KB registered — create one with `vouch hub init-personal`" + ) + root = Path(entry.path) + if not (root / ".vouch").is_dir(): + raise click.ClickException( + f"registered personal KB at {root} has no .vouch/ — re-run " + "`vouch hub init-personal` (or `vouch hub unregister` the stale row)" + ) + if state in (None, "status"): + enabled = hub_mod.personal_fallback_enabled(root) + click.echo(f"fallback capture: {'on' if enabled else 'off'} ({root})") + return + try: + hub_mod.set_personal_fallback(root, state == "on") + except (OSError, ValueError) as e: + raise click.ClickException(str(e)) from e + click.echo(f"fallback capture: {state} ({root})") + + +@cli.command() +@click.option( + "--from-path", + "from_path", + default=None, + type=click.Path(file_okay=False), + help="Adopt captures whose origin is this folder instead of the project " + "root (for a project that moved since its sessions were captured).", +) +@click.option( + "--retire", + is_flag=True, + help="Archive the adopted claims in the personal KB so they stop " + "surfacing in personal recall (sources and audit history stay).", +) +@click.option("--dry-run", is_flag=True, help="Report what would move; write nothing.") +@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON.") +def adopt( + from_path: str | None, retire: bool, dry_run: bool, as_json: bool +) -> None: + """Bring this folder's personal-KB captures into the project KB. + + Sessions that ran here before `vouch init` (under a --global install + with the personal fallback on) captured into the machine's personal + KB, stamped with this folder as their origin. Adopt copies those + sources in and re-proposes their claims through THIS KB's own review + gate — receipts re-verify mechanically, so under the starter config + they land durable; under a human-only gate they land pending. + """ + store = _load_store() + entry = hub_mod.personal_entry() + if entry is None: + raise click.ClickException( + "no personal KB registered on this machine — nothing to adopt " + "(see `vouch hub init-personal`)" + ) + personal_root = Path(entry.path) + if not (personal_root / ".vouch").is_dir(): + raise click.ClickException( + f"registered personal KB at {personal_root} has no .vouch/ — " + "re-run `vouch hub init-personal`" + ) + personal = KBStore(personal_root) + if personal.kb_dir.resolve() == store.kb_dir.resolve(): + raise click.ClickException( + "this folder IS the personal KB — adopt runs inside a project " + "with its own `.vouch/`" + ) + match_root = Path(from_path).resolve() if from_path else store.root.resolve() + with _cli_errors(): + report = adopt_mod.adopt( + store, + personal, + match_root=match_root, + actor=_whoami(), + retire=retire, + dry_run=dry_run, + ) + if as_json: + _emit_json(report.as_dict()) + return + verb = "would adopt" if dry_run else "adopted" + if not ( + report.sources + or report.claims_durable + or report.claims_pending + or report.claims_skipped + ): + click.echo( + f"nothing to adopt: no personal-KB captures originate under " + f"{match_root} (personal KB: {personal_root})" + ) + return + click.echo( + f"{verb} from personal KB {entry.name} ({entry.kb_id[:8]}…), " + f"origin {match_root}:" + ) + click.echo(f" sources copied: {len(report.sources)}") + click.echo(f" claims durable: {len(report.claims_durable)}") + click.echo(f" claims pending: {len(report.claims_pending)}") + click.echo(f" claims skipped: {len(report.claims_skipped)} (already here)") + if retire: + click.echo(f" retired (personal): {len(report.retired)}") + if report.claims_pending and not dry_run: + click.echo("review the pending ones with `vouch review`.") + + @cli.command() def capabilities() -> None: """Emit the JSON capabilities descriptor (mirrors kb.capabilities).""" @@ -2452,14 +2642,15 @@ def capture() -> None: """Automatic session capture (driven by claude code hooks).""" -def _capture_store(start: Path | None = None) -> KBStore | None: - """Locate the KB without the sys.exit(2) that _load_store does — hooks - must never abort the host. +def _capture_target(start: Path | None = None) -> hub_mod.CaptureTarget: + """Locate the capture target without the sys.exit(2) that _load_store + does — hooks must never abort the host. - Uses the hub resolver so ambient capture also respects the registry - guard: a KB registered as personal-role never absorbs another - directory's session (the write-plane half of the ~/.vouch hijack fix; - the $HOME walk-stop in discover_root is the structural half). + Uses the hub resolver so ambient capture respects the registry guard + (a personal-role KB never absorbs another directory's session via + discovery) AND the personal fallback: a folder with no project KB + captures into the opted-in personal catch-all, origin recorded, or + nowhere at all. ``start`` pins the resolution to the host-reported project directory (the hook payload's ``cwd``) — under a machine-wide install the hook @@ -2467,15 +2658,21 @@ def _capture_store(start: Path | None = None) -> KBStore | None: cwd, is what names the session's project. """ try: - res = hub_mod.resolve(start) + target = hub_mod.capture_target(start) except Exception: - return None - if res.root is None: - return None - if res.guard is not None: - click.echo(f"vouch: {res.guard}", err=True) - return None - return KBStore(res.root) + return hub_mod.CaptureTarget(store=None) + if target.store is None and target.note: + # A guard refusal must be loud; quiet fallback routing is announced + # once per session by `capture banner`, not per hook invocation. + click.echo(f"vouch: {target.note}", err=True) + return target + + +def _capture_store(start: Path | None = None) -> KBStore | None: + """The store half of :func:`_capture_target` for callers that don't + need the fallback origin (buffers, summaries — origin rides on the + captured *sources*, stamped in `capture answer`).""" + return _capture_target(start).store def _read_hook_payload() -> dict[str, Any]: @@ -2611,10 +2808,13 @@ def capture_answer_cmd(session_id: str | None) -> None: start, ok = _hook_start(payload) if not ok: return - store = _capture_store(start) - if store is None: + target = _capture_target(start) + if target.store is None: return - result = capture_mod.capture_answer(store, sid, Path(str(transcript_raw))) + result = capture_mod.capture_answer( + target.store, sid, Path(str(transcript_raw)), + origin=target.origin if target.fallback else None, + ) _emit_json(result) except Exception: # a capture failure must never break the user's turn. @@ -2732,7 +2932,8 @@ def capture_banner_cmd() -> None: """Emit a SessionStart nudge if captured summaries await review.""" payload = _read_hook_payload() start, _ok = _hook_start(payload) - store = _capture_store(start) + target = _capture_target(start) + store = target.store if store is None: # SessionStart stdout becomes session context — this is the one # channel where the promised "run `vouch init`" hint is actually @@ -2742,6 +2943,14 @@ def capture_banner_cmd() -> None: "`vouch init` here to enable durable memory." ) return + if target.fallback: + # Sessions must never capture somewhere the user can't see: this + # line is the per-session announcement of the personal fallback. + click.echo( + "vouch: no project KB in this folder — this session captures to " + f"your personal KB ({store.root}). Run `vouch init` here, then " + "`vouch adopt`, to move this folder's knowledge into its own KB." + ) n = capture_mod.pending_count(store) if n: click.echo( @@ -2754,12 +2963,14 @@ def capture_banner_cmd() -> None: def recall_cmd() -> None: """Emit a digest of all approved knowledge for session-start injection.""" # Read plane: like context-hook, the digest warns under the personal-role - # guard instead of going dark. Resolution starts at the hook payload's - # cwd when given (machine-wide installs run this from anywhere). + # guard instead of going dark, and follows capture into the personal + # fallback (a KB-less folder that captures personally recalls personally). + # Resolution starts at the hook payload's cwd when given (machine-wide + # installs run this from anywhere). payload = _read_hook_payload() start, _ok = _hook_start(payload) try: - store, warning = hub_mod.resolve_for_read(start) + store, warning, _fallback = hub_mod.read_target(start) except Exception: store, warning = None, None if warning: @@ -3054,9 +3265,11 @@ def context_hook() -> None: stdin_text = sys.stdin.read() # Read plane: recall must survive the personal-role guard (warn, don't # go dark) — a silent context-hook is indistinguishable from vouch - # being broken. Capture commands stay on the refusing _capture_store. - # Resolution starts at the payload's cwd when given: under a global - # install the hook process cwd is not guaranteed to be the project. + # being broken — and follows capture into the personal fallback so a + # KB-less folder recalls the knowledge it captures. Capture commands + # stay on the refusing _capture_target. Resolution starts at the + # payload's cwd when given: under a global install the hook process + # cwd is not guaranteed to be the project. start: Path | None = None try: loaded = json.loads(stdin_text) if stdin_text.strip() else {} @@ -3065,7 +3278,7 @@ def context_hook() -> None: except json.JSONDecodeError: start = None try: - store, warning = hub_mod.resolve_for_read(start) + store, warning, _fallback = hub_mod.read_target(start) except Exception: store, warning = None, None if warning: @@ -4141,12 +4354,64 @@ def pr_cache_show(repo: str, state: str, limit: int, as_json: bool, cache_dir: s # --- install-mcp: drop the right adapter files into a project tree -------- -def _install_mcp_global(host: str, *, tier: str, approve: bool) -> None: +def _offer_personal_fallback(personal_fallback: bool | None) -> None: + """The one-question opt-in after a --global install. + + An already-registered personal KB is reported (and re-flagged only when + a flag was passed explicitly). Otherwise: an explicit flag decides; a + terminal gets ONE question (default no); non-interactive installs stay + off with a hint. Failures here never fail the install — the global + wiring already landed. + """ + entry = hub_mod.personal_entry() + if entry is not None: + root = Path(entry.path) + if personal_fallback is not None: + try: + hub_mod.set_personal_fallback(root, personal_fallback) + except (OSError, ValueError) as e: + click.echo(f"warning: could not update fallback flag: {e}", err=True) + state = "on" if hub_mod.personal_fallback_enabled(root) else "off" + click.echo(f"Personal KB: {entry.path} (fallback capture {state})") + return + wanted = personal_fallback + if wanted is None: + if sys.stdin.isatty(): + wanted = click.confirm( + "Folders without a project KB currently don't capture at all. " + "Create a personal catch-all KB so they do? (`vouch adopt` " + "moves that knowledge into a project later)", + default=False, + ) + else: + click.echo( + "Optional: `vouch hub init-personal --fallback` adds a " + "personal catch-all KB for folders without a project KB." + ) + return + if not wanted: + return + try: + _init_personal_kb(True) + except click.ClickException as e: + click.echo( + f"warning: could not set up the personal KB: {e.message} — " + "the global install itself is complete; retry with " + "`vouch hub init-personal --fallback`.", + err=True, + ) + + +def _install_mcp_global( + host: str, *, tier: str, approve: bool, personal_fallback: bool | None = None +) -> None: """The --global path: user-level wiring once, no project target at all. - Deliberately no KB bootstrap — there is no project here. Each session - resolves its own project's `.vouch` (run `vouch init` once per project); - a folder without one no-ops with a hint instead of capturing anywhere. + Deliberately no project-KB bootstrap — there is no project here. Each + session resolves its own project's `.vouch` (run `vouch init` once per + project); a folder without one no-ops with a hint — or, after the + one-question personal opt-in below, captures into the personal + catch-all KB instead. """ try: result, target = install_mod.install_global(host, tier=tier, approve=approve) @@ -4188,6 +4453,7 @@ def _install_mcp_global(host: str, *, tier: str, approve: bool) -> None: f"{len(result.failed)} file(s) could not be installed: " + ", ".join(result.failed) ) + _offer_personal_fallback(personal_fallback) @cli.command(name="install-mcp", context_settings={"ignore_unknown_options": False}) @@ -4244,6 +4510,15 @@ def _install_mcp_global(host: str, *, tier: str, approve: bool) -> None: "(run `vouch init` once per project). Coexists safely with per-project " "installs.", ) +@click.option( + "--personal-fallback/--no-personal-fallback", + "personal_fallback", + default=None, + help="With --global: also create the personal catch-all KB so folders " + "without a project KB capture into it (adopt into a project later with " + "`vouch adopt`). Without either flag: one question on a terminal, " + "otherwise off.", +) def install_mcp( host: str | None, list_hosts: bool, @@ -4253,6 +4528,7 @@ def install_mcp( auto_init: bool, approve: bool, global_install: bool, + personal_fallback: bool | None, ) -> None: """Install vouch into HOST (claude-code, cursor, …) idempotently. @@ -4289,9 +4565,17 @@ def install_mcp( "--global is machine-wide; --path/--target do not apply " "(each project's KB comes from `vouch init` in that project)" ) - _install_mcp_global(host, tier=tier, approve=approve) + _install_mcp_global( + host, tier=tier, approve=approve, personal_fallback=personal_fallback + ) return + if personal_fallback is not None: + raise click.UsageError( + "--personal-fallback/--no-personal-fallback only applies with " + "--global (per-project installs capture into the project KB)" + ) + target = Path(target_alias or path).resolve() if host in hosts and install_mod.wants_kb_bootstrap(host): # a wired host without a KB is worse than a failed install: every diff --git a/src/vouch/hub.py b/src/vouch/hub.py index 7b64936d..7fdc6a7b 100644 --- a/src/vouch/hub.py +++ b/src/vouch/hub.py @@ -23,6 +23,7 @@ import contextlib import os +import re import tempfile from collections.abc import Iterator from dataclasses import dataclass @@ -36,6 +37,7 @@ from .storage import KB_DIRNAME, KBNotFoundError, KBStore, discover_root REGISTRY_ENV = "VOUCH_REGISTRY_PATH" +PERSONAL_KB_ENV = "VOUCH_PERSONAL_KB" REGISTRY_VERSION = 1 ROLES = ("project", "personal", "team") @@ -315,6 +317,212 @@ def resolve_for_capture(start: Path | None = None) -> KBStore | None: return KBStore(res.root) +# --- the personal catch-all KB (phase 3) ---------------------------------- + + +def personal_kb_root() -> Path | None: + """Default home of the personal catch-all KB. Never auto-created. + + ``VOUCH_PERSONAL_KB`` > ``$XDG_DATA_HOME/vouch/personal`` > + ``~/.local/share/vouch/personal``. Data path, not config path: the + personal KB is content (claims, sources, an audit log), the registry + row pointing at it is config. None when no home can be determined + (containers) — everything personal degrades to off. + """ + forced = os.environ.get(PERSONAL_KB_ENV) + if forced: + return Path(forced).expanduser() + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return Path(xdg) / "vouch" / "personal" + try: + home = Path.home() + except RuntimeError: + return None + return home / ".local" / "share" / "vouch" / "personal" + + +def personal_entry(*, path: Path | None = None) -> RegistryEntry | None: + """The registry's personal-role row (first match), or None.""" + for e in load_registry(path): + if e.role == "personal": + return e + return None + + +def personal_fallback_enabled(root: Path) -> bool: + """The personal KB's own opt-in: config ``personal.fallback_capture``. + + Authority lives in the KB's own config, not the registry — the registry + only says "a personal KB exists here"; whether KB-less folders may + capture into it is that KB's own setting. Defensive read: a missing or + corrupt config means off. + """ + cfg = root / KB_DIRNAME / "config.yaml" + try: + loaded = yaml.safe_load(cfg.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return False + if not isinstance(loaded, dict): + return False + personal = loaded.get("personal") + return isinstance(personal, dict) and personal.get("fallback_capture") is True + + +def set_personal_fallback(root: Path, enabled: bool) -> None: + """Flip ``personal.fallback_capture`` in the KB's config. + + Textual edits where possible (mirroring ``KBStore._mint_identity``) so + hand-written comments survive; a config that is not a yaml mapping is + refused untouched. + """ + cfg_path = root / KB_DIRNAME / "config.yaml" + text = cfg_path.read_text(encoding="utf-8") + try: + loaded = yaml.safe_load(text) + except yaml.YAMLError as e: + raise ValueError(f"{cfg_path} is not valid yaml — fix it by hand") from e + if loaded is None: + loaded = {} + if not isinstance(loaded, dict): + raise ValueError(f"{cfg_path} must be a yaml mapping") + value = "true" if enabled else "false" + personal = loaded.get("personal") + if "personal" not in loaded: + # No block yet: append one, preserving the rest of the file + # byte-for-byte. + block = ( + "\n# machine-personal catch-all settings (vouch hub init-personal)\n" + f"personal:\n fallback_capture: {value}\n" + ) + cfg_path.write_text(text.rstrip("\n") + "\n" + block, encoding="utf-8") + return + if isinstance(personal, dict) and "fallback_capture" in personal: + new_text, n = re.subn( + r"(?m)^(\s*fallback_capture:\s*).*$", + rf"\g<1>{value}", + text, + count=1, + ) + if n == 1: + cfg_path.write_text(new_text, encoding="utf-8") + return + elif isinstance(personal, dict): + new_text, n = re.subn( + r"(?m)^personal:[ \t]*$", + f"personal:\n fallback_capture: {value}", + text, + count=1, + ) + if n == 1: + cfg_path.write_text(new_text, encoding="utf-8") + return + # A non-mapping `personal:` stray, or inline/flow style the regexes + # can't see — structural rewrite. + loaded["personal"] = ( + {**personal, "fallback_capture": enabled} + if isinstance(personal, dict) + else {"fallback_capture": enabled} + ) + cfg_path.write_text( + yaml.safe_dump(loaded, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + +def personal_fallback_store(*, path: Path | None = None) -> KBStore | None: + """The opted-in personal KB, or None. + + Both belts must agree: the registry names a personal-role KB AND that + KB's own config carries ``personal.fallback_capture: true``. Anything + missing or corrupt along the way degrades to None — fallback capture + fails off, never open. + """ + entry = personal_entry(path=path) + if entry is None: + return None + root = Path(entry.path) + if not (root / KB_DIRNAME).is_dir(): + return None + if not personal_fallback_enabled(root): + return None + return KBStore(root) + + +@dataclass(frozen=True) +class CaptureTarget: + """Where a session's capture goes, and why.""" + + store: KBStore | None + # True => writing to the personal catch-all, not a project KB. + fallback: bool = False + # The KB-less folder the session ran in — stamped onto fallback captures + # so `vouch adopt` can drain them into that folder's KB later. + origin: Path | None = None + # Guard/refusal or routing text for the caller's stderr. + note: str | None = None + + +def _capture_origin(start: Path | None) -> Path: + """The folder a fallback capture is *about* (mirrors resolve()'s start).""" + origin = start + if origin is None and os.environ.get("VOUCH_PROJECT_DIR"): + candidate = Path(os.environ["VOUCH_PROJECT_DIR"]) + if candidate.is_dir(): + origin = candidate + if origin is None: + origin = Path.cwd() + return origin.resolve() + + +def capture_target(start: Path | None = None) -> CaptureTarget: + """Write-plane resolution with the personal-KB fallback. + + Project KB first, exactly as before. When no KB is discoverable AND an + opted-in personal KB is registered, capture routes there — deliberately, + via the registry plus the KB's own config flag, never via ambient + discovery — with the origin folder recorded for `vouch adopt`. A + personal-role guard refusal stays a refusal: the guard fires when + discovery lands on a personal KB from below (the hijack shape), which is + not the fallback shape. + """ + res = resolve(start) + if res.root is not None and res.guard is None: + return CaptureTarget(store=KBStore(res.root)) + if res.guard is not None: + return CaptureTarget(store=None, note=res.guard) + fb = personal_fallback_store() + if fb is None: + return CaptureTarget(store=None) + origin = _capture_origin(start) + return CaptureTarget( + store=fb, + fallback=True, + origin=origin, + note=( + f"no project KB at {origin} — capturing to the personal KB at " + f"{fb.root} (adopt later with `vouch init` + `vouch adopt`)" + ), + ) + + +def read_target(start: Path | None = None) -> tuple[KBStore | None, str | None, bool]: + """(store, warning, fallback) for read surfaces. + + The read-plane twin of ``capture_target``, so recall follows capture: a + session whose knowledge lands in the personal KB must be able to read it + back from the same folder. The fallback is reported via the warning + channel — reads reroute loudly, never silently. + """ + store, warning = resolve_for_read(start) + if store is not None: + return store, warning, False + fb = personal_fallback_store() + if fb is None: + return None, warning, False + return fb, f"no project KB here — reading the personal KB at {fb.root}", True + + def resolve_for_read(start: Path | None = None) -> tuple[KBStore | None, str | None]: """The read-plane resolver: (store, warning). diff --git a/tests/test_adopt.py b/tests/test_adopt.py new file mode 100644 index 00000000..29676bf2 --- /dev/null +++ b/tests/test_adopt.py @@ -0,0 +1,272 @@ +"""`vouch adopt` — draining personal-KB fallback captures into a project KB.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from vouch import adopt as adopt_mod +from vouch import audit, capture, hub +from vouch.cli import cli +from vouch.models import ClaimStatus, ProposalStatus +from vouch.storage import KBStore + + +@pytest.fixture(autouse=True) +def _isolated_machine(tmp_path_factory, monkeypatch): + """Fake $HOME + registry so tests never touch the real machine.""" + fake_home = tmp_path_factory.mktemp("home") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + monkeypatch.setenv(hub.REGISTRY_ENV, str(fake_home / "registry.yaml")) + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv(hub.PERSONAL_KB_ENV, raising=False) + return fake_home + + +ANSWER = ( + "The deploy cadence for this service is every second Tuesday. " + "The staging environment refreshes nightly at 02:00 UTC. " + "Rollbacks use the blue-green switch, never a re-deploy of an old tag." +) + + +@pytest.fixture() +def personal(tmp_path: Path) -> KBStore: + root = hub.personal_kb_root() + assert root is not None + store = KBStore.init(root) + hub.register_kb(root, role="personal", actor="t") + hub.set_personal_fallback(root, True) + return store + + +def _fallback_capture(personal: KBStore, origin: Path, tmp_path: Path) -> dict: + """One captured session answer in ``origin``, routed to the personal KB.""" + transcript = tmp_path / f"transcript-{origin.name}.jsonl" + lines = [ + {"type": "user", "message": {"role": "user", "content": [ + {"type": "text", "text": "what is the deploy cadence?"}]}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "text", "text": ANSWER}]}}, + ] + transcript.write_text( + "\n".join(json.dumps(entry) for entry in lines), encoding="utf-8" + ) + result = capture.capture_answer( + personal, f"s-{origin.name}", transcript, origin=origin, + ) + assert result["captured"] is True + return result + + +def test_fallback_capture_stamps_origin(personal: KBStore, tmp_path: Path) -> None: + origin = tmp_path / "projA" + origin.mkdir() + result = _fallback_capture(personal, origin, tmp_path) + src = personal.get_source(result["source"]) + assert src.metadata["origin_path"] == str(origin) + assert "personal-fallback" in src.tags + # a normal (non-fallback) capture carries no origin + assert result["approved"] >= 1 + + +def test_adopt_moves_claims_through_the_gate( + personal: KBStore, tmp_path: Path +) -> None: + origin = tmp_path / "projA" + origin.mkdir() + captured = _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + + report = adopt_mod.adopt(project, personal, match_root=origin) + assert report.sources == [captured["source"]] + assert len(report.claims_durable) == captured["approved"] + assert report.claims_pending == [] + # durable in the project KB, stamped with the project's own scope + for claim_id in report.claims_durable: + claim = project.get_claim(claim_id) + assert claim.scope.project == project.identity()[0] # type: ignore[index] + assert "adopted" in claim.tags + # both audit logs record the adoption with the other side's id + proj_events = [e for e in audit.read_events(project.kb_dir) if e.event == "kb.adopt"] + per_events = [e for e in audit.read_events(personal.kb_dir) if e.event == "kb.adopt"] + assert proj_events and proj_events[0].data["from_kb"] == personal.identity()[0] # type: ignore[index] + assert per_events and per_events[0].data["to_kb"] == project.identity()[0] # type: ignore[index] + + +def test_adopt_is_idempotent(personal: KBStore, tmp_path: Path) -> None: + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + first = adopt_mod.adopt(project, personal, match_root=origin) + assert first.claims_durable + again = adopt_mod.adopt(project, personal, match_root=origin) + assert again.sources == [] + assert again.claims_durable == [] + assert again.claims_pending == [] + assert sorted(again.claims_skipped) == sorted(first.claims_durable) + # a fully-skipped pass adds no second kb.adopt event + proj_events = [e for e in audit.read_events(project.kb_dir) if e.event == "kb.adopt"] + assert len(proj_events) == 1 + + +def test_adopt_respects_a_closed_gate(personal: KBStore, tmp_path: Path) -> None: + """A project whose review gate is human-only gets PENDING proposals.""" + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) + cfg["review"]["auto_approve_on_receipt"] = False + project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + report = adopt_mod.adopt(project, personal, match_root=origin) + assert report.claims_durable == [] + assert report.claims_pending + pending = project.list_proposals(ProposalStatus.PENDING) + assert {p.payload["id"] for p in pending} >= set(report.claims_pending) + + +def test_adopt_ignores_other_origins(personal: KBStore, tmp_path: Path) -> None: + origin_a = tmp_path / "projA" + origin_b = tmp_path / "projB" + origin_a.mkdir() + origin_b.mkdir() + _fallback_capture(personal, origin_b, tmp_path) + project = KBStore.init(origin_a) + report = adopt_mod.adopt(project, personal, match_root=origin_a) + assert report.sources == [] + assert report.claims_durable == [] + # the starter claim (no origin) never travels either + assert all("starter" not in cid for cid in report.claims_durable) + + +def test_adopt_skips_dead_personal_claims(personal: KBStore, tmp_path: Path) -> None: + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + # archive one personal claim before adopting — it must not travel + adoptable = [c for c in personal.list_claims() if "starter" not in c.id] + victim = adoptable[0] + from vouch import lifecycle + + lifecycle.archive(personal, claim_id=victim.id, actor="t") + project = KBStore.init(origin) + report = adopt_mod.adopt(project, personal, match_root=origin) + assert victim.id not in report.claims_durable + assert victim.id not in report.claims_pending + + +def test_adopt_retire_archives_personal_copies( + personal: KBStore, tmp_path: Path +) -> None: + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + report = adopt_mod.adopt(project, personal, match_root=origin, retire=True) + assert sorted(report.retired) == sorted(report.claims_durable) + for claim_id in report.retired: + assert personal.get_claim(claim_id).status == ClaimStatus.ARCHIVED + # the project copies stay durable + for claim_id in report.claims_durable: + assert project.get_claim(claim_id).status == ClaimStatus.WORKING + + +def test_adopt_dry_run_writes_nothing(personal: KBStore, tmp_path: Path) -> None: + origin = tmp_path / "projA" + origin.mkdir() + captured = _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + before = {c.id for c in project.list_claims()} + report = adopt_mod.adopt(project, personal, match_root=origin, dry_run=True) + assert report.sources == [captured["source"]] + assert report.claims_durable # candidates + assert {c.id for c in project.list_claims()} == before + assert not [e for e in audit.read_events(project.kb_dir) if e.event == "kb.adopt"] + + +def test_adopt_matches_subfolder_origins(personal: KBStore, tmp_path: Path) -> None: + """A session captured in a subfolder of the project still belongs to it.""" + origin = tmp_path / "projA" + sub = origin / "src" / "deep" + sub.mkdir(parents=True) + _fallback_capture(personal, sub, tmp_path) + project = KBStore.init(origin) + report = adopt_mod.adopt(project, personal, match_root=origin) + assert report.sources + assert report.claims_durable + + +# --- the CLI wrapper ------------------------------------------------------- + + +def test_cli_adopt_end_to_end(personal: KBStore, tmp_path: Path, monkeypatch) -> None: + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + KBStore.init(origin) + monkeypatch.chdir(origin) + runner = CliRunner() + r = runner.invoke(cli, ["adopt", "--dry-run"]) + assert r.exit_code == 0, r.output + assert "would adopt" in r.output + r = runner.invoke(cli, ["adopt", "--json"]) + assert r.exit_code == 0, r.output + payload = json.loads(r.output) + assert payload["claims_durable"] + r = runner.invoke(cli, ["adopt"]) + assert r.exit_code == 0, r.output + assert "skipped" in r.output + + +def test_cli_adopt_without_personal_kb(tmp_path: Path, monkeypatch) -> None: + proj = tmp_path / "proj" + KBStore.init(proj) + monkeypatch.chdir(proj) + r = CliRunner().invoke(cli, ["adopt"]) + assert r.exit_code != 0 + assert "init-personal" in r.output + + +def test_cli_adopt_refuses_inside_personal_kb( + personal: KBStore, monkeypatch +) -> None: + monkeypatch.chdir(personal.root) + r = CliRunner().invoke(cli, ["adopt"]) + assert r.exit_code != 0 + assert "IS the personal KB" in r.output + + +def test_cli_adopt_nothing_to_adopt(personal: KBStore, tmp_path: Path, monkeypatch) -> None: + proj = tmp_path / "clean" + KBStore.init(proj) + monkeypatch.chdir(proj) + r = CliRunner().invoke(cli, ["adopt"]) + assert r.exit_code == 0, r.output + assert "nothing to adopt" in r.output + + +def test_cli_adopt_from_path_override( + personal: KBStore, tmp_path: Path, monkeypatch +) -> None: + """A project that moved since capture adopts via --from-path.""" + old_home = tmp_path / "old-location" + old_home.mkdir() + _fallback_capture(personal, old_home, tmp_path) + new_home = tmp_path / "new-location" + KBStore.init(new_home) + monkeypatch.chdir(new_home) + runner = CliRunner() + r = runner.invoke(cli, ["adopt"]) + assert "nothing to adopt" in r.output + r = runner.invoke(cli, ["adopt", "--from-path", str(old_home)]) + assert r.exit_code == 0, r.output + assert "adopted" in r.output diff --git a/tests/test_capture.py b/tests/test_capture.py index ed49339e..158079d2 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -833,3 +833,152 @@ def test_capture_e2e_sessionstart_cleanup_then_finalize(tmp_path): # Total proposals: old + new assert len(pending_after) >= 2 + + +# --- personal-KB fallback capture through the hook CLI (phase 3) ----------- + + +@pytest.fixture() +def _fallback_machine(tmp_path_factory, monkeypatch): + """Fake home + registry + an opted-in personal KB; returns its store.""" + from vouch import hub + + fake_home = tmp_path_factory.mktemp("fbhome") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + monkeypatch.setenv(hub.REGISTRY_ENV, str(fake_home / "registry.yaml")) + monkeypatch.delenv("VOUCH_KB_PATH", raising=False) + monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv(hub.PERSONAL_KB_ENV, raising=False) + root = hub.personal_kb_root() + assert root is not None + personal = KBStore.init(root) + hub.register_kb(root, role="personal", actor="t") + hub.set_personal_fallback(root, True) + return personal + + +def _long_transcript(tmp_path: Path) -> Path: + transcript = tmp_path / "fb-transcript.jsonl" + answer = ( + "The deploy cadence for this service is every second Tuesday. " + "The staging environment refreshes nightly at 02:00 UTC. " + "Rollbacks use the blue-green switch, never an old tag." + ) + lines = [ + {"type": "user", "message": {"role": "user", "content": [ + {"type": "text", "text": "deploy cadence?"}]}}, + {"type": "assistant", "message": {"role": "assistant", "content": [ + {"type": "text", "text": answer}]}}, + ] + transcript.write_text( + "\n".join(_json.dumps(entry) for entry in lines), encoding="utf-8" + ) + return transcript + + +def test_answer_cli_falls_back_to_personal_kb( + _fallback_machine: KBStore, tmp_path: Path, monkeypatch +) -> None: + """A Stop hook firing in a KB-less folder captures into the personal KB, + origin recorded — the whole point of the phase 3 fallback.""" + personal = _fallback_machine + nowhere = tmp_path / "no-kb-project" + nowhere.mkdir() + monkeypatch.chdir(nowhere) + transcript = _long_transcript(tmp_path) + payload = _json.dumps({ + "session_id": "fb-1", + "transcript_path": str(transcript), + "cwd": str(nowhere), + }) + result = CliRunner().invoke(cli, ["capture", "answer"], input=payload) + assert result.exit_code == 0, result.output + out = _json.loads(result.output) + assert out["captured"] is True + src = personal.get_source(out["source"]) + assert src.metadata["origin_path"] == str(nowhere) + assert "personal-fallback" in src.tags + + +def test_answer_cli_project_kb_capture_has_no_origin( + _fallback_machine: KBStore, tmp_path: Path, monkeypatch +) -> None: + """A project-KB capture is NOT a fallback: no origin stamp, no tag.""" + proj = tmp_path / "realproj" + store = KBStore.init(proj) + monkeypatch.chdir(proj) + transcript = _long_transcript(tmp_path) + payload = _json.dumps({ + "session_id": "fb-2", + "transcript_path": str(transcript), + "cwd": str(proj), + }) + result = CliRunner().invoke(cli, ["capture", "answer"], input=payload) + assert result.exit_code == 0, result.output + out = _json.loads(result.output) + src = store.get_source(out["source"]) + assert "origin_path" not in src.metadata + assert "personal-fallback" not in src.tags + + +def test_banner_cli_announces_fallback( + _fallback_machine: KBStore, tmp_path: Path, monkeypatch +) -> None: + nowhere = tmp_path / "no-kb-project" + nowhere.mkdir() + monkeypatch.chdir(nowhere) + payload = _json.dumps({"session_id": "fb-3", "cwd": str(nowhere)}) + result = CliRunner().invoke(cli, ["capture", "banner"], input=payload) + assert result.exit_code == 0, result.output + assert "personal KB" in result.output + assert "vouch adopt" in result.output + + +def test_observe_cli_buffers_in_personal_kb_on_fallback( + _fallback_machine: KBStore, tmp_path: Path, monkeypatch +) -> None: + personal = _fallback_machine + nowhere = tmp_path / "no-kb-project" + nowhere.mkdir() + monkeypatch.chdir(nowhere) + payload = _json.dumps({ + "session_id": "fb-4", + "cwd": str(nowhere), + "tool_name": "Edit", + "tool_input": {"file_path": str(nowhere / "a.py")}, + "tool_response": "ok", + }) + result = CliRunner().invoke(cli, ["capture", "observe"], input=payload) + assert result.exit_code == 0, result.output + assert cap.buffer_path(personal, "fb-4").exists() + + +def test_fallback_off_captures_nowhere( + _fallback_machine: KBStore, tmp_path: Path, monkeypatch +) -> None: + from vouch import hub + + personal = _fallback_machine + hub.set_personal_fallback(personal.root, False) + nowhere = tmp_path / "no-kb-project" + nowhere.mkdir() + monkeypatch.chdir(nowhere) + transcript = _long_transcript(tmp_path) + payload = _json.dumps({ + "session_id": "fb-5", + "transcript_path": str(transcript), + "cwd": str(nowhere), + }) + result = CliRunner().invoke(cli, ["capture", "answer"], input=payload) + assert result.exit_code == 0 + assert result.output.strip() == "" # no capture happened anywhere + assert not list((personal.kb_dir / "sources").glob("*/meta.yaml")) or all( + "origin_path" not in s.metadata for s in personal.list_sources() + ) + # and the banner shows the plain no-KB hint, not the fallback line + banner = CliRunner().invoke( + cli, ["capture", "banner"], + input=_json.dumps({"session_id": "fb-5", "cwd": str(nowhere)}), + ) + assert "run `vouch init` here to enable durable memory" in banner.output diff --git a/tests/test_hub.py b/tests/test_hub.py index dffcfd97..9ecf8411 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -26,6 +26,8 @@ def _isolated_machine(tmp_path_factory, monkeypatch): monkeypatch.setenv(hub.REGISTRY_ENV, str(fake_home / "registry.yaml")) monkeypatch.delenv("VOUCH_KB_PATH", raising=False) monkeypatch.delenv("VOUCH_PROJECT_DIR", raising=False) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv(hub.PERSONAL_KB_ENV, raising=False) return fake_home @@ -583,3 +585,185 @@ def test_cli_capture_store_respects_guard( _parent, child = _personal_ancestor_setup(tmp_path) monkeypatch.chdir(child) assert _capture_store() is None + + +# --- the personal catch-all KB + fallback capture (phase 3) ---------------- + + +def _personal_fallback_setup(fake_home: Path, *, enabled: bool = True) -> KBStore: + """A registered personal KB at the default XDG path, opted in (or not).""" + root = hub.personal_kb_root() + assert root is not None + store = KBStore.init(root) + hub.register_kb(root, role="personal", actor="t") + hub.set_personal_fallback(root, enabled) + return store + + +def test_personal_kb_root_resolution(monkeypatch, _isolated_machine) -> None: + fake_home = _isolated_machine + assert hub.personal_kb_root() == ( + fake_home / ".local" / "share" / "vouch" / "personal" + ) + monkeypatch.setenv("XDG_DATA_HOME", str(fake_home / "xdg")) + assert hub.personal_kb_root() == fake_home / "xdg" / "vouch" / "personal" + monkeypatch.setenv(hub.PERSONAL_KB_ENV, str(fake_home / "elsewhere")) + assert hub.personal_kb_root() == fake_home / "elsewhere" + + +def test_personal_fallback_flag_roundtrip(_isolated_machine) -> None: + store = _personal_fallback_setup(_isolated_machine, enabled=False) + root = store.root + assert hub.personal_fallback_enabled(root) is False + hub.set_personal_fallback(root, True) + assert hub.personal_fallback_enabled(root) is True + hub.set_personal_fallback(root, False) + assert hub.personal_fallback_enabled(root) is False + # the rest of the starter config survives the textual edits + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + assert cfg["review"]["auto_approve_on_receipt"] is True + assert cfg["kb"]["id"] + + +def test_set_personal_fallback_preserves_comments(tmp_path: Path) -> None: + store = KBStore.init(tmp_path / "p") + marker = "# hand-written comment that must survive\n" + store.config_path.write_text( + marker + store.config_path.read_text(encoding="utf-8"), encoding="utf-8" + ) + hub.set_personal_fallback(store.root, True) + text = store.config_path.read_text(encoding="utf-8") + assert marker in text + assert hub.personal_fallback_enabled(store.root) is True + # toggling an existing flag also preserves the comment + hub.set_personal_fallback(store.root, False) + assert marker in store.config_path.read_text(encoding="utf-8") + assert hub.personal_fallback_enabled(store.root) is False + + +def test_set_personal_fallback_refuses_corrupt_config(tmp_path: Path) -> None: + store = KBStore.init(tmp_path / "p") + store.config_path.write_text("just a string\n", encoding="utf-8") + with pytest.raises(ValueError): + hub.set_personal_fallback(store.root, True) + assert store.config_path.read_text(encoding="utf-8") == "just a string\n" + + +def test_personal_fallback_store_needs_both_belts(_isolated_machine) -> None: + # no personal KB registered at all + assert hub.personal_fallback_store() is None + store = _personal_fallback_setup(_isolated_machine, enabled=False) + # registered but not opted in + assert hub.personal_fallback_store() is None + hub.set_personal_fallback(store.root, True) + fb = hub.personal_fallback_store() + assert fb is not None and fb.root == store.root + + +def test_capture_target_prefers_project_kb(tmp_path: Path, _isolated_machine) -> None: + _personal_fallback_setup(_isolated_machine) + proj = tmp_path / "proj" + KBStore.init(proj) + target = hub.capture_target(proj) + assert target.store is not None and target.store.root == proj + assert target.fallback is False and target.origin is None + + +def test_capture_target_falls_back_when_opted_in( + tmp_path: Path, _isolated_machine +) -> None: + personal = _personal_fallback_setup(_isolated_machine) + nowhere = tmp_path / "no-kb-here" + nowhere.mkdir() + target = hub.capture_target(nowhere) + assert target.store is not None and target.store.root == personal.root + assert target.fallback is True + assert target.origin == nowhere.resolve() + assert target.note is not None and "personal" in target.note + + +def test_capture_target_stays_off_without_opt_in( + tmp_path: Path, _isolated_machine +) -> None: + _personal_fallback_setup(_isolated_machine, enabled=False) + nowhere = tmp_path / "no-kb-here" + nowhere.mkdir() + target = hub.capture_target(nowhere) + assert target.store is None and target.fallback is False + + +def test_capture_target_guard_never_falls_through_to_fallback( + tmp_path: Path, _isolated_machine +) -> None: + """Discovery landing on a personal KB from below is the hijack shape — + it must stay a refusal even when that same KB has fallback enabled.""" + parent, child = _personal_ancestor_setup(tmp_path) + hub.set_personal_fallback(parent, True) + target = hub.capture_target(child) + assert target.store is None + assert target.fallback is False + assert target.note is not None and "refusing ambient capture" in target.note + + +def test_read_target_follows_capture_into_fallback( + tmp_path: Path, _isolated_machine +) -> None: + personal = _personal_fallback_setup(_isolated_machine) + nowhere = tmp_path / "no-kb-here" + nowhere.mkdir() + store, warning, fallback = hub.read_target(nowhere) + assert store is not None and store.root == personal.root + assert fallback is True + assert warning is not None and "personal" in warning + # a project KB wins, with no warning + proj = tmp_path / "proj" + KBStore.init(proj) + store, warning, fallback = hub.read_target(proj) + assert store is not None and store.root == proj + assert warning is None and fallback is False + # no personal opt-in -> reads stay dark like today + hub.set_personal_fallback(personal.root, False) + store, warning, fallback = hub.read_target(nowhere) + assert store is None and fallback is False + + +def test_cli_hub_init_personal_and_fallback_cmds(_isolated_machine) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + runner = CliRunner() + r = runner.invoke(cli, ["hub", "init-personal"]) + assert r.exit_code == 0, r.output + root = hub.personal_kb_root() + assert root is not None and (root / ".vouch").is_dir() + # non-interactive default: opt-in stays OFF + assert hub.personal_fallback_enabled(root) is False + entry = hub.personal_entry() + assert entry is not None and entry.role == "personal" + + r = runner.invoke(cli, ["hub", "fallback", "on"]) + assert r.exit_code == 0, r.output + assert hub.personal_fallback_enabled(root) is True + r = runner.invoke(cli, ["hub", "fallback"]) + assert r.exit_code == 0 and "on" in r.output + + # idempotent re-init leaves the flag alone + r = runner.invoke(cli, ["hub", "init-personal"]) + assert r.exit_code == 0, r.output + assert hub.personal_fallback_enabled(root) is True + + # an explicit flag wins + r = runner.invoke(cli, ["hub", "init-personal", "--no-fallback"]) + assert r.exit_code == 0, r.output + assert hub.personal_fallback_enabled(root) is False + + +def test_cli_hub_fallback_without_personal_kb(_isolated_machine) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + + r = CliRunner().invoke(cli, ["hub", "fallback", "on"]) + assert r.exit_code != 0 + assert "init-personal" in r.output diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index cfdfd6dd..f45f1d81 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -1508,3 +1508,89 @@ def test_install_global_coexists_with_project_install(tmp_path: Path) -> None: cfg = json.loads((home / ".claude.json").read_text(encoding="utf-8")) assert cfg["mcpServers"]["vouch"]["command"] == "vouch" assert str(project.resolve()) in cfg["projects"] # untouched + + +# --- --global + the personal-fallback opt-in (phase 3) --------------------- + + +@pytest.fixture() +def _personal_env(_sandbox_home: Path, monkeypatch): + """Pin the registry + personal paths inside the sandbox home.""" + from vouch import hub + + monkeypatch.setenv(hub.REGISTRY_ENV, str(_sandbox_home / "registry.yaml")) + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + monkeypatch.delenv(hub.PERSONAL_KB_ENV, raising=False) + return _sandbox_home + + +def test_install_global_personal_fallback_flag( + tmp_path: Path, monkeypatch, _personal_env: Path +) -> None: + """--personal-fallback sets up the opted-in personal KB in one command.""" + from vouch import hub + + workdir = tmp_path / "w" + workdir.mkdir() + monkeypatch.chdir(workdir) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--global", "--personal-fallback"] + ) + assert result.exit_code == 0, result.output + root = hub.personal_kb_root() + assert root is not None and (root / ".vouch").is_dir() + assert hub.personal_fallback_enabled(root) is True + entry = hub.personal_entry() + assert entry is not None and entry.role == "personal" + # re-running reports the existing KB instead of re-asking + again = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--global"] + ) + assert again.exit_code == 0, again.output + assert "Personal KB:" in again.output + assert "fallback capture on" in again.output + + +def test_install_global_no_personal_fallback_stays_off( + tmp_path: Path, monkeypatch, _personal_env: Path +) -> None: + from vouch import hub + + workdir = tmp_path / "w" + workdir.mkdir() + monkeypatch.chdir(workdir) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--global", "--no-personal-fallback"] + ) + assert result.exit_code == 0, result.output + root = hub.personal_kb_root() + assert root is not None and not (root / ".vouch").exists() + assert hub.personal_entry() is None + + +def test_install_global_non_tty_defaults_to_hint( + tmp_path: Path, monkeypatch, _personal_env: Path +) -> None: + """No flag + no terminal: nothing is created, the hint names the command.""" + from vouch import hub + + workdir = tmp_path / "w" + workdir.mkdir() + monkeypatch.chdir(workdir) + result = CliRunner().invoke(cli, ["install-mcp", "claude-code", "--global"]) + assert result.exit_code == 0, result.output + assert "hub init-personal --fallback" in result.output + root = hub.personal_kb_root() + assert root is not None and not (root / ".vouch").exists() + assert hub.personal_entry() is None + + +def test_personal_fallback_flag_requires_global(tmp_path: Path, monkeypatch) -> None: + proj = tmp_path / "proj" + proj.mkdir() + monkeypatch.chdir(proj) + result = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--personal-fallback"] + ) + assert result.exit_code != 0 + assert "--global" in result.output From 439ea3ca4a6d6440d7e4f882667144a37a3daef4 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:59:08 +0900 Subject: [PATCH 07/63] docs: personal catch-all kb + adopt in readmes and changelog the "a folder without a kb never captures anywhere" wording gains its "by default" qualifier now that the opt-in personal fallback exists; both readmes explain the opt-in, the origin stamping, the banner announcement, and that adoption re-verifies receipts through the project kb's own review gate. --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ README.md | 4 +++- adapters/claude-code/README.md | 15 +++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdc84df..63574e3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- **personal catch-all KB + `vouch adopt`** (global vouch, phase 3): + `vouch hub init-personal` creates and registers a personal KB at + `~/.local/share/vouch/personal` (`XDG_DATA_HOME` honoured; + `VOUCH_PERSONAL_KB` overrides). with its opt-in flag on + (`personal.fallback_capture` in the KB's own config — one question at + `install-mcp --global`, or `--personal-fallback`, or `vouch hub + fallback on`), sessions in folders WITHOUT a project KB capture into + it instead of nowhere: every captured source records the folder it + came from (`metadata.origin_path`), the session-start banner announces + the routing, and per-prompt recall in those folders reads the same KB + back. strictly double-opt-in (registry role `personal` + the KB's own + config flag) and fail-closed: no personal KB, no flag, a corrupt + registry, or a guard refusal (discovery landing on a personal KB from + below — the hijack shape) all mean capture stays off exactly as + before. `vouch adopt`, run inside a project that now has its own KB, + drains those captures home THROUGH the project's review gate: sources + copy byte-identically (content-addressed ids are stable across KBs), + each live personal claim is re-proposed against the copied source, its + byte-offset receipt re-verifies mechanically, and the project's own + review config decides durability — auto-approve on receipt where + enabled, pending for a human otherwise; adoption never bypasses + review. idempotent; `--dry-run` previews; `--from-path` adopts a + moved project's captures; `--retire` archives the personal copies; + both KBs log a `kb.adopt` audit event carrying the other side's id. + ## [1.5.0] — 2026-07-20 ### Added diff --git a/README.md b/README.md index 6d9d8de2..2077f5ec 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ vouch install-mcp claude-code # creates .vouch/ (if missing) + wires Claud > **What you'll see.** Every prompt is checked against the KB first. When vouch knows something relevant, the answer opens with **"From vouch memory:"**, grounded in the cited items; when it doesn't, it opens with *"Nothing in vouch on this."* — recall is visible on every turn, never silent. (A fresh KB knows almost nothing yet: work a session or two so capture fills it, then ask about the project again.) -> **Prefer one install for every project?** `vouch install-mcp claude-code --global` wires vouch once, machine-wide: user-level hooks and commands in `~/.claude/` plus a user-scope MCP server in `~/.claude.json`. Every Claude session in every folder then captures + recalls into *that folder's own* `.vouch/` — data stays per project. Run `vouch init` once in each project you want vouch in; a folder without a KB never captures anywhere — its session opens with a one-line "run `vouch init` to enable durable memory here" note and the `kb_*` tools say the same. Safe next to existing per-project installs: duplicate hooks collapse and capture dedups by event id. +> **Prefer one install for every project?** `vouch install-mcp claude-code --global` wires vouch once, machine-wide: user-level hooks and commands in `~/.claude/` plus a user-scope MCP server in `~/.claude.json`. Every Claude session in every folder then captures + recalls into *that folder's own* `.vouch/` — data stays per project. Run `vouch init` once in each project you want vouch in; by default a folder without a KB never captures anywhere — its session opens with a one-line "run `vouch init` to enable durable memory here" note and the `kb_*` tools say the same. Safe next to existing per-project installs: duplicate hooks collapse and capture dedups by event id. + +> **Optionally, a personal catch-all KB.** The global install asks one question (or pass `--personal-fallback`; later: `vouch hub init-personal --fallback`): opt in, and folders *without* a project KB capture into a personal KB at `~/.local/share/vouch/personal` instead of nowhere — each captured source stamped with the folder it came from, the session banner saying exactly where the knowledge is going, and recall in those folders reading the same KB back. When such a folder later becomes a real project, `vouch init` + **`vouch adopt`** moves its captures into the new project KB *through that KB's own review gate*: sources copy byte-identically, every claim is re-proposed and its byte-offset receipt re-verified, and the project's review config decides durability — adoption never bypasses review. Strictly opt-in; without it, nothing changes. **2. Point `compile` at an LLM** — the only step that needs a model. In `.vouch/config.yaml`: diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index 3d81dc33..e864288d 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -27,13 +27,24 @@ vouch **once for every project**: hooks + `/vouch-*` commands under `~/.claude.json`; `vouch serve` starts even where no KB exists, so the server never shows as failed in non-vouch folders). Each session still uses the nearest project `.vouch/`, so knowledge stays per project; run -`vouch init` once in any project you want vouch in. A folder with no KB -never captures anywhere — its session-start banner says "run +`vouch init` once in any project you want vouch in. By default a folder +with no KB never captures anywhere — its session-start banner says "run `vouch init` to enable durable memory here". This coexists safely with per-project installs: the settings template is byte-identical (Claude Code collapses duplicate hook commands) and capture additionally dedups on the event's `tool_use_id`. +Optionally, the global install offers a **personal catch-all KB** (one +question at install time, or `--personal-fallback`, or later +`vouch hub init-personal --fallback`): with it enabled, KB-less folders +capture into `~/.local/share/vouch/personal` instead of nowhere — the +banner announces the routing every session, each captured source +records the folder it came from, and recall in those folders reads the +same KB. When a folder later becomes a real project, `vouch init` + +`vouch adopt` drains its captures into the project KB through that KB's +own review gate (receipts re-verify; the project's review config +decides). `vouch hub fallback on|off|status` flips it any time. + ## 2. Drop the MCP server into your project Add `.mcp.json` at the root of your project (the same directory that From 8f0f0aaa2f7ae2824ee8ba04b8e7fb33d33288ca Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:22:13 +0900 Subject: [PATCH 08/63] fix(adopt): retire only durable claims; honest fallback surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adversarial review of the phase-3 diff confirmed 12 findings (6 refuted). the blocker: `adopt --retire` archived personal claims that had only landed PENDING in the project, so rejecting or expiring the proposal left the knowledge live in neither kb with no unarchive path. retire now covers exactly the claims that became durable. the rest: - re-running adopt filed a duplicate pending proposal per pass under a human-only gate (`_already_durable` never saw the queue) — the guard now checks pending payload ids too, and --dry-run predicts against the project's real gate instead of assuming receipts auto-approve. - fallback recall reads the whole personal kb, which is what a catch-all is — but the injected digest called it "knowledge for this repo". the digest header, the prompt-hook block, the banner, the opt-in question and both readmes now say it is one store shared by every kb-less folder, so recall there can surface knowledge captured elsewhere. - session rollups filed by the fallback carried no origin: they now record the folder like captured sources do, and adopt reports the ones still pending in the personal kb instead of leaving them silent. - a stale personal registry row could shadow a live one and switch fallback off; live rows now win and init-personal retires rows pointing elsewhere. - OSError from the personal-kb bootstrap escaped as a traceback and made a successful --global install exit 1; both entry points now report a remedy, and the install warns without failing. - `vouch status` in a kb-less folder said only "run vouch init" while the hook plane was capturing to the personal kb; it now names it. - set_personal_fallback took the kb's cross-process lock like identity minting does, instead of a lock-free read-modify-write of config.yaml. --- README.md | 2 +- adapters/claude-code/README.md | 8 +- src/vouch/adopt.py | 72 +++++++++-- src/vouch/capture.py | 7 +- src/vouch/cli.py | 115 +++++++++++++---- src/vouch/hooks.py | 26 +++- src/vouch/hub.py | 36 +++++- src/vouch/recall.py | 14 +- src/vouch/session_split.py | 33 ++++- tests/test_adopt.py | 226 +++++++++++++++++++++++++++++++++ 10 files changed, 487 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 2077f5ec..4e8ec5f2 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ vouch install-mcp claude-code # creates .vouch/ (if missing) + wires Claud > **Prefer one install for every project?** `vouch install-mcp claude-code --global` wires vouch once, machine-wide: user-level hooks and commands in `~/.claude/` plus a user-scope MCP server in `~/.claude.json`. Every Claude session in every folder then captures + recalls into *that folder's own* `.vouch/` — data stays per project. Run `vouch init` once in each project you want vouch in; by default a folder without a KB never captures anywhere — its session opens with a one-line "run `vouch init` to enable durable memory here" note and the `kb_*` tools say the same. Safe next to existing per-project installs: duplicate hooks collapse and capture dedups by event id. -> **Optionally, a personal catch-all KB.** The global install asks one question (or pass `--personal-fallback`; later: `vouch hub init-personal --fallback`): opt in, and folders *without* a project KB capture into a personal KB at `~/.local/share/vouch/personal` instead of nowhere — each captured source stamped with the folder it came from, the session banner saying exactly where the knowledge is going, and recall in those folders reading the same KB back. When such a folder later becomes a real project, `vouch init` + **`vouch adopt`** moves its captures into the new project KB *through that KB's own review gate*: sources copy byte-identically, every claim is re-proposed and its byte-offset receipt re-verified, and the project's review config decides durability — adoption never bypasses review. Strictly opt-in; without it, nothing changes. +> **Optionally, a personal catch-all KB.** The global install asks one question (or pass `--personal-fallback`; later: `vouch hub init-personal --fallback`): opt in, and folders *without* a project KB capture into a personal KB at `~/.local/share/vouch/personal` instead of nowhere — each captured source stamped with the folder it came from, and the session banner saying exactly where the knowledge is going. It is **one store shared by every KB-less folder**: recall in any of them reads the whole personal KB, so knowledge captured while working in one such folder can surface in another (the injected block says so). Projects with their own `.vouch/` are never affected. When such a folder later becomes a real project, `vouch init` + **`vouch adopt`** moves its captures into the new project KB *through that KB's own review gate*: sources copy byte-identically, every claim is re-proposed and its byte-offset receipt re-verified, and the project's review config decides durability — adoption never bypasses review. Strictly opt-in; without it, nothing changes. **2. Point `compile` at an LLM** — the only step that needs a model. In `.vouch/config.yaml`: diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md index e864288d..4270233e 100644 --- a/adapters/claude-code/README.md +++ b/adapters/claude-code/README.md @@ -38,9 +38,11 @@ Optionally, the global install offers a **personal catch-all KB** (one question at install time, or `--personal-fallback`, or later `vouch hub init-personal --fallback`): with it enabled, KB-less folders capture into `~/.local/share/vouch/personal` instead of nowhere — the -banner announces the routing every session, each captured source -records the folder it came from, and recall in those folders reads the -same KB. When a folder later becomes a real project, `vouch init` + +banner announces the routing every session and each captured source +records the folder it came from. It is one store shared by all KB-less +folders: recall in any of them reads the whole personal KB (the injected +block says so), so use a project KB — `vouch init` — wherever knowledge +should stay put. When a folder later becomes a real project, `vouch init` + `vouch adopt` drains its captures into the project KB through that KB's own review gate (receipts re-verify; the project's review config decides). `vouch hub fallback on|off|status` flips it any time. diff --git a/src/vouch/adopt.py b/src/vouch/adopt.py index 7ee4e23a..6fa4b3ac 100644 --- a/src/vouch/adopt.py +++ b/src/vouch/adopt.py @@ -55,6 +55,11 @@ class AdoptReport: claims_pending: list[str] = field(default_factory=list) claims_skipped: list[str] = field(default_factory=list) retired: list[str] = field(default_factory=list) + # Session-summary page proposals still pending in the personal KB for this + # origin. Adoption moves durable knowledge (sources + claims); a summary + # that no human has reviewed yet is not knowledge yet, so it stays where + # it was filed — reported, never silently left behind. + pages_pending_in_personal: list[str] = field(default_factory=list) def as_dict(self) -> dict[str, object]: return { @@ -67,6 +72,7 @@ def as_dict(self) -> dict[str, object]: "claims_pending": self.claims_pending, "claims_skipped": self.claims_skipped, "retired": self.retired, + "pages_pending_in_personal": self.pages_pending_in_personal, } @@ -141,7 +147,7 @@ def adopt( re-proposal that decodes to an identical durable claim is mechanically rejected by the receipt resolver. ``dry_run`` reports without writing. """ - root = match_root.resolve() + root = Path(match_root).resolve() personal_identity = personal.identity() project_identity = project.identity() report = AdoptReport( @@ -150,6 +156,7 @@ def adopt( to_kb=project_identity[0] if project_identity else None, dry_run=dry_run, ) + report.pages_pending_in_personal = _pending_pages_for_origin(personal, root) sources = find_adoptable_sources(personal, root) if not sources: return report @@ -160,11 +167,16 @@ def adopt( report.sources = sorted( sid for sid in source_ids if not _source_exists(project, sid) ) + queued = _pending_payload_ids(project) + # Predict against the PROJECT's real gate — a dry run that promises + # durable claims a closed gate will leave pending is worse than no + # preview at all. + gate_open = _receipts_auto_approve(project) for claim, receipt in pairs: - if _already_durable(project, claim): + if _already_durable(project, claim) or claim.id in queued: report.claims_skipped.append(claim.id) - elif receipt is not None: - report.claims_durable.append(claim.id) # candidate, gate decides + elif receipt is not None and gate_open: + report.claims_durable.append(claim.id) else: report.claims_pending.append(claim.id) return report @@ -189,9 +201,18 @@ def adopt( ) report.sources.append(src.id) - adopted_claim_ids: list[str] = [] + # Under a human-only gate adopted claims land PENDING, not durable — so + # "already here" must also mean "already queued", or every re-run files + # another copy of the same claim into the review queue. + queued = _pending_payload_ids(project) + # Only claims that actually landed DURABLE in the project may be retired + # from the personal KB. Archiving one that is merely pending would strand + # it: reject or expire the proposal and the knowledge is live in neither + # KB, with no unarchive path and no second adopt pass (archived claims are + # skipped as dead). + landed_durable: list[str] = [] for claim, receipt in pairs: - if _already_durable(project, claim): + if _already_durable(project, claim) or claim.id in queued: report.claims_skipped.append(claim.id) continue rationale = ( @@ -224,7 +245,7 @@ def adopt( ) if durable is not None: report.claims_durable.append(durable.id) - adopted_claim_ids.append(claim.id) + landed_durable.append(claim.id) else: try: filed = project.get_proposal(result.proposal.id) @@ -232,7 +253,6 @@ def adopt( filed = None if filed is not None and filed.status == ProposalStatus.PENDING: report.claims_pending.append(claim.id) - adopted_claim_ids.append(claim.id) else: # Rejected as a duplicate of an already-durable claim. report.claims_skipped.append(claim.id) @@ -253,10 +273,9 @@ def adopt( slug_hint=claim.id, ) report.claims_pending.append(claim.id) - adopted_claim_ids.append(claim.id) if retire: - for claim_id in adopted_claim_ids: + for claim_id in landed_durable: try: lifecycle.archive(personal, claim_id=claim_id, actor=actor) except Exception: @@ -297,6 +316,39 @@ def _already_durable(project: KBStore, claim: Claim) -> bool: return True +def _pending_pages_for_origin(personal: KBStore, match_root: Path) -> list[str]: + """Ids of PENDING page proposals captured in ``match_root`` (or below).""" + out: list[str] = [] + for proposal in personal.list_proposals(ProposalStatus.PENDING): + meta = proposal.payload.get("metadata") + if not isinstance(meta, dict): + continue + origin_path = meta.get("origin_path") + if isinstance(origin_path, str) and origin_path and _origin_matches( + origin_path, match_root + ): + out.append(proposal.id) + return out + + +def _receipts_auto_approve(project: KBStore) -> bool: + """Whether this KB's gate lets a verified receipt land durable by itself.""" + cfg = proposals_mod._review_config(project) + return bool(cfg.get("auto_approve_on_receipt")) or ( + cfg.get("approver_role") == "trusted-agent" + ) + + +def _pending_payload_ids(project: KBStore) -> set[str]: + """Claim ids already waiting in the project's review queue.""" + out: set[str] = set() + for proposal in project.list_proposals(ProposalStatus.PENDING): + payload_id = proposal.payload.get("id") + if isinstance(payload_id, str) and payload_id: + out.add(payload_id) + return out + + def _source_exists(project: KBStore, source_id: str) -> bool: try: project.get_source(source_id) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 7b5744d8..06d87e9e 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -446,6 +446,7 @@ def finalize( transcript_path: Path | None = None, mode: str = "auto", config: CaptureConfig | None = None, + origin: Path | None = None, ) -> dict[str, Any]: """Roll a session buffer into PENDING summary proposal(s). No approve(). @@ -455,6 +456,10 @@ def finalize( If cwd is None (e.g., finalizing orphaned buffers of unknown origin), git changes are not included; transcript_path (from the SessionEnd hook payload) supplies the human's first prompt for the summary title when present. + + ``origin`` marks a personal-KB fallback rollup (see ``capture_answer``): + the filed summary records the folder the session ran in, so a shared + personal KB's review queue says which folder each summary is about. """ from . import session_split # deferred: breaks the capture<->session_split cycle intent = ( @@ -462,7 +467,7 @@ def finalize( ) return session_split.summarize( store, session_id, intent=intent, cwd=cwd, project=project, - generated_at=generated_at, mode=mode, config=config, + generated_at=generated_at, mode=mode, config=config, origin=origin, ) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 35ab069b..ae3db91b 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib import getpass import io import json @@ -117,6 +118,19 @@ def _load_store(start: Path | None = None) -> KBStore: root = discover_root(start) except KBNotFoundError as e: click.echo(f"error: {e}", err=True) + # A KB-less folder under an opted-in personal fallback is NOT inert — + # its sessions capture into the personal KB. Saying only "run vouch + # init" here would hide where this folder's knowledge is going. + fallback = None + with contextlib.suppress(Exception): + fallback = hub_mod.personal_fallback_store() + if fallback is not None: + click.echo( + f"note: sessions in this folder capture into your personal KB " + f"({fallback.root}). `vouch init` here, then `vouch adopt`, " + "moves that knowledge into this project.", + err=True, + ) click.echo("hint: run `vouch init` in your project root.", err=True) sys.exit(2) # Reads proceed under the personal-role registry guard, but say so: @@ -381,15 +395,39 @@ def _init_personal_kb(fallback: bool | None) -> Path: ) created = not (root / ".vouch").is_dir() if created: - with _cli_errors(): + try: _bootstrap_kb(root) + except Exception as e: + # cli boundary: an unwritable XDG path (or any other OSError) must + # read as an error with a remedy, not a traceback — and must not + # leave half a KB that a rerun would mistake for a finished one. + shutil.rmtree(root / ".vouch", ignore_errors=True) + raise click.ClickException( + f"could not initialise the personal KB at {root}: {e} — fix " + "the cause and rerun, or set VOUCH_PERSONAL_KB to a writable " + "folder" + ) from e click.echo(f"Initialised personal KB at {root / '.vouch'}") else: click.echo(f"Personal KB already present at {root / '.vouch'}") - with _cli_errors(): + # A personal row pointing somewhere else is a routing hazard: capture + # would follow one KB while `hub fallback` flips another. Retire the + # stale rows instead of leaving the choice to ordering. + for stale in hub_mod.personal_entries(): + if Path(stale.path).expanduser().resolve() != root.resolve(): + hub_mod.unregister_kb(stale.kb_id) + click.echo( + f"note: unregistered a previous personal KB row ({stale.path})", + err=True, + ) + try: entry = hub_mod.register_kb( root, role="personal", name="personal", actor=_whoami() ) + except Exception as e: + raise click.ClickException( + f"could not register the personal KB at {root}: {e}" + ) from e click.echo(f"Registered in the machine registry: {entry.name} ({entry.kb_id})") if fallback is not None: try: @@ -400,8 +438,9 @@ def _init_personal_kb(fallback: bool | None) -> Path: if enabled: click.echo( "Fallback capture: on — sessions in folders WITHOUT a project KB " - "capture here; `vouch init` + `vouch adopt` moves that knowledge " - "into a project later." + "capture here, and recall in those folders reads this whole KB " + "(one store shared by all of them). `vouch init` + `vouch adopt` " + "moves a folder's share into its own project KB." ) else: click.echo( @@ -433,7 +472,9 @@ def hub_init_personal(fallback: bool | None) -> None: if fallback is None and created and sys.stdin.isatty(): fallback = click.confirm( "Capture sessions in folders WITHOUT a project KB into this " - "personal KB? (you can adopt that knowledge into a project later)", + "personal KB? It is one shared store: what you capture in any " + "KB-less folder is recalled in all of them (adopt a folder's " + "share into a project KB later)", default=False, ) _init_personal_kb(fallback) @@ -534,6 +575,7 @@ def adopt( or report.claims_durable or report.claims_pending or report.claims_skipped + or report.pages_pending_in_personal ): click.echo( f"nothing to adopt: no personal-KB captures originate under " @@ -549,7 +591,17 @@ def adopt( click.echo(f" claims pending: {len(report.claims_pending)}") click.echo(f" claims skipped: {len(report.claims_skipped)} (already here)") if retire: - click.echo(f" retired (personal): {len(report.retired)}") + click.echo( + f" retired (personal): {len(report.retired)} " + "(only claims that landed durable here)" + ) + if report.pages_pending_in_personal: + click.echo( + f" session summaries captured here that are still pending in the " + f"personal KB: {len(report.pages_pending_in_personal)} — review " + f"them there (`cd {personal_root} && vouch review`); adopt moves " + "sources and claims, not unreviewed pages." + ) if report.claims_pending and not dry_run: click.echo("review the pending ones with `vouch review`.") @@ -2769,16 +2821,17 @@ def capture_finalize_cmd(session_id: str | None) -> None: start, ok = _hook_start(payload) if not ok: return - store = _capture_store(start) - if store is None: + target = _capture_target(start) + if target.store is None: return cwd = Path(str(payload.get("cwd") or ".")).resolve() transcript_raw = payload.get("transcript_path") transcript = Path(str(transcript_raw)) if transcript_raw else None result = capture_mod.finalize( - store, sid, cwd=cwd, project=cwd.name, + target.store, sid, cwd=cwd, project=cwd.name, generated_at=datetime.now(UTC).isoformat(), transcript_path=transcript, + origin=target.origin if target.fallback else None, ) _emit_json(result) @@ -2945,11 +2998,14 @@ def capture_banner_cmd() -> None: return if target.fallback: # Sessions must never capture somewhere the user can't see: this - # line is the per-session announcement of the personal fallback. + # line is the per-session announcement of the personal fallback, + # including that the store is shared with every other KB-less folder. click.echo( "vouch: no project KB in this folder — this session captures to " - f"your personal KB ({store.root}). Run `vouch init` here, then " - "`vouch adopt`, to move this folder's knowledge into its own KB." + f"your personal KB ({store.root}), one store shared by every " + "KB-less folder, so recall here can surface knowledge captured " + "elsewhere. Run `vouch init` here, then `vouch adopt`, to give " + "this folder its own KB." ) n = capture_mod.pending_count(store) if n: @@ -2969,8 +3025,9 @@ def recall_cmd() -> None: # installs run this from anywhere). payload = _read_hook_payload() start, _ok = _hook_start(payload) + fallback = False try: - store, warning, _fallback = hub_mod.read_target(start) + store, warning, fallback = hub_mod.read_target(start) except Exception: store, warning = None, None if warning: @@ -2981,7 +3038,9 @@ def recall_cmd() -> None: if not cfg.enabled: return stats: dict[str, int] = {} - digest = recall_mod.build_digest(store, max_chars=cfg.max_chars, stats=stats) + digest = recall_mod.build_digest( + store, max_chars=cfg.max_chars, stats=stats, personal=fallback + ) if stats.get("hidden"): # stderr only — stdout is injected into the host turn and must stay # clean. Scope filtering must never be silent. @@ -3277,8 +3336,9 @@ def context_hook() -> None: start, _ok = _hook_start(loaded) except json.JSONDecodeError: start = None + fallback = False try: - store, warning, _fallback = hub_mod.read_target(start) + store, warning, fallback = hub_mod.read_target(start) except Exception: store, warning = None, None if warning: @@ -3286,7 +3346,9 @@ def context_hook() -> None: out = "" if store is not None: try: - out = hooks.build_claude_prompt_hook(store, stdin_text) + out = hooks.build_claude_prompt_hook( + store, stdin_text, personal=fallback + ) except Exception: out = "" if out: @@ -4363,8 +4425,11 @@ def _offer_personal_fallback(personal_fallback: bool | None) -> None: off with a hint. Failures here never fail the install — the global wiring already landed. """ - entry = hub_mod.personal_entry() - if entry is not None: + try: + entry = hub_mod.personal_entry() + except Exception: + entry = None + if entry is not None and (Path(entry.path) / ".vouch").is_dir(): root = Path(entry.path) if personal_fallback is not None: try: @@ -4379,8 +4444,10 @@ def _offer_personal_fallback(personal_fallback: bool | None) -> None: if sys.stdin.isatty(): wanted = click.confirm( "Folders without a project KB currently don't capture at all. " - "Create a personal catch-all KB so they do? (`vouch adopt` " - "moves that knowledge into a project later)", + "Create a personal catch-all KB so they do? It is ONE store " + "shared by every KB-less folder — its knowledge is recalled in " + "all of them (`vouch adopt` moves a folder's share into a " + "project KB later)", default=False, ) else: @@ -4393,9 +4460,13 @@ def _offer_personal_fallback(personal_fallback: bool | None) -> None: return try: _init_personal_kb(True) - except click.ClickException as e: + except Exception as e: + # The machine-wide wiring already landed and is what the command is + # for; a personal-KB failure is a warning with a retry, never a + # non-zero exit that reads as "the install failed". + message = e.message if isinstance(e, click.ClickException) else str(e) click.echo( - f"warning: could not set up the personal KB: {e.message} — " + f"warning: could not set up the personal KB: {message} — " "the global install itself is complete; retry with " "`vouch hub init-personal --fallback`.", err=True, diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index 3a5aaf00..5f6754fb 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -133,8 +133,26 @@ def _envelope(block: str) -> str: ) -def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: - """Return the stdout a host should inject for this prompt, or "" for none.""" +def _whose_kb(personal: bool) -> str: + """How the injected block names the KB it just searched.""" + if personal: + return ( + "your machine-wide personal vouch knowledge base (this folder has " + "no project KB of its own, so items may come from other folders)" + ) + return "the project's vouch knowledge base" + + +def build_claude_prompt_hook( + store: KBStore, stdin_text: str, *, personal: bool = False +) -> str: + """Return the stdout a host should inject for this prompt, or "" for none. + + ``personal`` marks a read served by the machine-wide personal catch-all + KB (this folder has no project KB): the injected text must not call it + "the project's knowledge base", because the items may have been captured + while working in a different folder. + """ try: payload = json.loads(stdin_text) if stdin_text.strip() else {} except json.JSONDecodeError: @@ -184,7 +202,7 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: # Say so explicitly even when empty — the user wants to see that vouch # was consulted, not silence that looks like vouch did nothing. return _envelope( - "[vouch memory] I searched the project's vouch knowledge base for this " + f"[vouch memory] I searched {_whose_kb(personal)} for this " "prompt and found nothing relevant. Your final reply MUST open with " 'the exact words "Nothing in vouch on this." — even if you use tools ' "or explore the codebase first — then answer from your own knowledge." @@ -215,7 +233,7 @@ def build_claude_prompt_hook(store: KBStore, stdin_text: str) -> str: # Likewise the blockquote rule: recalled facts must be visually # distinguishable from the model's own words in the rendered reply. block = ( - "[vouch memory] I searched the project's vouch knowledge base for this " + f"[vouch memory] I searched {_whose_kb(personal)} for this " "prompt. Approved, cited items are below — check them BEFORE reasoning " "or exploring on your own, and ground your answer in the relevant " "item(s). Your final reply MUST open with the exact words " diff --git a/src/vouch/hub.py b/src/vouch/hub.py index 7fdc6a7b..e891760c 100644 --- a/src/vouch/hub.py +++ b/src/vouch/hub.py @@ -342,12 +342,27 @@ def personal_kb_root() -> Path | None: return home / ".local" / "share" / "vouch" / "personal" +def personal_entries(*, path: Path | None = None) -> list[RegistryEntry]: + """Every personal-role row, live ones (a real ``.vouch/`` on disk) first. + + More than one row can exist — a personal KB moved, or a second one + registered under ``VOUCH_PERSONAL_KB``. Ordering is what makes the + ambiguity harmless rather than silent: a stale row left behind by a + deleted KB must never shadow the live one and switch fallback off. + """ + rows = [e for e in load_registry(path) if e.role == "personal"] + live = [e for e in rows if (Path(e.path).expanduser() / KB_DIRNAME).is_dir()] + dead = [e for e in rows if e not in live] + # Among live rows the most recently registered wins — "the one I just set + # up" is the least surprising answer. + live.sort(key=lambda e: e.added_at, reverse=True) + return live + dead + + def personal_entry(*, path: Path | None = None) -> RegistryEntry | None: - """The registry's personal-role row (first match), or None.""" - for e in load_registry(path): - if e.role == "personal": - return e - return None + """The registry's personal-role row, or None. See ``personal_entries``.""" + rows = personal_entries(path=path) + return rows[0] if rows else None def personal_fallback_enabled(root: Path) -> bool: @@ -374,8 +389,17 @@ def set_personal_fallback(root: Path, enabled: bool) -> None: Textual edits where possible (mirroring ``KBStore._mint_identity``) so hand-written comments survive; a config that is not a yaml mapping is - refused untouched. + refused untouched. Serialized on the KB's own cross-process lock — the + same one identity minting uses — because this is a read-modify-write of + the file that also carries `kb:` and `review:`, and a concurrent minter + or flag-flipper would otherwise write back a stale copy of the whole + config. """ + with audit_mod._audit_lock(root / KB_DIRNAME): + _set_personal_fallback_locked(root, enabled) + + +def _set_personal_fallback_locked(root: Path, enabled: bool) -> None: cfg_path = root / KB_DIRNAME / "config.yaml" text = cfg_path.read_text(encoding="utf-8") try: diff --git a/src/vouch/recall.py b/src/vouch/recall.py index beee28ed..b05b0581 100644 --- a/src/vouch/recall.py +++ b/src/vouch/recall.py @@ -56,6 +56,7 @@ def build_digest( max_chars: int = DEFAULT_MAX_CHARS, viewer: ViewerContext | None = None, stats: dict[str, int] | None = None, + personal: bool = False, ) -> str: """Return an injectable digest of every live approved claim + page title. @@ -68,6 +69,11 @@ def build_digest( ``stats``, when given, receives ``{"hidden": n}`` — how many live artifacts the scope filter dropped — so CLI callers can say so on stderr instead of filtering silently. + + ``personal`` labels the digest as the machine-wide personal catch-all + rather than "this repo". A KB-less folder's session reads the personal + KB *whole* — that is what a catch-all is — so the header must not claim + the knowledge belongs to the current folder. """ if viewer is None: viewer = viewer_from(config_path=store.config_path) @@ -80,9 +86,15 @@ def build_digest( if not claims and not pages: return "" + whose = ( + "in your machine-wide personal vouch KB (this folder has no project " + "KB; knowledge here is shared across every KB-less folder you work in)" + if personal + else "for this repo" + ) lines: list[str] = [ _OPEN_TAG, - f"# approved KB knowledge for this repo — {len(claims)} claim(s), " + f"# approved KB knowledge {whose} — {len(claims)} claim(s), " f"{len(pages)} page(s). reviewed, cited, durable. use kb_read_page / " "kb_search for detail; kb_propose_* (human-approved) to add more.", ] diff --git a/src/vouch/session_split.py b/src/vouch/session_split.py index c14187c4..0ef93e86 100644 --- a/src/vouch/session_split.py +++ b/src/vouch/session_split.py @@ -96,6 +96,7 @@ def summarize( generated_at: str | None = None, mode: str = "auto", config: capture.CaptureConfig | None = None, + origin: Path | None = None, ) -> dict[str, Any]: """Roll a session buffer into PENDING page proposals. Never approves. @@ -103,6 +104,11 @@ def summarize( (force the single rollup). The buffer is deleted only after a page is filed (or an explicit below-min skip), so a crash mid-run leaves it intact for the next `finalize-all` sweep to retry. + + `origin` marks a personal-KB fallback rollup — the session ran in a folder + with no project KB. It is recorded on the filed page(s) so a summary of + work done in folder X is identifiable as such in the shared personal KB, + the same way `capture_answer` stamps captured sources. """ cfg = config or capture.load_config(store) path = capture.buffer_path(store, session_id) @@ -141,7 +147,7 @@ def summarize( try: ids, dropped, truncated = _propose_split( store, session_id, observations, changed_files, git_stat, - intent=intent, split_cfg=split_cfg, + intent=intent, split_cfg=split_cfg, origin=origin, ) if ids: if path.exists(): @@ -163,6 +169,7 @@ def summarize( pid = _propose_mechanical( store, session_id, observations, changed_files, git_stat, project=project, generated_at=generated_at, intent=intent, + origin=origin, ) if path.exists(): path.unlink() @@ -190,6 +197,7 @@ def _propose_mechanical( project: str | None, generated_at: str | None, intent: str | None, + origin: Path | None = None, ) -> str: """File the single mechanical rollup page, exactly as capture did before.""" title, body = capture.build_summary_body( @@ -201,11 +209,24 @@ def _propose_mechanical( page_type=capture.CAPTURE_PAGE_TYPE, proposed_by=capture.CAPTURE_ACTOR, session_id=session_id, + tags=_origin_tags(origin), + metadata=_origin_metadata(session_id, origin), rationale="auto-captured session summary", ) return proposal.id +def _origin_tags(origin: Path | None) -> list[str] | None: + return ["personal-fallback"] if origin is not None else None + + +def _origin_metadata(session_id: str, origin: Path | None) -> dict[str, Any]: + meta: dict[str, Any] = {"session_id": session_id} + if origin is not None: + meta["origin_path"] = str(origin) + return meta + + def _propose_split( store: KBStore, session_id: str, @@ -215,6 +236,7 @@ def _propose_split( *, intent: str | None, split_cfg: SplitConfig, + origin: Path | None = None, ) -> tuple[list[str], list[dict[str, Any]], bool]: cmd = split_cfg.llm_cmd or compile_mod.load_config(store).llm_cmd if not cmd: @@ -231,7 +253,9 @@ def _propose_split( label="capture.split.llm_cmd", ) drafts = llm_draft.parse_drafts(raw, noun="page") - ids, dropped = _file_drafts(store, session_id, drafts, split_cfg.max_pages) + ids, dropped = _file_drafts( + store, session_id, drafts, split_cfg.max_pages, origin=origin + ) _audit_split(store, session_id, ids, dropped, len(observations), truncated) return ids, dropped, truncated @@ -418,6 +442,7 @@ def _file_drafts( session_id: str, drafts: list[dict[str, Any]], max_pages: int, + origin: Path | None = None, ) -> tuple[list[str], list[dict[str, Any]]]: existing = store.list_pages() taken = {p.title.strip().lower() for p in existing} @@ -444,9 +469,9 @@ def _file_drafts( store, title=title, body=body, page_type=capture.CAPTURE_PAGE_TYPE, # "session" — forced, ignore any LLM type proposed_by=SPLIT_ACTOR, - tags=["session", "split"], + tags=["session", "split", *(_origin_tags(origin) or [])], session_id=session_id, - metadata={"session_id": session_id}, + metadata=_origin_metadata(session_id, origin), rationale=f"llm topical split of session {session_id}", ) ids.append(proposal.id) diff --git a/tests/test_adopt.py b/tests/test_adopt.py index 29676bf2..c6fc4016 100644 --- a/tests/test_adopt.py +++ b/tests/test_adopt.py @@ -270,3 +270,229 @@ def test_cli_adopt_from_path_override( r = runner.invoke(cli, ["adopt", "--from-path", str(old_home)]) assert r.exit_code == 0, r.output assert "adopted" in r.output + + +def test_adopt_does_not_requeue_pending_claims( + personal: KBStore, tmp_path: Path +) -> None: + """Under a human-only gate adopted claims land PENDING; a second pass must + skip them, not file a duplicate proposal per run into the review queue.""" + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) + cfg["review"]["auto_approve_on_receipt"] = False + project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + first = adopt_mod.adopt(project, personal, match_root=origin) + assert first.claims_pending + queued_after_first = [ + p.payload["id"] for p in project.list_proposals(ProposalStatus.PENDING) + ] + second = adopt_mod.adopt(project, personal, match_root=origin) + assert second.claims_pending == [] + assert sorted(second.claims_skipped) == sorted(first.claims_pending) + queued_after_second = [ + p.payload["id"] for p in project.list_proposals(ProposalStatus.PENDING) + ] + assert sorted(queued_after_second) == sorted(queued_after_first) + # dry-run agrees with the real run about what is left to do + dry = adopt_mod.adopt(project, personal, match_root=origin, dry_run=True) + assert dry.claims_durable == [] and dry.claims_pending == [] + + +def test_personal_digest_says_it_is_machine_wide( + personal: KBStore, tmp_path: Path +) -> None: + """A KB-less folder reads the personal KB whole — the injected header must + not claim the knowledge belongs to this repo.""" + from vouch import recall as recall_mod + + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project_style = recall_mod.build_digest(personal, personal=False) + assert "for this repo" in project_style + personal_style = recall_mod.build_digest(personal, personal=True) + assert "for this repo" not in personal_style + assert "personal vouch KB" in personal_style + assert "shared across every KB-less folder" in personal_style + + +def test_personal_prompt_hook_names_the_personal_kb( + personal: KBStore, tmp_path: Path +) -> None: + from vouch import hooks + + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + stdin = json.dumps({"prompt": "what is the deploy cadence?", "session_id": "s1"}) + project_style = hooks.build_claude_prompt_hook(personal, stdin) + personal_style = hooks.build_claude_prompt_hook(personal, stdin, personal=True) + assert "the project's vouch knowledge base" in project_style + assert "the project's vouch knowledge base" not in personal_style + assert "personal vouch knowledge base" in personal_style + assert "may come from other folders" in personal_style + + +def test_load_store_hint_names_the_personal_kb( + personal: KBStore, tmp_path: Path, monkeypatch +) -> None: + """`vouch status` in a KB-less folder must not imply nothing is captured + there when the personal fallback is on.""" + nowhere = tmp_path / "no-kb" + nowhere.mkdir() + monkeypatch.chdir(nowhere) + r = CliRunner().invoke(cli, ["status"]) + assert r.exit_code == 2 + assert "personal KB" in r.output + assert "vouch adopt" in r.output + + +def test_retire_never_archives_a_claim_that_only_landed_pending( + personal: KBStore, tmp_path: Path +) -> None: + """BLOCKER regression: archiving the personal copy of a claim the project + has not accepted yet strands it — reject or expire the proposal and the + knowledge is live in neither KB, with no unarchive path.""" + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) + cfg["review"]["auto_approve_on_receipt"] = False + project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + report = adopt_mod.adopt(project, personal, match_root=origin, retire=True) + assert report.claims_durable == [] + assert report.claims_pending + assert report.retired == [] + for claim_id in report.claims_pending: + assert personal.get_claim(claim_id).status == ClaimStatus.WORKING + + +def test_retire_archives_only_the_durable_ones( + personal: KBStore, tmp_path: Path +) -> None: + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + report = adopt_mod.adopt(project, personal, match_root=origin, retire=True) + assert report.claims_durable + assert sorted(report.retired) == sorted(report.claims_durable) + + +def test_dry_run_honours_a_closed_gate(personal: KBStore, tmp_path: Path) -> None: + """A preview that promises durable claims a closed gate will leave pending + is worse than no preview.""" + origin = tmp_path / "projA" + origin.mkdir() + _fallback_capture(personal, origin, tmp_path) + project = KBStore.init(origin) + cfg = yaml.safe_load(project.config_path.read_text(encoding="utf-8")) + cfg["review"]["auto_approve_on_receipt"] = False + project.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + dry = adopt_mod.adopt(project, personal, match_root=origin, dry_run=True) + assert dry.claims_durable == [] + assert dry.claims_pending + real = adopt_mod.adopt(project, personal, match_root=origin) + assert sorted(real.claims_pending) == sorted(dry.claims_pending) + assert real.claims_durable == dry.claims_durable + + +def test_fallback_session_summary_records_its_origin( + personal: KBStore, tmp_path: Path +) -> None: + """A rollup filed into the shared personal KB must say which folder it is + about, and `adopt` must report it rather than leave it silently behind.""" + from vouch import capture as cap + + origin = tmp_path / "projA" + origin.mkdir() + for i in range(3): + cap.observe(personal, "sum-1", tool="Edit", summary=f"edited f{i}.py", + now=float(i)) + result = cap.finalize(personal, "sum-1", cwd=origin, project=origin.name, + origin=origin) + assert result["summary_proposal_id"] + proposal = personal.get_proposal(str(result["summary_proposal_id"])) + assert proposal.payload["metadata"]["origin_path"] == str(origin) + assert "personal-fallback" in proposal.payload["tags"] + + project = KBStore.init(origin) + report = adopt_mod.adopt(project, personal, match_root=origin) + assert proposal.id in report.pages_pending_in_personal + + +def test_personal_entry_prefers_a_live_row_over_a_stale_one( + personal: KBStore, tmp_path: Path +) -> None: + """A registry row left behind by a deleted personal KB must not shadow the + live one and silently switch fallback off.""" + ghost = tmp_path / "ghost-personal" + KBStore.init(ghost) + hub.register_kb(ghost, role="personal", actor="t") + import shutil + + shutil.rmtree(ghost / ".vouch") + + entry = hub.personal_entry() + assert entry is not None + assert Path(entry.path) == personal.root + fb = hub.personal_fallback_store() + assert fb is not None and fb.root == personal.root + + +def test_init_personal_retires_a_row_pointing_elsewhere( + personal: KBStore, tmp_path: Path, monkeypatch +) -> None: + """Two personal rows are a routing hazard: capture would follow one KB + while `hub fallback` flips another.""" + elsewhere = tmp_path / "other-personal" + monkeypatch.setenv(hub.PERSONAL_KB_ENV, str(elsewhere)) + r = CliRunner().invoke(cli, ["hub", "init-personal", "--fallback"]) + assert r.exit_code == 0, r.output + rows = hub.personal_entries() + assert len(rows) == 1 + assert Path(rows[0].path) == elsewhere + fb = hub.personal_fallback_store() + assert fb is not None and fb.root == elsewhere + + +def test_init_personal_reports_an_unwritable_path_cleanly( + tmp_path: Path, monkeypatch +) -> None: + blocker = tmp_path / "not-a-dir" + blocker.write_text("i am a file\n", encoding="utf-8") + monkeypatch.setenv(hub.PERSONAL_KB_ENV, str(blocker / "personal")) + r = CliRunner().invoke(cli, ["hub", "init-personal", "--fallback"]) + assert r.exit_code != 0 + assert "could not initialise the personal KB" in r.output + assert "Traceback" not in r.output + + +def test_global_install_survives_a_failing_personal_kb( + tmp_path: Path, monkeypatch +) -> None: + """The machine-wide wiring is what the command is for: a personal-KB + failure warns, it does not fail the install.""" + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) + monkeypatch.setenv(hub.REGISTRY_ENV, str(fake_home / "registry.yaml")) + blocker = tmp_path / "blocked" + blocker.write_text("file, not a dir\n", encoding="utf-8") + monkeypatch.setenv(hub.PERSONAL_KB_ENV, str(blocker / "personal")) + workdir = tmp_path / "w" + workdir.mkdir() + monkeypatch.chdir(workdir) + r = CliRunner().invoke( + cli, ["install-mcp", "claude-code", "--global", "--personal-fallback"] + ) + assert r.exit_code == 0, r.output + assert "could not set up the personal KB" in r.output + assert (fake_home / ".claude" / "settings.json").is_file() From 4c3f0d6a48fc43bf08a0a1b596a2e99561acfd13 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:28:57 +0900 Subject: [PATCH 09/63] docs(changelog): match the post-review adopt and fallback semantics retire covers only claims that landed durable, adopt is idempotent against the pending queue too, dry-run predicts against the real gate, session rollups are reported rather than moved, and the personal kb is described as the one shared store it is. --- CHANGELOG.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63574e3c..fed2114c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,19 @@ All notable changes to vouch are documented here. Format follows byte-offset receipt re-verifies mechanically, and the project's own review config decides durability — auto-approve on receipt where enabled, pending for a human otherwise; adoption never bypasses - review. idempotent; `--dry-run` previews; `--from-path` adopts a - moved project's captures; `--retire` archives the personal copies; - both KBs log a `kb.adopt` audit event carrying the other side's id. + review. idempotent in both directions (a claim already durable *or* + already queued in the project is skipped, so re-running never doubles + the review queue); `--dry-run` previews against the project's real + gate; `--from-path` adopts a moved project's captures; `--retire` + archives only the personal copies that actually landed durable — + retiring a merely-pending one would strand it if the proposal is + later rejected or expires; session rollups are reported, not moved + (an unreviewed summary is not knowledge yet, so it stays where it was + filed); both KBs log a `kb.adopt` audit event carrying the other + side's id. the personal KB is ONE store shared by every KB-less + folder — recall there reads all of it, and the digest header, the + per-prompt block, the session banner, `vouch status` and the opt-in + question all say so rather than calling it "this repo's" knowledge. ## [1.5.0] — 2026-07-20 From ec1c440a8737b7bbd8ab634441e574be166da1d0 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:36:40 +0900 Subject: [PATCH 10/63] =?UTF-8?q?feat(hooks):=20prompt=20gate=20=E2=80=94?= =?UTF-8?q?=20recall=20earns=20its=20place=20in=20a=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the per-prompt hook injected a block on every prompt, so a coding command like "fix the failing test" spent its reply opener announcing a memory search nobody asked for. three cases now, decided by a deterministic llm-free classifier (no added latency): chatter with no informative tokens ("ok thanks", "which one is better?") injects nothing. retrieval ORs every query token, so those used to match claims on "one" and inject noise on turns that wanted none — this half is ported from the unmerged #518, rebased onto phase 3 so the two stop fighting over hooks.py. "do work" imperatives get a smaller background pack (3 items / 700 chars) and NO reply contract: no "From vouch memory:" opener, no blockquote ritual, cite an id inline only where it was actually relied on — and nothing at all when the kb has no match. recall can still inform the work (conventions, architecture) without taking the turn over. lookups — questions, what/why/how, anything not imperative — keep the full visible-recall behaviour including the honest "Nothing in vouch on this." on a miss. silence there would be indistinguishable from vouch being broken, which is the thing 1.5.0 set out to fix. the imperative test reads past politeness lead-ins ("please fix", "ok now refactor", "can you run"), and a prompt made only of discourse tokens ("continue", "go ahead") counts as work, not a question. new kbs get the gate on; existing kbs behave exactly as before until they add retrieval.prompt_gate. --- CHANGELOG.md | 20 ++++- src/vouch/hooks.py | 150 +++++++++++++++++++++++++++++++++++-- src/vouch/storage.py | 9 +++ tests/test_hooks.py | 171 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 342 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fed2114c..f629eebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,25 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] -### Added +### Changed +- **the per-prompt hook now decides how much of a turn it is entitled + to** (`retrieval.prompt_gate`). recall used to inject a block on every + prompt, so "fix the failing test" spent its opener announcing a memory + search nobody asked for. three cases now: + *chatter* with no informative tokens ("ok thanks", "which one is + better?") injects nothing — retrieval ORs every query token, so those + used to match on `one` and pull noise into turns that wanted none; + *"do work" imperatives* ("fix …", "refactor …", "run the tests", + "continue") get a smaller background pack (3 items / 700 chars) with + **no reply contract** — no "From vouch memory:" opener, no blockquote + ritual, cite an id inline only where it was actually used — and inject + nothing at all when the KB has no match; *lookups* — questions, + "what/why/how", anything not imperative — keep the full visible-recall + behaviour, including the honest "Nothing in vouch on this." on a miss. + the classifier is deterministic and llm-free (an imperative-verb test + that reads past politeness lead-ins like "please" / "ok now" / "can + you"), so it adds no latency to a turn. new KBs get it on; existing KBs + behave exactly as before until they add the key. - **personal catch-all KB + `vouch adopt`** (global vouch, phase 3): `vouch hub init-personal` creates and registers a personal KB at `~/.local/share/vouch/personal` (`XDG_DATA_HOME` honoured; diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index 5f6754fb..45c2582a 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -19,6 +19,7 @@ import json import logging import math +import re from typing import Any import yaml @@ -32,6 +33,65 @@ _MAX_ITEMS = 8 _MAX_CHARS = 2000 +# A "do work" prompt gets a smaller pack: recall should inform the work, not +# crowd the turn it is trying to help. +_WORK_MAX_ITEMS = 3 +_WORK_MAX_CHARS = 700 + +_TOKEN_RE = re.compile(r"\w+") + +# Retrieval ORs every query token (see index_db._quote_match), so a prompt +# like "which one is better?" matched claims on "one" and injected noise on +# every conversational turn. Search only on informative tokens; a prompt +# with none is not a retrieval request at all. +_STOPWORDS = frozenset( + [ + "a", "an", "the", "this", "that", "these", "those", "there", "here", "some", + "any", "each", "every", "either", "neither", "both", "few", "more", "most", + "other", "such", "own", "same", "all", "only", "very", "too", "not", "no", + "nor", "one", "ones", "two", "first", "second", "also", "even", "still", + "ever", "never", "always", "already", "i", "me", "my", "mine", "we", "us", + "our", "ours", "you", "your", "yours", "he", "him", "his", "she", "her", + "hers", "it", "its", "they", "them", "their", "theirs", "myself", "yourself", + "ourselves", "themselves", "itself", "am", "is", "are", "was", "were", "be", + "been", "being", "do", "does", "did", "doing", "done", "have", "has", "had", + "having", "will", "would", "shall", "should", "can", "could", "may", "might", + "must", "ought", "and", "or", "but", "so", "yet", "if", "then", "else", + "because", "while", "until", "although", "when", "where", "why", "how", + "what", "which", "who", "whom", "whose", "than", "as", "of", "to", "in", + "on", "at", "by", "for", "with", "about", "against", "between", "into", + "through", "during", "before", "after", "above", "below", "from", "up", + "down", "out", "off", "over", "under", "again", "further", "once", "think", + "thinks", "thinking", "thought", "want", "wants", "wanted", "wanna", "need", + "needs", "needed", "really", "honestly", "actually", "just", "like", "maybe", + "perhaps", "please", "okay", "ok", "yes", "yeah", "hey", "hi", "hello", + "thanks", "thank", "sure", "kind", "kinda", "sort", "sorta", "stuff", + "thing", "things", "good", "better", "best", "great", "nice", "way", "ways", + "lot", "lots", "bit", "gonna", "gotta", "lets", "let", "make", "makes", + "made", "making", "get", "gets", "got", "getting", "go", "goes", "going", + "gone", "went", "come", "comes", "came", "coming", "know", "knows", "knew", + "knowing", "see", "sees", "saw", "seen", "look", "looks", "looked", + "looking", "tell", "tells", "told", "say", "says", "said", "much", "many", + "little", "anything", "something", "everything", "nothing", "anyone", + "someone", "everyone", + # filler interjections — a turn that is only these is not a query + "hmm", "hm", "huh", "um", "umm", "uh", "ah", "oh", "eh", "wow", "cool", + "right", "yep", "yup", "nope", "nah", "wait", "hold", "done", "great", + ] +) + + +def _informative_tokens(prompt: str) -> list[str]: + """Order-preserving informative tokens: no stopwords, digits, one-char.""" + seen: set[str] = set() + out: list[str] = [] + for tok in _TOKEN_RE.findall(prompt.lower()): + if len(tok) < 2 or tok.isdigit() or tok in _STOPWORDS or tok in seen: + continue + seen.add(tok) + out.append(tok) + return out + def _render(pack: dict[str, Any]) -> str: lines: list[str] = [] @@ -68,6 +128,8 @@ def _render(pack: dict[str, Any]) -> str: "rewrite", "optimize", "optimise", "debug", "configure", "rebase", "push", "revert", "bump", "replace", "integrate", "scaffold", "draft", "compile", "set", "apply", "convert", "extract", "split", "rework", "port", "cut", + "proceed", "resume", "retry", "finish", "ship", "land", "publish", "tag", + "continue", "go", "keep", "carry", }) @@ -91,6 +153,20 @@ def short_circuit_cfg(cfg: dict) -> tuple[bool, float]: return (enabled, threshold) +def prompt_gate_cfg(cfg: dict) -> bool: + """Read ``retrieval.prompt_gate.enabled`` defensively. + + Absent (the shape every pre-1.6 KB has on disk) means today's behaviour: + every prompt gets an injected block. The starter config ships it on, so + fresh KBs are quiet on prompts that are not asking the KB anything. + """ + retrieval = cfg.get("retrieval") if isinstance(cfg, dict) else None + gate = retrieval.get("prompt_gate") if isinstance(retrieval, dict) else None + if not isinstance(gate, dict): + return False + return bool(gate.get("enabled", False)) + + def _confidence(score: float) -> float: """Squash an unbounded retrieval score into a 0-1 heuristic confidence.""" if score <= 0: @@ -111,14 +187,39 @@ def _top_confidence(pack: Any) -> float: return best +# Politeness and discourse lead-ins sit in front of the real imperative +# ("please fix …", "ok now refactor …", "can you add …"). Skipping them is +# what makes the first-word test survive how people actually type. +_LEAD_INS = frozenset({ + "please", "pls", "now", "then", "also", "next", "ok", "okay", "so", "and", + "hey", "hi", "quick", "quickly", "just", "first", "finally", "lets", "let", + "can", "could", "would", "will", "you", "we", "i", "im", "id", "maybe", + "actually", "again", "still", "ahead", "on", "it", "that", "this", +}) + + def _looks_like_action(prompt: str) -> bool: - """True when the prompt's first real word is an imperative 'do work' verb.""" - for tok in prompt.strip().lower().split(): + """True when the prompt reads as an imperative 'do work' request. + + Scans past politeness / discourse lead-ins to the first substantive word + and tests it against the imperative verb list. A question word reached + that way ("can you tell me what the cadence is") stops the scan: it is a + lookup wearing an imperative's clothes, and lookups keep full recall. + """ + saw_lead_in = False + for tok in prompt.strip().lower().split()[:6]: word = "".join(ch for ch in tok if ch.isalpha()) if not word: continue # skip leading punctuation / emoji tokens - return word in _ACTION_VERBS - return False + if word in _ACTION_VERBS: + return True + if word in _LEAD_INS: + saw_lead_in = True + continue + return False + # Nothing but discourse tokens ("continue", "ok now", "go ahead"): a + # continuation of the work in progress, not a question for the KB. + return saw_lead_in def _envelope(block: str) -> str: @@ -187,18 +288,41 @@ def build_claude_prompt_hook( # Best-effort reflex feed: recording must never break a # working hook (module contract -- see docstring). _log.warning("context-hook: salience record_query failed", exc_info=True) + # The prompt gate decides how much of a turn vouch is entitled to. Two + # kinds of prompt are not asking the KB anything: + # + # * chatter with no informative tokens ("ok thanks", "which one?") — + # retrieval ORs every token, so these used to match on "one" and + # inject noise on turns that wanted none; + # * "do work" imperatives ("fix the failing test", "run the suite") — + # recall can still HELP these (conventions, architecture), but the + # turn belongs to the work, so a miss stays silent and a hit arrives + # as advice rather than as a reply contract. + # + # Lookups — questions, "what/why/how", anything not imperative — keep the + # full visible-recall behaviour, banner and opener included. + gate_on = prompt_gate_cfg(cfg) + tokens = _informative_tokens(prompt) + if gate_on and not tokens: + return "" + work_mode = gate_on and _looks_like_action(prompt) + query = " ".join(tokens) if (gate_on and tokens) else prompt try: pack = build_context_pack( store, - query=prompt, - limit=_MAX_ITEMS, - max_chars=_MAX_CHARS, + query=query, + limit=_WORK_MAX_ITEMS if work_mode else _MAX_ITEMS, + max_chars=_WORK_MAX_CHARS if work_mode else _MAX_CHARS, ) except Exception: _log.warning("context-hook: build_context_pack failed", exc_info=True) return "" body = _render(pack) if isinstance(pack, dict) else "" if not body: + if work_mode: + # Nothing to offer a work request: say nothing at all rather than + # spend the turn's opener announcing an empty search. + return "" # Say so explicitly even when empty — the user wants to see that vouch # was consulted, not silence that looks like vouch did nothing. return _envelope( @@ -207,6 +331,18 @@ def build_claude_prompt_hook( 'the exact words "Nothing in vouch on this." — even if you use tools ' "or explore the codebase first — then answer from your own knowledge." ) + if work_mode: + # Advisory, not a reply contract: the user asked for work, so the + # answer must read as the work — no forced opener, no blockquote + # ritual. Cite only what is actually used. + return _envelope( + f"[vouch memory] This is a work request, so I checked " + f"{_whose_kb(personal)} for background only. Use the items below " + "if they are relevant to the change; ignore them if they are not. " + "Do NOT open your reply with a memory banner — answer as you " + "normally would, and cite an item's [id] inline only where you " + "actually relied on it.\n\n" + body + ) sc_enabled, min_conf = short_circuit_cfg(cfg) if sc_enabled and not _looks_like_action(prompt) and _top_confidence(pack) >= min_conf: diff --git a/src/vouch/storage.py b/src/vouch/storage.py index c1bfd7b7..6fd3b45f 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -121,6 +121,15 @@ def _starter_config() -> dict[str, Any]: "enabled": True, "half_life_days": 90, }, + # decide per prompt how much of the turn vouch is entitled to. + # chatter with no informative tokens gets nothing; a "do work" + # imperative gets a small background pack and no reply contract + # (silent when there is no match); questions keep full visible + # recall. new KBs get it on; existing KBs behave exactly as + # before until they add this key. + "prompt_gate": { + "enabled": True, + }, }, "agents": { "recommended_loop": [ diff --git a/tests/test_hooks.py b/tests/test_hooks.py index d302a280..3da93780 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -253,3 +253,174 @@ def _boom(*a: object, **k: object) -> None: assert "tuesdays" in json.loads(out)["hookSpecificOutput"]["additionalContext"] finally: salience.reset_session(session_id) + + +# --- the prompt gate: which prompts are entitled to a turn ---------------- + + +def _enable_gate(store: KBStore) -> None: + import yaml + + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + cfg.setdefault("retrieval", {})["prompt_gate"] = {"enabled": True} + store.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + +def _disable_gate(store: KBStore) -> None: + import yaml + + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + retrieval = cfg.setdefault("retrieval", {}) + retrieval.pop("prompt_gate", None) + store.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + + +def _hook(store: KBStore, prompt: str) -> str: + return hooks.build_claude_prompt_hook(store, json.dumps({"prompt": prompt})) + + +@pytest.mark.parametrize( + "prompt", + [ + "fix the failing auth test", + "please refactor storage.py", + "ok now add a test for adopt", + "can you run the suite and commit?", + "bump the version", + "continue", + "go ahead", + ], +) +def test_action_prompts_are_classified_as_work(prompt: str) -> None: + assert hooks._looks_like_action(prompt) is True + + +@pytest.mark.parametrize( + "prompt", + [ + "what is our release cutoff?", + "how does the review gate work?", + "why did the container job fail?", + "can you tell me what the deploy cadence is?", + "who owns the deploy?", + "remind me how adopt works", + "the tests are failing, any idea why?", + ], +) +def test_lookup_prompts_are_not_classified_as_work(prompt: str) -> None: + assert hooks._looks_like_action(prompt) is False + + +def test_work_prompt_with_a_miss_injects_nothing( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """The whole point: a coding command the KB knows nothing about must not + spend the turn's opener announcing an empty search.""" + _enable_gate(store) + monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + assert _hook(store, "fix the flaky websocket reconnect") == "" + + +def test_work_prompt_with_a_hit_is_advisory_not_a_contract( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Recall still helps the work — it just must not force the reply shape.""" + _enable_gate(store) + _force_hit(monkeypatch) + out = _hook(store, "fix the tuesday deploy script") + assert out # context is still injected + block = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert "deploys run on tuesdays" in block + assert "work request" in block + assert "Do NOT open your reply with a memory banner" in block + assert "MUST open with" not in block + assert "From vouch memory:" not in block + + +def test_lookup_prompt_keeps_the_visible_recall_contract( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + _enable_gate(store) + _force_hit(monkeypatch) + out = _hook(store, "what day do deploys run?") + block = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert 'MUST open with the exact words "From vouch memory:"' in block + assert "work request" not in block + + +def test_lookup_prompt_with_a_miss_still_says_so( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A question the KB cannot answer keeps the honest 'nothing' answer — + silence there is indistinguishable from vouch being broken.""" + _enable_gate(store) + monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + out = _hook(store, "what is our incident escalation policy?") + block = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert '"Nothing in vouch on this."' in block + + +@pytest.mark.parametrize("prompt", ["ok thanks", "yes", "which one is better?", "hmm"]) +def test_chatter_injects_nothing( + store: KBStore, monkeypatch: pytest.MonkeyPatch, prompt: str +) -> None: + """Retrieval ORs every token, so 'which one' used to match on 'one'.""" + _enable_gate(store) + _force_hit(monkeypatch) + assert _hook(store, prompt) == "" + + +def test_work_prompts_get_a_smaller_pack( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Recall should inform the work, not crowd the turn it is helping.""" + _enable_gate(store) + _force_hit(monkeypatch) + seen: dict[str, int] = {} + + real = context.build_context_pack + + def _spy(store_arg, **kwargs): + seen["limit"] = kwargs.get("limit") + seen["max_chars"] = kwargs.get("max_chars") + return real(store_arg, **kwargs) + + monkeypatch.setattr(hooks, "build_context_pack", _spy) + _hook(store, "fix the tuesday deploy script") + assert seen["limit"] == hooks._WORK_MAX_ITEMS + assert seen["max_chars"] == hooks._WORK_MAX_CHARS + _hook(store, "what day do deploys run?") + assert seen["limit"] == hooks._MAX_ITEMS + assert seen["max_chars"] == hooks._MAX_CHARS + + +def test_gate_absent_keeps_todays_behaviour( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Existing KBs have no prompt_gate key on disk and must not change.""" + _disable_gate(store) + monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + out = _hook(store, "fix the flaky websocket reconnect") + block = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert '"Nothing in vouch on this."' in block + # chatter, too: unchanged without the key + assert _hook(store, "ok thanks") != "" + + +def test_starter_config_ships_the_gate_on(tmp_path: Path) -> None: + import yaml + + fresh = KBStore.init(tmp_path / "fresh") + cfg = yaml.safe_load(fresh.config_path.read_text(encoding="utf-8")) + assert cfg["retrieval"]["prompt_gate"]["enabled"] is True + assert hooks.prompt_gate_cfg(cfg) is True + + +def test_prompt_gate_cfg_is_defensive() -> None: + assert hooks.prompt_gate_cfg({}) is False + assert hooks.prompt_gate_cfg({"retrieval": "nonsense"}) is False + assert hooks.prompt_gate_cfg({"retrieval": {"prompt_gate": []}}) is False + assert hooks.prompt_gate_cfg({"retrieval": {"prompt_gate": {}}}) is False From 8d07646a899463d6ab77e1cdc39fcec51bfeed02 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:00 +0900 Subject: [PATCH 11/63] refactor(hooks): delegate the recall decision to the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the first cut of the prompt gate enumerated "action verbs" and defaulted everything else to a loud "From vouch memory:" block. that fails open: action verbs are an unbounded class, so any verb not on the list (vendorize, dockerize, spin-up, knock-out, yarn) fell through to loud recall — the exact noise the gate exists to stop. so stop making the hook guess. when the gate is on it now hands the host model ONE conditional instruction and lets the model — which already reads the prompt — choose per turn: a QUESTION the items answer opens with "From vouch memory:" and quotes them; a TASK (fix/build/change/run anything, known verb or not) uses them silently as background with no banner, citing an id inline only where relied on; irrelevant items are ignored. on a miss the same judgment applies — "Nothing in vouch on this." for a question, silence for a task. chatter with no informative tokens still injects nothing. no per-turn model call, no latency — the decision rides in instruction text the model already processes, and it generalizes to any phrasing because it no longer depends on recognizing the verb. the legacy unconditional block is kept intact behind the gate flag, so existing KBs are byte-for-byte unchanged until they add retrieval.prompt_gate; new KBs get delegation via the starter config. compliance was MEASURED before shipping, not assumed — real claude -p across haiku / sonnet / opus, offline and against the real context-hook pipeline. coding tasks were never wrongly announced on any tier (the goal). questions are announced by every tier in the real pipeline; the only misses were sonnet under-announcing when a test harness fed a KB-claiming block into an empty dir with hooks off, which it correctly distrusted — resolved in-environment. tests/test_hooks.py pins the injected block; tests/test_prompt_gate_live.py is an opt-in live probe (skips without an authenticated claude cli). --- CHANGELOG.md | 40 +++++----- src/vouch/hooks.py | 122 ++++++++++++++++++----------- tests/test_hooks.py | 136 +++++++++++++++++++++------------ tests/test_prompt_gate_live.py | 104 +++++++++++++++++++++++++ 4 files changed, 291 insertions(+), 111 deletions(-) create mode 100644 tests/test_prompt_gate_live.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f629eebc..0704c6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,24 +7,28 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Changed -- **the per-prompt hook now decides how much of a turn it is entitled - to** (`retrieval.prompt_gate`). recall used to inject a block on every - prompt, so "fix the failing test" spent its opener announcing a memory - search nobody asked for. three cases now: - *chatter* with no informative tokens ("ok thanks", "which one is - better?") injects nothing — retrieval ORs every query token, so those - used to match on `one` and pull noise into turns that wanted none; - *"do work" imperatives* ("fix …", "refactor …", "run the tests", - "continue") get a smaller background pack (3 items / 700 chars) with - **no reply contract** — no "From vouch memory:" opener, no blockquote - ritual, cite an id inline only where it was actually used — and inject - nothing at all when the KB has no match; *lookups* — questions, - "what/why/how", anything not imperative — keep the full visible-recall - behaviour, including the honest "Nothing in vouch on this." on a miss. - the classifier is deterministic and llm-free (an imperative-verb test - that reads past politeness lead-ins like "please" / "ok now" / "can - you"), so it adds no latency to a turn. new KBs get it on; existing KBs - behave exactly as before until they add the key. +- **the per-prompt hook now lets the model decide how much of a turn + vouch takes** (`retrieval.prompt_gate`). recall used to inject an + unconditional "open with From vouch memory:" block on *every* prompt, + so "fix the failing test" spent its reply opener announcing a memory + search nobody asked for. rather than have the hook guess the prompt's + intent with a verb list (which never covers the next phrasing), it now + hands the host model ONE conditional instruction and lets the model — + which already reads the prompt — choose per turn: a *question* the + items answer opens with "From vouch memory:" and quotes them; a *task* + (fix / build / change / run anything, known verb or not) uses them + silently as background, citing an id inline only where relied on, with + no banner; *irrelevant* items are ignored. on a miss the same judgment + applies — "Nothing in vouch on this." for a question, silence for a + task. chatter with no informative tokens ("ok thanks", "which one is + better?") injects nothing at all (retrieval ORs every query token, so + those matched noise on `one`). no per-turn model call and no latency — + the decision rides in the instruction text the model already + processes; it generalizes to any phrasing or language because it no + longer depends on recognizing the verb. compliance measured on real + `claude -p` across haiku / sonnet / opus (KB-backed block): coding + tasks are never wrongly announced. new KBs get it on; existing KBs + keep the unconditional block until they add the key. - **personal catch-all KB + `vouch adopt`** (global vouch, phase 3): `vouch hub init-personal` creates and registers a personal KB at `~/.local/share/vouch/personal` (`XDG_DATA_HOME` honoured; diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index 45c2582a..adce644f 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -33,11 +33,6 @@ _MAX_ITEMS = 8 _MAX_CHARS = 2000 -# A "do work" prompt gets a smaller pack: recall should inform the work, not -# crowd the turn it is trying to help. -_WORK_MAX_ITEMS = 3 -_WORK_MAX_CHARS = 700 - _TOKEN_RE = re.compile(r"\w+") # Retrieval ORs every query token (see index_db._quote_match), so a prompt @@ -288,41 +283,89 @@ def build_claude_prompt_hook( # Best-effort reflex feed: recording must never break a # working hook (module contract -- see docstring). _log.warning("context-hook: salience record_query failed", exc_info=True) - # The prompt gate decides how much of a turn vouch is entitled to. Two - # kinds of prompt are not asking the KB anything: - # - # * chatter with no informative tokens ("ok thanks", "which one?") — - # retrieval ORs every token, so these used to match on "one" and - # inject noise on turns that wanted none; - # * "do work" imperatives ("fix the failing test", "run the suite") — - # recall can still HELP these (conventions, architecture), but the - # turn belongs to the work, so a miss stays silent and a hit arrives - # as advice rather than as a reply contract. - # - # Lookups — questions, "what/why/how", anything not imperative — keep the - # full visible-recall behaviour, banner and opener included. - gate_on = prompt_gate_cfg(cfg) - tokens = _informative_tokens(prompt) - if gate_on and not tokens: - return "" - work_mode = gate_on and _looks_like_action(prompt) - query = " ".join(tokens) if (gate_on and tokens) else prompt + # The prompt gate (retrieval.prompt_gate) decides how vouch spends the + # turn. OFF (every pre-1.6 KB on disk) → the unconditional 1.6-era block, + # exactly as before. ON (starter default) → hand the MODEL one conditional + # instruction and let it choose, per turn, whether to surface memory + # loudly (a question), use it silently (a task), or ignore it (irrelevant). + # No verb lists, no per-turn model call — just instruction text the model + # already reads. Measured compliance (real claude -p, 3 tiers): tasks are + # never wrongly announced; questions are announced by haiku/opus, and by + # sonnet once the block is KB-backed (not a hand-crafted test string). + if not prompt_gate_cfg(cfg): + return _legacy_injection(store, prompt, cfg, personal) + return _gated_injection(store, prompt, cfg, personal) + + +def _retrieve_body(store: KBStore, query: str) -> tuple[Any, str]: + """Run the context-pack retrieval; (pack, rendered_body). ("", "") on error.""" try: pack = build_context_pack( - store, - query=query, - limit=_WORK_MAX_ITEMS if work_mode else _MAX_ITEMS, - max_chars=_WORK_MAX_CHARS if work_mode else _MAX_CHARS, + store, query=query, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, ) except Exception: _log.warning("context-hook: build_context_pack failed", exc_info=True) + return None, "" + return pack, (_render(pack) if isinstance(pack, dict) else "") + + +def _gated_injection( + store: KBStore, prompt: str, cfg: dict[str, Any], personal: bool +) -> str: + """Model-delegated recall: retrieve, hand over ONE conditional instruction, + let the model decide loud vs silent vs ignore from the prompt itself.""" + tokens = _informative_tokens(prompt) + if not tokens: + # Nothing worth searching on ("ok thanks", "which one is better?"). + # Retrieval ORs every token, so these matched noise on "one"/"better". return "" - body = _render(pack) if isinstance(pack, dict) else "" + pack, body = _retrieve_body(store, " ".join(tokens)) + whose = _whose_kb(personal) + if not body: + # A miss leaves the visible-recall promise to the MODEL: say "Nothing + # in vouch" only if this turn was actually a question; a task or small + # talk gets a clean answer with no vouch mention. + return _envelope( + f"[vouch memory] I searched {whose} for this prompt and found " + "nothing relevant. If this turn is a direct QUESTION to project " + 'memory, briefly say "Nothing in vouch on this." then answer from ' + "your own knowledge. Otherwise (a task, or small talk) answer " + "normally and do not mention vouch." + ) + sc_enabled, min_conf = short_circuit_cfg(cfg) + stop_clause = "" + if sc_enabled and _top_confidence(pack) >= min_conf: + # A high-confidence hit MAY collapse — but only for a question the item + # fully answers. A task never "fully answers", so the model won't stop. + stop_clause = ( + "\n- If a single item fully and directly answers a QUESTION here, " + 'you may reply with just its "From vouch memory:" quote and stop.' + ) + return _envelope( + f"[vouch memory] I checked {whose} for this prompt. Related approved " + "items are below. Decide, from THIS prompt, how to use them:\n" + "- If the prompt is ASKING something these items answer, treat them as " + 'the answer: open your reply with the exact words "From vouch memory:" ' + "and render each item you use as its own markdown blockquote line " + "ending in its [id]. That visible opener is how the user sees recall " + "ran.\n" + "- If the prompt is a TASK (fix / build / change / run something), use " + "the items silently as background: cite an item's [id] inline only " + "where you actually rely on it, and do NOT open with a memory banner — " + "answer as the work.\n" + "- If none are actually relevant to this prompt, ignore them and answer " + "normally without mentioning vouch." + stop_clause + "\n\n" + body + ) + + +def _legacy_injection( + store: KBStore, prompt: str, cfg: dict[str, Any], personal: bool +) -> str: + """Pre-gate behaviour, unchanged for existing KBs: an unconditional recall + block (or the explicit "Nothing in vouch" note), with the opt-in confidence + short-circuit gated on the imperative-verb heuristic.""" + pack, body = _retrieve_body(store, prompt) if not body: - if work_mode: - # Nothing to offer a work request: say nothing at all rather than - # spend the turn's opener announcing an empty search. - return "" # Say so explicitly even when empty — the user wants to see that vouch # was consulted, not silence that looks like vouch did nothing. return _envelope( @@ -331,19 +374,6 @@ def build_claude_prompt_hook( 'the exact words "Nothing in vouch on this." — even if you use tools ' "or explore the codebase first — then answer from your own knowledge." ) - if work_mode: - # Advisory, not a reply contract: the user asked for work, so the - # answer must read as the work — no forced opener, no blockquote - # ritual. Cite only what is actually used. - return _envelope( - f"[vouch memory] This is a work request, so I checked " - f"{_whose_kb(personal)} for background only. Use the items below " - "if they are relevant to the change; ignore them if they are not. " - "Do NOT open your reply with a memory banner — answer as you " - "normally would, and cite an item's [id] inline only where you " - "actually relied on it.\n\n" + body - ) - sc_enabled, min_conf = short_circuit_cfg(cfg) if sc_enabled and not _looks_like_action(prompt) and _top_confidence(pack) >= min_conf: # High-confidence lookup: let the model collapse to a near-instant diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 3da93780..05fac6c9 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -48,14 +48,16 @@ def test_relevant_prompt_yields_additional_context( def test_relevant_prompt_banner_instructs_from_vouch_memory( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """The block is instructional: the model is told to open its reply with - "From vouch memory:" and ground in the cited items — that visible opener - is the user-facing proof recall ran.""" + """The block is instructional: for a question the model is told to open + with "From vouch memory:" and render items as blockquotes — that visible + opener is the user-facing proof recall ran. (The starter `store` fixture + ships the prompt gate on, so this is the model-delegated conditional + block; the legacy unconditional wording is covered separately.)""" _force_hit(monkeypatch) out = hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "when do deploys run"})) ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"] assert ctx.startswith("[vouch memory]") - assert 'MUST open with the exact words "From vouch memory:"' in ctx + assert 'open your reply with the exact words "From vouch memory:"' in ctx # recalled facts must render visually distinct from the model's own words assert "markdown blockquote" in ctx @@ -311,55 +313,65 @@ def test_lookup_prompts_are_not_classified_as_work(prompt: str) -> None: assert hooks._looks_like_action(prompt) is False -def test_work_prompt_with_a_miss_injects_nothing( - store: KBStore, monkeypatch: pytest.MonkeyPatch -) -> None: - """The whole point: a coding command the KB knows nothing about must not - spend the turn's opener announcing an empty search.""" - _enable_gate(store) - monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) - monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) - assert _hook(store, "fix the flaky websocket reconnect") == "" +def _enable_gate_and_short_circuit(store: KBStore, min_conf: float = 0.8) -> None: + import yaml + + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + retrieval = cfg.setdefault("retrieval", {}) + retrieval["prompt_gate"] = {"enabled": True} + retrieval["short_circuit"] = {"enabled": True, "min_confidence": min_conf} + store.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") -def test_work_prompt_with_a_hit_is_advisory_not_a_contract( +def test_gate_hit_injects_one_conditional_block( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """Recall still helps the work — it just must not force the reply shape.""" + """Model-delegated: a hit hands the model ONE block carrying all three + branches (announce / silent / ignore) — not a fixed reply contract.""" _enable_gate(store) _force_hit(monkeypatch) out = _hook(store, "fix the tuesday deploy script") - assert out # context is still injected block = json.loads(out)["hookSpecificOutput"]["additionalContext"] - assert "deploys run on tuesdays" in block - assert "work request" in block - assert "Do NOT open your reply with a memory banner" in block - assert "MUST open with" not in block - assert "From vouch memory:" not in block - - -def test_lookup_prompt_keeps_the_visible_recall_contract( + assert "deploys run on tuesdays" in block # the item rode along + # question branch — announce + assert 'open your reply with the exact words "From vouch memory:"' in block + # task branch — stay silent + assert "do NOT open with a memory banner" in block + # irrelevant branch — ignore + assert "ignore them" in block + # NOT the old unconditional contract + assert "MUST open with the exact words" not in block + + +def test_gate_injects_the_same_block_for_task_and_question( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: + """The hook no longer classifies intent: the SAME conditional block is + injected whether the prompt looks like a task or a question — the model + makes the call, which is what lets it generalize to any phrasing.""" _enable_gate(store) _force_hit(monkeypatch) - out = _hook(store, "what day do deploys run?") - block = json.loads(out)["hookSpecificOutput"]["additionalContext"] - assert 'MUST open with the exact words "From vouch memory:"' in block - assert "work request" not in block + task = json.loads(_hook(store, "vendorize the deploy deps")) + ques = json.loads(_hook(store, "what day do deploys run?")) + assert ( + task["hookSpecificOutput"]["additionalContext"] + == ques["hookSpecificOutput"]["additionalContext"] + ) -def test_lookup_prompt_with_a_miss_still_says_so( +def test_gate_miss_defers_the_nothing_message_to_the_model( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """A question the KB cannot answer keeps the honest 'nothing' answer — - silence there is indistinguishable from vouch being broken.""" + """On a miss the model decides: say "Nothing in vouch" only for a + question; stay silent (no vouch mention) for a task.""" _enable_gate(store) monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) - out = _hook(store, "what is our incident escalation policy?") + out = _hook(store, "fix the flaky websocket reconnect") block = json.loads(out)["hookSpecificOutput"]["additionalContext"] - assert '"Nothing in vouch on this."' in block + assert "found nothing relevant" in block + assert '"Nothing in vouch on this."' in block # only if a question + assert "do not mention vouch" in block # otherwise silent @pytest.mark.parametrize("prompt", ["ok thanks", "yes", "which one is better?", "hmm"]) @@ -372,41 +384,71 @@ def test_chatter_injects_nothing( assert _hook(store, prompt) == "" -def test_work_prompts_get_a_smaller_pack( +def test_gate_uses_one_pack_size( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """Recall should inform the work, not crowd the turn it is helping.""" + """Model-delegation retrieves ONE pack and lets the model select from it — + there is no task/question pack-size split to get wrong.""" _enable_gate(store) _force_hit(monkeypatch) - seen: dict[str, int] = {} - + seen: list[tuple] = [] real = context.build_context_pack def _spy(store_arg, **kwargs): - seen["limit"] = kwargs.get("limit") - seen["max_chars"] = kwargs.get("max_chars") + seen.append((kwargs.get("limit"), kwargs.get("max_chars"))) return real(store_arg, **kwargs) monkeypatch.setattr(hooks, "build_context_pack", _spy) - _hook(store, "fix the tuesday deploy script") - assert seen["limit"] == hooks._WORK_MAX_ITEMS - assert seen["max_chars"] == hooks._WORK_MAX_CHARS + _hook(store, "fix the deploy script") _hook(store, "what day do deploys run?") - assert seen["limit"] == hooks._MAX_ITEMS - assert seen["max_chars"] == hooks._MAX_CHARS + assert set(seen) == {(hooks._MAX_ITEMS, hooks._MAX_CHARS)} + + +def test_gate_short_circuit_adds_a_stop_clause( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A high-confidence hit under the gate gains a 'you may stop' clause — but + scoped to a question the item fully answers, so a task can't collapse.""" + _enable_gate_and_short_circuit(store) + _stub_pack(monkeypatch, score=50.0) # conf ~1.0 >= 0.8 + out = _hook(store, "what day do deploys run?") + block = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert "you may reply with just its" in block + assert "and stop" in block + # without short_circuit the clause is absent + import yaml + + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + cfg["retrieval"].pop("short_circuit", None) + store.config_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + out2 = _hook(store, "what day do deploys run?") + assert "you may reply with just its" not in ( + json.loads(out2)["hookSpecificOutput"]["additionalContext"] + ) + + +def test_gate_absent_hit_keeps_legacy_contract( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """Existing KBs (no prompt_gate key) keep the unconditional 1.5-era block.""" + _disable_gate(store) + _force_hit(monkeypatch) + out = _hook(store, "fix the flaky websocket reconnect") + block = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert 'MUST open with the exact words "From vouch memory:"' in block + assert "Decide, from THIS prompt" not in block # not the conditional block -def test_gate_absent_keeps_todays_behaviour( +def test_gate_absent_miss_forces_the_nothing_banner( store: KBStore, monkeypatch: pytest.MonkeyPatch ) -> None: - """Existing KBs have no prompt_gate key on disk and must not change.""" _disable_gate(store) monkeypatch.setattr(context.index_db, "search_semantic", lambda *a, **k: []) monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) out = _hook(store, "fix the flaky websocket reconnect") block = json.loads(out)["hookSpecificOutput"]["additionalContext"] assert '"Nothing in vouch on this."' in block - # chatter, too: unchanged without the key + # legacy does not gate chatter either — it still injects assert _hook(store, "ok thanks") != "" diff --git a/tests/test_prompt_gate_live.py b/tests/test_prompt_gate_live.py new file mode 100644 index 00000000..37affc3f --- /dev/null +++ b/tests/test_prompt_gate_live.py @@ -0,0 +1,104 @@ +"""Live compliance probe for the model-delegated prompt gate. + +The prompt gate hands the host model ONE conditional instruction and trusts it +to choose loud recall (a question), silent background (a task), or ignore. Unit +tests pin the injected *block*; this probe measures whether a real model +actually *obeys* it — the half a unit test cannot cover. + +Skipped by default (needs a live, authenticated `claude` CLI, so it is useless +in headless CI, exactly like ``test_openclaw_plugin_load_real``). Opt in: + + VOUCH_LIVE_EVAL=1 pytest tests/test_prompt_gate_live.py -v + +Measured once on 2026-07-21 (real `claude -p`, KB-backed block, 3 tiers): +tasks were never wrongly announced on any tier; questions were announced by +every tier once the block came from a real KB (not a hand-crafted string). +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from vouch import hooks +from vouch.models import Claim +from vouch.storage import KBStore + +pytestmark = pytest.mark.skipif( + os.environ.get("VOUCH_LIVE_EVAL") != "1" or shutil.which("claude") is None, + reason="live probe: set VOUCH_LIVE_EVAL=1 with an authenticated `claude` CLI", +) + +MODEL = os.environ.get("VOUCH_EVAL_MODEL", "haiku") + +# (prompt, expected) — banner | no_banner | nothing +CASES = [ + ("what day do deploys run?", "banner"), + ("how often does staging refresh?", "banner"), + ("fix the deploy script for the tuesday release", "no_banner"), + ("vendorize the deploy dependencies", "no_banner"), + ("what is our incident escalation policy?", "nothing"), + ("fix the flaky websocket reconnect timeout", "no_banner"), +] + + +def _seed(store: KBStore) -> None: + facts = [ + "deploys run every second tuesday", + "the staging environment refreshes nightly at 02:00 utc", + "rollbacks use the blue-green switch, never a redeploy of an old tag", + ] + for i, text in enumerate(facts): + src = store.put_source(text.encode()) + store.put_claim(Claim(id=f"f{i}", text=text, evidence=[src.id])) + + +def _block(store: KBStore, prompt: str) -> str: + out = hooks.build_claude_prompt_hook(store, json.dumps({"prompt": prompt})) + if not out: + return "" + return json.loads(out)["hookSpecificOutput"]["additionalContext"] + + +def _ask_model(block: str, prompt: str, workdir: Path) -> str: + payload = f"{block}\n\n{prompt}" if block else prompt + res = subprocess.run( + ["claude", "-p", "--settings", '{"hooks":{}}', "--model", MODEL, + "--disallowed-tools", "Bash", "Read", "Edit", "Write", "Glob", + "Grep", "WebFetch", "WebSearch", "Task"], + input=payload, capture_output=True, text=True, timeout=180, cwd=str(workdir), + ) + return (res.stdout or "").strip() + + +def _obeys(expected: str, reply: str) -> bool: + head = reply.strip().lower() + banner = head.startswith("from vouch memory") + if expected == "banner": + return banner + if expected == "no_banner": + return not banner + if expected == "nothing": + return "nothing in vouch" in head[:200] + return False + + +def test_model_obeys_the_conditional_block(tmp_path: Path) -> None: + store = KBStore.init(tmp_path / "kb") + _seed(store) + failures: list[str] = [] + for prompt, expected in CASES: + reply = _ask_model(_block(store, prompt), prompt, tmp_path) + if not _obeys(expected, reply): + failures.append(f"{expected!r} for {prompt!r}: {reply[:100]!r}") + # A task must NEVER be wrongly announced — that is the whole point, and it + # held on every tier in the offline eval. Questions may occasionally + # under-announce on a weaker tier; allow one soft miss there. + task_fails = [f for f in failures if "no_banner" in f] + assert not task_fails, f"tasks wrongly announced ({MODEL}): {task_fails}" + assert len(failures) <= 1, f"too many misses on {MODEL}: {failures}" From cca342dcb4e7b8e30e85206425a2968380881780 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:24:55 +0900 Subject: [PATCH 12/63] fix(hooks): keep multi-digit query tokens; never assert an empty kb on a retrieval error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two coderabbit findings on the prompt-gate pr, both real: the informative-token filter dropped every all-digit token, losing issue ids, error codes (404/500), and ports (8080) — exactly the terms a technical query needs. the len < 2 check already drops single-digit noise, so only tok.isdigit() had to go. _retrieve_body returned (None, "") on a build_context_pack exception, which both injection paths saw as a genuine empty result — so a broken index made the model falsely assert "Nothing in vouch on this." it now raises, and the hook dispatch catches any retrieval/render error and injects NOTHING, restoring the pre-gate fail-safe. a true empty search (no exception) still gets the honest miss message. --- src/vouch/hooks.py | 36 ++++++++++++++++++++++++------------ tests/test_hooks.py | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/vouch/hooks.py b/src/vouch/hooks.py index adce644f..e01f6655 100644 --- a/src/vouch/hooks.py +++ b/src/vouch/hooks.py @@ -81,7 +81,10 @@ def _informative_tokens(prompt: str) -> list[str]: seen: set[str] = set() out: list[str] = [] for tok in _TOKEN_RE.findall(prompt.lower()): - if len(tok) < 2 or tok.isdigit() or tok in _STOPWORDS or tok in seen: + # keep multi-digit tokens — issue ids (#12345), error codes (404/500), + # and ports (8080) are exactly the terms a technical query needs; + # len < 2 already drops single-digit noise. + if len(tok) < 2 or tok in _STOPWORDS or tok in seen: continue seen.add(tok) out.append(tok) @@ -292,20 +295,29 @@ def build_claude_prompt_hook( # already reads. Measured compliance (real claude -p, 3 tiers): tasks are # never wrongly announced; questions are announced by haiku/opus, and by # sonnet once the block is KB-backed (not a hand-crafted test string). - if not prompt_gate_cfg(cfg): - return _legacy_injection(store, prompt, cfg, personal) - return _gated_injection(store, prompt, cfg, personal) + try: + if not prompt_gate_cfg(cfg): + return _legacy_injection(store, prompt, cfg, personal) + return _gated_injection(store, prompt, cfg, personal) + except Exception: + # Fail-safe: any retrieval/render error injects NOTHING, never a false + # "nothing in vouch" claim on a path where the search did not complete. + _log.warning("context-hook: injection failed", exc_info=True) + return "" def _retrieve_body(store: KBStore, query: str) -> tuple[Any, str]: - """Run the context-pack retrieval; (pack, rendered_body). ("", "") on error.""" - try: - pack = build_context_pack( - store, query=query, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, - ) - except Exception: - _log.warning("context-hook: build_context_pack failed", exc_info=True) - return None, "" + """Run the context-pack retrieval; returns (pack, rendered_body). + + Raises on retrieval failure — the caller must NOT treat a broken index or + bad config as an empty result. A genuine empty search asserts "nothing in + vouch" to the model; a retrieval error must stay silent (inject nothing), + exactly as the pre-gate hook did, so vouch never makes a false claim about + KB contents on a fail-safe path. + """ + pack = build_context_pack( + store, query=query, limit=_MAX_ITEMS, max_chars=_MAX_CHARS, + ) return pack, (_render(pack) if isinstance(pack, dict) else "") diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 05fac6c9..3c4a1942 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -170,6 +170,30 @@ def _boom(*a: object, **k: object) -> object: assert hooks.build_claude_prompt_hook(store, json.dumps({"prompt": "x"})) == "" +def test_retrieval_error_is_not_reported_as_an_empty_kb( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A retrieval FAILURE must inject nothing — never a false "Nothing in + vouch" claim, which is what a broken index would otherwise assert. Covers + both the gated and the legacy path.""" + def _boom(*a: object, **k: object) -> object: + raise RuntimeError("index is corrupt") + + monkeypatch.setattr(hooks, "build_context_pack", _boom) + _enable_gate(store) + assert _hook(store, "what is our deploy cadence?") == "" + _disable_gate(store) + assert _hook(store, "what is our deploy cadence?") == "" + + +def test_informative_tokens_keep_multidigit_numbers() -> None: + """Issue ids, error codes, and ports are load-bearing search terms.""" + toks = hooks._informative_tokens("why does endpoint 8080 return 500 on issue 12345") + assert "8080" in toks and "500" in toks and "12345" in toks + # single-digit noise is still dropped + assert "5" not in hooks._informative_tokens("retry 5 times") + + def test_context_hook_cli_always_exits_zero_without_kb( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From a19beec98034096326a0ae5b5b770d1f7b8a4ed2 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:28:20 +0900 Subject: [PATCH 13/63] feat(hub): gated federation import lands inbound knowledge as proposals the receiving side of vouchhub. inbound bundle knowledge no longer writes straight to the committed store: bundle.import_as_proposals files each inbound claim as a pending proposal via propose_claim, so nothing lands without passing this kb's own proposals.approve() -- the load-bearing invariant (roadmap step 10) upheld by design, not by surface removal. - import_as_proposals: sources/evidence registered as substrate (the inbox shape), claims filed as pending proposals; pages/entities/relations reported as deferred, never silently dropped. - provenance: the proposing actor is hub: and each proposed claim carries an origin: tag that survives approval, so a reviewer and the durable claim both show which kb vouched. - kb instance id: a lazy per-kb identity in a .vouch/instance_id sidecar, stamped into the bundle manifest as kb_id; kept out of config and every exported subdir so identity never travels inside a bundle or collides with a receiver's config on import. - hub pull rewired to the gated path (no on-conflict -- a colliding claim is another proposal for the reviewer, not a destructive overwrite); new vouch import-proposals cli for file-based gated federation. tests: test_gated_import.py covers the gated lifecycle, provenance, conformance (only approve makes it durable), and instance id; the hub pull tests are rewired to assert proposals instead of committed writes. full suite green, mypy clean. --- src/vouch/bundle.py | 156 ++++++++++++++++++++++++++++++++++++- src/vouch/cli.py | 42 +++++++--- src/vouch/hub_client.py | 42 ++++++---- src/vouch/storage.py | 31 ++++++++ tests/test_gated_import.py | 141 +++++++++++++++++++++++++++++++++ tests/test_hub_client.py | 92 +++++++++++----------- 6 files changed, 430 insertions(+), 74 deletions(-) create mode 100644 tests/test_gated_import.py diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index dbd6efed..a6b73f2e 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -29,7 +29,7 @@ from . import audit from .models import Claim, Entity, Evidence, Proposal, Relation, Session, Source -from .storage import _deserialize_page, sha256_hex +from .storage import _deserialize_page, read_or_create_instance_id, sha256_hex MANIFEST_NAME = "manifest.json" SPEC_VERSION = "vouch-bundle-0.1" @@ -97,7 +97,9 @@ def _iter_export_files(kb_dir: Path, exclude: tuple[str, ...] = ()): yield cfg.relative_to(kb_dir), cfg -def build_manifest(kb_dir: Path, exclude: tuple[str, ...] = ()) -> dict[str, Any]: +def build_manifest( + kb_dir: Path, exclude: tuple[str, ...] = (), *, kb_id: str | None = None +) -> dict[str, Any]: exclude = _check_exclude(exclude) files: list[dict[str, Any]] = [] for rel, abs_path in _iter_export_files(kb_dir, exclude): @@ -120,6 +122,7 @@ def build_manifest(kb_dir: Path, exclude: tuple[str, ...] = ()) -> dict[str, Any return { "spec": SPEC_VERSION, "bundle_id": h.hexdigest(), + "kb_id": kb_id, "files": files, "counts": { sub: sum(1 for f in files if f["path"].startswith(f"{sub}/")) for sub in EXPORT_SUBDIRS @@ -141,7 +144,7 @@ def export( exclude: tuple[str, ...] = (), ) -> dict[str, Any]: exclude = _check_exclude(exclude) - manifest = build_manifest(kb_dir, exclude) + manifest = build_manifest(kb_dir, exclude, kb_id=read_or_create_instance_id(kb_dir)) dest.parent.mkdir(parents=True, exist_ok=True) with tarfile.open(dest, "w:gz") as tar: for rel, abs_path in _iter_export_files(kb_dir, exclude): @@ -686,5 +689,152 @@ def import_apply( return result +def _manifest_kb_id(bundle_path: Path) -> str | None: + """The exporting KB's instance id, if the bundle manifest carries one. + + Older bundles predate `kb_id`; returns None so the caller can fall back to an + explicitly-supplied origin. + """ + try: + with tarfile.open(bundle_path, "r:gz") as tar: + raw = tar.extractfile(tar.getmember(MANIFEST_NAME)).read() # type: ignore[union-attr] + manifest = json.loads(raw.decode()) + except (OSError, tarfile.TarError, KeyError, json.JSONDecodeError): + return None + kb_id = manifest.get("kb_id") + return str(kb_id) if kb_id else None + + +def import_as_proposals( + kb_dir: Path, + bundle_path: Path, + *, + origin_kb: str | None = None, + actor: str | None = None, +) -> dict[str, Any]: + """Land a bundle's knowledge as PENDING PROPOSALS, never as committed writes. + + The gated counterpart to `import_apply`. Instead of writing claims straight + into the decided store, every inbound claim is filed as a pending claim + proposal via `proposals.propose_claim`, so nothing lands without passing this + KB's own `proposals.approve()` -- the receiving-side gate the federation + invariant requires (ROADMAP.md step 10: "any data path that lands writes + without a receiving-side proposal is wrong"). + + Sources and evidence -- raw substrate, not vouched knowledge -- are + registered directly (the same shape `inbox.scan` uses before it proposes a + page), because a claim proposal must be able to cite them. Provenance is + preserved: the proposing actor names the origin KB and each proposed claim + carries an ``origin:`` tag that survives approval, so a reviewer -- and + the approved claim -- can always see which KB vouched for it. + + Pages, entities and relations are reported under ``deferred`` rather than + silently dropped: filing them as proposals needs claim/entity ids that are + themselves still pending, which is follow-up work -- a silent skip would read + as "imported everything". + """ + # Local imports: proposals/storage import from this module's siblings, and a + # top-level import here would risk a cycle at package load. + from . import proposals as _proposals + from .models import Claim, Evidence + from .storage import KBStore + + check = import_check(kb_dir, bundle_path) + if check.issues: + raise RuntimeError(f"refusing to import: {check.issues[0]}") + + origin = origin_kb or _manifest_kb_id(bundle_path) or "unknown" + proposing_actor = actor or f"hub:{origin}" + store = KBStore(kb_dir.parent) + + proposed: list[str] = [] + failed: list[dict[str, str]] = [] + sources_registered = 0 + evidence_registered = 0 + deferred = {"pages": 0, "entities": 0, "relations": 0} + + with tarfile.open(bundle_path, "r:gz") as tar: + members = [m for m in tar.getmembers() if m.isfile() and m.name != MANIFEST_NAME] + + # Pass 1: register substrate so claim proposals can cite it. + contents: dict[str, bytes] = {} + for m in members: + if m.name.startswith("sources/") and m.name.endswith("/content"): + sha = m.name.split("/")[1] + contents[sha] = tar.extractfile(m).read() # type: ignore[union-attr] + for _sha, body in sorted(contents.items()): + store.put_source(body) + sources_registered += 1 + for m in members: + if m.name.startswith("evidence/") and m.name.endswith(".yaml"): + ev = Evidence.model_validate( + yaml.safe_load(tar.extractfile(m).read()) # type: ignore[union-attr] + ) + store.put_evidence(ev) + evidence_registered += 1 + + # Pass 2: file each inbound claim as a PENDING proposal. + origin_tag = f"origin:{origin}" + for m in members: + if m.name.startswith("claims/") and m.name.endswith(".yaml"): + claim = Claim.model_validate( + yaml.safe_load(tar.extractfile(m).read()) # type: ignore[union-attr] + ) + tags = list(claim.tags) + if origin_tag not in tags: + tags.append(origin_tag) + try: + res = _proposals.propose_claim( + store, + text=claim.text, + evidence=list(claim.evidence), + proposed_by=proposing_actor, + claim_type=claim.type.value, + confidence=claim.confidence, + entities=list(claim.entities), + tags=tags, + slug_hint=claim.id, + rationale=( + f"imported from KB '{origin}' via gated hub import; " + "review before it becomes durable knowledge here" + ), + ) + proposed.append(res.proposal.id) + except _proposals.ProposalError as e: + # One un-citable claim must not sink the whole import; record + # it rather than abort (and never silently drop it). + failed.append({"claim": claim.id, "error": str(e)}) + elif m.name.startswith("pages/"): + deferred["pages"] += 1 + elif m.name.startswith("entities/"): + deferred["entities"] += 1 + elif m.name.startswith("relations/"): + deferred["relations"] += 1 + + audit.log_event( + kb_dir, + event="bundle.import_proposals", + actor=proposing_actor, + object_ids=[check.bundle_id], + data={ + "origin_kb": origin, + "proposed": len(proposed), + "failed": len(failed), + "sources": sources_registered, + "evidence": evidence_registered, + "deferred": deferred, + }, + ) + return { + "bundle_id": check.bundle_id, + "origin_kb": origin, + "proposed": proposed, + "failed": failed, + "sources_registered": sources_registered, + "evidence_registered": evidence_registered, + "deferred": deferred, + } + + def _yaml_dump(obj: Any) -> str: # pragma: no cover — kept for symmetry return yaml.safe_dump(obj, sort_keys=False, allow_unicode=True) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 30cfb2fc..6770f457 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2923,24 +2923,20 @@ def hub_push() -> None: @hub.command("pull") -@click.option( - "--on-conflict", - type=click.Choice(["skip", "overwrite"]), - default=None, - help="How to apply conflicting artifacts; default refuses and lists them.", -) -def hub_pull(on_conflict: str | None) -> None: - """Pull the hub copy and import it through this KB's gate.""" +def hub_pull() -> None: + """Pull the hub copy and file it as pending proposals for this KB's review. + + Nothing lands durably here on pull: inbound knowledge becomes claim + proposals that must pass this KB's own review gate (`vouch review`). + """ store = _load_store() link = _hub_link_or_die(store) token = _hub_token_or_die(link.url) try: - result = hub_client.pull(store, link, token, on_conflict=on_conflict) + result = hub_client.pull(store, link, token) except hub_client.HubError as e: raise click.ClickException(str(e)) from e _emit_json(result) - if result.get("status") == "conflicts": - sys.exit(1) @hub.command("status") @@ -3040,6 +3036,30 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: _emit_json(r) +@cli.command("import-proposals") +@click.argument("bundle_path", type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--origin-kb", + default=None, + help="Label for the source KB; defaults to the bundle's own instance id.", +) +def import_proposals_cmd(bundle_path: str, origin_kb: str | None) -> None: + """Import a bundle's knowledge as PENDING PROPOSALS for review. + + The gated counterpart to import-apply: inbound claims are filed as proposals + that must pass this KB's own review gate, never written straight to the + durable store. This is the receiving-side gate federation requires -- nothing + lands without `vouch review`, so unlike import-apply it is safe to accept + knowledge from another KB with it. + """ + store = _load_store() + try: + r = bundle.import_as_proposals(store.kb_dir, Path(bundle_path), origin_kb=origin_kb) + except RuntimeError as e: + raise click.ClickException(str(e)) from e + _emit_json(r) + + # --- auto-pr: open N mergeable PRs against any github repo ----------------- diff --git a/src/vouch/hub_client.py b/src/vouch/hub_client.py index 0c572463..c4214784 100644 --- a/src/vouch/hub_client.py +++ b/src/vouch/hub_client.py @@ -194,9 +194,18 @@ def pull( store: KBStore, link: HubLink, token: str, - *, - on_conflict: str | None = None, ) -> dict[str, Any]: + """Pull the linked KB's knowledge and file it as PENDING PROPOSALS. + + Inbound knowledge is never applied to this KB's committed store: it lands as + claim proposals through ``bundle.import_as_proposals``, so nothing becomes + durable without passing this KB's own ``proposals.approve()``. That is the + receiving-side gate the federation invariant requires -- a receiving KB + accepts inbound knowledge as proposals, and the review gate is never + bypassed (ROADMAP.md step 10). There is therefore no ``on_conflict``: a + claim that collides with a local one is simply another proposal for the + reviewer to weigh, not a destructive overwrite. + """ headers: dict[str, str] = {} if link.last_bundle_id: headers["If-None-Match"] = f'"{link.last_bundle_id}"' @@ -210,24 +219,25 @@ def pull( with tempfile.TemporaryDirectory(prefix="vouch-hub-") as tmp: bundle_path = Path(tmp) / "pulled.tar.gz" bundle_path.write_bytes(payload) - check = bundle.import_check(store.kb_dir, bundle_path) - if check.issues: - raise HubError(f"pulled bundle failed validation: {check.issues[0]}") - if check.conflicts and on_conflict is None: - return {"status": "conflicts", "conflicts": check.conflicts} - result = bundle.import_apply( - store.kb_dir, - bundle_path, - on_conflict=on_conflict or "fail", - actor="hub-pull", - ) + try: + # actor defaults to f"hub:{origin}", so the proposing actor recorded + # in the audit log and on the proposal names the KB that vouched. + result = bundle.import_as_proposals( + store.kb_dir, + bundle_path, + origin_kb=link.kb, + ) + except RuntimeError as e: # import_check rejected the bundle + raise HubError(f"pulled bundle failed validation: {e}") from e link.last_bundle_id = remote_id save_link(store.kb_dir, link) return { - "status": "applied", + "status": "proposed", "bundle_id": remote_id, - "written": len(result.get("written", [])), - "skipped_conflicts": result.get("skipped_conflicts", []), + "origin_kb": result["origin_kb"], + "proposed": len(result["proposed"]), + "failed": len(result.get("failed", [])), + "deferred": result["deferred"], } diff --git a/src/vouch/storage.py b/src/vouch/storage.py index bd8987b0..9aca2dbf 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -29,6 +29,7 @@ import re import sqlite3 import stat +import uuid from pathlib import Path from typing import Any, TypeVar @@ -165,6 +166,36 @@ def _yaml_load(text: str) -> Any: return yaml.safe_load(text) +INSTANCE_ID_FILENAME = "instance_id" + + +def read_or_create_instance_id(kb_dir: Path) -> str: + """The KB's stable instance id, created on first request. + + A per-KB identity (uuid4 hex) so federation can record WHICH KB vouched for + a piece of inbound knowledge. Distinct from a bundle_id (a content hash of + the artifacts): two KBs holding identical artifacts still have different + instance ids. + + Stored in a sidecar file at the KB root (``.vouch/instance_id``), like the + schema-version marker init already writes -- deliberately NOT in config.yaml + or any exported subdir, so a KB's identity never travels inside a bundle or + collides with a receiver's config on import. Generated lazily, so existing + KBs adopt one with no migration. The manifest's ``kb_id`` field is the + channel that actually carries provenance to a receiver. + """ + path = kb_dir / INSTANCE_ID_FILENAME + try: + existing = path.read_text(encoding="utf-8").strip() + except OSError: + existing = "" + if existing: + return existing + new_id = uuid.uuid4().hex + path.write_text(new_id + "\n", encoding="utf-8") + return new_id + + _log = logging.getLogger("vouch.storage") _ModelT = TypeVar("_ModelT", bound=BaseModel) diff --git a/tests/test_gated_import.py b/tests/test_gated_import.py new file mode 100644 index 00000000..d3d12d5a --- /dev/null +++ b/tests/test_gated_import.py @@ -0,0 +1,141 @@ +"""Gated federation import: inbound knowledge lands as PENDING proposals. + +The load-bearing invariant (ROADMAP.md step 10): "any data path that lands +writes without a receiving-side proposal is wrong." `import_as_proposals` is the +gated counterpart to `import_apply` -- it files inbound claims as pending +proposals via `proposals.propose_claim`, so nothing reaches `decided/` without +this KB's own `proposals.approve()`. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import bundle, proposals +from vouch.models import Claim, ProposalKind, ProposalStatus +from vouch.storage import ArtifactNotFoundError, KBStore, read_or_create_instance_id + + +def _kb_with_claim(root: Path, *, claim_id: str, text: str) -> KBStore: + store = KBStore.init(root) + src = store.put_source(text.encode(), title="doc") + store.put_claim(Claim(id=claim_id, text=text, evidence=[src.id], tags=["seed-tag"])) + return store + + +def _knowledge_bundle(store: KBStore, dest: Path) -> Path: + # A hub push ships knowledge only -- no decided/ (the origin's own proposal + # records) and no sessions. + bundle.export(store.kb_dir, dest=dest, exclude=("decided", "sessions")) + return dest + + +def test_import_as_proposals_lands_claims_as_pending_not_committed(tmp_path: Path) -> None: + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="advisory locks are session scoped") + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + + dest = KBStore.init(tmp_path / "b") + result = bundle.import_as_proposals(dest.kb_dir, bundle_path, origin_kb="kb-alice") + + # The claim did NOT land in the committed store... + with pytest.raises(ArtifactNotFoundError): + dest.get_claim("c1") + # ...it is a PENDING claim proposal instead. + claim_props = [ + p for p in dest.list_proposals(ProposalStatus.PENDING) if p.kind == ProposalKind.CLAIM + ] + assert len(claim_props) == 1 + assert result["proposed"] + assert result["origin_kb"] == "kb-alice" + # provenance: the proposing actor names the origin KB. + assert "kb-alice" in claim_props[0].proposed_by + + +def test_imported_proposal_carries_origin_and_approves_into_a_claim(tmp_path: Path) -> None: + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="advisory locks are session scoped") + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + dest = KBStore.init(tmp_path / "b") + result = bundle.import_as_proposals(dest.kb_dir, bundle_path, origin_kb="kb-alice") + + pid = result["proposed"][0] + approved = proposals.approve(dest, pid, approved_by="human") + + # Only after THIS KB's approve does it become a durable claim... + landed = dest.get_claim(approved.id) + assert "advisory locks" in landed.text + # ...carrying the origin-KB provenance tag that survives approval. + assert "origin:kb-alice" in landed.tags + + +def test_import_as_proposals_writes_nothing_to_decided(tmp_path: Path) -> None: + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="a durable fact worth sharing") + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + dest = KBStore.init(tmp_path / "b") + + decided_dir = dest.kb_dir / "decided" + before = {p.name for p in decided_dir.glob("*")} if decided_dir.exists() else set() + bundle.import_as_proposals(dest.kb_dir, bundle_path, origin_kb="kb-alice") + after = {p.name for p in decided_dir.glob("*")} if decided_dir.exists() else set() + + # The gate held: not a single decided artifact was written on import. + assert before == after + + +def test_import_as_proposals_registers_sources_so_claims_can_cite_them(tmp_path: Path) -> None: + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="cited to a real source here") + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + dest = KBStore.init(tmp_path / "b") + + result = bundle.import_as_proposals(dest.kb_dir, bundle_path, origin_kb="kb-alice") + # Sources are substrate, registered directly so the claim proposal can cite + # them -- the same shape inbox.scan uses before it proposes. propose_claim + # would have raised if a cited id did not resolve locally, so a returned + # proposal is itself proof the source landed; assert it on disk too. + assert result["sources_registered"] >= 1 + assert result["proposed"] + sources_dir = dest.kb_dir / "sources" + assert sources_dir.exists() and any(sources_dir.iterdir()) + + +def test_export_stamps_instance_id_and_import_reads_it_as_origin(tmp_path: Path) -> None: + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="a fact from a real kb") + origin_id = read_or_create_instance_id(src.kb_dir) # the id export will stamp + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + + dest = KBStore.init(tmp_path / "b") + # No explicit origin_kb: provenance must come from the bundle manifest kb_id. + result = bundle.import_as_proposals(dest.kb_dir, bundle_path) + assert result["origin_kb"] == origin_id + + approved = proposals.approve(dest, result["proposed"][0], approved_by="human") + assert f"origin:{origin_id}" in dest.get_claim(approved.id).tags + + +def test_conformance_gate_holds_only_approve_makes_it_durable(tmp_path: Path) -> None: + """The ROADMAP step-10 invariant, as a test: the federation receive path + lands nothing in the committed store; only proposals.approve() does.""" + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="a fact to federate") + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + dest = KBStore.init(tmp_path / "b") + + result = bundle.import_as_proposals(dest.kb_dir, bundle_path, origin_kb="kb-alice") + # The committed claim file does NOT exist after import... + assert not (dest.kb_dir / "claims" / "c1.yaml").exists() + with pytest.raises(ArtifactNotFoundError): + dest.get_claim("c1") + # ...and only THIS KB's approve makes it durable. + proposals.approve(dest, result["proposed"][0], approved_by="human") + assert (dest.kb_dir / "claims" / "c1.yaml").exists() + assert dest.get_claim("c1").text == "a fact to federate" + + +def test_instance_id_is_stable_and_per_kb(tmp_path: Path) -> None: + a = KBStore.init(tmp_path / "a") + b = KBStore.init(tmp_path / "b") + id_a1 = read_or_create_instance_id(a.kb_dir) + id_a2 = read_or_create_instance_id(a.kb_dir) + id_b = read_or_create_instance_id(b.kb_dir) + assert id_a1 == id_a2 # stable across calls + assert id_a1 != id_b # distinct per KB diff --git a/tests/test_hub_client.py b/tests/test_hub_client.py index 7807c9bc..ba6414f0 100644 --- a/tests/test_hub_client.py +++ b/tests/test_hub_client.py @@ -201,48 +201,70 @@ def test_push_bad_token_raises_HubError(store: KBStore, fake_hub: str, home: Pat # --- pull --------------------------------------------------------------------- -def _remote_claim() -> dict[str, bytes]: - return {"claims/r1.yaml": b"id: r1\ntext: from the hub\n"} +def _remote_knowledge(tmp_path: Path) -> dict[str, bytes]: + """A real knowledge bundle (a cited claim + its source) as {path: bytes}. + Unlike a bare `claims/r1.yaml` with no evidence, this is what a genuine hub + push ships -- and the gated pull enforces vouch's citation rule, so the + claim must carry a source it can quote. + """ + src = KBStore.init(tmp_path / "remote-kb") + source = src.put_source(b"advisory locks are session scoped", title="doc") + src.put_claim(Claim(id="r1", text="advisory locks are session scoped", evidence=[source.id])) + return exported_files(src.kb_dir, tmp_path / "remote-exp") -def test_pull_applies_clean_bundle(store: KBStore, fake_hub: str, home: Path) -> None: - FakeHub.files = _remote_claim() + +def test_pull_files_proposals_not_committed_writes( + store: KBStore, fake_hub: str, home: Path, tmp_path: Path +) -> None: + from vouch.models import ProposalKind, ProposalStatus + from vouch.storage import ArtifactNotFoundError + + FakeHub.files = _remote_knowledge(tmp_path) link = _link(store, fake_hub) - r = hub_client.pull(store, link, "vhp_test", on_conflict=None) - assert r["status"] == "applied" - assert store.get_claim("r1").text == "from the hub" + r = hub_client.pull(store, link, "vhp_test") + + # The gate held: inbound knowledge is a pending PROPOSAL, not a committed claim. + assert r["status"] == "proposed" + assert r["proposed"] == 1 + assert r["origin_kb"] == "alice/proj" # provenance = the linked KB + with pytest.raises(ArtifactNotFoundError): + store.get_claim("r1") + pending = [ + p for p in store.list_proposals(ProposalStatus.PENDING) if p.kind == ProposalKind.CLAIM + ] + assert len(pending) == 1 + assert "alice/proj" in pending[0].proposed_by + reloaded = hub_client.load_link(store.kb_dir) assert reloaded is not None - r2 = hub_client.pull(store, reloaded, "vhp_test", on_conflict=None) + r2 = hub_client.pull(store, reloaded, "vhp_test") assert r2["status"] == "up_to_date" -def test_pull_refuses_conflicts_without_flag(store: KBStore, fake_hub: str, home: Path) -> None: - FakeHub.files = _remote_claim() - src = store.put_source(b"local evidence") - store.put_claim(Claim(id="r1", text="local version", evidence=[src.id])) - link = _link(store, fake_hub) - r = hub_client.pull(store, link, "vhp_test", on_conflict=None) - assert r["status"] == "conflicts" - assert r["conflicts"] == ["claims/r1.yaml"] - assert store.get_claim("r1").text == "local version" # untouched - +def test_pull_files_a_proposal_even_when_a_local_claim_exists( + store: KBStore, fake_hub: str, home: Path, tmp_path: Path +) -> None: + from vouch.models import ProposalStatus -def test_pull_overwrite_applies_conflicts(store: KBStore, fake_hub: str, home: Path) -> None: - FakeHub.files = _remote_claim() + FakeHub.files = _remote_knowledge(tmp_path) src = store.put_source(b"local evidence") store.put_claim(Claim(id="r1", text="local version", evidence=[src.id])) link = _link(store, fake_hub) - r = hub_client.pull(store, link, "vhp_test", on_conflict="overwrite") - assert r["status"] == "applied" - assert store.get_claim("r1").text == "from the hub" + + r = hub_client.pull(store, link, "vhp_test") + # No destructive overwrite: the local claim is untouched, the inbound one is + # just another proposal the reviewer will weigh. + assert r["status"] == "proposed" + assert store.get_claim("r1").text == "local version" + assert store.list_proposals(ProposalStatus.PENDING) # --- cli ------------------------------------------------------------------------ def test_cli_link_push_status_pull( - store: KBStore, fake_hub: str, home: Path, monkeypatch: pytest.MonkeyPatch + store: KBStore, fake_hub: str, home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: from click.testing import CliRunner @@ -263,10 +285,10 @@ def test_cli_link_push_status_pull( assert r.exit_code == 0, r.output assert json.loads(r.output)["linked"] is True - FakeHub.files = _remote_claim() + FakeHub.files = _remote_knowledge(tmp_path) r = runner.invoke(cli, ["hub", "pull"]) assert r.exit_code == 0, r.output - assert json.loads(r.output)["status"] == "applied" + assert json.loads(r.output)["status"] == "proposed" def test_cli_unlinked_errors_clearly( @@ -282,24 +304,6 @@ def test_cli_unlinked_errors_clearly( assert "vouch hub link" in r.output -def test_cli_pull_conflict_exits_nonzero( - store: KBStore, fake_hub: str, home: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - from click.testing import CliRunner - - from vouch.cli import cli - - FakeHub.files = _remote_claim() - src = store.put_source(b"local evidence") - store.put_claim(Claim(id="r1", text="local version", evidence=[src.id])) - monkeypatch.chdir(store.kb_dir.parent) - runner = CliRunner() - runner.invoke(cli, ["hub", "link", "alice/proj", "--url", fake_hub, "--token", "vhp_test"]) - r = runner.invoke(cli, ["hub", "pull"]) - assert r.exit_code == 1 - assert "conflicts" in r.output - - # --- status --------------------------------------------------------------------- From 95e9390c20c751aede99d6fe49b2c9efe4ae398a Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:37:04 +0900 Subject: [PATCH 14/63] feat(sync): gated sync mode files inbound knowledge as proposals closes the second federation receive path that bypassed the review gate. sync_apply --as-proposals lands another kb's NEW claims as pending proposals (sources/evidence registered as substrate; pages/entities/relations/sessions/ decided reported as deferred), so nothing reaches the committed store without passing this kb's own proposals.approve() -- the roadmap step-10 invariant. the direct-write default is unchanged: like import-apply it stays the human tool for reconciling your own kbs. --as-proposals (roadmap 8.2) is the federation-safe path for accepting another kb's knowledge. the propose-inbound-claim + origin-tag provenance logic is extracted from bundle.import_as_proposals into shared bundle.propose_inbound_claim and inbound_claim_id, so both gated receive paths (bundle import and sync) stamp identical provenance. tests: gated sync files pending not committed, approves into a claim carrying the origin tag, and the cli --as-proposals flow. full suite green, mypy clean. --- src/vouch/bundle.py | 71 ++++++++++++++++++---------- src/vouch/cli.py | 24 +++++++++- src/vouch/sync.py | 110 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_sync.py | 54 ++++++++++++++++++++++ 4 files changed, 233 insertions(+), 26 deletions(-) diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index a6b73f2e..78a3318f 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -705,6 +705,48 @@ def _manifest_kb_id(bundle_path: Path) -> str | None: return str(kb_id) if kb_id else None +def inbound_claim_id(claim_bytes: bytes) -> str: + """The id of an inbound claim (raw claim yaml), for failure reporting.""" + from .models import Claim + + return Claim.model_validate(yaml.safe_load(claim_bytes)).id + + +def propose_inbound_claim(store: Any, claim_bytes: bytes, *, origin: str, actor: str) -> str: + """File one inbound claim (raw claim yaml) as a pending proposal. + + Shared by the two gated receive paths -- bundle import and sync -- so both + stamp identical provenance: the proposing ``actor`` and an ``origin:`` + tag that survives approval. Propagates ``proposals.ProposalError`` (e.g. a + claim that cannot cite a resolvable source) so the caller can record it as a + failure rather than dropping it silently. Returns the pending proposal's id. + """ + from . import proposals as _proposals + from .models import Claim + + claim = Claim.model_validate(yaml.safe_load(claim_bytes)) + tags = list(claim.tags) + origin_tag = f"origin:{origin}" + if origin_tag not in tags: + tags.append(origin_tag) + res = _proposals.propose_claim( + store, + text=claim.text, + evidence=list(claim.evidence), + proposed_by=actor, + claim_type=claim.type.value, + confidence=claim.confidence, + entities=list(claim.entities), + tags=tags, + slug_hint=claim.id, + rationale=( + f"imported from KB '{origin}' via gated import; review before it " + "becomes durable knowledge here" + ), + ) + return res.proposal.id + + def import_as_proposals( kb_dir: Path, bundle_path: Path, @@ -736,7 +778,7 @@ def import_as_proposals( # Local imports: proposals/storage import from this module's siblings, and a # top-level import here would risk a cycle at package load. from . import proposals as _proposals - from .models import Claim, Evidence + from .models import Evidence from .storage import KBStore check = import_check(kb_dir, bundle_path) @@ -774,36 +816,17 @@ def import_as_proposals( evidence_registered += 1 # Pass 2: file each inbound claim as a PENDING proposal. - origin_tag = f"origin:{origin}" for m in members: if m.name.startswith("claims/") and m.name.endswith(".yaml"): - claim = Claim.model_validate( - yaml.safe_load(tar.extractfile(m).read()) # type: ignore[union-attr] - ) - tags = list(claim.tags) - if origin_tag not in tags: - tags.append(origin_tag) + body = tar.extractfile(m).read() # type: ignore[union-attr] try: - res = _proposals.propose_claim( - store, - text=claim.text, - evidence=list(claim.evidence), - proposed_by=proposing_actor, - claim_type=claim.type.value, - confidence=claim.confidence, - entities=list(claim.entities), - tags=tags, - slug_hint=claim.id, - rationale=( - f"imported from KB '{origin}' via gated hub import; " - "review before it becomes durable knowledge here" - ), + proposed.append( + propose_inbound_claim(store, body, origin=origin, actor=proposing_actor) ) - proposed.append(res.proposal.id) except _proposals.ProposalError as e: # One un-citable claim must not sink the whole import; record # it rather than abort (and never silently drop it). - failed.append({"claim": claim.id, "error": str(e)}) + failed.append({"claim": inbound_claim_id(body), "error": str(e)}) elif m.name.startswith("pages/"): deferred["pages"] += 1 elif m.name.startswith("entities/"): diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 6770f457..76d6b473 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3284,8 +3284,26 @@ def sync_check_cmd(source_path: str) -> None: show_default=True, type=click.Choice(["fail", "skip", "propose"]), ) -def sync_apply_cmd(source_path: str, on_conflict: str) -> None: - """Apply non-conflicting files from another .vouch directory or bundle.""" +@click.option( + "--as-proposals", + is_flag=True, + default=False, + help="Federation-safe: new inbound claims become pending proposals for review " + "instead of direct writes. Nothing lands without `vouch review`.", +) +@click.option( + "--origin-kb", + default=None, + help="Provenance label for the source KB (defaults to its instance id).", +) +def sync_apply_cmd( + source_path: str, on_conflict: str, as_proposals: bool, origin_kb: str | None +) -> None: + """Apply files from another .vouch directory or bundle. + + Default is a direct reconcile of your own KBs. Use --as-proposals to accept + another KB's knowledge through this KB's review gate (the federation path). + """ store = _load_store() try: r = sync_mod.sync_apply( @@ -3293,6 +3311,8 @@ def sync_apply_cmd(source_path: str, on_conflict: str) -> None: Path(source_path), on_conflict=on_conflict, actor=_whoami(), + as_proposals=as_proposals, + origin_kb=origin_kb, ) except (RuntimeError, ValueError) as e: raise click.ClickException(str(e)) from e diff --git a/src/vouch/sync.py b/src/vouch/sync.py index 1a6636b5..5c570967 100644 --- a/src/vouch/sync.py +++ b/src/vouch/sync.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Any +import yaml + from . import audit, bundle from .storage import sha256_hex @@ -286,12 +288,107 @@ def _write_conflict_report( return str(report_path.relative_to(kb_dir)) +def _source_origin(src: _SyncSource) -> str: + """Provenance label for a sync source: the bundle's manifest kb_id or the + source KB's instance id, falling back to its content-hash source_id.""" + if src.bundle_path is not None: + return bundle._manifest_kb_id(src.bundle_path) or src.source_id + if src.root is not None: + from .storage import read_or_create_instance_id + + return read_or_create_instance_id(src.root) + return src.source_id + + +def _sync_apply_as_proposals( + kb_dir: Path, src: _SyncSource, check: SyncCheckResult, *, origin: str, actor: str +) -> dict[str, Any]: + """Land another KB's NEW knowledge as pending proposals (federation-safe). + + Claims become proposals via ``bundle.propose_inbound_claim``; sources and + evidence are registered as substrate so those claims can cite them; pages, + entities, relations, sessions and decided records are reported as deferred, + never written. Conflicts are left to the human (surfaced in + ``check.conflicts``), so nothing here overwrites reviewed knowledge or + bypasses ``proposals.approve()``. + """ + from .models import Evidence + from .proposals import ProposalError + from .storage import KBStore + + store = KBStore(kb_dir.parent) + new = sorted(check.new_files) + proposed: list[str] = [] + failed: list[dict[str, str]] = [] + sources_registered = 0 + evidence_registered = 0 + deferred = {"pages": 0, "entities": 0, "relations": 0, "sessions": 0, "decided": 0} + deferred_key = { + "page": "pages", "entity": "entities", "relation": "relations", + "session": "sessions", "decided-proposal": "decided", + } + + # Pass 1: substrate registered directly so claim proposals can cite it. + for path in new: + kind, _ = _artifact_kind(path) + if kind == "source" and path.endswith("/content"): + store.put_source(_read_source_file(src, path)) + sources_registered += 1 + elif kind == "evidence": + ev = Evidence.model_validate(yaml.safe_load(_read_source_file(src, path))) + store.put_evidence(ev) + evidence_registered += 1 + + # Pass 2: claims -> proposals; every other durable kind is deferred, not written. + for path in new: + kind, _ = _artifact_kind(path) + if kind == "claim": + body = _read_source_file(src, path) + try: + proposed.append( + bundle.propose_inbound_claim(store, body, origin=origin, actor=actor) + ) + except ProposalError as e: + failed.append({"claim": bundle.inbound_claim_id(body), "error": str(e)}) + elif kind in deferred_key: + deferred[deferred_key[kind]] += 1 + + audit.log_event( + kb_dir, + event="sync.apply_proposals", + actor=actor, + object_ids=[check.source_id], + data={ + "origin": origin, + "proposed": len(proposed), + "failed": len(failed), + "sources": sources_registered, + "evidence": evidence_registered, + "deferred": deferred, + }, + ) + return { + "mode": "as_proposals", + "source_type": check.source_type, + "source_id": check.source_id, + "origin": origin, + "proposed": proposed, + "failed": failed, + "sources_registered": sources_registered, + "evidence_registered": evidence_registered, + "deferred": deferred, + "conflicts": [asdict(c) for c in check.conflicts], + } + + def sync_apply( kb_dir: Path, source_path: Path, *, on_conflict: str = "fail", actor: str = "vouch-sync", + as_proposals: bool = False, + origin_kb: str | None = None, ) -> dict[str, Any]: """Apply non-conflicting incoming files from another KB or bundle. @@ -299,6 +396,15 @@ def sync_apply( - ``fail``: abort if any incoming path conflicts. - ``skip``: import new files and leave conflicts untouched. - ``propose``: import new files and write a local sync conflict report. + + With ``as_proposals=True`` the review gate is upheld like + ``bundle.import_as_proposals``: new inbound claims become PENDING proposals + (sources/evidence are registered as substrate), nothing is written to the + committed store, and conflicts are reported rather than resolved -- so no + ``on_conflict`` policy applies. This is the federation-safe mode + (`sync --as-proposals`, ROADMAP.md step 8.2/10); the direct-write default + remains for a human reconciling their own KBs, the same posture as + ``import-apply``. """ if on_conflict not in {"fail", "skip", "propose"}: raise ValueError(f"on_conflict must be fail|skip|propose, got {on_conflict}") @@ -307,6 +413,10 @@ def sync_apply( check = _sync_check_with_src(kb_dir, src) if check.issues: raise RuntimeError(f"refusing to sync: {check.issues[0]}") + if as_proposals: + return _sync_apply_as_proposals( + kb_dir, src, check, origin=origin_kb or _source_origin(src), actor=actor + ) if on_conflict == "fail" and check.conflicts: raise RuntimeError(f"refusing to sync: {len(check.conflicts)} conflicts") diff --git a/tests/test_sync.py b/tests/test_sync.py index beac8041..071cbb6e 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -58,6 +58,60 @@ def test_sync_excludes_config_yaml(tmp_path: Path) -> None: assert dest.config_path.read_text() == "version: 1\nsync: local\n" +def test_sync_apply_as_proposals_files_pending_not_committed(tmp_path: Path) -> None: + from vouch.models import ProposalKind, ProposalStatus + from vouch.storage import ArtifactNotFoundError + + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "alpha from the other kb") + dest = _store(tmp_path / "dest") + + result = sync.sync_apply(dest.kb_dir, incoming.root, as_proposals=True, origin_kb="kb-remote") + + assert result["mode"] == "as_proposals" + assert result["proposed"] + assert result["origin"] == "kb-remote" + # The gate held: the inbound claim is a pending proposal, not a committed write. + assert "written" not in result + with pytest.raises(ArtifactNotFoundError): + dest.get_claim("c1") + assert not (dest.kb_dir / "claims" / "c1.yaml").exists() + pending = [ + p for p in dest.list_proposals(ProposalStatus.PENDING) if p.kind == ProposalKind.CLAIM + ] + assert len(pending) == 1 + + +def test_sync_apply_as_proposals_approves_into_a_claim_with_origin(tmp_path: Path) -> None: + from vouch import proposals + + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "alpha from the other kb") + dest = _store(tmp_path / "dest") + + result = sync.sync_apply(dest.kb_dir, incoming.root, as_proposals=True, origin_kb="kb-remote") + approved = proposals.approve(dest, result["proposed"][0], approved_by="human") + # Only this KB's approve makes it durable, and it carries the origin provenance. + assert dest.get_claim(approved.id).text == "alpha from the other kb" + assert "origin:kb-remote" in dest.get_claim(approved.id).tags + + +def test_cli_sync_apply_as_proposals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + incoming = _store(tmp_path / "incoming") + _claim(incoming, "c1", "alpha from the other kb") + dest = _store(tmp_path / "dest") + monkeypatch.chdir(dest.kb_dir.parent) + + r = CliRunner().invoke( + cli, ["sync-apply", str(incoming.root), "--as-proposals", "--origin-kb", "kb-remote"] + ) + assert r.exit_code == 0, r.output + out = json.loads(r.output) + assert out["mode"] == "as_proposals" + assert out["proposed"] + assert not (dest.kb_dir / "claims" / "c1.yaml").exists() + + def test_sync_check_classifies_claim_conflicts(tmp_path: Path) -> None: incoming = _store(tmp_path / "incoming") _claim(incoming, "c1", "incoming text") From 3c8139a6b4821e02846ea7b6083f6312e28e17e0 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:42:29 +0900 Subject: [PATCH 15/63] feat(context): surface federated provenance at read time a claim imported through the gate carries an origin: tag; build_context_pack now reads it onto each returned item (ContextItem.origin) and lists every contributing kb in the pack's origins field, so a reader can see which knowledge came from another kb and which is local -- the read-time half of "every result names the kb that vouched for it" (roadmap step 10). locally-authored claims have origin=None and add no origins field. tests: a federated claim surfaces its origin on recall; a local claim does not. full suite green, mypy clean. --- src/vouch/context.py | 18 +++++++++++++++++- src/vouch/models.py | 6 ++++++ tests/test_gated_import.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/vouch/context.py b/src/vouch/context.py index fa7e3f16..3821dd41 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -233,6 +233,15 @@ def _dedupe_near_duplicates(items: list[ContextItem]) -> list[ContextItem]: return [it for i, it in enumerate(items) if i not in dropped] +def _origin_from_tags(tags: list[str]) -> str | None: + """The origin-KB label a gated import stamped on a claim (`origin:`), so a + federated result can name which KB vouched for it. None for local claims.""" + for tag in tags: + if tag.startswith("origin:"): + return tag[len("origin:") :] + return None + + def build_context_pack( store: KBStore, *, @@ -260,6 +269,7 @@ def build_context_pack( items: list[ContextItem] = [] for kind, hid, summary, score, backend in hits: cites: list[str] = [] + origin: str | None = None if kind == "claim": # Exclude retracted claims even if the underlying index still # matches them (the FTS5 row's status column can lag — see #78 @@ -273,12 +283,13 @@ def build_context_pack( if claim.status in _RETRACTED_CLAIM_STATUSES: continue cites = list(claim.evidence) + origin = _origin_from_tags(claim.tags) summary = _enrich_summary(store, kind, hid, summary) items.append( ContextItem( id=hid, type=cast(ContextItemKind, kind), summary=summary, score=score, backend=backend, citations=cites, - freshness="unknown", + freshness="unknown", origin=origin, ) ) @@ -351,6 +362,11 @@ def build_context_pack( } # Determine the backend used (all hits share the same backend in _retrieve). result["backend"] = hits[0][4] if hits else "none" + # Federated provenance: name every KB that vouched for a returned item, so a + # reader can see which knowledge came from elsewhere (roadmap step 10). + origins = sorted({it.origin for it in items if it.origin}) + if origins: + result["origins"] = origins if explain: result["explain"] = [ {"kind": k, "id": i, "score": sc, "backend": hits[0][4] if hits else "none"} diff --git a/src/vouch/models.py b/src/vouch/models.py index 62c801d6..e7150130 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -432,6 +432,12 @@ class ContextItem(BaseModel): backend: str = "fts5" citations: list[str] = Field(default_factory=list) freshness: Literal["fresh", "unknown", "stale"] = "unknown" + origin: str | None = Field( + default=None, + description="vouch: the KB that vouched for this item, when it arrived via " + "gated federation import (from the claim's origin: tag). None for " + "locally-authored knowledge.", + ) class ContextQuality(BaseModel): diff --git a/tests/test_gated_import.py b/tests/test_gated_import.py index d3d12d5a..9a5857d5 100644 --- a/tests/test_gated_import.py +++ b/tests/test_gated_import.py @@ -131,6 +131,36 @@ def test_conformance_gate_holds_only_approve_makes_it_durable(tmp_path: Path) -> assert dest.get_claim("c1").text == "a fact to federate" +def test_federated_claim_surfaces_origin_at_read_time(tmp_path: Path) -> None: + """Provenance end to end: a claim imported from another KB, once approved, + names its origin KB when it is later recalled (roadmap step 10).""" + from vouch.context import build_context_pack + + src = _kb_with_claim(tmp_path / "a", claim_id="c1", text="advisory locks are session scoped") + bundle_path = _knowledge_bundle(src, tmp_path / "a.tar.gz") + dest = KBStore.init(tmp_path / "b") + result = bundle.import_as_proposals(dest.kb_dir, bundle_path, origin_kb="kb-alice") + proposals.approve(dest, result["proposed"][0], approved_by="human") + + pack = build_context_pack(dest, query="advisory locks session", limit=10) + claim_items = [i for i in pack["items"] if i["type"] == "claim"] + assert claim_items, pack + assert claim_items[0]["origin"] == "kb-alice" + assert pack.get("origins") == ["kb-alice"] + + +def test_locally_authored_claim_has_no_origin(tmp_path: Path) -> None: + from vouch.context import build_context_pack + + store = _kb_with_claim(tmp_path / "a", claim_id="c1", text="a locally authored fact here") + # put_claim above writes directly; approve path not needed for a read check. + pack = build_context_pack(store, query="locally authored fact", limit=10) + claim_items = [i for i in pack["items"] if i["type"] == "claim"] + assert claim_items + assert claim_items[0]["origin"] is None + assert "origins" not in pack + + def test_instance_id_is_stable_and_per_kb(tmp_path: Path) -> None: a = KBStore.init(tmp_path / "a") b = KBStore.init(tmp_path / "b") From 16c88489e79a84b8d21769d8a3bebd3adaad270f Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:45:04 +0900 Subject: [PATCH 16/63] chore(schemas): regenerate context schemas for the federated origin field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the gated-import work added ContextItem.origin (and the pack-level origins) but the generated json schemas were never regenerated — the drift was latent because the conflict blocked schema-check from running on the pr. regenerate context-item and context-pack from models.py so the drift gate passes. --- schemas/context-item.schema.json | 13 +++++++++++++ schemas/context-pack.schema.json | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/schemas/context-item.schema.json b/schemas/context-item.schema.json index 09afc41d..2362104d 100644 --- a/schemas/context-item.schema.json +++ b/schemas/context-item.schema.json @@ -29,6 +29,19 @@ "title": "Id", "type": "string" }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "vouch: the KB that vouched for this item, when it arrived via gated federation import (from the claim's origin: tag). None for locally-authored knowledge.", + "title": "Origin" + }, "score": { "default": 0.0, "title": "Score", diff --git a/schemas/context-pack.schema.json b/schemas/context-pack.schema.json index 1bf995be..3616896e 100644 --- a/schemas/context-pack.schema.json +++ b/schemas/context-pack.schema.json @@ -29,6 +29,19 @@ "title": "Id", "type": "string" }, + "origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "vouch: the KB that vouched for this item, when it arrived via gated federation import (from the claim's origin: tag). None for locally-authored knowledge.", + "title": "Origin" + }, "score": { "default": 0.0, "title": "Score", From 0020ea84a1aba310c2634ad3dfc64dfb7ce4b13c Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:38:27 +0900 Subject: [PATCH 17/63] feat(compile): structured pages, wiki index/moc, two-phase drafting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compile now drafts browsable, fully-grounded wiki pages that match the llm-wiki compiler's presentation without inheriting its failure modes. phase 1 — the page-draft prompt asks for typed markdown sections (what/why/ how, context/options/decision) plus a one-line summary, tags, and aliases, carried in page metadata. a citation-density guardrail drops any draft whose substantive sentences are mostly uncited, so richer prose cannot smuggle in assertions that trace to no claim. phase 2 — a new wiki_render module renders a derived index.md and a backlink map-of-content over approved pages (a regenerable view, never a gated write), exposed as `vouch render-wiki`. the wikilink resolver now matches title, slug, or alias, so a link to an existing page's alias no longer false-drops. phase 3 — optional two-phase compile (compile.two_phase) plans durable topics first, then drafts one focused page each. off by default; it doubles the llm calls in exchange for finer-grained pages. every write still lands as a pending proposal and every inline [claim: id] is verified against the store — the review gate and receipt fidelity are untouched; this only improves what the compiler drafts behind them. --- src/vouch/cli.py | 25 +++++ src/vouch/compile.py | 211 ++++++++++++++++++++++++++++++++++++-- src/vouch/wiki_render.py | 97 ++++++++++++++++++ tests/test_cli.py | 32 ++++++ tests/test_compile.py | 188 +++++++++++++++++++++++++++++++++ tests/test_wiki_render.py | 86 ++++++++++++++++ 6 files changed, 631 insertions(+), 8 deletions(-) create mode 100644 src/vouch/wiki_render.py create mode 100644 tests/test_wiki_render.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 909d5db4..f3e08a87 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -51,6 +51,7 @@ from . import trust as trust_mod from . import vault_sync as vault_sync_mod from . import verify as verify_mod +from . import wiki_render as wiki_render_mod from .capabilities import capabilities as build_caps from .context import build_context_pack from .lifecycle import LifecycleError @@ -3094,6 +3095,30 @@ def compile_cmd(dry_run: bool, max_pages: int | None, _echo("run `vouch review` to decide.") +@cli.command(name="render-wiki") +@click.option("--out", "out_dir", type=click.Path(file_okay=False), default=None, + help="Write index.md + MOC.md here (default: print the index).") +def render_wiki_cmd(out_dir: str | None) -> None: + """Render a derived index + map-of-content over approved pages. + + A regenerable view of the wiki front door — never a proposal, never gated. + With --out, writes index.md and MOC.md there; otherwise prints the index. + """ + store = _load_store() + pages = store.list_pages() + index = wiki_render_mod.render_index(pages) + if out_dir is None: + _echo(index) + return + target = Path(out_dir) + target.mkdir(parents=True, exist_ok=True) + (target / "index.md").write_text(index, encoding="utf-8") + (target / "MOC.md").write_text( + wiki_render_mod.render_moc(pages), encoding="utf-8", + ) + _echo(f"rendered {len(pages)} page(s) → {target}/index.md + MOC.md") + + @cli.command() @click.argument("session_id") @click.option("--no-page", is_flag=True, help="Skip the session-summary page.") diff --git a/src/vouch/compile.py b/src/vouch/compile.py index db1a168c..d70421b6 100644 --- a/src/vouch/compile.py +++ b/src/vouch/compile.py @@ -20,6 +20,7 @@ from __future__ import annotations +import json import re from dataclasses import dataclass, field from typing import Any @@ -49,6 +50,79 @@ _WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)") _CLAIM_MARKER_RE = re.compile(r"\[claim:\s*([^\]]+)\]") +# Anti-embellishment guardrail: a compiled page may synthesize prose, but every +# substantive sentence must trace to an approved claim. Below this fraction the +# draft is padding a few citations with uncited assertions — drop it. The +# llm-wiki compiler has no equivalent check, which is how a confidently-cited +# hallucination ships there; vouch refuses it here. +MIN_CITATION_COVERAGE = 0.5 +_MIN_SUBSTANTIVE_WORDS = 6 +_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") +_WORD_RE = re.compile(r"[A-Za-z0-9]+") + + +def _citation_coverage(body: str) -> tuple[int, int]: + """Return (cited, total) substantive sentences in a draft body. + + A substantive sentence asserts something: at least + ``_MIN_SUBSTANTIVE_WORDS`` words after stripping citation and wikilink + markers, and not a markdown header, table row, or blockquote. Structural + lines never count, so a well-sectioned page is judged only on its prose. + """ + cited = 0 + total = 0 + for raw_line in body.splitlines(): + line = raw_line.strip() + if not line or line[0] in "#|>": + continue + for sentence in _SENTENCE_SPLIT_RE.split(line): + s = sentence.strip() + if not s: + continue + has_marker = "[claim:" in s + stripped = _WIKILINK_RE.sub("", _CLAIM_MARKER_RE.sub("", s)) + if len(_WORD_RE.findall(stripped)) < _MIN_SUBSTANTIVE_WORDS: + continue + total += 1 + if has_marker: + cited += 1 + return cited, total + + +def _low_coverage_reason( + body: str, *, minimum: float = MIN_CITATION_COVERAGE, +) -> str | None: + """Drop reason when too few substantive sentences carry a citation.""" + cited, total = _citation_coverage(body) + if total == 0: + return None + coverage = cited / total + if coverage < minimum: + return f"citation coverage {coverage:.0%} below minimum {minimum:.0%}" + return None + + +def _page_frontmatter(draft: dict[str, Any]) -> tuple[list[str], dict[str, Any]]: + """Derive page tags and metadata (summary, aliases) from a draft. + + Tags always begin with the wiki/compiled provenance pair; the model's own + tags are appended, deduped and order-stable. summary and aliases land in + ``metadata`` so the Page model needs no new fields. + """ + tags = ["wiki", "compiled"] + for t in draft.get("tags") or []: + tag = str(t).strip() + if tag and tag not in tags: + tags.append(tag) + meta: dict[str, Any] = {} + summary = str(draft.get("summary") or "").strip() + if summary: + meta["summary"] = summary + aliases = [str(a).strip() for a in draft.get("aliases") or [] if str(a).strip()] + if aliases: + meta["aliases"] = aliases + return tags, meta + class CompileError(Exception): """Compile could not run at all (config, LLM, or output-shape failure).""" @@ -59,6 +133,10 @@ class CompileConfig: llm_cmd: str | None = None max_pages: int = DEFAULT_MAX_PAGES timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS + # Two-phase compile: first ask the LLM to plan durable topics, then draft + # one focused page per topic (llm-wiki's extract-then-generate shape). Off + # by default — it doubles the LLM calls for finer-grained pages. + two_phase: bool = False def _coerce(value: Any, default: Any, cast: Any) -> Any: @@ -91,6 +169,7 @@ def load_config(store: KBStore) -> CompileConfig: raw.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS), DEFAULT_TIMEOUT_SECONDS, float, ), + two_phase=bool(raw.get("two_phase", False)), ) @@ -123,12 +202,81 @@ def _pending_page_names(store: KBStore) -> set[str]: return names -def build_prompt(store: KBStore, *, max_pages: int) -> str: +def build_topic_prompt(store: KBStore, *, max_pages: int) -> str: + """Phase-A prompt: plan the durable topics before any page is drafted. + + Two-phase compile asks for a table of contents first so each page is then + written focused on a single subject — llm-wiki's extract-then-generate + split, minus its habit of inventing links to topics it never wrote. + """ + claims = [ + c for c in store.list_claims() + if c.status not in _RETRACTED_CLAIM_STATUSES + ] + if not claims: + raise CompileError("nothing to compile: the KB has no live approved claims") + lines = [ + "You are planning a project's knowledge wiki. Group the approved", + "claims below into a small set of durable, single-subject topics —", + "the pages a future reader browses first.", + "", + "APPROVED CLAIMS (id: text):", + ] + for c in claims: + lines.append(f"- {c.id}: {c.text}") + lines += [ + "", + "RULES", + f"- Propose at most {max_pages} focused topics. Prefer several tight", + " single-subject topics over one broad page.", + "- Skip chronology; never propose a topic about a session itself.", + "", + "OUTPUT: print ONLY a JSON array of topic titles (strings), no code", + "fences, no commentary. Example: [\"Retry Policy\", \"Staging Database\"]", + ] + return "\n".join(lines) + + +def parse_topics(raw: str) -> list[str]: + """Parse a phase-A topic list into titles, tolerating strings or objects. + + Accepts ``["Alpha", "Beta"]`` or ``[{"title": "Alpha"}, ...]`` and a + single wrapping code fence. Raises :class:`CompileError` on bad shape. + """ + text = raw.strip() + if text.startswith("```"): + text = text.split("\n", 1)[-1] if "\n" in text else "" + if text.rstrip().endswith("```"): + text = text.rstrip()[:-3] + try: + data = json.loads(text.strip()) + except json.JSONDecodeError as e: + raise CompileError(f"topic list is not valid JSON: {e}") from e + if not isinstance(data, list): + raise CompileError("topic list must be a JSON array") + titles: list[str] = [] + for item in data: + if isinstance(item, str): + title = item.strip() + elif isinstance(item, dict): + title = str(item.get("title") or "").strip() + else: + continue + if title: + titles.append(title) + return titles + + +def build_prompt( + store: KBStore, *, max_pages: int, planned_topics: list[str] | None = None, +) -> str: """Assemble the self-contained maintainer prompt. The whole working set (live claims + page inventory) is inlined rather than retrieved: compile is an ingest pass over the KB, and a KB small enough to review by hand is small enough to hand to the compiler whole. + When ``planned_topics`` is given (two-phase compile), the model is asked to + draft exactly those pages instead of choosing its own topics. """ claims = [ c for c in store.list_claims() @@ -149,6 +297,13 @@ def build_prompt(store: KBStore, *, max_pages: int) -> str: ] for c in claims: lines.append(f"- {c.id}: {c.text}") + if planned_topics: + lines += [ + "", + "PLANNED TOPICS — draft exactly one focused page for each of these", + "titles, grounded only in the claims above:", + ] + lines += [f"- {t}" for t in planned_topics] lines += ["", "TAKEN TOPICS (existing pages or drafts already awaiting " "review) — do NOT redraft any of these:"] taken_lines = [f"- {p.id}: {p.title} [{p.type}]" for p in pages] @@ -161,16 +316,25 @@ def build_prompt(store: KBStore, *, max_pages: int) -> str: " already taken; page updates are not supported yet.", "- Prefer durable topics over chronology. Never draft a page about a", " session itself; session records are raw material.", - "- Body: 80-200 words of synthesized markdown prose. After each", - " load-bearing statement add an inline citation marker", - " [claim: ] using ONLY ids from the list above.", + "- Give each page a one-line \"summary\" (<= 25 words), 3-6 lowercase", + " \"tags\", and optional \"aliases\" (alternative titles a reader might", + " search for).", + "- Structure the body with markdown \"##\" section headers suited to the", + " page type: a concept uses What / Why / How / Related; a decision uses", + " Context / Options / Decision / Consequences; a workflow uses Steps /", + " Preconditions / Related. Aim for 150-400 words.", + "- Every substantive sentence MUST end with an inline citation marker", + " [claim: ] using ONLY ids from the list above. Uncited prose", + " is dropped, so never assert anything you cannot cite, and do not pad a", + " page with filler sentences.", "- Cross-reference other pages with [[]] wikilinks, only", " when genuinely related, and only to existing pages or pages in", " this same batch.", "- Allowed \"type\" values: concept, workflow, decision, report, index.", "", "OUTPUT: print ONLY a JSON array, no code fences, no commentary.", - "Each element: {\"title\": str, \"type\": str, \"body\": str,", + "Each element: {\"title\": str, \"type\": str, \"summary\": str,", + " \"tags\": [str, ...], \"aliases\": [str, ...], \"body\": str,", " \"claims\": [claim-id, ...]}", ] return "\n".join(lines) @@ -245,6 +409,12 @@ def _draft_problem( cid = marker.strip() if cid not in live_ids: return f"body cites unlisted claim: {cid}" + + # citation-density guardrail: a structured page may synthesize prose but + # must ground it — mostly-uncited substantive sentences are embellishment. + coverage_problem = _low_coverage_reason(body) + if coverage_problem: + return coverage_problem return None @@ -288,7 +458,15 @@ def compile_kb( # run; refuse up front instead of silently producing nothing. raise CompileError(f"max_pages must be >= 1, got {cap}") - prompt = build_prompt(store, max_pages=cap) + planned: list[str] | None = None + if cfg.two_phase: + # phase A: plan the durable topics, then draft one focused page each. + topics_raw = run_llm( + cmd, build_topic_prompt(store, max_pages=cap), + timeout_seconds=cfg.timeout_seconds, + ) + planned = parse_topics(topics_raw)[:cap] or None + prompt = build_prompt(store, max_pages=cap, planned_topics=planned) drafts = parse_drafts(run_llm(cmd, prompt, timeout_seconds=cfg.timeout_seconds)) report = CompileReport(drafts=drafts, dry_run=dry_run) @@ -326,10 +504,25 @@ def compile_kb( # validator exists to prevent. known_static = {p.title.strip().lower() for p in existing} known_static |= {p.id.strip().lower() for p in existing} + # aliases resolve too, so a [[the anchor]] link to a page titled "Anchor + # Topic" is not falsely dropped — matches wiki_render's resolver. + for p in existing: + known_static |= { + str(a).strip().lower() + for a in (p.metadata.get("aliases") or []) + if str(a).strip() + } changed = True while changed: changed = False - known = known_static | {t.lower() for _, t in survivors} + known = set(known_static) + for draft, title in survivors: + known.add(title.lower()) + known |= { + str(a).strip().lower() + for a in (draft.get("aliases") or []) + if str(a).strip() + } for entry in list(survivors): draft, title = entry dangling = _first_dangling_link(str(draft.get("body") or ""), known) @@ -345,6 +538,7 @@ def compile_kb( if dry_run: report.proposed.append({"title": title, "proposal_id": "(dry-run)"}) continue + page_tags, page_meta = _page_frontmatter(draft) try: proposal = propose_page( store, @@ -353,7 +547,8 @@ def compile_kb( page_type=str(draft.get("type") or "concept").strip().lower(), claim_ids=[str(c) for c in draft.get("claims") or []], proposed_by=actor, - tags=["wiki", "compiled"], + tags=page_tags, + metadata=page_meta or None, session_id=session_id, rationale="compiled from approved claims; every inline " "citation was verified against the store", diff --git a/src/vouch/wiki_render.py b/src/vouch/wiki_render.py new file mode 100644 index 00000000..3d3c70b3 --- /dev/null +++ b/src/vouch/wiki_render.py @@ -0,0 +1,97 @@ +"""Derived wiki render: index, map-of-content, and backlinks over pages. + +These artifacts are regenerable *views* over the approved page set — like the +SQLite index, not authored knowledge — so they never go through the review +gate. ``render_*`` are pure functions; the CLI (``vouch render-wiki``) writes +their output to a render target. Keeping them derived is what lets vouch match +the llm-wiki compiler's browsable front door (index + map-of-content + +backlinks) without opening an ungated write path. +""" + +from __future__ import annotations + +import re +from collections import defaultdict + +from .models import Page + +_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)") + + +def _link_index(pages: list[Page]) -> dict[str, Page]: + """Map every resolvable name (title, id/slug, alias) to its page. + + Keys are lowercased. Earlier pages win a name collision (``setdefault``), + so a later page's alias never shadows an existing page's title. + """ + index: dict[str, Page] = {} + for page in pages: + for name in (page.title, page.id, *(page.metadata.get("aliases") or [])): + key = str(name).strip().lower() + if key: + index.setdefault(key, page) + return index + + +def resolve_link(target: str, pages: list[Page]) -> Page | None: + """Resolve a ``[[target]]`` to a page by title, id/slug, or alias.""" + return _link_index(pages).get(target.strip().lower()) + + +def backlinks(pages: list[Page]) -> dict[str, list[str]]: + """Map page id to the sorted titles of pages that link to it. + + A page's link to itself is ignored. Links are resolved through the same + title/slug/alias index used everywhere else, so an inbound link written as + an alias still counts. + """ + index = _link_index(pages) + inbound: dict[str, set[str]] = defaultdict(set) + for page in pages: + for raw in _WIKILINK_RE.findall(page.body): + target = index.get(raw.strip().lower()) + if target is not None and target.id != page.id: + inbound[target.id].add(page.title) + return {pid: sorted(titles) for pid, titles in inbound.items()} + + +def render_index(pages: list[Page]) -> str: + """Render an index grouped by page type, each entry with its summary.""" + if not pages: + return "# Knowledge Wiki\n\n_no approved pages yet._\n" + by_type: dict[str, list[Page]] = defaultdict(list) + for page in pages: + by_type[page.type].append(page) + lines = ["# Knowledge Wiki", ""] + for ptype in sorted(by_type): + lines.append(f"## {ptype}") + for page in sorted(by_type[ptype], key=lambda p: p.title.lower()): + summary = str(page.metadata.get("summary") or "").strip() + suffix = f" — {summary}" if summary else "" + lines.append(f"- [[{page.title}]]{suffix}") + lines.append("") + lines.append(f"_{len(pages)} page(s)_") + return "\n".join(lines) + "\n" + + +def render_moc(pages: list[Page]) -> str: + """Render a map-of-content ranked by how referenced each page is. + + The most linked-to pages surface first (hubs), each followed by its + inbound links, so a reader sees the graph's centre before its leaves. + """ + if not pages: + return "# Map of Content\n\n_no approved pages yet._\n" + inbound = backlinks(pages) + ordered = sorted( + pages, + key=lambda p: (-len(inbound.get(p.id, [])), p.title.lower()), + ) + lines = ["# Map of Content", ""] + for page in ordered: + refs = inbound.get(page.id, []) + lines.append(f"- **[[{page.title}]]** ({len(refs)} inbound)") + for title in refs: + lines.append(f" - ← [[{title}]]") + lines.append("") + return "\n".join(lines) + "\n" diff --git a/tests/test_cli.py b/tests/test_cli.py index 476fa116..f19d6fc1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -763,3 +763,35 @@ def test_session_summarize_missing_session_degrades_to_json(store: KBStore) -> N assert result.exit_code == 0, result.output assert "Traceback" not in result.output, result.output assert isinstance(json.loads(result.output), dict) + + +def test_render_wiki_writes_index_and_moc( + store: KBStore, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """`vouch render-wiki --out DIR` renders a derived index + MOC over the + approved pages — a view, not a gated write.""" + from vouch.proposals import approve, propose_page + + src = store.put_source(b"a durable fact about retries for grounding here") + cpr = propose_claim( + store, text="the retry limit is three", evidence=[src.id], + proposed_by="agent-A", + ) + claim = approve(store, cpr.id, approved_by="human-B") + ppr = propose_page( + store, + title="Retry Policy", + body=f"Retries cap at three attempts before failing hard [claim: {claim.id}].", + page_type="concept", + claim_ids=[claim.id], + proposed_by="agent-A", + metadata={"summary": "retries cap at three"}, + ) + approve(store, ppr.id, approved_by="human-B") + + out = tmp_path / "wikiout" + result = CliRunner().invoke(cli, ["render-wiki", "--out", str(out)]) + assert result.exit_code == 0, result.output + index = (out / "index.md").read_text(encoding="utf-8") + assert "[[Retry Policy]] — retries cap at three" in index + assert (out / "MOC.md").exists() diff --git a/tests/test_compile.py b/tests/test_compile.py index e1e6c9df..f4650d07 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -489,3 +489,191 @@ def test_same_batch_duplicate_slug_drops_second( assert len(report.proposed) == 1 assert len(report.dropped) == 1 assert "already exists or is pending review" in report.dropped[0]["reason"] + + +# --- phase 1: structured pages + rich frontmatter + citation-density ---------- + + +def test_draft_summary_and_aliases_land_in_page_metadata( + store: KBStore, tmp_path: Path, +) -> None: + c1 = _approved_claim(store, "the retry limit is three attempts before failing") + cmd = _stub_llm(tmp_path, [ + { + "title": "Billing Retries", + "type": "concept", + "summary": "retries cap at three before the operation fails", + "aliases": ["retry policy", "billing retry cap"], + "body": f"The retry limit is three attempts before failing [claim: {c1}].", + "claims": [c1], + }, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert len(report.proposed) == 1 + page = approve(store, report.proposed[0]["proposal_id"], approved_by="human-B") + stored = store.get_page(page.id) + assert stored.metadata.get("summary") == "retries cap at three before the operation fails" + assert stored.metadata.get("aliases") == ["retry policy", "billing retry cap"] + + +def test_draft_tags_merge_into_page_tags(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "staging runs postgres sixteen in the primary region") + cmd = _stub_llm(tmp_path, [ + { + "title": "Staging Database", + "type": "concept", + "tags": ["staging", "postgres"], + "body": f"Staging runs postgres sixteen in the primary region [claim: {c1}].", + "claims": [c1], + }, + ]) + report = compile_kb(store, config=_cfg(cmd)) + page = approve(store, report.proposed[0]["proposal_id"], approved_by="human-B") + tags = set(store.get_page(page.id).tags) + assert {"wiki", "compiled", "staging", "postgres"} <= tags + + +def test_low_citation_coverage_draft_drops(store: KBStore, tmp_path: Path) -> None: + """A structured draft whose substantive sentences are mostly uncited is + embellishment — drop it rather than ship invented prose behind one cite.""" + c1 = _approved_claim(store, "the retry limit is three") + body = ( + f"The retry limit is three attempts before the operation fails hard [claim: {c1}].\n" + "It also reconfigures the load balancer and drains connections gracefully.\n" + "The staging database is migrated to a new major version automatically.\n" + "Rollback happens through a separate out of band tooling path entirely." + ) + cmd = _stub_llm(tmp_path, [ + {"title": "Retry Behaviour", "type": "concept", "body": body, "claims": [c1]}, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert report.proposed == [] + assert "citation coverage" in report.dropped[0]["reason"] + + +def test_well_cited_structured_draft_survives(store: KBStore, tmp_path: Path) -> None: + c1 = _approved_claim(store, "the retry limit is three") + c2 = _approved_claim(store, "capping retries prevents backoff storms") + body = ( + "## What\n" + f"The retry limit is three attempts before the operation fails hard [claim: {c1}].\n" + "## Why\n" + f"Capping retries prevents unbounded backoff storms in staging [claim: {c2}]." + ) + cmd = _stub_llm(tmp_path, [ + {"title": "Retry Behaviour", "type": "concept", "body": body, + "claims": [c1, c2]}, + ]) + report = compile_kb(store, config=_cfg(cmd)) + assert [r["title"] for r in report.proposed] == ["Retry Behaviour"] + + +def test_build_prompt_requests_sections_summary_and_aliases(store: KBStore) -> None: + _approved_claim(store, "a fact to compile into a page") + prompt = compile_mod.build_prompt(store, max_pages=3) + low = prompt.lower() + assert "summary" in low + assert "aliases" in low + assert "section" in low or "##" in prompt + + +def test_wikilink_to_existing_page_alias_resolves( + store: KBStore, tmp_path: Path, +) -> None: + """A [[link]] that targets an existing page's alias must resolve, not drop: + the resolver knows title, slug, and alias.""" + c1 = _approved_claim(store, "a durable fact about the anchor topic here") + pr = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + { + "title": "Anchor Topic", + "type": "concept", + "aliases": ["the anchor"], + "summary": "the anchor topic", + "body": f"The anchor topic is durable and well understood here [claim: {c1}].", + "claims": [c1], + }, + ]))) + approve(store, pr.proposed[0]["proposal_id"], approved_by="human-B") + + report = compile_kb(store, config=_cfg(_stub_llm(tmp_path, [ + { + "title": "Follower Topic", + "type": "concept", + "body": f"This follower builds directly on [[the anchor]] as its base [claim: {c1}].", + "claims": [c1], + }, + ]))) + assert [r["title"] for r in report.proposed] == ["Follower Topic"] + + +# --- phase 3: two-phase concept extraction ------------------------------------ + + +def _phased_stub(tmp_path: Path, topics: list, drafts: list) -> str: + """A prompt-aware llm_cmd: returns the topic list when the prompt asks for + 'topic titles', otherwise the page drafts. Lets one stub serve both legs + of a two-phase compile.""" + tj = tmp_path / "topics.json" + tj.write_text(json.dumps(topics), encoding="utf-8") + dj = tmp_path / "drafts.json" + dj.write_text(json.dumps(drafts), encoding="utf-8") + script = tmp_path / "phased_stub.py" + script.write_text( + "import sys, pathlib\n" + "p = sys.stdin.read().lower()\n" + f"tj = pathlib.Path(r'{tj}'); dj = pathlib.Path(r'{dj}')\n" + "sys.stdout.write(tj.read_text() if 'topic titles' in p " + "else dj.read_text())\n", + encoding="utf-8", + ) + return f"python3 {script}" + + +def test_two_phase_config_flag_parsed(store: KBStore) -> None: + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + + "\ncompile:\n llm_cmd: \"cat /dev/null\"\n two_phase: true\n", + encoding="utf-8", + ) + cfg = compile_mod.load_config(store) + assert cfg.two_phase is True + + +def test_parse_topics_reads_strings_and_objects() -> None: + assert compile_mod.parse_topics('["Alpha", "Beta"]') == ["Alpha", "Beta"] + assert compile_mod.parse_topics('[{"title": "Gamma"}]') == ["Gamma"] + + +def test_build_topic_prompt_asks_for_topic_titles(store: KBStore) -> None: + _approved_claim(store, "a fact to group into durable topics") + prompt = compile_mod.build_topic_prompt(store, max_pages=5) + assert "topic" in prompt.lower() + assert "topic titles" in prompt.lower() + + +def test_build_prompt_includes_planned_topics(store: KBStore) -> None: + _approved_claim(store, "a fact to compile") + prompt = compile_mod.build_prompt( + store, max_pages=3, planned_topics=["Retry Policy", "Ship Flow"], + ) + assert "Retry Policy" in prompt + assert "Ship Flow" in prompt + + +def test_two_phase_compile_drafts_planned_pages( + store: KBStore, tmp_path: Path, +) -> None: + c1 = _approved_claim(store, "the retry limit is three attempts before failing") + topics = ["Retry Policy"] + drafts = [ + { + "title": "Retry Policy", + "type": "concept", + "summary": "retries cap at three", + "body": f"The retry limit is three attempts before failing hard [claim: {c1}].", + "claims": [c1], + }, + ] + cmd = _phased_stub(tmp_path, topics, drafts) + report = compile_kb(store, config=_cfg(cmd, two_phase=True)) + assert [r["title"] for r in report.proposed] == ["Retry Policy"] diff --git a/tests/test_wiki_render.py b/tests/test_wiki_render.py new file mode 100644 index 00000000..fbb52216 --- /dev/null +++ b/tests/test_wiki_render.py @@ -0,0 +1,86 @@ +"""Tests for `vouch.wiki_render` — the derived index/MOC/backlink render. + +These are pure functions over the approved page set: regenerable views (like +the SQLite index), never gated writes. The tests pin the shape of the front +door — grouped index with summaries, alias/slug resolution, inbound backlinks, +and a map-of-content ranked by how referenced a page is. +""" + +from __future__ import annotations + +from vouch import wiki_render +from vouch.models import Page + + +def _page( + title: str, + *, + body: str = "", + ptype: str = "concept", + summary: str = "", + aliases: list[str] | None = None, + pid: str | None = None, +) -> Page: + meta: dict[str, object] = {} + if summary: + meta["summary"] = summary + if aliases: + meta["aliases"] = aliases + return Page( + id=pid or title.lower().replace(" ", "-"), + title=title, + body=body, + type=ptype, + metadata=meta, + ) + + +def test_render_index_groups_by_type_with_summaries() -> None: + pages = [ + _page("Retry Policy", ptype="concept", summary="retries cap at three"), + _page("Ship Flow", ptype="workflow", summary="how to ship a release"), + ] + out = wiki_render.render_index(pages) + assert "[[Retry Policy]] — retries cap at three" in out + assert "[[Ship Flow]] — how to ship a release" in out + assert "concept" in out.lower() + assert "workflow" in out.lower() + + +def test_render_index_empty_is_safe() -> None: + out = wiki_render.render_index([]) + assert "no approved pages" in out.lower() + + +def test_resolve_link_matches_title_slug_and_alias() -> None: + p = _page("Retry Policy", aliases=["backoff cap"], pid="retry-policy") + pages = [p] + assert wiki_render.resolve_link("Retry Policy", pages) is p + assert wiki_render.resolve_link("retry-policy", pages) is p + assert wiki_render.resolve_link("backoff cap", pages) is p + assert wiki_render.resolve_link("nope", pages) is None + + +def test_backlinks_are_inbound_and_exclude_self() -> None: + a = _page("Alpha", body="see [[Beta]] for more", pid="alpha") + b = _page("Beta", body="a standalone leaf page", pid="beta") + bl = wiki_render.backlinks([a, b]) + assert bl.get("beta") == ["Alpha"] + assert "alpha" not in bl # nothing links to Alpha + + +def test_backlinks_resolve_through_aliases() -> None: + a = _page("Alpha", body="builds on [[the beta]]", pid="alpha") + b = _page("Beta", aliases=["the beta"], pid="beta") + bl = wiki_render.backlinks([a, b]) + assert bl.get("beta") == ["Alpha"] + + +def test_render_moc_ranks_by_inbound_links() -> None: + a = _page("Alpha", body="see [[Gamma]]", pid="alpha") + b = _page("Beta", body="see [[Gamma]]", pid="beta") + g = _page("Gamma", body="a leaf", pid="gamma") + out = wiki_render.render_moc([a, b, g]) + # Gamma has 2 inbound links; it must rank above the 0-inbound pages. + assert out.index("Gamma") < out.index("Alpha") + assert out.index("Gamma") < out.index("Beta") From e32737d4f65e98632ae85503115a6be97341be79 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:58:51 +0900 Subject: [PATCH 18/63] feat(admission): gate knowledge-shaped garbage at the proposal funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vouch has always gated *who approves* a write (proposals.approve) but never *whether the content is knowledge-shaped*. every ingestion path funnels through proposals._file_proposal, so one provenance-keyed admission predicate there raises the floor with no drift across surfaces. the gate is deterministic and receipt-safe — it only rejects verbatim payloads, never rewrites them, so byte-offset receipts stay intact. for claims it applies a structural floor (##+ headings, colon lead-ins, unbalanced code-spans/brackets) plus an optional confidence floor; for pages it rejects an uncited type:session/log page, a session diary rather than durable knowledge. it only blocks the passive auto-capture actors (vouch-capture, session-split, codex); a deliberate author's write stays advisory and reaches the review gate untouched. everything is configurable under the admission: block of config.yaml (enabled, min_confidence, reject_uncited_session_pages). auto-rejections are recorded (decided_by: vouch-admission) and reviewable with the new `vouch rejected` command — nothing is ever deleted. turning the page rule on by default deprecates the auto-captured session-page pipeline: capture.finalize, session_split renarrate, codex_rollout reingest, and the review banner become no-ops for these actors. the tests that asserted those pages land pending are updated to the auto-reject contract; deleting the dead machinery is a follow-up. resolve_pending_receipt_claim now skips a non-pending proposal so a gate-rejected fragment can no longer crash capture_answer's auto-approve loop and take the good claims beside it with it. --- CHANGELOG.md | 25 ++++ src/vouch/admission.py | 202 +++++++++++++++++++++++++++++++ src/vouch/cli.py | 38 ++++++ src/vouch/proposals.py | 22 +++- tests/test_admission.py | 230 ++++++++++++++++++++++++++++++++++++ tests/test_adopt.py | 20 +++- tests/test_capture.py | 80 +++++++++---- tests/test_codex_rollout.py | 51 +++++--- tests/test_session_split.py | 86 ++++++++------ 9 files changed, 673 insertions(+), 81 deletions(-) create mode 100644 src/vouch/admission.py create mode 100644 tests/test_admission.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0704c6a4..8a01124b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- **an admission gate that filters knowledge-shaped garbage before it is + filed** (`admission:` config). every ingestion path funnels through + `proposals._file_proposal`, so a single provenance-keyed predicate there + raises the floor with no drift across surfaces. it is deterministic and + receipt-safe — it rejects verbatim payloads, never rewrites them, so + byte-offset receipts stay intact. a claim that is a markdown heading, a + colon lead-in, or a truncated code-span/bracket is refused, as is an + uncited `type: session`/`log` page (a session diary, not durable + knowledge). only the passive auto-capture actors (`vouch-capture`, + `session-split`, `codex`) are blocked; a deliberate author's write stays + advisory and reaches the review gate untouched. tunable via + `admission.{enabled, min_confidence, reject_uncited_session_pages}`. +- **`vouch rejected`** — list rejected proposals (with `--admission` to show + only gate auto-rejections). auto-rejections are recorded + (`decided_by: vouch-admission`) and never deleted, so a false positive is + always recoverable. + +### Deprecated +- the auto-captured session-page pipeline is now a no-op for auto-capture + actors: `capture.finalize`'s session summary, `session_split` renarrate, + `codex_rollout` reingest, and the SessionStart review banner all produced + uncited `type: session` pages, which the admission gate now auto-rejects. + removal of the dead machinery is a follow-up. + ### Changed - **the per-prompt hook now lets the model decide how much of a turn vouch takes** (`retrieval.prompt_gate`). recall used to inject an diff --git a/src/vouch/admission.py b/src/vouch/admission.py new file mode 100644 index 00000000..e1ac74cd --- /dev/null +++ b/src/vouch/admission.py @@ -0,0 +1,202 @@ +"""Deterministic admission gate: reject knowledge-shaped garbage before it is +filed, keyed on provenance. + +vouch has always had a gate for *who approves* a write (``proposals.approve``); +it has never had one for *whether the content is knowledge-shaped*. Every +ingestion path funnels through ``proposals._file_proposal``, so this predicate — +applied there — is the single, drift-free place to raise the floor. + +Rules, all cheap, deterministic, and receipt-safe. They only accept or reject a +verbatim payload; they never rewrite it, so byte-offset receipts stay intact: + +* **L1 structural floor (claims).** A claim whose text is a fragment — a + markdown heading (``##``+), a lead-in ending in a colon, or a truncated span + with an unbalanced code span / bracket — is not a claim. Tuned for high + precision: episodic-but-grammatical prose is *not* caught here (that judgment + is advisory, not a regex's to make), and rules that misfire on real claims + (stranded prepositions, ``**kwargs``, ``27"``) were deliberately dropped. +* **Confidence floor (claims).** Optionally, a claim whose ``confidence`` is + below ``admission.min_confidence`` is rejected. Off by default (floor ``0.0``) + so it changes nothing until an operator opts in or a scoring layer starts + assigning varied per-claim confidence. +* **L0/L2 metadata rule (pages).** An auto-captured page of ``type: session`` / + ``log`` that cites nothing is a session diary, not durable knowledge — the + same exclusion ``compile._FORBIDDEN_TYPES`` already applies downstream, moved + upstream to admission. + +The gate only *blocks* for the passive auto-capture actors (the firehoses). For +a deliberate author — an agent calling ``kb_propose_claim``, a human at the CLI, +a hub import — the verdict is advisory: the write still goes through. Someone +chose to file it; the review gate, not a heuristic, decides its fate. + +Everything is configurable under the ``admission:`` block of ``config.yaml`` +(see ``AdmissionConfig``); auto-rejections are recorded (``decided_by: +vouch-admission``) and reviewable with ``vouch rejected``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import yaml + +if TYPE_CHECKING: + from .storage import KBStore + +# Passive session-capture actors whose proposals are auto-rejected on a failed +# admission check. Deliberate / human / downstream actors are advisory-only. +AUTO_CAPTURE_ACTORS: frozenset[str] = frozenset( + {"vouch-capture", "session-split", "codex"} +) + +# ``session`` / ``log`` pages are raw material, not topics — a mirror of +# ``compile._FORBIDDEN_TYPES``, enforced here at admission instead of at compile. +_RAW_PAGE_TYPES: frozenset[str] = frozenset({"session", "log"}) + +_MIN_LETTER_RATIO = 0.5 +# Two or more leading hashes = a markdown heading. A single '#' is left alone: +# it is ambiguous with the "# of X" ("number of") idiom, and rejecting real +# claims is worse than letting a rare single-'#' heading reach human review. +_HEADING_RE = re.compile(r"^#{2,6}\s") +_LEADING_MARKDOWN_RE = re.compile(r"^[>\-*+\s]+") + +DEFAULT_ENABLED = True +DEFAULT_MIN_CONFIDENCE = 0.0 +DEFAULT_REJECT_UNCITED_SESSION_PAGES = True + + +@dataclass(frozen=True) +class AdmissionConfig: + """The ``admission:`` block of ``config.yaml``. + + ``enabled`` is the master switch. ``min_confidence`` is the claim confidence + floor (``0.0`` = off). ``reject_uncited_session_pages`` toggles the page + metadata rule. All only ever affect auto-capture actors. + """ + + enabled: bool = DEFAULT_ENABLED + min_confidence: float = DEFAULT_MIN_CONFIDENCE + reject_uncited_session_pages: bool = DEFAULT_REJECT_UNCITED_SESSION_PAGES + + +def load_config(store: KBStore) -> AdmissionConfig: + """Read ``admission:`` from config.yaml; fall back to defaults on any error.""" + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return AdmissionConfig() + if not isinstance(loaded, dict): + return AdmissionConfig() + raw = loaded.get("admission") + if not isinstance(raw, dict): + return AdmissionConfig() + return AdmissionConfig( + enabled=bool(raw.get("enabled", DEFAULT_ENABLED)), + min_confidence=_as_float(raw.get("min_confidence")) or DEFAULT_MIN_CONFIDENCE, + reject_uncited_session_pages=bool( + raw.get("reject_uncited_session_pages", DEFAULT_REJECT_UNCITED_SESSION_PAGES) + ), + ) + + +@dataclass(frozen=True) +class AdmissionVerdict: + admit: bool + reason: str | None = None + + +_ADMIT = AdmissionVerdict(admit=True) + + +def _reject(reason: str) -> AdmissionVerdict: + return AdmissionVerdict(admit=False, reason=reason) + + +def _as_float(value: object) -> float | None: + try: + return float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +def _delimiters_balanced(s: str) -> bool: + """True unless an inline-code span or a bracket is left unpaired. + + An odd count of ```` ` ```` — or a bracket that closes without an opener / + opens without a close — is the signature of a span the capture splitter cut + mid-syntax. Deliberately NOT checked: ``**`` (rejects ``**kwargs``) and ``"`` + (rejects ``a 27" monitor``); both are common in real claims and neither + caught any observed fragment. + """ + if s.count("`") % 2: + return False + pairs = {")": "(", "]": "[", "}": "{"} + openers = set(pairs.values()) + stack: list[str] = [] + for ch in s: + if ch in openers: + stack.append(ch) + elif ch in pairs: + if not stack or stack[-1] != pairs[ch]: + return False # stray close — truncated head + stack.pop() + return not stack # leftover open — truncated tail + + +def assess_claim( + text: str, *, confidence: float | None = None, min_confidence: float = 0.0 +) -> AdmissionVerdict: + """Structural floor (always) + optional confidence floor for a claim span.""" + stripped = text.strip() + if _HEADING_RE.match(stripped): + return _reject("markdown heading, not a claim") + core = _LEADING_MARKDOWN_RE.sub("", stripped).strip() + if not core: + return _reject("empty after stripping markdown markers") + letters = sum(c.isalpha() for c in core) + if letters < len(core) * _MIN_LETTER_RATIO: + return _reject("mostly punctuation/markup, not prose") + if core.rstrip().endswith(":"): + return _reject("lead-in ending in a colon, not a standalone claim") + if not _delimiters_balanced(core): + return _reject("unbalanced delimiters — a truncated fragment") + # NOTE: no "ends on a dangling function word" rule — English claims routinely + # end in a stranded preposition ("...the config lives in.") and rejecting + # them silently loses real knowledge. Precision over recall here. + if confidence is not None and confidence < min_confidence: + return _reject( + f"confidence {confidence:.2f} below admission floor {min_confidence:.2f}" + ) + return _ADMIT + + +def assess_page(payload: dict) -> AdmissionVerdict: + """Metadata rule: an uncited ``session``/``log`` page is a diary, not a topic.""" + page_type = payload.get("type") + if page_type in _RAW_PAGE_TYPES and not ( + payload.get("claims") or payload.get("sources") + ): + return _reject( + f"uncited {page_type!r} page — a session diary, not durable knowledge" + ) + return _ADMIT + + +def assess(kind: str, payload: dict, cfg: AdmissionConfig | None = None) -> AdmissionVerdict: + """Dispatch on proposal kind under ``cfg``. Entities/relations/deletes admit.""" + cfg = cfg or AdmissionConfig() + if not cfg.enabled: + return _ADMIT + if kind == "claim": + return assess_claim( + str(payload.get("text", "")), + confidence=_as_float(payload.get("confidence")), + min_confidence=cfg.min_confidence, + ) + if kind == "page": + if not cfg.reject_uncited_session_pages: + return _ADMIT + return assess_page(payload) + return _ADMIT diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f3e08a87..b6a5515a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -68,6 +68,7 @@ from .page_filters import filter_pages, parse_kv from .page_kinds import PageKindError, load_page_kind_registry from .proposals import ( + ADMISSION_ACTOR, EXPIRE_ACTOR, ProposalError, check_approvable, @@ -1308,6 +1309,43 @@ def pending(as_json: bool) -> None: click.echo(f" {str(preview).strip()[:120]}") +@cli.command() +@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") +@click.option("--limit", type=click.IntRange(min=1), default=None, help="Show at most N.") +@click.option( + "--admission", + "admission_only", + is_flag=True, + help="Only auto-rejections by the admission gate.", +) +def rejected(as_json: bool, limit: int | None, admission_only: bool) -> None: + """List rejected proposals — including admission-gate auto-rejections. + + A rejected proposal is never deleted; it lives in ``decided/`` with the + reason it was refused. Use this to audit what the admission gate dropped and + to catch a false positive (re-propose it deliberately if it was good). + """ + store = _load_store() + items = store.list_proposals(ProposalStatus.REJECTED) + if admission_only: + items = [pr for pr in items if pr.decided_by == ADMISSION_ACTOR] + items.sort(key=lambda pr: pr.decided_at or pr.proposed_at, reverse=True) + if limit is not None: + items = items[:limit] + if as_json: + _emit_json([pr.model_dump(mode="json") for pr in items]) + return + if not items: + click.echo("no rejected proposals") + return + for pr in items: + preview = pr.payload.get("text") or pr.payload.get("title") or pr.payload.get("name") or "—" + click.echo(f"• {pr.id} [{pr.kind.value}] by {pr.proposed_by} → {pr.decided_by}") + click.echo(f" {str(preview).strip()[:100]}") + if pr.decision_reason: + click.echo(f" reason: {pr.decision_reason}") + + def _proposal_preview(pr: Proposal) -> str: preview = ( pr.payload.get("text") diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 81b5fb74..34b9ba38 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -16,7 +16,7 @@ import yaml from pydantic import ValidationError -from . import audit, index_db +from . import admission, audit, index_db from .models import ( ArtifactScope, Claim, @@ -39,6 +39,7 @@ class ProposalError(RuntimeError): EXPIRE_REASON = "expired" EXPIRE_ACTOR = "vouch-expire" +ADMISSION_ACTOR = "vouch-admission" _DEFAULT_EXPIRE_PENDING_DAYS = 90 @@ -144,6 +145,19 @@ def _file_proposal( store.kb_dir, event=f"proposal.{kind.value}.create", actor=proposed_by, object_ids=[proposal.id], data={"slug_hint": payload.get("id")}, ) + # Admission gate: deterministic, receipt-safe floor on knowledge-shaped + # garbage. It only *blocks* the passive auto-capture firehoses; a deliberate + # author's write is advisory-only and passes straight through to the review + # gate. Filing-then-rejecting (rather than dropping) keeps the audit log as + # the authoritative record of what was refused and why. + if proposed_by in admission.AUTO_CAPTURE_ACTORS: + verdict = admission.assess(kind.value, payload, admission.load_config(store)) + if not verdict.admit: + return reject( + store, proposal.id, + rejected_by=ADMISSION_ACTOR, + reason=f"admission: {verdict.reason}", + ) return proposal @@ -544,6 +558,12 @@ def resolve_pending_receipt_claim( """ if proposal.kind != ProposalKind.CLAIM: return None + if proposal.status is not ProposalStatus.PENDING: + # The admission gate may have auto-rejected this proposal at filing time + # (file-then-reject in _file_proposal). A decided proposal has nothing to + # resolve — approving it would raise. Skip so the capture loop survives a + # rejected fragment and still approves the good claims beside it. + return None review_cfg = _review_config(store) trusted = review_cfg.get("approver_role") == "trusted-agent" receipted = bool( diff --git a/tests/test_admission.py b/tests/test_admission.py new file mode 100644 index 00000000..ac51b1fe --- /dev/null +++ b/tests/test_admission.py @@ -0,0 +1,230 @@ +"""Deterministic admission gate: knowledge-shaped garbage is auto-rejected at the +proposal funnel for passive auto-capture actors, and left alone for deliberate +authors. Corpus is the real trash observed in a live KB's pending queue. +""" + +from __future__ import annotations + +import pytest + +from vouch import admission +from vouch.models import ProposalStatus +from vouch.proposals import propose_claim, propose_page +from vouch.storage import KBStore + +# --- real fragments observed in the wild (vouch-capture claim spans) ---------- +FRAGMENT_CLAIMS = [ + "Here's where things stand:", # lead-in colon + "> From vouch memory:", # lead-in colon (quoted) + "## The one idea everything hangs on", # markdown heading + "com/vouchdev/vouch/pull/517) is open", # stray close paren + "- [adapters/claude-code/README.", # unbalanced bracket + "The dorahack entry in `~/.", # unbalanced backtick +] + +# claims the adversarial pass PROVED were being wrongly hard-rejected — the gate +# must ADMIT these (precision regressions; keep them or the false positives return) +PRECISION_REGRESSIONS = [ + "Vouch is the knowledge base every project depends on.", # stranded preposition + "The config lives in the directory the KB was initialized in.", # stranded preposition + "Use **kwargs to accept arbitrary keyword arguments in Python.", # lone ** (kwargs) + "# of pending proposals dropped to zero after the sweep.", # '#' = number-of + 'A 27" monitor improved the reviewer workflow.', # lone double-quote +] + +# --- real durable claims that MUST survive (Karpathy + the two borderline) ---- +GOOD_CLAIMS = [ + 'In the Oct 2025 Dwarkesh Podcast interview, Andrej Karpathy proposed a "cognitive core".', + "> Simply: one-prompt memory remembers *what was said*; vouch remembers *what was verified*.", + "> In vouch's design, care of the output is mostly mechanical, " + "with a thin human curator on top.", +] + + +@pytest.fixture +def store(tmp_path, monkeypatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +# ---------------------------------------------------------------- pure predicate +@pytest.mark.parametrize("text", FRAGMENT_CLAIMS) +def test_assess_claim_rejects_structural_fragments(text: str) -> None: + verdict = admission.assess_claim(text) + assert not verdict.admit, f"should reject fragment: {text!r}" + assert verdict.reason + + +@pytest.mark.parametrize("text", GOOD_CLAIMS) +def test_assess_claim_admits_durable_claims(text: str) -> None: + assert admission.assess_claim(text).admit, f"should admit: {text!r}" + + +@pytest.mark.parametrize("text", PRECISION_REGRESSIONS) +def test_assess_claim_admits_previously_false_rejected(text: str) -> None: + assert admission.assess_claim(text).admit, f"regressed to a false reject: {text!r}" + + +def test_resolve_pending_receipt_claim_skips_gate_rejected(store: KBStore) -> None: + # a fragment the admission gate rejected must not crash capture's auto-approve + # loop — resolve must return None on a decided proposal, never call approve(). + from vouch.proposals import resolve_pending_receipt_claim + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, text="Here's where things stand:", evidence=[src.id], + proposed_by="vouch-capture", + ) + assert result.proposal.status is ProposalStatus.REJECTED + out = resolve_pending_receipt_claim( + store, result.proposal, actor="vouch-capture", reason="x", + ) + assert out is None # skipped gracefully, no ProposalError + + +def test_assess_page_rejects_uncited_session_narrative() -> None: + payload = {"type": "session", "claims": [], "sources": []} + assert not admission.assess_page(payload).admit + + +def test_assess_page_admits_cited_session_page() -> None: + payload = {"type": "session", "claims": ["some-claim"], "sources": []} + assert admission.assess_page(payload).admit + + +def test_assess_page_admits_non_raw_type() -> None: + payload = {"type": "concept", "claims": [], "sources": []} + assert admission.assess_page(payload).admit + + +# ------------------------------------------------------------- provenance gating +def test_autocapture_fragment_claim_is_auto_rejected(store: KBStore) -> None: + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, + text="Here's where things stand:", + evidence=[src.id], + proposed_by="vouch-capture", # passive firehose actor + ) + assert result.proposal.status is ProposalStatus.REJECTED + # it never lingers in the pending queue + pending = {p.id for p in store.list_proposals() if p.status is ProposalStatus.PENDING} + assert result.proposal.id not in pending + + +def test_autocapture_good_claim_stays_pending(store: KBStore) -> None: + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, + text="Vouch routes every write through a single review gate.", + evidence=[src.id], + proposed_by="vouch-capture", + ) + assert result.proposal.status is ProposalStatus.PENDING + + +def test_deliberate_author_fragment_is_advisory_not_rejected(store: KBStore) -> None: + # a human/agent who deliberately files something is never hard-rejected by + # the regex; the human review gate decides. + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, + text="Here's where things stand:", + evidence=[src.id], + proposed_by="claude-code", # deliberate actor + ) + assert result.proposal.status is ProposalStatus.PENDING + + +def test_autocapture_session_page_is_auto_rejected(store: KBStore) -> None: + proposal = propose_page( + store, + title="Built and stress-tested the adopt feature", + body="Implemented adopt.py, wrote tests, opened the PR.", + page_type="session", + proposed_by="session-split", # passive firehose actor + ) + assert proposal.status is ProposalStatus.REJECTED + pending = {p.id for p in store.list_proposals() if p.status is ProposalStatus.PENDING} + assert proposal.id not in pending + + +def test_deliberate_session_page_stays_pending(store: KBStore) -> None: + proposal = propose_page( + store, + title="Built and stress-tested the adopt feature", + body="Implemented adopt.py, wrote tests, opened the PR.", + page_type="session", + proposed_by="claude-code", # deliberate actor + ) + assert proposal.status is ProposalStatus.PENDING + + +# ----------------------------------------------------------- config: confidence +def _write_admission_config(store: KBStore, body: str) -> None: + store.config_path.write_text(f"admission:\n{body}", encoding="utf-8") + + +def test_assess_claim_confidence_floor() -> None: + good = "Vouch routes every write through a single review gate." + assert not admission.assess_claim(good, confidence=0.4, min_confidence=0.5).admit + assert admission.assess_claim(good, confidence=0.6, min_confidence=0.5).admit + assert admission.assess_claim(good, confidence=None, min_confidence=0.5).admit + assert admission.assess_claim(good).admit # no floor by default + + +def test_confidence_floor_rejects_low_confidence_autocapture(store: KBStore) -> None: + _write_admission_config(store, " min_confidence: 0.8\n") + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, text="Vouch routes every write through a single review gate.", + evidence=[src.id], proposed_by="vouch-capture", confidence=0.7, + ) + assert result.proposal.status is ProposalStatus.REJECTED + assert "confidence" in (result.proposal.decision_reason or "") + + +def test_confidence_floor_advisory_for_deliberate(store: KBStore) -> None: + _write_admission_config(store, " min_confidence: 0.8\n") + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, text="Vouch routes every write through a single review gate.", + evidence=[src.id], proposed_by="claude-code", confidence=0.7, + ) + assert result.proposal.status is ProposalStatus.PENDING + + +# ---------------------------------------------------------------- config: toggles +def test_admission_disabled_lets_fragments_through(store: KBStore) -> None: + _write_admission_config(store, " enabled: false\n") + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, text="Here's where things stand:", evidence=[src.id], + proposed_by="vouch-capture", + ) + assert result.proposal.status is ProposalStatus.PENDING + + +def test_page_rule_toggle_off_keeps_session_page(store: KBStore) -> None: + _write_admission_config(store, " reject_uncited_session_pages: false\n") + proposal = propose_page( + store, title="Built the adopt feature", body="did work", + page_type="session", proposed_by="session-split", + ) + assert proposal.status is ProposalStatus.PENDING + + +# ------------------------------------------------------------- check rejected cli +def test_vouch_rejected_lists_admission_rejections(store: KBStore) -> None: + from click.testing import CliRunner + + from vouch.cli import cli + src = store.put_source(b"here is a source body", title="t") + propose_claim( + store, text="Here's where things stand:", evidence=[src.id], + proposed_by="vouch-capture", + ) + res = CliRunner().invoke(cli, ["rejected", "--admission"]) + assert res.exit_code == 0, res.output + assert "vouch-admission" in res.output + assert "admission:" in res.output # the recorded reason is visible diff --git a/tests/test_adopt.py b/tests/test_adopt.py index c6fc4016..70a67517 100644 --- a/tests/test_adopt.py +++ b/tests/test_adopt.py @@ -408,7 +408,9 @@ def test_fallback_session_summary_records_its_origin( personal: KBStore, tmp_path: Path ) -> None: """A rollup filed into the shared personal KB must say which folder it is - about, and `adopt` must report it rather than leave it silently behind.""" + about. The admission gate now auto-rejects an uncited session page, but the + origin/provenance it carries survives on the rejected proposal rather than + being left silently behind.""" from vouch import capture as cap origin = tmp_path / "projA" @@ -418,14 +420,26 @@ def test_fallback_session_summary_records_its_origin( now=float(i)) result = cap.finalize(personal, "sum-1", cwd=origin, project=origin.name, origin=origin) + # finalize still returns the id even though the uncited session rollup is + # auto-rejected at filing by the admission gate. assert result["summary_proposal_id"] proposal = personal.get_proposal(str(result["summary_proposal_id"])) assert proposal.payload["metadata"]["origin_path"] == str(origin) assert "personal-fallback" in proposal.payload["tags"] - + # the same origin-bearing proposal is the one the admission gate rejected — + # explicitly rejected, not silently dropped. + assert proposal.status is ProposalStatus.REJECTED + assert proposal.decided_by == "vouch-admission" + assert proposal.decision_reason.startswith("admission:") + pending = {p.id for p in personal.list_proposals(ProposalStatus.PENDING)} + rejected = {p.id for p in personal.list_proposals(ProposalStatus.REJECTED)} + assert proposal.id not in pending + assert proposal.id in rejected + + # a rejected page is not adoptable — adopt must not surface it as pending. project = KBStore.init(origin) report = adopt_mod.adopt(project, personal, match_root=origin) - assert proposal.id in report.pages_pending_in_personal + assert proposal.id not in report.pages_pending_in_personal def test_personal_entry_prefers_a_live_row_over_a_stale_one( diff --git a/tests/test_capture.py b/tests/test_capture.py index 158079d2..584b27c0 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -300,21 +300,28 @@ def _seed(store: KBStore, sid: str, n: int) -> None: cap.observe(store, sid, tool="Edit", summary=f"Edited f{i}.py", now=float(i)) -def test_finalize_files_one_pending_page(store: KBStore, tmp_path: Path) -> None: +def test_finalize_files_one_auto_rejected_page(store: KBStore, tmp_path: Path) -> None: from vouch.models import ProposalKind, ProposalStatus _seed(store, "s1", 3) result = cap.finalize(store, "s1", cwd=tmp_path) pid = result["summary_proposal_id"] assert pid is not None - pend = store.list_proposals(ProposalStatus.PENDING) - match = [p for p in pend if p.id == pid] + # an uncited session page from an auto-capture actor is filed then + # immediately auto-rejected by admission: it never lands in the pending + # queue, but the same mechanical page is what finalize returns. + assert store.list_proposals(ProposalStatus.PENDING) == [] + rej = store.list_proposals(ProposalStatus.REJECTED) + match = [p for p in rej if p.id == pid] assert len(match) == 1 pr = match[0] assert pr.kind == ProposalKind.PAGE assert pr.proposed_by == cap.CAPTURE_ACTOR assert pr.payload["type"] == cap.CAPTURE_PAGE_TYPE - assert pr.status == ProposalStatus.PENDING + assert pr.status == ProposalStatus.REJECTED + assert pr.decided_by == "vouch-admission" + assert pr.decision_reason.startswith("admission:") + assert store.get_proposal(pid).status == ProposalStatus.REJECTED def test_finalize_below_min_files_nothing(store: KBStore, tmp_path: Path) -> None: @@ -421,15 +428,28 @@ def test_finalize_reads_transcript_for_title(store: KBStore, tmp_path: Path) -> encoding="utf-8", ) cap.finalize(store, "s1", cwd=tmp_path, transcript_path=transcript) - pend = store.list_proposals(ProposalStatus.PENDING) - assert len(pend) == 1 - assert pend[0].payload["title"] == "session: ship the digest" + # the uncited session page is auto-rejected by admission, but the title is + # still built from the transcript's first prompt before it is filed. + rej = store.list_proposals(ProposalStatus.REJECTED) + assert len(rej) == 1 + assert rej[0].payload["title"] == "session: ship the digest" + +def test_capture_page_auto_rejected_not_counted_pending( + store: KBStore, tmp_path: Path +) -> None: + from vouch.models import ProposalStatus -def test_pending_count_counts_capture_actor(store: KBStore, tmp_path: Path) -> None: _seed(store, "s1", 3) cap.finalize(store, "s1", cwd=tmp_path) - assert cap.pending_count(store) == 1 + # the auto-captured session page is auto-rejected by admission, so it never + # counts toward the pending-review banner. + assert cap.pending_count(store) == 0 + rej = store.list_proposals(ProposalStatus.REJECTED) + assert len(rej) == 1 + assert rej[0].proposed_by == cap.CAPTURE_ACTOR + assert rej[0].status == ProposalStatus.REJECTED + assert rej[0].decided_by == "vouch-admission" import json as _json # noqa: E402 @@ -471,17 +491,26 @@ def test_cli_finalize_files_proposal(store: KBStore) -> None: payload = _json.dumps({"session_id": "cc-2", "cwd": str(store.kb_dir.parent)}) res = _run(store, ["capture", "finalize"], stdin=payload) assert res.exit_code == 0 - pend = store.list_proposals(ProposalStatus.PENDING) - assert any(p.proposed_by == cap.CAPTURE_ACTOR for p in pend) + # the CLI files the session page through the same admission gate: an + # uncited capture-actor page is auto-rejected, not left pending. + assert not any( + p.proposed_by == cap.CAPTURE_ACTOR + for p in store.list_proposals(ProposalStatus.PENDING) + ) + rej = store.list_proposals(ProposalStatus.REJECTED) + assert any(p.proposed_by == cap.CAPTURE_ACTOR for p in rej) -def test_cli_banner_emits_when_pending(store: KBStore) -> None: +def test_cli_banner_silent_after_capture_auto_rejected(store: KBStore) -> None: for i in range(3): cap.observe(store, "cc-3", tool="Edit", summary=f"Edited f{i}.py", now=float(i)) cap.finalize(store, "cc-3", cwd=store.kb_dir.parent) + # the session page was auto-rejected by admission, so nothing awaits review: + # the SessionStart banner stays silent rather than announcing a pending page. + assert store.list_proposals(ProposalStatus.REJECTED) res = _run(store, ["capture", "banner"]) assert res.exit_code == 0 - assert "awaiting review" in res.output + assert "awaiting review" not in res.output def test_cli_banner_silent_when_none(store: KBStore) -> None: @@ -777,10 +806,12 @@ def test_finalize_all_except_returns_proposal_ids(tmp_path): ) assert old_sess in result["finalized"] - # Verify a proposal was created + # Verify a proposal was created — the uncited session page is filed then + # auto-rejected by admission, landing in the rejected queue, not pending. from vouch.models import ProposalStatus - pending = store.list_proposals(ProposalStatus.PENDING) - assert len(pending) > 0 + assert store.list_proposals(ProposalStatus.PENDING) == [] + rejected = store.list_proposals(ProposalStatus.REJECTED) + assert len(rejected) > 0 def test_capture_e2e_sessionstart_cleanup_then_finalize(tmp_path): @@ -813,9 +844,10 @@ def test_capture_e2e_sessionstart_cleanup_then_finalize(tmp_path): assert old_sess in cleanup_result["finalized"] assert not old_path.exists() - # Verify old session was proposed - pending_before = store.list_proposals(ProposalStatus.PENDING) - old_proposals = [p for p in pending_before if p.session_id == old_sess] + # Verify old session was filed and auto-rejected by admission (uncited + # session page from an auto-capture actor never becomes pending) + rejected_before = store.list_proposals(ProposalStatus.REJECTED) + old_proposals = [p for p in rejected_before if p.session_id == old_sess] assert len(old_proposals) == 1 # 2. SessionEnd finalize (current session) @@ -826,13 +858,13 @@ def test_capture_e2e_sessionstart_cleanup_then_finalize(tmp_path): assert finalize_result["summary_proposal_id"] is not None assert not new_path.exists() - # Verify new session was proposed - pending_after = store.list_proposals(ProposalStatus.PENDING) - new_proposals = [p for p in pending_after if p.session_id == new_sess] + # Verify new session was filed and auto-rejected too + rejected_after = store.list_proposals(ProposalStatus.REJECTED) + new_proposals = [p for p in rejected_after if p.session_id == new_sess] assert len(new_proposals) == 1 - # Total proposals: old + new - assert len(pending_after) >= 2 + # Total proposals: old + new, both auto-rejected + assert len(rejected_after) >= 2 # --- personal-KB fallback capture through the hook CLI (phase 3) ----------- diff --git a/tests/test_codex_rollout.py b/tests/test_codex_rollout.py index 00977912..a810424f 100644 --- a/tests/test_codex_rollout.py +++ b/tests/test_codex_rollout.py @@ -109,9 +109,16 @@ def test_ingest_files_one_pending_page(store: KBStore) -> None: pid = result["summary_proposal_id"] assert pid is not None assert result["captured"] == 4 - pend = store.list_proposals(ProposalStatus.PENDING) - assert [p.id for p in pend] == [pid] - pr = pend[0] + # An uncited `session` page filed by the `codex` auto-capture actor is + # auto-rejected at admission: it never lands in PENDING, it lands decided. + assert store.list_proposals(ProposalStatus.PENDING) == [] + rej = store.list_proposals(ProposalStatus.REJECTED) + assert [p.id for p in rej] == [pid] + pr = rej[0] + assert pr.status == ProposalStatus.REJECTED + assert pr.decided_by == "vouch-admission" + assert pr.decision_reason is not None + assert pr.decision_reason.startswith("admission:") assert pr.kind == ProposalKind.PAGE assert pr.proposed_by == cr.CODEX_ACTOR assert pr.session_id == BASIC_SESSION @@ -188,29 +195,37 @@ def _grown_copy(tmp_path: Path) -> Path: def test_reingest_grown_session_updates_pending_in_place( store: KBStore, tmp_path: Path ) -> None: + # The first ingest's session page is auto-rejected at admission (an uncited + # session diary), so it never stays PENDING. A later, grown turn can no + # longer refresh it in place — the now-decided proposal blocks re-ingest, + # so the same id comes back as an `already-ingested` no-op and the body is + # never refreshed with the grown turn's `ruff check src`. first = cr.ingest_rollout(store, BASIC) pid = first["summary_proposal_id"] second = cr.ingest_rollout(store, _grown_copy(tmp_path)) - assert second["updated"] is True + assert second["skipped"] == "already-ingested" assert second["summary_proposal_id"] == pid proposals = store.list_proposals(None) - assert len(proposals) == 1 # refreshed, not duplicated - assert "ruff check src" in proposals[0].payload["body"] - assert proposals[0].status == ProposalStatus.PENDING + assert len(proposals) == 1 # still one, not duplicated + assert proposals[0].status == ProposalStatus.REJECTED + assert "ruff check src" not in proposals[0].payload["body"] def test_reingest_decided_session_stays_decided( store: KBStore, tmp_path: Path ) -> None: - """A proposal the human already reviewed is history — a later turn must - not resurrect or mutate it.""" - from vouch.proposals import approve - + """A proposal that's already decided is history — a later turn must not + resurrect or mutate it. The session page is auto-rejected at admission, so + it's decided from the very first ingest, no human review step needed.""" first = cr.ingest_rollout(store, BASIC) - approve(store, first["summary_proposal_id"], approved_by="alice-example") + pid = first["summary_proposal_id"] + assert store.get_proposal(pid).status == ProposalStatus.REJECTED result = cr.ingest_rollout(store, _grown_copy(tmp_path)) assert result["skipped"] == "already-ingested" + assert result["summary_proposal_id"] == pid assert len(store.list_proposals(ProposalStatus.PENDING)) == 0 + # the decided proposal is untouched by the later turn + assert store.get_proposal(pid).status == ProposalStatus.REJECTED def test_reingest_update_lands_in_audit_log(store: KBStore, tmp_path: Path) -> None: @@ -219,7 +234,12 @@ def test_reingest_update_lands_in_audit_log(store: KBStore, tmp_path: Path) -> N cr.ingest_rollout(store, BASIC) cr.ingest_rollout(store, _grown_copy(tmp_path)) events = [e.event for e in audit.read_events(store.kb_dir)] - assert "proposal.page.update" in events + # The session page is auto-rejected at admission, so the ingest's decision + # lands in the audit log as create + reject. The grown re-ingest is a no-op + # (already-ingested), so the update-in-place path never fires. + assert "proposal.page.create" in events + assert "proposal.page.reject" in events + assert "proposal.page.update" not in events # --- hook wire (--hook): codex Stop event ------------------------------------ @@ -273,7 +293,10 @@ def test_cli_hook_mode_files_proposal(store: KBStore, monkeypatch) -> None: cli, ["capture", "ingest-codex", "--hook"], input=payload ) assert res.exit_code == 0, res.output - assert len(store.list_proposals(ProposalStatus.PENDING)) == 1 + # The hook files a proposal, but an uncited `session` page from the `codex` + # auto-capture actor is auto-rejected at admission — decided, not pending. + assert len(store.list_proposals(ProposalStatus.PENDING)) == 0 + assert len(store.list_proposals(ProposalStatus.REJECTED)) == 1 def test_cli_hook_mode_exits_zero_on_garbage(store: KBStore, monkeypatch) -> None: diff --git a/tests/test_session_split.py b/tests/test_session_split.py index 38e7a502..beda6495 100644 --- a/tests/test_session_split.py +++ b/tests/test_session_split.py @@ -79,9 +79,12 @@ def test_mechanical_single_page_below_threshold(store: KBStore) -> None: assert res["mode"] == "mechanical" assert len(res["summary_proposal_ids"]) == 1 assert res["summary_proposal_id"] == res["summary_proposal_ids"][0] - pending = store.list_proposals(ProposalStatus.PENDING) - assert len(pending) == 1 - assert pending[0].payload["type"] == "session" + # a bare mechanical session rollup is auto-rejected by the admission gate + prop = store.get_proposal(res["summary_proposal_ids"][0]) + assert prop.status is ProposalStatus.REJECTED + assert prop.decided_by == "vouch-admission" + assert prop.payload["type"] == "session" + assert store.list_proposals(ProposalStatus.PENDING) == [] def test_finalize_still_returns_summary_proposal_id(store: KBStore) -> None: @@ -122,11 +125,14 @@ def test_split_files_multiple_pending_session_pages(store: KBStore, tmp_path: Pa res = session_split.summarize(store, "s1", mode="auto") assert res["mode"] == "split" assert len(res["summary_proposal_ids"]) == 2 - pending = store.list_proposals(ProposalStatus.PENDING) - assert len(pending) == 2 - assert all(p.payload["type"] == "session" for p in pending) - assert all(p.proposed_by == session_split.SPLIT_ACTOR for p in pending) - assert store.list_pages() == [] # nothing durable — only proposed + # both split pages are uncited type:session from an auto-capture actor, so + # the admission gate auto-rejects them — nothing pending, nothing durable. + rejected = [store.get_proposal(pid) for pid in res["summary_proposal_ids"]] + assert all(p.status is ProposalStatus.REJECTED for p in rejected) + assert all(p.payload["type"] == "session" for p in rejected) + assert all(p.proposed_by == session_split.SPLIT_ACTOR for p in rejected) + assert store.list_proposals(ProposalStatus.PENDING) == [] + assert store.list_pages() == [] # nothing durable def test_split_forces_session_type_even_if_llm_says_concept(store: KBStore, tmp_path: Path) -> None: @@ -136,8 +142,12 @@ def test_split_forces_session_type_even_if_llm_says_concept(store: KBStore, tmp_ {"title": "a topic", "type": "concept", "body": "body " * 20}, ]) _config_with_split(store, cmd, threshold=3) - session_split.summarize(store, "s1", mode="auto") - assert store.list_proposals(ProposalStatus.PENDING)[0].payload["type"] == "session" + res = session_split.summarize(store, "s1", mode="auto") + # type is forced to session even though the llm said concept — which is why + # the admission gate then auto-rejects it as an uncited session page. + prop = store.get_proposal(res["summary_proposal_ids"][0]) + assert prop.payload["type"] == "session" + assert prop.status is ProposalStatus.REJECTED def test_no_llm_cmd_falls_back_to_mechanical(store: KBStore) -> None: @@ -244,18 +254,16 @@ def test_build_session_rows_lists_open_buffer(store: KBStore) -> None: assert row["last_activity"] is not None -def test_build_session_rows_mechanical_pending_needs_narration(store: KBStore) -> None: +def test_build_session_rows_omits_auto_rejected_mechanical_summary(store: KBStore) -> None: from vouch import capture _observe(store, "sess-filed", 5) capture.finalize(store, "sess-filed", cwd=None, generated_at="2026-07-09T00:00:00Z") + # the mechanical rollup is auto-rejected (uncited session page), so the + # session no longer surfaces as a pending row awaiting narration. rows = session_split.build_session_rows(store) - row = next(r for r in rows if r["session_id"] == "sess-filed") - assert row["stage"] == "pending" - # a mechanical rollup has not been LLM-narrated → still needs a summary - assert row["summarized"] is False - assert row["proposal_id"] is not None - assert row["kind"] == "page" - assert row["title"] + assert not any( + r["session_id"] == "sess-filed" and r["stage"] == "pending" for r in rows + ) def test_build_session_rows_split_proposal_is_summarized(store: KBStore, tmp_path: Path) -> None: @@ -267,13 +275,14 @@ def test_build_session_rows_split_proposal_is_summarized(store: KBStore, tmp_pat assert all(r["summarized"] for r in rows if r["session_id"] == "sess-split") -def test_finalized_session_not_double_listed_as_buffer(store: KBStore) -> None: +def test_finalized_session_not_listed_after_auto_reject(store: KBStore) -> None: from vouch import capture _observe(store, "sess-x", 5) capture.finalize(store, "sess-x", cwd=None, generated_at="2026-07-09T00:00:00Z") + # buffer consumed by finalize + mechanical summary auto-rejected → the + # session is neither a live buffer nor a pending proposal. rows = [r for r in session_split.build_session_rows(store) if r["session_id"] == "sess-x"] - assert len(rows) == 1 - assert rows[0]["stage"] == "pending" + assert rows == [] def test_summarize_returns_webapp_keys_on_split(store: KBStore, tmp_path: Path) -> None: @@ -303,14 +312,16 @@ def test_summarize_fallback_flags_llm_failed(store: KBStore) -> None: assert res["skipped"] == "llm-failed" -def test_renarrate_filed_mechanical_summary(store: KBStore, tmp_path: Path) -> None: +def test_renarrate_is_noop_when_mechanical_summary_auto_rejected( + store: KBStore, tmp_path: Path +) -> None: from vouch import capture from vouch.models import ProposalStatus _observe(store, "sess-m", 5) - capture.finalize(store, "sess-m", cwd=None, generated_at="2026-07-09T00:00:00Z") - mech = store.list_proposals(ProposalStatus.PENDING) - assert len(mech) == 1 - mech_id = mech[0].id + res0 = capture.finalize(store, "sess-m", cwd=None, generated_at="2026-07-09T00:00:00Z") + mech_id = res0["summary_proposal_id"] + # the mechanical rollup is auto-rejected at filing (uncited session page) + assert store.get_proposal(mech_id).status is ProposalStatus.REJECTED assert not capture.buffer_path(store, "sess-m").exists() # buffer gone cmd = _stub_llm(tmp_path, [ @@ -319,26 +330,23 @@ def test_renarrate_filed_mechanical_summary(store: KBStore, tmp_path: Path) -> N _config_with_split(store, cmd, threshold=3) res = session_split.summarize(store, "sess-m", mode="auto") - assert res["mode"] == "renarrated" - assert res["summarized"] is True - assert res["superseded"] == mech_id - # the mechanical proposal is superseded (rejected); the narrated page is pending - assert store.get_proposal(mech_id).status == ProposalStatus.REJECTED - pending = store.list_proposals(ProposalStatus.PENDING) - assert [p.id for p in pending] == res["summary_proposal_ids"] - assert all(p.proposed_by == session_split.SPLIT_ACTOR for p in pending) + # no pending mechanical summary survives, so renarrate has nothing to act on + assert res["summarized"] is False + assert res["skipped"] == "no-pending-summary-for-session" + assert store.list_proposals(ProposalStatus.PENDING) == [] -def test_renarrate_without_llm_leaves_mechanical_intact(store: KBStore) -> None: +def test_renarrate_without_llm_after_auto_reject(store: KBStore) -> None: from vouch import capture from vouch.models import ProposalStatus _observe(store, "sess-m", 5) - capture.finalize(store, "sess-m", cwd=None, generated_at="2026-07-09T00:00:00Z") - mech_id = store.list_proposals(ProposalStatus.PENDING)[0].id + res0 = capture.finalize(store, "sess-m", cwd=None, generated_at="2026-07-09T00:00:00Z") + mech_id = res0["summary_proposal_id"] + assert store.get_proposal(mech_id).status is ProposalStatus.REJECTED res = session_split.summarize(store, "sess-m", mode="auto") # no llm_cmd assert res["summarized"] is False - assert res["skipped"] == "not-configured" - assert store.get_proposal(mech_id).status == ProposalStatus.PENDING # untouched + # the mechanical summary was already auto-rejected; nothing left to narrate + assert store.get_proposal(mech_id).status is ProposalStatus.REJECTED def test_summarize_no_buffer_no_proposal_skips(store: KBStore) -> None: From 5e14dabc0c9151044cae70981985e79a773553d0 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:27:38 +0900 Subject: [PATCH 19/63] fix(admission): reject emphasis-wrapped heading labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the structural floor caught ##-headings but not the emoji/bold heading convention — "✨ **What's new**", "**Summary**" — because the heading rule only matches ##-prefixed lines and ** is deliberately left unchecked (it would strand **kwargs). a receipt-verified span like that was being laundered straight into approved knowledge by the auto-capture path. add a claim rung that rejects a span whose entire body, after stripping leading emoji/symbol decoration, is a single closed emphasis run (**...**, *...*, _..._). high precision by construction: a claim that merely contains inline emphasis keeps prose outside the run, and a lone opening **kwargs has no closing delimiter, so both still admit. --- src/vouch/admission.py | 25 +++++++++++++++++++++++++ tests/test_admission.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/vouch/admission.py b/src/vouch/admission.py index e1ac74cd..207d4992 100644 --- a/src/vouch/admission.py +++ b/src/vouch/admission.py @@ -61,6 +61,16 @@ # claims is worse than letting a rare single-'#' heading reach human review. _HEADING_RE = re.compile(r"^#{2,6}\s") _LEADING_MARKDOWN_RE = re.compile(r"^[>\-*+\s]+") +# A span whose entire body is one emphasis run — optionally prefixed by an emoji +# or other non-word decoration — is a section label, not a claim: "✨ **What's +# new**", "**Summary**", "📝 **Notes**". The '##' heading rule above misses this +# emoji/bold heading convention. Only a *fully* wrapped span is caught, so a +# claim that merely contains inline emphasis ("use **kwargs ...", "ships with +# **trusted** publishing") keeps prose outside the run and is admitted; and the +# run must be *closed*, so a lone opening ** (the **kwargs precision case) is +# left alone — the closing delimiter is what marks a label rather than a marker. +_LEADING_DECORATION_RE = re.compile(r"^[^\w*_]+") +_EMPHASIS_LABEL_RE = re.compile(r"(\*{1,3}|_{1,3})[^*_]+\1") DEFAULT_ENABLED = True DEFAULT_MIN_CONFIDENCE = 0.0 @@ -145,6 +155,19 @@ def _delimiters_balanced(s: str) -> bool: return not stack # leftover open — truncated tail +def _is_emphasis_label(text: str) -> bool: + """True if the whole span is a single closed emphasis run. + + Catches the emoji/bold heading convention — "✨ **What's new**" — that the + ``##`` rule in :func:`assess_claim` cannot see. Leading emoji/symbol + decoration is stripped first; the run must then be closed and span the + entire remainder, so inline emphasis inside real prose and a lone opening + ``**kwargs`` are both left untouched. + """ + candidate = _LEADING_DECORATION_RE.sub("", text).strip() + return bool(_EMPHASIS_LABEL_RE.fullmatch(candidate)) + + def assess_claim( text: str, *, confidence: float | None = None, min_confidence: float = 0.0 ) -> AdmissionVerdict: @@ -152,6 +175,8 @@ def assess_claim( stripped = text.strip() if _HEADING_RE.match(stripped): return _reject("markdown heading, not a claim") + if _is_emphasis_label(stripped): + return _reject("emphasis-wrapped label, not a claim") core = _LEADING_MARKDOWN_RE.sub("", stripped).strip() if not core: return _reject("empty after stripping markdown markers") diff --git a/tests/test_admission.py b/tests/test_admission.py index ac51b1fe..abc4f96c 100644 --- a/tests/test_admission.py +++ b/tests/test_admission.py @@ -20,6 +20,8 @@ "com/vouchdev/vouch/pull/517) is open", # stray close paren "- [adapters/claude-code/README.", # unbalanced bracket "The dorahack entry in `~/.", # unbalanced backtick + "✨ **What's new**", # emoji + bold heading label + "**Summary**", # bold-only label ] # claims the adversarial pass PROVED were being wrongly hard-rejected — the gate @@ -66,6 +68,41 @@ def test_assess_claim_admits_previously_false_rejected(text: str) -> None: assert admission.assess_claim(text).admit, f"regressed to a false reject: {text!r}" +def test_assess_claim_rejects_emphasis_heading_labels() -> None: + """Emoji/bold heading labels are not claims — the '##' rule cannot see them. + + Regression for "✨ **What's new**", a section header the receipt-verified + auto-approve path was laundering into approved knowledge. + """ + for label in ("✨ **What's new**", "**Summary**", "📝 **Notes**", "***TODO***"): + verdict = admission.assess_claim(label) + assert not verdict.admit, f"should reject label: {label!r}" + assert verdict.reason + + +def test_assess_claim_admits_inline_emphasis_prose() -> None: + """A claim that merely *contains* emphasis is prose, not a label — admit it. + + Precision guard so the emphasis-label rule never eats a real claim: a fully + wrapped span is a label, a wrapped word inside a sentence is not. + """ + assert admission.assess_claim( + "The release ships with **trusted** publishing enabled on pypi." + ).admit + assert admission.assess_claim( + "Use **kwargs to accept arbitrary keyword arguments in Python." + ).admit + + +def test_autocapture_emphasis_label_is_auto_rejected(store: KBStore) -> None: + src = store.put_source(b"here is a source body", title="t") + result = propose_claim( + store, text="✨ **What's new**", evidence=[src.id], + proposed_by="vouch-capture", # passive firehose actor + ) + assert result.proposal.status is ProposalStatus.REJECTED + + def test_resolve_pending_receipt_claim_skips_gate_rejected(store: KBStore) -> None: # a fragment the admission gate rejected must not crash capture's auto-approve # loop — resolve must return None on a decided proposal, never call approve(). From cda87e48245f427dc2e75768132bdd861157cebd Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:43:25 +0900 Subject: [PATCH 20/63] feat(worthiness): add tier-2 advisory claim-worthiness scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the admission gate answers "is this knowledge-shaped?" structurally at the funnel. this adds the softer "is it worth remembering?" as a separate, advisory tier that never runs in the funnel and never mutates a payload, so determinism and byte-offset receipts stay intact. worthiness.py exposes a pluggable Scorer protocol and a default HeuristicScorer — fully local, no llm. it blends cheap deterministic signals (question, agent-directive imperative, leading deixis, has-a-verb, entity presence, length band) with a novelty check against the approved kb: a claim that near-duplicates an existing one (fts lookup + jaccard overlap) carries little marginal worth. the scorer emits a score in [0,1] plus the dominant reason. config lives in a top-level worthiness: block (scorer/min_score/action/ apply_to), parsed fail-soft with a yaml-1.1 guard so `scorer: off` disables scoring rather than parsing as boolean false. the llm backend and the review-queue wiring are deliberate follow-ups; this lands the deterministic engine and its tests. --- src/vouch/worthiness.py | 380 +++++++++++++++++++++++++++++++++++++++ tests/test_worthiness.py | 104 +++++++++++ 2 files changed, 484 insertions(+) create mode 100644 src/vouch/worthiness.py create mode 100644 tests/test_worthiness.py diff --git a/src/vouch/worthiness.py b/src/vouch/worthiness.py new file mode 100644 index 00000000..d48fb5d5 --- /dev/null +++ b/src/vouch/worthiness.py @@ -0,0 +1,380 @@ +"""Tier 2 — semantic claim-worthiness scoring (advisory, never at the funnel). + +The Tier 1 admission gate (``admission.py``) answers *is this knowledge-shaped?* +— a cheap structural yes/no run at ``proposals._file_proposal``. This module +answers the softer *is it worth remembering?* and does it **advisorily**: it +emits a score in ``[0, 1]`` plus a one-line reason, and something downstream +(``vouch review``, compile) decides what to do with it. It NEVER runs inside the +proposal funnel and NEVER mutates a claim payload, so determinism and byte-offset +receipts are untouched. + +The default :class:`HeuristicScorer` is fully local and deterministic — no LLM, +no network. It blends a handful of cheap signals (question / imperative / leading +deixis / has-a-verb / entity presence / length band) with a **novelty** check +against the already-approved KB (an FTS lookup: a claim that near-duplicates an +approved one carries little marginal worth). An opt-in ``LlmScorer`` behind the +``worthiness.scorer: llm`` config is a later addition; the deterministic scorer +is the floor everything else is measured against. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol + +import yaml + +from . import index_db + +if TYPE_CHECKING: + from .storage import KBStore + +# The passive auto-capture actors — same firehoses Tier 1 gates. Worthiness is +# advisory for everyone, but only these are candidates for a configured +# ``defer`` / ``reject`` action; a deliberate author is always annotate-only. +AUTO_CAPTURE_ACTORS: frozenset[str] = frozenset({"vouch-capture", "session-split", "codex"}) + +DEFAULT_SCORER = "heuristic" +DEFAULT_MIN_SCORE = 0.4 +DEFAULT_ACTION = "annotate" + +# A near-duplicate is a claim whose content words overlap an approved claim's by +# at least this Jaccard ratio. High so only genuine restatements are flagged. +_DUP_THRESHOLD = 0.8 + +# Words that carry no topic — stripped before the novelty overlap so two claims +# aren't called duplicates just for sharing "the", "is", "a". +_STOPWORDS: frozenset[str] = frozenset( + [ + "a", + "an", + "the", + "of", + "to", + "in", + "on", + "at", + "for", + "and", + "or", + "but", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "it", + "its", + "this", + "that", + "these", + "those", + "he", + "she", + "they", + "them", + "we", + "you", + "i", + "as", + "by", + "with", + "from", + "into", + "over", + "than", + "then", + "so", + "if", + "not", + "no", + "yes", + "do", + "does", + "did", + "has", + "have", + "had", + "will", + "would", + "can", + "could", + "should", + "may", + "might", + "must", + ] +) + +# A leading directive verb marks a task issued to the agent ("create pr and +# generate announcement message"), not a durable fact. Kept small and high-signal. +_IMPERATIVE_VERBS: frozenset[str] = frozenset( + [ + "create", + "add", + "run", + "fix", + "make", + "update", + "implement", + "generate", + "write", + "build", + "remove", + "delete", + "refactor", + "open", + "close", + "merge", + "push", + "commit", + "install", + "rerun", + "regenerate", + "draft", + ] +) + +# A copula/auxiliary is a cheap proxy for "has a finite verb", i.e. states +# something rather than labelling it. +_COPULA_AUX: frozenset[str] = frozenset( + [ + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "am", + "has", + "have", + "had", + "do", + "does", + "did", + "can", + "could", + "will", + "would", + "should", + "may", + "might", + "must", + ] +) + +# A span that leads with a bare pronoun and no antecedent is not self-contained. +_DEICTIC_LEADS: frozenset[str] = frozenset( + ["it", "this", "that", "these", "those", "they", "he", "she", "there", "here"] +) + +_WORD_RE = re.compile(r"[A-Za-z][A-Za-z'-]*") +_LEADING_MARKDOWN_RE = re.compile(r"^[>\-*+#\s]+") + + +@dataclass(frozen=True) +class WorthinessConfig: + """The ``worthiness:`` block of config.yaml. + + ``scorer`` picks the backend (``heuristic`` | ``llm`` | ``off``). ``min_score`` + is the advisory threshold. ``action`` is what a surface MAY do to a + sub-threshold auto-capture claim (``annotate`` | ``defer`` | ``reject``); + ``annotate`` changes nothing on its own. + """ + + scorer: str = DEFAULT_SCORER + min_score: float = DEFAULT_MIN_SCORE + action: str = DEFAULT_ACTION + apply_to: frozenset[str] = AUTO_CAPTURE_ACTORS + + +def load_config(store: KBStore) -> WorthinessConfig: + """Read ``worthiness:`` from config.yaml; fall back to defaults on any error.""" + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return WorthinessConfig() + if not isinstance(loaded, dict): + return WorthinessConfig() + raw = loaded.get("worthiness") + if not isinstance(raw, dict): + return WorthinessConfig() + apply_raw = raw.get("apply_to") + apply_to = ( + frozenset(str(a) for a in apply_raw) if isinstance(apply_raw, list) else AUTO_CAPTURE_ACTORS + ) + return WorthinessConfig( + # YAML 1.1 parses a bare ``off`` as boolean False — coerce it back so + # ``scorer: off`` disables scoring rather than becoming the string "False". + scorer=_normalize_scorer(raw.get("scorer", DEFAULT_SCORER)), + min_score=_as_float(raw.get("min_score")) or DEFAULT_MIN_SCORE, + action=str(raw.get("action", DEFAULT_ACTION)), + apply_to=apply_to, + ) + + +def _normalize_scorer(value: object) -> str: + if value is False: + return "off" + return str(value).lower() + + +@dataclass(frozen=True) +class WorthinessResult: + """A score in ``[0, 1]``, the dominant reason, and the raw per-signal detail.""" + + score: float + reason: str + signals: dict[str, float] = field(default_factory=dict) + + +def _as_float(value: object) -> float | None: + try: + return float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +def _content_tokens(text: str) -> set[str]: + """Lowercase topic words, stopwords removed — the unit of the novelty overlap.""" + return {w for w in (m.group().lower() for m in _WORD_RE.finditer(text)) if w not in _STOPWORDS} + + +def _jaccard(a: set[str], b: set[str]) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _first_word(text: str) -> str: + core = _LEADING_MARKDOWN_RE.sub("", text).strip() + m = _WORD_RE.match(core) + return m.group().lower() if m else "" + + +def _has_verb(words: list[str]) -> bool: + """Crude finite-verb check: a copula/aux, or an inflected non-initial word. + + Deliberately over-inclusive (a plural noun like "claims" reads as a verb), + because a false *verb* only forgoes a small bonus, whereas a false *no-verb* + penalises a real claim. The penalty this feeds should fire only on genuine + noun-phrase labels ("session summary notes") with no inflection at all. + """ + low = [w.lower() for w in words] + if set(low) & _COPULA_AUX: + return True + for w in low[1:]: + if w in _STOPWORDS: + continue + if w.endswith(("ed", "ing")) or (w.endswith("s") and not w.endswith("ss") and len(w) > 3): + return True + return False + + +def _novelty(text: str, store: KBStore) -> tuple[float, float]: + """Return ``(novelty, best_overlap)`` against approved claims via FTS. + + ``novelty = 1 - best_overlap``. Fail-open: if the index is unavailable or the + claim has no content words, treat it as novel (``1.0``) — the novelty signal + should never punish a claim just because the index could not answer. + """ + cand = _content_tokens(text) + if not cand: + return 1.0, 0.0 + try: + hits = index_db.search(store.kb_dir, text, limit=5) + except Exception: + return 1.0, 0.0 + best = 0.0 + for kind, cid, snippet, _score in hits: + if kind != "claim": + continue + try: + other = store.get_claim(cid).text + except Exception: + other = snippet + best = max(best, _jaccard(cand, _content_tokens(other))) + return 1.0 - best, best + + +class Scorer(Protocol): + """A worthiness backend. Pluggable like index_db's fts5/embeddings backends.""" + + def score(self, text: str, *, store: KBStore) -> WorthinessResult: ... + + +class HeuristicScorer: + """Deterministic, local, LLM-free worthiness scoring. + + A blend of cheap signals around a neutral 0.5. Hard shapes (a question, an + agent-directive imperative) cap the score low; softer signals nudge it; the + novelty ratio scales the whole thing so a near-duplicate of an approved claim + lands below a fresh one. The dominant negative signal becomes the reason. + """ + + def score(self, text: str, *, store: KBStore) -> WorthinessResult: + stripped = text.strip() + words = [m.group() for m in _WORD_RE.finditer(stripped)] + n_words = len(words) + first = _first_word(stripped) + novelty, overlap = _novelty(stripped, store) + + signals: dict[str, float] = { + "novelty": round(novelty, 3), + "words": float(n_words), + } + + base = 0.5 + reason = "self-contained claim" + cap = 1.0 + + if stripped.endswith("?"): + cap = 0.15 + reason = "a question, not a claim" + elif first in _IMPERATIVE_VERBS: + cap = 0.2 + reason = "an imperative/task, not a durable fact" + + if _has_verb(words): + base += 0.15 + else: + base -= 0.1 + if reason == "self-contained claim": + reason = "no finite verb — reads as a label, not a proposition" + + if first in _DEICTIC_LEADS: + base -= 0.2 + if reason == "self-contained claim": + reason = "leads with an unresolved pronoun — not self-contained" + + # An entity — a capitalised word past the first token, or any digit — is a + # sign the claim is about something specific and durable. + if any(w[0].isupper() for w in words[1:]) or any(c.isdigit() for c in stripped): + base += 0.1 + + if n_words < 4: + base -= 0.15 + if reason == "self-contained claim": + reason = "too short to carry a proposition" + elif n_words > 60: + base -= 0.1 + + score = max(0.0, min(base, cap)) * novelty + if overlap >= _DUP_THRESHOLD: + reason = "near-duplicate of an existing approved claim" + score = max(0.0, min(1.0, score)) + signals["base"] = round(base, 3) + return WorthinessResult(score=score, reason=reason, signals=signals) + + +def get_scorer(cfg: WorthinessConfig) -> Scorer | None: + """The scorer backend for ``cfg``, or ``None`` when scoring is off.""" + if cfg.scorer == "heuristic": + return HeuristicScorer() + # ``llm`` backend lands in a follow-up; unknown values are treated as off so a + # typo degrades to no-op rather than crashing a review pass. + return None diff --git a/tests/test_worthiness.py b/tests/test_worthiness.py new file mode 100644 index 00000000..20eddcc2 --- /dev/null +++ b/tests/test_worthiness.py @@ -0,0 +1,104 @@ +"""Tier 2 worthiness scoring: advisory, deterministic, local by default. + +The scorer never runs at the funnel and never mutates a payload; these tests pin +the directional behaviour of the heuristic backend (a question scores below a +statement, an imperative below a fact, a near-duplicate below a fresh claim) plus +config parsing and backend selection. +""" + +from __future__ import annotations + +import pytest + +from vouch import health, worthiness +from vouch.models import Claim +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path, monkeypatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +def _score(store: KBStore, text: str) -> worthiness.WorthinessResult: + return worthiness.HeuristicScorer().score(text, store=store) + + +# ------------------------------------------------------------ directional signals +def test_good_claim_scores_above_threshold(store: KBStore) -> None: + r = _score(store, "Vouch stores every claim as a yaml file committed in the repo.") + assert r.score >= worthiness.DEFAULT_MIN_SCORE + + +def test_question_scores_below_a_statement(store: KBStore) -> None: + q = _score(store, "honestly, which review backend is better do you think?") + good = _score(store, "Vouch routes every write through a single review gate.") + assert q.score < worthiness.DEFAULT_MIN_SCORE < good.score + assert "question" in q.reason + + +def test_imperative_task_scores_low(store: KBStore) -> None: + r = _score(store, "create pr and generate the announcement message") + assert r.score < worthiness.DEFAULT_MIN_SCORE + assert "imperative" in r.reason + + +def test_leading_pronoun_scores_below_self_contained(store: KBStore) -> None: + deictic = _score(store, "it was wiped out during the reset last night") + grounded = _score(store, "the pending queue was wiped out during the reset last night") + assert deictic.score < grounded.score + + +def test_too_short_scores_low(store: KBStore) -> None: + assert _score(store, "session summary").score < worthiness.DEFAULT_MIN_SCORE + + +# ------------------------------------------------------------------------- novelty +def test_near_duplicate_scores_below_novel_claim(store: KBStore) -> None: + src = store.put_source(b"here is a source body", title="t") + approved = "Vouch routes every write through a single mandatory review gate." + store.put_claim(Claim(id="c1", text=approved, evidence=[src.id])) + health.rebuild_index(store) + + dup = _score(store, "Vouch routes every write through a single mandatory review gate.") + novel = _score(store, "The release workflow publishes wheels to pypi via trusted publishing.") + + assert dup.score < novel.score + assert "duplicate" in dup.reason + assert dup.signals["novelty"] < novel.signals["novelty"] + + +def test_novelty_fails_open_without_index(store: KBStore) -> None: + # no index built and no approved claims — a fresh claim must not be punished + r = _score(store, "The audit log is the only authoritative history in vouch.") + assert r.signals["novelty"] == 1.0 + + +# -------------------------------------------------------------------------- config +def _write_worthiness_config(store: KBStore, body: str) -> None: + store.config_path.write_text(f"worthiness:\n{body}", encoding="utf-8") + + +def test_load_config_defaults_when_absent(store: KBStore) -> None: + cfg = worthiness.load_config(store) + assert cfg.scorer == "heuristic" + assert cfg.action == "annotate" + assert cfg.min_score == worthiness.DEFAULT_MIN_SCORE + + +def test_load_config_parses_block(store: KBStore) -> None: + _write_worthiness_config(store, " scorer: off\n min_score: 0.6\n action: reject\n") + cfg = worthiness.load_config(store) + assert cfg.scorer == "off" + assert cfg.min_score == 0.6 + assert cfg.action == "reject" + + +def test_get_scorer_selects_backend(store: KBStore) -> None: + default = worthiness.get_scorer(worthiness.WorthinessConfig()) + assert isinstance(default, worthiness.HeuristicScorer) + assert worthiness.get_scorer(worthiness.WorthinessConfig(scorer="off")) is None + # llm backend not wired yet — degrades to no-op, never crashes a review pass + assert worthiness.get_scorer(worthiness.WorthinessConfig(scorer="llm")) is None From a0773ac71885adcc0676b2c79cdec41976a36b9a Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:45:02 +0900 Subject: [PATCH 21/63] feat(webapp): batch-approve pending proposals from the review queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the pending queue could approve only one proposal at a time; clearing a review backlog meant opening and approving each in turn. selection checkboxes already existed but drove only page merges. show a selection checkbox on every approvable row, add a "select all" toggle and an "approve N selected" action that approves the checked rows in one pass — client-side fan-out per project, the same shape as the existing clear (reject-all) path, since there is no bulk-approve endpoint. merge stays pages-only off the same selection set. --- webapp/src/views/PendingView.multi.test.tsx | 23 +++++ webapp/src/views/PendingView.test.tsx | 12 ++- webapp/src/views/PendingView.tsx | 96 +++++++++++++++++++-- 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/webapp/src/views/PendingView.multi.test.tsx b/webapp/src/views/PendingView.multi.test.tsx index ee79c0f5..bbb293bc 100644 --- a/webapp/src/views/PendingView.multi.test.tsx +++ b/webapp/src/views/PendingView.multi.test.tsx @@ -77,3 +77,26 @@ test('approve routes to the project the proposal belongs to', async () => { const approveCalls = vi.mocked(rpc).mock.calls.filter(([, m]) => m === 'kb.approve') expect(approveCalls).toHaveLength(1) }) + +test('batch approve: select all then approve fires kb.approve once per checked row', async () => { + renderWithProviders() + await screen.findByText('claim living in project a') + await userEvent.click(screen.getByRole('checkbox', { name: /select all pending/i })) + await userEvent.click(await screen.findByRole('button', { name: /approve 2 selected/i })) + + await waitFor(() => { + const approveCalls = vi.mocked(rpc).mock.calls.filter(([, m]) => m === 'kb.approve') + expect(approveCalls).toHaveLength(2) + }) + // each approval routed to the project that owns the proposal + const approved = vi + .mocked(rpc) + .mock.calls.filter(([, m]) => m === 'kb.approve') + .map(([conn, , params]) => [conn.endpoint, (params as { proposal_id: string }).proposal_id]) + expect(approved).toEqual( + expect.arrayContaining([ + [TEST_ENDPOINT, 'prop-from-a'], + [TEST_ENDPOINT_B, 'prop-from-b'], + ]), + ) +}) diff --git a/webapp/src/views/PendingView.test.tsx b/webapp/src/views/PendingView.test.tsx index 1a7e1df3..f52b4b35 100644 --- a/webapp/src/views/PendingView.test.tsx +++ b/webapp/src/views/PendingView.test.tsx @@ -151,10 +151,10 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m throw new Error(`unexpected ${method}`) }) renderWithProviders() - await userEvent.click(await screen.findByLabelText('select prop-page-a for merge')) + await userEvent.click(await screen.findByLabelText('select prop-page-a')) // one selection is not mergeable yet expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument() - await userEvent.click(screen.getByLabelText('select prop-page-b for merge')) + await userEvent.click(screen.getByLabelText('select prop-page-b')) await userEvent.click(screen.getByRole('button', { name: /merge 2 into one/i })) await waitFor(() => expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.merge_pending', { @@ -164,7 +164,7 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m expect(await screen.findByText(/merged 2 → prop-merged/i)).toBeInTheDocument() }) -test('merge checkboxes are absent when kb.merge_pending is not advertised', async () => { +test('selection checkbox drives batch-approve; merge button is absent without kb.merge_pending', async () => { const pageA = { ...PROPOSAL, id: 'prop-page-a', @@ -174,7 +174,11 @@ test('merge checkboxes are absent when kb.merge_pending is not advertised', asyn vi.mocked(rpc).mockResolvedValue([pageA]) renderWithProviders() await screen.findByText(/prop-page-a/) - expect(screen.queryByLabelText(/for merge/)).not.toBeInTheDocument() + // the row is approvable, so a selection checkbox is present for batch approve … + expect(screen.getByLabelText('select prop-page-a')).toBeInTheDocument() + // … but with kb.merge_pending unadvertised there is no merge action + await userEvent.click(screen.getByLabelText('select prop-page-a')) + expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument() }) test('approve removes the proposal from the queue before the server responds (optimistic)', async () => { diff --git a/webapp/src/views/PendingView.tsx b/webapp/src/views/PendingView.tsx index f72fdbd9..09a244d8 100644 --- a/webapp/src/views/PendingView.tsx +++ b/webapp/src/views/PendingView.tsx @@ -113,12 +113,21 @@ export function PendingView() { const clearTargets = rows.filter((r) => hasMethod('kb.reject', r.project.conn.endpoint)) const canClear = clearTargets.length > 0 // Intersect with the live queue: a proposal decided elsewhere mid-selection - // must not be sent to merge. Merge combines proposals inside ONE KB — a - // mixed-project selection cannot be merged. + // must not be acted on. Merge combines PAGES inside ONE KB; batch-approve can + // target any approvable row, across projects. const checkedRows = rows.filter((r) => checked.has(rowKey(r))) - const mergeProject = checkedRows[0]?.project ?? null - const mergeSameProject = checkedRows.every((r) => r.project === mergeProject) - const mergeIds = mergeSameProject ? checkedRows.map((r) => r.proposal.id) : [] + const mergeRows = checkedRows.filter((r) => r.proposal.kind === 'page') + const mergeProject = mergeRows[0]?.project ?? null + const mergeSameProject = mergeRows.every((r) => r.project === mergeProject) + const mergeIds = mergeSameProject ? mergeRows.map((r) => r.proposal.id) : [] + // Batch approve: every row whose endpoint advertises kb.approve is selectable; + // the action targets the checked subset. + const approvableRows = rows.filter((r) => hasMethod('kb.approve', r.project.conn.endpoint)) + const approveTargets = approvableRows.filter((r) => checked.has(rowKey(r))) + const allApprovableChecked = + approvableRows.length > 0 && approveTargets.length === approvableRows.length + const canCheck = (r: Row) => + hasMethod('kb.approve', r.project.conn.endpoint) || (canMerge && r.proposal.kind === 'page') function toggleChecked(key: string) { setChecked((prev) => { @@ -129,6 +138,19 @@ export function PendingView() { }) } + function toggleAllApprovable() { + setChecked((prev) => { + const next = new Set(prev) + const keys = approvableRows.map(rowKey) + const allOn = keys.length > 0 && keys.every((k) => next.has(k)) + for (const k of keys) { + if (allOn) next.delete(k) + else next.add(k) + } + return next + }) + } + function afterDecision() { setSelectedKey(null) setRejecting(false) @@ -229,6 +251,26 @@ export function PendingView() { }, }) + // Batch approve: approve every checked approvable row. No bulk endpoint exists — + // loop client-side per project, the same shape as clear (reject-all). + const approveSelected = useMutation({ + mutationFn: async () => { + const results = await Promise.allSettled( + approveTargets.map((r) => + rpc(r.project.conn, 'kb.approve', { proposal_id: r.proposal.id }), + ), + ) + const ok = results.filter((x) => x.status === 'fulfilled').length + return { ok, failed: results.length - ok } + }, + onError: decisionFailed, + onSuccess: ({ ok, failed }) => { + toast(failed ? 'info' : 'success', `Approved ${ok}${failed ? ` (${failed} failed)` : ''}`) + setChecked(new Set()) + afterDecision() + }, + }) + // Compile ingests ONE project's approved claims — it is offered when the // scope names a single project (use the scope switcher to pick one). const canCompile = !aggregated && !!conn && hasMethod('kb.compile') @@ -378,7 +420,43 @@ export function PendingView() { reject all {clearTargets.length} pending at once ))} - {canMerge && checkedRows.length >= 2 && ( + {approvableRows.length > 0 && ( +
+ + {approveTargets.length >= 1 && ( + <> + + + + )} +
+ )} + {canMerge && mergeRows.length >= 2 && (
{!mergeSameProject && ( pick pages from one project @@ -402,11 +480,11 @@ export function PendingView() {
    {rows.map((r) => (
  • - {canMerge && r.proposal.kind === 'page' && ( + {canCheck(r) && (
+ ) : null + // The compile bar renders on the empty queue too — that is the natural // starting state for an ingest pass over already-approved claims. const compileBar = canCompile ? ( @@ -362,6 +463,7 @@ export function PendingView() { return (
{compileBar} + {wipeBar}
{compileBar} + {wipeBar} {canClear && (clearing ? (
@@ -435,7 +538,7 @@ export function PendingView() { {approveTargets.length >= 1 && ( <>
)} + {deadRefRows.length > 0 && ( +
+ + {deadRefRows.length} proposal(s) cite claims that no longer exist + + + +
+ )} {canMerge && mergeRows.length >= 2 && (
+ +
+
+ ) : decisionError ? (
- )} + ) : null} {!canDecide ? (

@@ -607,7 +763,7 @@ export function PendingView() { ) : (

+ + ) + })} + +
+
+ ) +}