From a6d4a9b615fcfa99d0e00e8ccf02080f3b08db5f Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 13:36:26 +0200 Subject: [PATCH] feat: flavor-correct rmgr scaffold; record C-15/C-23 file-wins RMGR-WP-0004-T03: project repos get GOAL.md and a required prefix. RMGR-WP-0005-T06 implemented in state-hub consistency. --- README.md | 1 + src/repo_manager/cli.py | 24 +++ src/repo_manager/commands/scaffold.py | 190 ++++++++++++++++++ tests/test_scaffold.py | 65 ++++++ ...P-0004-repository-standards-conformance.md | 7 +- ...gistrar-consolidation-deterministic-ids.md | 10 +- 6 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 src/repo_manager/commands/scaffold.py create mode 100644 tests/test_scaffold.py diff --git a/README.md b/README.md index a93698d..4e0247b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ rmgr rapp validate --path ../rapp-user-engine --family-root .. rmgr rapp place --path ../rapp-some-app --reef reef-railiance rmgr conform --path . rmgr prefix-uniqueness --root .. +rmgr scaffold --path ../prj-example --flavor project --wp-prefix EX-WP --no-commit ``` Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md). diff --git a/src/repo_manager/cli.py b/src/repo_manager/cli.py index a4a464b..0e3f6cb 100644 --- a/src/repo_manager/cli.py +++ b/src/repo_manager/cli.py @@ -75,6 +75,15 @@ def main(argv: list[str] | None = None) -> int: p_pref.add_argument("--root", default=".", help="Fleet root or a single repository") p_pref.add_argument("--registry", default=None, help="Override prefix registry YAML") + p_scaf = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files") + p_scaf.add_argument("--path", required=True) + p_scaf.add_argument("--flavor", required=True, choices=["experimental", "research", "project", "tooling", "product", "business"]) + p_scaf.add_argument("--slug", default=None) + p_scaf.add_argument("--domain", default="infotech") + p_scaf.add_argument("--wp-prefix", default=None) + p_scaf.add_argument("--force", action="store_true") + p_scaf.add_argument("--no-commit", action="store_true") + args = parser.parse_args(argv) if args.version or args.command in (None, "version"): @@ -214,6 +223,21 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(report, indent=2)) return 0 if report.get("ok") else 1 + if args.command == "scaffold": + from repo_manager.commands.scaffold import scaffold_repository + + result = scaffold_repository( + Path(args.path), + flavor=args.flavor, + slug=args.slug, + domain=args.domain, + wp_prefix=args.wp_prefix, + force=args.force, + commit=not args.no_commit, + ) + print(json.dumps(result.to_dict(), indent=2)) + return 0 if result.status == "applied" else 1 + parser.print_help() return 0 diff --git a/src/repo_manager/commands/scaffold.py b/src/repo_manager/commands/scaffold.py new file mode 100644 index 0000000..507ee42 --- /dev/null +++ b/src/repo_manager/commands/scaffold.py @@ -0,0 +1,190 @@ +"""Governed command: create flavor-correct repository files (RMGR-WP-0004-T03).""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any + +from repo_manager.gitops import GitError, commit_paths, is_git_repo +from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix + +DURABLE_FLAVORS = ("experimental", "research", "tooling", "product", "business") +FLAVORS = (*DURABLE_FLAVORS, "project") + + +@dataclass +class CommandResult: + status: str + evidence: dict[str, Any] + error: dict[str, Any] | None = None + correlation_id: str = "" + + def to_dict(self) -> dict[str, Any]: + out = { + "command": "repo.scaffold", + "status": self.status, + "correlation_id": self.correlation_id, + "evidence": self.evidence, + } + if self.error: + out["error"] = self.error + return out + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not content.endswith("\n"): + content += "\n" + path.write_text(content) + + +def scaffold_repository( + path: Path, + *, + flavor: str, + slug: str | None = None, + domain: str = "infotech", + wp_prefix: str | None = None, + force: bool = False, + commit: bool = True, +) -> CommandResult: + cid = str(uuid.uuid4()) + dest = path.expanduser().resolve() + dest.mkdir(parents=True, exist_ok=True) + slug = slug or dest.name + flavor = flavor.strip().lower() + if flavor not in FLAVORS: + return CommandResult( + "rejected", + {"path": str(dest)}, + {"message": f"unknown flavor {flavor!r}"}, + cid, + ) + + prj = flavor == "project" or slug.startswith("prj-") + if prj: + flavor = "project" + prefix = (wp_prefix or "").strip().upper() + if prefix and not prefix.endswith("-WP"): + prefix = prefix.rstrip("-") + "-WP" + if not prefix: + derived = expected_workplan_prefix(dest, slug=slug) + return CommandResult( + "rejected", + {"path": str(dest), "suggested_prefix": derived}, + { + "message": ( + "project flavor requires --wp-prefix derived from the " + "project identity (never PRJ-WP-)" + ) + }, + cid, + ) + if prefix == FLAVOR_MARKER_PREFIX or prefix.startswith("PRJ-"): + return CommandResult( + "rejected", + {"path": str(dest)}, + {"message": "refusing flavor-derived prefix PRJ-WP-"}, + cid, + ) + else: + prefix = (wp_prefix or expected_workplan_prefix(dest, slug=slug) or "REPO-WP").upper() + if not prefix.endswith("-WP"): + prefix = prefix.rstrip("-") + "-WP" + + written: list[str] = [] + + def put(rel: str, content: str) -> None: + target = dest / rel + if target.exists() and not force: + return + _write(target, content) + written.append(rel) + + put( + ".repo-classification.yaml", + "repo_classification:\n" + f" category: {'project' if prj else flavor}\n" + f" domain: {domain}\n" + " secondary_domains: []\n" + " capability_tags: []\n", + ) + put("README.md", f"# {slug}\n\nSee {'GOAL.md' if prj else 'INTENT.md'} and SCOPE.md.\n") + put( + "SCOPE.md", + f"# Scope\n\n## In scope\n\n- {slug}\n\n## Out of scope\n\n- TBD\n", + ) + put( + "AGENTS.md", + f"# Agent instructions — {slug}\n\n" + f"Orient: {'GOAL.md' if prj else 'INTENT.md'} → SCOPE.md → workplans/.\n" + f"Workplan prefix: `{prefix}-`.\n", + ) + if prj: + today = date.today().isoformat() + put( + "GOAL.md", + "---\n" + f"repo: {slug}\n" + "repo_flavor: project\n" + "project_status: draft\n" + f'started: "{today}"\n' + "---\n\n" + f"# Goal — {slug}\n\n" + "## Outcome\n\nWhat success looks like.\n\n" + "## Invariants\n\nRules that must hold during the project.\n\n" + "## Success gates\n\nCheckable conditions for goal achieved.\n\n" + "## Project retirement\n\n" + "Archive this repository when gates are met and residuals have live owners.\n", + ) + if (dest / "INTENT.md").exists() and not force: + pass + else: + put( + "INTENT.md", + f"# INTENT\n\n> Why `{slug}` exists.\n\n## One-liner\n\nTBD.\n", + ) + + (dest / "workplans" / "archived").mkdir(parents=True, exist_ok=True) + if not prj: + bootstrap = dest / "workplans" / f"{prefix}-0001-foundation.md" + if force or not bootstrap.exists(): + _write( + bootstrap, + "---\n" + f"id: {prefix}-0001\n" + "type: workplan\n" + f'title: "Foundation"\n' + f"domain: {domain}\n" + f"repo: {slug}\n" + "status: proposed\n" + "---\n\n" + "# Foundation\n\n" + "```task\n" + f"id: {prefix}-0001-T01\n" + "status: todo\n" + "priority: high\n" + "```\n\n" + "Establish the repository baseline.\n", + ) + written.append(str(bootstrap.relative_to(dest))) + + evidence: dict[str, Any] = { + "path": str(dest), + "flavor": flavor, + "slug": slug, + "wp_prefix": prefix, + "written": written, + "prj_layout": prj, + } + if commit and is_git_repo(dest) and written: + try: + sha = commit_paths(dest, written, f"chore: scaffold {flavor} repository baseline") + evidence["git_sha"] = sha + except GitError as exc: + return CommandResult("failed", evidence, {"message": str(exc)}, cid) + + return CommandResult("applied" if written else "rejected", evidence, None, cid) diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 0000000..9c73c3a --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,65 @@ +from pathlib import Path + +from repo_manager.cli import main +from repo_manager.commands.scaffold import scaffold_repository +from repo_manager.standards import check_repository + + +def test_prj_scaffold_requires_prefix(tmp_path: Path): + dest = tmp_path / "prj-example" + result = scaffold_repository(dest, flavor="project", commit=False) + assert result.status == "rejected" + assert "wp-prefix" in (result.error or {}).get("message", "").lower() or "PRJ" in str( + result.error + ) + + +def test_prj_scaffold_refuses_flavor_prefix(tmp_path: Path): + dest = tmp_path / "prj-example" + result = scaffold_repository(dest, flavor="project", wp_prefix="PRJ-WP", commit=False) + assert result.status == "rejected" + + +def test_prj_scaffold_writes_goal_not_intent(tmp_path: Path): + dest = tmp_path / "prj-example-cutover" + result = scaffold_repository( + dest, flavor="project", wp_prefix="EXCO-WP", commit=False + ) + assert result.status == "applied" + assert (dest / "GOAL.md").is_file() + assert not (dest / "INTENT.md").exists() + goal = (dest / "GOAL.md").read_text() + for heading in ("Outcome", "Invariants", "Success gates", "Project retirement"): + assert heading in goal + assert not list((dest / "workplans").glob("*-0001-*.md")) + report = check_repository(dest) + assert report.ok, report.to_dict() + + +def test_durable_scaffold_writes_intent(tmp_path: Path): + dest = tmp_path / "some-tool" + result = scaffold_repository(dest, flavor="tooling", commit=False) + assert result.status == "applied" + assert (dest / "INTENT.md").is_file() + assert not (dest / "GOAL.md").exists() + assert (dest / "workplans" / "SOMETOOL-WP-0001-foundation.md").is_file() + report = check_repository(dest) + assert report.ok, report.to_dict() + + +def test_cli_scaffold(tmp_path: Path): + dest = tmp_path / "cli-tool" + assert ( + main( + [ + "scaffold", + "--path", + str(dest), + "--flavor", + "tooling", + "--no-commit", + ] + ) + == 0 + ) + assert (dest / "INTENT.md").is_file() diff --git a/workplans/RMGR-WP-0004-repository-standards-conformance.md b/workplans/RMGR-WP-0004-repository-standards-conformance.md index 1d6a534..acdf28d 100644 --- a/workplans/RMGR-WP-0004-repository-standards-conformance.md +++ b/workplans/RMGR-WP-0004-repository-standards-conformance.md @@ -115,7 +115,7 @@ consistency lane / STATE-WP-0080 guard. (Do not set `progress` until ```task id: RMGR-WP-0004-T03 -status: wait +status: done priority: high state_hub_task_id: "8e6fa8d7-f169-44f5-bdc9-29376bb2ef64" ``` @@ -142,6 +142,11 @@ Behaviour for durable product repos must be preserved exactly; this workplan changes where the capability lives and how project repos are treated, not what ordinary repos receive. +**Result (2026-08-18):** `rmgr scaffold` writes flavor-correct baselines. +`prj-` requires `--wp-prefix` (never `PRJ-WP-`), writes `GOAL.md` with +the four required sections, and does not emit a bootstrap workplan. +Durable flavors get `INTENT.md` and an optional foundation workplan. + ## Regenerate agent instructions per flavor ```task diff --git a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md index a35c619..7f2804e 100644 --- a/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md +++ b/workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: infotech created: "2026-08-17" -updated: "2026-08-17" +updated: "2026-08-18" parent_project: prj-state-hub-retirement parent_workplan: SHR-WP-0001 related: @@ -253,7 +253,7 @@ Coordinate with the hub-extension architecture in ```task id: RMGR-WP-0005-T06 -status: wait +status: done priority: medium state_hub_task_id: "d440d59c-f78e-4752-84c7-f3d5fdf7d3c3" ``` @@ -271,3 +271,9 @@ regardless of file content — reproduced three times, via file edit and via `update_task_status`, with the task never holding `todo`. Establish which direction wins for task status and make it consistent with `ADR-001`, where the file originates work. + +**Result (2026-08-18):** In `state-hub` consistency: C-23 does not +auto-promote `proposed` → `active` (report only). C-15 no longer +writebacks wait over progress/todo; file wins via C-10 (ADR-001). +C-15 remains a non-fixable warning when the DB is terminal and the file +is not.