+ {{ proposal.kind }} + {{ proposal.id }} +
+ ← queue +payload
+{{ preview }}
+full payload
+{{ proposal.payload | tojson(indent=2) }}
+ diff --git a/pyproject.toml b/pyproject.toml
index 3c358527..b15e9bad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,12 +28,19 @@ embeddings = [
"sentence-transformers>=3,<4",
"numpy>=1.26,<2",
]
+web = [
+ "fastapi>=0.115,<1",
+ "jinja2>=3,<4",
+ "python-multipart>=0.0.9",
+ "uvicorn>=0.30,<1",
+]
dev = [
"pytest>=9.0.3,<10",
"pytest-cov>=5,<6",
"mypy>=2.1.0",
"ruff>=0.15.13",
"types-pyyaml",
+ "httpx>=0.28,<1",
]
embeddings-fast = [
"fastembed>=0.3,<1",
@@ -94,3 +101,16 @@ ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["fastembed", "fastembed.*"]
ignore_missing_imports = true
+
+# fastapi + jinja2 + uvicorn live behind the [web] extra; the base CI install
+# only has [dev], so mypy can't resolve them when scanning src/vouch/web/.
+# The web module guards its own imports with a clean ImportError message so
+# the runtime story stays clean even without the extra.
+[[tool.mypy.overrides]]
+module = [
+ "fastapi", "fastapi.*",
+ "jinja2", "jinja2.*",
+ "uvicorn", "uvicorn.*",
+ "starlette", "starlette.*",
+]
+ignore_missing_imports = true
diff --git a/src/vouch/cli.py b/src/vouch/cli.py
index 100dcdce..987cb959 100644
--- a/src/vouch/cli.py
+++ b/src/vouch/cli.py
@@ -784,5 +784,81 @@ def serve(transport: str) -> None:
run_jsonl()
+# --- review-ui: browser-based review console -----------------------------
+
+
+@cli.command(name="review-ui")
+@click.option("--bind", "bind", default="127.0.0.1:7780", show_default=True,
+ help="host:port to bind. Refuses 0.0.0.0 in this MVP slice "
+ "(auth lands with the HTTP-transport feature).")
+@click.option("--kb", "kb_root", default=None,
+ type=click.Path(exists=True, file_okay=False),
+ help="KB root (defaults to the nearest .vouch/ above cwd).")
+@click.option("--open-browser/--no-open-browser", default=True, show_default=True,
+ help="Open the browser to the queue on startup.")
+def review_ui(bind: str, kb_root: str | None, open_browser: bool) -> None:
+ """Run the browser-based review console.
+
+ \b
+ Examples:
+ vouch review-ui # bind 127.0.0.1:7780, open browser
+ vouch review-ui --bind 127.0.0.1:8000
+ vouch review-ui --no-open-browser # ssh / headless friendly
+ """
+ if ":" not in bind:
+ raise click.ClickException(
+ f"--bind must be host:port (got {bind!r})"
+ )
+ host, _, port_str = bind.rpartition(":")
+ try:
+ port = int(port_str)
+ except ValueError as e:
+ raise click.ClickException(f"invalid port in --bind: {port_str!r}") from e
+
+ # The full design (issue #194) refuses 0.0.0.0 without --auth bearer.
+ # The MVP slice does not ship the Bearer-auth layer yet (depends on
+ # the HTTP-transport feature), so we refuse non-localhost binds
+ # outright — a clearer error than silently exposing an unauthenticated
+ # approve surface on the network.
+ if host not in ("127.0.0.1", "localhost", "::1"):
+ raise click.ClickException(
+ f"--bind {bind!r}: review-ui is localhost-only in the MVP slice. "
+ "Bearer-auth + non-loopback binding land alongside the HTTP "
+ "transport feature."
+ )
+
+ try:
+ from . import web as web_pkg
+ except ImportError as e:
+ raise click.ClickException(str(e)) from e
+
+ try:
+ app = web_pkg.create_app(kb_root)
+ except (FileNotFoundError, RuntimeError) as e:
+ raise click.ClickException(str(e)) from e
+
+ try:
+ import uvicorn
+ except ImportError as e:
+ raise click.ClickException(
+ "vouch review-ui needs the [web] extra. "
+ "Install with: pip install 'vouch-kb[web]'"
+ ) from e
+
+ if open_browser:
+ # Lazy-import webbrowser; some CI envs (headless) don't have a default
+ # browser configured and webbrowser.open() returns False rather than
+ # raising — that's fine, the URL is also printed to stdout.
+ import threading
+ import webbrowser
+ url = f"http://{host}:{port}/"
+ click.echo(f"vouch review-ui running at {url}")
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
+ else:
+ click.echo(f"vouch review-ui running at http://{host}:{port}/")
+
+ uvicorn.run(app, host=host, port=port, log_level="info")
+
+
if __name__ == "__main__":
cli()
diff --git a/src/vouch/web/__init__.py b/src/vouch/web/__init__.py
new file mode 100644
index 00000000..51222ecf
--- /dev/null
+++ b/src/vouch/web/__init__.py
@@ -0,0 +1,42 @@
+"""Browser-based review console for vouch.
+
+The web layer is a *viewport* over the existing kb.* surface — every action
+(approve, reject) goes through the same ``vouch.proposals`` / ``vouch.storage``
+code path as the CLI, so the audit log is identical regardless of surface.
+
+The dependencies (fastapi, jinja2) live behind the ``[web]`` extra so the
+base install stays light. ``vouch review-ui`` produces an actionable
+``ImportError`` line if the extra is missing.
+"""
+
+from __future__ import annotations
+
+
+def _require_web_extra() -> None:
+ """Fail with a clean message if fastapi/jinja2 aren't installed."""
+ missing: list[str] = []
+ try:
+ import fastapi # noqa: F401
+ except ImportError:
+ missing.append("fastapi")
+ try:
+ import jinja2 # noqa: F401
+ except ImportError:
+ missing.append("jinja2")
+ if missing:
+ raise ImportError(
+ "vouch review-ui needs the [web] extra. "
+ "Install with: pip install 'vouch-kb[web]' "
+ f"(missing: {', '.join(missing)})"
+ )
+
+
+def create_app(kb_root: str | None = None): # type: ignore[no-untyped-def]
+ """Build the FastAPI app for a given KB root. Lazy-imports the web stack."""
+ _require_web_extra()
+ from .server import build_app
+
+ return build_app(kb_root)
+
+
+__all__ = ["create_app"]
diff --git a/src/vouch/web/server.py b/src/vouch/web/server.py
new file mode 100644
index 00000000..2f56a33b
--- /dev/null
+++ b/src/vouch/web/server.py
@@ -0,0 +1,174 @@
+"""FastAPI app for the review console.
+
+MVP slice: queue + claim detail + approve/reject + audit timeline. No
+WebSocket, no Bearer auth — those land alongside the HTTP transport (#1)
+and multi-dim scopes (#2). Localhost-only by default.
+
+The web layer is intentionally thin: every approve/reject goes through
+``vouch.proposals.approve`` / ``vouch.proposals.reject`` so the audit log
+is identical regardless of whether the action came from the CLI or the UI.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+from .. import audit as audit_mod
+from .. import proposals as proposals_mod
+from ..models import ProposalStatus
+from ..storage import ArtifactNotFoundError, KBStore, discover_root
+
+_MODULE_DIR = Path(__file__).resolve().parent
+_TEMPLATES_DIR = _MODULE_DIR / "templates"
+_STATIC_DIR = _MODULE_DIR / "static"
+
+
+def _is_review_event(name: str) -> bool:
+ """The audit log carries every mutation; the timeline only shows
+ review-gate decisions (approve / reject) and claim lifecycle moves."""
+ if name.startswith("proposal.") and name.endswith((".approve", ".reject")):
+ return True
+ return name in {"claim.supersede", "claim.contradict",
+ "claim.archive", "claim.confirm"}
+
+
+def _whoami() -> str:
+ """Reviewer identity. Without the Bearer-auth layer from #1 this falls
+ back to the same env var the CLI uses, so audit-log attribution stays
+ consistent across surfaces."""
+ return os.environ.get("VOUCH_AGENT", "web-reviewer")
+
+
+def _proposal_preview(payload: dict[str, Any]) -> str:
+ """One-line preview shown in the queue. Mirrors the CLI's `pending` output."""
+ for key in ("text", "title", "name"):
+ value = payload.get(key)
+ if value:
+ return str(value).strip().splitlines()[0][:160]
+ return "—"
+
+
+def build_app(kb_root: str | None = None) -> FastAPI:
+ """FastAPI app bound to a KB root. ``kb_root`` defaults to the nearest
+ ``.vouch/`` discovered by walking up from ``cwd``."""
+ start = Path(kb_root).resolve() if kb_root else None
+ # Resolve once at construction so every request hits the same store.
+ # discover_root walks up looking for .vouch/ — the same behavior as
+ # every other vouch CLI command. Failing here means a clearer error
+ # than a 500 on the first request.
+ root = discover_root(start)
+ store = KBStore(root)
+
+ templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
+ app = FastAPI(title="vouch review-ui", docs_url=None, redoc_url=None)
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
+
+ @app.get("/healthz")
+ def healthz() -> dict[str, Any]:
+ return {
+ "ok": True,
+ "kb": str(store.root),
+ "pending": len(store.list_proposals(ProposalStatus.PENDING)),
+ }
+
+ @app.get("/", response_class=HTMLResponse)
+ def queue(request: Request) -> Any:
+ pending = store.list_proposals(ProposalStatus.PENDING)
+ items = [
+ {
+ "id": p.id,
+ "kind": p.kind.value,
+ "proposed_by": p.proposed_by,
+ "proposed_at": p.proposed_at.isoformat(timespec="seconds"),
+ "preview": _proposal_preview(p.payload),
+ }
+ for p in pending
+ ]
+ return templates.TemplateResponse(
+ request,
+ "queue.html",
+ {"items": items, "count": len(items)},
+ )
+
+ @app.get("/claim/{proposal_id}", response_class=HTMLResponse)
+ def claim_detail(request: Request, proposal_id: str) -> Any:
+ try:
+ pr = store.get_proposal(proposal_id)
+ except ArtifactNotFoundError as e:
+ raise HTTPException(status_code=404, detail=str(e)) from e
+ return templates.TemplateResponse(
+ request,
+ "claim.html",
+ {
+ "proposal": pr.model_dump(mode="json"),
+ "preview": _proposal_preview(pr.payload),
+ },
+ )
+
+ @app.post("/approve/{proposal_id}")
+ def approve(proposal_id: str, reason: str | None = Form(default=None)) -> Any:
+ try:
+ proposals_mod.approve(
+ store, proposal_id, approved_by=_whoami(), reason=reason
+ )
+ except (proposals_mod.ProposalError, ArtifactNotFoundError) as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ return RedirectResponse(url="/", status_code=303)
+
+ @app.post("/reject/{proposal_id}")
+ def reject(proposal_id: str, reason: str = Form(...)) -> Any:
+ if not reason.strip():
+ raise HTTPException(status_code=400, detail="reason is required")
+ try:
+ proposals_mod.reject(
+ store, proposal_id, rejected_by=_whoami(), reason=reason
+ )
+ except (proposals_mod.ProposalError, ArtifactNotFoundError) as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ return RedirectResponse(url="/", status_code=303)
+
+ @app.get("/audit", response_class=HTMLResponse)
+ def audit(request: Request, limit: int = 100) -> Any:
+ events = list(audit_mod.read_events(store.kb_dir))
+ events.reverse() # newest first
+ filtered = [e for e in events if _is_review_event(e.event)][:limit]
+ rows = [
+ {
+ "id": e.id,
+ "event": e.event,
+ "actor": e.actor,
+ "object_ids": e.object_ids,
+ "at": e.created_at.isoformat(timespec="seconds"),
+ "reason": e.data.get("reason"),
+ }
+ for e in filtered
+ ]
+ return templates.TemplateResponse(
+ request,
+ "audit.html",
+ {"rows": rows, "count": len(rows)},
+ )
+
+ @app.get("/api/pending")
+ def api_pending() -> JSONResponse:
+ pending = store.list_proposals(ProposalStatus.PENDING)
+ return JSONResponse(
+ [
+ {
+ "id": p.id,
+ "kind": p.kind.value,
+ "proposed_by": p.proposed_by,
+ "preview": _proposal_preview(p.payload),
+ }
+ for p in pending
+ ]
+ )
+
+ return app
diff --git a/src/vouch/web/static/app.css b/src/vouch/web/static/app.css
new file mode 100644
index 00000000..d957dc6a
--- /dev/null
+++ b/src/vouch/web/static/app.css
@@ -0,0 +1,242 @@
+/* vouch review-ui — minimal, monograph-adjacent. No framework. */
+
+:root {
+ --paper: #f4eedb;
+ --ink: #1a1208;
+ --rule: #1a120833;
+ --muted: #6b5d4a;
+ --vermillion: #a82c1c;
+ --moss: #4a5d3a;
+ --mono: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace;
+ --serif: "EB Garamond", "Iowan Old Style", "Palatino", "Georgia", serif;
+ --sans: "Inter", system-ui, sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html, body { margin: 0; padding: 0; }
+body {
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--serif);
+ font-size: 18px;
+ line-height: 1.5;
+}
+
+.masthead {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 1.25rem 2rem 0.75rem;
+ border-bottom: 1px solid var(--rule);
+}
+.brand { display: flex; align-items: baseline; gap: 0.5rem; }
+.brand-mark { color: var(--vermillion); font-size: 1.4rem; }
+.brand-text { font-variant: small-caps; letter-spacing: 0.06em; }
+.nav { display: flex; gap: 1.25rem; }
+.nav a {
+ color: var(--ink);
+ text-decoration: none;
+ font-family: var(--sans);
+ font-size: 0.875rem;
+ text-transform: lowercase;
+}
+.nav a[aria-current="page"] {
+ border-bottom: 2px solid var(--vermillion);
+}
+
+.content {
+ max-width: 56rem;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+.section-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 1.5rem;
+}
+.section-head h1 {
+ font-size: 2rem;
+ font-weight: 500;
+ margin: 0;
+ letter-spacing: -0.01em;
+}
+.count {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.875rem;
+}
+
+.empty {
+ color: var(--muted);
+ font-style: italic;
+ padding: 2rem 0;
+ text-align: center;
+}
+
+.queue-list, .audit-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.queue-row {
+ border-top: 1px solid var(--rule);
+ padding: 1rem 0;
+}
+.queue-row:last-child { border-bottom: 1px solid var(--rule); }
+.queue-meta {
+ display: flex;
+ align-items: baseline;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.25rem;
+}
+.kind {
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ padding: 0.1rem 0.4rem;
+ border: 1px solid var(--rule);
+ border-radius: 2px;
+}
+.kind-claim { color: var(--vermillion); border-color: var(--vermillion); }
+.kind-page { color: var(--moss); border-color: var(--moss); }
+.kind-entity { color: var(--ink); }
+.kind-relation { color: var(--muted); }
+
+.proposal-link {
+ font-family: var(--mono);
+ font-size: 0.875rem;
+ color: var(--ink);
+}
+.by { color: var(--muted); font-size: 0.875rem; }
+time {
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ color: var(--muted);
+ margin-left: auto;
+}
+
+.preview { margin: 0.25rem 0 0.75rem; }
+
+.actions {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+.actions-large { margin-top: 2rem; }
+.inline { display: inline-flex; gap: 0.5rem; margin: 0; }
+
+button {
+ font-family: var(--sans);
+ font-size: 0.875rem;
+ padding: 0.4rem 1rem;
+ border: 1px solid var(--ink);
+ background: var(--paper);
+ color: var(--ink);
+ cursor: pointer;
+ border-radius: 2px;
+}
+button.approve { border-color: var(--moss); color: var(--moss); }
+button.approve:hover { background: var(--moss); color: var(--paper); }
+button.reject { border-color: var(--vermillion); color: var(--vermillion); }
+button.reject:hover { background: var(--vermillion); color: var(--paper); }
+
+input[type="text"] {
+ font-family: var(--sans);
+ font-size: 0.875rem;
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--rule);
+ background: var(--paper);
+ color: var(--ink);
+ border-radius: 2px;
+ min-width: 14rem;
+}
+
+.claim-detail .back {
+ font-family: var(--sans);
+ font-size: 0.875rem;
+ color: var(--muted);
+ text-decoration: none;
+}
+.meta-grid {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.4rem 1.5rem;
+ margin: 1.5rem 0;
+ font-size: 0.95rem;
+}
+.meta-grid dt {
+ font-variant: small-caps;
+ letter-spacing: 0.05em;
+ color: var(--muted);
+}
+.meta-grid dd { margin: 0; font-family: var(--mono); font-size: 0.875rem; }
+
+.payload h2 {
+ font-size: 1.25rem;
+ font-weight: 500;
+ margin: 2rem 0 0.75rem;
+ border-bottom: 1px solid var(--rule);
+ padding-bottom: 0.4rem;
+}
+.preview-large { font-size: 1.05rem; line-height: 1.6; }
+details summary {
+ cursor: pointer;
+ color: var(--muted);
+ font-family: var(--sans);
+ font-size: 0.875rem;
+ margin-top: 0.75rem;
+}
+pre {
+ background: #ebe3ca;
+ padding: 1rem;
+ border-radius: 2px;
+ font-family: var(--mono);
+ font-size: 0.8rem;
+ overflow-x: auto;
+}
+
+.audit-row {
+ border-top: 1px solid var(--rule);
+ padding: 0.75rem 0;
+}
+.audit-row:last-child { border-bottom: 1px solid var(--rule); }
+.audit-head {
+ display: flex;
+ gap: 0.75rem;
+ align-items: baseline;
+ flex-wrap: wrap;
+}
+.event {
+ font-family: var(--mono);
+ font-size: 0.875rem;
+ color: var(--ink);
+}
+.objects { margin-top: 0.25rem; }
+.objects code {
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ color: var(--muted);
+ margin-right: 0.5rem;
+}
+.reason {
+ margin: 0.25rem 0 0;
+ font-style: italic;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+.colophon {
+ text-align: center;
+ padding: 2rem;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.75rem;
+ border-top: 1px solid var(--rule);
+ margin-top: 4rem;
+}
diff --git a/src/vouch/web/templates/audit.html b/src/vouch/web/templates/audit.html
new file mode 100644
index 00000000..6f9907d9
--- /dev/null
+++ b/src/vouch/web/templates/audit.html
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+{% set active = "audit" %}
+{% block title %}audit · vouch review{% endblock %}
+
+{% block content %}
+ no review decisions yet — the timeline lights up as proposals are approved or rejected. {{ row.reason }}audit timeline
+ {{ count }} review decisions
+
+ {% for row in rows %}
+
+ {% endif %}
+{{ oid }}{% endfor %}
+
{{ preview }}
+{{ proposal.payload | tojson(indent=2) }}
+ no pending proposals — every agent write has been reviewed.
+ {% else %} +{{ item.preview }}
+