From 0a5bebb9b45bb89f671d77ac4a069829b2f41e07 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:25:29 +0900 Subject: [PATCH 01/13] feat(onboarding): add company-brain init template with typed page kinds declares record kinds (contact, org, project-record, meeting-notes, followup, decision-record, voice) as ordinary page_kinds config so both gates validate them, and seeds a cited, approved guide page describing the conventions. merging into config.yaml is additive: operator-declared kinds win, and when the file has no page_kinds key the block is appended textually so comments survive. idempotent via stable artifact ids. --- src/vouch/onboarding.py | 195 +++++++++++++++++++++++++++++++++++++++ tests/test_onboarding.py | 92 +++++++++++++++++- 2 files changed, 286 insertions(+), 1 deletion(-) diff --git a/src/vouch/onboarding.py b/src/vouch/onboarding.py index b825441b..38f428ea 100644 --- a/src/vouch/onboarding.py +++ b/src/vouch/onboarding.py @@ -5,6 +5,8 @@ from collections.abc import Callable from dataclasses import dataclass +import yaml + from .models import ( Claim, ClaimStatus, @@ -270,10 +272,203 @@ def seed_gittensor_kb(store: KBStore, *, approved_by: str = "vouch-init") -> See return SeedResult(template="gittensor", created=created) +# --- company-brain template ------------------------------------------------- + +BRAIN_TEMPLATE = "company-brain" +BRAIN_PAGE_ID = "company-brain-guide" + +# Typed record kinds for running a team's shared memory through the review +# gate: people and orgs as entities paired with typed pages, work as +# project records, commitments as followups with a due date, decisions and +# voice pages forced to cite their evidence. Declared as plain +# `page_kinds` config so `vouch schema list` shows them and both the +# propose and approve gates validate them — no new machinery. +BRAIN_PAGE_KINDS: dict[str, dict[str, object]] = { + "contact": { + "description": "a person the team works with", + "required_fields": ["role"], + "frontmatter_schema": { + "type": "object", + "properties": { + "role": {"type": "string"}, + "org": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + }, + "org": { + "description": "a company or organisation the team deals with", + "frontmatter_schema": { + "type": "object", + "properties": {"website": {"type": "string"}}, + }, + }, + "project-record": { + "description": "one project's living record", + "required_fields": ["record_status"], + "frontmatter_schema": { + "type": "object", + "properties": { + "record_status": {"type": "string"}, + "owner": {"type": "string"}, + }, + }, + }, + "meeting-notes": { + "description": "notes from one meeting", + "frontmatter_schema": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "attendees": {"type": "array"}, + }, + }, + }, + "followup": { + "description": "a dated commitment; surfaced by `vouch digest` until closed", + "required_fields": ["due_at", "followup_status"], + "frontmatter_schema": { + "type": "object", + "properties": { + "due_at": {"type": "string"}, + "followup_status": {"type": "string"}, + "owner": {"type": "string"}, + }, + }, + }, + "decision-record": { + "description": "why the team decided something; must cite evidence", + "required_citations": True, + }, + "voice": { + "description": "how the team sounds in one channel; cites the examples it distills", + "required_citations": True, + }, +} + +BRAIN_SOURCE_TEXT = """# Company-brain starter source + +Created by `vouch init --template company-brain` so the seeded guide page has +a citable source. A team's shared memory works when every record — contact, +project, followup, decision — is proposed with evidence and approved by a +human before agents rely on it. +""" + +BRAIN_PAGE_BODY = """# Company brain: typed records through the review gate + +This KB is set up as a team memory. Records are ordinary vouch pages with a +declared kind (see `vouch schema list`), so every write is proposed, reviewed, +and audited like any other knowledge. + +Conventions: + +1. A person = `kb_propose_entity` (type `person`) plus a `contact` page + (frontmatter: `role`, `org`, `email`). An organisation = entity (type + `company`) plus an `org` page. +2. A project = entity (type `project`) plus a `project-record` page whose + `record_status` frontmatter tracks where it stands. +3. A commitment = a `followup` page with `due_at` and `followup_status` + frontmatter. `vouch digest` lists open followups that are due; close one + by re-proposing the page with `followup_status: done`. +4. A decision = a `decision-record` page. It must cite the claims or sources + it rests on — the gate rejects an uncited one. +5. Meeting notes go in `meeting-notes` pages; durable facts distilled from + them become claims via `kb_propose_claim`, citing a registered source. + +Daily loop: agents file proposals all day; a human reviews with +`vouch review` or `vouch approve `; `vouch digest` (run it from cron) +shows what is pending, what was decided, which claims went stale, and which +followups are due. +""" + + +def seed_company_brain_kb(store: KBStore, *, approved_by: str = "vouch-init") -> SeedResult: + """Declare the typed record kinds and seed a cited guide page. + + Idempotent: kinds the operator already declared are left untouched, and + stable artifact ids mean a second call creates nothing. + """ + created: list[str] = [] + + created += [f"page_kinds.{k}" for k in _merge_page_kinds(store, BRAIN_PAGE_KINDS)] + + body = BRAIN_SOURCE_TEXT.encode("utf-8") + source_id = sha256_hex(body) + if not (store.kb_dir / "sources" / source_id / "meta.yaml").exists(): + created.append(source_id) + source = store.put_source( + body, + title="Company-brain starter source", + locator="vouch:template/company-brain", + source_type="message", + media_type="text/markdown", + tags=["company-brain", "onboarding"], + ) + + try: + store.get_page(BRAIN_PAGE_ID) + except ArtifactNotFoundError: + store.put_page( + Page( + id=BRAIN_PAGE_ID, + title="Company brain: typed records through the review gate", + body=BRAIN_PAGE_BODY, + type=PageType.WORKFLOW, + status=PageStatus.ACTIVE, + sources=[source.id], + tags=["company-brain", "onboarding"], + ) + ) + created.append(BRAIN_PAGE_ID) + + return SeedResult(template=BRAIN_TEMPLATE, created=created) + + +def _merge_page_kinds(store: KBStore, kinds: dict[str, dict[str, object]]) -> list[str]: + """Add missing `page_kinds` entries to config.yaml; return the names added. + + When the file has no `page_kinds` key at all the block is appended + textually so operator comments and formatting survive. When the key + exists the file is parsed, missing kinds are filled in, and the mapping + is re-dumped (a yaml round-trip drops comments — the narrow price of a + real merge). + """ + path = store.kb_dir / "config.yaml" + text = path.read_text(encoding="utf-8") if path.exists() else "" + try: + loaded = yaml.safe_load(text) or {} + except yaml.YAMLError as e: + raise ValueError(f"config.yaml is not valid yaml; fix it first: {e}") from e + if not isinstance(loaded, dict): + raise ValueError("config.yaml must be a yaml mapping") + + existing = loaded.get("page_kinds") + if not isinstance(existing, dict): + block = yaml.safe_dump( + {"page_kinds": kinds}, sort_keys=False, default_flow_style=False + ) + prefix = text.rstrip("\n") + joined = f"{prefix}\n\n{block}" if prefix else block + path.write_text(joined, encoding="utf-8") + return sorted(kinds) + + added = sorted(k for k in kinds if k not in existing) + if not added: + return [] + for name in added: + existing[name] = kinds[name] + path.write_text( + yaml.safe_dump(loaded, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + return added + + # Non-default templates dispatched by `vouch init --template`. The default # `starter` seed keeps its own bespoke output, so it isn't registered here. TEMPLATES: dict[str, Callable[..., SeedResult]] = { "gittensor": seed_gittensor_kb, + BRAIN_TEMPLATE: seed_company_brain_kb, } diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index ec60f33b..427cc8fe 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -4,11 +4,21 @@ from pathlib import Path +import yaml from click.testing import CliRunner from vouch import index_db from vouch.cli import cli -from vouch.onboarding import STARTER_CLAIM_ID, seed_starter_kb +from vouch.onboarding import ( + BRAIN_PAGE_ID, + BRAIN_PAGE_KINDS, + STARTER_CLAIM_ID, + TEMPLATES, + available_templates, + seed_company_brain_kb, + seed_starter_kb, +) +from vouch.page_kinds import load_page_kind_registry from vouch.storage import KBStore @@ -52,6 +62,86 @@ def test_init_command_seeds_searchable_starter_kb(tmp_path: Path) -> None: assert any(kind == "claim" and hid == STARTER_CLAIM_ID for kind, hid, _, _ in hits) +def test_company_brain_template_declares_page_kinds(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + seed = seed_company_brain_kb(store, approved_by="tester") + + assert seed.template == "company-brain" + assert seed.created_anything is True + + registry = load_page_kind_registry(store) + for kind in BRAIN_PAGE_KINDS: + assert registry.is_known(kind), kind + + required, schema, _ = registry.resolve("followup") + assert "due_at" in required + assert "followup_status" in required + assert schema["properties"]["due_at"] == {"type": "string"} + + # decision records and voice pages must cite their evidence + assert registry.resolve("decision-record")[2] is True + assert registry.resolve("voice")[2] is True + + # the seeded guide page is approved, cited, and searchable + page = store.get_page(BRAIN_PAGE_ID) + assert page.sources + assert page.status.value == "active" + + +def test_company_brain_template_preserves_existing_config(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + store.config_path.write_text( + "review:\n" + " approver_role: human\n" + "page_kinds:\n" + " followup:\n" + " required_fields: [custom]\n", + encoding="utf-8", + ) + + seed_company_brain_kb(store) + + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + assert loaded["review"]["approver_role"] == "human" + # a kind the operator already declared wins over the template + assert loaded["page_kinds"]["followup"]["required_fields"] == ["custom"] + # missing kinds are filled in + assert "contact" in loaded["page_kinds"] + assert "decision-record" in loaded["page_kinds"] + + +def test_company_brain_template_appends_when_no_page_kinds(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + store.config_path.write_text( + "# operator notes survive the template\nreview:\n approver_role: human\n", + encoding="utf-8", + ) + + seed_company_brain_kb(store) + + text = store.config_path.read_text(encoding="utf-8") + # no page_kinds key -> the block is appended; comments survive verbatim + assert text.startswith("# operator notes survive the template") + loaded = yaml.safe_load(text) + assert set(BRAIN_PAGE_KINDS) <= set(loaded["page_kinds"]) + assert loaded["review"]["approver_role"] == "human" + + +def test_company_brain_template_is_idempotent(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + + first = seed_company_brain_kb(store) + second = seed_company_brain_kb(store) + + assert first.created_anything is True + assert second.created_anything is False + + +def test_company_brain_registered_in_templates() -> None: + assert "company-brain" in TEMPLATES + assert "company-brain" in available_templates() + + def test_init_command_can_run_twice(tmp_path: Path) -> None: runner = CliRunner() From bf86584930b8ad49c622015bcaaaf2f56b52c3c0 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:26:40 +0900 Subject: [PATCH 02/13] feat(cli): wire vouch init --template to the onboarding template registry templates layer on top of the starter seed instead of replacing it, so an init'd kb always carries the walkthrough basics. click.Choice keeps the flag self-documenting and rejects unknown names before any i/o. --- src/vouch/cli.py | 27 +++++++++++++++++++++++++-- tests/test_onboarding.py | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f8b4a0c8..c44e5b7e 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -43,7 +43,12 @@ from .lifecycle import LifecycleError from .logging_config import configure_logging from .models import Proposal, ProposalKind, ProposalStatus -from .onboarding import seed_starter_kb +from .onboarding import ( + DEFAULT_TEMPLATE, + TEMPLATES, + available_templates, + seed_starter_kb, +) from .page_kinds import PageKindError, load_page_kind_registry from .proposals import ( EXPIRE_ACTOR, @@ -148,12 +153,22 @@ def cli() -> None: @cli.command() @click.option("--path", default=".", type=click.Path(file_okay=False), show_default=True) -def init(path: str) -> None: +@click.option( + "--template", + default=DEFAULT_TEMPLATE, + show_default=True, + type=click.Choice(available_templates()), + help="Seed preset applied on top of the starter KB.", +) +def init(path: str, template: str) -> None: """Initialise a .vouch/ knowledge base at PATH.""" root = Path(path).resolve() root.mkdir(parents=True, exist_ok=True) store = KBStore.init(root) seed = seed_starter_kb(store, approved_by=_whoami()) + template_result = None + if template != DEFAULT_TEMPLATE: + template_result = TEMPLATES[template](store, approved_by=_whoami()) health.rebuild_index(store) audit_mod.log_event(store.kb_dir, event="kb.init", actor=_whoami()) click.echo(f"Initialised KB at {store.kb_dir}") @@ -161,6 +176,14 @@ def init(path: str) -> None: click.echo(f"Seeded starter claim: {seed.claim_id}") else: click.echo("Starter claim already present.") + if template_result is not None: + if template_result.created_anything: + click.echo( + f"Applied template '{template_result.template}': " + f"{len(template_result.created)} item(s) created" + ) + else: + click.echo(f"Template '{template_result.template}' already applied.") click.echo("Next steps:") click.echo(" vouch status") click.echo(" vouch search agent") diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 427cc8fe..c0e5fa77 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -142,6 +142,31 @@ def test_company_brain_registered_in_templates() -> None: assert "company-brain" in available_templates() +def test_init_command_with_company_brain_template(tmp_path: Path) -> None: + result = CliRunner().invoke( + cli, ["init", "--path", str(tmp_path), "--template", "company-brain"] + ) + + assert result.exit_code == 0, result.output + assert "company-brain" in result.output + + store = KBStore(tmp_path) + registry = load_page_kind_registry(store) + assert registry.is_known("followup") + assert store.get_page(BRAIN_PAGE_ID).status.value == "active" + # the starter claim still seeds — templates add to the default, not replace + assert store.get_claim(STARTER_CLAIM_ID) + + +def test_init_command_rejects_unknown_template(tmp_path: Path) -> None: + result = CliRunner().invoke( + cli, ["init", "--path", str(tmp_path), "--template", "bogus"] + ) + + assert result.exit_code != 0 + assert "bogus" in result.output + + def test_init_command_can_run_twice(tmp_path: Path) -> None: runner = CliRunner() From 0f13579948d23ae95349b5063f32f2266ae008f2 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:28:44 +0900 Subject: [PATCH 03/13] feat(adapters): add ask/remember/record/followup slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the natural-language layer for a team-memory kb lives host-side as prompts: ask answers only with citations, remember registers the user's words as a source then proposes claims citing it, record files entity + typed-page pairs, followup files dated commitments. every flow terminates at kb_propose_* — none may call kb_approve. registered in the claude-code install manifest (T3) and the plugin skills array. --- .../claude-code/.claude/commands/vouch-ask.md | 24 +++++++++++++++++ .../.claude/commands/vouch-followup.md | 24 +++++++++++++++++ .../.claude/commands/vouch-record.md | 27 +++++++++++++++++++ .../.claude/commands/vouch-remember.md | 22 +++++++++++++++ adapters/claude-code/install.yaml | 10 +++++-- openclaw.plugin.json | 6 ++++- tests/test_install_adapter.py | 12 +++++++-- 7 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 adapters/claude-code/.claude/commands/vouch-ask.md create mode 100644 adapters/claude-code/.claude/commands/vouch-followup.md create mode 100644 adapters/claude-code/.claude/commands/vouch-record.md create mode 100644 adapters/claude-code/.claude/commands/vouch-remember.md diff --git a/adapters/claude-code/.claude/commands/vouch-ask.md b/adapters/claude-code/.claude/commands/vouch-ask.md new file mode 100644 index 00000000..f2a0d256 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-ask.md @@ -0,0 +1,24 @@ +--- +description: Answer a question from the vouch KB with citations, or say what's missing +--- + +# /vouch-ask + +Answer "$ARGUMENTS" using only reviewed knowledge from the vouch KB. Every +statement in the answer must carry a `[claim-id]` or `[source-id]` citation. + +Steps: + +1. Call `kb_search` with `query: "$ARGUMENTS"`, then `kb_context` on the same + query to assemble the working set. +2. If the results answer the question, write the answer citing every claim id + you relied on. Use `kb_read_page` for typed record detail (contacts, + project records, followups). +3. If the KB cannot support an answer, say so plainly and list the closest + claims found. Do not fill gaps from your own knowledge — an uncited answer + is worse than no answer. +4. Only when the user explicitly asks about in-flight knowledge, list + `kb_list_pending` items — each labeled `UNREVIEWED` — after the cited + answer, never mixed into it. + +Never call `kb_approve`. Never restate pending proposals as facts. diff --git a/adapters/claude-code/.claude/commands/vouch-followup.md b/adapters/claude-code/.claude/commands/vouch-followup.md new file mode 100644 index 00000000..4dfa911c --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-followup.md @@ -0,0 +1,24 @@ +--- +description: Propose a dated followup the vouch digest will surface until closed +--- + +# /vouch-followup + +File "$ARGUMENTS" as a `followup` page proposal — a commitment with a due +date that `vouch digest` lists until it's closed. Requires the company-brain +page kinds (`vouch init --template company-brain`). + +Steps: + +1. Extract what's owed, by whom, and when. If no due date is stated, ask — + an undated followup never surfaces. +2. Call `kb_propose_page` with `page_type: "followup"`, a short imperative + title, and `metadata`: `due_at` (ISO date), `followup_status: "open"`, + `owner` when known. Put context in the body; cite a source id if the + commitment came from a registered conversation or document. +3. To close or move one later: re-propose the same page (`slug_hint: `) with `followup_status: "done"` (or a new `due_at`) — status changes + go through the gate like any other edit. +4. Report the proposal id and the due date filed. + +Never call `kb_approve`. diff --git a/adapters/claude-code/.claude/commands/vouch-record.md b/adapters/claude-code/.claude/commands/vouch-record.md new file mode 100644 index 00000000..c5daeb29 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-record.md @@ -0,0 +1,27 @@ +--- +description: Propose a typed record (contact, org, project) into the vouch KB +--- + +# /vouch-record + +File "$ARGUMENTS" as a typed record: an entity plus a kind-validated page, +both as pending proposals. Requires the company-brain page kinds +(`vouch init --template company-brain`; check with `vouch schema list`). + +Steps: + +1. Decide the record shape: person -> entity type `person` + `contact` page + (frontmatter: `role`, optional `org`, `email`); organisation -> `company` + entity + `org` page; project -> `project` entity + `project-record` page + (frontmatter: `record_status`, optional `owner`). +2. Call `kb_search` first — if the entity already exists, propose only the + page update (pass the existing page id as `slug_hint`) instead of a + duplicate. +3. Call `kb_propose_entity`, then `kb_propose_page` with `page_type` set to + the record kind and the frontmatter in `metadata`. Cite a registered + source when the record distills one (register the conversation via + `kb_register_source` if the user is dictating facts). +4. Link ownership or membership with `kb_propose_relation` where it's clear. +5. Report every proposal id filed. + +Never call `kb_approve`. One record per invocation; ask before batching. diff --git a/adapters/claude-code/.claude/commands/vouch-remember.md b/adapters/claude-code/.claude/commands/vouch-remember.md new file mode 100644 index 00000000..785e1c73 --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-remember.md @@ -0,0 +1,22 @@ +--- +description: File something the user wants remembered as a cited, review-gated proposal +--- + +# /vouch-remember + +Turn "$ARGUMENTS" into durable KB knowledge — proposed, never self-approved. + +Steps: + +1. Call `kb_register_source` with the user's exact wording as `content` + (`source_type: "message"`, a short descriptive `title`). Sources are + evidence intake; registering one writes no knowledge. +2. Distill the durable fact(s) into one `kb_propose_claim` each, citing the + registered source id in `evidence`. Keep each claim one sentence, + present tense, self-contained. +3. If the fact is about a person, org, or project the KB doesn't know yet, + also file `kb_propose_entity` (and `kb_propose_relation` to link it). +4. Report the proposal id(s) and remind the user: pending items are invisible + to retrieval until a human runs `vouch approve ` or `vouch review`. + +Never call `kb_approve` — the human at the gate decides what the KB believes. diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml index a1775ef0..75084244 100644 --- a/adapters/claude-code/install.yaml +++ b/adapters/claude-code/install.yaml @@ -2,8 +2,10 @@ # # T1 = MCP wire (project-local `.mcp.json` picked up by `claude` on launch). # T2 = CLAUDE.md fenced snippet (idempotent append, see install_adapter._install_fenced). -# T3 = four custom slash commands (`/vouch-recall`, `/vouch-status`, -# `/vouch-resolve-issue`, `/vouch-propose-from-pr`). +# T3 = custom slash commands (`/vouch-recall`, `/vouch-status`, +# `/vouch-resolve-issue`, `/vouch-propose-from-pr`, plus the +# company-brain set: `/vouch-ask`, `/vouch-remember`, `/vouch-record`, +# `/vouch-followup`). # T4 = `.claude/settings.json`: SessionStart (kb status + capture review banner + # recall digest of approved knowledge), PostToolUse (capture observe), # SessionEnd (capture finalize), plus read-only kb_* auto-allow. @@ -22,5 +24,9 @@ tiers: - { src: .claude/commands/vouch-status.md, dst: .claude/commands/vouch-status.md } - { src: .claude/commands/vouch-resolve-issue.md, dst: .claude/commands/vouch-resolve-issue.md } - { src: .claude/commands/vouch-propose-from-pr.md, dst: .claude/commands/vouch-propose-from-pr.md } + - { src: .claude/commands/vouch-ask.md, dst: .claude/commands/vouch-ask.md } + - { src: .claude/commands/vouch-remember.md, dst: .claude/commands/vouch-remember.md } + - { src: .claude/commands/vouch-record.md, dst: .claude/commands/vouch-record.md } + - { src: .claude/commands/vouch-followup.md, dst: .claude/commands/vouch-followup.md } T4: - { src: .claude/settings.json, dst: .claude/settings.json, json_merge: true } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index a79fa472..d359783b 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -38,7 +38,11 @@ "adapters/claude-code/.claude/commands/vouch-recall.md", "adapters/claude-code/.claude/commands/vouch-status.md", "adapters/claude-code/.claude/commands/vouch-resolve-issue.md", - "adapters/claude-code/.claude/commands/vouch-propose-from-pr.md" + "adapters/claude-code/.claude/commands/vouch-propose-from-pr.md", + "adapters/claude-code/.claude/commands/vouch-ask.md", + "adapters/claude-code/.claude/commands/vouch-remember.md", + "adapters/claude-code/.claude/commands/vouch-record.md", + "adapters/claude-code/.claude/commands/vouch-followup.md" ], "shared_deps": [ "adapters/claude-code/CLAUDE.md.snippet", diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index e299e396..f4027560 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -90,9 +90,13 @@ def test_install_claude_code_t4_writes_all_tiers(tmp_path: Path) -> None: assert (cmd_dir / "vouch-status.md").is_file() assert (cmd_dir / "vouch-resolve-issue.md").is_file() assert (cmd_dir / "vouch-propose-from-pr.md").is_file() + assert (cmd_dir / "vouch-ask.md").is_file() + assert (cmd_dir / "vouch-remember.md").is_file() + assert (cmd_dir / "vouch-record.md").is_file() + assert (cmd_dir / "vouch-followup.md").is_file() assert (tmp_path / ".claude" / "settings.json").is_file() - # T1 .mcp.json + T2 CLAUDE.md + 4 T3 commands + T4 settings = 7 files. - assert len(result.written) == 7, result.written + # T1 .mcp.json + T2 CLAUDE.md + 8 T3 commands + T4 settings = 11 files. + assert len(result.written) == 11, result.written def test_install_claude_code_is_idempotent(tmp_path: Path) -> None: @@ -109,6 +113,10 @@ def test_install_claude_code_is_idempotent(tmp_path: Path) -> None: ".claude/commands/vouch-status.md", ".claude/commands/vouch-resolve-issue.md", ".claude/commands/vouch-propose-from-pr.md", + ".claude/commands/vouch-ask.md", + ".claude/commands/vouch-remember.md", + ".claude/commands/vouch-record.md", + ".claude/commands/vouch-followup.md", ".claude/settings.json", } From bfc242a4e8d1c31cad8dc2f2b644f4e2d49b0fa7 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:31:06 +0900 Subject: [PATCH 04/13] feat(pages): frontmatter filters on kb.list_pages across mcp/jsonl/cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typed record kinds put their structure in page frontmatter; this adds one shared filter vocabulary over it — kind equality, field equality, and inclusive ordered bounds (numbers, iso dates) — as a viewport over store.list_pages(). deliberately not a query language: anything richer belongs in the caller. same kb.list_pages method, new optional params, plus a human mirror: vouch pages --kind followup --before due_at=... --- src/vouch/cli.py | 58 +++++++++++++++++ src/vouch/jsonl_server.py | 12 +++- src/vouch/page_filters.py | 82 +++++++++++++++++++++++ src/vouch/server.py | 27 +++++++- tests/test_page_filters.py | 130 +++++++++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 src/vouch/page_filters.py create mode 100644 tests/test_page_filters.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index c44e5b7e..ccf410f0 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -49,6 +49,7 @@ available_templates, seed_starter_kb, ) +from .page_filters import filter_pages, parse_kv from .page_kinds import PageKindError, load_page_kind_registry from .proposals import ( EXPIRE_ACTOR, @@ -681,6 +682,63 @@ def metrics( ) +# --- pages ------------------------------------------------------------------ + + +@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", + help="Frontmatter equality filter (repeatable).", +) +@click.option( + "--before", multiple=True, metavar="K=V", + help="Inclusive upper bound on a frontmatter field (dates/numbers).", +) +@click.option( + "--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.") +def pages_cmd( + kind: str | None, + meta: tuple[str, ...], + before: tuple[str, ...], + after: tuple[str, ...], + as_json: bool, +) -> None: + """List pages, optionally filtered by kind and frontmatter. + + Examples: `vouch pages --kind followup --meta followup_status=open + --before due_at=2026-07-10` lists open followups due by july 10. + """ + store = _load_store() + try: + equals, lo, hi = parse_kv(meta), parse_kv(after), parse_kv(before) + 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, + ) + if as_json: + _emit_json( + [ + { + "id": p.id, "title": p.title, "type": p.type, + "tags": p.tags, "metadata": p.metadata, + } + for p in hits + ] + ) + return + for p in hits: + extras = " ".join(f"{k}={v}" for k, v in sorted(p.metadata.items())) + suffix = f" ({extras})" if extras else "" + click.echo(f"{p.id} [{p.type}] {p.title}{suffix}") + if not hits: + click.echo("no matching pages") + + # --- proposals ------------------------------------------------------------ diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5f42e16b..761a2bb2 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -38,6 +38,7 @@ from .context import build_context_pack from .logging_config import configure_logging from .models import ProposalStatus +from .page_filters import filter_pages from .proposals import ( EXPIRE_ACTOR, ProposalError, @@ -235,8 +236,15 @@ def _h_read_relation(p: dict) -> dict: return _store().get_relation(p["relation_id"]).model_dump(mode="json") -def _h_list_pages(_: dict) -> list[dict]: - return [p.model_dump(mode="json") for p in _store().list_pages()] +def _h_list_pages(p: dict) -> list[dict]: + pages = filter_pages( + _store().list_pages(), + kind=p.get("type"), + equals=p.get("meta"), + before=p.get("meta_before"), + after=p.get("meta_after"), + ) + return [pg.model_dump(mode="json") for pg in pages] def _h_list_claims(p: dict) -> list[dict]: diff --git a/src/vouch/page_filters.py b/src/vouch/page_filters.py new file mode 100644 index 00000000..068059d2 --- /dev/null +++ b/src/vouch/page_filters.py @@ -0,0 +1,82 @@ +"""Frontmatter-aware filtering for page listings. + +Typed record kinds (see the company-brain template in `onboarding.py`) put +their structure in `Page.metadata` — `due_at` on a followup, `record_status` +on a project record. This module gives every listing surface one shared, +deliberately small filter vocabulary over that frontmatter: kind equality, +per-field equality, and inclusive ordered bounds. Anything richer belongs in +the caller — this is a viewport over `store.list_pages()`, not a query +language, and the yaml files stay the only source of truth. + +Ordered comparisons try numbers first and fall back to string comparison, +which orders ISO-8601 dates correctly. A page missing a filtered field never +matches: filters select records that positively satisfy the predicate. +""" + +from __future__ import annotations + +from typing import Any + +from .models import Page + + +def filter_pages( + pages: list[Page], + *, + kind: str | None = None, + equals: dict[str, str] | None = None, + before: dict[str, str] | None = None, + after: dict[str, str] | None = None, +) -> list[Page]: + """Return the pages matching every given predicate. + + kind: exact match on `Page.type`. + equals: frontmatter field == value (string-compared). + before / after: inclusive bounds — value <= / >= the given bound. + """ + out: list[Page] = [] + for page in pages: + if kind is not None and page.type != kind: + continue + meta = page.metadata + if equals and not all(_eq(meta.get(k), v) for k, v in equals.items()): + continue + if before and not all(_lte(meta.get(k), v) for k, v in before.items()): + continue + if after and not all(_lte(v, meta.get(k)) for k, v in after.items()): + continue + out.append(page) + return out + + +def parse_kv(pairs: tuple[str, ...] | list[str]) -> dict[str, str]: + """Parse repeated ``key=value`` CLI arguments; raise ValueError on malformed.""" + parsed: dict[str, str] = {} + for pair in pairs: + key, sep, value = pair.partition("=") + if not sep or not key: + raise ValueError(f"expected key=value, got {pair!r}") + parsed[key] = value + return parsed + + +def _eq(value: Any, bound: str) -> bool: + if value is None: + return False + if isinstance(value, str): + return value == bound + return str(value) == bound + + +def _lte(value: Any, bound: Any) -> bool: + """value <= bound; numeric when both sides parse as numbers, else string. + + String comparison orders ISO-8601 timestamps correctly, which is what + date-bearing frontmatter (``due_at``) uses. + """ + if value is None or bound is None: + return False + try: + return float(value) <= float(bound) + except (TypeError, ValueError): + return str(value) <= str(bound) diff --git a/src/vouch/server.py b/src/vouch/server.py index 81fa5c23..64c3c007 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -29,6 +29,7 @@ from .context import build_context_pack from .logging_config import configure_logging from .models import ProposalStatus +from .page_filters import filter_pages from .proposals import ( EXPIRE_ACTOR, ProposalError, @@ -284,10 +285,30 @@ def kb_read_relation(relation_id: str) -> dict[str, Any]: @mcp.tool() -def kb_list_pages() -> list[dict[str, Any]]: +def kb_list_pages( + *, + type: str | None = None, + meta: dict[str, str] | None = None, + meta_before: dict[str, str] | None = None, + meta_after: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + """List pages, optionally filtered by kind and frontmatter. + + type: page kind (built-in or config-declared, e.g. "followup"). + meta: frontmatter equality, e.g. {"followup_status": "open"}. + meta_before / meta_after: inclusive bounds — numbers or ISO dates, + e.g. meta_before={"due_at": "2026-07-10"} for followups due by then. + """ + pages = filter_pages( + _store().list_pages(), + kind=type, + equals=meta, + before=meta_before, + after=meta_after, + ) return [ - {"id": p.id, "title": p.title, "type": p.type, "tags": p.tags} - for p in _store().list_pages() + {"id": p.id, "title": p.title, "type": p.type, "tags": p.tags, "metadata": p.metadata} + for p in pages ] diff --git a/tests/test_page_filters.py b/tests/test_page_filters.py new file mode 100644 index 00000000..216cda8d --- /dev/null +++ b/tests/test_page_filters.py @@ -0,0 +1,130 @@ +"""Frontmatter filters on page listings (page_filters + the three surfaces).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch.cli import cli +from vouch.models import Page, PageStatus +from vouch.page_filters import filter_pages, parse_kv +from vouch.storage import KBStore + + +def _page(pid: str, *, kind: str = "concept", **meta: object) -> Page: + return Page( + id=pid, + title=pid.replace("-", " "), + type=kind, + status=PageStatus.ACTIVE, + metadata=dict(meta), + ) + + +@pytest.fixture +def followups() -> list[Page]: + return [ + _page( + "ping-alice-example", kind="followup", + due_at="2026-07-01", followup_status="open", owner="bob-example", + ), + _page( + "renew-acme-example", kind="followup", + due_at="2026-07-20", followup_status="open", + ), + _page( + "ship-report", kind="followup", + due_at="2026-06-01", followup_status="done", + ), + _page("acme-example", kind="org", website="https://acme.example"), + ] + + +def test_filter_by_kind(followups: list[Page]) -> None: + hits = filter_pages(followups, kind="followup") + assert [p.id for p in hits] == ["ping-alice-example", "renew-acme-example", "ship-report"] + + +def test_filter_equality_and_missing_field_excludes(followups: list[Page]) -> None: + hits = filter_pages(followups, kind="followup", equals={"owner": "bob-example"}) + assert [p.id for p in hits] == ["ping-alice-example"] + # a page without the field never matches + assert filter_pages(followups, equals={"nonexistent": "x"}) == [] + + +def test_filter_date_bounds_are_inclusive(followups: list[Page]) -> None: + due = filter_pages( + followups, kind="followup", + equals={"followup_status": "open"}, before={"due_at": "2026-07-01"}, + ) + assert [p.id for p in due] == ["ping-alice-example"] + + upcoming = filter_pages(followups, kind="followup", after={"due_at": "2026-07-01"}) + assert [p.id for p in upcoming] == ["ping-alice-example", "renew-acme-example"] + + +def test_filter_numeric_bounds() -> None: + pages = [_page(f"p{i}", kind="project-record", record_status="active", budget=i) for i in (5, 30, 200)] + hits = filter_pages(pages, before={"budget": "30"}) + assert [p.id for p in hits] == ["p5", "p30"] + + +def test_parse_kv() -> None: + assert parse_kv(("a=1", "b=x=y")) == {"a": "1", "b": "x=y"} + with pytest.raises(ValueError): + parse_kv(("noequals",)) + + +def _seed_store(root: Path, pages: list[Page]) -> KBStore: + store = KBStore.init(root) + for p in pages: + store.put_page(p) + return store + + +def test_jsonl_list_pages_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page]) -> None: + _seed_store(tmp_path, followups) + monkeypatch.chdir(tmp_path) + + from vouch.jsonl_server import HANDLERS + + rows = HANDLERS["kb.list_pages"]({"type": "followup", "meta": {"followup_status": "open"}}) + assert sorted(r["id"] for r in rows) == ["ping-alice-example", "renew-acme-example"] + + rows = HANDLERS["kb.list_pages"]({"meta_before": {"due_at": "2026-06-30"}}) + assert [r["id"] for r in rows] == ["ship-report"] + + # no params -> unchanged full listing + rows = HANDLERS["kb.list_pages"]({}) + assert len(rows) == 4 + + +def test_mcp_list_pages_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page]) -> None: + _seed_store(tmp_path, followups) + monkeypatch.chdir(tmp_path) + + from vouch import server + + rows = server.kb_list_pages(type="followup", meta={"followup_status": "open"}) + assert sorted(r["id"] for r in rows) == ["ping-alice-example", "renew-acme-example"] + assert all("metadata" in r for r in rows) + + +def test_cli_pages_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page]) -> None: + _seed_store(tmp_path, followups) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + cli, + ["pages", "--kind", "followup", "--meta", "followup_status=open", + "--before", "due_at=2026-07-31", "--json"], + ) + assert result.exit_code == 0, result.output + rows = json.loads(result.output) + assert sorted(r["id"] for r in rows) == ["ping-alice-example", "renew-acme-example"] + + bad = CliRunner().invoke(cli, ["pages", "--meta", "malformed"]) + assert bad.exit_code != 0 From f40ee77d75d198e08d7dc8661defa37ba14a52bf Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:38:00 +0900 Subject: [PATCH 05/13] feat(digest): read-only briefing of pending, decisions, stale, and due followups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit an operator returning to a kb reconstructs "what needs me" from several commands; vouch digest folds them into one glance — pending proposals oldest-first, decisions in the window (from the decided/ records), stale claims behind the count metrics already reports, and open followup pages whose due_at has arrived. strictly a viewport: composes list_proposals, list_claims, list_pages and metrics.compute, writes nothing, logs no audit event. registered at all four surfaces as kb.digest plus a /vouch-standup slash command that narrates it. --- .../.claude/commands/vouch-standup.md | 22 ++ adapters/claude-code/install.yaml | 3 +- openclaw.plugin.json | 3 +- src/vouch/capabilities.py | 1 + src/vouch/cli.py | 48 ++++ src/vouch/digest.py | 269 ++++++++++++++++++ src/vouch/jsonl_server.py | 13 + src/vouch/server.py | 24 ++ tests/test_digest.py | 179 ++++++++++++ tests/test_install_adapter.py | 6 +- tests/test_page_filters.py | 17 +- 11 files changed, 577 insertions(+), 8 deletions(-) create mode 100644 adapters/claude-code/.claude/commands/vouch-standup.md create mode 100644 src/vouch/digest.py create mode 100644 tests/test_digest.py diff --git a/adapters/claude-code/.claude/commands/vouch-standup.md b/adapters/claude-code/.claude/commands/vouch-standup.md new file mode 100644 index 00000000..f1561fdd --- /dev/null +++ b/adapters/claude-code/.claude/commands/vouch-standup.md @@ -0,0 +1,22 @@ +--- +description: Narrate the vouch digest — pending reviews, decisions, stale claims, due followups +--- + +# /vouch-standup + +Give the user a morning briefing from the KB, then get out of the way. + +Steps: + +1. Call the `kb_digest` MCP tool (or run `vouch digest --format markdown` + via Bash if MCP is unavailable). Default window is fine unless the user + names one. +2. Narrate it in four short lines, leading with counts: pending proposals + (oldest first — nudge `vouch review` if any), decisions since the window + start, stale claims worth a `kb_confirm` or supersede, and followups due + (with owners). +3. If a followup in the list is done, offer to file the close — a + `kb_propose_page` with the page's id as `slug_hint` and + `followup_status: "done"` — but only on explicit confirmation. + +Read-only otherwise. Never call `kb_approve`; reviewing is the human's job. diff --git a/adapters/claude-code/install.yaml b/adapters/claude-code/install.yaml index 75084244..6e4f0f99 100644 --- a/adapters/claude-code/install.yaml +++ b/adapters/claude-code/install.yaml @@ -5,7 +5,7 @@ # T3 = custom slash commands (`/vouch-recall`, `/vouch-status`, # `/vouch-resolve-issue`, `/vouch-propose-from-pr`, plus the # company-brain set: `/vouch-ask`, `/vouch-remember`, `/vouch-record`, -# `/vouch-followup`). +# `/vouch-followup`, `/vouch-standup`). # T4 = `.claude/settings.json`: SessionStart (kb status + capture review banner + # recall digest of approved knowledge), PostToolUse (capture observe), # SessionEnd (capture finalize), plus read-only kb_* auto-allow. @@ -28,5 +28,6 @@ tiers: - { src: .claude/commands/vouch-remember.md, dst: .claude/commands/vouch-remember.md } - { src: .claude/commands/vouch-record.md, dst: .claude/commands/vouch-record.md } - { src: .claude/commands/vouch-followup.md, dst: .claude/commands/vouch-followup.md } + - { src: .claude/commands/vouch-standup.md, dst: .claude/commands/vouch-standup.md } T4: - { src: .claude/settings.json, dst: .claude/settings.json, json_merge: true } diff --git a/openclaw.plugin.json b/openclaw.plugin.json index d359783b..8c674952 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -42,7 +42,8 @@ "adapters/claude-code/.claude/commands/vouch-ask.md", "adapters/claude-code/.claude/commands/vouch-remember.md", "adapters/claude-code/.claude/commands/vouch-record.md", - "adapters/claude-code/.claude/commands/vouch-followup.md" + "adapters/claude-code/.claude/commands/vouch-followup.md", + "adapters/claude-code/.claude/commands/vouch-standup.md" ], "shared_deps": [ "adapters/claude-code/CLAUDE.md.snippet", diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 2efc39a3..860bf084 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -18,6 +18,7 @@ "kb.capabilities", "kb.status", "kb.stats", + "kb.digest", "kb.search", "kb.neighbors", "kb.context", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index ccf410f0..24318e7a 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -24,6 +24,7 @@ from . import __version__, bundle, health, volunteer_context from . import audit as audit_mod from . import capture as capture_mod +from . import digest as digest_mod from . import install_adapter as install_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -288,6 +289,53 @@ def stats(days: int, as_json: bool) -> None: _echo(f" invalid: {cites['invalid_claim']}, broken: {cites['broken_citation']}") +@cli.command(name="digest") +@click.option( + "--since", + default=digest_mod.DEFAULT_SINCE_SPEC, + show_default=True, + help="Window: a duration (7d, 12h), an ISO date, or 'all'.", +) +@click.option( + "--stale-days", + default=metrics_mod.DEFAULT_STALE_DAYS, + show_default=True, + type=int, + help="Freshness threshold for the stale-claims section.", +) +@click.option( + "--limit", + default=digest_mod.DEFAULT_LIMIT, + show_default=True, + type=int, + help="Cap per section (pending, decisions, stale, followups).", +) +@click.option( + "--format", + "fmt", + default="text", + show_default=True, + type=click.Choice(["text", "json", "markdown"]), +) +def digest_cmd(since: str, stale_days: int, limit: int, fmt: str) -> None: + """Read-only briefing: pending queue, recent decisions, stale claims, + followups due. Writes nothing — safe to run from cron.""" + store = _load_store() + try: + since_dt = metrics_mod.parse_since(since) + 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, + ) + if fmt == "json": + _emit_json(d.to_dict()) + elif fmt == "markdown": + click.echo(digest_mod.render_markdown(d)) + else: + click.echo(digest_mod.render_text(d)) + + def _findings_json(report) -> list[dict[str, Any]]: return [ { diff --git a/src/vouch/digest.py b/src/vouch/digest.py new file mode 100644 index 00000000..e5c279ad --- /dev/null +++ b/src/vouch/digest.py @@ -0,0 +1,269 @@ +"""Read-only reviewer briefing: what needs the human at the gate right now. + +An operator returning to a KB after a day otherwise reconstructs "what needs +me" from several commands: `vouch pending` for the backlog, `vouch metrics` +for rates, a walk through `decided/` for what happened, `vouch pages` for +due followups. This folds them into one glance, as named oldest-first lists +rather than aggregate gauges. + +Strictly a viewport: it composes `store.list_proposals`, `store.list_claims`, +`store.list_pages` and `metrics.compute`, writes nothing, logs no audit +event, and never touches a proposal — so there is nothing here for the +review gate to gate. Run it from cron and pipe `--format markdown` wherever +the team reads its mornings. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any + +from .metrics import DEFAULT_STALE_DAYS, compute +from .models import ClaimStatus, ProposalStatus +from .page_filters import filter_pages +from .storage import KBStore + +DEFAULT_LIMIT = 10 +DEFAULT_SINCE_SPEC = "7d" + +# Followup states that no longer need attention. Everything else is open by +# definition — an unknown status should surface, not hide. +_CLOSED_FOLLOWUP_STATUSES = {"done", "dropped"} + +_RETIRED_CLAIM_STATUSES = { + ClaimStatus.SUPERSEDED, + ClaimStatus.ARCHIVED, + ClaimStatus.REDACTED, +} + + +@dataclass(frozen=True) +class PendingRow: + id: str + kind: str + proposed_by: str + proposed_at: str + age_days: int + + +@dataclass(frozen=True) +class DecisionRow: + id: str + kind: str + decision: str + decided_by: str + decided_at: str + title: str + + +@dataclass(frozen=True) +class StaleRow: + id: str + text: str + anchor: str + age_days: int + + +@dataclass(frozen=True) +class FollowupRow: + id: str + title: str + due_at: str + owner: str | None + followup_status: str + + +@dataclass(frozen=True) +class Digest: + """Stable `to_dict()` schema — the `--format json` contract.""" + + generated_at: str + since: str | None + stale_after_days: int + limit: int + pending_total: int + pending: list[PendingRow] = field(default_factory=list) + decisions: list[DecisionRow] = field(default_factory=list) + stale_claims: list[StaleRow] = field(default_factory=list) + stale_total: int = 0 + followups_due: list[FollowupRow] = field(default_factory=list) + citation_coverage: float | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _as_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) + + +def _payload_title(payload: dict[str, Any]) -> str: + for key in ("title", "text", "name", "id"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value if len(value) <= 72 else value[:69] + "..." + return "(untitled)" + + +def build( + store: KBStore, + *, + since: datetime | None = None, + stale_after_days: int = DEFAULT_STALE_DAYS, + limit: int = DEFAULT_LIMIT, + now: datetime | None = None, +) -> Digest: + """Compose the briefing. Read-only by construction.""" + now = _as_utc(now) or datetime.now(UTC) + since = _as_utc(since) + + pending_all = sorted( + store.list_proposals(ProposalStatus.PENDING), + key=lambda p: _as_utc(p.proposed_at) or now, + ) + pending_rows = [ + PendingRow( + id=p.id, + kind=p.kind.value, + proposed_by=p.proposed_by, + proposed_at=(_as_utc(p.proposed_at) or now).isoformat(timespec="seconds"), + age_days=max(0, (now - (_as_utc(p.proposed_at) or now)).days), + ) + for p in pending_all[:limit] + ] + + decided = [ + p + for status in (ProposalStatus.APPROVED, ProposalStatus.REJECTED) + for p in store.list_proposals(status) + if p.decided_at is not None + and (since is None or (_as_utc(p.decided_at) or now) >= since) + ] + decided.sort(key=lambda p: _as_utc(p.decided_at) or now, reverse=True) + decision_rows = [ + DecisionRow( + id=p.id, + kind=p.kind.value, + decision=p.status.value, + decided_by=p.decided_by or "(unknown)", + decided_at=(_as_utc(p.decided_at) or now).isoformat(timespec="seconds"), + title=_payload_title(p.payload), + ) + for p in decided[:limit] + ] + + # Same freshness anchor and thresholds as `vouch metrics` / `vouch lint`, + # rendered as the oldest-first list behind the count they report. + threshold = timedelta(days=stale_after_days) + stale: list[tuple[datetime, StaleRow]] = [] + for c in store.list_claims(): + if c.status in _RETIRED_CLAIM_STATUSES: + continue + anchor = _as_utc(c.last_confirmed_at or c.updated_at or c.created_at) + if anchor is not None and (now - anchor) > threshold: + text = c.text if len(c.text) <= 96 else c.text[:93] + "..." + stale.append( + ( + anchor, + StaleRow( + id=c.id, + text=text, + anchor=anchor.isoformat(timespec="seconds"), + age_days=(now - anchor).days, + ), + ) + ) + stale.sort(key=lambda pair: pair[0]) + stale_rows = [row for _, row in stale[:limit]] + + due_pages = filter_pages( + store.list_pages(), + kind="followup", + before={"due_at": now.date().isoformat()}, + ) + followup_rows = sorted( + ( + FollowupRow( + id=p.id, + title=p.title, + due_at=str(p.metadata.get("due_at", "")), + owner=(str(p.metadata["owner"]) if p.metadata.get("owner") else None), + followup_status=str(p.metadata.get("followup_status", "")), + ) + for p in due_pages + if str(p.metadata.get("followup_status", "")) not in _CLOSED_FOLLOWUP_STATUSES + ), + key=lambda r: r.due_at, + ) + + m = compute(store, since=since, stale_after_days=stale_after_days, now=now) + + return Digest( + generated_at=now.isoformat(timespec="seconds"), + since=since.isoformat(timespec="seconds") if since else None, + stale_after_days=stale_after_days, + limit=limit, + pending_total=len(pending_all), + pending=pending_rows, + decisions=decision_rows, + stale_claims=stale_rows, + stale_total=m.stale_claims, + followups_due=followup_rows, + citation_coverage=m.citation_coverage, + ) + + +def render_text(d: Digest) -> str: + lines = [f"digest @ {d.generated_at} (window since: {d.since or 'all'})", ""] + lines.append(f"pending awaiting review: {d.pending_total}") + for pr in d.pending: + lines.append(f" {pr.id} [{pr.kind}] by {pr.proposed_by} {pr.age_days}d old") + if d.pending_total > len(d.pending): + lines.append(f" ... and {d.pending_total - len(d.pending)} more (vouch pending)") + lines.append("") + lines.append(f"recent decisions: {len(d.decisions)}") + for dr in d.decisions: + lines.append(f" {dr.decision:<8} {dr.id} [{dr.kind}] {dr.title} by {dr.decided_by}") + lines.append("") + lines.append(f"stale claims (> {d.stale_after_days}d): {d.stale_total}") + for sr in d.stale_claims: + lines.append(f" {sr.id} {sr.age_days}d {sr.text}") + lines.append("") + lines.append(f"followups due: {len(d.followups_due)}") + for fr in d.followups_due: + owner = f" owner: {fr.owner}" if fr.owner else "" + lines.append(f" {fr.due_at} {fr.id} {fr.title}{owner}") + if d.citation_coverage is not None: + lines.append("") + lines.append(f"citation coverage: {d.citation_coverage:.0%}") + return "\n".join(lines) + + +def render_markdown(d: Digest) -> str: + lines = [f"# kb digest — {d.generated_at}", ""] + lines.append(f"## pending awaiting review ({d.pending_total})") + lines += [ + f"- `{r.id}` [{r.kind}] by {r.proposed_by}, {r.age_days}d old" for r in d.pending + ] or ["- none"] + lines.append("") + lines.append(f"## recent decisions ({len(d.decisions)})") + lines += [ + f"- **{r.decision}** `{r.id}` [{r.kind}] {r.title} — {r.decided_by}" + for r in d.decisions + ] or ["- none"] + lines.append("") + lines.append(f"## stale claims > {d.stale_after_days}d ({d.stale_total})") + lines += [f"- `{r.id}` {r.age_days}d: {r.text}" for r in d.stale_claims] or ["- none"] + lines.append("") + lines.append(f"## followups due ({len(d.followups_due)})") + lines += [ + f"- {r.due_at} `{r.id}` {r.title}" + (f" (owner: {r.owner})" if r.owner else "") + for r in d.followups_due + ] or ["- none"] + if d.citation_coverage is not None: + lines.append("") + lines.append(f"citation coverage: {d.citation_coverage:.0%}") + return "\n".join(lines) diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 761a2bb2..55059950 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -29,7 +29,9 @@ import yaml from . import audit, bundle, health, volunteer_context +from . import digest as digest_mod from . import lifecycle as life +from . import metrics as metrics_mod from . import salience as salience_mod from . import sessions as sess_mod from . import trust as trust_mod @@ -96,6 +98,16 @@ def _h_stats(p: dict) -> dict: return collect_stats(_store(), since_days=since) +def _h_digest(p: dict) -> dict: + d = digest_mod.build( + _store(), + since=metrics_mod.parse_since(str(p.get("since", digest_mod.DEFAULT_SINCE_SPEC))), + stale_after_days=int(p.get("stale_days", metrics_mod.DEFAULT_STALE_DAYS)), + limit=int(p.get("limit", digest_mod.DEFAULT_LIMIT)), + ) + return d.to_dict() + + def _h_search(p: dict) -> dict: from . import index_db from .scoping import filter_hits, scoped_fetch_limit, viewer_from @@ -685,6 +697,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.capabilities": _h_capabilities, "kb.status": _h_status, "kb.stats": _h_stats, + "kb.digest": _h_digest, "kb.search": _h_search, "kb.neighbors": _h_neighbors, "kb.context": _h_context, diff --git a/src/vouch/server.py b/src/vouch/server.py index 64c3c007..6095f002 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -20,7 +20,9 @@ from mcp.server.fastmcp import FastMCP from . import audit, bundle, health, volunteer_context +from . import digest as digest_mod from . import lifecycle as life +from . import metrics as metrics_mod from . import salience as salience_mod from . import sessions as sess_mod from . import trust as trust_mod @@ -93,6 +95,28 @@ def kb_stats(*, days: int = 30) -> dict[str, Any]: return collect_stats(_store(), since_days=since) +@mcp.tool() +def kb_digest( + *, + since: str = digest_mod.DEFAULT_SINCE_SPEC, + stale_days: int = metrics_mod.DEFAULT_STALE_DAYS, + limit: int = digest_mod.DEFAULT_LIMIT, +) -> dict[str, Any]: + """Read-only reviewer briefing: pending proposals oldest-first, recent + decisions, stale claims, and followups due. + + since: window spec — a duration ("7d", "12h"), an ISO date, or "all". + """ + store = _store() + d = digest_mod.build( + store, + since=metrics_mod.parse_since(since), + stale_after_days=stale_days, + limit=limit, + ) + return d.to_dict() + + # === read tools (unrestricted) ============================================ diff --git a/tests/test_digest.py b/tests/test_digest.py new file mode 100644 index 00000000..8bb55205 --- /dev/null +++ b/tests/test_digest.py @@ -0,0 +1,179 @@ +"""`vouch digest` / kb.digest — the read-only reviewer briefing.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import digest as digest_mod +from vouch.cli import cli +from vouch.models import ( + Claim, + ClaimStatus, + ClaimType, + Page, + PageStatus, + Proposal, + ProposalKind, +) +from vouch.proposals import approve, propose_claim, reject +from vouch.storage import KBStore + +NOW = datetime(2026, 7, 4, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + src = s.put_source(b"evidence body", title="src", locator="test:src", source_type="message") + + # two pending proposals with distinct ages (older one first in the digest); + # written directly because put_proposal is create-only and the ages must + # be deterministic relative to NOW + s.put_proposal( + Proposal( + id="pending-old", kind=ProposalKind.CLAIM, proposed_by="agent-a", + proposed_at=NOW - timedelta(days=5), + payload={"text": "older pending fact", "evidence": [src.id]}, + ) + ) + s.put_proposal( + Proposal( + id="pending-fresh", kind=ProposalKind.CLAIM, proposed_by="agent-b", + proposed_at=NOW - timedelta(days=1), + payload={"text": "newer pending fact", "evidence": [src.id]}, + ) + ) + + # one approval and one rejection inside the window + approved = propose_claim( + s, text="approved fact", evidence=[src.id], proposed_by="agent-a", + ) + approve(s, approved.id, approved_by="reviewer") + rejected = propose_claim( + s, text="rejected fact", evidence=[src.id], proposed_by="agent-a", + ) + reject(s, rejected.id, rejected_by="reviewer", reason="test rejection") + + # a stale approved claim (anchor far past the threshold) and a fresh one + s.put_claim( + Claim( + id="stale-claim", text="an old fact nobody confirmed", + type=ClaimType.FACT, status=ClaimStatus.STABLE, + evidence=[src.id], approved_by="reviewer", + created_at=NOW - timedelta(days=400), + updated_at=NOW - timedelta(days=400), + ) + ) + s.put_claim( + Claim( + id="fresh-claim", text="a recent fact", + type=ClaimType.FACT, status=ClaimStatus.STABLE, + evidence=[src.id], approved_by="reviewer", + created_at=NOW - timedelta(days=1), + updated_at=NOW - timedelta(days=1), + ) + ) + + # followups: one open+due, one open+future, one closed+due + def followup(pid: str, due: str, status: str) -> Page: + return Page( + id=pid, title=pid, type="followup", status=PageStatus.ACTIVE, + metadata={"due_at": due, "followup_status": status, "owner": "alice-example"}, + ) + + s.put_page(followup("due-open", "2026-07-01", "open")) + s.put_page(followup("future-open", "2026-08-01", "open")) + s.put_page(followup("due-done", "2026-07-01", "done")) + return s + + +def test_build_sections(store: KBStore) -> None: + d = digest_mod.build(store, since=NOW - timedelta(days=7), now=NOW) + + # pending: oldest first, both listed + assert d.pending_total == 2 + assert [r.proposed_by for r in d.pending] == ["agent-a", "agent-b"] + assert d.pending[0].age_days == 5 + + # decisions: both inside the window, newest first, titled from payload + decisions = {r.decision for r in d.decisions} + assert decisions == {"approved", "rejected"} + assert any("approved fact" in r.title for r in d.decisions) + + # stale: only the old claim + assert [r.id for r in d.stale_claims] == ["stale-claim"] + assert d.stale_total == 1 + + # followups: open and due only + assert [r.id for r in d.followups_due] == ["due-open"] + assert d.followups_due[0].owner == "alice-example" + + assert d.citation_coverage is not None + + +def test_build_window_excludes_old_decisions(store: KBStore) -> None: + d = digest_mod.build(store, since=NOW + timedelta(days=1), now=NOW) + assert d.decisions == [] + # pending and followups are point-in-time state, not window-scoped + assert d.pending_total == 2 + assert [r.id for r in d.followups_due] == ["due-open"] + + +def test_build_limit_caps_sections(store: KBStore) -> None: + d = digest_mod.build(store, limit=1, now=NOW) + assert len(d.pending) == 1 + assert d.pending_total == 2 + assert len(d.decisions) <= 1 + + +def test_digest_is_read_only(store: KBStore) -> None: + audit_before = (store.kb_dir / "audit.log.jsonl").read_text(encoding="utf-8") + files_before = sorted(p.name for p in (store.kb_dir / "proposed").glob("*")) + + d = digest_mod.build(store, now=NOW) + digest_mod.render_text(d) + digest_mod.render_markdown(d) + json.dumps(d.to_dict()) + + assert (store.kb_dir / "audit.log.jsonl").read_text(encoding="utf-8") == audit_before + assert sorted(p.name for p in (store.kb_dir / "proposed").glob("*")) == files_before + + +def test_empty_kb_digest(tmp_path: Path) -> None: + s = KBStore.init(tmp_path) + d = digest_mod.build(s, now=NOW) + assert d.pending_total == 0 + assert d.followups_due == [] + assert "pending awaiting review: 0" in digest_mod.render_text(d) + + +def test_cli_digest_formats(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(store.root) + runner = CliRunner() + + as_json = runner.invoke(cli, ["digest", "--format", "json"]) + assert as_json.exit_code == 0, as_json.output + body = json.loads(as_json.output) + assert body["pending_total"] == 2 + assert "_meta" in body # trust stamp rides on dict-shaped CLI json + + md = runner.invoke(cli, ["digest", "--format", "markdown", "--since", "all"]) + assert md.exit_code == 0, md.output + assert "## pending awaiting review" in md.output + + bad = runner.invoke(cli, ["digest", "--since", "notaspec"]) + assert bad.exit_code != 0 + + +def test_jsonl_digest_handler(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(store.root) + from vouch.jsonl_server import HANDLERS + + body = HANDLERS["kb.digest"]({"since": "all", "limit": 5}) + assert body["pending_total"] == 2 + assert [r["id"] for r in body["followups_due"]] == ["due-open"] diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index f4027560..27c0ee2f 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -94,9 +94,10 @@ def test_install_claude_code_t4_writes_all_tiers(tmp_path: Path) -> None: assert (cmd_dir / "vouch-remember.md").is_file() assert (cmd_dir / "vouch-record.md").is_file() assert (cmd_dir / "vouch-followup.md").is_file() + assert (cmd_dir / "vouch-standup.md").is_file() assert (tmp_path / ".claude" / "settings.json").is_file() - # T1 .mcp.json + T2 CLAUDE.md + 8 T3 commands + T4 settings = 11 files. - assert len(result.written) == 11, result.written + # T1 .mcp.json + T2 CLAUDE.md + 9 T3 commands + T4 settings = 12 files. + assert len(result.written) == 12, result.written def test_install_claude_code_is_idempotent(tmp_path: Path) -> None: @@ -117,6 +118,7 @@ def test_install_claude_code_is_idempotent(tmp_path: Path) -> None: ".claude/commands/vouch-remember.md", ".claude/commands/vouch-record.md", ".claude/commands/vouch-followup.md", + ".claude/commands/vouch-standup.md", ".claude/settings.json", } diff --git a/tests/test_page_filters.py b/tests/test_page_filters.py index 216cda8d..58983b0b 100644 --- a/tests/test_page_filters.py +++ b/tests/test_page_filters.py @@ -67,7 +67,10 @@ def test_filter_date_bounds_are_inclusive(followups: list[Page]) -> None: def test_filter_numeric_bounds() -> None: - pages = [_page(f"p{i}", kind="project-record", record_status="active", budget=i) for i in (5, 30, 200)] + pages = [ + _page(f"p{i}", kind="project-record", record_status="active", budget=i) + for i in (5, 30, 200) + ] hits = filter_pages(pages, before={"budget": "30"}) assert [p.id for p in hits] == ["p5", "p30"] @@ -85,7 +88,9 @@ def _seed_store(root: Path, pages: list[Page]) -> KBStore: return store -def test_jsonl_list_pages_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page]) -> None: +def test_jsonl_list_pages_filters( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page], +) -> None: _seed_store(tmp_path, followups) monkeypatch.chdir(tmp_path) @@ -102,7 +107,9 @@ def test_jsonl_list_pages_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatc assert len(rows) == 4 -def test_mcp_list_pages_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page]) -> None: +def test_mcp_list_pages_filters( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page], +) -> None: _seed_store(tmp_path, followups) monkeypatch.chdir(tmp_path) @@ -113,7 +120,9 @@ def test_mcp_list_pages_filters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, assert all("metadata" in r for r in rows) -def test_cli_pages_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page]) -> None: +def test_cli_pages_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, followups: list[Page], +) -> None: _seed_store(tmp_path, followups) monkeypatch.chdir(tmp_path) From 5ef782b7b94c98298f9c8f844d083fb2f35242a3 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:41:35 +0900 Subject: [PATCH 06/13] fix(page-kinds): accept yaml date scalars where a schema says string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yaml parses a bare due_at: 2026-07-01 as a date object — from the cli --meta parser and from every frontmatter disk round-trip — so a type: string schema field either rejects the natural spelling at propose time or, worse, fails re-validation at approve time for a page that validated when proposed. treat date/datetime as string-compatible scalars; genuinely wrong types still fail. --- src/vouch/page_kinds.py | 9 +++++++-- tests/test_page_kinds.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/vouch/page_kinds.py b/src/vouch/page_kinds.py index 2f51c506..795fdeff 100644 --- a/src/vouch/page_kinds.py +++ b/src/vouch/page_kinds.py @@ -17,6 +17,7 @@ from __future__ import annotations +import datetime as _dt from typing import Any import yaml @@ -29,9 +30,13 @@ # JSON-Schema `type` keyword -> python types it accepts. `bool` is excluded # from the numeric types on purpose: in python `True` is an int, but a schema -# author asking for an integer never means a boolean. +# author asking for an integer never means a boolean. `string` accepts yaml's +# native date/datetime scalars: a bare `due_at: 2026-07-01` loads as a date +# object from CLI --meta parsing and from every frontmatter disk round-trip, +# so rejecting it would make string schemas unusable for date fields (and +# fail re-validation at approve time for pages that validated at propose). _JSON_TYPES: dict[str, tuple[type, ...]] = { - "string": (str,), + "string": (str, _dt.date, _dt.datetime), "number": (int, float), "integer": (int,), "boolean": (bool,), diff --git a/tests/test_page_kinds.py b/tests/test_page_kinds.py index 0418f7df..80ad3123 100644 --- a/tests/test_page_kinds.py +++ b/tests/test_page_kinds.py @@ -57,6 +57,46 @@ def test_undeclared_kind_is_rejected(store: KBStore) -> None: assert "unknown page kind" in str(exc.value) +def test_string_schema_accepts_yaml_date_scalars(store: KBStore) -> None: + """A bare `due_at: 2026-07-01` loads as datetime.date from CLI --meta + parsing and from every frontmatter disk round-trip; a `type: string` + schema must accept it or pages fail re-validation at approve time.""" + import datetime + + _declare_kinds( + store, + { + "followup": { + "required_fields": ["due_at"], + "frontmatter_schema": { + "type": "object", + "properties": {"due_at": {"type": "string"}}, + }, + } + }, + ) + pr = propose_page( + store, + title="ping alice-example", + body="", + page_type="followup", + metadata={"due_at": datetime.date(2026, 7, 1)}, + proposed_by="agent", + ) + page = approve(store, pr.id, approved_by="reviewer") + assert isinstance(page, Page) + # a genuinely wrong type still fails + with pytest.raises(ProposalError): + propose_page( + store, + title="bad", + body="", + page_type="followup", + metadata={"due_at": ["not", "a", "date"]}, + proposed_by="agent", + ) + + # --- acceptance: per-field error on a missing required field --------------- From 8588de77582028340c8658bbb0b51e1751b508cb Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:41:42 +0900 Subject: [PATCH 07/13] docs: company-brain guide + changelog entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one page covering the template, the record conventions, frontmatter queries, the digest cron loop, and the slash commands — plus the honest constraint that the review queue is the bottleneck by design. --- CHANGELOG.md | 21 +++++++++ docs/company-brain.md | 104 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs/company-brain.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 523eb817..651dc90d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- company-brain template: `vouch init --template company-brain` declares + typed record kinds (contact, org, project-record, meeting-notes, followup, + decision-record, voice) as `page_kinds` config and seeds a cited guide + page. operator-declared kinds always win; the merge is additive and + idempotent. see `docs/company-brain.md`. +- `vouch init --template ` dispatches the onboarding template registry + (starter stays the default; templates layer on top of it). +- frontmatter filters on `kb.list_pages` across mcp/jsonl/cli: kind equality, + field equality, and inclusive ordered bounds (numbers, iso dates), plus the + `vouch pages` human mirror. a viewport over `store.list_pages()`, not a + query language. +- `kb.digest` / `vouch digest`: read-only reviewer briefing — pending + proposals oldest-first, recent decisions, stale claims, followups due, and + citation coverage. `--format text|json|markdown`; writes nothing, so it is + safe to run from cron. +- five company-brain slash commands in the claude-code adapter (mirrored to + the plugin skills list): `/vouch-ask`, `/vouch-remember`, `/vouch-record`, + `/vouch-followup`, `/vouch-standup`. every flow terminates at + `kb_propose_*` — none may call `kb_approve`. + ## [1.1.0] — 2026-07-03 ### Added diff --git a/docs/company-brain.md b/docs/company-brain.md new file mode 100644 index 00000000..cf3e86b8 --- /dev/null +++ b/docs/company-brain.md @@ -0,0 +1,104 @@ +# Company brain: a team memory that goes through the review gate + +vouch can run your team's shared memory — who you work with, what each +project is doing, what you owe people, why you decided things — as typed, +reviewable records. Nothing about this is a separate mode: records are +ordinary pages with a declared kind, every write is a proposal a human +approves, and the audit log keeps the history. What this guide adds is a +set of conventions plus three small tools: an init template, frontmatter +filters, and a daily digest. + +## Setup + +```sh +vouch init --template company-brain +vouch install-mcp claude-code --tier T4 # slash commands + hooks for agents +``` + +The template declares seven page kinds in `.vouch/config.yaml` (inspect +them with `vouch schema list`): + +| kind | frontmatter | rule | +|---|---|---| +| `contact` | `role` (required), `org`, `email` | a person you work with | +| `org` | `website` | a company or organisation | +| `project-record` | `record_status` (required), `owner` | one project's living record | +| `meeting-notes` | `date`, `attendees` | notes from one meeting | +| `followup` | `due_at`, `followup_status` (required), `owner` | a dated commitment | +| `decision-record` | — | must cite claims/sources | +| `voice` | — | must cite the examples it distills | + +Kinds you've already declared are never overwritten, and the template is +idempotent. Extend the set by editing `config.yaml: page_kinds` — a schema +change is a reviewed diff like everything else. + +## The conventions + +**A person or org is an entity plus a typed page.** `kb_propose_entity` +(type `person` / `company`) pairs with a `contact` / `org` page carrying the +structured frontmatter. Relations (`owned_by`, `relates_to`) link people to +orgs and projects. + +**Work is a `project-record`; commitments are `followup` pages.** A followup +carries `due_at` and `followup_status: open`. Closing one is a page-edit +proposal (`slug_hint: `, `followup_status: done`) — status changes +go through the gate and land in the audit log like any other write. + +**Decisions and voice must cite.** The `decision-record` and `voice` kinds +set `required_citations: true`, so the gate rejects an uncited one at both +propose and approve time. + +## Querying records + +`kb.list_pages` (and the `vouch pages` mirror) filters on kind and +frontmatter — equality plus inclusive bounds that order numbers and ISO +dates correctly: + +```sh +vouch pages --kind followup --meta followup_status=open --before due_at=2026-07-10 +vouch pages --kind contact --meta org=acme-example --json +``` + +Deliberately not a query language: equality and bounds on declared fields, +nothing more. The yaml files stay the only source of truth. + +## The daily loop + +Agents file proposals all day (see the slash commands below). A human +reviews with `vouch review` or `vouch approve `. The briefing that +tells you where to spend attention: + +```sh +vouch digest # pending oldest-first, decisions, stale, followups due +vouch digest --format markdown --since 1d +``` + +It is read-only by construction — safe to run from cron and pipe wherever +the team reads its mornings: + +```cron +0 8 * * 1-5 cd /path/to/repo && vouch digest --format markdown > /tmp/kb-digest.md +``` + +`kb.digest` exposes the same briefing to agents over MCP/JSONL. + +## Slash commands (installed with the claude-code adapter) + +- `/vouch-ask` — answer from the KB with citations, or say what's missing. +- `/vouch-remember` — register the user's words as a source, propose claims + citing it. +- `/vouch-record` — file a contact/org/project as entity + typed page. +- `/vouch-followup` — file a dated commitment. +- `/vouch-standup` — narrate `vouch digest`. + +Every flow terminates at `kb_propose_*`; none may call `kb_approve`. The +human at the gate decides what the brain believes. + +## The honest constraint + +A team memory multiplies small writes, so the review queue is the +bottleneck by design: "remember X" is invisible to retrieval until someone +approves it. `vouch digest`, batch approval (`vouch approve a b c`), and +`vouch review` keep the queue moving; if you stop reviewing, the brain +stops learning — that's the deal, and it's what makes the answers +trustworthy. From 8d4e331ac2b5160a55bd893f00049ae0dfed7dd6 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:45:59 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat(sources):=20vouch=20source=20fetch?= =?UTF-8?q?=20=E2=80=94=20snapshot=20a=20url=20as=20a=20cited=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit evidence intake for web content: fetch the exact bytes once, register them content-addressed, and let claims cite the immutable snapshot id so the evidence a reviewer approved against survives the live page drifting. conservative defaults for the first outbound network call in the intake path: http/https only, every redirect hop re-validated, hosts must resolve to public addresses, 2mib body cap, raw bytes only. fetched_at and final_url recorded in source metadata; audit event source.fetch. never imports proposals — sources are evidence, not knowledge. --- src/vouch/cli.py | 42 +++++++++++ src/vouch/fetch.py | 167 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_fetch.py | 114 ++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 src/vouch/fetch.py create mode 100644 tests/test_fetch.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 24318e7a..6c6bd52d 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -25,6 +25,7 @@ from . import audit as audit_mod from . import capture as capture_mod from . import digest as digest_mod +from . import fetch as fetch_mod from . import install_adapter as install_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -1311,6 +1312,47 @@ def source_add(path: str, title: str | None, url: str | None, source_type: str) click.echo(src.id) +@source.command("fetch") +@click.argument("url") +@click.option("--title", default=None) +@click.option( + "--max-bytes", + default=fetch_mod.DEFAULT_MAX_BYTES, + show_default=True, + type=int, + help="Snapshot size cap.", +) +@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, ...], +) -> None: + """Fetch URL and register the exact bytes as a content-addressed Source. + + Claims cite the immutable snapshot id, so the evidence a reviewer + approved against survives the live page drifting. http/https only; + hosts must resolve to public addresses; redirects are re-validated. + """ + store = _load_store() + with _cli_errors(): + src = fetch_mod.snapshot_url( + store, + url, + title=title, + tags=list(tags) or None, + max_bytes=max_bytes, + timeout=timeout, + ) + audit_mod.log_event( + store.kb_dir, + event="source.fetch", + actor=_whoami(), + object_ids=[src.id], + data={"url": url}, + ) + click.echo(src.id) + + @source.command("verify") @click.option("--fail-on-issue", is_flag=True) def source_verify(fail_on_issue: bool) -> None: diff --git a/src/vouch/fetch.py b/src/vouch/fetch.py new file mode 100644 index 00000000..7a81be63 --- /dev/null +++ b/src/vouch/fetch.py @@ -0,0 +1,167 @@ +"""Snapshot a URL into the content-addressed source store. + +Evidence intake for web content: fetch the exact bytes once, register them +via `KBStore.put_source` (sha256 content addressing, deliberately below the +review gate like every other source), and let claims cite the immutable +snapshot id. The live page can drift; the evidence a reviewer approved +against cannot. + +Conservative by default, because this is the first outbound network call in +the intake path: + +- http/https only; every hop of a redirect chain is re-validated +- hosts must resolve to public addresses (loopback / private / link-local / + reserved ranges are refused), so a snapshot can't be pointed at the + operator's own network. resolution and connection are separate steps, so + a hostile DNS server could still rebind between them — the guard is a + seatbelt against lazy SSRF, not a substitute for network policy. +- bodies are capped (default 2 MiB) and stored as raw bytes; no decoding is + attempted here, so charset quirks stay a consumer concern. + +Never imports proposals or lifecycle: registering a source writes evidence, +not knowledge. +""" + +from __future__ import annotations + +import ipaddress +import socket +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import UTC, datetime + +from .models import Source +from .storage import KBStore + +DEFAULT_MAX_BYTES = 2 * 1024 * 1024 +DEFAULT_TIMEOUT = 20.0 +MAX_REDIRECTS = 5 + +_USER_AGENT = "vouch-source-fetch" + + +class FetchError(ValueError): + """Raised when a URL cannot be snapshotted safely.""" + + +@dataclass(frozen=True) +class FetchResult: + content: bytes + final_url: str + media_type: str + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + +_opener = urllib.request.build_opener(_NoRedirect) + + +def _addr_is_public(address: str) -> bool: + try: + ip = ipaddress.ip_address(address) + except ValueError: + return False + return not ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ) + + +def _check_url(url: str, *, allow_private: bool) -> None: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise FetchError(f"only http/https URLs can be snapshotted, got {url!r}") + host = parsed.hostname + if not host: + raise FetchError(f"URL has no host: {url!r}") + if allow_private: + return + try: + infos = socket.getaddrinfo(host, parsed.port or 0, proto=socket.IPPROTO_TCP) + except OSError as e: + raise FetchError(f"cannot resolve {host!r}: {e}") from e + for info in infos: + address = str(info[4][0]) + if not _addr_is_public(address): + raise FetchError( + f"{host!r} resolves to non-public address {address} — refusing to fetch" + ) + + +def fetch_url( + url: str, + *, + max_bytes: int = DEFAULT_MAX_BYTES, + timeout: float = DEFAULT_TIMEOUT, + allow_private: bool = False, +) -> FetchResult: + """Fetch `url` with redirect re-validation and a byte cap. + + allow_private skips the public-address check — used by tests that run a + loopback fixture server; production callers leave it False. + """ + current = url + for _ in range(MAX_REDIRECTS + 1): + _check_url(current, allow_private=allow_private) + req = urllib.request.Request(current, headers={"User-Agent": _USER_AGENT}) + try: + resp = _opener.open(req, timeout=timeout) + except urllib.error.HTTPError as e: + if e.code in (301, 302, 303, 307, 308): + target = e.headers.get("Location") + e.close() + if not target: + raise FetchError(f"redirect from {current!r} without Location") from e + current = urllib.parse.urljoin(current, target) + continue + raise FetchError(f"HTTP {e.code} fetching {current!r}") from e + except OSError as e: + raise FetchError(f"fetch failed for {current!r}: {e}") from e + with resp: + body = resp.read(max_bytes + 1) + if len(body) > max_bytes: + raise FetchError( + f"{current!r} exceeds the {max_bytes}-byte snapshot cap" + ) + content_type = resp.headers.get("Content-Type", "application/octet-stream") + media_type = content_type.split(";")[0].strip() or "application/octet-stream" + return FetchResult(content=body, final_url=current, media_type=media_type) + raise FetchError(f"too many redirects fetching {url!r}") + + +def snapshot_url( + store: KBStore, + url: str, + *, + title: str | None = None, + tags: list[str] | None = None, + max_bytes: int = DEFAULT_MAX_BYTES, + timeout: float = DEFAULT_TIMEOUT, + allow_private: bool = False, +) -> Source: + """Fetch `url` and register the exact bytes as a content-addressed Source.""" + result = fetch_url( + url, max_bytes=max_bytes, timeout=timeout, allow_private=allow_private, + ) + return store.put_source( + result.content, + title=title or url, + url=result.final_url, + locator=url, + source_type="url", + media_type=result.media_type, + tags=tags, + metadata={ + "fetched_at": datetime.now(UTC).isoformat(timespec="seconds"), + "final_url": result.final_url, + }, + ) diff --git a/tests/test_fetch.py b/tests/test_fetch.py new file mode 100644 index 00000000..aafaf4b6 --- /dev/null +++ b/tests/test_fetch.py @@ -0,0 +1,114 @@ +"""URL snapshot intake (`vouch source fetch` / fetch.py).""" + +from __future__ import annotations + +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch.cli import cli +from vouch.fetch import FetchError, _addr_is_public, fetch_url, snapshot_url +from vouch.storage import KBStore + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - stdlib naming + if self.path == "/page": + body = b"# hello from the fixture\n" + self.send_response(200) + self.send_header("Content-Type", "text/markdown; charset=utf-8") + self.end_headers() + self.wfile.write(body) + elif self.path == "/redirect": + self.send_response(302) + self.send_header("Location", "/page") + self.end_headers() + elif self.path == "/big": + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + self.wfile.write(b"x" * 4096) + elif self.path == "/loop": + self.send_response(302) + self.send_header("Location", "/loop") + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *args: object) -> None: # keep test output quiet + pass + + +@pytest.fixture +def http_url() -> str: + server = HTTPServer(("127.0.0.1", 0), _Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + + +def test_public_address_guard() -> None: + assert _addr_is_public("93.184.216.34") is True + for private in ("127.0.0.1", "10.0.0.8", "192.168.1.1", "169.254.1.1", "::1", "0.0.0.0"): + assert _addr_is_public(private) is False, private + + +def test_fetch_refuses_non_http_and_private_hosts(http_url: str) -> None: + with pytest.raises(FetchError): + fetch_url("file:///etc/hostname") + # loopback fixture is refused unless the test bypass is set + with pytest.raises(FetchError): + fetch_url(f"{http_url}/page") + + +def test_fetch_follows_redirects_and_caps_size(http_url: str) -> None: + result = fetch_url(f"{http_url}/redirect", allow_private=True) + assert result.content == b"# hello from the fixture\n" + assert result.media_type == "text/markdown" + assert result.final_url.endswith("/page") + + with pytest.raises(FetchError, match="snapshot cap"): + fetch_url(f"{http_url}/big", allow_private=True, max_bytes=1024) + + with pytest.raises(FetchError, match="redirect"): + fetch_url(f"{http_url}/loop", allow_private=True) + + +def test_snapshot_registers_content_addressed_source( + tmp_path: Path, http_url: str, +) -> None: + store = KBStore.init(tmp_path) + src = snapshot_url(store, f"{http_url}/page", allow_private=True) + + assert (store.kb_dir / "sources" / src.id / "content").read_bytes() == ( + b"# hello from the fixture\n" + ) + assert src.metadata["fetched_at"] + assert src.metadata["final_url"].endswith("/page") + + # idempotent: same bytes -> same id + again = snapshot_url(store, f"{http_url}/page", allow_private=True) + assert again.id == src.id + + +def test_cli_source_fetch_records_audit_event( + tmp_path: Path, http_url: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + KBStore.init(tmp_path) + monkeypatch.chdir(tmp_path) + # the CLI has no private-host bypass; point the guard off for the fixture + monkeypatch.setattr("vouch.fetch._check_url", lambda url, allow_private: None) + + result = CliRunner().invoke(cli, ["source", "fetch", f"{http_url}/page"]) + assert result.exit_code == 0, result.output + sid = result.output.strip().splitlines()[-1] + + store = KBStore(tmp_path) + assert (store.kb_dir / "sources" / sid / "content").exists() + audit_text = (store.kb_dir / "audit.log.jsonl").read_text(encoding="utf-8") + assert "source.fetch" in audit_text From fe3cb513d4ae572f48a8829000789fdd9ae2a198 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:47:33 +0900 Subject: [PATCH 09/13] feat(inbox): drop-folder importer that proposes, never approves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit each new .md/.txt file in the folder is registered as a content-addressed source and rolled into exactly one pending page proposal citing it — the capture.finalize shape with a dropped file as the trigger instead of a hook payload. content-hash seen-state sidecar makes re-runs idempotent; edited files re-propose. --watch is a bounded stdlib poll, no daemon. reads go through read_under_root, and an ast test pins that the module never imports an approve path. --- src/vouch/cli.py | 39 +++++++++++ src/vouch/inbox.py | 162 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_inbox.py | 125 ++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 src/vouch/inbox.py create mode 100644 tests/test_inbox.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 6c6bd52d..2027dbd4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -26,6 +26,7 @@ from . import capture as capture_mod from . import digest as digest_mod from . import fetch as fetch_mod +from . import inbox as inbox_mod from . import install_adapter as install_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -1371,6 +1372,44 @@ def source_verify(fail_on_issue: bool) -> None: sys.exit(1) +@cli.command(name="inbox") +@click.option( + "--dir", "directory", required=True, + type=click.Path(exists=True, file_okay=False), + help="Folder to scan (must live under the project root).", +) +@click.option("--watch", "watch_mode", is_flag=True, help="Poll instead of a single pass.") +@click.option( + "--poll-interval", + default=inbox_mod.DEFAULT_POLL_INTERVAL, + show_default=True, + type=float, +) +@click.option("--once", is_flag=True, help="Single tick even under --watch (test/ci bound).") +def inbox_cmd(directory: str, watch_mode: bool, poll_interval: float, once: bool) -> None: + """Scan an inbox folder: each new file becomes a registered source plus + one pending page proposal. Proposes only — a human still approves.""" + store = _load_store() + 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)}") + + try: + inbox_mod.watch( + store, path, poll_interval=poll_interval, on_result=_report, + ) + except KeyboardInterrupt: + pass + return + res = inbox_mod.scan(store, path) + click.echo(f"filed {len(res.proposed)} proposal(s); skipped {len(res.skipped)} file(s)") + for pid in res.proposed: + click.echo(f" {pid}") + + # --- lifecycle ------------------------------------------------------------ diff --git a/src/vouch/inbox.py b/src/vouch/inbox.py new file mode 100644 index 00000000..82ee365b --- /dev/null +++ b/src/vouch/inbox.py @@ -0,0 +1,162 @@ +"""Inbox folder: dropped files become pending proposals, never approved writes. + +Dropping a markdown or text file into a folder is the most natural way to +hand knowledge to a project — meeting notes, a design memo, a pasted +transcript. Each new file is registered as a content-addressed source and +rolled into exactly one PENDING page proposal citing it, the same +"background actor proposes, human approves" shape `capture.finalize()` +ships. Mechanical, no model in the loop, and no code path here calls +`proposals.approve()`. + +A content-hash-keyed seen-state sidecar (`.vouch/inbox-state.json`) makes +re-runs cheap and idempotent: an unchanged file is skipped, an edited file +re-proposes. The watch loop is a bounded stdlib poll (the `vault_sync` +precedent) — no daemon, no watchdog dependency. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +from .proposals import propose_page +from .storage import KBStore + +STATE_FILENAME = "inbox-state.json" +DEFAULT_MIN_CHARS = 40 +DEFAULT_EXTENSIONS = (".md", ".txt") +DEFAULT_POLL_INTERVAL = 2.0 +INBOX_ACTOR = "inbox" + + +@dataclass(frozen=True) +class InboxConfig: + enabled: bool = True + min_chars: int = DEFAULT_MIN_CHARS + extensions: tuple[str, ...] = DEFAULT_EXTENSIONS + + +@dataclass(frozen=True) +class ScanResult: + proposed: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + + @property + def proposed_anything(self) -> bool: + return bool(self.proposed) + + +def load_config(store: KBStore) -> InboxConfig: + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return InboxConfig() + if not isinstance(loaded, dict): + return InboxConfig() + raw = loaded.get("inbox") + if not isinstance(raw, dict): + return InboxConfig() + extensions = raw.get("extensions") + return InboxConfig( + enabled=bool(raw.get("enabled", True)), + min_chars=int(raw.get("min_chars", DEFAULT_MIN_CHARS)), + extensions=( + tuple(str(e) for e in extensions) + if isinstance(extensions, list) + else DEFAULT_EXTENSIONS + ), + ) + + +def _state_path(store: KBStore) -> Path: + return store.kb_dir / STATE_FILENAME + + +def _load_state(store: KBStore) -> dict[str, str]: + path = _state_path(store) + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return {str(k): str(v) for k, v in loaded.items()} if isinstance(loaded, dict) else {} + + +def _save_state(store: KBStore, state: dict[str, str]) -> None: + _state_path(store).write_text( + json.dumps(state, indent=1, sort_keys=True), encoding="utf-8" + ) + + +def scan(store: KBStore, directory: Path, *, config: InboxConfig | None = None) -> ScanResult: + """One pass: register + propose every new or changed eligible file.""" + cfg = config or load_config(store) + if not cfg.enabled: + return ScanResult() + + state = _load_state(store) + proposed: list[str] = [] + skipped: list[str] = [] + + for path in sorted(p for p in directory.iterdir() if p.is_file()): + if path.suffix.lower() not in cfg.extensions: + skipped.append(path.name) + continue + # read through the same containment + O_NOFOLLOW hardening the MCP + # register_source_from_path entrypoint uses + resolved, data = store.read_under_root(path) + text = data.decode("utf-8", errors="replace") + if len(text.strip()) < cfg.min_chars: + skipped.append(path.name) + continue + + source = store.put_source( + data, + title=path.name, + locator=str(resolved), + source_type="file", + media_type="text/markdown" if path.suffix.lower() == ".md" else "text/plain", + tags=["inbox"], + ) + if state.get(str(resolved)) == source.id: + skipped.append(path.name) + continue + + proposal = propose_page( + store, + title=f"inbox: {path.stem}", + body=text, + page_type="log", + source_ids=[source.id], + proposed_by=INBOX_ACTOR, + rationale=f"imported from inbox file {path.name}; distill durable facts into claims", + tags=["inbox"], + ) + state[str(resolved)] = source.id + proposed.append(proposal.id) + + _save_state(store, state) + return ScanResult(proposed=proposed, skipped=skipped) + + +def watch( + store: KBStore, + directory: Path, + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + iterations: int | None = None, + on_result: object = None, +) -> None: + """Bounded stdlib poll loop over `scan`. `iterations` bounds it for tests.""" + ticks = 0 + while iterations is None or ticks < iterations: + result = scan(store, directory) + if callable(on_result): + on_result(result) + ticks += 1 + if iterations is not None and ticks >= iterations: + break + time.sleep(poll_interval) diff --git a/tests/test_inbox.py b/tests/test_inbox.py new file mode 100644 index 00000000..d35e4902 --- /dev/null +++ b/tests/test_inbox.py @@ -0,0 +1,125 @@ +"""Inbox folder importer — proposes, never approves.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import inbox +from vouch.cli import cli +from vouch.models import ProposalStatus +from vouch.storage import KBStore + +DOC = "# meeting notes\n\nalice-example agreed to send the acme-example draft by friday.\n" + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + (tmp_path / "inbox").mkdir() + return s + + +def _drop(store: KBStore, name: str, text: str = DOC) -> Path: + path = store.root / "inbox" / name + path.write_text(text, encoding="utf-8") + return path + + +def test_scan_proposes_one_pending_page_per_file(store: KBStore) -> None: + _drop(store, "notes.md") + _drop(store, "memo.txt") + + result = inbox.scan(store, store.root / "inbox") + + assert len(result.proposed) == 2 + pending = store.list_proposals(ProposalStatus.PENDING) + assert {p.id for p in pending} == set(result.proposed) + # every proposal cites the registered content-addressed source + sources = {s.id for s in store.list_sources()} + for p in pending: + assert set(p.payload.get("sources", [])) <= sources + assert p.payload.get("sources"), p.id + assert p.proposed_by == "inbox" + # nothing was approved: no durable pages beyond the starter seed + assert all(pg.tags != ["inbox"] for pg in store.list_pages()) + + +def test_scan_seen_state_dedups_and_rescans_changed_files(store: KBStore) -> None: + path = _drop(store, "notes.md") + + first = inbox.scan(store, store.root / "inbox") + second = inbox.scan(store, store.root / "inbox") + assert len(first.proposed) == 1 + assert second.proposed == [] + + path.write_text(DOC + "\nnew paragraph with more substance to it.\n", encoding="utf-8") + third = inbox.scan(store, store.root / "inbox") + assert len(third.proposed) == 1 + + +def test_scan_skips_short_files_and_foreign_extensions(store: KBStore) -> None: + _drop(store, "tiny.md", "hi") + _drop(store, "binary.png", "not really a png but the extension rules") + + result = inbox.scan(store, store.root / "inbox") + + assert result.proposed == [] + assert sorted(result.skipped) == ["binary.png", "tiny.md"] + + +def test_scan_disabled_via_config_is_noop(store: KBStore) -> None: + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + "\ninbox:\n enabled: false\n", + encoding="utf-8", + ) + _drop(store, "notes.md") + + result = inbox.scan(store, store.root / "inbox") + assert result.proposed == [] + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_watch_is_bounded_by_iterations(store: KBStore) -> None: + _drop(store, "notes.md") + results: list[inbox.ScanResult] = [] + + inbox.watch( + store, + store.root / "inbox", + poll_interval=0.01, + iterations=2, + on_result=results.append, + ) + + assert len(results) == 2 + assert len(results[0].proposed) == 1 + assert results[1].proposed == [] + + +def test_cli_inbox_single_pass(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _drop(store, "notes.md") + monkeypatch.chdir(store.root) + + result = CliRunner().invoke(cli, ["inbox", "--dir", "inbox"]) + + assert result.exit_code == 0, result.output + assert "1 proposal(s)" in result.output + assert len(store.list_proposals(ProposalStatus.PENDING)) == 1 + + +def test_inbox_never_imports_approve() -> None: + import ast + import inspect + + from vouch import inbox as inbox_mod + + tree = ast.parse(inspect.getsource(inbox_mod)) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + imported.update(f"{node.module}.{a.name}" for a in node.names) + assert "vouch.lifecycle" not in {i.rsplit(".", 1)[0] for i in imported} + assert not any(name.endswith(".approve") for name in imported) From 8263aea8be8d664216cf334469b7519b0d3d4c60 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:51:21 +0900 Subject: [PATCH 10/13] feat(notify): reviewer webhooks + protected page kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the gate only works if a human notices it has work. vouch notify sweep evaluates the pending queue against config-declared webhooks — new proposals, a backlog crossing its threshold, proposals aged past a cutoff — and posts a small json envelope (optionally hmac-signed, secrets via the env: convention serve.bearer_token uses). delivery is best-effort and idempotent per (event, proposal); state lives in the derived state.db so losing it can only re-notify. read-and-notify only: no path here proposes, approves, or edits. protected page kinds tighten the gate the one direction invariants welcome: a kind with protected: true is exempt from the trusted-agent self-approval opt-out, so policy-bearing pages (voice, decision records — both marked in the company-brain template) always need a reviewer other than the proposer. --- src/vouch/cli.py | 39 ++++++ src/vouch/notify.py | 295 +++++++++++++++++++++++++++++++++++++++ src/vouch/onboarding.py | 2 + src/vouch/page_kinds.py | 9 ++ src/vouch/proposals.py | 13 +- tests/test_notify.py | 152 ++++++++++++++++++++ tests/test_page_kinds.py | 26 ++++ 7 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 src/vouch/notify.py create mode 100644 tests/test_notify.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 2027dbd4..cc27d306 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -31,6 +31,7 @@ from . import lifecycle as life from . import metrics as metrics_mod from . import migrations as migrations_mod +from . import notify as notify_mod from . import pr_cache as prc_mod from . import provenance as prov_mod from . import recall as recall_mod @@ -1185,6 +1186,7 @@ def schema_list_cmd(as_json: bool) -> None: "required_fields": required, "required_citations": citations, "has_frontmatter_schema": bool(fm_schema), + "protected": registry.is_protected(name), } ) extras: list[str] = [] @@ -1194,6 +1196,8 @@ def schema_list_cmd(as_json: bool) -> None: extras.append("citations-required") if fm_schema: extras.append("schema") + if registry.is_protected(name): + extras.append("protected") suffix = f" ({'; '.join(extras)})" if extras else "" lines.append(f"{name}{suffix}") if as_json: @@ -1410,6 +1414,41 @@ def _report(res: inbox_mod.ScanResult) -> None: click.echo(f" {pid}") +@cli.group() +def notify() -> None: + """Outbound reviewer notification webhooks (config: notify.webhooks).""" + + +@notify.command("sweep") +def notify_sweep() -> None: + """Evaluate pending-queue triggers and fire configured webhooks. + + Idempotent per (event, proposal) — safe to run from cron. Read-and- + notify only: nothing here can propose, approve, or edit.""" + store = _load_store() + with _cli_errors(): + fired = notify_mod.sweep(store) + if fired: + click.echo(f"fired {len(fired)} event(s): {', '.join(fired)}") + else: + click.echo("nothing to fire") + + +@notify.command("test") +@click.option("--url", required=True) +@click.option("--secret", default=None, help="Optional hmac secret (or env:VAR).") +def notify_test(url: str, secret: str | None) -> None: + """Send a synthetic event to URL and report delivery.""" + resolved = None + if secret: + with _cli_errors(): + resolved = notify_mod._resolve_env(secret, what="--secret") + ok = notify_mod.send_test(url, secret=resolved) + click.echo("delivered" if ok else "delivery failed") + if not ok: + sys.exit(1) + + # --- lifecycle ------------------------------------------------------------ diff --git a/src/vouch/notify.py b/src/vouch/notify.py new file mode 100644 index 00000000..86f131f1 --- /dev/null +++ b/src/vouch/notify.py @@ -0,0 +1,295 @@ +"""Outbound reviewer notifications: the gate only works if a human notices. + +Config-declared webhooks, fired by an operator-run sweep (cron / systemd +timer). Vouch is strictly the HTTP client; the endpoint belongs to the +operator — no inbound service, no polling by anyone else, no hosted +component. Read-and-notify only: nothing here proposes, approves, or edits; +a webhook's whole job is to make a human come look at the queue. + +```yaml +notify: + webhooks: + - url: env:VOUCH_NOTIFY_URL # env: ref keeps the secret out of git + events: [proposal.created, queue.backlogged, proposal.aged] + backlog_threshold: 25 + age_threshold: 48h + secret: env:VOUCH_NOTIFY_SECRET # optional hmac-sha256 body signing + include_summary: false +``` + +Delivery is best-effort: timeouts and non-2xx are logged and swallowed so a +dead endpoint can never wedge a sweep. Idempotence state (which proposal ids +already fired which event) lives in the derived state.db — losing it merely +re-notifies, it can never lose knowledge. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import urllib.error +import urllib.request +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +import yaml + +from . import index_db +from .metrics import parse_since +from .models import ProposalStatus +from .storage import KBStore + +logger = logging.getLogger(__name__) + +EVENT_CREATED = "proposal.created" +EVENT_BACKLOGGED = "queue.backlogged" +EVENT_AGED = "proposal.aged" +ALL_EVENTS = (EVENT_CREATED, EVENT_BACKLOGGED, EVENT_AGED) + +DEFAULT_TIMEOUT = 5.0 +_STATE_KEY = "notify_state" +SIGNATURE_HEADER = "X-Vouch-Signature" + + +class NotifyConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class Webhook: + url: str + events: tuple[str, ...] = ALL_EVENTS + backlog_threshold: int | None = None + age_threshold: str | None = None + secret: str | None = None + include_summary: bool = False + + +def _resolve_env(raw: str, *, what: str) -> str: + if raw.startswith("env:"): + var = raw.removeprefix("env:").strip() + if not var: + raise NotifyConfigError(f"{what} 'env:' must be followed by a variable name") + value = os.environ.get(var) + if not value: + raise NotifyConfigError(f"{what} references env:{var} but the variable is unset") + return value + return raw + + +def load_webhooks(store: KBStore) -> list[Webhook]: + try: + loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return [] + if not isinstance(loaded, dict): + return [] + raw = loaded.get("notify") + if not isinstance(raw, dict): + return [] + hooks: list[Webhook] = [] + for item in raw.get("webhooks") or []: + if not isinstance(item, dict) or not item.get("url"): + raise NotifyConfigError("notify.webhooks entries need a url") + events = item.get("events") + hooks.append( + Webhook( + url=_resolve_env(str(item["url"]), what="notify.webhooks.url"), + events=( + tuple(str(e) for e in events) if isinstance(events, list) else ALL_EVENTS + ), + backlog_threshold=( + int(item["backlog_threshold"]) + if item.get("backlog_threshold") is not None + else None + ), + age_threshold=( + str(item["age_threshold"]) if item.get("age_threshold") else None + ), + secret=( + _resolve_env(str(item["secret"]), what="notify.webhooks.secret") + if item.get("secret") + else None + ), + include_summary=bool(item.get("include_summary", False)), + ) + ) + return hooks + + +def deliver( + hook_url: str, + envelope: dict[str, object], + *, + secret: str | None = None, + timeout: float = DEFAULT_TIMEOUT, +) -> bool: + """POST the envelope; log-and-swallow every failure. Returns success.""" + body = json.dumps(envelope, sort_keys=True).encode("utf-8") + headers = {"Content-Type": "application/json", "User-Agent": "vouch-notify"} + if secret: + headers[SIGNATURE_HEADER] = hmac.new( + secret.encode("utf-8"), body, hashlib.sha256 + ).hexdigest() + req = urllib.request.Request(hook_url, data=body, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + ok = 200 <= resp.status < 300 + except (urllib.error.URLError, OSError, ValueError) as e: + logger.warning("notify delivery to %s failed: %s", hook_url, e) + return False + if not ok: + logger.warning("notify delivery to %s got HTTP %s", hook_url, resp.status) + return ok + + +def _load_state(kb_dir: Path) -> dict[str, list[str]]: + raw = index_db.get_meta(kb_dir, _STATE_KEY) + try: + loaded = json.loads(raw) if raw else {} + except json.JSONDecodeError: + loaded = {} + return { + "created": list(loaded.get("created", [])), + "aged": list(loaded.get("aged", [])), + "backlogged": list(loaded.get("backlogged", [])), + } + + +def _save_state(kb_dir: Path, state: dict[str, list[str]]) -> None: + with index_db.open_db(kb_dir) as conn: + index_db.set_meta(conn, _STATE_KEY, json.dumps(state, sort_keys=True)) + + +def _envelope( + store: KBStore, + event: str, + *, + proposal_ids: list[str], + pending_count: int, + now: datetime, + summaries: list[str] | None = None, +) -> dict[str, object]: + body: dict[str, object] = { + "event": event, + "timestamp": now.isoformat(timespec="seconds"), + "kb_path": str(store.kb_dir), + "proposal_ids": proposal_ids, + "pending_count": pending_count, + } + if summaries: + body["summaries"] = summaries + return body + + +def sweep(store: KBStore, *, now: datetime | None = None) -> list[str]: + """Evaluate triggers against the pending queue and fire subscribed hooks. + + Idempotent per (event, proposal id): a re-run fires nothing new unless + the queue changed. Returns the event names fired (with repeats per hook). + """ + hooks = load_webhooks(store) + if not hooks: + return [] + now = now or datetime.now(UTC) + pending = store.list_proposals(ProposalStatus.PENDING) + pending_ids = [p.id for p in pending] + state = _load_state(store.kb_dir) + fired: list[str] = [] + + for hook in hooks: + if EVENT_CREATED in hook.events: + new_ids = [pid for pid in pending_ids if pid not in state["created"]] + if new_ids: + summaries = ( + [ + str(p.payload.get("title") or p.payload.get("text") or "")[:120] + for p in pending + if p.id in new_ids + ] + if hook.include_summary + else None + ) + if deliver( + hook.url, + _envelope( + store, EVENT_CREATED, + proposal_ids=new_ids, pending_count=len(pending), + now=now, summaries=summaries, + ), + secret=hook.secret, + ): + fired.append(EVENT_CREATED) + + if ( + EVENT_BACKLOGGED in hook.events + and hook.backlog_threshold is not None + and len(pending) >= hook.backlog_threshold + ): + marker = f"{hook.url}:{hook.backlog_threshold}" + if marker not in state["backlogged"] and deliver( + hook.url, + _envelope( + store, EVENT_BACKLOGGED, + proposal_ids=pending_ids, pending_count=len(pending), now=now, + ), + secret=hook.secret, + ): + fired.append(EVENT_BACKLOGGED) + state["backlogged"].append(marker) + + if EVENT_AGED in hook.events and hook.age_threshold: + cutoff = parse_since(hook.age_threshold, now=now) + aged_ids = [ + p.id + for p in pending + if cutoff is not None + and ( + p.proposed_at.replace(tzinfo=UTC) + if p.proposed_at.tzinfo is None + else p.proposed_at + ) + < cutoff + and p.id not in state["aged"] + ] + if aged_ids and deliver( + hook.url, + _envelope( + store, EVENT_AGED, + proposal_ids=aged_ids, pending_count=len(pending), now=now, + ), + secret=hook.secret, + ): + fired.append(EVENT_AGED) + state["aged"].extend(aged_ids) + + # created-state is hook-independent: a proposal counts as announced once + # any hook accepted it. drop decided proposals so the state can't grow + # without bound. + if any(e == EVENT_CREATED for e in fired): + state["created"] = sorted(set(state["created"]) | set(pending_ids)) + state["created"] = [pid for pid in state["created"] if pid in pending_ids] + state["aged"] = [pid for pid in state["aged"] if pid in pending_ids] + if not pending: + state["backlogged"] = [] + _save_state(store.kb_dir, state) + return fired + + +def send_test(url: str, *, secret: str | None = None) -> bool: + """Deliver a synthetic event so an operator can verify the endpoint.""" + now = datetime.now(UTC) + return deliver( + url, + { + "event": "notify.test", + "timestamp": now.isoformat(timespec="seconds"), + "proposal_ids": [], + "pending_count": 0, + }, + secret=secret, + ) diff --git a/src/vouch/onboarding.py b/src/vouch/onboarding.py index 38f428ea..bced1c8d 100644 --- a/src/vouch/onboarding.py +++ b/src/vouch/onboarding.py @@ -339,10 +339,12 @@ def seed_gittensor_kb(store: KBStore, *, approved_by: str = "vouch-init") -> See "decision-record": { "description": "why the team decided something; must cite evidence", "required_citations": True, + "protected": True, }, "voice": { "description": "how the team sounds in one channel; cites the examples it distills", "required_citations": True, + "protected": True, }, } diff --git a/src/vouch/page_kinds.py b/src/vouch/page_kinds.py index 795fdeff..cb5d9b5d 100644 --- a/src/vouch/page_kinds.py +++ b/src/vouch/page_kinds.py @@ -72,6 +72,11 @@ class PageKindSpec(BaseModel): description="JSON-Schema subset: {type: object, properties: {...}, required: [...]}", ) required_citations: bool = False + # A protected kind is exempt from the `review.approver_role: + # trusted-agent` self-approval opt-out: its pages always need a reviewer + # other than the proposer. Not inherited through `extends` — protection + # is a property of the concrete kind, declared where a reviewer reads it. + protected: bool = False description: str | None = None @field_validator("frontmatter_schema", mode="before") @@ -106,6 +111,10 @@ def known(self) -> set[str]: def is_known(self, name: str) -> bool: return name in self._specs + def is_protected(self, name: str) -> bool: + spec = self._specs.get(name) + return bool(spec and spec.protected) + def resolve(self, name: str) -> tuple[list[str], dict[str, Any], bool]: """Return (required_fields, frontmatter_schema, required_citations). diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 6f1dd25a..876c13e1 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -25,7 +25,7 @@ ProposalStatus, Relation, ) -from .page_kinds import PageKindError, validate_page +from .page_kinds import PageKindError, load_page_kind_registry, validate_page from .storage import ArtifactNotFoundError, KBStore @@ -331,6 +331,17 @@ def _approval_block_reason( if proposal.status != ProposalStatus.PENDING: return f"proposal {proposal.id} is {proposal.status.value}, not pending" if approved_by == proposal.proposed_by: + # Protected page kinds are exempt from the trusted-agent opt-out: + # policy-bearing pages (voice, decision records) always need a + # reviewer other than the proposer, whatever review.approver_role + # says. Checked first so the opt-out below can never widen it. + if proposal.kind == ProposalKind.PAGE: + page_type = str(proposal.payload.get("type", "")) + if page_type and load_page_kind_registry(store).is_protected(page_type): + return ( + f"forbidden_self_approval: page kind '{page_type}' is protected — " + "it always requires a reviewer other than the proposer" + ) cfg: dict[str, Any] = {} try: loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text(encoding="utf-8")) diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 00000000..6fc0b293 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,152 @@ +"""Reviewer notification webhooks — read-and-notify only.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import threading +from datetime import UTC, datetime, timedelta +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +from vouch import notify +from vouch.models import Proposal, ProposalKind +from vouch.storage import KBStore + +NOW = datetime(2026, 7, 4, 12, 0, 0, tzinfo=UTC) + + +class _Sink(BaseHTTPRequestHandler): + received: list[tuple[dict, dict]] = [] + + def do_POST(self) -> None: # noqa: N802 - stdlib naming + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + _Sink.received.append((body, dict(self.headers))) + self.send_response(200) + self.end_headers() + + def log_message(self, *args: object) -> None: + pass + + +@pytest.fixture +def sink_url() -> str: + _Sink.received = [] + server = HTTPServer(("127.0.0.1", 0), _Sink) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_port}/hook" + server.shutdown() + + +def _pending(store: KBStore, pid: str, *, age_hours: int = 1) -> None: + store.put_proposal( + Proposal( + id=pid, kind=ProposalKind.CLAIM, proposed_by="agent-a", + proposed_at=NOW - timedelta(hours=age_hours), + payload={"text": f"fact from {pid}"}, + ) + ) + + +def _configure(store: KBStore, yaml_block: str) -> None: + store.config_path.write_text( + store.config_path.read_text(encoding="utf-8") + "\n" + yaml_block, + encoding="utf-8", + ) + + +def test_sweep_without_config_is_noop(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + _pending(store, "p1") + assert notify.sweep(store, now=NOW) == [] + + +def test_sweep_fires_created_once(tmp_path: Path, sink_url: str) -> None: + store = KBStore.init(tmp_path) + _configure(store, f"notify:\n webhooks:\n - url: {sink_url}\n") + _pending(store, "p1") + _pending(store, "p2") + + first = notify.sweep(store, now=NOW) + second = notify.sweep(store, now=NOW) + + assert first == [notify.EVENT_CREATED] + assert second == [] + body, _ = _Sink.received[0] + assert body["event"] == "proposal.created" + assert sorted(body["proposal_ids"]) == ["p1", "p2"] + assert body["pending_count"] == 2 + # no proposal content unless include_summary is opted into + assert "summaries" not in body + + +def test_sweep_aged_and_backlogged(tmp_path: Path, sink_url: str) -> None: + store = KBStore.init(tmp_path) + _configure( + store, + "notify:\n webhooks:\n" + f" - url: {sink_url}\n" + " events: [proposal.aged, queue.backlogged]\n" + " backlog_threshold: 2\n" + " age_threshold: 48h\n", + ) + _pending(store, "old", age_hours=72) + _pending(store, "young", age_hours=1) + + fired = notify.sweep(store, now=NOW) + assert sorted(fired) == ["proposal.aged", "queue.backlogged"] + aged = next(b for b, _ in _Sink.received if b["event"] == "proposal.aged") + assert aged["proposal_ids"] == ["old"] + + # idempotent: same state, nothing re-fires + assert notify.sweep(store, now=NOW) == [] + + +def test_sweep_signs_body_with_env_secret( + tmp_path: Path, sink_url: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_TEST_NOTIFY_SECRET", "s3kr1t") + store = KBStore.init(tmp_path) + _configure( + store, + "notify:\n webhooks:\n" + f" - url: {sink_url}\n" + " secret: env:VOUCH_TEST_NOTIFY_SECRET\n", + ) + _pending(store, "p1") + + notify.sweep(store, now=NOW) + + body, headers = _Sink.received[0] + expected = hmac.new( + b"s3kr1t", json.dumps(body, sort_keys=True).encode("utf-8"), hashlib.sha256 + ).hexdigest() + assert headers.get(notify.SIGNATURE_HEADER) == expected + + +def test_unset_env_secret_fails_loudly(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + _configure( + store, + "notify:\n webhooks:\n" + " - url: http://127.0.0.1:9/hook\n" + " secret: env:VOUCH_TEST_NOTIFY_UNSET\n", + ) + with pytest.raises(notify.NotifyConfigError): + notify.sweep(store, now=NOW) + + +def test_dead_endpoint_is_swallowed_and_retried(tmp_path: Path) -> None: + store = KBStore.init(tmp_path) + # port 9 (discard) refuses connections immediately + _configure(store, "notify:\n webhooks:\n - url: http://127.0.0.1:9/hook\n") + _pending(store, "p1") + + assert notify.sweep(store, now=NOW) == [] + # failed delivery is not marked announced — a later sweep retries + assert notify._load_state(store.kb_dir)["created"] == [] diff --git a/tests/test_page_kinds.py b/tests/test_page_kinds.py index 80ad3123..267e2c65 100644 --- a/tests/test_page_kinds.py +++ b/tests/test_page_kinds.py @@ -97,6 +97,32 @@ def test_string_schema_accepts_yaml_date_scalars(store: KBStore) -> None: ) +def test_protected_kind_blocks_self_approval_despite_trusted_agent(store: KBStore) -> None: + cfg = yaml.safe_load(store.config_path.read_text()) + cfg["review"] = {"approver_role": "trusted-agent"} + cfg["page_kinds"] = { + "voice": {"protected": True}, + "meeting-notes": {}, + } + store.config_path.write_text(yaml.safe_dump(cfg)) + + # unprotected kind: trusted-agent opt-out lets the proposer self-approve + open_pr = propose_page( + store, title="sync", body="b", page_type="meeting-notes", proposed_by="agent", + ) + assert isinstance(approve(store, open_pr.id, approved_by="agent"), Page) + + # protected kind: self-approval stays forbidden regardless of the opt-out + voice_pr = propose_page( + store, title="email voice", body="b", page_type="voice", proposed_by="agent", + ) + with pytest.raises(ProposalError, match="protected"): + approve(store, voice_pr.id, approved_by="agent") + + # a distinct reviewer can still approve it + assert isinstance(approve(store, voice_pr.id, approved_by="reviewer"), Page) + + # --- acceptance: per-field error on a missing required field --------------- From da5c9173815a055a184e9bc575f76fa274a01c90 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:54:16 +0900 Subject: [PATCH 11/13] test(brain): pin never-approve rule in prompts and intake modules the nl layer lives host-side as prompt files, so the strongest deterministic guarantee is textual: every brain command must keep its explicit never-approve instruction, every manifest skills path must exist, and an ast walk pins that fetch/inbox/notify have no import or attribute path to approve(). plus docs and changelog for the intake and notification features. --- CHANGELOG.md | 19 +++++++++++++ docs/company-brain.md | 41 ++++++++++++++++++++++++++- src/vouch/cli.py | 6 ++-- tests/test_brain_commands.py | 54 ++++++++++++++++++++++++++++++++++++ tests/test_fetch.py | 2 +- tests/test_notify.py | 5 ++-- 6 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 tests/test_brain_commands.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 651dc90d..7b2dde11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,25 @@ All notable changes to vouch are documented here. Format follows the plugin skills list): `/vouch-ask`, `/vouch-remember`, `/vouch-record`, `/vouch-followup`, `/vouch-standup`. every flow terminates at `kb_propose_*` — none may call `kb_approve`. +- `vouch source fetch `: snapshot a url's exact bytes as a + content-addressed source so claims cite immutable evidence. conservative + intake: http/https only, redirects re-validated, private-network hosts + refused, 2 mib cap, `fetched_at` recorded in source metadata. +- `vouch inbox --dir [--watch]`: dropped `.md`/`.txt` files become a + registered source plus one pending page proposal each — mechanical, no + model in the loop, never approves. content-hash seen-state makes re-runs + idempotent; bounded stdlib poll, no daemon. +- `vouch notify sweep|test`: config-declared reviewer webhooks + (`proposal.created`, `queue.backlogged`, `proposal.aged`) with optional + hmac-signed envelopes and `env:` secret refs. read-and-notify only; + best-effort delivery; idempotent per (event, proposal). +- protected page kinds: `page_kinds..protected: true` exempts a kind + from the `trusted-agent` self-approval opt-out — its pages always need a + reviewer other than the proposer. the template marks `voice` and + `decision-record` protected. +- string-typed frontmatter schema fields now accept yaml's native + date/datetime scalars, fixing approve-time re-validation of pages whose + frontmatter round-tripped through disk (e.g. `due_at: 2026-07-01`). ## [1.1.0] — 2026-07-03 diff --git a/docs/company-brain.md b/docs/company-brain.md index cf3e86b8..c8958bb9 100644 --- a/docs/company-brain.md +++ b/docs/company-brain.md @@ -46,7 +46,9 @@ go through the gate and land in the audit log like any other write. **Decisions and voice must cite.** The `decision-record` and `voice` kinds set `required_citations: true`, so the gate rejects an uncited one at both -propose and approve time. +propose and approve time. They are also `protected: true`: even under +`review.approver_role: trusted-agent`, a protected page always needs a +reviewer other than its proposer. ## Querying records @@ -62,6 +64,43 @@ vouch pages --kind contact --meta org=acme-example --json Deliberately not a query language: equality and bounds on declared fields, nothing more. The yaml files stay the only source of truth. +## Feeding the brain + +Evidence comes in two ungated intake channels (sources are evidence, not +knowledge — only claims and pages go through review): + +```sh +vouch source fetch https://example.com/spec # snapshot a URL, cite its sha256 id +vouch inbox --dir inbox/ # dropped files -> pending page proposals +``` + +`source fetch` stores the exact bytes once, content-addressed, so claims +cite an immutable snapshot the live page can't drift away from. It fetches +conservatively: http/https only, redirects re-validated, private-network +hosts refused, 2 MiB cap. The inbox is the hands-free path: drop meeting +notes or a memo into a folder, each new file becomes a registered source +plus one pending page proposal citing it. Both channels only propose; +neither can approve. + +## Staying responsive + +Configure outbound webhooks so the reviewer learns the queue grew without +polling: + +```yaml +notify: + webhooks: + - url: env:VOUCH_NOTIFY_URL + events: [proposal.created, queue.backlogged, proposal.aged] + backlog_threshold: 25 + age_threshold: 48h + secret: env:VOUCH_NOTIFY_SECRET # optional hmac-sha256 signing +``` + +`vouch notify sweep` (cron it next to the digest) fires the configured +events idempotently; `vouch notify test --url ` verifies an endpoint. +Delivery is best-effort — a dead endpoint never wedges anything. + ## The daily loop Agents file proposals all day (see the slash commands below). A human diff --git a/src/vouch/cli.py b/src/vouch/cli.py index cc27d306..5e70e985 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -12,7 +12,7 @@ import os import sys from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import asdict from datetime import UTC, datetime from pathlib import Path @@ -1401,12 +1401,10 @@ def _report(res: inbox_mod.ScanResult) -> None: if res.proposed: click.echo(f"filed {len(res.proposed)} proposal(s): {', '.join(res.proposed)}") - try: + with suppress(KeyboardInterrupt): inbox_mod.watch( store, path, poll_interval=poll_interval, on_result=_report, ) - except KeyboardInterrupt: - pass return res = inbox_mod.scan(store, path) click.echo(f"filed {len(res.proposed)} proposal(s); skipped {len(res.skipped)} file(s)") diff --git a/tests/test_brain_commands.py b/tests/test_brain_commands.py new file mode 100644 index 00000000..21068f6e --- /dev/null +++ b/tests/test_brain_commands.py @@ -0,0 +1,54 @@ +"""Guards on the company-brain adapter prompts and intake modules. + +The NL layer lives host-side as prompt files, so the strongest deterministic +guarantee available is textual: every brain command must carry the explicit +never-approve instruction, and every registered skill path must exist. The +intake modules get the structural version: no import path to approve(). +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMMANDS_DIR = REPO_ROOT / "adapters" / "claude-code" / ".claude" / "commands" + +BRAIN_COMMANDS = [ + "vouch-ask.md", + "vouch-remember.md", + "vouch-record.md", + "vouch-followup.md", + "vouch-standup.md", +] + + +@pytest.mark.parametrize("name", BRAIN_COMMANDS) +def test_brain_command_pins_the_never_approve_rule(name: str) -> None: + body = (COMMANDS_DIR / name).read_text(encoding="utf-8") + assert "kb_approve" in body, f"{name} must state the approve rule explicitly" + assert "Never call" in body, f"{name} lost its never-approve instruction" + # proposing is the only write verb a brain prompt may teach + assert "kb_propose" in body or "kb_digest" in body or "kb_context" in body + + +def test_manifest_skills_all_exist() -> None: + manifest = json.loads((REPO_ROOT / "openclaw.plugin.json").read_text(encoding="utf-8")) + for rel in manifest["skills"]: + assert (REPO_ROOT / rel).is_file(), f"manifest skills entry missing: {rel}" + + +@pytest.mark.parametrize("module", ["fetch", "inbox", "notify"]) +def test_intake_modules_have_no_approve_import(module: str) -> None: + source = (REPO_ROOT / "src" / "vouch" / f"{module}.py").read_text(encoding="utf-8") + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + names = {a.name for a in node.names} + assert "approve" not in names, f"{module}.py imports approve" + assert node.module != "vouch.lifecycle", f"{module}.py imports lifecycle" + if isinstance(node, ast.Attribute): + assert node.attr != "approve", f"{module}.py references .approve" diff --git a/tests/test_fetch.py b/tests/test_fetch.py index aafaf4b6..bdfcbade 100644 --- a/tests/test_fetch.py +++ b/tests/test_fetch.py @@ -15,7 +15,7 @@ class _Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: # noqa: N802 - stdlib naming + def do_GET(self) -> None: if self.path == "/page": body = b"# hello from the fixture\n" self.send_response(200) diff --git a/tests/test_notify.py b/tests/test_notify.py index 6fc0b293..8bc7b75b 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -9,6 +9,7 @@ from datetime import UTC, datetime, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +from typing import ClassVar import pytest @@ -20,9 +21,9 @@ class _Sink(BaseHTTPRequestHandler): - received: list[tuple[dict, dict]] = [] + received: ClassVar[list[tuple[dict, dict]]] = [] - def do_POST(self) -> None: # noqa: N802 - stdlib naming + def do_POST(self) -> None: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) _Sink.received.append((body, dict(self.headers))) From 1112fc964924aa9f6186e3a1b674563d6abba0a6 Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:51:04 +0900 Subject: [PATCH 12/13] feat(capture): human-readable titles for auto-captured sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the queue used to show 27 proposals all titled "session summary: ()" — unreviewable at a glance. the sessionend hook payload already carries transcript_path, so finalize now lifts the human's first genuine prompt from the transcript (pure extraction — host wrapper messages skipped, no model involved, capture's no-llm rule holds) and titles the proposal "session: ", with the prompt quoted in a new body section. without a transcript the title falls back to what changed: "session : web, docs — 9 file(s)". the uuid stays in the body for traceability. --- src/vouch/capture.py | 88 ++++++++++++++++++++++++++++++++++++++++++- src/vouch/cli.py | 3 ++ tests/test_capture.py | 65 +++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/vouch/capture.py b/src/vouch/capture.py index 79f2623c..89db5fdc 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -189,6 +189,72 @@ def _git_changes(cwd: Path) -> tuple[list[str], str]: return files, stat +def first_user_prompt(transcript_path: Path, *, max_chars: int = 240) -> str | None: + """Mechanically extract the session's first genuine user prompt. + + The SessionEnd hook payload carries the transcript path; the first thing + the human actually typed is the best available one-line description of + what the session was about. Pure extraction — host wrapper messages + (`…`, `…`, caveats) and meta lines are + skipped, no model is involved, so capture's no-LLM rule holds. + """ + try: + fh = transcript_path.open(encoding="utf-8") + except OSError: + return None + with fh: + for line in fh: + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(obj, dict) or obj.get("type") != "user" or obj.get("isMeta"): + continue + msg = obj.get("message") + content = msg.get("content") if isinstance(msg, dict) else None + texts: list[str] = [] + if isinstance(content, str): + texts = [content] + elif isinstance(content, list): + texts = [ + str(c.get("text", "")) + for c in content + if isinstance(c, dict) and c.get("type") == "text" + ] + for raw in texts: + text = raw.strip() + if not text or text.startswith("<"): + continue + if text.lower().startswith("caveat:"): + continue + collapsed = " ".join(text.split()) + if len(collapsed) > max_chars: + collapsed = collapsed[: max_chars - 1].rstrip() + "…" + return collapsed + return None + + +def _excerpt(prompt: str, *, max_chars: int = 64) -> str: + if len(prompt) <= max_chars: + return prompt + return prompt[: max_chars - 1].rstrip() + "…" + + +def _fallback_title( + files: set[str], observations_count: int, generated_at: str | None +) -> str: + """Describe the session by what it touched — never by its uuid.""" + date = f" {generated_at[:10]}" if generated_at else "" + if not files: + return f"session{date}: {observations_count} observation(s), no file changes" + segments: dict[str, int] = {} + for f in sorted(files): + seg = f.split("/", 1)[0] if "/" in f else _basename(f) + segments[seg] = segments.get(seg, 0) + 1 + top = sorted(segments, key=lambda s: (-segments[s], s))[:3] + return f"session{date}: {', '.join(top)} — {len(files)} file(s)" + + def build_summary_body( session_id: str, observations: list[dict[str, Any]], @@ -197,6 +263,7 @@ def build_summary_body( *, project: str | None = None, generated_at: str | None = None, + first_prompt: str | None = None, ) -> tuple[str, str]: tool_counts: dict[str, int] = {} files: set[str] = set(changed_files) @@ -209,11 +276,21 @@ def build_summary_body( cmd = obs.get("cmd") if cmd: commands.append(str(cmd)) - title = f"session summary: {project or 'workspace'} ({session_id})" + # The title is what a reviewer scans in the queue: lead with the human's + # own words when the transcript offers them, else with what changed. + # The session uuid stays in the body for traceability. + if first_prompt: + title = f"session: {_excerpt(first_prompt)}" + else: + title = _fallback_title(files, len(observations), generated_at) + if project: + title = f"{title} [{project}]" lines: list[str] = [f"# {title}", ""] if generated_at: lines.append(f"- generated: {generated_at}") lines += [f"- session: `{session_id}`", f"- observations: {len(observations)}", ""] + if first_prompt: + lines += ["## prompt", "", f"> {first_prompt}", ""] if files: lines += ["## files modified this session", ""] lines += [f"- {f}" for f in sorted(files)[:20]] @@ -242,12 +319,16 @@ def finalize( cwd: Path | None = None, project: str | None = None, generated_at: str | None = None, + transcript_path: Path | None = None, config: CaptureConfig | None = None, ) -> dict[str, Any]: """Roll a session buffer into one PENDING summary proposal. No approve(). If cwd is None (e.g., when finalizing orphaned buffers with unknown origin), git changes are not included. Otherwise, git changes from cwd are included. + transcript_path (from the SessionEnd hook payload) supplies the human's + first prompt for the proposal title; absent, the title falls back to the + files the session touched. """ cfg = config or load_config(store) path = buffer_path(store, session_id) @@ -267,9 +348,12 @@ def finalize( path.unlink() return {"captured": total, "summary_proposal_id": None, "skipped": "below-min"} + first_prompt = ( + first_user_prompt(transcript_path) if transcript_path is not None else None + ) title, body = build_summary_body( session_id, observations, changed_files, git_stat, - project=project, generated_at=generated_at, + project=project, generated_at=generated_at, first_prompt=first_prompt, ) proposal = propose_page( store, diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5e70e985..549c7031 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -1630,9 +1630,12 @@ def capture_finalize_cmd(session_id: str | None) -> None: if 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, generated_at=datetime.now(UTC).isoformat(), + transcript_path=transcript, ) _emit_json(result) diff --git a/tests/test_capture.py b/tests/test_capture.py index 9c4f2a2e..b1b039c9 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -238,12 +238,75 @@ def test_build_summary_body_has_sections() -> None: {"ts": 2.0, "tool": "Bash", "summary": "Ran: pytest", "cmd": "pytest"}, ] title, body = cap.build_summary_body("s1", obs, ["a.py"], "a.py | 2 +-") - assert "s1" in title + # the title describes what changed, never the uuid; the uuid stays in the body + assert "a.py" in title + assert "s1" not in title + assert "- session: `s1`" in body assert "files modified this session" in body.lower() assert "## activity" in body.lower() assert "a.py" in body +def test_title_uses_first_prompt_excerpt() -> None: + obs = [{"ts": 1.0, "tool": "Edit", "summary": "Edited a.py", "files": ["a.py"]}] + title, body = cap.build_summary_body( + "s1", obs, ["a.py"], "", first_prompt="fix the login redirect bug in oauth", + ) + assert title == "session: fix the login redirect bug in oauth" + assert "## prompt" in body + assert "> fix the login redirect bug in oauth" in body + + long_prompt = "p" * 300 + title, _ = cap.build_summary_body("s1", obs, [], "", first_prompt=long_prompt) + assert len(title) <= len("session: ") + 64 + assert title.endswith("…") + + +def test_title_fallback_names_dirs_and_date() -> None: + files = ["web/app.css", "web/index.html", "docs/guide.md"] + title, _ = cap.build_summary_body( + "s1", [], files, "", generated_at="2026-07-04T10:00:00+00:00", + ) + assert title == "session 2026-07-04: web, docs — 3 file(s)" + + title, _ = cap.build_summary_body("s1", [{"ts": 1.0, "tool": "Read", "summary": "x"}], [], "") + assert "no file changes" in title + + +def test_first_user_prompt_skips_host_wrappers(tmp_path: Path) -> None: + transcript = tmp_path / "t.jsonl" + lines = [ + {"type": "queue-operation", "operation": "enqueue"}, + {"type": "user", "message": {"role": "user", "content": "/model"}}, + {"type": "user", "message": {"role": "user", "content": "ok"}}, + {"type": "user", "isMeta": True, "message": {"role": "user", "content": "meta noise"}}, + {"type": "user", "message": {"role": "user", "content": [ + {"type": "text", "text": " please add retry logic\nto the fetcher "}, + ]}}, + {"type": "user", "message": {"role": "user", "content": "a later prompt"}}, + ] + transcript.write_text( + "\n".join(_json.dumps(entry) for entry in lines), encoding="utf-8" + ) + assert cap.first_user_prompt(transcript) == "please add retry logic to the fetcher" + assert cap.first_user_prompt(tmp_path / "missing.jsonl") is None + + +def test_finalize_reads_transcript_for_title(store: KBStore, tmp_path: Path) -> None: + from vouch.models import ProposalStatus + + _seed(store, "s1", 3) + transcript = tmp_path / "t.jsonl" + transcript.write_text( + _json.dumps({"type": "user", "message": {"role": "user", "content": "ship the digest"}}), + encoding="utf-8", + ) + cap.finalize(store, "s1", cwd=tmp_path, transcript_path=transcript) + pend = store.list_proposals(ProposalStatus.PENDING) + assert len(pend) == 1 + assert pend[0].payload["title"] == "session: ship the digest" + + def test_pending_count_counts_capture_actor(store: KBStore, tmp_path: Path) -> None: _seed(store, "s1", 3) cap.finalize(store, "s1", cwd=tmp_path) From 5ab41eced5ed31b31c8e7696d1873afcb360e79a Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:18:06 +0900 Subject: [PATCH 13/13] style(tests): wrap long transcript fixture lines in test_capture ruff flagged the two host-wrapper transcript entries at over 100 columns (e501); reformat only, no behaviour change. --- tests/test_capture.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_capture.py b/tests/test_capture.py index b1b039c9..17961882 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -277,8 +277,17 @@ def test_first_user_prompt_skips_host_wrappers(tmp_path: Path) -> None: transcript = tmp_path / "t.jsonl" lines = [ {"type": "queue-operation", "operation": "enqueue"}, - {"type": "user", "message": {"role": "user", "content": "/model"}}, - {"type": "user", "message": {"role": "user", "content": "ok"}}, + { + "type": "user", + "message": {"role": "user", "content": "/model"}, + }, + { + "type": "user", + "message": { + "role": "user", + "content": "ok", + }, + }, {"type": "user", "isMeta": True, "message": {"role": "user", "content": "meta noise"}}, {"type": "user", "message": {"role": "user", "content": [ {"type": "text", "text": " please add retry logic\nto the fetcher "},