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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ All notable changes to vouch are documented here. Format follows
## [Unreleased]

### Added
- `vouch expire` garbage-collects stale pending proposals: dry-run by default,
`--apply` moves them to `decided/` with `decision_reason: expired`, emits
`proposal.expire` audit events, and honors `review.expire_pending_after_days`
in `config.yaml` (default 90; `0` disables). `kb.expire` on MCP/JSONL.
- `vouch init --template <name>` seeds a domain starter pack. The default `starter` template is unchanged; the new `gittensor` template seeds a small, cited, approved KB about Gittensor (SN74) contribution scoring (1 source, 1 entity, 7 claims — merged-PR rewards, PAT verification, scoring factors, sybil-resistance, repo allow-list policy, issue-solving multiplier, and emission split) so a fresh KB in a Gittensor repo has retrievable context on day one. Templates are an in-code registry — future packs plug in the same way.

## [0.1.0] — 2026-05-26
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ vouch show <id> # full details
vouch approve <id> # → durable artifact
vouch approve <id> <id> ... # approve several reviewed proposals at once
vouch reject <id> --reason "..."
vouch expire --apply # optional: clear stale pending proposals

# 4. commit
git add .vouch/ && git commit -m "kb: approve auth-uses-jwt"
Expand Down Expand Up @@ -150,6 +151,7 @@ vouch review [--limit N] [--type KIND] # guided proposal review queue
vouch show <proposal-id>
vouch approve <proposal-id>... [--reason ...] [--keep-going]
vouch reject <proposal-id> --reason "..."
vouch expire [--apply] [--days N] [--json] # GC stale pending proposals

vouch propose-claim --text ... --source ... [--type ...] [--confidence X]
vouch propose-page --title ... [--body -] [--claim ID ...]
Expand Down
1 change: 1 addition & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"kb.propose_relation",
"kb.approve",
"kb.reject",
"kb.expire",
"kb.supersede",
"kb.contradict",
"kb.archive",
Expand Down
64 changes: 64 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
seed_starter_kb,
)
from .proposals import (
EXPIRE_ACTOR,
ProposalError,
check_approvable,
expire_pending,
propose_claim,
propose_entity,
propose_page,
Expand Down Expand Up @@ -488,6 +490,68 @@ def reject(proposal_id: str, reason: str) -> None:
click.echo(f"Rejected {proposal_id}")


def _expire_row(proposal: Proposal) -> dict[str, Any]:
return {
"id": proposal.id,
"kind": proposal.kind.value,
"proposed_by": proposal.proposed_by,
"proposed_at": proposal.proposed_at.isoformat(),
}


@cli.command()
@click.option("--apply", is_flag=True, help="Expire stale proposals (default is dry-run).")
@click.option("--days", type=int, default=None,
help="Override review.expire_pending_after_days for this run.")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.")
def expire(apply: bool, days: int | None, as_json: bool) -> None:
"""Garbage-collect pending proposals older than the configured threshold."""
store = _load_store()
result = expire_pending(
store, apply=apply, expired_by=EXPIRE_ACTOR, days=days,
)
if as_json:
payload: dict[str, Any] = {
"threshold_days": result.threshold_days,
"enabled": result.threshold_days > 0,
"dry_run": not apply,
"would_expire": [_expire_row(p) for p in result.would_expire],
"expired": [_expire_row(p) for p in result.expired],
}
_emit_json(payload)
return

if result.threshold_days <= 0:
click.echo("expire disabled (review.expire_pending_after_days is 0)")
return

if not result.would_expire:
click.echo(
f"no stale pending proposals (threshold: {result.threshold_days} days)"
)
return

if not apply:
click.echo(
f"dry-run: {len(result.would_expire)} proposal(s) would expire "
f"(threshold: {result.threshold_days} days)"
)
for pr in result.would_expire:
click.echo(
f" {pr.id} [{pr.kind.value}] by {pr.proposed_by} "
f"proposed {pr.proposed_at.date().isoformat()}"
)
click.echo("rerun with --apply to expire")
return

click.echo(
f"expired {len(result.expired)} proposal(s) "
f"(threshold: {result.threshold_days} days)"
)
for pr in result.expired:
click.echo(f" {pr.id} [{pr.kind.value}]")


# --- proposal-from-CLI shortcuts -----------------------------------------


Expand Down
19 changes: 19 additions & 0 deletions src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
from .context import build_context_pack
from .models import ProposalStatus
from .proposals import (
EXPIRE_ACTOR,
ProposalError,
approve,
expire_pending,
propose_claim,
propose_entity,
propose_page,
Expand Down Expand Up @@ -295,6 +297,22 @@ def _h_reject(p: dict) -> dict:
return {"proposal_id": p["proposal_id"], "status": "rejected"}


def _h_expire(p: dict) -> dict:
result = expire_pending(
_store(),
apply=bool(p.get("apply")),
expired_by=EXPIRE_ACTOR,
days=p.get("days"),
)
return {
"threshold_days": result.threshold_days,
"enabled": result.threshold_days > 0,
"dry_run": not bool(p.get("apply")),
"would_expire": [pr.id for pr in result.would_expire],
"expired": [pr.id for pr in result.expired],
}


def _h_supersede(p: dict) -> dict:
old, new = life.supersede(
_store(), old_claim_id=p["old_claim_id"],
Expand Down Expand Up @@ -491,6 +509,7 @@ def _h_embeddings_stats(_: dict) -> dict:
"kb.propose_relation": _h_propose_relation,
"kb.approve": _h_approve,
"kb.reject": _h_reject,
"kb.expire": _h_expire,
"kb.supersede": _h_supersede,
"kb.contradict": _h_contradict,
"kb.archive": _h_archive,
Expand Down
111 changes: 109 additions & 2 deletions src/vouch/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@

import time
import uuid
from datetime import UTC, datetime
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any

import yaml

from . import audit, index_db
from .models import (
Claim,
Expand All @@ -28,6 +31,20 @@ class ProposalError(RuntimeError):
pass


EXPIRE_REASON = "expired"
EXPIRE_ACTOR = "vouch-expire"
_DEFAULT_EXPIRE_PENDING_DAYS = 90


@dataclass
class ExpireResult:
"""Outcome of `expire_pending` (dry-run or apply)."""

threshold_days: int
would_expire: list[Proposal] = field(default_factory=list)
expired: list[Proposal] = field(default_factory=list)


def new_proposal_id() -> str:
# Sortable timestamped id: '20260517-143052-<short>'. Sorted listings
# naturally show oldest pending first, which matches review intuition.
Expand Down Expand Up @@ -224,7 +241,6 @@ def _approval_block_reason(
if approved_by == proposal.proposed_by:
cfg: dict[str, Any] = {}
try:
import yaml
loaded = yaml.safe_load((store.kb_dir / "config.yaml").read_text())
if isinstance(loaded, dict):
cfg = loaded
Expand Down Expand Up @@ -352,6 +368,97 @@ def reject(
return proposal


def expire_pending_after_days(store: KBStore, *, override: int | None = None) -> int:
"""Resolve GC threshold from config (`review.expire_pending_after_days`)."""
if override is not None:
return override
try:
loaded = yaml.safe_load(store.config_path.read_text())
except Exception:
return _DEFAULT_EXPIRE_PENDING_DAYS
if not isinstance(loaded, dict):
return _DEFAULT_EXPIRE_PENDING_DAYS
review_cfg = loaded.get("review")
if not isinstance(review_cfg, dict):
return _DEFAULT_EXPIRE_PENDING_DAYS
days = review_cfg.get("expire_pending_after_days")
if isinstance(days, int) and days >= 0:
return days
return _DEFAULT_EXPIRE_PENDING_DAYS


def _utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)


def list_stale_pending(store: KBStore, *, days: int) -> list[Proposal]:
"""Pending proposals older than `days` (by `proposed_at`). `days <= 0` → none."""
if days <= 0:
return []
cutoff = datetime.now(UTC) - timedelta(days=days)
stale: list[Proposal] = []
for proposal in store.list_proposals(ProposalStatus.PENDING):
if _utc(proposal.proposed_at) < cutoff:
stale.append(proposal)
return stale


def expire_one(
store: KBStore,
proposal_id: str,
*,
expired_by: str = EXPIRE_ACTOR,
) -> Proposal:
"""Expire a single pending proposal (terminal reject + audit)."""
proposal = store.get_proposal(proposal_id)
if proposal.status != ProposalStatus.PENDING:
if (
proposal.status == ProposalStatus.REJECTED
and proposal.decision_reason == EXPIRE_REASON
):
return proposal
raise ProposalError(
f"proposal {proposal_id} is {proposal.status.value}, not pending"
)
proposal.status = ProposalStatus.REJECTED
proposal.decided_at = datetime.now(UTC)
proposal.decided_by = expired_by
proposal.decision_reason = EXPIRE_REASON
store.move_proposal_to_decided(proposal)
audit.log_event(
store.kb_dir,
event="proposal.expire",
actor=expired_by,
object_ids=[proposal.id],
data={"kind": proposal.kind.value},
)
return proposal


def expire_pending(
store: KBStore,
*,
apply: bool = False,
expired_by: str = EXPIRE_ACTOR,
days: int | None = None,
) -> ExpireResult:
"""Garbage-collect stale pending proposals per review-gate spec."""
threshold = expire_pending_after_days(store, override=days)
stale = list_stale_pending(store, days=threshold)
if not apply:
return ExpireResult(threshold_days=threshold, would_expire=stale)
expired = [
expire_one(store, proposal.id, expired_by=expired_by) for proposal in stale
]
return ExpireResult(
threshold_days=threshold,
would_expire=stale,
expired=expired,
)


_ARTIFACT_GETTERS = {
ProposalKind.CLAIM: "get_claim",
ProposalKind.PAGE: "get_page",
Expand Down
20 changes: 20 additions & 0 deletions src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
from .context import build_context_pack
from .models import ProposalStatus
from .proposals import (
EXPIRE_ACTOR,
ProposalError,
approve,
expire_pending,
propose_claim,
propose_entity,
propose_page,
Expand Down Expand Up @@ -421,6 +423,24 @@ def kb_reject(proposal_id: str, reason: str) -> dict[str, Any]:
return {"proposal_id": proposal_id, "status": "rejected", "reason": reason}


@mcp.tool()
def kb_expire(apply: bool = False, days: int | None = None) -> dict[str, Any]:
"""Expire stale pending proposals (dry-run unless apply=True)."""
try:
result = expire_pending(
_store(), apply=apply, expired_by=EXPIRE_ACTOR, days=days,
)
except (ArtifactNotFoundError, ValueError, ProposalError) as e:
raise ValueError(str(e)) from e
return {
"threshold_days": result.threshold_days,
"enabled": result.threshold_days > 0,
"dry_run": not apply,
"would_expire": [p.id for p in result.would_expire],
"expired": [p.id for p in result.expired],
}


# === lifecycle ============================================================


Expand Down
5 changes: 4 additions & 1 deletion src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ class ArtifactNotFoundError(KeyError):
def _starter_config() -> dict[str, Any]:
return {
"version": 1,
"review": {"require_human_approval": True},
"review": {
"require_human_approval": True,
"expire_pending_after_days": 90,
},
"retrieval": {
# auto = embedding -> fts5 -> substring; or pin one of
# embedding | fts5 | substring. See context._retrieve.
Expand Down
Loading
Loading