diff --git a/CHANGELOG.md b/CHANGELOG.md index 6961cbd0..c61ce193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ 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`. +- `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 ### Added 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/.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 a1775ef0..6e4f0f99 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`, `/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. @@ -22,5 +24,10 @@ 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 } + - { 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/adapters/openclaw/skills/vouch-ask/SKILL.md b/adapters/openclaw/skills/vouch-ask/SKILL.md new file mode 100644 index 00000000..e4dd5f39 --- /dev/null +++ b/adapters/openclaw/skills/vouch-ask/SKILL.md @@ -0,0 +1,25 @@ +--- +name: vouch-ask +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/openclaw/skills/vouch-followup/SKILL.md b/adapters/openclaw/skills/vouch-followup/SKILL.md new file mode 100644 index 00000000..4a6de3e6 --- /dev/null +++ b/adapters/openclaw/skills/vouch-followup/SKILL.md @@ -0,0 +1,25 @@ +--- +name: vouch-followup +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/openclaw/skills/vouch-record/SKILL.md b/adapters/openclaw/skills/vouch-record/SKILL.md new file mode 100644 index 00000000..a795b49f --- /dev/null +++ b/adapters/openclaw/skills/vouch-record/SKILL.md @@ -0,0 +1,28 @@ +--- +name: vouch-record +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/openclaw/skills/vouch-remember/SKILL.md b/adapters/openclaw/skills/vouch-remember/SKILL.md new file mode 100644 index 00000000..bb87b9d4 --- /dev/null +++ b/adapters/openclaw/skills/vouch-remember/SKILL.md @@ -0,0 +1,23 @@ +--- +name: vouch-remember +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/openclaw/skills/vouch-standup/SKILL.md b/adapters/openclaw/skills/vouch-standup/SKILL.md new file mode 100644 index 00000000..66c5b0d5 --- /dev/null +++ b/adapters/openclaw/skills/vouch-standup/SKILL.md @@ -0,0 +1,23 @@ +--- +name: vouch-standup +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/docs/company-brain.md b/docs/company-brain.md new file mode 100644 index 00000000..c8958bb9 --- /dev/null +++ b/docs/company-brain.md @@ -0,0 +1,143 @@ +# 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. 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 + +`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. + +## 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 +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. diff --git a/docs/img/examples/decision-log-diff.svg b/docs/img/examples/decision-log-diff.svg index d7a1226a..92618067 100644 --- a/docs/img/examples/decision-log-diff.svg +++ b/docs/img/examples/decision-log-diff.svg @@ -1,21 +1,18 @@ - - - - + + + + -decision-log/ — vouch diff (supersession) -$ vouch diff free-tier-100-req-superseded free-tier-500-req -diff claim free-tier-100-req-superseded → free-tier-500-req - status: superseded → stable - evidence: ['2f5a8b1c4d7e9a3b6c1d4e7f2a5b8c3d6e9f1a4b7c2d5e8f3a6b9c1d4e7f2a5b'] → ['5b8c1d4e7f2a5b8c3d6e9f1a4b7c2d5e8f3a6b9c1d4e7f2a5b8c3d6e9f1a4b7c'] - supersedes: [] → ['free-tier-100-req-superseded'] - superseded_by: free-tier-500-req → None - text: - --- - +++ - @@ -1 +1 @@ - -Free-tier accounts get 100 API requests per day. - +Free-tier accounts get 500 API requests per day (raised from 100 after Q1 review). +decision-log/ — vouch diff (decision evolution) +$ vouch diff billing-data-requires-acid-guarantees-postgresql-is-the-prim use-postgresql-15-for-enhanced-replication-and-monitoring-fe +diff claim billing-data-requires-acid-guarantees-postgresql-is-the-prim → use-postgresql-15-for-enhanced-replication-and-monitoring-fe + confidence: 0.95 → 0.98 + text: + --- + +++ + @@ -1 +1 @@ + -Billing data requires ACID guarantees; PostgreSQL is the primary data store. + +Use PostgreSQL 15 for enhanced replication and monitoring features. diff --git a/docs/img/examples/decision-log-search.svg b/docs/img/examples/decision-log-search.svg index e500ef22..8db7e341 100644 --- a/docs/img/examples/decision-log-search.svg +++ b/docs/img/examples/decision-log-search.svg @@ -1,12 +1,12 @@ - - - - + + + + -decision-log/ — vouch search free-tier -$ vouch search free-tier -claim/free-tier-100-req-superseded Free-tier accounts get 100 API requests per day. (substring) -claim/free-tier-500-req Free-tier accounts get 500 API requests per day (raised from 100 after Q1 review). (substring) +decision-log/ — vouch search postgresql +$ vouch search postgresql +claim/billing-data-requires-acid-guarantees-postgresql-is-the-prim Billing data requires ACID guarantees; PostgreSQL is the primary data store. (substring) +claim/use-postgresql-15-for-enhanced-replication-and-monitoring-fe Use PostgreSQL 15 for enhanced replication and monitoring features. (substring) diff --git a/docs/img/examples/render.py b/docs/img/examples/render.py index 5c3be316..c4ee6e5c 100644 --- a/docs/img/examples/render.py +++ b/docs/img/examples/render.py @@ -71,15 +71,19 @@ class Shot: Shot("tiny-search", "tiny", ["search", "auth"], "tiny/ — vouch search auth"), Shot("tiny-show", "tiny", ["show", "prop-001"], "tiny/ — vouch show prop-001"), Shot("tiny-audit", "tiny", ["audit"], "tiny/ — vouch audit"), - # decision-log/ — demonstrates supersession across two pricing claims. + # decision-log/ — decisions as claims; diff shows how one evolved. Shot( - "decision-log-search", "decision-log", ["search", "free-tier"], - "decision-log/ — vouch search free-tier", + "decision-log-search", "decision-log", ["search", "postgresql"], + "decision-log/ — vouch search postgresql", ), Shot( "decision-log-diff", "decision-log", - ["diff", "free-tier-100-req-superseded", "free-tier-500-req"], - "decision-log/ — vouch diff (supersession)", + [ + "diff", + "billing-data-requires-acid-guarantees-postgresql-is-the-prim", + "use-postgresql-15-for-enhanced-replication-and-monitoring-fe", + ], + "decision-log/ — vouch diff (decision evolution)", ), ] diff --git a/examples/decision-log/README.md b/examples/decision-log/README.md index 85942b05..70a0707f 100644 --- a/examples/decision-log/README.md +++ b/examples/decision-log/README.md @@ -33,16 +33,15 @@ patterns; browse the directory for the full set.) ## See it in action -After `cp -r examples/decision-log/vouch ./.vouch`, here's the supersession -story on this fixture. (Images are rendered from the fixture by +After `cp -r examples/decision-log/vouch ./.vouch`, here's the fixture in +use. (Images are rendered from the fixture by [`docs/img/examples/render.py`](../../docs/img/examples/render.py).) -`vouch search free-tier` — both pricing claims surface, the old one marked -`superseded`: +`vouch search postgresql` — both database decisions surface: -vouch search free-tier on the decision-log example +vouch search postgresql on the decision-log example -`vouch diff free-tier-100-req-superseded free-tier-500-req` — what changed -across the supersession: status, evidence, and the decision text itself: +`vouch diff` across the two database claims — how the decision evolved, +confidence and text side by side: -vouch diff showing supersession on the decision-log example +vouch diff showing decision evolution on the decision-log example diff --git a/openclaw.plugin.json b/openclaw.plugin.json index de73bf81..2e5fedf9 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,39 +1,9 @@ { - "name": "vouch", + "id": "vouch", + "name": "Vouch", "version": "1.1.0", - "description": "Git-native, review-gated knowledge base for LLM agents. MCP server + JSONL tool server + CLI.", - "family": "bundle-plugin", - "homepage": "https://github.com/vouchdev/vouch", - "configSchema": { - "kb_path": { - "type": "string", - "required": false, - "description": "Absolute path to the project root containing .vouch/. When unset, vouch walks up from cwd to find the nearest KB.", - "default": null - }, - "agent": { - "type": "string", - "required": false, - "description": "Identity string recorded on every proposal + audit event when this plugin is the writer. Exported as VOUCH_AGENT to the child process.", - "default": "openclaw" - }, - "transport": { - "type": "string", - "required": false, - "description": "MCP wire format. 'stdio' is the canonical surface; 'jsonl' is the AKBP-style newline-delimited fallback for harnesses that can't speak MCP.", - "default": "stdio", - "enum": ["stdio", "jsonl"] - } - }, - "mcpServers": { - "vouch": { - "command": "vouch", - "args": ["serve"], - "env": { - "VOUCH_AGENT": "openclaw" - } - } - }, + "description": "Git-native, review-gated knowledge base. Registers vouch's context engine (cited retrieval + salience reflex + hot memory) and the vouch skills. The kb.* MCP server is deployment config: `openclaw mcp add vouch -- vouch serve`.", + "kind": "context-engine", "skills": [ "adapters/openclaw/skills" ], diff --git a/package.json b/package.json index e8fc4c43..e3b95565 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vouch", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "OpenClaw plugin packaging for vouch. The Python package lives in pyproject.toml; this file only tells OpenClaw's plugin loader which entry module to import and which plugin API range the plugin supports.", "openclaw": { 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/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 d7ce822d..59d5ef90 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 @@ -24,10 +24,14 @@ 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 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 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 @@ -43,7 +47,13 @@ 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_filters import filter_pages, parse_kv from .page_kinds import PageKindError, load_page_kind_registry from .proposals import ( EXPIRE_ACTOR, @@ -148,12 +158,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 +181,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") @@ -264,6 +292,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 [ { @@ -658,6 +733,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 ------------------------------------------------------------ @@ -1389,6 +1521,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] = [] @@ -1398,6 +1531,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: @@ -1517,6 +1652,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: @@ -1535,6 +1711,77 @@ 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)}") + + with suppress(KeyboardInterrupt): + inbox_mod.watch( + store, path, poll_interval=poll_interval, on_result=_report, + ) + 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}") + + +@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 ------------------------------------------------------------ @@ -1718,9 +1965,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/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/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/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/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5f42e16b..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 @@ -38,6 +40,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, @@ -95,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 @@ -235,8 +248,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]: @@ -677,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/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 b825441b..bced1c8d 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,205 @@ 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, + "protected": True, + }, + "voice": { + "description": "how the team sounds in one channel; cites the examples it distills", + "required_citations": True, + "protected": 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/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/page_kinds.py b/src/vouch/page_kinds.py index 2f51c506..cb5d9b5d 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,), @@ -67,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") @@ -101,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/src/vouch/server.py b/src/vouch/server.py index 81fa5c23..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 @@ -29,6 +31,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, @@ -92,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) ============================================ @@ -284,10 +309,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_brain_commands.py b/tests/test_brain_commands.py new file mode 100644 index 00000000..ab0a1faf --- /dev/null +++ b/tests/test_brain_commands.py @@ -0,0 +1,62 @@ +"""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: + # the 2026.6 dialect lists skill *directories*; each brain command must be + # published as a child dir with a SKILL.md + manifest = json.loads((REPO_ROOT / "openclaw.plugin.json").read_text(encoding="utf-8")) + roots = [REPO_ROOT / rel for rel in manifest["skills"]] + for root in roots: + assert root.is_dir(), f"manifest skills entry missing: {root}" + for name in BRAIN_COMMANDS: + stem = name.removesuffix(".md") + assert any( + (root / stem / "SKILL.md").is_file() for root in roots + ), f"brain command {stem} not published as an openclaw skill" + + +@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_capture.py b/tests/test_capture.py index 9c4f2a2e..17961882 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -238,12 +238,84 @@ 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) 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_fetch.py b/tests/test_fetch.py new file mode 100644 index 00000000..bdfcbade --- /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: + 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 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) diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index e299e396..27c0ee2f 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -90,9 +90,14 @@ 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 (cmd_dir / "vouch-standup.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 + 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: @@ -109,6 +114,11 @@ 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/commands/vouch-standup.md", ".claude/settings.json", } diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 00000000..8bc7b75b --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,153 @@ +"""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 +from typing import ClassVar + +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: ClassVar[list[tuple[dict, dict]]] = [] + + 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))) + 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_onboarding.py b/tests/test_onboarding.py index ec60f33b..c0e5fa77 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,111 @@ 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_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() diff --git a/tests/test_openclaw_plugin_manifest.py b/tests/test_openclaw_plugin_manifest.py index af11e915..a7c87273 100644 --- a/tests/test_openclaw_plugin_manifest.py +++ b/tests/test_openclaw_plugin_manifest.py @@ -22,9 +22,14 @@ PACKAGE_JSON_PATH = REPO_ROOT / "package.json" EXTENSION_PATH = REPO_ROOT / "adapters" / "openclaw" / "vouch-context-engine.mjs" SKILL_NAMES = ( + "vouch-ask", + "vouch-followup", "vouch-propose-from-pr", "vouch-recall", + "vouch-record", + "vouch-remember", "vouch-resolve-issue", + "vouch-standup", "vouch-status", ) diff --git a/tests/test_page_filters.py b/tests/test_page_filters.py new file mode 100644 index 00000000..58983b0b --- /dev/null +++ b/tests/test_page_filters.py @@ -0,0 +1,139 @@ +"""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 diff --git a/tests/test_page_kinds.py b/tests/test_page_kinds.py index 0418f7df..267e2c65 100644 --- a/tests/test_page_kinds.py +++ b/tests/test_page_kinds.py @@ -57,6 +57,72 @@ 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", + ) + + +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 ---------------