feat: first-class goals — review-gated in-flight objectives - #676
Merged
Conversation
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 vouchdev#427
Member
|
I was missing this feature while comparing vouch with ditto |
…rfaces 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.
minion1227
added a commit
to minion1227/vouch
that referenced
this pull request
Jul 31, 2026
`METHOD_SCOPES` was exhaustive over `capabilities.METHODS` when this branch was written. four methods have merged to `test` since — kb.capture_correction (vouchdev#679), and kb.list_goals / kb.propose_goal / kb.set_goal_status (vouchdev#676) — leaving the table stale by four and all three matrix jobs red on `test_every_method_is_classified`. that failure is the guard working. by this branch's own rule an unclassified method is denied to a scoped caller, so merging as-is would have silently locked every scoped credential out of goal writes and correction capture. the right failure direction, but still a regression, and invisible from the diff. the classifications: - kb.list_goals -> kb:read. a listing that cannot change durable state, beside kb.list_sessions. - kb.propose_goal -> kb:propose. files a PENDING goal a human approves. - kb.capture_correction -> kb:propose. routes exclusively through propose_quoted_claim and has no import of approve. - kb.set_goal_status -> kb:approve, not kb:propose. this is the one that departs from the suggestion on the closing comment, and deliberately: it is a lifecycle op living in lifecycle.py beside supersede/archive/confirm, it mutates an already approved goal in place, and server.py documents it as "the only write path for goal status" — status moves never go through a second proposal. filing it under kb:propose would let a propose-only credential change durable state with no review, which is the exact boundary this module exists to hold. no other change: the scope machinery, the two safety rules and the tests are as reviewed. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
vouch models claims, pages, entities and relations — everything the project knows. it has no representation of what the project is trying to do right now, so an agent re-orienting after a compaction recovers facts and decisions but not in-flight intent. this adds
goalas a first-class, review-gated artifact.What lands
Goal+GoalStatus(open/done/abandoned/blocked) inmodels.py, with the validation gap: Claim.text, Page.title, Entity.name accept empty/whitespace at the model layer; only propose_* enforces non-empty #155 empty-title validator on the model itself so every write path is closed at once.GOALadded toProposalKind;kb.propose_goalroutes throughproposals.propose_*like any other proposal — there is no direct-write entry point.kb.list_goals(defaults to open, oldest-first, viewer-scoped) andkb.set_goal_status, backed bylifecycle.set_goal_status.vouch digestand in the SessionStart recall digest.storage.pygainsgoals/and pure put/get/update/list — no business logic; the status rules live inlifecycle.pyandproposals.py.How the review gate is preserved
two invariants, both tested:
approval is pinned to
open._payload_block_reasonrefuses a GOAL proposal whose payload carries any other status. approving one would put a transition on disk that never passed the lifecycle path and so never reached the audit log — a parallel write path in disguise.lifecycle.set_goal_statusis the only mutation path. it appends agoal.statusevent toaudit.log.jsonland a row to the goal's own append-onlyhistory.test_only_lifecycle_mutates_a_stored_goalscans the package and asserts no second caller ofstore.update_goalexists.One deviation from the issue, called out
the issue's acceptance criteria ask for a
decided/record per transition.decided/holds decidedProposalyaml keyed by proposal id and is read back throughlist_proposals; writing a non-proposal there would corrupt that read. the transition record instead lands in the two places that already carry authoritative history — the append-only audit log, and the goal's ownhistorylist, which is diffable in PRs exactly likedecided/is. happy to change shape if you'd rather transitions be proposals themselves.Test Plan
tests/test_goals.py(18 tests) — propose→approve→transition, the propose-only invariant, the pinned-openrefusal, unknown/no-op status refusal, oldest-first viewer-scoped listing, both digest surfaces, and four-site registrationtest_capabilitiesparity (_CLI_MIRRORSentries added) andtest_hot_memory_universal_coverageboth greenCloses #427