diff --git a/CHANGELOG.md b/CHANGELOG.md index c61ce19..ee95eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,16 @@ All notable changes to vouch are documented here. Format follows ## [1.1.0] — 2026-07-03 ### Added +- `kb.timeline` / `vouch timeline ` — a read-only chronological + trajectory of an entity's approved claims and relations (#313). orders them + along a time axis oldest-first: `--order effective` uses artifact `created_at`, + `--order decided` recovers approval time from the audit log. `--since` / + `--until` / `--types` / `--limit` filters; `--json` for the machine shape. + superseded/archived claims still appear, flagged by current status; relations + carry `status = null`; pending proposals never appear. pure read — no write + path is reachable from it. registered at all four surfaces (mcp/jsonl/ + capabilities/cli) and attaches `_meta.vouch_salience` when a `session_id` is + passed. - 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/capabilities.py b/src/vouch/capabilities.py index 860bf08..141c30c 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -21,6 +21,7 @@ "kb.digest", "kb.search", "kb.neighbors", + "kb.timeline", "kb.context", "kb.synthesize", "kb.read_page", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 59d5ef9..42960f0 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -39,6 +39,7 @@ from . import stats as stats_mod from . import sync as sync_mod from . import synthesize as synth +from . import timeline as timeline_mod from . import trust as trust_mod from . import vault_sync as vault_sync_mod from . import verify as verify_mod @@ -790,6 +791,87 @@ def pages_cmd( click.echo("no matching pages") +# --- timeline ------------------------------------------------------------- + + +@cli.command() +@click.argument("entity_id") +@click.option( + "--order", + type=click.Choice(["effective", "decided"]), + default="effective", + show_default=True, + help="Time axis: `effective` (artifact created_at) or `decided` (approval " + "time from the audit log).", +) +@click.option( + "--since", default=None, help="Lower bound (iso date/datetime or a duration like 30d)." +) +@click.option("--until", default=None, help="Upper bound (same formats as --since).") +@click.option( + "--types", + default=None, + help="Comma-separated filter on claim type (fact,decision,…), relation type, " + "or the literal `claim` / `relation`.", +) +@click.option("--limit", default=None, type=int, help="Keep only the most recent N entries.") +@click.option("--json", "as_json", is_flag=True, help="Emit the machine shape instead of a table.") +def timeline( + entity_id: str, + order: str, + since: str | None, + until: str | None, + types: str | None, + limit: int | None, + as_json: bool, +) -> None: + """Chronological trajectory of an entity's claims and relations (#313). + + \b + Orders an entity's approved claims + relations along a time axis, oldest + first. Read-only — pending proposals never appear. + + \b + Examples: + vouch timeline person:alice-example + vouch timeline repo:acme --order decided --since 2026-01-01 + vouch timeline concept:auth --types decision,fact --json + """ + store = _load_store() + type_list = [t.strip() for t in types.split(",")] if types else None + try: + result = timeline_mod.build_timeline( + store, + entity_id, + since=metrics_mod.parse_since(since), + until=metrics_mod.parse_since(until), + order=order, + types=type_list, + limit=limit, + ) + except timeline_mod.TimelineError as e: + raise click.ClickException(str(e)) from e + except (ArtifactNotFoundError, KeyError) as e: + raise click.ClickException(f"entity not found: {entity_id}") from e + + if as_json: + _emit_json(result) + return + + ent = result["entity"] + click.echo(f"timeline: {ent['name']} [{ent['type']}] ({ent['id']})") + click.echo(f" order={result['order']} {result['count']} of {result['total']} entries") + click.echo("") + if not result["entries"]: + click.echo(" (no claims or relations for this entity in range)") + return + for entry in result["entries"]: + when = (entry["when"] or "—")[:19] + status = f" <{entry['status']}>" if entry["status"] else "" + click.echo(f" {when} {entry['kind']:<8} {entry['id']}{status}") + click.echo(f" {entry['summary'][:100]}") + + # --- proposals ------------------------------------------------------------ diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5505995..f801d36 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -195,6 +195,26 @@ def _h_neighbors(p: dict) -> dict: ) +def _h_timeline(p: dict) -> dict: + from .metrics import parse_since + from .timeline import build_timeline + + store = _store() + cfg = _load_cfg(store) + session_id = p.get("session_id") + limit = p.get("limit") + result = build_timeline( + store, + p["entity_id"], + since=parse_since(p.get("since")), + until=parse_since(p.get("until")), + order=p.get("order", "effective"), + types=p.get("types"), + limit=int(limit) if limit is not None else None, + ) + return salience_mod.attach_salience(result, store, session_id, cfg) + + def _h_context(p: dict) -> dict: store = _store() query = p["task"] @@ -700,6 +720,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.digest": _h_digest, "kb.search": _h_search, "kb.neighbors": _h_neighbors, + "kb.timeline": _h_timeline, "kb.context": _h_context, "kb.synthesize": _h_synthesize, "kb.read_page": _h_read_page, diff --git a/src/vouch/server.py b/src/vouch/server.py index 6095f00..1b3fd37 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -226,6 +226,39 @@ def kb_neighbors( raise ValueError(str(e)) from e +@mcp.tool() +def kb_timeline( + entity_id: str, + order: str = "effective", + since: str | None = None, + until: str | None = None, + types: list[str] | None = None, + limit: int | None = None, + session_id: str | None = None, +) -> dict[str, Any]: + """Chronological trajectory of an entity's approved claims and relations. + + ``order`` is ``effective`` (artifact ``created_at``) or ``decided`` (approval + time recovered from the audit log). Read-only; pending proposals never + appear. Attaches ``_meta.vouch_salience`` when a ``session_id`` is given. + """ + from .metrics import parse_since + from .timeline import TimelineError, build_timeline + + store = _store() + try: + result = build_timeline( + store, entity_id, + since=parse_since(since), until=parse_since(until), + order=order, types=types, limit=limit, + ) + except ArtifactNotFoundError as e: + raise ValueError(str(e)) from e + except TimelineError as e: + raise ValueError(str(e)) from e + return salience_mod.attach_salience(result, store, session_id, _load_cfg(store)) + + @mcp.tool() def kb_context( task: str, diff --git a/src/vouch/timeline.py b/src/vouch/timeline.py new file mode 100644 index 0000000..67bffb3 --- /dev/null +++ b/src/vouch/timeline.py @@ -0,0 +1,172 @@ +"""``kb.timeline`` — chronological trajectory of an entity (vouchdev/vouch#313). + +``kb.read_entity`` returns an entity and its claims as a flat set; ``kb.neighbors`` +expands graph adjacency at a point in time. Neither answers "what did the KB +learn about this entity, *in what order*?" — a trajectory. The raw material is +already on disk: ``Claim`` and ``Relation`` carry ``created_at``, and decision +time is recoverable from the append-only ``audit.log.jsonl``. This orders an +entity's approved claims and relations along that time axis. + +Pure read. It never proposes, approves, or writes — a viewport over +already-reviewed artifacts, exactly like ``read_entity`` / ``neighbors``. Only +*approved* durable artifacts are read (``list_claims`` / ``list_relations``); +pending proposals never appear. All ordering logic lives here, not in +``storage.py``, which stays pure I/O. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from .audit import read_events +from .metrics import _APPROVE_RE +from .models import Claim, Relation +from .storage import KBStore + +# Ordering axes. ``effective`` orders by the artifact's own ``created_at`` (when +# the fact entered the KB); ``decided`` orders by the moment the proposal that +# produced it was approved, recovered from the audit log. +ORDER_EFFECTIVE = "effective" +ORDER_DECIDED = "decided" +ORDERS = (ORDER_EFFECTIVE, ORDER_DECIDED) + + +class TimelineError(ValueError): + """User-visible bad input (e.g. an unknown ``order``).""" + + +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 _decided_map(store: KBStore) -> dict[str, datetime]: + """artifact id -> approval time, from the authoritative audit stream. + + ``proposals.approve`` logs an approve event with ``object_ids = [proposal_id, + result_id]``; the event's ``created_at`` is the decision time. Artifacts + written outside the proposal path (rare, e.g. a seeded starter KB) simply + have no entry and fall back to their ``created_at``. + """ + out: dict[str, datetime] = {} + for ev in read_events(store.kb_dir): + if _APPROVE_RE.match(ev.event) and len(ev.object_ids) >= 2: + ts = _as_utc(ev.created_at) + if ts is not None: + out[ev.object_ids[1]] = ts + return out + + +def _claim_summary(c: Claim) -> str: + return str(c.text).strip()[:120] or "—" + + +def _relation_summary(r: Relation) -> str: + return f"{r.source} {r.relation.value} {r.target}" + + +def build_timeline( + store: KBStore, + entity_id: str, + *, + since: datetime | None = None, + until: datetime | None = None, + order: str = ORDER_EFFECTIVE, + types: list[str] | None = None, + limit: int | None = None, +) -> dict[str, Any]: + """Order an entity's approved claims + relations along a time axis. + + Entries are ``{when, kind, id, summary, status}``, oldest first + (most-recent-last). ``status`` is the claim's current :class:`ClaimStatus` + (a superseded/archived claim still appears, flagged); relations have no + status, so it is ``None``. When ``limit`` is set, the most recent ``limit`` + entries are kept, still in chronological order. Raises + :class:`~vouch.storage.ArtifactNotFoundError` if the entity does not exist. + """ + if order not in ORDERS: + raise TimelineError(f"order must be one of {ORDERS}, got {order!r}") + if limit is not None and limit < 0: + raise TimelineError("limit must be >= 0") + since = _as_utc(since) + until = _as_utc(until) + + entity = store.get_entity(entity_id) # raises ArtifactNotFoundError + + want = {t.strip() for t in types if t.strip()} if types else None + decided = _decided_map(store) if order == ORDER_DECIDED else {} + + def when_for(artifact_id: str, created: datetime | None) -> datetime | None: + eff = _as_utc(created) + if order == ORDER_DECIDED: + return decided.get(artifact_id) or eff + return eff + + rows: list[dict[str, Any]] = [] + + for c in store.list_claims(): + if entity_id not in c.entities: + continue + # type filter: a claim matches on its ClaimType, or the generic "claim". + if want is not None and c.type.value not in want and "claim" not in want: + continue + rows.append( + { + "_when": when_for(c.id, c.created_at), + "kind": "claim", + "id": c.id, + "summary": _claim_summary(c), + "status": c.status.value, + } + ) + + for r in store.list_relations(): + if entity_id not in (r.source, r.target): + continue + # type filter: a relation matches on its RelationType, or "relation". + if want is not None and r.relation.value not in want and "relation" not in want: + continue + rows.append( + { + "_when": when_for(r.id, r.created_at), + "kind": "relation", + "id": r.id, + "summary": _relation_summary(r), + "status": None, + } + ) + + # window filter on the chosen timestamp + if since is not None: + rows = [e for e in rows if e["_when"] is not None and e["_when"] >= since] + if until is not None: + rows = [e for e in rows if e["_when"] is not None and e["_when"] <= until] + + # oldest first; id tie-break for deterministic output on identical stamps. + # entries with no recoverable timestamp sort to the front (epoch). + _epoch = datetime(1970, 1, 1, tzinfo=UTC) + rows.sort(key=lambda e: (e["_when"] or _epoch, e["id"])) + + total = len(rows) + if limit is not None and limit < total: + rows = rows[total - limit :] # keep the most recent `limit`, still chronological + + entries = [ + {"when": _iso(e["_when"]), "kind": e["kind"], "id": e["id"], + "summary": e["summary"], "status": e["status"]} + for e in rows + ] + + return { + "entity": {"id": entity.id, "name": entity.name, "type": entity.type.value}, + "order": order, + "count": len(entries), + "total": total, + "entries": entries, + } diff --git a/tests/test_timeline.py b/tests/test_timeline.py new file mode 100644 index 0000000..205113c --- /dev/null +++ b/tests/test_timeline.py @@ -0,0 +1,225 @@ +"""`kb.timeline` — chronological entity trajectory (vouchdev/vouch#313). + +Pins ordering (both axes), the since/until/types/limit filters, the +superseded-still-visible case, the four-site registration, and the read-only +invariant (a timeline run adds no audit mutation) against a fixture entity whose +history is known by construction. +""" + +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 count_events, log_event +from vouch.cli import cli +from vouch.models import ( + Claim, + ClaimStatus, + ClaimType, + Entity, + EntityType, + Relation, + RelationType, +) +from vouch.storage import KBStore +from vouch.timeline import TimelineError, build_timeline + +NOW = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _seed(store: KBStore) -> str: + """One entity `acme` with three claims (accrued t-3d, t-2d, t-1d, the last + superseded) and one relation (t-2d). A second entity's claim is present to + prove entity-scoping. Approve events land in the audit log for order=decided. + """ + src = store.put_source(b"evidence") + store.put_entity(Entity(id="acme", name="Acme", type=EntityType.PROJECT)) + store.put_entity(Entity(id="other", name="Other", type=EntityType.PROJECT)) + + def claim(cid, days_ago, *, ctype=ClaimType.FACT, status=ClaimStatus.WORKING, ents=("acme",)): + store.put_claim(Claim( + id=cid, text=f"text {cid}", type=ctype, status=status, + evidence=[src.id], entities=list(ents), + created_at=NOW - timedelta(days=days_ago), + )) + + claim("c1", 3, ctype=ClaimType.FACT) + claim("c2", 2, ctype=ClaimType.DECISION) + claim("c3", 1, ctype=ClaimType.FACT, status=ClaimStatus.SUPERSEDED) + claim("c_other", 1, ents=("other",)) # different entity — must not appear + + store.put_relation(Relation( + id="r1", source="acme", relation=RelationType.DEPENDS_ON, target="other", + evidence=[src.id], created_at=NOW - timedelta(days=2), + )) + + # approval events (order=decided): c1 approved LAST despite earliest created. + def approve(pid, rid, days_ago): + log_event(store.kb_dir, event="proposal.claim.approve", actor="rev", + object_ids=[pid, rid]) + path = store.kb_dir / "audit.log.jsonl" + lines = path.read_text().splitlines() + obj = json.loads(lines[-1]) + obj["created_at"] = (NOW - timedelta(days=days_ago)).isoformat() + lines[-1] = json.dumps(obj, separators=(",", ":"), sort_keys=True) + path.write_text("\n".join(lines) + "\n") + + approve("p3", "c3", 5) # c3: created t-1d but decided t-5d (earliest) + approve("p2", "c2", 4) + approve("p1", "c1", 3) # c1: created t-3d but decided t-3d (latest) + return "acme" + + +# --- ordering ------------------------------------------------------------- + + +def test_effective_order_by_created_at(store: KBStore) -> None: + _seed(store) + tl = build_timeline(store, "acme", order="effective") + ids = [e["id"] for e in tl["entries"]] + # created order: c1(t-3d), then c2 & r1 (both t-2d, id tie-break), then c3(t-1d) + assert ids == ["c1", "c2", "r1", "c3"] + assert tl["count"] == 4 + assert "c_other" not in ids # entity-scoped + + +def test_decided_order_from_audit(store: KBStore) -> None: + _seed(store) + tl = build_timeline(store, "acme", order="decided") + ids = [e["id"] for e in tl["entries"]] + # decided order: c3(t-5d), c2(t-4d), c1(t-3d); r1 has no approve event -> + # falls back to its created_at (t-2d), so it sorts last. + assert ids == ["c3", "c2", "c1", "r1"] + + +# --- filters -------------------------------------------------------------- + + +def test_types_filter_claim_type(store: KBStore) -> None: + _seed(store) + tl = build_timeline(store, "acme", types=["decision"]) + assert [e["id"] for e in tl["entries"]] == ["c2"] + + +def test_types_filter_relation_literal(store: KBStore) -> None: + _seed(store) + tl = build_timeline(store, "acme", types=["relation"]) + assert [e["id"] for e in tl["entries"]] == ["r1"] + assert tl["entries"][0]["status"] is None # relations carry no status + + +def test_since_until_window(store: KBStore) -> None: + _seed(store) + tl = build_timeline( + store, "acme", + since=NOW - timedelta(days=2, hours=1), + until=NOW - timedelta(hours=1), + ) + # only t-2d (c2, r1) and t-1d (c3) fall in the window + assert [e["id"] for e in tl["entries"]] == ["c2", "r1", "c3"] + + +def test_limit_keeps_most_recent(store: KBStore) -> None: + _seed(store) + tl = build_timeline(store, "acme", limit=2) + assert [e["id"] for e in tl["entries"]] == ["r1", "c3"] # newest two, chronological + assert tl["count"] == 2 + assert tl["total"] == 4 + + +# --- status visibility ---------------------------------------------------- + + +def test_superseded_still_visible_flagged(store: KBStore) -> None: + _seed(store) + tl = build_timeline(store, "acme") + c3 = next(e for e in tl["entries"] if e["id"] == "c3") + assert c3["status"] == "superseded" # retired but still shown + + +# --- errors --------------------------------------------------------------- + + +def test_missing_entity_raises(store: KBStore) -> None: + from vouch.storage import ArtifactNotFoundError + + with pytest.raises(ArtifactNotFoundError): + build_timeline(store, "nope") + + +def test_bad_order_raises(store: KBStore) -> None: + _seed(store) + with pytest.raises(TimelineError): + build_timeline(store, "acme", order="sideways") + + +# --- read-only invariant -------------------------------------------------- + + +def test_timeline_writes_nothing(store: KBStore) -> None: + _seed(store) + before = count_events(store.kb_dir) + build_timeline(store, "acme", order="decided") + build_timeline(store, "acme", order="effective", limit=1) + assert count_events(store.kb_dir) == before # no mutation event appended + + +# --- four-site registration ---------------------------------------------- + + +def test_registered_at_all_sites() -> None: + from vouch.capabilities import capabilities + from vouch.jsonl_server import HANDLERS + from vouch.server import kb_timeline # noqa: F401 (mcp tool exists) + + assert "kb.timeline" in set(capabilities().methods) + assert "kb.timeline" in HANDLERS + + +def test_jsonl_handler_runs(store: KBStore, monkeypatch) -> None: + _seed(store) + monkeypatch.chdir(store.root) + from vouch.jsonl_server import HANDLERS + + out = HANDLERS["kb.timeline"]({"entity_id": "acme", "order": "effective"}) + assert [e["id"] for e in out["entries"]] == ["c1", "c2", "r1", "c3"] + + +# --- cli ------------------------------------------------------------------ + + +def test_cli_table(store: KBStore, monkeypatch) -> None: + _seed(store) + monkeypatch.chdir(store.root) + res = CliRunner().invoke(cli, ["timeline", "acme"]) + assert res.exit_code == 0, res.output + assert "timeline: Acme" in res.output + assert "c1" in res.output and "r1" in res.output + + +def test_cli_json(store: KBStore, monkeypatch) -> None: + _seed(store) + monkeypatch.chdir(store.root) + res = CliRunner().invoke(cli, ["timeline", "acme", "--json", "--order", "decided"]) + assert res.exit_code == 0, res.output + doc = json.loads(res.output) + assert doc["order"] == "decided" + assert [e["id"] for e in doc["entries"]] == ["c3", "c2", "c1", "r1"] + + +def test_cli_missing_entity(store: KBStore, monkeypatch) -> None: + _seed(store) + monkeypatch.chdir(store.root) + res = CliRunner().invoke(cli, ["timeline", "ghost"]) + assert res.exit_code != 0 + assert "not found" in res.output.lower()