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

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("--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

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

View file

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