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
1 change: 1 addition & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"kb.archive",
"kb.confirm",
"kb.clear_claims",
"kb.wipe_dead_refs",
"kb.cite",
"kb.source_verify",
"kb.session_start",
Expand Down
107 changes: 100 additions & 7 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
ProposalError,
check_approvable,
expire_pending,
missing_claim_refs,
propose_claim,
propose_delete,
propose_entity,
Expand Down Expand Up @@ -1596,7 +1597,19 @@ def triage(proposal_ids: tuple[str, ...], as_json: bool, reverse: bool) -> None:
help="Best-effort: approve every id that can be approved and report the "
"rest, instead of the default all-or-nothing precheck.",
)
def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool) -> None:
@click.option(
"--drop-missing-claims",
is_flag=True,
help="Strip references to claims that no longer exist from page "
"proposals instead of refusing to approve them (dropped ids are "
"recorded in the audit event).",
)
def approve(
proposal_ids: tuple[str, ...],
reason: str | None,
keep_going: bool,
drop_missing_claims: bool,
) -> None:
"""Approve one or more proposals — converts each into a durable artifact.

Pass several ids to approve a batch in one call (useful for CI and
Expand All @@ -1611,16 +1624,46 @@ def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool)
aborts the whole batch and nothing is approved.
- --keep-going (best-effort): approve each id independently, report the
failures, and exit non-zero if any failed.

A page proposal citing claims that no longer exist blocks the batch;
interactively you are offered to strip the dead references, and
--drop-missing-claims does the same without the prompt.
"""
store = _load_store()
approver = _whoami()

if not keep_going:
blocked = [
(pid, reason_blocked)
for pid in proposal_ids
if (reason_blocked := check_approvable(store, pid, approved_by=approver))
]
blocked: list[tuple[str, str]] = []
dead_blocked: list[tuple[str, list[str]]] = []
for pid in proposal_ids:
why = check_approvable(store, pid, approved_by=approver)
if not why:
continue
# A dead claim reference is a decision, not a defect: offer the
# strip-and-approve path instead of a hard abort. Any other block
# (typo, already decided, self-approval) still aborts the batch.
if "references unknown claim" in why:
dead = missing_claim_refs(store, store.get_proposal(pid))
if dead:
dead_blocked.append((pid, dead))
continue
blocked.append((pid, why))
if dead_blocked and not drop_missing_claims:
for pid, dead in dead_blocked:
click.echo(
f"! {pid}: cites missing claim(s): {', '.join(dead)}", err=True
)
if sys.stdin.isatty() and click.confirm(
f"strip the dead claim reference(s) from {len(dead_blocked)} "
"proposal(s) and approve?"
):
drop_missing_claims = True
else:
blocked.extend(
(pid, f"cites missing claim(s): {', '.join(dead)} "
"(use --drop-missing-claims to strip them)")
for pid, dead in dead_blocked
)
if blocked:
for pid, why in blocked:
click.echo(f"✗ {pid}: {why}", err=True)
Expand All @@ -1632,7 +1675,10 @@ def approve(proposal_ids: tuple[str, ...], reason: str | None, keep_going: bool)
failures = 0
for pid in proposal_ids:
try:
artifact = do_approve(store, pid, approved_by=approver, reason=reason)
artifact = do_approve(
store, pid, approved_by=approver, reason=reason,
drop_missing_claims=drop_missing_claims,
)
except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e:
failures += 1
click.echo(f"✗ {pid}: {e}", err=True)
Expand Down Expand Up @@ -2594,6 +2640,53 @@ def claims_clear(auto_only: bool, before: str | None, confirm: bool, dry_run: bo
click.echo(f"cleared {len(to_clear)} claims")


@cli.command(name="wipe-dead-refs")
@click.option("--dry-run", is_flag=True, default=False,
help="Preview what would be stripped without making changes")
@click.option("--confirm", "skip_confirm", is_flag=True, default=False,
help="Skip confirmation prompt")
def wipe_dead_refs(dry_run: bool, skip_confirm: bool) -> None:
"""Strip references to claims that no longer exist, KB-wide.

Scans durable pages and pending page proposals for claim ids that
resolve to no claim file (archived claims still resolve) and removes
them — frontmatter list and inline [claim: …] markers both. One
audited bulk event records what was removed. Run after claims were
redacted or bulk-cleared and lint reports orphan_page_ref.
"""
store = _load_store()
with _cli_errors():
preview = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=True)

if not preview.pages and not preview.proposals:
click.echo("no dead claim references found")
return

click.echo(f"found {preview.dropped} dead claim reference(s):")
for page_id, dead in preview.pages.items():
click.echo(f" page {page_id}: {', '.join(dead)}")
for pid, dead in preview.proposals.items():
click.echo(f" proposal {pid}: {', '.join(dead)}")

if dry_run:
click.echo("(dry-run mode: no changes made)")
return

if not skip_confirm and not click.confirm(
f"\nStrip {preview.dropped} dead reference(s)?"
):
click.echo("cancelled")
return

with _cli_errors():
result = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=False)
click.echo(
f"stripped {result.dropped} dead reference(s) from "
f"{len(result.pages)} page(s) and {len(result.proposals)} "
"pending proposal(s)"
)
Comment on lines +2643 to +2687

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

--confirm flag name inverts its own semantics — skips confirmation instead of requiring it.

the option is named --confirm but is bound to skip_confirm and its help text says "skip confirmation prompt." a user who passes --confirm expecting extra safety on a KB-wide, bulk-mutating command instead bypasses the prompt outright. rename to something unambiguous (--yes / --no-confirm / --force) so the flag name matches its effect.

🛡️ proposed rename
-@click.option("--confirm", "skip_confirm", is_flag=True, default=False,
-              help="Skip confirmation prompt")
+@click.option("--yes", "skip_confirm", is_flag=True, default=False,
+              help="Skip confirmation prompt")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@cli.command(name="wipe-dead-refs")
@click.option("--dry-run", is_flag=True, default=False,
help="Preview what would be stripped without making changes")
@click.option("--confirm", "skip_confirm", is_flag=True, default=False,
help="Skip confirmation prompt")
def wipe_dead_refs(dry_run: bool, skip_confirm: bool) -> None:
"""Strip references to claims that no longer exist, KB-wide.
Scans durable pages and pending page proposals for claim ids that
resolve to no claim file (archived claims still resolve) and removes
themfrontmatter list and inline [claim: …] markers both. One
audited bulk event records what was removed. Run after claims were
redacted or bulk-cleared and lint reports orphan_page_ref.
"""
store = _load_store()
with _cli_errors():
preview = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=True)
if not preview.pages and not preview.proposals:
click.echo("no dead claim references found")
return
click.echo(f"found {preview.dropped} dead claim reference(s):")
for page_id, dead in preview.pages.items():
click.echo(f" page {page_id}: {', '.join(dead)}")
for pid, dead in preview.proposals.items():
click.echo(f" proposal {pid}: {', '.join(dead)}")
if dry_run:
click.echo("(dry-run mode: no changes made)")
return
if not skip_confirm and not click.confirm(
f"\nStrip {preview.dropped} dead reference(s)?"
):
click.echo("cancelled")
return
with _cli_errors():
result = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=False)
click.echo(
f"stripped {result.dropped} dead reference(s) from "
f"{len(result.pages)} page(s) and {len(result.proposals)} "
"pending proposal(s)"
)
`@cli.command`(name="wipe-dead-refs")
`@click.option`("--dry-run", is_flag=True, default=False,
help="Preview what would be stripped without making changes")
`@click.option`("--yes", "skip_confirm", is_flag=True, default=False,
help="Skip confirmation prompt")
def wipe_dead_refs(dry_run: bool, skip_confirm: bool) -> None:
"""Strip references to claims that no longer exist, KB-wide.
Scans durable pages and pending page proposals for claim ids that
resolve to no claim file (archived claims still resolve) and removes
themfrontmatter list and inline [claim: …] markers both. One
audited bulk event records what was removed. Run after claims were
redacted or bulk-cleared and lint reports orphan_page_ref.
"""
store = _load_store()
with _cli_errors():
preview = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=True)
if not preview.pages and not preview.proposals:
click.echo("no dead claim references found")
return
click.echo(f"found {preview.dropped} dead claim reference(s):")
for page_id, dead in preview.pages.items():
click.echo(f" page {page_id}: {', '.join(dead)}")
for pid, dead in preview.proposals.items():
click.echo(f" proposal {pid}: {', '.join(dead)}")
if dry_run:
click.echo("(dry-run mode: no changes made)")
return
if not skip_confirm and not click.confirm(
f"\nStrip {preview.dropped} dead reference(s)?"
):
click.echo("cancelled")
return
with _cli_errors():
result = life.wipe_dead_claim_refs(store, actor=_whoami(), dry_run=False)
click.echo(
f"stripped {result.dropped} dead reference(s) from "
f"{len(result.pages)} page(s) and {len(result.proposals)} "
"pending proposal(s)"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/cli.py` around lines 2643 - 2687, Rename the `wipe_dead_refs`
confirmation-bypass option so its CLI name clearly indicates that it skips
confirmation, while preserving the existing `skip_confirm` behavior and help
text semantics. Update the `@click.option` declaration and any related
command-facing references consistently; do not make the flag require
confirmation.



@cli.command()
@click.argument("claim_id")
def confirm(claim_id: str) -> None:
Expand Down
1 change: 1 addition & 0 deletions src/vouch/hot_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def mark_volunteered(session_id: str, claim_id: str, *, pushed_at: float) -> Non
"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",
"kb.wipe_dead_refs": "lifecycle — mutates durable state",
"kb.approve": "lifecycle — mutates durable state",
"kb.reject": "lifecycle — mutates durable state",
"kb.reject_extracted": "lifecycle — mutates durable state",
Expand Down
25 changes: 24 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .page_filters import filter_pages
from .proposals import (
EXPIRE_ACTOR,
DeadClaimRefsError,
ProposalError,
approve,
expire_pending,
Expand Down Expand Up @@ -508,7 +509,8 @@ def _h_propose_delete(p: dict) -> dict:

def _h_approve(p: dict) -> dict:
a = approve(_store(), p["proposal_id"], approved_by=_agent(),
reason=p.get("reason"))
reason=p.get("reason"),
drop_missing_claims=bool(p.get("drop_missing_claims", False)))
return {"kind": type(a).__name__.lower(), "id": a.id}


Expand Down Expand Up @@ -560,6 +562,18 @@ def _h_archive(p: dict) -> dict:
return {"id": c.id, "status": c.status.value}


def _h_wipe_dead_refs(p: dict) -> dict:
r = life.wipe_dead_claim_refs(
_store(), actor=_agent(), dry_run=bool(p.get("dry_run", False)),
)
return {
"pages": r.pages,
"proposals": r.proposals,
"dropped": r.dropped,
"dry_run": r.dry_run,
}


def _h_confirm(p: dict) -> dict:
c = life.confirm(_store(), claim_id=p["claim_id"], actor=_agent())
return {"id": c.id, "last_confirmed_at": c.last_confirmed_at.isoformat()
Expand Down Expand Up @@ -885,6 +899,7 @@ def _h_propose_theme(p: dict) -> dict:
"kb.archive": _h_archive,
"kb.confirm": _h_confirm,
"kb.clear_claims": _h_clear_claims,
"kb.wipe_dead_refs": _h_wipe_dead_refs,
"kb.cite": _h_cite,
"kb.source_verify": _h_source_verify,
"kb.session_start": _h_session_start,
Expand Down Expand Up @@ -941,6 +956,14 @@ def handle_request(envelope: dict) -> dict:
"id": req_id, "ok": False,
"error": {"code": "missing_param", "message": str(e)},
}
except DeadClaimRefsError as e:
# Distinct code so interactive clients can offer "strip dead refs
# and approve" and retry with drop_missing_claims — a message-match
# on invalid_request would be a brittle contract.
return {
"id": req_id, "ok": False,
"error": {"code": "dead_claim_refs", "message": str(e)},
}
except (ValueError, ProposalError, ArtifactNotFoundError) as e:
return {
"id": req_id, "ok": False,
Expand Down
90 changes: 88 additions & 2 deletions src/vouch/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime

from . import audit
from .models import Claim, ClaimStatus, Evidence, Relation, RelationType
from . import audit, index_db
from .models import (
Claim,
ClaimStatus,
Evidence,
ProposalKind,
ProposalStatus,
Relation,
RelationType,
)
from .proposals import strip_claim_markers
from .storage import ArtifactNotFoundError, KBStore


Expand Down Expand Up @@ -219,6 +229,82 @@ def clear_claims(
return to_clear


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

pages: dict[str, list[str]] = field(default_factory=dict)
proposals: dict[str, list[str]] = field(default_factory=dict)
dry_run: bool = False

@property
def dropped(self) -> int:
return sum(len(v) for v in self.pages.values()) + sum(
len(v) for v in self.proposals.values()
)


def wipe_dead_claim_refs(
store: KBStore,
*,
actor: str,
dry_run: bool = False,
) -> DeadRefsWipeResult:
"""Strip references to claims that no longer exist, KB-wide.

Covers durable pages and pending PAGE proposals: the frontmatter claim
list and the inline `[claim: …]` body markers both lose the dead ids.
Claims themselves are untouched — an archived claim's file still exists,
so only ids that resolve to nothing count as dead. One audited bulk
event records exactly which ids were removed from where.
"""
result = DeadRefsWipeResult(dry_run=dry_run)
for page in store.list_pages():
dead = [c for c in page.claims if not store._claim_path(c).exists()]
if not dead:
continue
result.pages[page.id] = dead
if dry_run:
continue
page.claims = [c for c in page.claims if c not in dead]
page.body = strip_claim_markers(page.body, dead)
page.updated_at = datetime.now(UTC)
store.update_page(page)
with index_db.open_db(store.kb_dir) as conn:
index_db.index_page(
conn, id=page.id, title=page.title, body=page.body,
type=page.type, tags=page.tags,
)
Comment on lines +262 to +277

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

durable pages aren't revalidated after stripping — citation-requirement invariant can be silently violated.

proposals.approve()'s drop_missing_claims path rebuilds the Page and calls validate_page(..., has_citations=bool(page.claims or page.sources)) after stripping dead ids (see src/vouch/proposals.py lines 808-820). this function strips the same kind of dead ids from an already-durable page and calls store.update_page(page) directly, with no equivalent revalidation. a page whose kind requires citations, with claims=[dead_id] and sources=[], would end up persisted with zero citations after a bulk wipe — an invariant the approve-time path explicitly protects but this one does not.

🛡️ proposed fix — revalidate before persisting
+from .page_kinds import PageKindError, validate_page
+
 def wipe_dead_claim_refs(
     store: KBStore,
     *,
     actor: str,
     dry_run: bool = False,
 ) -> DeadRefsWipeResult:
     result = DeadRefsWipeResult(dry_run=dry_run)
     for page in store.list_pages():
         dead = [c for c in page.claims if not store._claim_path(c).exists()]
         if not dead:
             continue
         result.pages[page.id] = dead
         if dry_run:
             continue
-        page.claims = [c for c in page.claims if c not in dead]
-        page.body = strip_claim_markers(page.body, dead)
+        new_claims = [c for c in page.claims if c not in dead]
+        try:
+            validate_page(
+                store, page.type, page.metadata,
+                has_citations=bool(new_claims or page.sources),
+            )
+        except PageKindError:
+            # stripping would violate this kind's citation requirement —
+            # skip this page rather than silently persisting an invalid one.
+            result.pages.pop(page.id, None)
+            continue
+        page.claims = new_claims
+        page.body = strip_claim_markers(page.body, dead)
         page.updated_at = datetime.now(UTC)
         store.update_page(page)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for page in store.list_pages():
dead = [c for c in page.claims if not store._claim_path(c).exists()]
if not dead:
continue
result.pages[page.id] = dead
if dry_run:
continue
page.claims = [c for c in page.claims if c not in dead]
page.body = strip_claim_markers(page.body, dead)
page.updated_at = datetime.now(UTC)
store.update_page(page)
with index_db.open_db(store.kb_dir) as conn:
index_db.index_page(
conn, id=page.id, title=page.title, body=page.body,
type=page.type, tags=page.tags,
)
for page in store.list_pages():
dead = [c for c in page.claims if not store._claim_path(c).exists()]
if not dead:
continue
result.pages[page.id] = dead
if dry_run:
continue
new_claims = [c for c in page.claims if c not in dead]
try:
validate_page(
store, page.type, page.metadata,
has_citations=bool(new_claims or page.sources),
)
except PageKindError:
# stripping would violate this kind's citation requirement —
# skip this page rather than silently persisting an invalid one.
result.pages.pop(page.id, None)
continue
page.claims = new_claims
page.body = strip_claim_markers(page.body, dead)
page.updated_at = datetime.now(UTC)
store.update_page(page)
with index_db.open_db(store.kb_dir) as conn:
index_db.index_page(
conn, id=page.id, title=page.title, body=page.body,
type=page.type, tags=page.tags,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/lifecycle.py` around lines 262 - 277, Before persisting each
modified page in the dead-claim cleanup loop, revalidate it using the same
validate_page flow and citation-presence condition as proposals.approve()'s
drop_missing_claims path. Apply this after stripping dead claims and before
store.update_page(page), preserving dry-run behavior and preventing
citation-requiring pages from being saved without claims or sources.

Source: Path instructions

for prop in store.list_proposals(ProposalStatus.PENDING):
if prop.kind != ProposalKind.PAGE:
continue
refs = prop.payload.get("claims") or []
dead = [c for c in refs if not store._claim_path(c).exists()]
if not dead:
continue
result.proposals[prop.id] = dead
if dry_run:
continue
prop.payload["claims"] = [c for c in refs if c not in dead]
body = prop.payload.get("body")
if isinstance(body, str):
prop.payload["body"] = strip_claim_markers(body, dead)
store.update_proposal(prop)
if not dry_run and (result.pages or result.proposals):
audit.log_event(
store.kb_dir,
event="page.dead_refs_wipe",
actor=actor,
object_ids=[*result.pages, *result.proposals],
data={
"pages": result.pages,
"proposals": result.proposals,
"dropped": result.dropped,
},
)
return result


def cite(store: KBStore, claim_id: str) -> list[Evidence | dict]:
"""Return resolved citations for a claim.

Expand Down
Loading
Loading