Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ 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.2.2] — 2026-07-07

### Packaging
Expand Down
8 changes: 6 additions & 2 deletions src/vouch/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -37,6 +37,7 @@
EXPORT_SUBDIRS = (
"claims",
"pages",
"goals",
"sources",
"entities",
"relations",
Expand All @@ -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)),
Expand Down Expand Up @@ -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",
Expand All @@ -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"),
Expand Down
3 changes: 3 additions & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"kb.list_claims",
"kb.list_entities",
"kb.list_relations",
"kb.list_goals",
"kb.list_sources",
"kb.list_pending",
"kb.triage_pending",
Expand All @@ -54,6 +55,7 @@
"kb.propose_page",
"kb.propose_entity",
"kb.propose_relation",
"kb.propose_goal",
"kb.approve",
"kb.reject",
"kb.reject_extracted",
Expand All @@ -62,6 +64,7 @@
"kb.contradict",
"kb.archive",
"kb.confirm",
"kb.goal_set_status",
"kb.cite",
"kb.source_verify",
"kb.session_start",
Expand Down
73 changes: 73 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
from .context import build_context_pack
from .lifecycle import LifecycleError
from .logging_config import configure_logging
from .models import GoalStatus, Proposal, ProposalKind, ProposalStatus
from .onboarding import seed_starter_kb
from .models import Proposal, ProposalKind, ProposalStatus
from .onboarding import (
DEFAULT_TEMPLATE,
Expand All @@ -65,6 +67,7 @@
expire_pending,
propose_claim,
propose_entity,
propose_goal,
propose_page,
propose_relation,
reject_auto_extracted,
Expand Down Expand Up @@ -1690,6 +1693,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 --------------------------------------------------------------


Expand Down Expand Up @@ -2167,6 +2236,10 @@ 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(name="compile")
@click.option("--dry-run", is_flag=True, help="Draft and validate; file nothing.")
@click.option("--max-pages", type=int, default=None,
Expand Down
8 changes: 7 additions & 1 deletion src/vouch/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from . import index_db
from .audit import count_events, verify_chain
from .models import Claim, ClaimStatus, Entity, Goal, Page, ProposalKind, ProposalStatus
from .models import Claim, ClaimStatus, Entity, Page, ProposalKind, ProposalStatus, Source
from .storage import KBStore, _yaml_load, sha256_hex
from .verify import verify_all
Expand Down Expand Up @@ -48,6 +49,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()),
Expand All @@ -70,6 +72,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()),
Expand Down Expand Up @@ -308,11 +311,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:
Expand Down Expand Up @@ -412,6 +416,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:
Expand All @@ -425,6 +430,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,
}
Expand Down
48 changes: 48 additions & 0 deletions src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
expire_pending,
propose_claim,
propose_entity,
propose_goal,
propose_page,
propose_relation,
reject,
Expand Down Expand Up @@ -293,6 +294,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()]

Expand Down Expand Up @@ -426,6 +443,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"))
Expand Down Expand Up @@ -493,6 +527,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 [
Expand Down Expand Up @@ -744,6 +789,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.triage_pending": _h_triage_pending,
Expand All @@ -754,6 +800,7 @@ def _h_propose_theme(p: dict) -> dict:
"kb.compile": _h_compile,
"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,
Expand All @@ -762,6 +809,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,
Expand Down
Loading