From aa8b8b3c9facc5dbc312b48597141ae9f68b5563 Mon Sep 17 00:00:00 2001 From: e11734937-beep Date: Fri, 3 Jul 2026 08:27:49 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20vouch=20new=20=20?= =?UTF-8?q?=E2=80=94=20scaffold=20a=20typed=20page/entity=20proposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #330. Add `vouch new `: read the page-kind registry (or EntityType) for KIND, stub every required field, and file a pending proposal through the normal review gate — composing the existing propose_page / propose_entity calls, with no new kb.* method, storage logic, or server registration. --- src/vouch/cli.py | 183 ++++++++++++++++++++++++++++++++++++- tests/test_new_scaffold.py | 125 +++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 tests/test_new_scaffold.py diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f8b4a0c..667873b 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -42,7 +42,7 @@ from .context import build_context_pack from .lifecycle import LifecycleError from .logging_config import configure_logging -from .models import Proposal, ProposalKind, ProposalStatus +from .models import EntityType, Proposal, ProposalKind, ProposalStatus from .onboarding import seed_starter_kb from .page_kinds import PageKindError, load_page_kind_registry from .proposals import ( @@ -1033,6 +1033,187 @@ def _parse_meta(pairs: tuple[str, ...]) -> dict[str, Any]: return out +@cli.command(name="new") +@click.argument("kind") +@click.option("--title", default=None, help="page title (required for page kinds).") +@click.option("--name", default=None, help="entity name (required for entity kinds).") +@click.option( + "--entity", + "force_entity", + is_flag=True, + help="force the entity path for a name that is also a page kind.", +) +@click.option( + "--field", + "fields", + multiple=True, + help="pre-fill a frontmatter field as key=value (repeatable). " + "Value parsed as YAML, mirroring --meta on propose-page.", +) +@click.option("--body", default="", help="page body. Use `-` to read from stdin.") +@click.option( + "-i", + "--interactive", + is_flag=True, + help="prompt for each unfilled required field (default off, so runs stay scriptable).", +) +@click.option( + "--dry-run", + "dry_run", + is_flag=True, + help="print the assembled draft and its missing required fields; create no proposal.", +) +@click.option( + "--json", "as_json", is_flag=True, help="emit the proposal id (or dry-run draft) as JSON." +) +@click.option("--claim", "claims", multiple=True, help="claim id to cite (page kinds).") +@click.option("--source", "sources", multiple=True, help="source id to cite (page kinds).") +@click.option("--alias", "aliases", multiple=True, help="entity alias (entity kinds).") +@click.option("--description", default=None, help="entity description (entity kinds).") +def new_cmd( + kind: str, + title: str | None, + name: str | None, + force_entity: bool, + fields: tuple[str, ...], + body: str, + interactive: bool, + dry_run: bool, + as_json: bool, + claims: tuple[str, ...], + sources: tuple[str, ...], + aliases: tuple[str, ...], + description: str | None, +) -> None: + """Scaffold a typed page or entity proposal from its registered shape. + + Reads the page-kind registry (or ``EntityType``) for KIND, stubs every + required field, and files a *pending* proposal through the normal review + gate exactly as ``propose-page`` / ``propose-entity`` do. A human still + runs ``vouch approve `` to land it; the scaffold never writes an + approved artifact and never weakens validation (``propose_page`` + re-validates the kind, so an unfilled required field is flagged the same + way any other proposal is). + """ + store = _load_store() + registry = load_page_kind_registry(store) + page_kinds = registry.known() + entity_kinds = {e.value for e in EntityType} + + # Deterministic page/entity dispatch. Some names (``decision``, ``project``) + # are both a page kind and an EntityType member: a registered page kind + # scaffolds a page; ``--entity`` forces the entity path; a name that is only + # an EntityType scaffolds an entity; anything else fails with the known set. + if force_entity: + if kind not in entity_kinds: + raise click.BadParameter( + f"--entity given, but {kind!r} is not an entity type " + f"(known: {', '.join(sorted(entity_kinds))})." + ) + target = "entity" + elif kind in page_kinds: + target = "page" + elif kind in entity_kinds: + target = "entity" + else: + raise click.BadParameter( + f"unknown kind {kind!r}. page kinds: {', '.join(sorted(page_kinds))}; " + f"entity types: {', '.join(sorted(entity_kinds))}." + ) + + if target == "entity": + if not name: + raise click.BadParameter(f"entity kind {kind!r} needs --name.") + if dry_run: + if as_json: + _emit_json( + { + "target": "entity", + "kind": kind, + "name": name, + "aliases": list(aliases), + "description": description, + } + ) + else: + click.echo(f"[dry-run] entity {kind}: name={name!r}") + if aliases: + click.echo(f" aliases: {', '.join(aliases)}") + return + with _cli_errors(): + pr = propose_entity( + store, + name=name, + entity_type=kind, + aliases=list(aliases), + description=description, + proposed_by=_whoami(), + ) + if as_json: + _emit_json({"id": pr.id}) + else: + click.echo(pr.id) + return + + # --- page path --- + if not title: + raise click.BadParameter(f"page kind {kind!r} needs --title.") + if body == "-": + body = sys.stdin.read() + required, _schema, requires_citations = registry.resolve(kind) + metadata = _parse_meta(fields) + for field in required: + if field in metadata: + continue + if interactive: + raw = click.prompt(field, default="", show_default=False) + metadata[field] = yaml.safe_load(raw) if raw != "" else "" + else: + metadata[field] = "" + missing = [f for f in required if metadata.get(f) in (None, "", [], {})] + cite_reminder = bool(requires_citations) and not (claims or sources) + + if dry_run: + if as_json: + _emit_json( + { + "target": "page", + "kind": kind, + "title": title, + "frontmatter": metadata, + "missing_required": missing, + "citations_required": bool(requires_citations), + } + ) + else: + click.echo(f"[dry-run] page {kind}: {title!r}") + for key, value in metadata.items(): + click.echo(f" {key}: {value!r}") + if missing: + click.echo(f" missing required: {', '.join(missing)}") + if cite_reminder: + click.echo(" reminder: this kind requires citations - add --claim/--source.") + return + + if cite_reminder: + click.echo("reminder: this kind requires citations - add --claim/--source.", err=True) + with _cli_errors(): + pr = propose_page( + store, + title=title, + body=body, + page_type=kind, + claim_ids=list(claims), + source_ids=list(sources), + metadata=metadata, + proposed_by=_whoami(), + ) + if as_json: + _emit_json({"id": pr.id}) + else: + click.echo(pr.id) + + @cli.group(name="schema") def schema() -> None: """inspect and validate config-declared page kinds (issue #234).""" diff --git a/tests/test_new_scaffold.py b/tests/test_new_scaffold.py new file mode 100644 index 0000000..896c8c9 --- /dev/null +++ b/tests/test_new_scaffold.py @@ -0,0 +1,125 @@ +"""``vouch new `` — scaffold a typed page/entity proposal (issue #330). + +The scaffold reads the page-kind registry (or ``EntityType``) for the kind, +stubs every required field, and files a *pending* proposal through the normal +review gate. It reuses ``propose_page`` / ``propose_entity`` verbatim, so it +never writes an approved artifact and never weakens validation: an unfilled +required field is flagged the same way any other proposal is. ``--dry-run`` +shows the stubbed shape (and the missing-field list) without filing anything. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml +from click.testing import CliRunner + +from vouch.cli import cli +from vouch.models import ProposalKind, ProposalStatus +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +def _declare_kind(store: KBStore, name: str, **spec: Any) -> None: + """Write a ``config.yaml`` ``page_kinds.`` entry for the test kb.""" + cfg = store.kb_dir / "config.yaml" + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) if cfg.exists() else {} + data = data or {} + data.setdefault("page_kinds", {})[name] = spec + cfg.write_text(yaml.safe_dump(data), encoding="utf-8") + + +def test_page_scaffold_creates_pending_with_required_fields(store: KBStore) -> None: + _declare_kind(store, "decision-record", required_fields=["status", "owner"]) + r = CliRunner().invoke( + cli, + [ + "new", "decision-record", "--title", "pick db", + "--field", "status=open", "--field", "owner=alice", + ], + ) + assert r.exit_code == 0, r.output + pr = store.get_proposal(r.output.strip()) + assert pr.status == ProposalStatus.PENDING + assert pr.kind == ProposalKind.PAGE + md = pr.payload["metadata"] + assert md["status"] == "open" + assert md["owner"] == "alice" + + +def test_dry_run_stubs_required_fields_and_files_nothing(store: KBStore) -> None: + _declare_kind(store, "decision-record", required_fields=["status", "owner"]) + before = len(store.list_proposals()) + r = CliRunner().invoke( + cli, + ["new", "decision-record", "--title", "x", "--field", "status=open", "--dry-run"], + ) + assert r.exit_code == 0, r.output + # every required field is stubbed into the draft; the unfilled one is listed. + assert "status" in r.output and "owner" in r.output + assert "missing required" in r.output + assert len(store.list_proposals()) == before # nothing filed + + +def test_unfilled_required_field_is_flagged(store: KBStore) -> None: + # propose_page re-validates, so an empty required field is flagged, not + # silently written — the scaffold never weakens validation. + _declare_kind(store, "decision-record", required_fields=["status"]) + r = CliRunner().invoke(cli, ["new", "decision-record", "--title", "x"]) + assert r.exit_code != 0 + assert "Traceback" not in r.output + assert "status" in r.output + + +def test_field_is_parsed_as_yaml(store: KBStore) -> None: + _declare_kind(store, "meeting", required_fields=["attendees"]) + r = CliRunner().invoke( + cli, ["new", "meeting", "--title", "sync", "--field", "attendees=[a, b]"] + ) + assert r.exit_code == 0, r.output + pr = store.get_proposal(r.output.strip()) + assert pr.payload["metadata"]["attendees"] == ["a", "b"] + + +def test_entity_scaffold_routes_to_propose_entity(store: KBStore) -> None: + r = CliRunner().invoke(cli, ["new", "person", "--name", "alice-example"]) + assert r.exit_code == 0, r.output + pr = store.get_proposal(r.output.strip()) + assert pr.kind == ProposalKind.ENTITY + assert pr.status == ProposalStatus.PENDING + assert pr.payload["name"] == "alice-example" + + +def test_collision_defaults_to_page_and_entity_flag_forces_entity(store: KBStore) -> None: + # ``decision`` is both a built-in page type and an EntityType member. + r_page = CliRunner().invoke(cli, ["new", "decision", "--title", "d1"]) + assert r_page.exit_code == 0, r_page.output + assert store.get_proposal(r_page.output.strip()).kind == ProposalKind.PAGE + + r_ent = CliRunner().invoke(cli, ["new", "decision", "--entity", "--name", "d-ent"]) + assert r_ent.exit_code == 0, r_ent.output + assert store.get_proposal(r_ent.output.strip()).kind == ProposalKind.ENTITY + + +def test_unknown_kind_errors_with_known_list(store: KBStore) -> None: + r = CliRunner().invoke(cli, ["new", "no-such-kind", "--title", "x"]) + assert r.exit_code != 0 + assert "unknown kind" in r.output + + +def test_scaffold_only_files_a_pending_proposal(store: KBStore) -> None: + r = CliRunner().invoke(cli, ["new", "concept", "--title", "graphs"]) + assert r.exit_code == 0, r.output + pid = r.output.strip() + assert store.get_proposal(pid).status == ProposalStatus.PENDING + pending_ids = [p.id for p in store.list_proposals(ProposalStatus.PENDING)] + assert pid in pending_ids From adbba1392682c98d4dbabe62630552b48687e473 Mon Sep 17 00:00:00 2001 From: e11734937-beep Date: Fri, 3 Jul 2026 12:14:03 +0200 Subject: [PATCH 2/2] fix(cli): wrap page-kind registry lookups in _cli_errors load_page_kind_registry() and registry.resolve() ran outside the _cli_errors() handler in `vouch new`, so an invalid page_kinds config surfaced a raw traceback and could block entity-only scaffolds. Move the registry load/known()/resolve() calls inside _cli_errors() so PageKindError (already a ValueError subclass) is translated to a clean CLI error. Success path is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/vouch/cli.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 667873b..81b13cc 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -1096,8 +1096,9 @@ def new_cmd( way any other proposal is). """ store = _load_store() - registry = load_page_kind_registry(store) - page_kinds = registry.known() + with _cli_errors(): + registry = load_page_kind_registry(store) + page_kinds = registry.known() entity_kinds = {e.value for e in EntityType} # Deterministic page/entity dispatch. Some names (``decision``, ``project``) @@ -1160,7 +1161,8 @@ def new_cmd( raise click.BadParameter(f"page kind {kind!r} needs --title.") if body == "-": body = sys.stdin.read() - required, _schema, requires_citations = registry.resolve(kind) + with _cli_errors(): + required, _schema, requires_citations = registry.resolve(kind) metadata = _parse_meta(fields) for field in required: if field in metadata: