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 1/4] 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 2/4] 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 3/4] 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 4/4] 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