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.
This commit is contained in:
tegwick 2026-08-18 13:36:26 +02:00
parent 0c5177ec4c
commit a6d4a9b615
6 changed files with 294 additions and 3 deletions

View file

@ -40,6 +40,7 @@ rmgr rapp validate --path ../rapp-user-engine --family-root ..
rmgr rapp place --path ../rapp-some-app --reef reef-railiance rmgr rapp place --path ../rapp-some-app --reef reef-railiance
rmgr conform --path . rmgr conform --path .
rmgr prefix-uniqueness --root .. 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). Vertical-slice proof: [docs/evidence/t05-vertical-slice.md](docs/evidence/t05-vertical-slice.md).

View file

@ -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("--root", default=".", help="Fleet root or a single repository")
p_pref.add_argument("--registry", default=None, help="Override prefix registry YAML") 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) args = parser.parse_args(argv)
if args.version or args.command in (None, "version"): 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)) print(json.dumps(report, indent=2))
return 0 if report.get("ok") else 1 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() parser.print_help()
return 0 return 0

View file

@ -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)

65
tests/test_scaffold.py Normal file
View file

@ -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()

View file

@ -115,7 +115,7 @@ consistency lane / STATE-WP-0080 guard. (Do not set `progress` until
```task ```task
id: RMGR-WP-0004-T03 id: RMGR-WP-0004-T03
status: wait status: done
priority: high priority: high
state_hub_task_id: "8e6fa8d7-f169-44f5-bdc9-29376bb2ef64" 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 changes where the capability lives and how project repos are treated, not what
ordinary repos receive. 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 ## Regenerate agent instructions per flavor
```task ```task

View file

@ -8,7 +8,7 @@ status: active
owner: codex owner: codex
topic_slug: infotech topic_slug: infotech
created: "2026-08-17" created: "2026-08-17"
updated: "2026-08-17" updated: "2026-08-18"
parent_project: prj-state-hub-retirement parent_project: prj-state-hub-retirement
parent_workplan: SHR-WP-0001 parent_workplan: SHR-WP-0001
related: related:
@ -253,7 +253,7 @@ Coordinate with the hub-extension architecture in
```task ```task
id: RMGR-WP-0005-T06 id: RMGR-WP-0005-T06
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "d440d59c-f78e-4752-84c7-f3d5fdf7d3c3" 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 `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 direction wins for task status and make it consistent with `ADR-001`, where the
file originates work. 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.