diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af930ac..32e37e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ All notable changes to vouch are documented here. Format follows the catalogue without restarting the server, and is surfaced on `kb.capabilities.mcp.publish_skills` so clients can detect the gate. An existing KB with no `mcp:` block stays default-on (#235). +- `lesson` claim type (`ClaimType.LESSON`) and `kb.mark_lesson_followed` — + an append-only observation of whether a surfaced lesson was actually + followed in a given turn. Never edits the claim; appends a + `lesson.followed` event to `audit.log.jsonl` with `followed`, optional + `context`, and `claim_type`. Registered across CLI (`mark-lesson-followed`), + MCP, JSONL, and `capabilities.METHODS`. Lessons resurface through the + normal retrieval path with no special-casing, and get the propose-time + repeat guard (#147) for free, since `propose_claim` already runs + `find_similar_on_propose` for every claim type. Not restricted to + `ClaimType.LESSON` — any claim id can carry a follow-through observation. + (#428) - `vouch console`: serve the vendored React web console straight from the installed package — a same-origin `/proxy` bridge (loopback-guarded) to `vouch serve --transport http` backends, reimplementing the vite dev-proxy @@ -98,14 +109,13 @@ All notable changes to vouch are documented here. Format follows and pending proposals, never updated as drafts were accepted within the batch. Approving the second proposal would silently route through `update_page()` and overwrite the first. (#439) - -### Fixed - claude-code: the `UserPromptSubmit` context hook computed retrieval but never fed the entity-salience reflex (#223) — `salience.record_query` was never called from the hook path, leaving the reflex permanently dormant for every claude-code session. OpenClaw's context engine already called it correctly; cursor's `beforeSubmitPrompt` hook cannot accept injected context at all, so it is not wired. (#425) + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/schemas/claim.schema.json b/schemas/claim.schema.json index 84cdd764..8c1f1975 100644 --- a/schemas/claim.schema.json +++ b/schemas/claim.schema.json @@ -56,7 +56,8 @@ "workflow", "observation", "question", - "warning" + "warning", + "lesson" ], "title": "ClaimType", "type": "string" diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 9d50f305..56d46a06 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -66,6 +66,7 @@ "kb.contradict", "kb.archive", "kb.confirm", + "kb.mark_lesson_followed", "kb.clear_claims", "kb.cite", "kb.source_verify", @@ -126,6 +127,7 @@ def capabilities(*, publish_skills: bool = True) -> Capabilities: retrieval = ["fts5", "substring"] try: from .embeddings import get_embedder + get_embedder() retrieval.append("embedding") retrieval.append("hybrid") diff --git a/src/vouch/cli.py b/src/vouch/cli.py index abe36889..875e38a0 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -398,7 +398,11 @@ def activity( agent=agent, ) body = stats_mod.collect_activity( - store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer, + store, + days=days, + tz_offset_minutes=tz_offset_minutes, + tz=tz, + viewer=viewer, ) if as_json: _emit_json(body) @@ -451,7 +455,10 @@ def digest_cmd(since: str, stale_days: int, limit: int, fmt: str) -> None: except metrics_mod.MetricsError as e: raise click.UsageError(str(e)) from e d = digest_mod.build( - store, since=since_dt, stale_after_days=stale_days, limit=limit, + store, + since=since_dt, + stale_after_days=stale_days, + limit=limit, ) if fmt == "json": _emit_json(d.to_dict()) @@ -861,15 +868,22 @@ def metrics( @cli.command(name="pages") @click.option("--kind", default=None, help="Filter by page kind (built-in or config-declared).") @click.option( - "--meta", "meta", multiple=True, metavar="K=V", + "--meta", + "meta", + multiple=True, + metavar="K=V", help="Frontmatter equality filter (repeatable).", ) @click.option( - "--before", multiple=True, metavar="K=V", + "--before", + multiple=True, + metavar="K=V", help="Inclusive upper bound on a frontmatter field (dates/numbers).", ) @click.option( - "--after", multiple=True, metavar="K=V", + "--after", + multiple=True, + metavar="K=V", help="Inclusive lower bound on a frontmatter field (dates/numbers).", ) @click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.") @@ -891,14 +905,21 @@ def pages_cmd( except ValueError as e: raise click.UsageError(str(e)) from e hits = filter_pages( - store.list_pages(), kind=kind, equals=equals, before=hi, after=lo, + store.list_pages(), + kind=kind, + equals=equals, + before=hi, + after=lo, ) if as_json: _emit_json( [ { - "id": p.id, "title": p.title, "type": p.type, - "tags": p.tags, "metadata": p.metadata, + "id": p.id, + "title": p.title, + "type": p.type, + "tags": p.tags, + "metadata": p.metadata, } for p in hits ] @@ -1131,11 +1152,14 @@ def list_relations() -> None: @cli.command() @click.argument("proposal_ids", nargs=-1) @click.option( - "--json", "as_json", is_flag=True, + "--json", + "as_json", + is_flag=True, help="Emit machine-readable _meta.vouch_triage blocks.", ) @click.option( - "--reverse", is_flag=True, + "--reverse", + is_flag=True, help="Ascending order (worst-first) instead of the default descending (best-first).", ) def triage(proposal_ids: tuple[str, ...], as_json: bool, reverse: bool) -> None: @@ -1251,7 +1275,10 @@ def reject_extracted(page_id: str | None, reason: str) -> None: store = _load_store() with _cli_errors(): rejected = reject_auto_extracted( - store, rejected_by=_whoami(), page_id=page_id, reason=reason, + store, + rejected_by=_whoami(), + page_id=page_id, + reason=reason, ) if not rejected: click.echo("no pending auto-extracted edges to reject") @@ -1605,7 +1632,9 @@ def new_cmd( metadata = _parse_meta(fields, flag="--field") metadata, missing, requires_citations = _stub_page_frontmatter( - registry, resolved_kind, metadata, + registry, + resolved_kind, + metadata, ) if interactive and missing: missing = _prompt_missing_fields(missing, metadata) @@ -1865,7 +1894,11 @@ def source_add(path: str, title: str | None, url: str | None, source_type: str) @click.option("--timeout", default=fetch_mod.DEFAULT_TIMEOUT, show_default=True, type=float) @click.option("--tag", "tags", multiple=True) def source_fetch( - url: str, title: str | None, max_bytes: int, timeout: float, tags: tuple[str, ...], + url: str, + title: str | None, + max_bytes: int, + timeout: float, + tags: tuple[str, ...], ) -> None: """Fetch URL and register the exact bytes as a content-addressed Source. @@ -1913,7 +1946,9 @@ def source_verify(fail_on_issue: bool) -> None: @cli.command(name="inbox") @click.option( - "--dir", "directory", required=True, + "--dir", + "directory", + required=True, type=click.Path(exists=True, file_okay=False), help="Folder to scan (must live under the project root).", ) @@ -1932,13 +1967,17 @@ def inbox_cmd(directory: str, watch_mode: bool, poll_interval: float, once: bool path = Path(directory) with _cli_errors(): if watch_mode and not once: + def _report(res: inbox_mod.ScanResult) -> None: if res.proposed: click.echo(f"filed {len(res.proposed)} proposal(s): {', '.join(res.proposed)}") with suppress(KeyboardInterrupt): inbox_mod.watch( - store, path, poll_interval=poll_interval, on_result=_report, + store, + path, + poll_interval=poll_interval, + on_result=_report, ) return res = inbox_mod.scan(store, path) @@ -2018,14 +2057,26 @@ def archive(claim_id: str) -> None: @cli.command(name="claims-clear") -@click.option("--auto-only", is_flag=True, default=True, show_default=True, - help="Clear only auto-approved claims (default: yes)") -@click.option("--before", type=str, default=None, - help="Clear only claims created before this date (ISO 8601, e.g. 2026-07-01)") -@click.option("--confirm", is_flag=True, default=False, - help="Skip confirmation prompt") -@click.option("--dry-run", is_flag=True, default=False, - help="Preview what would be cleared without making changes") +@click.option( + "--auto-only", + is_flag=True, + default=True, + show_default=True, + help="Clear only auto-approved claims (default: yes)", +) +@click.option( + "--before", + type=str, + default=None, + help="Clear only claims created before this date (ISO 8601, e.g. 2026-07-01)", +) +@click.option("--confirm", is_flag=True, default=False, help="Skip confirmation prompt") +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Preview what would be cleared without making changes", +) def claims_clear(auto_only: bool, before: str | None, confirm: bool, dry_run: bool) -> None: """Clear auto-saved claims. Archived claims are preserved in history.""" from datetime import datetime @@ -2090,6 +2141,31 @@ def confirm(claim_id: str) -> None: click.echo(f"confirmed {claim_id}") +@cli.command(name="mark-lesson-followed") +@click.argument("claim_id") +@click.option( + "--followed/--not-followed", + required=True, + help="Whether the lesson was actually applied this turn.", +) +@click.option("--context", default=None, help="Optional free-text context for the observation.") +def mark_lesson_followed(claim_id: str, followed: bool, context: str | None) -> None: + """Record a followed/not-followed observation for a lesson (#428). + + Append-only: appends a lesson.followed audit event, edits nothing else. + """ + store = _load_store() + with _cli_errors(): + life.mark_lesson_followed( + store, + claim_id=claim_id, + followed=followed, + actor=_whoami(), + context=context, + ) + click.echo(f"recorded: {claim_id} followed={followed}") + + @cli.command() @click.argument("claim_id") def cite(claim_id: str) -> None: @@ -2144,9 +2220,7 @@ def session_volunteer_cmd(session_id: str, no_clear: bool, as_json: bool) -> Non click.echo("(no volunteered context)") return for offer in offers: - click.echo( - f"{offer.claim_id} relevance={offer.relevance:.2f} {offer.why}" - ) + click.echo(f"{offer.claim_id} relevance={offer.relevance:.2f} {offer.why}") @session.command("end") @@ -2198,9 +2272,12 @@ def capture_observe_cmd() -> None: if store is None: return capture_mod.observe( - store, session_id, - tool=obs["tool"], summary=obs["summary"], - files=obs.get("files"), cmd=obs.get("cmd"), + store, + session_id, + tool=obs["tool"], + summary=obs["summary"], + files=obs.get("files"), + cmd=obs.get("cmd"), ) except Exception: # a capture failure must never break the user's tool call. @@ -2231,7 +2308,10 @@ def capture_finalize_cmd(session_id: str | None) -> None: 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, + store, + sid, + cwd=cwd, + project=cwd.name, generated_at=datetime.now(UTC).isoformat(), transcript_path=transcript, ) @@ -2256,27 +2336,35 @@ def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> return result = capture_mod.finalize_all_except( - store, sid, max_age_seconds=max_age_seconds, + store, + sid, + max_age_seconds=max_age_seconds, ) _emit_json(result) @capture.command("ingest-codex") @click.argument( - "rollout", required=False, + "rollout", + required=False, type=click.Path(exists=True, dir_okay=False, path_type=Path), ) @click.option( - "--latest", is_flag=True, + "--latest", + is_flag=True, help="Resolve the newest codex rollout recorded for this project (by cwd).", ) @click.option( - "--hook", "hook_mode", is_flag=True, + "--hook", + "hook_mode", + is_flag=True, help="Read a codex Stop-hook payload from stdin; never fails the host " - "(exits 0 even on errors, like `capture observe`).", + "(exits 0 even on errors, like `capture observe`).", ) @click.option( - "--codex-home", type=click.Path(file_okay=False, path_type=Path), default=None, + "--codex-home", + type=click.Path(file_okay=False, path_type=Path), + default=None, help="Codex state dir holding sessions/ (default: $CODEX_HOME or ~/.codex).", ) def capture_ingest_codex_cmd( @@ -2313,9 +2401,7 @@ def capture_ingest_codex_cmd( store = _load_store() with _cli_errors(): if latest: - found = codex_rollout_mod.find_latest_rollout( - Path.cwd(), codex_home=codex_home - ) + found = codex_rollout_mod.find_latest_rollout(Path.cwd(), codex_home=codex_home) if found is None: raise click.ClickException( "no codex rollout found for this project under " @@ -2339,8 +2425,7 @@ def capture_banner_cmd() -> None: n = capture_mod.pending_count(store) if n: click.echo( - f"🔔 {n} auto-captured session summary(ies) awaiting review — " - f"run `vouch review`." + f"🔔 {n} auto-captured session summary(ies) awaiting review — run `vouch review`." ) @@ -2360,13 +2445,14 @@ def recall_cmd() -> None: @cli.command(name="compile") @click.option("--dry-run", is_flag=True, help="Draft and validate; file nothing.") -@click.option("--max-pages", type=int, default=None, - help="Cap drafted pages (default: compile.max_pages, 5).") -@click.option("--llm-cmd", default=None, - help="Override compile.llm_cmd from config.yaml for this run.") +@click.option( + "--max-pages", type=int, default=None, help="Cap drafted pages (default: compile.max_pages, 5)." +) +@click.option( + "--llm-cmd", default=None, help="Override compile.llm_cmd from config.yaml for this run." +) @click.option("--json", "as_json", is_flag=True, help="Machine-readable report.") -def compile_cmd(dry_run: bool, max_pages: int | None, - llm_cmd: str | None, as_json: bool) -> None: +def compile_cmd(dry_run: bool, max_pages: int | None, llm_cmd: str | None, as_json: bool) -> None: """Compile approved claims into topic-page proposals (llm-wiki ingest). Runs the deployment-configured LLM (compile.llm_cmd) over the live @@ -2378,8 +2464,12 @@ def compile_cmd(dry_run: bool, max_pages: int | None, actor = os.environ.get("VOUCH_AGENT") or compile_mod.COMPILE_ACTOR try: report = compile_mod.compile_kb( - store, actor=actor, triggered_by=_whoami(), llm_cmd=llm_cmd, - max_pages=max_pages, dry_run=dry_run, + store, + actor=actor, + triggered_by=_whoami(), + llm_cmd=llm_cmd, + max_pages=max_pages, + dry_run=dry_run, ) except compile_mod.CompileError as e: raise click.ClickException(str(e)) from e @@ -2532,15 +2622,16 @@ def search( click.echo("warning: rerank extras not installed; skipping rerank", err=True) if as_json: - _emit_json({ - "backend": used, - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "hits": [ - {"kind": k, "id": i, "snippet": snip, "score": score, - "backend": used} - for k, i, snip, score in hits - ], - }) + _emit_json( + { + "backend": used, + "viewer": {"project": viewer.project, "agent": viewer.agent}, + "hits": [ + {"kind": k, "id": i, "snippet": snip, "score": score, "backend": used} + for k, i, snip, score in hits + ], + } + ) return for k, i, snip, score in hits: @@ -2553,18 +2644,20 @@ def search( @cli.command() @click.argument("node_id") @click.option("--depth", default=1, show_default=True, type=int) -@click.option("--rel-type", "rel_types", multiple=True, - help="Filter to relation types (repeatable).") +@click.option( + "--rel-type", "rel_types", multiple=True, help="Filter to relation types (repeatable)." +) @click.option("--max-nodes", default=50, show_default=True, type=int) -def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...], - max_nodes: int) -> None: +def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...], max_nodes: int) -> None: """List graph neighbors of a claim, page, entity, or source.""" from .graph import find_neighbors store = _load_store() with _cli_errors(): result = find_neighbors( - store, node_id, depth=depth, + store, + node_id, + depth=depth, rel_types=list(rel_types) or None, max_nodes=max_nodes, ) @@ -2579,8 +2672,7 @@ def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...], @click.option("--min-items", default=0, type=int) @click.option("--project", default=None, help="Viewer project for scope filtering.") @click.option("--agent", default=None, help="Viewer agent for scope filtering.") -@click.option("--expand-graph", is_flag=True, - help="Include 1-hop graph neighbors of search hits.") +@click.option("--expand-graph", is_flag=True, help="Include 1-hop graph neighbors of search hits.") @click.option("--graph-depth", default=1, show_default=True, type=int) @click.option("--graph-limit", default=20, show_default=True, type=int) def context( @@ -2856,13 +2948,17 @@ def eval_embedding(queries: str, metric: str) -> None: @eval_group.command("recall") @click.argument("queries", type=click.Path(exists=True, dir_okay=False)) @click.option("--k", default=5, show_default=True, type=int) -@click.option("--baseline", default=None, type=click.Path(exists=True, dir_okay=False), - help="Baseline report JSON; fail on a P@k regression beyond tolerance.") +@click.option( + "--baseline", + default=None, + type=click.Path(exists=True, dir_okay=False), + help="Baseline report JSON; fail on a P@k regression beyond tolerance.", +) @click.option("--max-regression", default=0.05, show_default=True, type=float) -def eval_recall(queries: str, k: int, baseline: str | None, - max_regression: float) -> None: +def eval_recall(queries: str, k: int, baseline: str | None, max_regression: float) -> None: """Score kb.context retrieval against a labeled query set (P@k/R@k/MRR/nDCG).""" from .eval.recall import compare_baseline, run_recall + store = _load_store() with _cli_errors(): report = run_recall(store, queries, k=k) @@ -2922,10 +3018,12 @@ def audit(tail: int, as_json: bool, project: str | None, agent: str | None) -> N ) events = list(audit_mod.read_events(store.kb_dir, store=store, viewer=viewer))[-tail:] if as_json: - _emit_json({ - "viewer": {"project": viewer.project, "agent": viewer.agent}, - "events": [e.model_dump(mode="json") for e in events], - }) + _emit_json( + { + "viewer": {"project": viewer.project, "agent": viewer.agent}, + "events": [e.model_dump(mode="json") for e in events], + } + ) return if viewer.project or viewer.agent: click.echo( @@ -2967,20 +3065,22 @@ def detect_themes_cmd( top_k=top_k, ) if as_json and not propose: - _emit_json({ - "clusters": [ - { - "entities": c.entities, - "claim_ids": c.claim_ids, - "session_ids": c.session_ids, - "score": c.score, - "session_count": c.session_count, - "claim_count": c.claim_count, - } - for c in result.clusters - ], - "config": result.config_used, - }) + _emit_json( + { + "clusters": [ + { + "entities": c.entities, + "claim_ids": c.claim_ids, + "session_ids": c.session_ids, + "score": c.score, + "session_count": c.session_count, + "claim_count": c.claim_count, + } + for c in result.clusters + ], + "config": result.config_used, + } + ) return if not result.clusters: click.echo("no themes detected") @@ -3095,32 +3195,65 @@ def import_apply_cmd(bundle_path: str, on_conflict: str) -> None: @cli.command(name="auto-pr") @click.argument("repo_url") -@click.option("--workspace", required=True, type=click.Path(), - help="directory holding (or to hold) the clone/fork.") -@click.option("--count", default=1, show_default=True, type=int, - help="how many PRs to attempt.") -@click.option("--claude-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--codex-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--issue-label", "issue_labels", multiple=True, - help="restrict the open-issue source to these labels (repeatable).") -@click.option("--fork-owner", default=None, - help="fork owner login (default: the authenticated gh user).") -@click.option("--max-revise", default=2, show_default=True, type=int, - help="max fixer<->verifier revise rounds per item.") -@click.option("--autonomy", default="edit", show_default=True, - type=click.Choice(["edit", "full"]), - help="'edit' auto-accepts file edits only (safer default); " - "'full' lets the fixer run arbitrary commands " - "(bypasses claude's permission prompts).") -@click.option("--dry-run", is_flag=True, - help="run every stage except git push / gh pr create.") +@click.option( + "--workspace", + required=True, + type=click.Path(), + help="directory holding (or to hold) the clone/fork.", +) +@click.option("--count", default=1, show_default=True, type=int, help="how many PRs to attempt.") +@click.option( + "--claude-effort", + default="high", + show_default=True, + type=click.Choice(["low", "medium", "high", "max"]), +) +@click.option( + "--codex-effort", + default="high", + show_default=True, + type=click.Choice(["low", "medium", "high", "max"]), +) +@click.option( + "--issue-label", + "issue_labels", + multiple=True, + help="restrict the open-issue source to these labels (repeatable).", +) +@click.option( + "--fork-owner", default=None, help="fork owner login (default: the authenticated gh user)." +) +@click.option( + "--max-revise", + default=2, + show_default=True, + type=int, + help="max fixer<->verifier revise rounds per item.", +) +@click.option( + "--autonomy", + default="edit", + show_default=True, + type=click.Choice(["edit", "full"]), + help="'edit' auto-accepts file edits only (safer default); " + "'full' lets the fixer run arbitrary commands " + "(bypasses claude's permission prompts).", +) +@click.option("--dry-run", is_flag=True, help="run every stage except git push / gh pr create.") @click.option("--json", "as_json", is_flag=True) -def auto_pr_cmd(repo_url: str, workspace: str, count: int, claude_effort: str, - codex_effort: str, issue_labels: tuple[str, ...], - fork_owner: str | None, max_revise: int, autonomy: str, - dry_run: bool, as_json: bool) -> None: +def auto_pr_cmd( + repo_url: str, + workspace: str, + count: int, + claude_effort: str, + codex_effort: str, + issue_labels: tuple[str, ...], + fork_owner: str | None, + max_revise: int, + autonomy: str, + dry_run: bool, + as_json: bool, +) -> None: """Open N mergeable PRs against REPO_URL, cross-verified by claude + codex. Sources open issues first (then agent-discovered improvements), bootstraps @@ -3129,11 +3262,19 @@ def auto_pr_cmd(repo_url: str, workspace: str, count: int, claude_effort: str, reviewing engine signs off. A sibling tool — it never writes to the KB. """ from . import auto_pr as ap_mod + try: results = ap_mod.run_auto_pr( - repo_url, workspace, count, claude_effort, codex_effort, - labels=tuple(issue_labels), fork_owner=fork_owner, - max_revise=max_revise, autonomy=autonomy, dry_run=dry_run, + repo_url, + workspace, + count, + claude_effort, + codex_effort, + labels=tuple(issue_labels), + fork_owner=fork_owner, + max_revise=max_revise, + autonomy=autonomy, + dry_run=dry_run, ) except (ValueError, RuntimeError) as e: # surface auto-pr failures as `Error: ...` like the rest of the cli, @@ -3141,12 +3282,20 @@ def auto_pr_cmd(repo_url: str, workspace: str, count: int, claude_effort: str, # KB domain types that _cli_errors handles.) raise click.ClickException(str(e)) from e if as_json: - _emit_json([ - {"status": r.status, "url": r.url, "fixer": r.fixer, - "verifier": r.verifier, "title": r.item.title, - "reason": r.reason, "rounds": r.rounds} - for r in results - ]) + _emit_json( + [ + { + "status": r.status, + "url": r.url, + "fixer": r.fixer, + "verifier": r.verifier, + "title": r.item.title, + "reason": r.reason, + "rounds": r.rounds, + } + for r in results + ] + ) return if not results: click.echo(f"no work items found for {repo_url}", err=True) @@ -3166,30 +3315,61 @@ def auto_pr_cmd(repo_url: str, workspace: str, count: int, claude_effort: str, @cli.command(name="dual-solve") @click.argument("issue_url") -@click.option("--claude-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--codex-effort", default="high", show_default=True, - type=click.Choice(["low", "medium", "high", "max"])) -@click.option("--autonomy", default="edit", show_default=True, - type=click.Choice(["edit", "full"]), - help="'edit' auto-accepts file edits only (safer default); " - "'full' lets engines run arbitrary commands.") -@click.option("--reason", default=None, - help="why you picked the winner (skips the interactive prompt).") -@click.option("--no-record", is_flag=True, - help="keep the chosen branch but propose nothing to the kb.") -@click.option("--dry-run", is_flag=True, - help="run both engines but make no commits / kb writes.") -@click.option("--sandbox", is_flag=True, - help="Run claude/codex inside a Docker sandbox image instead of on the host.") -@click.option("--sandbox-image", default=None, - help="Docker image for --sandbox (default: vouch/coder:latest).") -@click.option("--json", "as_json", is_flag=True, - help="non-interactive: emit both diffs + metadata, no prompt.") -def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, - autonomy: str, reason: str | None, no_record: bool, - dry_run: bool, sandbox: bool, sandbox_image: str | None, - as_json: bool) -> None: +@click.option( + "--claude-effort", + default="high", + show_default=True, + type=click.Choice(["low", "medium", "high", "max"]), +) +@click.option( + "--codex-effort", + default="high", + show_default=True, + type=click.Choice(["low", "medium", "high", "max"]), +) +@click.option( + "--autonomy", + default="edit", + show_default=True, + type=click.Choice(["edit", "full"]), + help="'edit' auto-accepts file edits only (safer default); " + "'full' lets engines run arbitrary commands.", +) +@click.option( + "--reason", default=None, help="why you picked the winner (skips the interactive prompt)." +) +@click.option( + "--no-record", is_flag=True, help="keep the chosen branch but propose nothing to the kb." +) +@click.option("--dry-run", is_flag=True, help="run both engines but make no commits / kb writes.") +@click.option( + "--sandbox", + is_flag=True, + help="Run claude/codex inside a Docker sandbox image instead of on the host.", +) +@click.option( + "--sandbox-image", + default=None, + help="Docker image for --sandbox (default: vouch/coder:latest).", +) +@click.option( + "--json", + "as_json", + is_flag=True, + help="non-interactive: emit both diffs + metadata, no prompt.", +) +def dual_solve_cmd( + issue_url: str, + claude_effort: str, + codex_effort: str, + autonomy: str, + reason: str | None, + no_record: bool, + dry_run: bool, + sandbox: bool, + sandbox_image: str | None, + as_json: bool, +) -> None: """Run claude + codex on ISSUE_URL; you pick the winning diff. Each engine works in its own git worktree on a fresh branch. You compare @@ -3200,40 +3380,54 @@ def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, from . import dual_solve as ds_mod from .auto_pr import SubprocessRunner from .sandbox import DEFAULT_SANDBOX_IMAGE, DockerAgentRunner + store = _load_store() base_runner = SubprocessRunner() sandbox_image = sandbox_image or DEFAULT_SANDBOX_IMAGE try: if sandbox: - ds_mod._require_engines( - sandboxed=True, sandbox_image=sandbox_image, runner=base_runner) + ds_mod._require_engines(sandboxed=True, sandbox_image=sandbox_image, runner=base_runner) else: ds_mod._require_engines() root = ds_mod.repo_root(base_runner, Path.cwd()) runner = ( DockerAgentRunner(repo_root=root, runner=base_runner, image=sandbox_image) - if sandbox else base_runner + if sandbox + else base_runner ) issue, candidates, engines = ds_mod.prepare( - store, issue_url, root, runner, - claude_effort=claude_effort, codex_effort=codex_effort, - autonomy=autonomy, dry_run=dry_run, + store, + issue_url, + root, + runner, + claude_effort=claude_effort, + codex_effort=codex_effort, + autonomy=autonomy, + dry_run=dry_run, on_progress=lambda m: click.echo(m, err=True), ) except (ValueError, RuntimeError) as e: raise click.ClickException(str(e)) from e if as_json: - _emit_json({ - "issue": {"number": issue.number, "title": issue.title, - "url": issue.url}, - "recommendation": ds_mod.recommendation(candidates), - "candidates": [ - {"engine": c.engine, "branch": c.branch, "ok": c.ok, - "error": c.error, "changed_files": ds_mod.changed_files(c.diff), - "log": c.log, "diff": c.diff} for c in candidates - ], - }) + _emit_json( + { + "issue": {"number": issue.number, "title": issue.title, "url": issue.url}, + "recommendation": ds_mod.recommendation(candidates), + "candidates": [ + { + "engine": c.engine, + "branch": c.branch, + "ok": c.ok, + "error": c.error, + "changed_files": ds_mod.changed_files(c.diff), + "log": c.log, + "diff": c.diff, + } + for c in candidates + ], + } + ) return for c in candidates: @@ -3250,8 +3444,11 @@ def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, rec = ds_mod.recommendation(candidates) if rec.get("reason"): - label = f"recommendation: {rec['engine']}" if rec.get("engine") \ + label = ( + f"recommendation: {rec['engine']}" + if rec.get("engine") else "recommendation: no automatic pick" + ) click.echo(f"{label} -- {rec['reason']}", err=True) ok = [c for c in candidates if c.ok] @@ -3261,15 +3458,28 @@ def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, if len(ok) == 1: survivor = ok[0] if not click.confirm( - f"only {survivor.engine} produced a usable diff; proceed with it?", - default=True): - ds_mod.finalize(store, root, issue, None, engines, candidates, "", - runner, record=False, proposed_by=_whoami()) + f"only {survivor.engine} produced a usable diff; proceed with it?", default=True + ): + ds_mod.finalize( + store, + root, + issue, + None, + engines, + candidates, + "", + runner, + record=False, + proposed_by=_whoami(), + ) raise click.ClickException("aborted; both branches discarded") choice = survivor.engine else: - letter = click.prompt("pick a winner [c]laude / [x]codex / [n]either", - type=click.Choice(["c", "x", "n"]), default="c") + letter = click.prompt( + "pick a winner [c]laude / [x]codex / [n]either", + type=click.Choice(["c", "x", "n"]), + default="c", + ) choice = {"c": "claude", "x": "codex", "n": None}[letter] chosen = next((c for c in candidates if c.engine == choice), None) @@ -3278,8 +3488,16 @@ def dual_solve_cmd(issue_url: str, claude_effort: str, codex_effort: str, try: ids = ds_mod.finalize( - store, root, issue, chosen, engines, candidates, reason or "", runner, - record=not no_record and not dry_run, proposed_by=_whoami(), + store, + root, + issue, + chosen, + engines, + candidates, + reason or "", + runner, + record=not no_record and not dry_run, + proposed_by=_whoami(), ) except (ValueError, RuntimeError) as e: raise click.ClickException(f"failed to record/clean up: {e}") from e @@ -4009,7 +4227,7 @@ def console(bind: str, allow_remote: bool, open_browser: bool) -> None: "--allow-dual-solve", is_flag=True, help="Mount the dual-solve runner SPA (spawns claude+codex; edit-only). " - "Off by default; the server must run inside the target git repo.", + "Off by default; the server must run inside the target git repo.", ) @click.option( "--dual-solve-sandbox", @@ -4070,7 +4288,10 @@ def review_ui( try: app = web_pkg.create_app( - kb_root, auth_token=token, auth_label=reviewer, page_size=page_size, + kb_root, + auth_token=token, + auth_label=reviewer, + page_size=page_size, allow_dual_solve=allow_dual_solve, dual_solve_sandbox=dual_solve_sandbox, dual_solve_sandbox_image=dual_solve_sandbox_image, diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py index b6eae608..59602258 100644 --- a/src/vouch/hot_memory.py +++ b/src/vouch/hot_memory.py @@ -119,20 +119,22 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non } # kb.* methods that attach ``_meta.vouch_hot_memory`` on read responses. -HOT_MEMORY_COVERED: frozenset[str] = frozenset({ - "kb.search", - "kb.context", - "kb.read_page", - "kb.read_claim", - "kb.read_entity", - "kb.read_relation", - "kb.list_pages", - "kb.list_claims", - "kb.list_entities", - "kb.list_relations", - "kb.list_sources", - "kb.list_pending", -}) +HOT_MEMORY_COVERED: frozenset[str] = frozenset( + { + "kb.search", + "kb.context", + "kb.read_page", + "kb.read_claim", + "kb.read_entity", + "kb.read_relation", + "kb.list_pages", + "kb.list_claims", + "kb.list_entities", + "kb.list_relations", + "kb.list_sources", + "kb.list_pending", + } +) # Explicit exclusions for ``test_hot_memory_universal_coverage``. HOT_MEMORY_EXCLUDED: dict[str, str] = { @@ -172,6 +174,10 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non "kb.contradict": "lifecycle — mutates durable state", "kb.archive": "lifecycle — mutates durable state", "kb.confirm": "lifecycle — mutates durable state", + "kb.mark_lesson_followed": ( + "append-only audit observation — never touches the claim itself, " + "no browse payload to decorate" + ), "kb.cite": "lifecycle — mutates durable state", "kb.source_verify": "write path — verification intake", "kb.session_start": "session control — not a KB read", @@ -286,9 +292,7 @@ def _matches_query(query_norm: str, text_lower: str) -> bool: return False if query_norm in text_lower: return True - return any( - len(token) >= 3 and token in text_lower for token in query_norm.split() - ) + return any(len(token) >= 3 and token in text_lower for token in query_norm.split()) def _compute_sidebar( @@ -324,16 +328,18 @@ def _compute_sidebar( out: list[dict[str, Any]] = [] for _score, ts_dt, c, why in candidates[:limit]: - out.append({ - "id": c.id, - "text": _preview(c.text), - "type": c.type.value, - "status": c.status.value, - "citations": list(c.evidence), - "approved_by": c.approved_by, - "approved_at": ts_dt.isoformat(timespec="seconds"), - "why_hot": why, - }) + out.append( + { + "id": c.id, + "text": _preview(c.text), + "type": c.type.value, + "status": c.status.value, + "citations": list(c.evidence), + "approved_by": c.approved_by, + "approved_at": ts_dt.isoformat(timespec="seconds"), + "why_hot": why, + } + ) return out @@ -353,7 +359,10 @@ def attach_hot_memory( deprecation note for JSONL clients that expected a flat list. """ sidebar = compute_hot_memory( - store, query=query, limit=limit, exclude_ids=exclude_ids, + store, + query=query, + limit=limit, + exclude_ids=exclude_ids, ) if isinstance(result, list) and list_envelope: diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 6e2fc6ab..1e335190 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -153,13 +153,15 @@ def _h_search(p: dict) -> dict: valid_backends = {"auto", "embedding", "fts5", "substring", "hybrid"} if backend_arg not in valid_backends: raise ValueError( - f"unknown backend: {backend_arg!r} " - f"(expected one of {sorted(valid_backends)})" + f"unknown backend: {backend_arg!r} (expected one of {sorted(valid_backends)})" ) if backend_arg in ("auto", "embedding"): hits = index_db.search_semantic( - s.kb_dir, q, limit=fetch_limit, min_score=min_score, + s.kb_dir, + q, + limit=fetch_limit, + min_score=min_score, ) if hits: used = "embedding" @@ -176,10 +178,14 @@ def _h_search(p: dict) -> dict: from .embeddings.fusion import ( # type: ignore[import-not-found,import-untyped,unused-ignore] rrf_fuse, ) + # Hybrid must honour min_score and survive FTS failures the same # way the dedicated fts5 branch does. emb = index_db.search_semantic( - s.kb_dir, q, limit=fetch_limit * 2, min_score=min_score, + s.kb_dir, + q, + limit=fetch_limit * 2, + min_score=min_score, ) try: fts = index_db.search(s.kb_dir, q, limit=fetch_limit * 2) @@ -455,7 +461,8 @@ def _h_propose_claim(p: dict) -> dict: def _h_propose_page(p: dict) -> dict: pr = propose_page( _store(), - title=p["title"], body=p.get("body", ""), + title=p["title"], + body=p.get("body", ""), page_type=p.get("page_type", "concept"), claim_ids=p.get("claim_ids"), entity_ids=p.get("entity_ids"), @@ -473,7 +480,8 @@ def _h_propose_page(p: dict) -> dict: def _h_compile(p: dict) -> dict: try: report = compile_mod.compile_kb( - _store(), triggered_by=_agent(), + _store(), + triggered_by=_agent(), max_pages=p.get("max_pages"), dry_run=bool(p.get("dry_run", False)), session_id=p.get("session_id"), @@ -488,18 +496,23 @@ def _h_compile(p: dict) -> dict: def _h_summarize_session(p: dict) -> dict: from . import session_split + return session_split.summarize( - _store(), p["session_id"], mode=p.get("mode", "auto"), + _store(), + p["session_id"], + mode=p.get("mode", "auto"), ) def _h_list_sessions(p: dict) -> dict: from . import session_split + return {"sessions": session_split.build_session_rows(_store())} def _h_session_transcript(p: dict) -> dict: from . import transcript + session_id = p["session_id"] agent = p.get("agent") if agent is not None and agent not in ("claude", "codex"): @@ -510,9 +523,12 @@ def _h_session_transcript(p: dict) -> dict: def _h_propose_entity(p: dict) -> dict: pr = propose_entity( _store(), - name=p["name"], entity_type=p["entity_type"], - aliases=p.get("aliases"), description=p.get("description"), - rationale=p.get("rationale"), slug_hint=p.get("slug_hint"), + name=p["name"], + entity_type=p["entity_type"], + aliases=p.get("aliases"), + description=p.get("description"), + rationale=p.get("rationale"), + slug_hint=p.get("slug_hint"), session_id=p.get("session_id"), dry_run=bool(p.get("dry_run", False)), proposed_by=_agent(), @@ -523,7 +539,9 @@ def _h_propose_entity(p: dict) -> dict: def _h_propose_relation(p: dict) -> dict: pr = propose_relation( _store(), - src=p["src"], relation=p["relation"], target=p["target"], + src=p["src"], + relation=p["relation"], + target=p["target"], confidence=float(p.get("confidence", 0.7)), evidence=p.get("evidence"), rationale=p.get("rationale"), @@ -553,8 +571,7 @@ def _h_propose_delete(p: dict) -> dict: def _h_approve(p: dict) -> dict: - a = approve(_store(), p["proposal_id"], approved_by=_agent(), - reason=p.get("reason")) + a = approve(_store(), p["proposal_id"], approved_by=_agent(), reason=p.get("reason")) return {"kind": type(a).__name__.lower(), "id": a.id} @@ -589,15 +606,18 @@ def _h_expire(p: dict) -> dict: def _h_supersede(p: dict) -> dict: old, new = life.supersede( - _store(), old_claim_id=p["old_claim_id"], - new_claim_id=p["new_claim_id"], actor=_agent(), + _store(), + old_claim_id=p["old_claim_id"], + new_claim_id=p["new_claim_id"], + actor=_agent(), ) return {"old": old.id, "new": new.id, "status": old.status.value} def _h_contradict(p: dict) -> dict: - a, b, rel = life.contradict(_store(), claim_a=p["claim_a"], - claim_b=p["claim_b"], actor=_agent()) + a, b, rel = life.contradict( + _store(), claim_a=p["claim_a"], claim_b=p["claim_b"], actor=_agent() + ) return {"a": a.id, "b": b.id, "relation_id": rel.id} @@ -608,12 +628,25 @@ def _h_archive(p: dict) -> dict: def _h_confirm(p: dict) -> dict: c = life.confirm(_store(), claim_id=p["claim_id"], actor=_agent()) - return {"id": c.id, "last_confirmed_at": c.last_confirmed_at.isoformat() - if c.last_confirmed_at else None} + return { + "id": c.id, + "last_confirmed_at": c.last_confirmed_at.isoformat() if c.last_confirmed_at else None, + } + + +def _h_mark_lesson_followed(p: dict) -> dict: + return life.mark_lesson_followed( + _store(), + claim_id=p["claim_id"], + followed=p["followed"], + actor=_agent(), + context=p.get("context"), + ) def _h_clear_claims(p: dict) -> dict: from datetime import datetime + before_dt = None if p.get("before"): before_dt = datetime.fromisoformat(p["before"]) @@ -641,27 +674,37 @@ def _h_cite(p: dict) -> list: def _h_source_verify(_: dict) -> list[dict]: results = verify_mod.verify_all(_store(), actor=_agent()) return [ - {"source_id": r.source.id, "title": r.source.title, - "stored_ok": r.stored_ok, "external_status": r.external_status, - "note": r.note} + { + "source_id": r.source.id, + "title": r.source.title, + "stored_ok": r.stored_ok, + "external_status": r.external_status, + "note": r.note, + } for r in results ] def _h_session_start(p: dict) -> dict: return sess_mod.session_start( - _store(), agent=_agent(), task=p.get("task"), note=p.get("note"), + _store(), + agent=_agent(), + task=p.get("task"), + note=p.get("note"), ).model_dump(mode="json") def _h_session_end(p: dict) -> dict: - return sess_mod.session_end(_store(), p["session_id"], - note=p.get("note")).model_dump(mode="json") + return sess_mod.session_end(_store(), p["session_id"], note=p.get("note")).model_dump( + mode="json" + ) def _h_crystallize(p: dict) -> dict: return sess_mod.crystallize( - _store(), p["session_id"], approver=_agent(), + _store(), + p["session_id"], + approver=_agent(), write_summary_page=bool(p.get("write_summary_page", True)), ) @@ -679,13 +722,16 @@ def _h_index_rebuild(_: dict) -> dict: def _h_lint(p: dict) -> dict: - report = health.lint(_store(), - stale_after_days=int(p.get("stale_days", 180))) + report = health.lint(_store(), stale_after_days=int(p.get("stale_days", 180))) return { "ok": report.ok, "findings": [ - {"severity": f.severity, "code": f.code, - "message": f.message, "object_ids": f.object_ids} + { + "severity": f.severity, + "code": f.code, + "message": f.message, + "object_ids": f.object_ids, + } for f in report.findings ], "counts": report.counts, @@ -697,8 +743,12 @@ def _h_doctor(_: dict) -> dict: return { "ok": report.ok, "findings": [ - {"severity": f.severity, "code": f.code, - "message": f.message, "object_ids": f.object_ids} + { + "severity": f.severity, + "code": f.code, + "message": f.message, + "object_ids": f.object_ids, + } for f in report.findings ], "counts": report.counts, @@ -706,29 +756,42 @@ def _h_doctor(_: dict) -> dict: def _h_export(p: dict) -> dict: - manifest = bundle.export(_store().kb_dir, dest=Path(p["out_path"]), - actor=_agent()) - return {"bundle_id": manifest["bundle_id"], - "files": len(manifest["files"]), "out": p["out_path"]} + manifest = bundle.export(_store().kb_dir, dest=Path(p["out_path"]), actor=_agent()) + return { + "bundle_id": manifest["bundle_id"], + "files": len(manifest["files"]), + "out": p["out_path"], + } def _h_export_check(p: dict) -> dict: r = bundle.export_check(Path(p["bundle_path"])) - return {"ok": r.ok, "bundle_id": r.bundle_id, - "files_checked": r.files_checked, "issues": r.issues} + return { + "ok": r.ok, + "bundle_id": r.bundle_id, + "files_checked": r.files_checked, + "issues": r.issues, + } def _h_import_check(p: dict) -> dict: r = bundle.import_check(_store().kb_dir, Path(p["bundle_path"])) - return {"ok": r.ok, "bundle_id": r.bundle_id, - "new_files": r.new_files, "conflicts": r.conflicts, - "identical_files": len(r.identical), "issues": r.issues} + return { + "ok": r.ok, + "bundle_id": r.bundle_id, + "new_files": r.new_files, + "conflicts": r.conflicts, + "identical_files": len(r.identical), + "issues": r.issues, + } def _h_import_apply(p: dict) -> dict: r = bundle.import_apply( - _store().kb_dir, Path(p["bundle_path"]), - on_conflict=p.get("on_conflict", "skip"), actor=_agent(), + _store().kb_dir, + Path(p["bundle_path"]), + on_conflict=p.get("on_conflict", "skip"), + actor=_agent(), ) health.rebuild_index(_store()) return r @@ -749,12 +812,14 @@ def _h_audit(p: dict) -> dict: def _h_reindex_embeddings(p: dict) -> dict: from .embeddings.migration import backfill_embeddings + n = backfill_embeddings(_store(), force=bool(p.get("force", False))) return {"touched": n} def _h_dedup_scan(p: dict) -> dict: from .embeddings.dedup import scan_all + return { "duplicates": scan_all( _store().kb_dir, @@ -768,6 +833,7 @@ def _h_eval_embeddings(p: dict) -> dict: from pathlib import Path from .embeddings.scorer import evaluate + return evaluate( kb_dir=_store().kb_dir, queries_file=Path(p["queries_path"]), @@ -778,13 +844,13 @@ def _h_eval_embeddings(p: dict) -> dict: def _h_embeddings_stats(_: dict) -> dict: from . import index_db from .embeddings.cache import query_cache_stats + store = _store() meta = index_db.get_embedding_meta(store.kb_dir) with index_db.open_db(store.kb_dir) as conn: counts = { - k: int(n) for k, n in conn.execute( - "SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind" - ) + k: int(n) + for k, n in conn.execute("SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind") } return { "model": meta.get("embedding_model"), @@ -927,6 +993,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.contradict": _h_contradict, "kb.archive": _h_archive, "kb.confirm": _h_confirm, + "kb.mark_lesson_followed": _h_mark_lesson_followed, "kb.clear_claims": _h_clear_claims, "kb.cite": _h_cite, "kb.source_verify": _h_source_verify, @@ -965,7 +1032,8 @@ def handle_request(envelope: dict) -> dict: params = envelope.get("params") or {} if not method or method not in HANDLERS: return { - "id": req_id, "ok": False, + "id": req_id, + "ok": False, "error": {"code": "method_not_found", "message": f"unknown method: {method}"}, } try: @@ -982,18 +1050,21 @@ def handle_request(envelope: dict) -> dict: } except KeyError as e: return { - "id": req_id, "ok": False, + "id": req_id, + "ok": False, "error": {"code": "missing_param", "message": str(e)}, } except (ValueError, ProposalError, ArtifactNotFoundError) as e: return { - "id": req_id, "ok": False, + "id": req_id, + "ok": False, "error": {"code": "invalid_request", "message": str(e)}, } except Exception as e: _log.exception("internal error handling %s", method) return { - "id": req_id, "ok": False, + "id": req_id, + "ok": False, "error": { "code": "internal_error", "message": str(e), @@ -1014,10 +1085,16 @@ def run_jsonl(stdin=None, stdout=None) -> None: try: envelope = json.loads(line) except json.JSONDecodeError as e: - stdout.write(json.dumps({ - "id": None, "ok": False, - "error": {"code": "invalid_json", "message": str(e)}, - }) + "\n") + stdout.write( + json.dumps( + { + "id": None, + "ok": False, + "error": {"code": "invalid_json", "message": str(e)}, + } + ) + + "\n" + ) stdout.flush() continue response = handle_request(envelope) diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index b7582d73..223b5e50 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -62,7 +62,9 @@ def supersede( # Mirror the supersedes link into the graph for graph-traversal queries. store.put_relation_idempotent(rel) audit.log_event( - store.kb_dir, event="claim.supersede", actor=actor, + store.kb_dir, + event="claim.supersede", + actor=actor, object_ids=[old.id, new.id, rel.id], ) return old, new @@ -98,7 +100,9 @@ def contradict( ) store.put_relation_idempotent(rel) audit.log_event( - store.kb_dir, event="claim.contradict", actor=actor, + store.kb_dir, + event="claim.contradict", + actor=actor, object_ids=[a.id, b.id, rel.id], ) return a, b, rel @@ -110,7 +114,10 @@ def archive(store: KBStore, *, claim_id: str, actor: str) -> Claim: claim.updated_at = datetime.now(UTC) store.update_claim(claim) audit.log_event( - store.kb_dir, event="claim.archive", actor=actor, object_ids=[claim.id], + store.kb_dir, + event="claim.archive", + actor=actor, + object_ids=[claim.id], ) return claim @@ -124,7 +131,10 @@ def confirm(store: KBStore, *, claim_id: str, actor: str) -> Claim: claim.status = ClaimStatus.ACTIONABLE store.update_claim(claim) audit.log_event( - store.kb_dir, event="claim.confirm", actor=actor, object_ids=[claim.id], + store.kb_dir, + event="claim.confirm", + actor=actor, + object_ids=[claim.id], ) return claim @@ -196,6 +206,39 @@ def clear_claims( return to_clear +def mark_lesson_followed( + store: KBStore, + *, + claim_id: str, + followed: bool, + actor: str, + context: str | None = None, +) -> dict: + """Record whether a lesson was followed in a given turn (issue #428). + + Append-only observation: appends a ``lesson.followed`` event to + ``audit.log.jsonl`` and returns without touching the claim's text, + status, or any other field. This is deliberately not a claim edit -- + the review gate stays untouched, and a lost or replayed observation + can only make future effectiveness scoring noisier, never corrupt the + claim itself. + + Not restricted to ``ClaimType.LESSON`` — the issue's own proposed + surface treats "lesson" as either a dedicated type or a flag on an + existing WORKFLOW/WARNING claim, so any claim id can carry a + follow-through observation. + """ + claim = store.get_claim(claim_id) # raises ArtifactNotFoundError if missing + audit.log_event( + store.kb_dir, + event="lesson.followed", + actor=actor, + object_ids=[claim.id], + data={"followed": followed, "context": context, "claim_type": claim.type}, + ) + return {"id": claim.id, "followed": followed} + + def cite(store: KBStore, claim_id: str) -> list[Evidence | dict]: """Return resolved citations for a claim. @@ -213,13 +256,15 @@ def cite(store: KBStore, claim_id: str) -> list[Evidence | dict]: pass try: src = store.get_source(ref) - out.append({ - "kind": "source", - "source_id": src.id, - "title": src.title, - "locator": src.locator, - "hash": src.hash, - }) + out.append( + { + "kind": "source", + "source_id": src.id, + "title": src.title, + "locator": src.locator, + "hash": src.hash, + } + ) except ArtifactNotFoundError: out.append({"kind": "missing", "ref": ref}) return out diff --git a/src/vouch/mcp_profiles.py b/src/vouch/mcp_profiles.py index 6f79f7c3..f43c6ed1 100644 --- a/src/vouch/mcp_profiles.py +++ b/src/vouch/mcp_profiles.py @@ -17,28 +17,33 @@ from .capabilities import METHODS -_MINIMAL: frozenset[str] = frozenset({ - "kb.capabilities", - "kb.status", - "kb.context", - "kb.search", - "kb.read_page", - "kb.propose_claim", - "kb.propose_page", - "kb.list_pending", -}) - -_STANDARD: frozenset[str] = _MINIMAL | frozenset({ - "kb.approve", - "kb.reject", - "kb.supersede", - "kb.contradict", - "kb.confirm", - "kb.read_claim", - "kb.list_claims", - "kb.neighbors", - "kb.why", -}) +_MINIMAL: frozenset[str] = frozenset( + { + "kb.capabilities", + "kb.status", + "kb.context", + "kb.search", + "kb.read_page", + "kb.propose_claim", + "kb.propose_page", + "kb.list_pending", + } +) + +_STANDARD: frozenset[str] = _MINIMAL | frozenset( + { + "kb.approve", + "kb.reject", + "kb.supersede", + "kb.contradict", + "kb.confirm", + "kb.mark_lesson_followed", + "kb.read_claim", + "kb.list_claims", + "kb.neighbors", + "kb.why", + } +) PROFILES: dict[str, frozenset[str]] = { "minimal": _MINIMAL, diff --git a/src/vouch/models.py b/src/vouch/models.py index 701e4a01..d39b80c9 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -48,6 +48,12 @@ class ClaimType(StrEnum): OBSERVATION = "observation" QUESTION = "question" WARNING = "warning" + # A procedural rule with a follow-through signal (issue #428): unlike + # WORKFLOW/WARNING, a lesson's actual value is measured over time via + # kb.mark_lesson_followed's append-only observation events, not just + # its text. Resurfaces through retrieval exactly like any other claim + # type -- no special-casing in context.py or search. + LESSON = "lesson" class ClaimStatus(StrEnum): @@ -254,6 +260,7 @@ def _text_non_empty(cls, v: str) -> str: # accepted text="" / whitespace and landed a claim carrying zero # semantic content. Enforce on the model to close all paths at once. return _require_non_empty(v, "claim text") + entities: list[str] = Field(default_factory=list) supersedes: list[str] = Field(default_factory=list) superseded_by: str | None = None diff --git a/src/vouch/server.py b/src/vouch/server.py index b78c7ca1..de84cb3e 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -157,7 +157,11 @@ def kb_activity( agent=agent, ) return collect_activity( - store, days=days, tz_offset_minutes=tz_offset_minutes, tz=tz, viewer=viewer, + store, + days=days, + tz_offset_minutes=tz_offset_minutes, + tz=tz, + viewer=viewer, ) @@ -203,6 +207,7 @@ def kb_search( project/agent: optional viewer context for scope filtering. """ from . import index_db + store = _store() viewer = viewer_from( config_path=store.config_path, @@ -230,7 +235,10 @@ def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any] if backend in ("auto", "embedding"): hits = index_db.search_semantic( - store.kb_dir, query, limit=fetch_limit, min_score=min_score, + store.kb_dir, + query, + limit=fetch_limit, + min_score=min_score, ) if hits: return _to_dicts(hits, "embedding") @@ -255,11 +263,15 @@ def _to_dicts(h: list[tuple[str, str, str, float]], used: str) -> dict[str, Any] from .embeddings.fusion import ( # type: ignore[import-not-found,import-untyped,unused-ignore] rrf_fuse, ) + # Hybrid must honour min_score (the embedding side can return # low-relevance noise otherwise) and survive FTS failures the same # way the dedicated fts5 branch does. emb = index_db.search_semantic( - store.kb_dir, query, limit=fetch_limit * 2, min_score=min_score, + store.kb_dir, + query, + limit=fetch_limit * 2, + min_score=min_score, ) try: fts = index_db.search(store.kb_dir, query, limit=fetch_limit * 2) @@ -308,7 +320,11 @@ def kb_neighbors( try: return find_neighbors( - _store(), node_id, depth=depth, rel_types=rel_types, max_nodes=max_nodes, + _store(), + node_id, + depth=depth, + rel_types=rel_types, + max_nodes=max_nodes, ) except ArtifactNotFoundError as e: raise ValueError(str(e)) from e @@ -339,10 +355,17 @@ def kb_context( _, window, _ = salience_mod.reflex_cfg(cfg) salience_mod.record_query(session_id, task, window=window) result: dict[str, Any] = build_context_pack( # type: ignore[assignment] - store, query=task, limit=limit, max_chars=max_chars, - min_items=min_items, require_citations=require_citations, - project=project, agent=agent, - expand_graph=expand_graph, graph_depth=graph_depth, graph_limit=graph_limit, + store, + query=task, + limit=limit, + max_chars=max_chars, + min_items=min_items, + require_citations=require_citations, + project=project, + agent=agent, + expand_graph=expand_graph, + graph_depth=graph_depth, + graph_limit=graph_limit, ) result = salience_mod.attach_salience(result, store, session_id, cfg) pack_items = result.get("items") if isinstance(result, dict) else None @@ -436,6 +459,7 @@ def kb_diff(old_id: str, new_id: str | None = None) -> dict[str, Any]: from dataclasses import asdict from .diff import diff_artifacts + return asdict(diff_artifacts(_store(), old_id, new_id)) @@ -567,28 +591,30 @@ def kb_register_source( evidence is harmless and de-duplicates by content hash).""" src = _store().put_source( content.encode("utf-8"), - title=title, url=url, + title=title, + url=url, locator=url or title or "inline", source_type=source_type, media_type=media_type, ) - audit.log_event(_store().kb_dir, event="source.add", actor=_agent(), - object_ids=[src.id]) + audit.log_event(_store().kb_dir, event="source.add", actor=_agent(), object_ids=[src.id]) return src.model_dump(mode="json") @mcp.tool() -def kb_register_source_from_path(path: str, title: str | None = None, - url: str | None = None, - source_type: str = "file") -> dict[str, Any]: +def kb_register_source_from_path( + path: str, title: str | None = None, url: str | None = None, source_type: str = "file" +) -> dict[str, Any]: s = _store() p, body = s.read_under_root(path) src = s.put_source( - body, title=title or p.name, url=url, - locator=str(p), source_type=source_type, + body, + title=title or p.name, + url=url, + locator=str(p), + source_type=source_type, ) - audit.log_event(s.kb_dir, event="source.add", actor=_agent(), - object_ids=[src.id]) + audit.log_event(s.kb_dir, event="source.add", actor=_agent(), object_ids=[src.id]) return src.model_dump(mode="json") @@ -608,11 +634,18 @@ def kb_propose_claim( """Propose a new claim. Becomes durable only after `kb_approve`.""" try: result = propose_claim( - _store(), text=text, evidence=evidence, - claim_type=claim_type, confidence=confidence, - entities=entities, tags=tags, rationale=rationale, - slug_hint=slug_hint, session_id=session_id, - dry_run=dry_run, proposed_by=_agent(), + _store(), + text=text, + evidence=evidence, + claim_type=claim_type, + confidence=confidence, + entities=entities, + tags=tags, + rationale=rationale, + slug_hint=slug_hint, + session_id=session_id, + dry_run=dry_run, + proposed_by=_agent(), ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e @@ -635,10 +668,19 @@ def kb_propose_page( ) -> dict[str, Any]: try: pr = propose_page( - _store(), title=title, body=body, page_type=page_type, - claim_ids=claim_ids, entity_ids=entity_ids, source_ids=source_ids, - metadata=metadata, rationale=rationale, slug_hint=slug_hint, - session_id=session_id, dry_run=dry_run, proposed_by=_agent(), + _store(), + title=title, + body=body, + page_type=page_type, + claim_ids=claim_ids, + entity_ids=entity_ids, + source_ids=source_ids, + metadata=metadata, + rationale=rationale, + slug_hint=slug_hint, + session_id=session_id, + dry_run=dry_run, + proposed_by=_agent(), ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e @@ -660,8 +702,11 @@ def kb_compile( """ try: report = compile_mod.compile_kb( - _store(), triggered_by=_agent(), - max_pages=max_pages, dry_run=dry_run, session_id=session_id, + _store(), + triggered_by=_agent(), + max_pages=max_pages, + dry_run=dry_run, + session_id=session_id, ) except compile_mod.CompileError as e: raise ValueError(str(e)) from e @@ -681,6 +726,7 @@ def kb_summarize_session( Long-running when it splits (the LLM call is synchronous). Never approves. """ from . import session_split + return session_split.summarize(_store(), session_id, mode=mode) @@ -693,6 +739,7 @@ def kb_list_sessions() -> dict[str, Any]: kind, title, summarized, observations, last_activity. """ from . import session_split + return {"sessions": session_split.build_session_rows(_store())} @@ -706,6 +753,7 @@ def kb_session_transcript(session_id: str, agent: str | None = None) -> dict[str Degrades to compact capture observations when the raw file is unavailable. """ from . import transcript + if agent is not None and agent not in ("claude", "codex"): raise ValueError(f"unknown agent: {agent!r} (expected 'claude' or 'codex')") return transcript.load_transcript(_store(), session_id, agent=agent) @@ -724,10 +772,15 @@ def kb_propose_entity( ) -> dict[str, Any]: try: pr = propose_entity( - _store(), name=name, entity_type=entity_type, - aliases=aliases, description=description, - rationale=rationale, slug_hint=slug_hint, - session_id=session_id, dry_run=dry_run, + _store(), + name=name, + entity_type=entity_type, + aliases=aliases, + description=description, + rationale=rationale, + slug_hint=slug_hint, + session_id=session_id, + dry_run=dry_run, proposed_by=_agent(), ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: @@ -748,10 +801,16 @@ def kb_propose_relation( ) -> dict[str, Any]: try: pr = propose_relation( - _store(), src=src, relation=relation, target=target, - confidence=confidence, evidence=evidence, - rationale=rationale, session_id=session_id, - dry_run=dry_run, proposed_by=_agent(), + _store(), + src=src, + relation=relation, + target=target, + confidence=confidence, + evidence=evidence, + rationale=rationale, + session_id=session_id, + dry_run=dry_run, + proposed_by=_agent(), ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e @@ -760,8 +819,11 @@ def kb_propose_relation( @mcp.tool() def kb_propose_delete( - target_kind: str, target_id: str, rationale: str | None = None, - session_id: str | None = None, dry_run: bool = False, + target_kind: str, + target_id: str, + rationale: str | None = None, + session_id: str | None = None, + dry_run: bool = False, ) -> dict[str, Any]: """Propose hard-deleting a durable artifact (claim/page/entity/relation). @@ -770,9 +832,13 @@ def kb_propose_delete( """ try: pr = propose_delete( - _store(), target_kind=target_kind, target_id=target_id, - proposed_by=_agent(), rationale=rationale, - session_id=session_id, dry_run=dry_run, + _store(), + target_kind=target_kind, + target_id=target_id, + proposed_by=_agent(), + rationale=rationale, + session_id=session_id, + dry_run=dry_run, ) except (ProposalError, ArtifactNotFoundError, ValueError) as e: raise ValueError(str(e)) from e @@ -788,7 +854,8 @@ def _proposal_response(result, dry_run: bool) -> dict[str, Any]: "dry_run": dry_run, "note": ( "dry run — not written" - if dry_run else "pending human approval via `vouch approve `" + if dry_run + else "pending human approval via `vouch approve `" ), } warnings = getattr(result, "warnings", None) @@ -805,8 +872,7 @@ def _proposal_response(result, dry_run: bool) -> dict[str, Any]: def kb_approve(proposal_id: str, reason: str | None = None) -> dict[str, Any]: """Approve a proposal → durable artifact. Use carefully.""" try: - artifact = approve(_store(), proposal_id, approved_by=_agent(), - reason=reason) + artifact = approve(_store(), proposal_id, approved_by=_agent(), reason=reason) except (ArtifactNotFoundError, ValueError, ProposalError) as e: raise ValueError(str(e)) from e return {"kind": type(artifact).__name__.lower(), "id": artifact.id} @@ -823,7 +889,8 @@ def kb_reject(proposal_id: str, reason: str) -> dict[str, Any]: @mcp.tool() def kb_reject_extracted( - page_id: str | None = None, reason: str | None = None, + page_id: str | None = None, + reason: str | None = None, ) -> dict[str, Any]: """Mass-reject pending edges the auto-extractor filed (issue #224). @@ -832,7 +899,9 @@ def kb_reject_extracted( """ try: rejected = reject_auto_extracted( - _store(), rejected_by=_agent(), page_id=page_id, + _store(), + rejected_by=_agent(), + page_id=page_id, **({"reason": reason} if reason else {}), ) except (ArtifactNotFoundError, ValueError, ProposalError) as e: @@ -845,7 +914,10 @@ def kb_expire(apply: bool = False, days: int | None = None) -> dict[str, Any]: """Expire stale pending proposals (dry-run unless apply=True).""" try: result = expire_pending( - _store(), apply=apply, expired_by=EXPIRE_ACTOR, days=days, + _store(), + apply=apply, + expired_by=EXPIRE_ACTOR, + days=days, ) except (ArtifactNotFoundError, ValueError, ProposalError) as e: raise ValueError(str(e)) from e @@ -864,7 +936,9 @@ def kb_expire(apply: bool = False, days: int | None = None) -> dict[str, Any]: @mcp.tool() def kb_supersede(old_claim_id: str, new_claim_id: str) -> dict[str, Any]: old, new = life.supersede( - _store(), old_claim_id=old_claim_id, new_claim_id=new_claim_id, + _store(), + old_claim_id=old_claim_id, + new_claim_id=new_claim_id, actor=_agent(), ) return {"old": old.id, "new": new.id, "status": old.status.value} @@ -872,8 +946,7 @@ def kb_supersede(old_claim_id: str, new_claim_id: str) -> dict[str, Any]: @mcp.tool() def kb_contradict(claim_a: str, claim_b: str) -> dict[str, Any]: - a, b, rel = life.contradict(_store(), claim_a=claim_a, claim_b=claim_b, - actor=_agent()) + a, b, rel = life.contradict(_store(), claim_a=claim_a, claim_b=claim_b, actor=_agent()) return {"a": a.id, "b": b.id, "relation_id": rel.id} @@ -889,6 +962,23 @@ def kb_confirm(claim_id: str) -> dict[str, Any]: return {"id": c.id, "last_confirmed_at": c.last_confirmed_at} +@mcp.tool() +def kb_mark_lesson_followed( + claim_id: str, followed: bool, context: str | None = None +) -> dict[str, Any]: + """Record a followed/not-followed observation for a lesson claim (#428). + + Append-only: appends a lesson.followed audit event, edits nothing else. + """ + return life.mark_lesson_followed( + _store(), + claim_id=claim_id, + followed=followed, + actor=_agent(), + context=context, + ) + + @mcp.tool() def kb_clear_claims( auto_only: bool = True, @@ -963,7 +1053,9 @@ async def kb_session_start(task: str | None = None, note: str | None = None) -> ctx = mcp.get_context() if ctx.session is not None: volunteer_context.register_mcp_push( - sess.id, ctx.session, asyncio.get_running_loop(), + sess.id, + ctx.session, + asyncio.get_running_loop(), ) return sess.model_dump(mode="json") @@ -982,11 +1074,12 @@ def kb_session_end(session_id: str, note: str | None = None) -> dict[str, Any]: @mcp.tool() -def kb_crystallize(session_id: str, write_summary_page: bool = True - ) -> dict[str, Any]: +def kb_crystallize(session_id: str, write_summary_page: bool = True) -> dict[str, Any]: """Approve every pending proposal in `session_id` (host must trust the agent).""" return sess_mod.crystallize( - _store(), session_id, approver=_agent(), + _store(), + session_id, + approver=_agent(), write_summary_page=write_summary_page, ) @@ -1006,8 +1099,12 @@ def kb_lint(stale_days: int = 180) -> dict[str, Any]: return { "ok": report.ok, "findings": [ - {"severity": f.severity, "code": f.code, - "message": f.message, "object_ids": f.object_ids} + { + "severity": f.severity, + "code": f.code, + "message": f.message, + "object_ids": f.object_ids, + } for f in report.findings ], "counts": report.counts, @@ -1020,8 +1117,12 @@ def kb_doctor() -> dict[str, Any]: return { "ok": report.ok, "findings": [ - {"severity": f.severity, "code": f.code, - "message": f.message, "object_ids": f.object_ids} + { + "severity": f.severity, + "code": f.code, + "message": f.message, + "object_ids": f.object_ids, + } for f in report.findings ], "counts": report.counts, @@ -1042,8 +1143,10 @@ def kb_export(out_path: str) -> dict[str, Any]: def kb_export_check(bundle_path: str) -> dict[str, Any]: r = bundle.export_check(Path(bundle_path)) return { - "ok": r.ok, "bundle_id": r.bundle_id, - "files_checked": r.files_checked, "issues": r.issues, + "ok": r.ok, + "bundle_id": r.bundle_id, + "files_checked": r.files_checked, + "issues": r.issues, } @@ -1051,9 +1154,12 @@ def kb_export_check(bundle_path: str) -> dict[str, Any]: def kb_import_check(bundle_path: str) -> dict[str, Any]: r = bundle.import_check(_store().kb_dir, Path(bundle_path)) return { - "ok": r.ok, "bundle_id": r.bundle_id, - "new_files": r.new_files, "conflicts": r.conflicts, - "identical_files": len(r.identical), "issues": r.issues, + "ok": r.ok, + "bundle_id": r.bundle_id, + "new_files": r.new_files, + "conflicts": r.conflicts, + "identical_files": len(r.identical), + "issues": r.issues, } @@ -1061,8 +1167,10 @@ def kb_import_check(bundle_path: str) -> dict[str, Any]: def kb_import_apply(bundle_path: str, on_conflict: str = "skip") -> dict[str, Any]: try: r = bundle.import_apply( - _store().kb_dir, Path(bundle_path), - on_conflict=on_conflict, actor=_agent(), + _store().kb_dir, + Path(bundle_path), + on_conflict=on_conflict, + actor=_agent(), ) except (RuntimeError, ValueError) as e: raise ValueError(str(e)) from e @@ -1093,13 +1201,18 @@ def kb_audit( @mcp.tool() def kb_reindex_embeddings( - *, backfill: bool = False, force: bool = False, model: str | None = None, + *, + backfill: bool = False, + force: bool = False, + model: str | None = None, ) -> dict[str, Any]: """Re-encode every artifact under the current embedding adapter.""" from .embeddings.migration import backfill_embeddings + store = _store() if model: from .embeddings import get_embedder + get_embedder(model) n = backfill_embeddings(store, force=force) return {"touched": n, "model": _current_model_name()} @@ -1107,10 +1220,13 @@ def kb_reindex_embeddings( @mcp.tool() def kb_dedup_scan( - *, threshold: float = 0.95, dry_run: bool = False, + *, + threshold: float = 0.95, + dry_run: bool = False, ) -> dict[str, Any]: """Find near-duplicate artifacts via embedding cosine.""" from .embeddings.dedup import scan_all + store = _store() rows = scan_all(store.kb_dir, threshold=threshold, dry_run=dry_run) return {"duplicates": rows, "threshold": threshold} @@ -1122,6 +1238,7 @@ def kb_eval_embeddings(*, queries_path: str, k: int = 10) -> dict[str, Any]: from pathlib import Path from .embeddings.scorer import evaluate + store = _store() return evaluate( kb_dir=store.kb_dir, @@ -1135,13 +1252,12 @@ def kb_embeddings_stats() -> dict[str, Any]: """Model identity, per-kind counts, query cache stats.""" from . import index_db from .embeddings.cache import query_cache_stats + store = _store() meta = index_db.get_embedding_meta(store.kb_dir) counts: dict[str, int] = {} with index_db.open_db(store.kb_dir) as conn: - for k, n in conn.execute( - "SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind" - ): + for k, n in conn.execute("SELECT kind, COUNT(*) FROM embedding_index GROUP BY kind"): counts[k] = int(n) return { "model": meta.get("embedding_model"), @@ -1162,6 +1278,7 @@ def kb_why(claim_id: str, *, depth: int = 3) -> dict[str, Any]: depth: how many hops of provenance to expand (default 3). """ from . import provenance as prov + return prov.why(_store(), claim_id=claim_id, depth=depth) @@ -1169,15 +1286,15 @@ def kb_why(claim_id: str, *, depth: int = 3) -> dict[str, Any]: def kb_trace(from_id: str, to_id: str) -> dict[str, Any]: """Shortest typed-edge path between two artifacts (or found=false).""" from . import provenance as prov + return prov.trace(_store(), from_id=from_id, to_id=to_id) @mcp.tool() -def kb_impact( - claim_id: str, *, depth: int = 1, op: str | None = None -) -> dict[str, Any]: +def kb_impact(claim_id: str, *, depth: int = 1, op: str | None = None) -> dict[str, Any]: """Forward impact: dependents, and breakage if op (archive/contradict/supersede).""" from . import provenance as prov + return prov.impact(_store(), claim_id=claim_id, depth=depth, op=op) @@ -1185,6 +1302,7 @@ def kb_impact( def kb_graph_export(*, session: str | None = None, format: str = "dot") -> dict[str, Any]: """Render the provenance DAG (or one session's subgraph) as dot/mermaid.""" from . import provenance as prov + graph = prov.graph_export(_store(), session=session, fmt=format) return {"format": format, "graph": graph} @@ -1193,6 +1311,7 @@ def kb_graph_export(*, session: str | None = None, format: str = "dot") -> dict[ def kb_provenance_rebuild() -> dict[str, Any]: """Rebuild the prov_edges cache from durable files; returns edge count.""" from . import provenance as prov + return {"edges": prov.rebuild_prov_edges(_store())} @@ -1216,6 +1335,7 @@ def kb_detect_themes( top_k: maximum clusters to return (default from config or 10). """ from . import themes + store = _store() result = themes.detect_themes( store, @@ -1254,6 +1374,7 @@ def kb_propose_theme( Pass a cluster from kb.detect_themes directly. """ from . import themes + store = _store() actor = agent or os.environ.get("VOUCH_AGENT", "unknown-agent") cluster = themes.ThemeCluster( @@ -1270,6 +1391,7 @@ def kb_propose_theme( def _current_model_name() -> str: try: from .embeddings import get_embedder + return get_embedder().name except Exception: return "" diff --git a/tests/test_lessons.py b/tests/test_lessons.py new file mode 100644 index 00000000..fd424a39 --- /dev/null +++ b/tests/test_lessons.py @@ -0,0 +1,233 @@ +"""Lessons: a typed artifact with follow-through tracking (issue #428). + +Covers the achievable slice of #428: ClaimType.LESSON resurfacing through the +normal retrieval path, kb.mark_lesson_followed as an append-only observation +(never edits the claim), and the repeat guard at propose time -- which is +free, since propose_claim already runs find_similar_on_propose (#147) for +every claim regardless of type. + +Not covered here: the "effectiveness computation" the issue names as a +downstream consumer of these events does not exist anywhere in this codebase +(its own cross-reference points at an unrelated PR) -- these events are +shaped for such a consumer, but building that unspecified system is out of +scope for this issue. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import audit, capabilities, context +from vouch.context import build_context_pack +from vouch.embeddings import register +from vouch.embeddings.base import DEFAULT_MODEL_NAME +from vouch.jsonl_server import handle_request +from vouch.lifecycle import mark_lesson_followed +from vouch.models import Claim, ClaimType, ProposalStatus +from vouch.proposals import propose_claim +from vouch.server import kb_mark_lesson_followed +from vouch.storage import ArtifactNotFoundError, KBStore + +_LESSON_TEXT = "Always run mypy before pushing." + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _lesson(store: KBStore, cid: str = "lesson-mypy", text: str = _LESSON_TEXT) -> Claim: + src = store.put_source(b"e") + return store.put_claim(Claim(id=cid, text=text, type=ClaimType.LESSON, evidence=[src.id])) + + +# --------------------------------------------------------------------------- +# Claim type +# --------------------------------------------------------------------------- + + +def test_lesson_is_a_claim_type() -> None: + assert ClaimType.LESSON.value == "lesson" + + +def test_lesson_claim_round_trips_through_store(store: KBStore) -> None: + _lesson(store) + loaded = store.get_claim("lesson-mypy") + assert loaded.type == ClaimType.LESSON + + +# --------------------------------------------------------------------------- +# Resurfacing via retrieval +# --------------------------------------------------------------------------- + + +def test_lesson_resurfaces_via_context_pack( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + """A lesson claim must resurface through the normal retrieval path -- + no special-casing anywhere excludes ClaimType.LESSON.""" + _lesson(store) + monkeypatch.setattr( + context.index_db, + "search_semantic", + lambda *a, **k: [("claim", "lesson-mypy", _LESSON_TEXT, 0.9)], + ) + monkeypatch.setattr(context.index_db, "search", lambda *a, **k: []) + pack = build_context_pack(store, query="mypy before pushing") + ids = {item["id"] for item in pack["items"]} + assert "lesson-mypy" in ids + + +# --------------------------------------------------------------------------- +# mark_lesson_followed: observe, don't edit +# --------------------------------------------------------------------------- + + +def test_mark_followed_appends_audit_event(store: KBStore) -> None: + _lesson(store) + result = mark_lesson_followed(store, claim_id="lesson-mypy", followed=True, actor="agent") + assert result == {"id": "lesson-mypy", "followed": True} + events = [e for e in audit.read_events(store.kb_dir) if e.event == "lesson.followed"] + assert len(events) == 1 + assert events[0].object_ids == ["lesson-mypy"] + assert events[0].data["followed"] is True + + +def test_mark_not_followed_records_false_with_context(store: KBStore) -> None: + _lesson(store) + mark_lesson_followed( + store, + claim_id="lesson-mypy", + followed=False, + actor="agent", + context="skipped due to deadline", + ) + events = [e for e in audit.read_events(store.kb_dir) if e.event == "lesson.followed"] + assert events[0].data["followed"] is False + assert events[0].data["context"] == "skipped due to deadline" + + +def test_mark_followed_does_not_edit_the_claim(store: KBStore) -> None: + """Core invariant: observing usage must never mutate the lesson itself.""" + before = _lesson(store) + mark_lesson_followed(store, claim_id="lesson-mypy", followed=True, actor="agent") + after = store.get_claim("lesson-mypy") + assert after.text == before.text + assert after.status == before.status + assert after.updated_at == before.updated_at + assert after.confidence == before.confidence + + +def test_mark_followed_multiple_times_appends_each_observation(store: KBStore) -> None: + """Repeated observations each land as their own event -- a log of usage + over time, not a single mutable field.""" + _lesson(store) + mark_lesson_followed(store, claim_id="lesson-mypy", followed=True, actor="agent-a") + mark_lesson_followed(store, claim_id="lesson-mypy", followed=False, actor="agent-b") + events = [e for e in audit.read_events(store.kb_dir) if e.event == "lesson.followed"] + assert len(events) == 2 + assert [e.data["followed"] for e in events] == [True, False] + + +def test_mark_followed_missing_claim_raises(store: KBStore) -> None: + with pytest.raises(ArtifactNotFoundError): + mark_lesson_followed(store, claim_id="ghost", followed=True, actor="agent") + + +def test_mark_followed_not_restricted_to_lesson_type(store: KBStore) -> None: + """The issue's own proposed surface allows 'lesson' as a flag on an + existing WORKFLOW/WARNING claim, not only a dedicated type -- any claim + id may carry a follow-through observation.""" + src = store.put_source(b"e") + store.put_claim( + Claim(id="wf1", text="a workflow rule", type=ClaimType.WORKFLOW, evidence=[src.id]) + ) + mark_lesson_followed(store, claim_id="wf1", followed=True, actor="agent") + events = [e for e in audit.read_events(store.kb_dir) if e.event == "lesson.followed"] + assert events[0].data["claim_type"] == "workflow" + + +# --------------------------------------------------------------------------- +# Registration across surfaces (capabilities / JSONL / MCP) +# --------------------------------------------------------------------------- + + +def test_method_registered_in_capabilities() -> None: + assert "kb.mark_lesson_followed" in capabilities.METHODS + + +def test_jsonl_mark_lesson_followed_end_to_end(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + _lesson(store) + resp = handle_request( + { + "id": "r1", + "method": "kb.mark_lesson_followed", + "params": {"claim_id": "lesson-mypy", "followed": True}, + } + ) + assert resp["ok"] is True, resp + assert resp["result"]["id"] == "lesson-mypy" + assert resp["result"]["followed"] is True + + +def test_mcp_mark_lesson_followed(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.root) + _lesson(store) + result = kb_mark_lesson_followed( + claim_id="lesson-mypy", + followed=False, + context="ran out of time", + ) + assert result == {"id": "lesson-mypy", "followed": False} + events = [e for e in audit.read_events(store.kb_dir) if e.event == "lesson.followed"] + assert events[0].data["context"] == "ran out of time" + + +# --------------------------------------------------------------------------- +# Repeat guard: reuses existing propose-time similarity warnings (#147) +# --------------------------------------------------------------------------- + + +def test_proposing_overlapping_lesson_warns_without_blocking(store: KBStore) -> None: + """propose_claim already runs find_similar_on_propose for every claim + type -- adding ClaimType.LESSON gets the repeat guard for free. + + _REGISTRY is module-global (vouch.embeddings.base); registering a + MockEmbedder here without restoring it afterward leaks into unrelated + later tests elsewhere in the suite (e.g. test_cli's fts5-backend-label + test), flipping their expected backend from fts5 to embedding. Mirrors + tests/embeddings/conftest.py's _isolate_embedder_registry, which only + covers that directory, not this one. + """ + pytest.importorskip("numpy") + from tests.embeddings._fakes import MockEmbedder + from vouch.embeddings import base as embeddings_base + + saved_registry = dict(embeddings_base._REGISTRY) + register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)) + try: + _assert_overlapping_lesson_warns(store) + finally: + embeddings_base._REGISTRY.clear() + embeddings_base._REGISTRY.update(saved_registry) + + +def _assert_overlapping_lesson_warns(store: KBStore) -> None: + src = store.put_source(b"e") + store.put_claim( + Claim(id="lesson-mypy", text=_LESSON_TEXT, type=ClaimType.LESSON, evidence=[src.id]) + ) + result = propose_claim( + store, + text=_LESSON_TEXT, + evidence=[src.id], + proposed_by="agent", + claim_type="lesson", + ) + codes = {w["code"] for w in result.warnings} + assert "similar_approved" in codes + # non-blocking: the proposal was still filed, not rejected + assert result.proposal.status is ProposalStatus.PENDING diff --git a/tests/test_propose_similarity.py b/tests/test_propose_similarity.py index b2bc4d52..0316d97f 100644 --- a/tests/test_propose_similarity.py +++ b/tests/test_propose_similarity.py @@ -16,12 +16,25 @@ @pytest.fixture(autouse=True) -def _mock_embedder() -> None: +def _mock_embedder(): # MockEmbedder requires numpy. Skip cleanly when CI installs only [dev]. pytest.importorskip("numpy") from tests.embeddings._fakes import MockEmbedder - + from vouch.embeddings import base as embeddings_base + + # _REGISTRY is module-global -- without saving/restoring it, registering + # a MockEmbedder here leaks into unrelated later tests in a full-suite + # run (e.g. test_cli's fts5-backend-label test, test_delete's deindex + # test), flipping their expected backend from fts5/substring to + # embedding. Mirrors tests/embeddings/conftest.py's + # _isolate_embedder_registry, which only covers that directory. + saved_registry = dict(embeddings_base._REGISTRY) register(DEFAULT_MODEL_NAME, lambda: MockEmbedder(dim=8)) + try: + yield + finally: + embeddings_base._REGISTRY.clear() + embeddings_base._REGISTRY.update(saved_registry) @pytest.fixture @@ -71,7 +84,10 @@ def test_propose_no_warning_for_unrelated_text(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text="apples and oranges", evidence=[src.id])) result = propose_claim( - store, text="zebras run fast in the savanna", evidence=[src.id], proposed_by="agent", + store, + text="zebras run fast in the savanna", + evidence=[src.id], + proposed_by="agent", ) assert result.warnings == [] @@ -80,15 +96,19 @@ def test_propose_similarity_on_dry_run(store: KBStore) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text=_SIMILAR_TEXT, evidence=[src.id])) result = propose_claim( - store, text=_SIMILAR_TEXT, evidence=[src.id], - proposed_by="agent", dry_run=True, + store, + text=_SIMILAR_TEXT, + evidence=[src.id], + proposed_by="agent", + dry_run=True, ) assert not (store.kb_dir / "proposed" / f"{result.id}.yaml").exists() assert any(w["code"] == "similar_approved" for w in result.warnings) def test_jsonl_propose_claim_includes_warnings( - store: KBStore, monkeypatch: pytest.MonkeyPatch, + store: KBStore, + monkeypatch: pytest.MonkeyPatch, ) -> None: from vouch.jsonl_server import handle_request @@ -97,21 +117,24 @@ def test_jsonl_propose_claim_includes_warnings( Claim(id="c1", text=_SIMILAR_TEXT, evidence=[src.id]), ) monkeypatch.chdir(store.root) - resp = handle_request({ - "id": "1", - "method": "kb.propose_claim", - "params": { - "text": _SIMILAR_TEXT, - "evidence": [src.id], - }, - }) + resp = handle_request( + { + "id": "1", + "method": "kb.propose_claim", + "params": { + "text": _SIMILAR_TEXT, + "evidence": [src.id], + }, + } + ) assert resp["ok"] assert "warnings" in resp["result"] assert any(w["code"] == "similar_approved" for w in resp["result"]["warnings"]) def test_propose_similarity_without_embedder( - store: KBStore, monkeypatch: pytest.MonkeyPatch, + store: KBStore, + monkeypatch: pytest.MonkeyPatch, ) -> None: src = store.put_source(b"e") store.put_claim(Claim(id="c1", text=_SIMILAR_TEXT, evidence=[src.id])) @@ -122,6 +145,9 @@ def _no_embedder(name: str | None = None) -> None: monkeypatch.setattr("vouch.embeddings.get_embedder", _no_embedder) result = propose_claim( - store, text=_SIMILAR_TEXT, evidence=[src.id], proposed_by="agent", + store, + text=_SIMILAR_TEXT, + evidence=[src.id], + proposed_by="agent", ) assert result.warnings == []