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
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Fixed
- `discover_root()` now honours `VOUCH_KB_PATH=/abs/path/.vouch` and returns the parent root, instead of always walking up from cwd. The env var was already documented in `adapters/generic-mcp/README.md` but wasn't wired into the code — closing the doc-vs-code drift removes the `"cwd": "..."` ceremony hosts like Claude Desktop need today to point at a specific KB.
### Added
- `vouch fsck` performs deep consistency checks beyond `vouch doctor`:
orphaned embeddings, dangling supersede/contradict chains, decided
proposals whose artifact is missing, and FTS5 index-vs-file drift
(orphan rows, missing rows, status drift). Read-only; reports findings
with object ids. `--fix` is intentionally out of scope (#96).
- `vouch migrate` checks, dry-runs, and applies on-disk KB format migrations,
preserving audit history and rebuilding derived indexes after successful
upgrades.
- `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`
Expand All @@ -28,6 +29,9 @@ All notable changes to vouch are documented here. Format follows
was already documented in `ROADMAP.md` and `adapters/generic-mcp/README.md`
but had no implementation (#97).

### Fixed
- `discover_root()` now honours `VOUCH_KB_PATH=/abs/path/.vouch` and returns the parent root, instead of always walking up from cwd. The env var was already documented in `adapters/generic-mcp/README.md` but wasn't wired into the code — closing the doc-vs-code drift removes the `"cwd": "..."` ceremony hosts like Claude Desktop need today to point at a specific KB.

## [0.1.0] — 2026-05-26

### Packaging
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ vouch status [--json] # KB counts + pending proposals
vouch lint [--stale-days N] # user-actionable problems
vouch doctor # full sweep incl. source verification
vouch fsck # deep consistency: indexes, lifecycle, decided
vouch migrate [--check] [--dry-run] # upgrade .vouch/ format safely

vouch pending # list pending proposals
vouch review [--limit N] [--type KIND] # guided proposal review queue
Expand Down
65 changes: 64 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import __version__, bundle, health
from . import audit as audit_mod
from . import lifecycle as life
from . import migrations as migrations_mod
from . import sessions as sess_mod
from . import sync as sync_mod
from . import verify as verify_mod
Expand Down Expand Up @@ -71,7 +72,13 @@ def _cli_errors() -> Iterator[None]:
# their own request envelopes.
try:
yield
except (ArtifactNotFoundError, ValueError, ProposalError, LifecycleError) as e:
except (
ArtifactNotFoundError,
ValueError,
ProposalError,
LifecycleError,
migrations_mod.MigrationError,
) as e:
raise click.ClickException(str(e)) from e


Expand Down Expand Up @@ -308,6 +315,62 @@ def fsck() -> None:
sys.exit(0 if report.ok else 1)


@cli.command()
@click.option("--check", "check_only", is_flag=True, help="Only check whether migration is needed.")
@click.option("--dry-run", is_flag=True, help="Show planned changes without writing.")
@click.option("--to-version", type=int, default=None, help="Target KB format version.")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of text.")
def migrate(
check_only: bool,
dry_run: bool,
to_version: int | None,
as_json: bool,
) -> None:
"""Upgrade the on-disk .vouch/ layout to the supported format."""
if check_only and dry_run:
raise click.ClickException("--check and --dry-run are mutually exclusive")

store = _load_store()
with _cli_errors():
result = migrations_mod.migrate(
store,
to_version=to_version,
dry_run=check_only or dry_run,
)
if as_json:
_emit_json(asdict(result))
else:
if result.steps:
click.echo(
f"KB format: {result.from_version} -> {result.to_version} "
f"({'dry run' if result.dry_run else 'applied'})"
)
for step in result.steps:
click.echo(f"- {step}")
for change in result.changes:
click.echo(f" * {change}")
else:
click.echo(f"KB format: {result.from_version} (up to date)")

if result.applied:
health.rebuild_index(store)
audit_mod.log_event(
store.kb_dir,
event="kb.migrate",
actor=_whoami(),
reversible=False,
data={
"from_version": result.from_version,
"to_version": result.to_version,
"steps": result.steps,
"changes": result.changes,
},
)

if check_only and result.steps:
sys.exit(1)


# --- proposals ------------------------------------------------------------


Expand Down
184 changes: 184 additions & 0 deletions src/vouch/migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""On-disk KB format migrations.

The migration layer only changes source-of-truth files under `.vouch/`.
Derived caches such as state.db are rebuilt by the CLI after a successful
apply.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

import yaml

from .storage import KB_FORMAT_VERSION, SUBDIRS, KBStore, _starter_config


class MigrationError(RuntimeError):
"""Raised when a KB cannot be migrated safely."""


@dataclass(frozen=True)
class MigrationStep:
from_version: int
to_version: int
description: str
apply: Callable[[KBStore, bool], list[str]]


@dataclass(frozen=True)
class MigrationPlan:
current_version: int
target_version: int
latest_version: int
steps: list[MigrationStep]

@property
def needed(self) -> bool:
return bool(self.steps)


@dataclass(frozen=True)
class MigrationResult:
from_version: int
to_version: int
applied: bool
dry_run: bool
steps: list[str]
changes: list[str]


def read_config(store: KBStore) -> dict[str, Any]:
if not store.config_path.exists():
return {}
loaded = yaml.safe_load(store.config_path.read_text())
if loaded is None:
return {}
if not isinstance(loaded, dict):
raise MigrationError("config.yaml must contain a mapping")
return loaded


def write_config(store: KBStore, config: dict[str, Any]) -> None:
store.config_path.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True))


def detect_version(store: KBStore) -> int:
config = read_config(store)
raw = config.get("version", 0)
if isinstance(raw, bool) or not isinstance(raw, int):
raise MigrationError("config.yaml version must be an integer")
if raw < 0:
raise MigrationError("config.yaml version must be non-negative")
if raw > KB_FORMAT_VERSION:
raise MigrationError(
f"KB format version {raw} is newer than this vouch supports "
f"({KB_FORMAT_VERSION})"
)
return raw


def build_plan(store: KBStore, *, to_version: int | None = None) -> MigrationPlan:
current = detect_version(store)
target = KB_FORMAT_VERSION if to_version is None else to_version
if target > KB_FORMAT_VERSION:
raise MigrationError(
f"target KB format version {target} is newer than this vouch supports "
f"({KB_FORMAT_VERSION})"
)
if target < current:
raise MigrationError(
f"cannot migrate backwards from KB format version {current} to {target}"
)

steps: list[MigrationStep] = []
version = current
while version < target:
step = _STEPS_BY_FROM.get(version)
if step is None:
raise MigrationError(f"no migration registered from KB format version {version}")
if step.to_version > target:
break
steps.append(step)
version = step.to_version

if version != target:
raise MigrationError(
f"no complete migration path from KB format version {current} to {target}"
)
return MigrationPlan(
current_version=current,
target_version=target,
latest_version=KB_FORMAT_VERSION,
steps=steps,
)


def migrate(
store: KBStore,
*,
to_version: int | None = None,
dry_run: bool = False,
) -> MigrationResult:
plan = build_plan(store, to_version=to_version)
changes: list[str] = []
for step in plan.steps:
changes.extend(step.apply(store, dry_run))
return MigrationResult(
from_version=plan.current_version,
to_version=plan.target_version,
applied=plan.needed and not dry_run,
dry_run=dry_run,
steps=[s.description for s in plan.steps],
changes=changes,
)


def _migration_0_to_1(store: KBStore, dry_run: bool) -> list[str]:
changes: list[str] = []

missing_subdirs = [sub for sub in SUBDIRS if not (store.kb_dir / sub).is_dir()]
for sub in missing_subdirs:
changes.append(f"create {sub}/")
if not dry_run:
(store.kb_dir / sub).mkdir(parents=True, exist_ok=True)

config = read_config(store)
if not config:
config = _starter_config()
changes.append("create config.yaml with version 1")
elif config.get("version") != 1:
config = dict(config)
config["version"] = 1
changes.append("set config.yaml version to 1")
if not dry_run and changes:
write_config(store, config)

gitignore_path = store.kb_dir / ".gitignore"
required_ignores = ("proposed/", "state.db", "state.db-*")
existing_ignores: list[str] = []
if gitignore_path.exists():
existing_ignores = gitignore_path.read_text().splitlines()
missing_ignores = [line for line in required_ignores if line not in existing_ignores]
if missing_ignores:
changes.append("ensure .gitignore excludes proposed/ and state.db")
if not dry_run:
gitignore_path.parent.mkdir(parents=True, exist_ok=True)
lines = [*existing_ignores, *missing_ignores]
gitignore_path.write_text("\n".join(lines).rstrip() + "\n")

return changes


_STEPS: tuple[MigrationStep, ...] = (
MigrationStep(
from_version=0,
to_version=1,
description="stamp legacy KB as format version 1",
apply=_migration_0_to_1,
),
)

_STEPS_BY_FROM = {step.from_version: step for step in _STEPS}
3 changes: 2 additions & 1 deletion src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

KB_DIRNAME = ".vouch"
CONFIG_FILENAME = "config.yaml"
KB_FORMAT_VERSION = 1

SUBDIRS = (
"claims", "pages", "sources", "entities", "relations",
Expand All @@ -67,7 +68,7 @@ class ArtifactNotFoundError(KeyError):

def _starter_config() -> dict[str, Any]:
return {
"version": 1,
"version": KB_FORMAT_VERSION,
"review": {
"require_human_approval": True,
"expire_pending_after_days": 90,
Expand Down
Loading
Loading