From 28a4c9dedd72440b82b44df63a2eef6fc9d8385b Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 8 Jul 2026 10:33:46 -0400 Subject: [PATCH] feat(goals): add review-gated first-class goals model goals as durable reviewed artifacts with propose/list/status surfaces across cli, mcp, and jsonl. this keeps in-flight objectives queryable, audit-logged, and visible in session-start recall without bypassing the review gate. Co-authored-by: Cursor --- CHANGELOG.md | 6 +++ src/vouch/bundle.py | 8 +++- src/vouch/capabilities.py | 3 ++ src/vouch/cli.py | 75 ++++++++++++++++++++++++++++++- src/vouch/health.py | 9 +++- src/vouch/jsonl_server.py | 48 ++++++++++++++++++++ src/vouch/lifecycle.py | 79 +++++++++++++++++++++++++++++++- src/vouch/models.py | 35 +++++++++++++++ src/vouch/proposals.py | 64 +++++++++++++++++++++++++- src/vouch/recall.py | 12 ++++- src/vouch/server.py | 67 ++++++++++++++++++++++++++++ src/vouch/storage.py | 39 +++++++++++++++- src/vouch/sync.py | 3 +- tests/test_goals.py | 94 +++++++++++++++++++++++++++++++++++++++ tests/test_recall.py | 14 +++++- tests/test_storage.py | 2 +- 16 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 tests/test_goals.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 523eb817..9fdd71ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- first-class goals: added a review-gated `goal` artifact with `open|done|abandoned|blocked` + statuses, `kb.propose_goal`, `kb.list_goals`, and `kb.goal_set_status` across mcp/jsonl/capabilities/cli. + goals persist under `.vouch/goals/`, status transitions are audited and mirrored into `decided/`, + and session-start recall now surfaces open goals alongside approved claims/pages (#427). + ## [1.1.0] — 2026-07-03 ### Added diff --git a/src/vouch/bundle.py b/src/vouch/bundle.py index 51e2f0d9..60938b97 100644 --- a/src/vouch/bundle.py +++ b/src/vouch/bundle.py @@ -28,7 +28,7 @@ import yaml from . import audit -from .models import Claim, Entity, Evidence, Proposal, Relation, Session, Source +from .models import Claim, Entity, Evidence, Goal, Proposal, Relation, Session, Source from .storage import _deserialize_page, sha256_hex MANIFEST_NAME = "manifest.json" @@ -37,6 +37,7 @@ EXPORT_SUBDIRS = ( "claims", "pages", + "goals", "sources", "entities", "relations", @@ -55,6 +56,7 @@ VALIDATORS: dict[str, Any] = { "claims": lambda data: Claim.model_validate(yaml.safe_load(data)), "pages": lambda data: _deserialize_page(data.decode()), + "goals": lambda data: Goal.model_validate(yaml.safe_load(data)), "sources": lambda data: Source.model_validate(yaml.safe_load(data)), "entities": lambda data: Entity.model_validate(yaml.safe_load(data)), "relations": lambda data: Relation.model_validate(yaml.safe_load(data)), @@ -294,6 +296,7 @@ def _artifact_id_from_path(path: str) -> tuple[str, str] | None: singular = { "claims": "claim", "pages": "page", + "goals": "goal", "entities": "entity", "relations": "relation", "evidence": "evidence", @@ -311,11 +314,12 @@ def _existing_ids(kb_dir: Path) -> dict[str, set[str]]: disk OR is being delivered by this same bundle. """ out: dict[str, set[str]] = { - "claim": set(), "page": set(), "entity": set(), + "claim": set(), "page": set(), "goal": set(), "entity": set(), "source": set(), "evidence": set(), } for sub, kind, suffix in ( ("claims", "claim", ".yaml"), + ("goals", "goal", ".yaml"), ("entities", "entity", ".yaml"), ("relations", "relation", ".yaml"), ("evidence", "evidence", ".yaml"), diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index 2efc39a3..5619fed0 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -30,6 +30,7 @@ "kb.list_claims", "kb.list_entities", "kb.list_relations", + "kb.list_goals", "kb.list_sources", "kb.list_pending", "kb.register_source", @@ -38,6 +39,7 @@ "kb.propose_page", "kb.propose_entity", "kb.propose_relation", + "kb.propose_goal", "kb.approve", "kb.reject", "kb.reject_extracted", @@ -46,6 +48,7 @@ "kb.contradict", "kb.archive", "kb.confirm", + "kb.goal_set_status", "kb.cite", "kb.source_verify", "kb.session_start", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f8b4a0c8..fe668b0c 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -42,7 +42,7 @@ from .context import build_context_pack from .lifecycle import LifecycleError from .logging_config import configure_logging -from .models import Proposal, ProposalKind, ProposalStatus +from .models import GoalStatus, Proposal, ProposalKind, ProposalStatus from .onboarding import seed_starter_kb from .page_kinds import PageKindError, load_page_kind_registry from .proposals import ( @@ -52,6 +52,7 @@ expire_pending, propose_claim, propose_entity, + propose_goal, propose_page, propose_relation, reject_auto_extracted, @@ -1148,6 +1149,72 @@ def propose_relation_cmd(src: str, relation: str, target: str, confidence: float click.echo(pr.id) +@cli.command(name="propose-goal") +@click.option("--title", required=True) +@click.option("--detail", default=None) +@click.option("--claim", "claims", multiple=True) +@click.option("--entity", "entities", multiple=True) +@click.option("--tag", "tags", multiple=True) +def propose_goal_cmd( + title: str, + detail: str | None, + claims: tuple[str, ...], + entities: tuple[str, ...], + tags: tuple[str, ...], +) -> None: + store = _load_store() + with _cli_errors(): + pr = propose_goal( + store, + title=title, + detail=detail, + claim_ids=list(claims), + entity_ids=list(entities), + tags=list(tags), + proposed_by=_whoami(), + ) + click.echo(pr.id) + + +# --- goals --------------------------------------------------------------- + + +@cli.command(name="list-goals") +@click.option( + "--status", + default="open", + type=click.Choice([s.value for s in GoalStatus]), + show_default=True, +) +@click.option("--json", "as_json", is_flag=True) +def list_goals_cmd(status: str, as_json: bool) -> None: + store = _load_store() + goals = sorted(store.list_goals(), key=lambda g: g.created_at) + if status: + goals = [g for g in goals if g.status.value == status] + if as_json: + _emit_json([g.model_dump(mode="json") for g in goals]) + return + if not goals: + click.echo("(no goals)") + return + for g in goals: + click.echo(f"{g.id} [{g.status.value}] {g.title}") + + +@cli.command(name="goal-status") +@click.argument("goal_id") +@click.argument("status", type=click.Choice([s.value for s in GoalStatus])) +@click.option("--reason", default=None) +def goal_status_cmd(goal_id: str, status: str, reason: str | None) -> None: + store = _load_store() + with _cli_errors(): + goal = life.goal_set_status( + store, goal_id=goal_id, status=status, actor=_whoami(), reason=reason, + ) + click.echo(f"{goal.id} -> {goal.status.value}") + + # --- sources -------------------------------------------------------------- @@ -1441,6 +1508,12 @@ def recall_cmd() -> None: click.echo(digest) +@cli.command(name="digest") +def digest_cmd() -> None: + """Alias for session-start digest output.""" + recall_cmd() + + @cli.command() @click.argument("session_id") @click.option("--no-page", is_flag=True, help="Skip the session-summary page.") diff --git a/src/vouch/health.py b/src/vouch/health.py index a22cef7e..d344aad1 100644 --- a/src/vouch/health.py +++ b/src/vouch/health.py @@ -17,7 +17,7 @@ from . import index_db from .audit import count_events, verify_chain -from .models import Claim, ClaimStatus, Entity, Page, ProposalKind, ProposalStatus +from .models import Claim, ClaimStatus, Entity, Goal, Page, ProposalKind, ProposalStatus from .storage import KBStore, _yaml_load, sha256_hex from .verify import verify_all @@ -48,6 +48,7 @@ def status(store: KBStore) -> dict[str, Any]: "kb_dir": str(store.kb_dir), "claims": len(store.list_claims()), "pages": len(store.list_pages()), + "goals": len(store.list_goals()), "sources": len(store.list_sources()), "entities": len(store.list_entities()), "relations": len(store.list_relations()), @@ -70,6 +71,7 @@ def _safe_counts(store: KBStore, claim_count: int) -> dict: "kb_dir": str(store.kb_dir), "claims": claim_count, "pages": len(store.list_pages()), + "goals": len(store.list_goals()), "sources": len(store.list_sources()), "entities": len(store.list_entities()), "relations": len(store.list_relations()), @@ -269,11 +271,12 @@ def fsck(store: KBStore) -> HealthReport: claim_list, findings = _load_claims_for_lint(store) claims: dict[str, Claim] = {c.id: c for c in claim_list} pages: dict[str, Page] = {p.id: p for p in store.list_pages()} + goals: dict[str, Goal] = {g.id: g for g in store.list_goals()} entities: dict[str, Entity] = {e.id: e for e in store.list_entities()} _check_lifecycle_chains(claims, findings) _check_claim_graph_refs(claims, entities, findings) - _check_decided_proposals(store, claims, pages, entities, findings) + _check_decided_proposals(store, claims, pages, goals, entities, findings) db_present = (store.kb_dir / index_db.DB_FILENAME).exists() if not db_present: @@ -373,6 +376,7 @@ def _check_decided_proposals( store: KBStore, claims: dict[str, Claim], pages: dict[str, Page], + goals: dict[str, Goal], entities: dict[str, Entity], findings: list[Finding], ) -> None: @@ -386,6 +390,7 @@ def _check_decided_proposals( presence: dict[ProposalKind, set[str]] = { ProposalKind.CLAIM: set(claims), ProposalKind.PAGE: set(pages), + ProposalKind.GOAL: set(goals), ProposalKind.ENTITY: set(entities), ProposalKind.RELATION: relations, } diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 5f42e16b..c6c1594c 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -45,6 +45,7 @@ expire_pending, propose_claim, propose_entity, + propose_goal, propose_page, propose_relation, reject, @@ -262,6 +263,22 @@ def _h_list_relations(p: dict) -> list[dict]: return [r.model_dump(mode="json") for r in rels] +def _h_list_goals(p: dict) -> list[dict]: + from .scoping import is_visible, viewer_from + + s = _store() + viewer = viewer_from( + config_path=s.config_path, + project=p.get("project"), + agent=p.get("agent"), + ) + status = p.get("status", "open") + goals = sorted(s.list_goals(), key=lambda g: g.created_at) + if status: + goals = [g for g in goals if g.status.value == status] + return [g.model_dump(mode="json") for g in goals if is_visible(g.scope, viewer)] + + def _h_list_sources(_: dict) -> list[dict]: return [s.model_dump(mode="json") for s in _store().list_sources()] @@ -373,6 +390,23 @@ def _h_propose_relation(p: dict) -> dict: return {"proposal_id": pr.id, "status": pr.status.value, "kind": pr.kind.value} +def _h_propose_goal(p: dict) -> dict: + pr = propose_goal( + _store(), + title=p["title"], + detail=p.get("detail"), + claim_ids=p.get("claim_ids"), + entity_ids=p.get("entity_ids"), + 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} + + def _h_approve(p: dict) -> dict: a = approve(_store(), p["proposal_id"], approved_by=_agent(), reason=p.get("reason")) @@ -440,6 +474,17 @@ def _h_cite(p: dict) -> list: return out +def _h_goal_set_status(p: dict) -> dict: + goal = life.goal_set_status( + _store(), + goal_id=p["goal_id"], + status=p["status"], + actor=_agent(), + reason=p.get("reason"), + ) + return {"id": goal.id, "status": goal.status.value} + + def _h_source_verify(_: dict) -> list[dict]: results = verify_mod.verify_all(_store(), actor=_agent()) return [ @@ -689,6 +734,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.list_claims": _h_list_claims, "kb.list_entities": _h_list_entities, "kb.list_relations": _h_list_relations, + "kb.list_goals": _h_list_goals, "kb.list_sources": _h_list_sources, "kb.list_pending": _h_list_pending, "kb.register_source": _h_register_source, @@ -697,6 +743,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.propose_page": _h_propose_page, "kb.propose_entity": _h_propose_entity, "kb.propose_relation": _h_propose_relation, + "kb.propose_goal": _h_propose_goal, "kb.approve": _h_approve, "kb.reject": _h_reject, "kb.reject_extracted": _h_reject_extracted, @@ -705,6 +752,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.contradict": _h_contradict, "kb.archive": _h_archive, "kb.confirm": _h_confirm, + "kb.goal_set_status": _h_goal_set_status, "kb.cite": _h_cite, "kb.source_verify": _h_source_verify, "kb.session_start": _h_session_start, diff --git a/src/vouch/lifecycle.py b/src/vouch/lifecycle.py index fadbb072..bb6e84e0 100644 --- a/src/vouch/lifecycle.py +++ b/src/vouch/lifecycle.py @@ -11,10 +11,22 @@ from __future__ import annotations +import uuid from datetime import UTC, datetime from . import audit -from .models import Claim, ClaimStatus, Evidence, Relation, RelationType +from .models import ( + Claim, + ClaimStatus, + Evidence, + Goal, + GoalStatus, + Proposal, + ProposalKind, + ProposalStatus, + Relation, + RelationType, +) from .storage import ArtifactNotFoundError, KBStore @@ -154,3 +166,68 @@ def cite(store: KBStore, claim_id: str) -> list[Evidence | dict]: except ArtifactNotFoundError: out.append({"kind": "missing", "ref": ref}) return out + + +def goal_set_status( + store: KBStore, + *, + goal_id: str, + status: GoalStatus | str, + actor: str, + reason: str | None = None, +) -> Goal: + goal: Goal = store.get_goal(goal_id) + status_val = status.value if isinstance(status, GoalStatus) else status + try: + target = GoalStatus(status_val) + except ValueError as e: + raise LifecycleError(f"unknown goal status: {status_val}") from e + if goal.status == target: + return goal + goal.status = target + goal.updated_at = datetime.now(UTC) + store.update_goal(goal) + action = f"goal.{target.value}" + _record_goal_decision_summary( + store, + goal_id=goal.id, + actor=actor, + action=action, + reason=reason, + ) + audit.log_event( + store.kb_dir, + event=action, + actor=actor, + object_ids=[goal.id], + data={"reason": reason}, + ) + return goal + + +def _record_goal_decision_summary( + store: KBStore, + *, + goal_id: str, + actor: str, + action: str, + reason: str | None, +) -> None: + # Lifecycle transitions are not proposals; write a decided/ summary record + # so `decided/` stays the durable decision timeline for operators. + ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + decided = Proposal( + id=f"{ts}-goal-{goal_id}-{action.rsplit('.', 1)[-1]}-{uuid.uuid4().hex[:6]}", + kind=ProposalKind.GOAL, + proposed_by=actor, + status=ProposalStatus.APPROVED, + payload={ + "id": goal_id, + "action": action, + "reason": reason, + }, + decided_at=datetime.now(UTC), + decided_by=actor, + decision_reason=reason, + ) + store.move_proposal_to_decided(decided) diff --git a/src/vouch/models.py b/src/vouch/models.py index dccf4453..fc55bb6f 100644 --- a/src/vouch/models.py +++ b/src/vouch/models.py @@ -145,6 +145,13 @@ class PageStatus(StrEnum): ARCHIVED = "archived" +class GoalStatus(StrEnum): + OPEN = "open" + DONE = "done" + ABANDONED = "abandoned" + BLOCKED = "blocked" + + # --- core artifacts ------------------------------------------------------- @@ -316,6 +323,33 @@ def _normalize_type(cls, v: Any) -> str: return v.strip() +class Goal(BaseModel): + """Review-gated in-flight objective with explicit lifecycle state.""" + + id: str + title: str + detail: str | None = None + status: GoalStatus = GoalStatus.OPEN + claims: list[str] = Field(default_factory=list) + entities: list[str] = 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) + + @field_validator("title") + @classmethod + def _title_non_empty(cls, v: str) -> str: + if not v.strip(): + raise ValueError("goal title must be non-empty") + return v.strip() + + @field_validator("scope", mode="before") + @classmethod + def _coerce_scope(cls, v: object) -> object: + return _coerce_artifact_scope(v) + + # --- audit + sessions ----------------------------------------------------- @@ -358,6 +392,7 @@ class ProposalKind(StrEnum): PAGE = "page" ENTITY = "entity" RELATION = "relation" + GOAL = "goal" class ProposalStatus(StrEnum): diff --git a/src/vouch/proposals.py b/src/vouch/proposals.py index 6f1dd25a..4ea89942 100644 --- a/src/vouch/proposals.py +++ b/src/vouch/proposals.py @@ -19,6 +19,8 @@ from .models import ( Claim, Entity, + Goal, + GoalStatus, Page, Proposal, ProposalKind, @@ -316,6 +318,48 @@ def propose_relation( ) +def propose_goal( + store: KBStore, + *, + title: str, + detail: str | None = None, + claim_ids: list[str] | None = None, + entity_ids: list[str] | None = None, + proposed_by: str, + tags: list[str] | None = None, + rationale: str | None = None, + slug_hint: str | None = None, + session_id: str | None = None, + dry_run: bool = False, +) -> Proposal: + if not title.strip(): + raise ProposalError("goal title is empty") + for cid in claim_ids or []: + try: + store.get_claim(cid) + except ArtifactNotFoundError as e: + raise ProposalError(f"unknown claim id: {cid}") from e + for eid in entity_ids or []: + try: + store.get_entity(eid) + except ArtifactNotFoundError as e: + raise ProposalError(f"unknown entity id: {eid}") from e + payload = { + "id": slug_hint or _slugify(title), + "title": title.strip(), + "detail": detail, + "status": GoalStatus.OPEN.value, + "claims": claim_ids or [], + "entities": entity_ids or [], + "tags": tags or [], + } + return _file_proposal( + store, kind=ProposalKind.GOAL, payload=payload, + proposed_by=proposed_by, session_id=session_id, + rationale=rationale, dry_run=dry_run, + ) + + # --- decisions ------------------------------------------------------------ @@ -401,6 +445,17 @@ def _payload_block_reason(store: KBStore, proposal: Proposal) -> str | None: 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}" + for cid in goal.claims: + if not store._claim_path(cid).exists(): + return f"goal {goal.id} references unknown claim {cid}" + for eid in goal.entities: + if not store._entity_path(eid).exists(): + return f"goal {goal.id} references unknown entity {eid}" return None @@ -429,7 +484,7 @@ def approve( *, approved_by: str, reason: str | None = None, -) -> 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 @@ -448,7 +503,7 @@ def approve( # via update_page rather than put_page. if proposal.kind != ProposalKind.PAGE: _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: claim = Claim(approved_by=approved_by, **payload) store.put_claim(claim) @@ -502,6 +557,10 @@ def approve( type=entity.type.value, aliases=entity.aliases, ) result = entity + elif proposal.kind == ProposalKind.GOAL: + goal = Goal(**payload) + store.put_goal(goal) + result = goal else: # RELATION rel = Relation(**payload) store.put_relation(rel) @@ -668,6 +727,7 @@ def expire_pending( ProposalKind.PAGE: "get_page", ProposalKind.ENTITY: "get_entity", ProposalKind.RELATION: "get_relation", + ProposalKind.GOAL: "get_goal", } diff --git a/src/vouch/recall.py b/src/vouch/recall.py index 911d512c..44f2a5c6 100644 --- a/src/vouch/recall.py +++ b/src/vouch/recall.py @@ -58,16 +58,24 @@ def build_digest(store: KBStore, *, max_chars: int = DEFAULT_MAX_CHARS) -> str: c for c in store.list_claims() if c.status not in _RETRACTED_CLAIM_STATUSES ] + goals = sorted( + [g for g in store.list_goals() if g.status.value == "open"], + key=lambda g: g.created_at, + ) pages = store.list_pages() - if not claims and not pages: + if not claims and not pages and not goals: return "" lines: list[str] = [ _OPEN_TAG, f"# approved KB knowledge for this repo — {len(claims)} claim(s), " - f"{len(pages)} page(s). reviewed, cited, durable. use kb_read_page / " + f"{len(pages)} page(s), {len(goals)} open goal(s). " + "reviewed, cited, durable. use kb_read_page / " "kb_search for detail; kb_propose_* (human-approved) to add more.", ] + if goals: + lines += ["", "## open goals"] + lines += [f"- [{g.id}] {g.title}" for g in goals] if claims: lines += ["", "## claims"] lines += [f"- [{c.id}] {c.text}" for c in claims] diff --git a/src/vouch/server.py b/src/vouch/server.py index 81fa5c23..2b7f1067 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -36,6 +36,7 @@ expire_pending, propose_claim, propose_entity, + propose_goal, propose_page, propose_relation, reject, @@ -318,6 +319,28 @@ def kb_list_relations(node_id: str | None = None) -> list[dict[str, Any]]: return [r.model_dump(mode="json") for r in rels] +@mcp.tool() +def kb_list_goals( + status: str = "open", + *, + project: str | None = None, + agent: str | None = None, +) -> list[dict[str, Any]]: + """List goals (default open), oldest-first, optionally viewer-scoped.""" + from .scoping import is_visible, viewer_from + + store = _store() + viewer = viewer_from(config_path=store.config_path, project=project, agent=agent) + goals = sorted(store.list_goals(), key=lambda g: g.created_at) + if status: + goals = [g for g in goals if g.status.value == status] + return [ + g.model_dump(mode="json") + for g in goals + if is_visible(g.scope, viewer) + ] + + @mcp.tool() def kb_list_sources() -> list[dict[str, Any]]: return [ @@ -476,6 +499,38 @@ def kb_propose_relation( return _proposal_response(pr, dry_run) +@mcp.tool() +def kb_propose_goal( + title: str, + *, + detail: str | None = None, + claim_ids: list[str] | None = None, + entity_ids: 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]: + try: + pr = propose_goal( + _store(), + title=title, + detail=detail, + claim_ids=claim_ids, + entity_ids=entity_ids, + 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) + + def _proposal_response(result, dry_run: bool) -> dict[str, Any]: pr = result.proposal if hasattr(result, "proposal") else result out: dict[str, Any] = { @@ -586,6 +641,18 @@ def kb_confirm(claim_id: str) -> dict[str, Any]: return {"id": c.id, "last_confirmed_at": c.last_confirmed_at} +@mcp.tool() +def kb_goal_set_status( + goal_id: str, + status: str, + reason: str | None = None, +) -> dict[str, Any]: + goal = life.goal_set_status( + _store(), goal_id=goal_id, status=status, actor=_agent(), reason=reason, + ) + return {"id": goal.id, "status": goal.status.value} + + @mcp.tool() def kb_cite(claim_id: str) -> list[dict[str, Any]]: """Return resolved citations (sources or evidence records) backing a claim.""" diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 653ea806..af316261 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -8,6 +8,7 @@ .gitignore — keeps proposed/ + state.db out of git claims/.yaml — durable approved claims (committed) pages/.md — markdown pages with YAML frontmatter + goals/.yaml — durable approved goals (committed) sources//meta.yaml — source metadata sources//content — raw source bytes entities/.yaml — graph nodes @@ -39,6 +40,7 @@ Claim, Entity, Evidence, + Goal, Page, Proposal, ProposalStatus, @@ -61,7 +63,7 @@ SUBDIRS = ( "claims", "pages", "sources", "entities", "relations", - "evidence", "sessions", "proposed", "decided", + "evidence", "sessions", "goals", "proposed", "decided", ) @@ -286,6 +288,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) @@ -736,6 +741,38 @@ 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 put_goal(self, goal: Goal) -> Goal: + try: + with self._goal_path(goal.id).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 get_goal(self, goal_id: str) -> Goal: + p = self._goal_path(goal_id) + if not p.exists(): + raise ArtifactNotFoundError(f"goal {goal_id}") + return Goal.model_validate(_yaml_load(p.read_text(encoding="utf-8"))) + + def update_goal(self, goal: Goal) -> Goal: + if not self._goal_path(goal.id).exists(): + raise ArtifactNotFoundError(f"goal {goal.id}") + self._goal_path(goal.id).write_text( + _yaml_dump(goal.model_dump(mode="json")), encoding="utf-8") + return goal + + 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/src/vouch/sync.py b/src/vouch/sync.py index 1a6636b5..a3432b95 100644 --- a/src/vouch/sync.py +++ b/src/vouch/sync.py @@ -74,6 +74,7 @@ def _artifact_kind(path: str) -> tuple[str, str | None]: singular = { "claims": "claim", "pages": "page", + "goals": "goal", "entities": "entity", "relations": "relation", "evidence": "evidence", @@ -254,7 +255,7 @@ def _sync_check_with_src(kb_dir: Path, src: _SyncSource) -> SyncCheckResult: semantic_conflicts = [ c for c in conflicts - if c.kind in {"claim", "page", "entity", "relation", "evidence", "session"} + if c.kind in {"claim", "page", "goal", "entity", "relation", "evidence", "session"} ] decided_conflicts = [c for c in conflicts if c.kind == "decided-proposal"] return SyncCheckResult( diff --git a/tests/test_goals.py b/tests/test_goals.py new file mode 100644 index 00000000..ea69a6dc --- /dev/null +++ b/tests/test_goals.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import lifecycle +from vouch.models import Goal, GoalStatus, ProposalKind, ProposalStatus +from vouch.proposals import approve, propose_goal +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def test_goal_model_rejects_whitespace_title() -> None: + with pytest.raises(ValueError, match="non-empty"): + Goal(id="g1", title=" ") + + +def test_propose_goal_then_approve(store: KBStore) -> None: + pr = propose_goal( + store, + title="finish typed config migration", + detail="replace ad-hoc dict reads with typed settings access", + proposed_by="agent", + tags=["migration"], + ) + assert pr.kind == ProposalKind.GOAL + approved = approve(store, pr.id, approved_by="reviewer") + assert isinstance(approved, Goal) + assert approved.status == GoalStatus.OPEN + assert store.get_goal(approved.id).title == "finish typed config migration" + decided = store.get_proposal(pr.id) + assert decided.status == ProposalStatus.APPROVED + + +def test_goal_status_transitions_write_decided_and_audit(store: KBStore) -> None: + pr = propose_goal(store, title="ship v1.2", proposed_by="agent") + goal = approve(store, pr.id, approved_by="reviewer") + lifecycle.goal_set_status( + store, + goal_id=goal.id, + status=GoalStatus.BLOCKED, + actor="reviewer", + reason="waiting on release checks", + ) + updated = store.get_goal(goal.id) + assert updated.status == GoalStatus.BLOCKED + decided = store.list_proposals(ProposalStatus.APPROVED) + assert any( + p.kind == ProposalKind.GOAL + and p.payload.get("id") == goal.id + and p.payload.get("action") == "goal.blocked" + for p in decided + ) + + +def test_jsonl_goal_flow(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + from vouch.jsonl_server import handle_request + + monkeypatch.chdir(store.root) + proposed = handle_request({ + "id": "1", + "method": "kb.propose_goal", + "params": {"title": "reduce flaky tests"}, + }) + assert proposed["ok"] + pid = proposed["result"]["proposal_id"] + monkeypatch.setenv("VOUCH_AGENT", "reviewer") + approved = handle_request({ + "id": "2", + "method": "kb.approve", + "params": {"proposal_id": pid}, + }) + assert approved["ok"] + listed = handle_request({ + "id": "3", + "method": "kb.list_goals", + "params": {}, + }) + assert listed["ok"] + assert len(listed["result"]) == 1 + assert listed["result"][0]["status"] == "open" + moved = handle_request({ + "id": "4", + "method": "kb.goal_set_status", + "params": {"goal_id": listed["result"][0]["id"], "status": "done"}, + }) + assert moved["ok"] + assert moved["result"]["status"] == "done" + diff --git a/tests/test_recall.py b/tests/test_recall.py index a08a02ce..ab003b8b 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -8,7 +8,7 @@ from vouch import recall from vouch.models import ClaimStatus -from vouch.proposals import approve, propose_claim, propose_page +from vouch.proposals import approve, propose_claim, propose_goal, propose_page from vouch.storage import KBStore, _starter_config @@ -28,6 +28,11 @@ def _approve_page(store: KBStore, title: str): return approve(store, pr.id, approved_by="u") +def _approve_goal(store: KBStore, title: str): + pr = propose_goal(store, title=title, proposed_by="a") + return approve(store, pr.id, approved_by="u") + + def test_digest_includes_approved_claim_and_page(store: KBStore) -> None: _approve_claim(store, "JWT chosen over sessions") _approve_page(store, "auth design") @@ -37,6 +42,13 @@ def test_digest_includes_approved_claim_and_page(store: KBStore) -> None: assert "auth design" in d +def test_digest_includes_open_goals(store: KBStore) -> None: + _approve_goal(store, "finish typed config migration") + d = recall.build_digest(store) + assert "open goals" in d + assert "finish typed config migration" in d + + def test_digest_excludes_retracted_claims(store: KBStore) -> None: _approve_claim(store, "live fact") archived = _approve_claim(store, "archived fact") diff --git a/tests/test_storage.py b/tests/test_storage.py index 03ce0fba..a3dddf60 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -52,7 +52,7 @@ def store(tmp_path: Path) -> KBStore: def test_init_creates_layout(tmp_path: Path) -> None: s = KBStore.init(tmp_path) for sub in ("claims", "pages", "sources", "entities", "relations", - "evidence", "sessions", "proposed", "decided"): + "evidence", "sessions", "goals", "proposed", "decided"): assert (s.kb_dir / sub).is_dir() assert s.config_path.exists() gi = (s.kb_dir / ".gitignore").read_text()