Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **explicit pins — a working set that always enters the pack** (#615):
`vouch pin <id>` / `vouch pins list` / `vouch unpin <id>`. Pinned claims and
pages lead every context pack instead of having to win the query each turn,
Expand Down
3 changes: 2 additions & 1 deletion schemas/proposal.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"page",
"entity",
"relation",
"delete"
"delete",
"goal"
],
"title": "ProposalKind",
"type": "string"
Expand Down
3 changes: 3 additions & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,7 @@
propose_claim,
propose_delete,
propose_entity,
propose_goal,
propose_page,
propose_relation,
reject_auto_extracted,
Expand Down Expand Up @@ -2568,6 +2570,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 ------------------------------------------------------------


Expand Down
39 changes: 39 additions & 0 deletions src/vouch/digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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]:
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)

Expand All @@ -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%}")
Expand Down Expand Up @@ -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%}")
Expand Down
49 changes: 49 additions & 0 deletions src/vouch/goals.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/vouch/hot_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +54,7 @@
propose_claim,
propose_delete,
propose_entity,
propose_goal,
propose_page,
propose_relation,
reject,
Expand Down Expand Up @@ -560,6 +562,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"),
Expand Down Expand Up @@ -965,6 +1013,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,
Expand Down
Loading
Loading