From 4fc4ba5f36c76addc7ab3282cbb723e4a7f676d9 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:08:19 +0900 Subject: [PATCH 1/2] feat(goals): first-class review-gated in-flight objectives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vouch modelled everything a project knows and nothing about what it is doing. an agent re-orienting after a compaction could recall facts and decisions but not intent — "mid-migration to typed config", "release blocked on the audit-race fix" — which only ever lived as prose in a session summary, unqueryable and status-less. adds `Goal` as a first-class artifact carrying GoalStatus open / done / abandoned / blocked, proposed and approved through exactly the same machinery as every other write. `propose_goal` files a pending proposal; approve() writes diffable yaml under `.vouch/goals/`. two invariants carry the review gate through: - approval is pinned to `open`. a payload claiming any other status is refused at the precheck, because approving it would put a transition on disk that never passed the lifecycle path and so never reached the audit log. - `lifecycle.set_goal_status` is the only mutation path. it appends a `goal.status` event to audit.log.jsonl and a row to the goal's own append-only history. a test asserts no second caller of store.update_goal exists anywhere in the package. open goals resurface oldest-first — the objective open longest is the one most likely to be stale — in `vouch digest` and in the SessionStart recall digest, viewer-scoped like every other retrieval surface. registered on all four surfaces plus the hot-memory coverage map and the cli-mirror table. tests/test_goals.py covers propose→approve→ transition, the no-bypass invariants, and both digest surfaces. closes #427 --- CHANGELOG.md | 17 +++ src/vouch/capabilities.py | 3 + src/vouch/cli.py | 71 +++++++++++ src/vouch/digest.py | 39 ++++++ src/vouch/goals.py | 49 ++++++++ src/vouch/hot_memory.py | 3 + src/vouch/jsonl_server.py | 51 ++++++++ src/vouch/lifecycle.py | 63 ++++++++++ src/vouch/models.py | 60 +++++++++ src/vouch/proposals.py | 90 +++++++++++++- src/vouch/recall.py | 15 ++- src/vouch/server.py | 66 ++++++++++ src/vouch/storage.py | 63 +++++++++- tests/test_capabilities.py | 2 + tests/test_goals.py | 247 +++++++++++++++++++++++++++++++++++++ 15 files changed, 829 insertions(+), 10 deletions(-) create mode 100644 src/vouch/goals.py create mode 100644 tests/test_goals.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8405f9db..ecd8bc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- **first-class goals — review-gated in-flight objectives** (#427): vouch could + record everything a project *knows* and nothing about what it is *doing*, so + an agent re-orienting after a compaction recovered facts and decisions but + not intent ("mid-migration to typed config", "release blocked on the + audit-race fix") — that lived as prose in a session summary, unqueryable. + Adds a `Goal` artifact with a `GoalStatus` of `open` / `done` / `abandoned` / + `blocked`, taking the same route as every other write: `kb.propose_goal` + files a pending proposal, a human approves it, and the goal lands as diffable + yaml under `.vouch/goals/`. Approval is pinned to `open` — a proposal cannot + land a goal that is already `done`, which would put a transition on disk that + never passed the lifecycle path. Every later move goes through + `lifecycle.set_goal_status`, the single write path, which appends a + `goal.status` event to `audit.log.jsonl` and a row to the goal's own + append-only `history`. Open goals resurface oldest-first in `vouch digest` + and in the SessionStart recall digest, so a returning operator or a fresh + agent session sees what is in flight before it picks something up. + `vouch propose-goal`, `vouch goals`, `vouch goal-status`, plus MCP and JSONL. - **`kb.effectiveness` — is this claim earning its keep?** (#426): a read-only, measurement-only signal ranking approved artifacts by how the sessions they were surfaced into ended. Per artifact it reports good/bad session counts, an diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 5fecb9dc..18436c6f 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -61,6 +61,9 @@ "kb.propose_entity", "kb.propose_relation", "kb.propose_delete", + "kb.propose_goal", + "kb.list_goals", + "kb.set_goal_status", "kb.approve", "kb.reject", "kb.reject_extracted", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index d1e27ca1..b3c0da63 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -35,6 +35,7 @@ from . import contradictions as contradictions_mod from . import digest as digest_mod from . import fetch as fetch_mod +from . import goals as goals_mod from . import hub as hub_mod from . import inbox as inbox_mod from . import install_adapter as install_mod @@ -79,6 +80,7 @@ propose_claim, propose_delete, propose_entity, + propose_goal, propose_page, propose_relation, reject_auto_extracted, @@ -2566,6 +2568,75 @@ def notify_test(url: str, secret: str | None) -> None: sys.exit(1) +# --- goals ---------------------------------------------------------------- + + +@cli.command(name="propose-goal") +@click.option("--title", required=True) +@click.option("--detail", default=None) +@click.option("--claim", "claims", multiple=True, help="claim id this goal concerns") +@click.option("--entity", "entities", multiple=True, help="entity id this goal concerns") +@click.option("--tag", "tags", multiple=True) +@click.option("--rationale", default=None) +def propose_goal_cmd( + title: str, + detail: str | None, + claims: tuple[str, ...], + entities: tuple[str, ...], + tags: tuple[str, ...], + rationale: str | None, +) -> None: + """Propose an in-flight objective for review.""" + store = _load_store() + with _cli_errors(): + pr = propose_goal( + store, + title=title, + detail=detail, + claims=list(claims), + entities=list(entities), + tags=list(tags), + rationale=rationale, + proposed_by=_whoami(), + ) + click.echo(pr.id) + + +@cli.command(name="goals") +@click.option( + "--status", + default="open", + show_default=True, + help="goal status to list, or 'all' for every goal", +) +def list_goals_cmd(status: str) -> None: + """List approved goals, oldest first.""" + store = _load_store() + with _cli_errors(): + found = goals_mod.list_goals( + store, status=None if status == "all" else status + ) + if not found: + click.echo(f"no {status} goals" if status != "all" else "no goals") + return + for goal in found: + click.echo(f"{goal.id:50} [{goal.status.value}] {goal.title}") + + +@cli.command(name="goal-status") +@click.argument("goal_id") +@click.argument("status") +@click.option("--reason", default=None) +def set_goal_status_cmd(goal_id: str, status: str, reason: str | None) -> None: + """Move a goal to open / done / abandoned / blocked.""" + store = _load_store() + with _cli_errors(): + goal = life.set_goal_status( + store, goal_id=goal_id, status=status, actor=_whoami(), reason=reason + ) + click.echo(f"{goal.id} -> {goal.status.value}") + + # --- lifecycle ------------------------------------------------------------ diff --git a/src/vouch/digest.py b/src/vouch/digest.py index 5116c861..df7d147c 100644 --- a/src/vouch/digest.py +++ b/src/vouch/digest.py @@ -19,6 +19,7 @@ from datetime import UTC, datetime, timedelta from typing import Any +from .goals import list_goals as list_open_goals from .metrics import DEFAULT_STALE_DAYS, compute from .models import ClaimStatus, PageStatus, ProposalStatus from .page_filters import filter_pages @@ -74,6 +75,14 @@ class FollowupRow: followup_status: str +@dataclass(frozen=True) +class GoalRow: + id: str + title: str + status: str + age_days: int + + @dataclass(frozen=True) class Digest: """Stable `to_dict()` schema — the `--format json` contract.""" @@ -88,6 +97,8 @@ class Digest: stale_claims: list[StaleRow] = field(default_factory=list) stale_total: int = 0 followups_due: list[FollowupRow] = field(default_factory=list) + open_goals: list[GoalRow] = field(default_factory=list) + open_goals_total: int = 0 citation_coverage: float | None = None def to_dict(self) -> dict[str, Any]: @@ -207,6 +218,19 @@ def build( key=lambda r: r.due_at, )[:limit] + # Open objectives, oldest first — what the project is mid-way through, + # which is the context a returning operator loses first. + open_goals = list_open_goals(store) + goal_rows = [ + GoalRow( + id=g.id, + title=g.title if len(g.title) <= 72 else g.title[:69] + "...", + status=g.status.value, + age_days=max(0, (now - (_as_utc(g.created_at) or now)).days), + ) + for g in open_goals[:limit] + ] + m = compute(store, since=since, stale_after_days=stale_after_days, now=now) return Digest( @@ -220,6 +244,8 @@ def build( stale_claims=stale_rows, stale_total=m.stale_claims, followups_due=followup_rows, + open_goals=goal_rows, + open_goals_total=len(open_goals), citation_coverage=m.citation_coverage, ) @@ -244,6 +270,14 @@ def render_text(d: Digest) -> str: 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}") + lines.append("") + lines.append(f"open goals: {d.open_goals_total}") + for gr in d.open_goals: + lines.append(f" {gr.id} {gr.age_days}d {gr.title}") + if d.open_goals_total > len(d.open_goals): + lines.append( + f" ... and {d.open_goals_total - len(d.open_goals)} more (vouch goals)" + ) if d.citation_coverage is not None: lines.append("") lines.append(f"citation coverage: {d.citation_coverage:.0%}") @@ -271,6 +305,11 @@ def render_markdown(d: Digest) -> str: f"- {r.due_at} `{r.id}` {r.title}" + (f" (owner: {r.owner})" if r.owner else "") for r in d.followups_due ] or ["- none"] + lines.append("") + lines.append(f"## open goals ({d.open_goals_total})") + lines += [ + f"- `{r.id}` {r.title} — open {r.age_days}d" for r in d.open_goals + ] or ["- none"] if d.citation_coverage is not None: lines.append("") lines.append(f"citation coverage: {d.citation_coverage:.0%}") diff --git a/src/vouch/goals.py b/src/vouch/goals.py new file mode 100644 index 00000000..682720f3 --- /dev/null +++ b/src/vouch/goals.py @@ -0,0 +1,49 @@ +"""Read helpers for goals — the in-flight objectives half of the KB. + +Writes live elsewhere on purpose: `proposals.propose_goal` files one, the +reviewer approves it, and `lifecycle.set_goal_status` moves it. This module +only reads, so every surface that shows goals (digest, session-start recall, +CLI, MCP) agrees on what "open, viewer-visible, oldest first" means instead +of each re-deriving it. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from .models import Goal, GoalStatus +from .scoping import ViewerContext, is_visible, viewer_from +from .storage import KBStore + + +def _created(goal: Goal) -> datetime: + dt = goal.created_at + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) + + +def list_goals( + store: KBStore, + *, + status: str | GoalStatus | None = GoalStatus.OPEN, + viewer: ViewerContext | None = None, + limit: int | None = None, +) -> list[Goal]: + """Approved goals, viewer-scoped, oldest first. + + Defaults to open goals only — the question a returning operator is + actually asking is "what is in flight", not "what has this project ever + intended". Pass ``status=None`` for the whole history. + + Oldest-first (not newest-first like decisions): an objective that has been + open longest is the one most likely to be stale or forgotten, so it earns + the top of the list. + """ + if viewer is None: + viewer = viewer_from(config_path=store.config_path) + wanted = GoalStatus(status) if status is not None else None + goals = [ + g for g in store.list_goals() + if (wanted is None or g.status is wanted) and is_visible(g.scope, viewer) + ] + goals.sort(key=_created) + return goals[:limit] if limit is not None else goals diff --git a/src/vouch/hot_memory.py b/src/vouch/hot_memory.py index 026db86c..ece2071b 100644 --- a/src/vouch/hot_memory.py +++ b/src/vouch/hot_memory.py @@ -134,6 +134,7 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non "kb.list_relations", "kb.list_sources", "kb.list_pending", + "kb.list_goals", }) # Explicit exclusions for ``test_hot_memory_universal_coverage``. @@ -170,6 +171,8 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non "kb.propose_relation": "write path — review gate", "kb.propose_theme": "write path — review gate", "kb.propose_delete": "write path — review gate", + "kb.propose_goal": "write path — review gate", + "kb.set_goal_status": "lifecycle — mutates durable state", "kb.compile": "write path — review gate (files page proposals via wiki-compiler)", "kb.summarize_session": "write path — review gate (files session-summary page proposals)", "kb.clear_claims": "lifecycle — mutates durable state", diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 868ed906..bd6d50a9 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -31,6 +31,7 @@ from . import audit, bundle, health, volunteer_context from . import compile as compile_mod from . import digest as digest_mod +from . import goals as goals_mod from . import hot_memory as hot_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -53,6 +54,7 @@ propose_claim, propose_delete, propose_entity, + propose_goal, propose_page, propose_relation, reject, @@ -555,6 +557,52 @@ def _h_propose_delete(p: dict) -> dict: } +def _h_propose_goal(p: dict) -> dict: + pr = propose_goal( + _store(), + title=p["title"], + detail=p.get("detail"), + claims=p.get("claims"), + entities=p.get("entities"), + tags=p.get("tags"), + rationale=p.get("rationale"), + slug_hint=p.get("slug_hint"), + session_id=p.get("session_id"), + dry_run=bool(p.get("dry_run", False)), + proposed_by=_agent(), + ) + return { + "proposal_id": pr.id, + "status": pr.status.value, + "kind": pr.kind.value, + "dry_run": bool(p.get("dry_run", False)), + } + + +def _h_list_goals(p: dict) -> dict: + store = _store() + found = goals_mod.list_goals( + store, + status=p.get("status", "open"), + limit=p.get("limit"), + ) + items = [g.model_dump(mode="json") for g in found] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) + + +def _h_set_goal_status(p: dict) -> dict: + g = life.set_goal_status( + _store(), + goal_id=p["goal_id"], + status=p["status"], + actor=_agent(), + reason=p.get("reason"), + ) + return {"id": g.id, "status": g.status.value, "history": g.history} + + def _h_approve(p: dict) -> dict: a = approve(_store(), p["proposal_id"], approved_by=_agent(), reason=p.get("reason"), @@ -960,6 +1008,9 @@ def _h_propose_theme(p: dict) -> dict: "kb.propose_entity": _h_propose_entity, "kb.propose_relation": _h_propose_relation, "kb.propose_delete": _h_propose_delete, + "kb.propose_goal": _h_propose_goal, + "kb.list_goals": _h_list_goals, + "kb.set_goal_status": _h_set_goal_status, "kb.approve": _h_approve, "kb.reject": _h_reject, "kb.reject_extracted": _h_reject_extracted, diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index 30592a35..d6583997 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -19,6 +19,8 @@ Claim, ClaimStatus, Evidence, + Goal, + GoalStatus, ProposalKind, ProposalStatus, Relation, @@ -168,6 +170,67 @@ def confirm(store: KBStore, *, claim_id: str, actor: str) -> Claim: return claim +# Terminal statuses stamp `closed_at`; a goal can still be reopened out of +# them (a "done" migration that turned out not to be done is a real event), +# in which case the stamp is cleared again. +_CLOSED_GOAL_STATUSES = frozenset({GoalStatus.DONE, GoalStatus.ABANDONED}) + + +def set_goal_status( + store: KBStore, + *, + goal_id: str, + status: str | GoalStatus, + actor: str, + reason: str | None = None, +) -> Goal: + """Move an approved goal to a new status. The only goal write path. + + Same posture as `archive` / `confirm` above: a status move is metadata + about already-reviewed knowledge, not a new assertion, so it lands + directly — but it lands *here*, in one place, so every transition appends + a `goal.status` event to the audit log and a row to the goal's own + `history`. Nothing else in the codebase may set `Goal.status`; if a future + change needs to, it belongs in this function. + """ + try: + new_status = GoalStatus(status) + except ValueError as e: + raise LifecycleError( + f"unknown goal status {status!r}; expected one of " + f"{[s.value for s in GoalStatus]}" + ) from e + goal = store.get_goal(goal_id) + if goal.status is new_status: + raise LifecycleError( + f"goal {goal_id} is already {new_status.value}" + ) + previous = goal.status + now = datetime.now(UTC) + goal.status = new_status + goal.updated_at = now + goal.closed_at = now if new_status in _CLOSED_GOAL_STATUSES else None + goal.history = [ + *goal.history, + { + "from": previous.value, + "to": new_status.value, + "at": now.isoformat(), + "actor": actor, + "reason": reason, + }, + ] + store.update_goal(goal) + audit.log_event( + store.kb_dir, + event="goal.status", + actor=actor, + object_ids=[goal.id], + data={"from": previous.value, "to": new_status.value, "reason": reason}, + ) + return goal + + def clear_claims( store: KBStore, *, diff --git a/src/vouch/models.py b/src/vouch/models.py index 99bf27e6..2118602b 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -157,6 +157,21 @@ class PageStatus(StrEnum): ARCHIVED = "archived" +class GoalStatus(StrEnum): + """Where an approved objective stands. + + ``open`` is the only status a goal can be approved into — every other + value is reached through ``lifecycle.set_goal_status``, which is the + single write path and the only thing that appends the transition to the + audit log. + """ + + OPEN = "open" + DONE = "done" + ABANDONED = "abandoned" + BLOCKED = "blocked" + + # --- core artifacts ------------------------------------------------------- @@ -382,6 +397,50 @@ def _coerce_scope(cls, v: object) -> object: return ArtifactScope() +class Goal(BaseModel): + """A review-gated in-flight objective — what the project is *doing*. + + Claims/pages/entities record what a project knows; a goal records what it + is currently trying to achieve ("mid-migration to typed config", "release + blocked on the audit-race fix"). Reviewed knowledge, not a scratchpad: a + goal is proposed and approved through the same gate as every other + artifact, and every status move goes through ``lifecycle.set_goal_status`` + so the audit log carries the whole trajectory. + """ + + id: str + title: str + detail: str | None = None + status: GoalStatus = GoalStatus.OPEN + # Optional links to the knowledge the objective concerns. Existence is + # checked at the gate (propose + approve), like a page's claim refs. + claims: list[str] = Field(default_factory=list) + entities: list[str] = Field(default_factory=list) + # Append-only trajectory: one row per accepted transition, oldest first. + # The audit log stays authoritative; this is the diffable-in-PR summary, + # the same relationship `decided/` has to the log for proposals. + history: list[dict[str, Any]] = Field(default_factory=list) + scope: ArtifactScope = Field(default_factory=ArtifactScope) + tags: list[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=utcnow) + updated_at: datetime = Field(default_factory=utcnow) + closed_at: datetime | None = None + approved_by: str | None = None # vouch: review-gate audit + + @field_validator("title") + @classmethod + def _title_non_empty(cls, v: str) -> str: + # Same posture as Claim.text / Page.title (#155): enforce on the model + # so propose, approve, and any future direct-write path are all closed + # at once rather than each re-implementing the check. + return _require_non_empty(v, "goal title") + + @field_validator("scope", mode="before") + @classmethod + def _coerce_scope(cls, v: object) -> object: + return _coerce_artifact_scope(v) + + # --- audit + sessions ----------------------------------------------------- @@ -429,6 +488,7 @@ class ProposalKind(StrEnum): ENTITY = "entity" RELATION = "relation" DELETE = "delete" + GOAL = "goal" class ProposalStatus(StrEnum): diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index bb9c1015..c8bc5bf7 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -23,6 +23,8 @@ ArtifactScope, Claim, Entity, + Goal, + GoalStatus, Page, Proposal, ProposalKind, @@ -493,6 +495,62 @@ def propose_relation( ) +def propose_goal( + store: KBStore, + *, + title: str, + proposed_by: str, + detail: str | None = None, + claims: list[str] | None = None, + entities: list[str] | None = None, + tags: list[str] | None = None, + scope: dict[str, Any] | str | None = None, + rationale: str | None = None, + slug_hint: str | None = None, + session_id: str | None = None, + dry_run: bool = False, +) -> Proposal: + """File a review-gated objective. Approving it creates the `open` goal. + + A goal is knowledge about intent, so it takes the same route as every + other write: pending proposal → human approve → durable yaml. There is + deliberately no direct-write entry point, and the payload is pinned to + `status: open` — a proposal cannot land a goal that is already `done`, + which would put a transition on disk that never passed through + `lifecycle.set_goal_status` and so never reached the audit log. + """ + if not title.strip(): + raise ProposalError("goal title is empty") + for cid in claims or []: + if not store._claim_path(cid).exists(): + raise ProposalError(f"unknown claim id: {cid}") + for eid in entities or []: + if not store._entity_path(eid).exists(): + raise ProposalError(f"unknown entity id: {eid}") + payload: dict[str, Any] = { + "id": slug_hint or _slugify(title), + "title": title.strip(), + "detail": detail, + "status": GoalStatus.OPEN.value, + "claims": list(claims or []), + "entities": list(entities or []), + "tags": list(tags or []), + } + _stamp_scope(store, payload, scope) + # Validate against the model here, at propose time, for the same reason + # propose_entity does: a payload that can never pass approve() must not + # sit in the pending queue waiting for someone to notice. + try: + Goal(**payload) + except (ValidationError, TypeError) as e: + raise ProposalError(f"invalid goal payload: {e}") from e + return _file_proposal( + store, kind=ProposalKind.GOAL, payload=payload, + proposed_by=proposed_by, session_id=session_id, + rationale=rationale, dry_run=dry_run, + ) + + def propose_delete( store: KBStore, *, @@ -702,7 +760,7 @@ def auto_approve_receipts( def auto_approve_pending( store: KBStore, *, actor: str | None = None -) -> list[Claim | Page | Entity | Relation]: +) -> list[Claim | Page | Entity | Relation | Goal]: """Approve every pending proposal the configured gate allows. The full drain behind auto-approval-by-default. Under @@ -724,7 +782,7 @@ def auto_approve_pending( review_cfg = _review_config(store) if review_cfg.get("approver_role") != "trusted-agent": return list(auto_approve_receipts(store, actor=actor)) - approved: list[Claim | Page | Entity | Relation] = [] + approved: list[Claim | Page | Entity | Relation | Goal] = [] for proposal in store.list_proposals(ProposalStatus.PENDING): if proposal.kind == ProposalKind.DELETE: continue @@ -813,6 +871,21 @@ def _payload_block_reason( Entity(**payload) except (ValidationError, TypeError) as e: return f"invalid entity payload: {e}" + elif proposal.kind == ProposalKind.GOAL: + try: + goal = Goal(**payload) + except (ValidationError, TypeError) as e: + return f"invalid goal payload: {e}" + if goal.status is not GoalStatus.OPEN: + return ( + f"goal {goal.id} would be approved into status " + f"{goal.status.value!r}; only 'open' may be approved — later " + "moves go through lifecycle.set_goal_status" + ) + try: + store._validate_goal_refs(goal) + except ValueError as e: + return str(e) elif proposal.kind == ProposalKind.DELETE: target_kind = str(payload.get("target_kind", "")) target_id = str(payload.get("id", "")) @@ -858,7 +931,7 @@ def approve( approved_by: str, reason: str | None = None, drop_missing_claims: bool = False, -) -> Claim | Page | Entity | Relation: +) -> Claim | Page | Entity | Relation | Goal: """Approve a pending proposal and write it as a durable artifact. Raises ProposalError if the proposal is not pending or if @@ -887,7 +960,7 @@ def approve( # via update_page rather than put_page. if proposal.kind not in (ProposalKind.PAGE, ProposalKind.DELETE): _ensure_no_existing_artifact(store, proposal.kind, payload["id"]) - result: Claim | Page | Entity | Relation + result: Claim | Page | Entity | Relation | Goal if proposal.kind == ProposalKind.CLAIM: is_auto_approved = approved_by == proposal.proposed_by claim = Claim( @@ -957,6 +1030,10 @@ def approve( type=entity.type.value, aliases=entity.aliases, ) result = entity + elif proposal.kind == ProposalKind.GOAL: + goal = Goal(approved_by=approved_by, **payload) + store.put_goal(goal) + result = goal elif proposal.kind == ProposalKind.DELETE: result = _approve_delete(store, proposal, approved_by=approved_by) else: # RELATION @@ -1134,6 +1211,7 @@ def expire_pending( ProposalKind.PAGE: "get_page", ProposalKind.ENTITY: "get_entity", ProposalKind.RELATION: "get_relation", + ProposalKind.GOAL: "get_goal", } @@ -1200,7 +1278,7 @@ def referenced_by(store: KBStore, target_kind: str, target_id: str) -> list[str] def _reconstruct_deleted( target_kind: str, snapshot: dict[str, Any] -) -> Claim | Page | Entity | Relation: +) -> Claim | Page | Entity | Relation | Goal: """Rebuild a typed model from a delete proposal's snapshot. Used only on the idempotent path (artifact already gone) so the approve @@ -1217,7 +1295,7 @@ def _reconstruct_deleted( def _approve_delete( store: KBStore, proposal: Proposal, *, approved_by: str -) -> Claim | Page | Entity | Relation: +) -> Claim | Page | Entity | Relation | Goal: """Execute an approved DELETE proposal: remove the artifact + index rows. Re-checks references at approve time (they may have appeared since the diff --git a/src/vouch/recall.py b/src/vouch/recall.py index 73e97246..03e22d4e 100644 --- a/src/vouch/recall.py +++ b/src/vouch/recall.py @@ -18,6 +18,7 @@ from .config_coerce import coerce_bool from .context import _RETRACTED_CLAIM_STATUSES +from .goals import list_goals as list_open_goals from .models import PageStatus from .scoping import ViewerContext, is_visible, viewer_from from .storage import KBStore @@ -87,9 +88,13 @@ def build_digest( p for p in active_pages if is_visible(p.scope, viewer) ] + # Open objectives ride along with the approved facts: a fresh session that + # knows what the project *knows* but not what it is mid-way through will + # confidently resume the wrong thing. Viewer-scoped like everything else. + open_goals = list_open_goals(store, viewer=viewer) if stats is not None: stats["hidden"] = (len(live) - len(claims)) + (len(active_pages) - len(pages)) - if not claims and not pages: + if not claims and not pages and not open_goals: return "" whose = ( @@ -101,8 +106,9 @@ def build_digest( lines: list[str] = [ _OPEN_TAG, f"# approved KB knowledge {whose} — {len(claims)} claim(s), " - f"{len(pages)} page(s). reviewed, cited, durable. use kb_read_page / " - "kb_search for detail; kb_propose_* (human-approved) to add more.", + f"{len(pages)} page(s), {len(open_goals)} open goal(s). reviewed, " + "cited, durable. use kb_read_page / kb_search for detail; " + "kb_propose_* (human-approved) to add more.", ] if claims: lines += ["", "## claims"] @@ -110,6 +116,9 @@ def build_digest( if pages: lines += ["", "## pages"] lines += [f"- [{p.id}] {p.title}" for p in pages] + if open_goals: + lines += ["", "## open goals — what this project is mid-way through"] + lines += [f"- [{g.id}] {g.title}" for g in open_goals] lines.append(_CLOSE_TAG) body = "\n".join(lines) diff --git a/src/vouch/server.py b/src/vouch/server.py index 1a4d7f06..23a94585 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -22,6 +22,7 @@ from . import audit, bundle, health, mcp_profiles, volunteer_context from . import compile as compile_mod from . import digest as digest_mod +from . import goals as goals_mod from . import hot_memory as hot_mod from . import lifecycle as life from . import metrics as metrics_mod @@ -32,6 +33,7 @@ from . import verify as verify_mod from .capabilities import capabilities as build_caps from .context import build_context_pack +from .lifecycle import LifecycleError from .logging_config import configure_logging from .models import ProposalStatus from .page_filters import filter_pages @@ -43,6 +45,7 @@ propose_claim, propose_delete, propose_entity, + propose_goal, propose_page, propose_relation, reject, @@ -812,6 +815,69 @@ def kb_propose_delete( return _proposal_response(pr, dry_run) +@mcp.tool() +def kb_propose_goal( + title: str, + detail: str | None = None, + claims: list[str] | None = None, + entities: list[str] | None = None, + tags: list[str] | None = None, + rationale: str | None = None, + slug_hint: str | None = None, + session_id: str | None = None, + dry_run: bool = False, +) -> dict[str, Any]: + """Propose an in-flight objective — what this project is trying to do now. + + Files a PENDING goal a human approves via kb.approve; the approved goal + then re-surfaces in the session-start digest so a fresh session picks up + the intent, not just the facts. Status moves after approval go through + kb.set_goal_status, never through a second proposal. + """ + try: + pr = propose_goal( + _store(), title=title, detail=detail, claims=claims, + entities=entities, tags=tags, rationale=rationale, + slug_hint=slug_hint, session_id=session_id, dry_run=dry_run, + proposed_by=_agent(), + ) + except (ProposalError, ArtifactNotFoundError, ValueError) as e: + raise ValueError(str(e)) from e + return _proposal_response(pr, dry_run) + + +@mcp.tool() +def kb_list_goals(status: str | None = "open", limit: int | None = None) -> dict[str, Any]: + """List approved goals, oldest first. Defaults to the open ones.""" + store = _store() + try: + found = goals_mod.list_goals(store, status=status, limit=limit) + except ValueError as e: + raise ValueError(f"unknown goal status: {status!r}") from e + items = [g.model_dump(mode="json") for g in found] + return hot_mod.attach_hot_memory( # type: ignore[no-any-return] + items, store, query=None, list_envelope=True, + ) + + +@mcp.tool() +def kb_set_goal_status( + goal_id: str, status: str, reason: str | None = None +) -> dict[str, Any]: + """Move an approved goal to open / done / abandoned / blocked. + + Appends the transition to the audit log — this is the only write path for + goal status. + """ + try: + g = life.set_goal_status( + _store(), goal_id=goal_id, status=status, actor=_agent(), reason=reason, + ) + except (LifecycleError, ArtifactNotFoundError) as e: + raise ValueError(str(e)) from e + return {"id": g.id, "status": g.status.value, "history": g.history} + + def _proposal_response(result, dry_run: bool) -> dict[str, Any]: pr = result.proposal if hasattr(result, "proposal") else result out: dict[str, Any] = { diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 75483f83..49125bea 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -41,6 +41,7 @@ Claim, Entity, Evidence, + Goal, Page, Proposal, ProposalStatus, @@ -63,7 +64,7 @@ SUBDIRS = ( "claims", "pages", "sources", "entities", "relations", - "evidence", "sessions", "proposed", "decided", + "evidence", "sessions", "goals", "proposed", "decided", ) @@ -572,6 +573,9 @@ def _evidence_path(self, eid: str) -> Path: def _session_path(self, sid: str) -> Path: return self._yaml("sessions", sid) + def _goal_path(self, gid: str) -> Path: + return self._yaml("goals", gid) + def _proposal_path(self, pid: str) -> Path: return self._yaml("proposed", pid) @@ -1071,6 +1075,63 @@ def list_sessions(self) -> list[Session]: return [s for p in sorted(d.glob("*.yaml")) if (s := _load_or_skip(p, Session, "session")) is not None] + # --- goals --------------------------------------------------------------- + + def _validate_goal_refs(self, goal: Goal) -> None: + """Reject a goal pointing at claims/entities that don't exist. + + Mirrors `_validate_claim_refs`: the propose-time check in + `proposals.propose_goal` is the friendly error, this is the one that + actually closes the write path. + """ + for cid in goal.claims: + if not self._claim_path(cid).exists(): + raise ValueError(f"goal {goal.id} references unknown claim {cid!r}") + for eid in goal.entities: + if not self._entity_path(eid).exists(): + raise ValueError(f"goal {goal.id} references unknown entity {eid!r}") + + def put_goal(self, goal: Goal) -> Goal: + self._validate_goal_refs(goal) + path = self._goal_path(goal.id) + # A KB bootstrapped before goals existed has no goals/ directory; the + # migration runner adds it, but creating on demand keeps an un-migrated + # KB from failing its first approve with a bare FileNotFoundError. + path.parent.mkdir(parents=True, exist_ok=True) + try: + with path.open("x", encoding="utf-8") as f: + f.write(_yaml_dump(goal.model_dump(mode="json"))) + except FileExistsError as e: + raise ValueError( + f"goal {goal.id} already exists -- choose a different slug" + ) from e + return goal + + def update_goal(self, goal: Goal) -> Goal: + """Persist a mutated goal. Only `lifecycle.set_goal_status` calls this.""" + if not self._goal_path(goal.id).exists(): + raise ArtifactNotFoundError(f"goal {goal.id}") + # Round-trip so in-place mutation can't skip the model validators + # (empty title, bad status) — same reason update_claim re-validates. + Goal.model_validate(goal.model_dump(mode="json")) + self._validate_goal_refs(goal) + self._goal_path(goal.id).write_text( + _yaml_dump(goal.model_dump(mode="json")), encoding="utf-8") + return goal + + def get_goal(self, gid: str) -> Goal: + p = self._goal_path(gid) + if not p.exists(): + raise ArtifactNotFoundError(f"goal {gid}") + return Goal.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + + def list_goals(self) -> list[Goal]: + d = self.kb_dir / "goals" + if not d.is_dir(): + return [] + return [g for p in sorted(d.glob("*.yaml")) + if (g := _load_or_skip(p, Goal, "goal")) is not None] + # --- embedding hook ------------------------------------------------------ def _embed_and_store( diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index dca034a8..5c7b8fe7 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -151,6 +151,8 @@ def test_mcp_tools_match_methods() -> None: "kb.effectiveness": "eval effectiveness", "kb.graph_export": "graph", "kb.propose_theme": "detect-themes --propose", + "kb.list_goals": "goals", + "kb.set_goal_status": "goal-status", } diff --git a/tests/test_goals.py b/tests/test_goals.py new file mode 100644 index 00000000..c664bafb --- /dev/null +++ b/tests/test_goals.py @@ -0,0 +1,247 @@ +"""Goals — review-gated in-flight objectives (#427). + +The load-bearing invariants here are the same two the north star names: a +goal cannot exist without passing `proposals.approve`, and its status cannot +move except through `lifecycle.set_goal_status`, which is what puts the +transition in the audit log. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +import yaml + +from vouch import audit, digest, recall +from vouch import goals as goals_mod +from vouch import lifecycle as life +from vouch.capabilities import capabilities +from vouch.jsonl_server import HANDLERS +from vouch.lifecycle import LifecycleError +from vouch.models import Goal, GoalStatus, ProposalKind, ProposalStatus +from vouch.proposals import ProposalError, approve, propose_claim, propose_goal +from vouch.storage import ArtifactNotFoundError, KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + s.config_path.write_text( + "review:\n approver_role: trusted-agent\n", encoding="utf-8", + ) + return s + + +def _approved_goal(store: KBStore, title: str = "migrate config to typed loader") -> Goal: + pr = propose_goal(store, title=title, proposed_by="agent") + approve(store, pr.id, approved_by="reviewer") + return store.get_goal(pr.payload["id"]) + + +# --- the gate -------------------------------------------------------------- + + +def test_propose_goal_creates_a_pending_proposal_not_a_goal(store: KBStore) -> None: + pr = propose_goal( + store, title="migrate config to typed loader", proposed_by="agent" + ) + assert pr.kind is ProposalKind.GOAL + assert pr.status is ProposalStatus.PENDING + with pytest.raises(ArtifactNotFoundError): + store.get_goal(pr.payload["id"]) + assert store.list_goals() == [] + + +def test_approve_writes_the_goal_open(store: KBStore) -> None: + goal = _approved_goal(store) + assert goal.status is GoalStatus.OPEN + assert goal.approved_by == "reviewer" + assert goal.title == "migrate config to typed loader" + + +def test_dry_run_proposal_touches_nothing(store: KBStore) -> None: + pr = propose_goal( + store, title="ship the audit-race fix", proposed_by="agent", dry_run=True + ) + assert store.list_proposals(ProposalStatus.PENDING) == [] + with pytest.raises(ArtifactNotFoundError): + store.get_proposal(pr.id) + + +def test_empty_title_is_rejected_at_the_model_layer(store: KBStore) -> None: + """#155 posture: the model, not just the propose helper, is the gate.""" + with pytest.raises(ProposalError): + propose_goal(store, title=" ", proposed_by="agent") + with pytest.raises(ValueError): + Goal(id="g", title=" ") + + +def test_proposal_cannot_land_a_goal_that_is_already_done(store: KBStore) -> None: + """A non-open payload would put a transition on disk that skipped the + lifecycle write path, so the audit log would never carry it.""" + pr = propose_goal(store, title="already finished", proposed_by="agent") + proposal = store.get_proposal(pr.id) + proposal.payload["status"] = "done" + store._proposal_path(proposal.id).write_text( + yaml.safe_dump(proposal.model_dump(mode="json")), encoding="utf-8", + ) + with pytest.raises(ProposalError, match="only 'open' may be approved"): + approve(store, pr.id, approved_by="reviewer") + + +def test_goal_refs_must_resolve(store: KBStore) -> None: + with pytest.raises(ProposalError, match="unknown claim id"): + propose_goal( + store, title="finish the thing", proposed_by="agent", claims=["nope"] + ) + with pytest.raises(ProposalError, match="unknown entity id"): + propose_goal( + store, title="finish the thing", proposed_by="agent", entities=["nope"] + ) + + +def test_goal_can_cite_an_approved_claim(store: KBStore) -> None: + src = store.put_source(b"the release is blocked on the audit-race fix") + claim_pr = propose_claim( + store, text="the release is blocked", evidence=[src.id], proposed_by="agent" + ) + claim = approve(store, claim_pr.id, approved_by="reviewer") + pr = propose_goal( + store, title="unblock the release", proposed_by="agent", claims=[claim.id] + ) + approve(store, pr.id, approved_by="reviewer") + assert store.get_goal(pr.payload["id"]).claims == [claim.id] + + +# --- transitions ----------------------------------------------------------- + + +def test_status_transition_appends_to_the_audit_log(store: KBStore) -> None: + goal = _approved_goal(store) + moved = life.set_goal_status( + store, goal_id=goal.id, status="blocked", actor="human", reason="waiting on ci" + ) + assert moved.status is GoalStatus.BLOCKED + assert store.get_goal(goal.id).status is GoalStatus.BLOCKED + + events = [e for e in audit.read_events(store.kb_dir) if e.event == "goal.status"] + assert len(events) == 1 + assert events[0].data == { + "from": "open", "to": "blocked", "reason": "waiting on ci", + } + assert events[0].actor == "human" + assert goal.id in events[0].object_ids + + +def test_transition_records_history_on_the_goal(store: KBStore) -> None: + goal = _approved_goal(store) + life.set_goal_status(store, goal_id=goal.id, status="blocked", actor="human") + life.set_goal_status(store, goal_id=goal.id, status="done", actor="human") + reread = store.get_goal(goal.id) + assert [(h["from"], h["to"]) for h in reread.history] == [ + ("open", "blocked"), ("blocked", "done"), + ] + assert reread.closed_at is not None + + +def test_reopening_clears_closed_at(store: KBStore) -> None: + goal = _approved_goal(store) + life.set_goal_status(store, goal_id=goal.id, status="done", actor="human") + reopened = life.set_goal_status( + store, goal_id=goal.id, status="open", actor="human", reason="not actually done" + ) + assert reopened.closed_at is None + assert reopened.status is GoalStatus.OPEN + + +def test_unknown_status_and_no_op_transition_are_refused(store: KBStore) -> None: + goal = _approved_goal(store) + with pytest.raises(LifecycleError, match="unknown goal status"): + life.set_goal_status(store, goal_id=goal.id, status="shipped", actor="human") + with pytest.raises(LifecycleError, match="already open"): + life.set_goal_status(store, goal_id=goal.id, status="open", actor="human") + # a refused transition writes nothing + assert not [ + e for e in audit.read_events(store.kb_dir) if e.event == "goal.status" + ] + + +def test_only_lifecycle_mutates_a_stored_goal() -> None: + """`store.update_goal` is the single mutation path, and only + `lifecycle.set_goal_status` may call it. + + A second caller would be a status move that skipped the audit-log append + — exactly the parallel write path the north star forbids. Storage and the + test suite are excluded: one defines the method, the other exercises it. + """ + src_dir = Path(life.__file__).parent + callers = sorted( + path.name + for path in src_dir.rglob("*.py") + if path.name != "storage.py" + and "update_goal(" in path.read_text(encoding="utf-8") + ) + assert callers == ["lifecycle.py"], callers + + +# --- reads ----------------------------------------------------------------- + + +def test_list_goals_defaults_to_open_oldest_first(store: KBStore) -> None: + first = _approved_goal(store, "older objective") + second = _approved_goal(store, "newer objective") + older = store.get_goal(first.id) + older.created_at = datetime.now(UTC) - timedelta(days=30) + store.update_goal(older) + + assert [g.id for g in goals_mod.list_goals(store)] == [first.id, second.id] + + life.set_goal_status(store, goal_id=second.id, status="done", actor="human") + assert [g.id for g in goals_mod.list_goals(store)] == [first.id] + assert [g.id for g in goals_mod.list_goals(store, status=None)] == [ + first.id, second.id, + ] + assert [g.id for g in goals_mod.list_goals(store, status="done")] == [second.id] + + +def test_open_goals_reach_the_digest_and_session_start_recall(store: KBStore) -> None: + goal = _approved_goal(store, "migrate config to typed loader") + + d = digest.build(store) + assert d.open_goals_total == 1 + assert [row.id for row in d.open_goals] == [goal.id] + assert "open goals" in digest.render_text(d) + assert goal.title in digest.render_markdown(d) + assert d.to_dict()["open_goals"][0]["title"] == goal.title + + body = recall.build_digest(store) + assert "## open goals" in body + assert goal.title in body + + # a closed goal drops out of both surfaces + life.set_goal_status(store, goal_id=goal.id, status="done", actor="human") + assert digest.build(store).open_goals_total == 0 + assert "## open goals" not in recall.build_digest(store) + + +# --- registration ---------------------------------------------------------- + + +@pytest.mark.parametrize( + "method", ["kb.propose_goal", "kb.list_goals", "kb.set_goal_status"] +) +def test_goal_methods_registered_on_every_surface(method: str) -> None: + assert method in set(capabilities().methods) + assert method in HANDLERS + from vouch.server import mcp + + assert mcp._tool_manager.get_tool(method.replace(".", "_")) is not None + + +def test_cli_exposes_the_goal_commands() -> None: + from vouch.cli import cli + + names = set(cli.commands) + assert {"propose-goal", "goals", "goal-status"} <= names From 940359d3b83cfb994416d0de0fab66f82fc9fc45 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:47:38 +0900 Subject: [PATCH 2/2] fix(schemas): regenerate for the goal proposal kind, and cover the surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two ci gates were red. `schemas/` is generated from the pydantic models and checked for drift on every pr; adding `ProposalKind.GOAL` changed `proposal.schema.json` and the regenerate step was missed. `python scripts/gen_schemas.py`, one enum member added. the diff-coverage gate wants 100% of changed python. what was uncovered was the cli, mcp and jsonl bodies — registered and asserted-registered, never actually called — plus the write gates, which are the part of this feature worth pinning since they are what keeps a goal from reaching disk without passing the review gate. added: a cli round trip (propose, empty listing, approve, list, move, --status all); the mcp tools round-tripping and surfacing every error as the ValueError an mcp host can render; the jsonl envelopes; a goal payload the model rejects at propose time and another corrupted after filing; a goal whose cited claim vanishes between propose and approve; put_goal on dangling refs and on a duplicate slug; update_goal on a goal that is gone; list_goals on a kb bootstrapped before goals existed; and the digest's elision line. --- schemas/proposal.schema.json | 3 +- tests/test_goals.py | 183 +++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/schemas/proposal.schema.json b/schemas/proposal.schema.json index 5fcbf5dc..bbdd4980 100644 --- a/schemas/proposal.schema.json +++ b/schemas/proposal.schema.json @@ -6,7 +6,8 @@ "page", "entity", "relation", - "delete" + "delete", + "goal" ], "title": "ProposalKind", "type": "string" diff --git a/tests/test_goals.py b/tests/test_goals.py index c664bafb..f181d17f 100644 --- a/tests/test_goals.py +++ b/tests/test_goals.py @@ -245,3 +245,186 @@ def test_cli_exposes_the_goal_commands() -> None: names = set(cli.commands) assert {"propose-goal", "goals", "goal-status"} <= names + + +# --- the surfaces, exercised rather than merely registered ----------------- + + +def _cli(store: KBStore, args: list[str]): + from click.testing import CliRunner + + from vouch.cli import cli + + return CliRunner().invoke(cli, args, env={"VOUCH_KB_PATH": str(store.kb_dir)}) + + +def test_cli_round_trip_propose_list_and_move(store: KBStore) -> None: + proposed = _cli(store, ["propose-goal", "--title", "ship the typed loader", + "--detail", "config.yaml first", + "--tag", "infra", "--rationale", "q3 objective"]) + assert proposed.exit_code == 0, proposed.output + proposal_id = proposed.output.strip() + + # still nothing durable — the gate is the point + assert "no open goals" in _cli(store, ["goals"]).output + + approve(store, proposal_id, approved_by="reviewer") + listed = _cli(store, ["goals"]) + assert listed.exit_code == 0, listed.output + assert "ship the typed loader" in listed.output + assert "[open]" in listed.output + + goal_id = store.list_goals()[0].id + moved = _cli(store, ["goal-status", goal_id, "done", "--reason", "shipped"]) + assert moved.exit_code == 0, moved.output + assert f"{goal_id} -> done" in moved.output + + assert "no open goals" in _cli(store, ["goals"]).output + assert "[done]" in _cli(store, ["goals", "--status", "all"]).output + + +def test_cli_reports_an_empty_all_listing(store: KBStore) -> None: + result = _cli(store, ["goals", "--status", "all"]) + assert result.exit_code == 0 + assert result.output.strip() == "no goals" + + +def test_mcp_goal_tools_round_trip( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + from vouch import server + + monkeypatch.chdir(store.root) + proposed = server.kb_propose_goal(title="ship the typed loader") + assert proposed["status"] == "pending" + approve(store, proposed["proposal_id"], approved_by="reviewer") + + listed = server.kb_list_goals() + assert [item["title"] for item in listed["items"]] == ["ship the typed loader"] + + goal_id = listed["items"][0]["id"] + moved = server.kb_set_goal_status(goal_id, "blocked", reason="waiting on #1") + assert moved["status"] == "blocked" + assert moved["history"] + + +def test_mcp_goal_tools_surface_errors_as_value_errors( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + # The MCP contract: a host sees ValueError, never an internal exception. + from vouch import server + + monkeypatch.chdir(store.root) + with pytest.raises(ValueError): + server.kb_propose_goal(title=" ") + with pytest.raises(ValueError, match="unknown goal status"): + server.kb_list_goals(status="nonsense") + with pytest.raises(ValueError): + server.kb_set_goal_status("no-such-goal", "done") + + +def test_jsonl_goal_handlers_round_trip( + store: KBStore, monkeypatch: pytest.MonkeyPatch +) -> None: + from vouch.jsonl_server import handle_request + + monkeypatch.chdir(store.root) + proposed = handle_request({ + "id": "g1", "method": "kb.propose_goal", + "params": {"title": "ship the typed loader"}, + }) + assert proposed["ok"] is True + assert proposed["result"]["kind"] == "goal" + approve(store, proposed["result"]["proposal_id"], approved_by="reviewer") + + goal_id = store.list_goals()[0].id + moved = handle_request({ + "id": "g2", "method": "kb.set_goal_status", + "params": {"goal_id": goal_id, "status": "done"}, + }) + assert moved["ok"] is True + assert moved["result"]["status"] == "done" + assert moved["result"]["history"] + + +# --- the write gates ------------------------------------------------------- + + +def test_an_unapprovable_goal_never_enters_the_queue(store: KBStore) -> None: + """Validated at propose time for the same reason entities are: a payload + that can never pass approve() must not sit in the queue waiting for + someone to notice.""" + with pytest.raises(ProposalError, match="goal title is empty"): + propose_goal(store, title=" ", proposed_by="agent") + # and a payload the model rejects for any other reason, e.g. a non-string + # tag arriving from a transport that did not type-check it + with pytest.raises(ProposalError, match="invalid goal payload"): + propose_goal( + store, title="a real goal", tags=[123], # type: ignore[list-item] + proposed_by="agent", + ) + assert store.list_proposals(ProposalStatus.PENDING) == [] + + +def test_approve_refuses_a_goal_payload_corrupted_after_filing( + store: KBStore +) -> None: + pr = propose_goal(store, title="a real goal", proposed_by="agent") + broken = pr.model_copy(deep=True) + broken.payload["status"] = "not-a-status" + store.update_proposal(broken) + with pytest.raises(ProposalError, match="invalid goal payload"): + approve(store, pr.id, approved_by="reviewer") + + +def test_approve_refuses_a_goal_citing_an_artifact_that_vanished( + store: KBStore +) -> None: + src = store.put_source(b"evidence") + claim_pr = propose_claim( + store, text="the loader is typed", evidence=[src.id], proposed_by="agent" + ) + claim = approve(store, claim_pr.id, approved_by="reviewer") + pr = propose_goal( + store, title="finish the loader", claims=[claim.id], proposed_by="agent" + ) + store._claim_path(claim.id).unlink() # the artifact goes away before review + with pytest.raises(ProposalError, match="unknown claim"): + approve(store, pr.id, approved_by="reviewer") + + +def test_put_goal_rejects_dangling_references(store: KBStore) -> None: + with pytest.raises(ValueError, match="unknown claim"): + store.put_goal(Goal(id="g-a", title="t", claims=["no-such-claim"])) + with pytest.raises(ValueError, match="unknown entity"): + store.put_goal(Goal(id="g-b", title="t", entities=["no-such-entity"])) + + +def test_put_goal_refuses_to_overwrite_an_existing_slug(store: KBStore) -> None: + store.put_goal(Goal(id="g-dup", title="first")) + with pytest.raises(ValueError, match="already exists"): + store.put_goal(Goal(id="g-dup", title="second")) + + +def test_update_goal_requires_the_goal_to_exist(store: KBStore) -> None: + with pytest.raises(ArtifactNotFoundError): + store.update_goal(Goal(id="g-missing", title="t")) + + +def test_list_goals_on_a_kb_with_no_goals_dir(store: KBStore) -> None: + # A KB bootstrapped before goals existed: reading must degrade, not raise. + goals_dir = store.kb_dir / "goals" + if goals_dir.exists(): + for child in goals_dir.iterdir(): + child.unlink() + goals_dir.rmdir() + assert store.list_goals() == [] + + +def test_digest_says_how_many_open_goals_it_elided(store: KBStore) -> None: + for i in range(6): + _approved_goal(store, title=f"objective {i}") + d = digest.build(store, limit=2) + rendered = digest.render_text(d) + assert d.open_goals_total == 6 + assert f"... and {d.open_goals_total - len(d.open_goals)} more" in rendered