diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c53359..6d2d3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ All notable changes to vouch are documented here. Format follows committed SVGs stay reproducible (#286). ### Added +- `vouch digest` — a read-only operator briefing (#324). folds four viewports + over an existing `.vouch/` into one glance: pending proposals awaiting review + (oldest first), approvals/rejections in a `--since` window (from the + authoritative audit log), active claims aged past `--stale-days`, and + citation-coverage movement over the window. `--format text|json|markdown`; + the `json` shape is a stable `to_dict()` schema. read-only by construction — + it composes `metrics.compute` and `store` reads and writes nothing, so the + review gate is untouched. defaults to a 7-day window since a digest is + inherently "since last look". - auto-capture: claude code sessions are harvested via hooks and filed as a single pending session-summary proposal for human approval. a `PostToolUse` hook (`vouch capture observe`) appends compact tool-use observations to an diff --git a/src/vouch/cli.py b/src/vouch/cli.py index d7ce822..55a7242 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -24,6 +24,7 @@ from . import __version__, bundle, health, volunteer_context from . import audit as audit_mod from . import capture as capture_mod +from . import digest as digest_mod from . import install_adapter as install_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -658,6 +659,83 @@ def metrics( ) +# --- digest --------------------------------------------------------------- + + +@cli.command() +@click.option( + "--since", + default=digest_mod.DEFAULT_SINCE, + show_default=True, + help="Window: a duration like 7d / 12h / 2w, an ISO date like 2026-01-01, " + "or 'all' (same specs as `vouch metrics --since`).", +) +@click.option("--until", default=None, help="Upper bound for the window (same formats as --since).") +@click.option( + "--stale-days", + default=digest_mod.DEFAULT_STALE_DAYS, + show_default=True, + type=int, + help="A claim un-confirmed for this many days counts as stale " + "(matches `vouch metrics` / `vouch lint --stale-days`).", +) +@click.option( + "--limit", + default=digest_mod.DEFAULT_LIMIT, + show_default=True, + type=int, + help="Cap each list at N rows (0 = show none).", +) +@click.option( + "--format", + "fmt", + type=click.Choice(["text", "json", "markdown"]), + default="text", + show_default=True, + help="Output format. `json` is the stable to_dict() schema.", +) +def digest(since: str | None, until: str | None, stale_days: int, limit: int, fmt: str) -> None: + """Read-only operator briefing: what needs review, what changed (#324). + + \b + Folds four viewports over an existing .vouch/ into one glance: + • pending proposals awaiting review (oldest first) + • approvals / rejections in the window (from the audit log) + • active claims aged past the stale threshold + • citation-coverage movement over the window + + \b + Examples: + vouch digest # last 7 days, human text + vouch digest --since 2w # since two weeks ago + vouch digest --format markdown # paste into a standup note + vouch digest --format json # stable schema for a hook + + Writes nothing — no proposals, no audit events, no files. The --json + shape is a documented, stable to_dict() schema. + """ + store = _load_store() + try: + since_dt = metrics_mod.parse_since(since) + until_dt = metrics_mod.parse_since(until) + d = digest_mod.compute_digest( + store, + since=since_dt, + until=until_dt, + stale_after_days=stale_days, + limit=limit, + ) + except metrics_mod.MetricsError as e: + raise click.ClickException(str(e)) from e + + if fmt == "json": + _emit_json(d.to_dict()) + elif fmt == "markdown": + click.echo(digest_mod.render_markdown(d), nl=False) + else: + click.echo(digest_mod.render_text(d), nl=False) + + # --- proposals ------------------------------------------------------------ diff --git a/src/vouch/digest.py b/src/vouch/digest.py new file mode 100644 index 0000000..00b69ae --- /dev/null +++ b/src/vouch/digest.py @@ -0,0 +1,510 @@ +"""``vouch digest`` — a read-only operator briefing (vouchdev/vouch#324). + +``vouch metrics`` answers "is the review gate healthy?" with aggregate gauges +(rates, ratios, percentiles) built for graphing and alerting. ``vouch digest`` +is its human-facing counterpart: named, oldest-first *lists* of what to act on +now, folded into one glance for an operator returning to a KB after a day or a +week — "n proposals awaiting review, what got decided while I was away, which +claims aged past the freshness threshold, and whether citation coverage moved". + +Everything here is a **viewport** over sources that already exist on disk: + +* ``store.list_proposals(PENDING)`` — the review backlog (same set as + ``kb.list_pending``), oldest ``proposed_at`` first. +* ``.vouch/audit.log.jsonl`` via :func:`audit.read_events` — the authoritative + approve/reject stream, titled from the ``decided/`` record. +* the claim artifacts — active claims whose freshness anchor is older than + ``--stale-days``, listed rather than merely counted. +* ``metrics.compute`` — reused verbatim for the authoritative current citation + coverage; the digest additionally computes coverage over the cohort of claims + that already existed at the window start and reports the movement. + +It is read-only by construction: it calls ``list_*`` / ``read_events`` and +formats. It never routes through ``propose_*`` / ``approve`` / ``reject``, so +the review gate is untouched — nothing here can create or edit knowledge, and +there is nothing to approve. No new on-disk state, no schema migration. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any + +from . import metrics as metrics_mod +from .audit import read_events +from .metrics import _APPROVE_RE, _REJECT_RE, DEFAULT_STALE_DAYS, MetricsError +from .models import ClaimStatus, ProposalStatus +from .storage import ArtifactNotFoundError, KBStore + +# A digest is inherently "since last look", so unlike ``vouch metrics`` (which +# defaults to all of history) it windows to a short recent span by default. +DEFAULT_SINCE = "7d" + +# How many rows each list is capped at — a briefing stays a briefing. +DEFAULT_LIMIT = 10 + +# Bumped independently of ``metrics.SCHEMA_VERSION``; the ``to_dict`` shape is +# the contract a notification hook or standup script builds on. +SCHEMA_VERSION = 1 + +# Statuses that are *not* live corpus — mirrors ``metrics._fill_corpus_metrics`` +# so "active" and "stale" mean exactly what ``vouch metrics`` / ``vouch lint`` +# mean. +_RETIRED = frozenset( + {ClaimStatus.SUPERSEDED, ClaimStatus.ARCHIVED, ClaimStatus.REDACTED} +) + + +def _as_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) + + +def _iso(dt: datetime | None) -> str | None: + return dt.isoformat() if dt is not None else None + + +def _preview(payload: dict[str, Any]) -> str: + """One-line summary of a proposal payload, matching ``vouch pending``.""" + text = payload.get("text") or payload.get("title") or payload.get("name") or payload.get("id") + return str(text).strip() if text else "—" + + +# --- result containers ---------------------------------------------------- + + +@dataclass(frozen=True) +class PendingItem: + id: str + kind: str + proposed_by: str + proposed_at: datetime + age_seconds: float + preview: str + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "kind": self.kind, + "proposed_by": self.proposed_by, + "proposed_at": _iso(self.proposed_at), + "age_seconds": self.age_seconds, + "preview": self.preview, + } + + +@dataclass(frozen=True) +class DecisionItem: + proposal_id: str + kind: str + decision: str # "approve" | "reject" + actor: str + decided_at: datetime + title: str + + def to_dict(self) -> dict[str, Any]: + return { + "proposal_id": self.proposal_id, + "kind": self.kind, + "decision": self.decision, + "actor": self.actor, + "decided_at": _iso(self.decided_at), + "title": self.title, + } + + +@dataclass(frozen=True) +class StaleItem: + id: str + anchor_at: datetime + age_days: float + preview: str + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "anchor_at": _iso(self.anchor_at), + "age_days": self.age_days, + "preview": self.preview, + } + + +@dataclass +class Digest: + """The full briefing. ``to_dict`` is the stable, documented schema.""" + + since: datetime | None = None + until: datetime | None = None + generated_at: datetime | None = None + stale_after_days: int = DEFAULT_STALE_DAYS + limit: int = DEFAULT_LIMIT + + pending_total: int = 0 + pending: list[PendingItem] = field(default_factory=list) + + approvals: int = 0 + rejections: int = 0 + decisions: list[DecisionItem] = field(default_factory=list) + + stale_total: int = 0 + stale: list[StaleItem] = field(default_factory=list) + + citation_coverage_now: float | None = None + citation_coverage_at_since: float | None = None + citation_coverage_delta: float | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "window": { + "since": _iso(self.since), + "until": _iso(self.until), + "generated_at": _iso(self.generated_at), + }, + "stale_after_days": self.stale_after_days, + "limit": self.limit, + "pending": { + "total": self.pending_total, + "items": [p.to_dict() for p in self.pending], + }, + "decisions": { + "approvals": self.approvals, + "rejections": self.rejections, + "total": self.approvals + self.rejections, + "items": [d.to_dict() for d in self.decisions], + }, + "stale": { + "total": self.stale_total, + "items": [s.to_dict() for s in self.stale], + }, + "citation_coverage": { + "now": self.citation_coverage_now, + "at_since": self.citation_coverage_at_since, + "delta": self.citation_coverage_delta, + }, + } + + +# --- computation ---------------------------------------------------------- + + +def compute_digest( + store: KBStore, + *, + since: datetime | None = None, + until: datetime | None = None, + stale_after_days: int = DEFAULT_STALE_DAYS, + limit: int = DEFAULT_LIMIT, + now: datetime | None = None, +) -> Digest: + """Build the :class:`Digest` briefing for ``store``. + + Read-only: streams the audit log once and loads the artifacts once. All + predicates ("active", "stale", "cited") mirror ``metrics`` so the two + surfaces never disagree. + """ + now = _as_utc(now) or datetime.now(UTC) + since = _as_utc(since) + until = _as_utc(until) + if since is not None and until is not None and since > until: + raise MetricsError( + f"--since ({since.isoformat()}) is after --until ({until.isoformat()})" + ) + if stale_after_days < 0: + raise MetricsError("--stale-days must be >= 0") + if limit < 0: + raise MetricsError("--limit must be >= 0") + + d = Digest( + since=since, + until=until, + generated_at=now, + stale_after_days=stale_after_days, + limit=limit, + ) + + _fill_pending(store, d, now=now) + _fill_decisions(store, d) + _fill_stale(store, d, now=now, stale_after_days=stale_after_days) + _fill_coverage(store, d, since=since, until=until, stale_after_days=stale_after_days, now=now) + return d + + +def _fill_pending(store: KBStore, d: Digest, *, now: datetime) -> None: + """Pending proposals, oldest ``proposed_at`` first — the actionable backlog + behind ``metrics.pending_now``, rendered as a list rather than a count.""" + pending = store.list_proposals(ProposalStatus.PENDING) + pending.sort(key=lambda pr: (_as_utc(pr.proposed_at) or now, pr.id)) + d.pending_total = len(pending) + for pr in pending[: d.limit]: + anchor = _as_utc(pr.proposed_at) or now + d.pending.append( + PendingItem( + id=pr.id, + kind=pr.kind.value, + proposed_by=pr.proposed_by, + proposed_at=anchor, + age_seconds=max(0.0, (now - anchor).total_seconds()), + preview=_preview(pr.payload), + ) + ) + + +def _fill_decisions(store: KBStore, d: Digest) -> None: + """Approvals/rejections in the window from the authoritative audit stream, + newest first, titled from the ``decided/`` record when it's still on disk.""" + hits: list[DecisionItem] = [] + approvals = 0 + rejections = 0 + for ev in read_events(store.kb_dir): + ts = _as_utc(ev.created_at) + if ts is None: + continue + if d.since is not None and ts < d.since: + continue + if d.until is not None and ts > d.until: + continue + + am = _APPROVE_RE.match(ev.event) + rm = None if am else _REJECT_RE.match(ev.event) + m = am or rm + if m is None: + continue + + pid = ev.object_ids[0] if ev.object_ids else "—" + hits.append( + DecisionItem( + proposal_id=pid, + kind=m.group("kind"), + decision="approve" if am else "reject", + actor=ev.actor, + decided_at=ts, + title=_decision_title(store, pid), + ) + ) + if am: + approvals += 1 + else: + rejections += 1 + + d.approvals = approvals + d.rejections = rejections + # newest first; id tie-break for deterministic output on identical stamps. + hits.sort(key=lambda h: (h.decided_at, h.proposal_id), reverse=True) + d.decisions = hits[: d.limit] + + +def _decision_title(store: KBStore, pid: str) -> str: + """Best-effort payload title for a decided proposal. The audit log is the + authority for *that* a decision happened; the ``decided/`` record (which a + KB may prune) only supplies a friendly title, so its absence is not fatal.""" + if pid == "—": + return "—" + try: + return _preview(store.get_proposal(pid).payload) + except (ArtifactNotFoundError, ValueError): + return pid + + +def _fill_stale( + store: KBStore, d: Digest, *, now: datetime, stale_after_days: int +) -> None: + """Active claims whose freshness anchor is older than the threshold — the + exact predicate in ``metrics._fill_corpus_metrics`` / ``vouch lint``, listed + oldest first rather than merely counted.""" + threshold = timedelta(days=stale_after_days) + rows: list[StaleItem] = [] + for c in store.list_claims(): + if c.status in _RETIRED: + continue + anchor = _as_utc(c.last_confirmed_at or c.updated_at or c.created_at) + if anchor is None or (now - anchor) <= threshold: + continue + rows.append( + StaleItem( + id=c.id, + anchor_at=anchor, + age_days=(now - anchor).total_seconds() / 86400.0, + preview=str(c.text).strip()[:120] or "—", + ) + ) + rows.sort(key=lambda s: (s.anchor_at, s.id)) # oldest anchor first + d.stale_total = len(rows) + d.stale = rows[: d.limit] + + +def _fill_coverage( + store: KBStore, + d: Digest, + *, + since: datetime | None, + until: datetime | None, + stale_after_days: int, + now: datetime, +) -> None: + """Citation-coverage movement. + + ``now`` is the authoritative value, reused verbatim from ``metrics.compute`` + so the digest never disagrees with ``vouch metrics``. ``at_since`` is the + same coverage restricted to the cohort of claims that already existed at the + window start (``created_at <= since``), evaluated against the *current* + source/evidence set — it shows how coverage moved as newer claims landed, + not a historical snapshot (vouch does not retain past citation state). With + an unbounded window (``since is None``) there is no baseline, so ``at_since`` + and ``delta`` are ``None``. + """ + m = metrics_mod.compute( + store, + since=since, + until=until, + stale_after_days=stale_after_days, + top_actors=0, + now=now, + ) + d.citation_coverage_now = m.citation_coverage + + if since is None: + return + + resolvable = {s.id for s in store.list_sources()} | {e.id for e in store.list_evidence()} + cohort_total = 0 + cohort_cited = 0 + for c in store.list_claims(): + created = _as_utc(c.created_at) + if created is None or created > since: + continue + cohort_total += 1 + refs = list(c.evidence) + if refs and all(r in resolvable for r in refs): + cohort_cited += 1 + + if cohort_total: + d.citation_coverage_at_since = cohort_cited / cohort_total + if d.citation_coverage_now is not None: + d.citation_coverage_delta = d.citation_coverage_now - d.citation_coverage_at_since + + +# --- rendering ------------------------------------------------------------ + + +def _fmt_age(seconds: float) -> str: + if seconds < 90: + return f"{seconds:.0f}s" + if seconds < 5400: + return f"{seconds / 60:.0f}m" + if seconds < 172800: + return f"{seconds / 3600:.0f}h" + return f"{seconds / 86400:.0f}d" + + +def _fmt_pct(x: float | None) -> str: + return "—" if x is None else f"{x * 100:.1f}%" + + +def _fmt_delta(x: float | None) -> str: + if x is None: + return "—" + sign = "+" if x >= 0 else "" + return f"{sign}{x * 100:.1f}pp" + + +def _window_label(d: Digest) -> str: + window = "all history" if d.since is None else f"since {d.since.isoformat()}" + if d.until is not None: + window += f" until {d.until.isoformat()}" + return window + + +def render_text(d: Digest) -> str: + """Human briefing (the default). Lists are already capped by ``limit``.""" + out: list[str] = [] + out.append(f"vouch digest ({_window_label(d)})") + out.append("") + + out.append(f" pending review ({d.pending_total})") + if d.pending: + for p in d.pending: + out.append(f" {p.id} [{p.kind}] by {p.proposed_by} {_fmt_age(p.age_seconds)} old") + out.append(f" {p.preview[:100]}") + else: + out.append(" (nothing awaiting review)") + if d.pending_total > len(d.pending): + out.append(f" … and {d.pending_total - len(d.pending)} more") + out.append("") + + out.append(f" decided in window ({d.approvals} approved / {d.rejections} rejected)") + if d.decisions: + for de in d.decisions: + verb = "approved" if de.decision == "approve" else "rejected" + out.append(f" {verb} {de.proposal_id} [{de.kind}] by {de.actor}") + out.append(f" {de.title[:100]}") + else: + out.append(" (no decisions in window)") + out.append("") + + out.append(f" stale claims ({d.stale_total} past {d.stale_after_days}d)") + if d.stale: + for s in d.stale: + out.append(f" {s.id} {s.age_days:.0f}d old") + out.append(f" {s.preview[:100]}") + else: + out.append(" (nothing stale)") + if d.stale_total > len(d.stale): + out.append(f" … and {d.stale_total - len(d.stale)} more") + out.append("") + + out.append(" citation coverage") + out.append( + f" now {_fmt_pct(d.citation_coverage_now)} " + f"(at window start {_fmt_pct(d.citation_coverage_at_since)}, " + f"moved {_fmt_delta(d.citation_coverage_delta)})" + ) + return "\n".join(out) + "\n" + + +def render_markdown(d: Digest) -> str: + """A markdown block suitable for pasting into a notification or standup.""" + out: list[str] = [] + out.append(f"## vouch digest — {_window_label(d)}") + out.append("") + + out.append(f"**Pending review** ({d.pending_total})") + if d.pending: + for p in d.pending: + out.append( + f"- `{p.id}` [{p.kind}] by {p.proposed_by}, " + f"{_fmt_age(p.age_seconds)} old — {p.preview[:100]}" + ) + else: + out.append("- _nothing awaiting review_") + if d.pending_total > len(d.pending): + out.append(f"- _…and {d.pending_total - len(d.pending)} more_") + out.append("") + + out.append(f"**Decided in window** ({d.approvals} approved / {d.rejections} rejected)") + if d.decisions: + for de in d.decisions: + verb = "approved" if de.decision == "approve" else "rejected" + out.append(f"- {verb} `{de.proposal_id}` [{de.kind}] by {de.actor} — {de.title[:100]}") + else: + out.append("- _no decisions in window_") + out.append("") + + out.append(f"**Stale claims** ({d.stale_total} past {d.stale_after_days}d)") + if d.stale: + for s in d.stale: + out.append(f"- `{s.id}` {s.age_days:.0f}d old — {s.preview[:100]}") + else: + out.append("- _nothing stale_") + if d.stale_total > len(d.stale): + out.append(f"- _…and {d.stale_total - len(d.stale)} more_") + out.append("") + + out.append( + f"**Citation coverage** — now {_fmt_pct(d.citation_coverage_now)} " + f"(at window start {_fmt_pct(d.citation_coverage_at_since)}, " + f"moved {_fmt_delta(d.citation_coverage_delta)})" + ) + return "\n".join(out) + "\n" diff --git a/tests/test_digest.py b/tests/test_digest.py new file mode 100644 index 0000000..329c65e --- /dev/null +++ b/tests/test_digest.py @@ -0,0 +1,279 @@ +"""`vouch digest` — read-only operator briefing (vouchdev/vouch#324). + +These tests pin the stable to_dict() schema and the four viewports against a +fixture KB whose contents are known by construction, and assert the load-bearing +invariant: a digest writes *nothing* (no proposals, no audit events, no files). +""" + +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.audit import log_event +from vouch.cli import cli +from vouch.digest import ( + DEFAULT_LIMIT, + DEFAULT_SINCE, + Digest, + compute_digest, + render_markdown, + render_text, +) +from vouch.metrics import MetricsError +from vouch.models import Claim, ClaimStatus, Proposal, ProposalKind +from vouch.storage import KBStore + +NOW = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +# --- fixture builder ------------------------------------------------------ + + +def _ev(kb_dir: Path, event: str, actor: str, ids: list[str], ts: datetime) -> None: + """Append an audit event, then rewrite its created_at to ``ts`` — the same + deterministic-timestamp trick test_metrics uses, against the real reader.""" + log_event(kb_dir, event=event, actor=actor, object_ids=ids) + path = kb_dir / "audit.log.jsonl" + lines = path.read_text(encoding="utf-8").splitlines() + obj = json.loads(lines[-1]) + obj["created_at"] = ts.isoformat() + lines[-1] = json.dumps(obj, separators=(",", ":"), sort_keys=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _pending(store: KBStore, pid: str, *, kind=ProposalKind.CLAIM, by="alice", at, payload): + store.put_proposal( + Proposal(id=pid, kind=kind, proposed_by=by, proposed_at=at, payload=payload) + ) + + +def _seed(store: KBStore) -> None: + """A KB with known-by-construction contents. + + Pending (3): p_old (2d ago), p_mid (1d ago), p_new (1h ago). + Decisions in the audit log: 2 approvals + 1 rejection inside a recent + window, 1 approval 100d ago (outside a 7d window). + Claims (3): c_fresh (confirmed today), c_stale (300d), c_archived (retired). + """ + src = store.put_source(b"evidence-bytes") + + _pending(store, "p_old", by="alice", at=NOW - timedelta(days=2), + payload={"text": "oldest pending"}) + _pending(store, "p_mid", by="bob", at=NOW - timedelta(days=1), + payload={"title": "middle pending"}) + _pending(store, "p_new", by="alice", at=NOW - timedelta(hours=1), + payload={"name": "newest pending"}) + + store.put_claim(Claim(id="c_fresh", text="fresh claim", evidence=[src.id], + last_confirmed_at=NOW - timedelta(days=1))) + store.put_claim(Claim(id="c_stale", text="stale claim", evidence=[src.id], + last_confirmed_at=NOW - timedelta(days=300))) + store.put_claim(Claim(id="c_archived", text="archived claim", evidence=[src.id], + status=ClaimStatus.ARCHIVED, + last_confirmed_at=NOW - timedelta(days=400))) + + kb = store.kb_dir + _ev(kb, "proposal.claim.approve", "carol", ["p_a", "c_fresh"], NOW - timedelta(days=2)) + _ev(kb, "proposal.page.reject", "carol", ["p_b"], NOW - timedelta(days=1)) + _ev(kb, "proposal.claim.approve", "dave", ["p_c", "c_stale"], NOW - timedelta(hours=6)) + # well outside a 7d window — must not appear: + _ev(kb, "proposal.claim.approve", "dave", ["p_z", "c_old"], NOW - timedelta(days=100)) + + +# --- pending viewport ----------------------------------------------------- + + +def test_pending_oldest_first(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=None, now=NOW) + assert d.pending_total == 3 + assert [p.id for p in d.pending] == ["p_old", "p_mid", "p_new"] + # preview pulls text / title / name in that order + assert d.pending[0].preview == "oldest pending" + assert d.pending[1].preview == "middle pending" + assert d.pending[2].preview == "newest pending" + # age is measured from proposed_at to now + assert d.pending[0].age_seconds == pytest.approx(2 * 86400) + + +def test_pending_limit_caps_but_total_is_full(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=None, limit=1, now=NOW) + assert d.pending_total == 3 + assert [p.id for p in d.pending] == ["p_old"] + + +# --- decisions viewport --------------------------------------------------- + + +def test_decisions_window_filtering(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=NOW - timedelta(days=7), now=NOW) + # the 100d-old approval is excluded; 2 approve + 1 reject remain + assert d.approvals == 2 + assert d.rejections == 1 + ids = {de.proposal_id for de in d.decisions} + assert ids == {"p_a", "p_b", "p_c"} + # newest first + assert d.decisions[0].proposal_id == "p_c" + + +def test_decisions_all_history(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=None, now=NOW) + assert d.approvals == 3 # includes the 100d-old one + assert d.rejections == 1 + + +# --- stale viewport ------------------------------------------------------- + + +def test_stale_uses_metrics_predicate(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=None, stale_after_days=180, now=NOW) + # c_stale is stale; c_fresh is fresh; c_archived is retired (exempt) + assert d.stale_total == 1 + assert d.stale[0].id == "c_stale" + assert d.stale[0].age_days == pytest.approx(300, abs=1) + + +# --- citation-coverage delta ---------------------------------------------- + + +def test_citation_delta_none_when_unbounded(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=None, now=NOW) + assert d.citation_coverage_now == pytest.approx(1.0) + assert d.citation_coverage_at_since is None + assert d.citation_coverage_delta is None + + +def test_citation_delta_reports_movement(store: KBStore) -> None: + # baseline claim cited before the cutoff; a new UNCITED claim lands after it, + # dragging current coverage below the at-cutoff cohort coverage. + src = store.put_source(b"bytes") + store.put_claim(Claim(id="c_before", text="before", evidence=[src.id], + created_at=NOW - timedelta(days=10))) + # write an uncited claim directly (put_claim refuses unresolvable citations) + import yaml + broken = Claim(id="c_after", text="after", evidence=["missing-src"], + created_at=NOW - timedelta(days=1)) + (store.kb_dir / "claims" / "c_after.yaml").write_text( + yaml.safe_dump(broken.model_dump(mode="json")), encoding="utf-8") + + cutoff = NOW - timedelta(days=5) + d = compute_digest(store, since=cutoff, now=NOW) + assert d.citation_coverage_at_since == pytest.approx(1.0) # only c_before existed + assert d.citation_coverage_now == pytest.approx(0.5) # 1 of 2 cited now + assert d.citation_coverage_delta == pytest.approx(-0.5) + + +# --- empty kb ------------------------------------------------------------- + + +def test_empty_kb(store: KBStore) -> None: + d = compute_digest(store, since=None, now=NOW) + assert d.pending_total == 0 + assert d.pending == [] + assert d.approvals == 0 and d.rejections == 0 + assert d.stale_total == 0 + assert d.citation_coverage_now is None + # renders without error on an empty kb + assert "vouch digest" in render_text(d) + assert "vouch digest" in render_markdown(d) + + +# --- read-only invariant -------------------------------------------------- + + +def test_digest_writes_nothing(store: KBStore) -> None: + _seed(store) + + def snapshot() -> dict[str, tuple[float, int]]: + snap: dict[str, tuple[float, int]] = {} + for p in sorted(store.kb_dir.rglob("*")): + if p.is_file(): + st = p.stat() + snap[str(p.relative_to(store.kb_dir))] = (st.st_mtime, st.st_size) + return snap + + before = snapshot() + compute_digest(store, since=NOW - timedelta(days=7), now=NOW) + compute_digest(store, since=None, now=NOW) + after = snapshot() + assert before == after # no file created, modified, or grown + + +# --- schema --------------------------------------------------------------- + + +def test_to_dict_schema(store: KBStore) -> None: + _seed(store) + d = compute_digest(store, since=NOW - timedelta(days=7), now=NOW) + doc = d.to_dict() + assert doc["schema_version"] == 1 + assert set(doc) == { + "schema_version", "window", "stale_after_days", "limit", + "pending", "decisions", "stale", "citation_coverage", + } + assert set(doc["pending"]) == {"total", "items"} + assert set(doc["decisions"]) == {"approvals", "rejections", "total", "items"} + assert doc["decisions"]["total"] == 3 + assert set(doc["citation_coverage"]) == {"now", "at_since", "delta"} + # window is round-trippable json + json.dumps(doc) + + +def test_bad_since_raises(store: KBStore) -> None: + with pytest.raises(MetricsError): + compute_digest(store, since=NOW, until=NOW - timedelta(days=1), now=NOW) + with pytest.raises(MetricsError): + compute_digest(store, stale_after_days=-1, now=NOW) + + +# --- cli ------------------------------------------------------------------ + + +def test_cli_text_default(store: KBStore, monkeypatch, tmp_path) -> None: + _seed(store) + monkeypatch.chdir(tmp_path) + res = CliRunner().invoke(cli, ["digest", "--since", "all"]) + assert res.exit_code == 0, res.output + assert "vouch digest" in res.output + assert "pending review" in res.output + assert "p_old" in res.output + + +def test_cli_json_is_stable_schema(store: KBStore, monkeypatch, tmp_path) -> None: + _seed(store) + monkeypatch.chdir(tmp_path) + res = CliRunner().invoke(cli, ["digest", "--since", "7d", "--format", "json"]) + assert res.exit_code == 0, res.output + doc = json.loads(res.output) + assert doc["schema_version"] == 1 + assert doc["pending"]["total"] == 3 + + +def test_cli_markdown(store: KBStore, monkeypatch, tmp_path) -> None: + _seed(store) + monkeypatch.chdir(tmp_path) + res = CliRunner().invoke(cli, ["digest", "--format", "markdown", "--since", "all"]) + assert res.exit_code == 0, res.output + assert res.output.startswith("## vouch digest") + + +def test_cli_defaults_exposed(store: KBStore) -> None: + # guard the documented defaults the help text promises + assert DEFAULT_SINCE == "7d" + assert DEFAULT_LIMIT == 10 + assert isinstance(Digest().to_dict(), dict)