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 @@ -6,6 +6,10 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Added
- Seed a cited starter source and claim during `vouch init`, print first-run
next steps, and document a 30-second onboarding tour (#54).

### Fixed
- Raise `ProposalError("forbidden_self_approval")` in `proposals.approve()` when `approved_by == proposal.proposed_by`, enforcing the review-gate guarantee documented in the README and CONTRIBUTING.
- Bundle import rejects tar members whose path escapes `kb_dir`
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ vouch reject <id> --reason "..."
git add .vouch/ && git commit -m "kb: approve auth-uses-jwt"
```

## 30-second tour

`vouch init` now creates a starter config plus one cited example claim, so
you can try the loop before wiring an agent:

```bash
mkdir vouch-demo && cd vouch-demo
vouch init
vouch status
vouch search agent
vouch cite vouch-starter-reviewed-knowledge
```

The starter claim is already durable and cites the starter source. Replace it
with your project's first real source and claim when you are ready.

![vouch end-to-end demo](docs/demo.gif)

The full captured walkthrough lives at [docs/example-session.md](docs/example-session.md); re-render the GIF from [docs/demo.tape](docs/demo.tape) with `vhs docs/demo.tape`.
Expand Down Expand Up @@ -102,7 +118,7 @@ After `vouch init`, your repo contains:
├── .gitignore # ignores proposed/, state.db
├── audit.log.jsonl # append-only audit (committed)
├── state.db # SQLite FTS5 index (derived; not committed)
├── claims/<id>.yaml # durable, reviewed claims
├── claims/<id>.yaml # reviewed claims (init seeds vouch-starter-*.yaml)
├── pages/<id>.md # markdown pages with YAML frontmatter
├── sources/<sha>/{meta.yaml,content}
├── entities/<id>.yaml # graph nodes
Expand Down
12 changes: 11 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .context import build_context_pack
from .lifecycle import LifecycleError
from .models import ProposalStatus
from .onboarding import seed_starter_kb
from .proposals import (
ProposalError,
propose_claim,
Expand Down Expand Up @@ -103,9 +104,18 @@ def init(path: str) -> None:
root = Path(path).resolve()
root.mkdir(parents=True, exist_ok=True)
store = KBStore.init(root)
seed = seed_starter_kb(store, approved_by=_whoami())
health.rebuild_index(store)
audit_mod.log_event(store.kb_dir, event="kb.init", actor=_whoami())
click.echo(f"Initialised KB at {store.kb_dir}")
click.echo("Next: `vouch serve` to expose the MCP server to your agent.")
if seed.created_anything:
click.echo(f"Seeded starter claim: {seed.claim_id}")
else:
click.echo("Starter claim already present.")
click.echo("Next steps:")
click.echo(" vouch status")
click.echo(" vouch search agent")
click.echo(" vouch serve")


@cli.command()
Expand Down
82 changes: 82 additions & 0 deletions src/vouch/onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""First-run KB content used by `vouch init`."""

from __future__ import annotations

from dataclasses import dataclass

from .models import Claim, ClaimStatus, ClaimType, Source
from .storage import ArtifactNotFoundError, KBStore, sha256_hex

STARTER_SOURCE_TEXT = """# Vouch starter source

This starter source is created by `vouch init` so new users can see how a
reviewed claim cites durable evidence.

Keep facts small, cite their sources, and approve only the knowledge you want
future agents to retrieve.
"""

STARTER_CLAIM_ID = "vouch-starter-reviewed-knowledge"
STARTER_CLAIM_TEXT = (
"Vouch stores reviewed, cited knowledge in the repository so future agent "
"sessions can retrieve agreed project context."
)


@dataclass(frozen=True)
class StarterSeedResult:
source_id: str
claim_id: str
created_source: bool
created_claim: bool

@property
def created_anything(self) -> bool:
return self.created_source or self.created_claim


def seed_starter_kb(
store: KBStore, *, approved_by: str = "vouch-init"
) -> StarterSeedResult:
source, created_source = _starter_source(store)
created_claim = _starter_claim(store, source_id=source.id, approved_by=approved_by)
return StarterSeedResult(
source_id=source.id,
claim_id=STARTER_CLAIM_ID,
created_source=created_source,
created_claim=created_claim,
)


def _starter_source(store: KBStore) -> tuple[Source, bool]:
body = STARTER_SOURCE_TEXT.encode("utf-8")
source_id = sha256_hex(body)
created = not (store.kb_dir / "sources" / source_id / "meta.yaml").exists()
source = store.put_source(
body,
title="Vouch starter source",
locator="vouch:init",
source_type="message",
media_type="text/markdown",
tags=["vouch", "onboarding"],
)
return source, created


def _starter_claim(store: KBStore, *, source_id: str, approved_by: str) -> bool:
try:
store.get_claim(STARTER_CLAIM_ID)
return False
except ArtifactNotFoundError:
claim = Claim(
id=STARTER_CLAIM_ID,
text=STARTER_CLAIM_TEXT,
type=ClaimType.WORKFLOW,
status=ClaimStatus.ACTIONABLE,
confidence=0.95,
evidence=[source_id],
tags=["vouch", "onboarding"],
approved_by=approved_by,
)
store.put_claim(claim)
return True
28 changes: 19 additions & 9 deletions src/vouch/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ class ArtifactNotFoundError(KeyError):
pass


def _starter_config() -> dict[str, Any]:
return {
"version": 1,
"review": {"require_human_approval": True},
"retrieval": {
"backends": ["fts5", "substring"],
"default_limit": 10,
},
"agents": {
"recommended_loop": [
"kb.search before writing",
"kb.propose_* with citations",
"human review via vouch pending/show/approve",
],
},
}


def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()

Expand Down Expand Up @@ -156,15 +174,7 @@ def init(cls, root: Path) -> KBStore:
for sub in SUBDIRS:
(kb.kb_dir / sub).mkdir(exist_ok=True)
if not kb.config_path.exists():
kb.config_path.write_text(
_yaml_dump(
{
"version": 1,
"review": {"require_human_approval": True},
"retrieval": {"backends": ["fts5", "substring"]},
}
)
)
kb.config_path.write_text(_yaml_dump(_starter_config()))
gi = kb.kb_dir / ".gitignore"
if not gi.exists():
# state.db is derived; proposed/ is the agent's scratch space.
Expand Down
64 changes: 64 additions & 0 deletions tests/test_onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""First-run onboarding behavior."""

from __future__ import annotations

from pathlib import Path

from click.testing import CliRunner

from vouch import index_db
from vouch.cli import cli
from vouch.onboarding import STARTER_CLAIM_ID, seed_starter_kb
from vouch.storage import KBStore


def test_seed_starter_kb_creates_cited_claim(tmp_path: Path) -> None:
store = KBStore.init(tmp_path)
seed = seed_starter_kb(store, approved_by="tester")

claim = store.get_claim(STARTER_CLAIM_ID)
source = store.get_source(seed.source_id)

assert seed.created_source is True
assert seed.created_claim is True
assert claim.evidence == [source.id]
assert claim.approved_by == "tester"
assert "future agent sessions" in claim.text


def test_seed_starter_kb_is_idempotent(tmp_path: Path) -> None:
store = KBStore.init(tmp_path)

first = seed_starter_kb(store)
second = seed_starter_kb(store)

assert first.created_anything is True
assert second.created_anything is False
assert [c.id for c in store.list_claims()] == [STARTER_CLAIM_ID]
assert len(store.list_sources()) == 1


def test_init_command_seeds_searchable_starter_kb(tmp_path: Path) -> None:
result = CliRunner().invoke(cli, ["init", "--path", str(tmp_path)])

assert result.exit_code == 0, result.output
assert "Seeded starter claim" in result.output
assert "vouch search agent" in result.output

store = KBStore(tmp_path)
hits = index_db.search(store.kb_dir, "agent", limit=5)

assert store.get_claim(STARTER_CLAIM_ID).evidence
assert any(kind == "claim" and hid == STARTER_CLAIM_ID for kind, hid, _, _ in hits)


def test_init_command_can_run_twice(tmp_path: Path) -> None:
runner = CliRunner()

first = runner.invoke(cli, ["init", "--path", str(tmp_path)])
second = runner.invoke(cli, ["init", "--path", str(tmp_path)])

assert first.exit_code == 0, first.output
assert second.exit_code == 0, second.output
assert "Starter claim already present" in second.output
assert [c.id for c in KBStore(tmp_path).list_claims()] == [STARTER_CLAIM_ID]
Loading