From f564e99a14cbc014f556cb164ee88ad6983c0eca Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 21:36:53 +0200 Subject: [PATCH] fix: refuse to scaffold prj- repos from statehub register STATE-WP-0080-T01: detect project flavor from classification, GOAL.md, or a prj- slug, then exit pointing at rmgr scaffold. Durable-repo write path is unchanged. Rebind 0080 hub IDs to the live workstream and open T02 now that RMGR-WP-0004-T03 has landed. --- WORK-RECORDS.md | 9 ++ statehub_register.py | 83 ++++++++++++++ tests/test_statehub_register_cli.py | 104 ++++++++++++++++++ ...-0080-register-project-flavor-awareness.md | 32 ++++-- 4 files changed, 216 insertions(+), 12 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b8f329b..1c825eb 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -49,6 +49,7 @@ | workplan | STATE-WP-0077 | finished | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md | | workplan | STATE-WP-0078 | finished | — | workplans/STATE-WP-0078-ops-run-read-projection.md | | workplan | STATE-WP-0079 | proposed | — | workplans/STATE-WP-0079-retirement-strangler.md | +| workplan | STATE-WP-0080 | active | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | | task | ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md | | task | ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md | | task | ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md | @@ -279,3 +280,11 @@ | task | STATE-WP-0079-T04 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | | task | STATE-WP-0079-T05 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | | task | STATE-WP-0079-T06 | todo | — | workplans/STATE-WP-0079-retirement-strangler.md | +| task | STATE-WP-0080-T01 | done | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T02 | todo | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T03 | wait | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T04 | wait | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T05 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T06 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T07 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | +| task | STATE-WP-0080-T08 | cancel | — | workplans/STATE-WP-0080-register-project-flavor-awareness.md | diff --git a/statehub_register.py b/statehub_register.py index 76778f4..fcf61e9 100644 --- a/statehub_register.py +++ b/statehub_register.py @@ -15,11 +15,18 @@ from datetime import date from pathlib import Path from typing import Any +import yaml + STATE_HUB_DIR = Path(__file__).resolve().parent API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") RULES_TEMPLATES_DIR = STATE_HUB_DIR / "scripts" / "project_rules" +from api.classification import ( # noqa: E402 + CLASSIFICATION_FILENAME, + extract_classification_block, + load_classification_document, +) from scripts.ensure_gitignore_claude_rules import ensure_claude_gitignore # noqa: E402 KEY_CONTEXT_FILES = [ @@ -60,12 +67,87 @@ class RegisterInference: current_state: str | None = None +_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.DOTALL) + + +def detect_project_flavor_signal( + project_path: Path, + repo_slug: str | None = None, +) -> str | None: + """Return the first project-flavor signal, or None for durable repos. + + Precedence matches repo-manager: classification category, GOAL.md + ``repo_flavor``, then a ``prj-`` slug prefix. Any match is enough to + refuse scaffolding here (STATE-WP-0080-T01). + """ + category = _classification_category(project_path) + if category == "project": + return f"{CLASSIFICATION_FILENAME} category=project" + + goal_flavor = _goal_repo_flavor(project_path) + if goal_flavor == "project": + return "GOAL.md repo_flavor=project" + + for candidate in (repo_slug, project_path.name): + if not candidate: + continue + slug = _slugify(candidate) + if slug.startswith("prj-"): + return f"slug prefix prj- ({slug})" + return None + + +def refuse_project_flavor_scaffold( + project_path: Path, + repo_slug: str | None = None, +) -> None: + """Exit before writing INTENT.md / PRJ-WP- files into a project repo.""" + signal = detect_project_flavor_signal(project_path, repo_slug) + if signal is None: + return + raise SystemExit( + "ERROR: statehub register refuses to scaffold a project-flavor repository.\n" + f" Detected via: {signal}\n" + " Durable-repo registration is unchanged. Scaffold project repos with Repo Manager:\n" + f" rmgr scaffold --path {project_path} --flavor project --wp-prefix -WP\n" + " Choose --wp-prefix from the project identity, never PRJ-WP-." + ) + + +def _classification_category(project_path: Path) -> str | None: + doc = load_classification_document(project_path / CLASSIFICATION_FILENAME) + block = extract_classification_block(doc) + if not block: + return None + raw = block.get("category") + return str(raw).strip().lower() if raw else None + + +def _goal_repo_flavor(project_path: Path) -> str | None: + path = project_path / "GOAL.md" + if not path.is_file(): + return None + match = _FRONTMATTER_RE.match(_read_limited(path, 4000)) + if not match: + return None + try: + frontmatter = yaml.safe_load(match.group(1)) or {} + except yaml.YAMLError: + return None + if not isinstance(frontmatter, dict): + return None + raw = frontmatter.get("repo_flavor") + return str(raw).strip().lower() if raw else None + + def run_register(args: argparse.Namespace) -> None: project_path = Path(args.path).expanduser().resolve() if not project_path.is_dir(): print(f"ERROR: {project_path} is not a directory.") sys.exit(1) + refuse_project_flavor_scaffold(project_path, args.repo_slug) + snapshot = collect_repo_snapshot(project_path) print(f"==> Inspecting repo at {snapshot.path}") @@ -75,6 +157,7 @@ def run_register(args: argparse.Namespace) -> None: inference = infer_registration(snapshot, args, domain_slugs) repo_slug = args.repo_slug or inference.repo_slug or _slugify(snapshot.project_name) + refuse_project_flavor_scaffold(project_path, repo_slug) wp_prefix = args.wp_prefix or inference.workplan_prefix or _default_wp_prefix(repo_slug) domain = args.domain or inference.domain_slug or _detect_domain_from_files(snapshot) project_description = ( diff --git a/tests/test_statehub_register_cli.py b/tests/test_statehub_register_cli.py index a5f7b05..af76404 100644 --- a/tests/test_statehub_register_cli.py +++ b/tests/test_statehub_register_cli.py @@ -11,6 +11,9 @@ import custodian_cli from custodian_cli import cmd_fix_consistency from statehub_register import ( RegisterInference, + detect_project_flavor_signal, + refuse_project_flavor_scaffold, + run_register, _invoke_llm, _normalise_inference, _parse_json_object, @@ -101,6 +104,107 @@ def test_write_registration_files_primes_codex_repo(tmp_path: Path): assert "statehub fix-consistency" in workplan +def test_detect_project_flavor_from_classification(tmp_path: Path): + (tmp_path / ".repo-classification.yaml").write_text( + "repo_classification:\n category: project\n domain: infotech\n", + encoding="utf-8", + ) + + assert detect_project_flavor_signal(tmp_path) == ( + ".repo-classification.yaml category=project" + ) + + +def test_detect_project_flavor_from_goal_md(tmp_path: Path): + (tmp_path / "GOAL.md").write_text( + "---\nrepo_flavor: project\n---\n\n# Project goal\n", + encoding="utf-8", + ) + + assert detect_project_flavor_signal(tmp_path) == "GOAL.md repo_flavor=project" + + +def test_detect_project_flavor_from_prj_directory_name(tmp_path: Path): + repo = tmp_path / "prj-example" + repo.mkdir() + + assert detect_project_flavor_signal(repo) == "slug prefix prj- (prj-example)" + + +def test_detect_project_flavor_from_repo_slug_argument(tmp_path: Path): + repo = tmp_path / "example" + repo.mkdir() + + assert detect_project_flavor_signal(repo) is None + assert detect_project_flavor_signal(repo, "prj-example") == ( + "slug prefix prj- (prj-example)" + ) + + +def test_classification_project_takes_precedence_over_slug(tmp_path: Path): + repo = tmp_path / "prj-example" + repo.mkdir() + (repo / ".repo-classification.yaml").write_text( + "repo_classification:\n category: project\n domain: infotech\n", + encoding="utf-8", + ) + + assert detect_project_flavor_signal(repo) == ( + ".repo-classification.yaml category=project" + ) + + +def test_durable_repo_is_not_project_flavor(tmp_path: Path): + (tmp_path / ".repo-classification.yaml").write_text( + "repo_classification:\n category: tooling\n domain: infotech\n", + encoding="utf-8", + ) + + assert detect_project_flavor_signal(tmp_path, "demo-service") is None + + +def test_refuse_project_flavor_scaffold_points_at_rmgr(tmp_path: Path): + repo = tmp_path / "prj-example" + repo.mkdir() + + with pytest.raises(SystemExit, match="rmgr scaffold") as exc: + refuse_project_flavor_scaffold(repo) + + message = str(exc.value) + assert "refuses to scaffold a project-flavor repository" in message + assert "never PRJ-WP-" in message + assert str(repo) in message + + +def test_run_register_refuses_prj_repo_without_writing_or_calling_api( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + repo = tmp_path / "prj-example" + repo.mkdir() + (repo / ".repo-classification.yaml").write_text( + "repo_classification:\n category: project\n domain: infotech\n", + encoding="utf-8", + ) + called: list[str] = [] + monkeypatch.setattr( + "statehub_register._check_api", + lambda *_args, **_kwargs: called.append("api"), + ) + monkeypatch.setattr( + "statehub_register.write_registration_files", + lambda **_kwargs: called.append("write") or [], + ) + + args = argparse.Namespace(path=str(repo), repo_slug=None) + + with pytest.raises(SystemExit, match="rmgr scaffold"): + run_register(args) + + assert called == [] + assert not (repo / "INTENT.md").exists() + assert not (repo / "workplans").exists() + + def test_write_registration_files_is_idempotent_without_force(tmp_path: Path): inference = RegisterInference() kwargs = { diff --git a/workplans/STATE-WP-0080-register-project-flavor-awareness.md b/workplans/STATE-WP-0080-register-project-flavor-awareness.md index c0bbe4a..f448dfc 100644 --- a/workplans/STATE-WP-0080-register-project-flavor-awareness.md +++ b/workplans/STATE-WP-0080-register-project-flavor-awareness.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: infotech created: "2026-08-16" -updated: "2026-08-16" +updated: "2026-08-18" parent_project: prj-state-hub-retirement parent_workplan: SHR-WP-0001 related: @@ -16,7 +16,7 @@ related: - STATE-WP-0079 - CFED-WP-0001 - SHR-WP-0001 -state_hub_workstream_id: "bbfce36a-0b19-462f-af78-167f9eb89b86" +state_hub_workstream_id: "03f38314-7d2c-42ee-bd1e-b0cc555dc06b" --- # statehub register: stop repo scaffolding, hand off to repo-manager @@ -70,9 +70,9 @@ manual deletion after registration. ```task id: STATE-WP-0080-T01 -status: todo +status: done priority: high -state_hub_task_id: "6b01a939-7d60-4a0c-90db-af8e31c45359" +state_hub_task_id: "65c52600-15fe-45bd-82b1-dfeff54698af" ``` Add a minimal flavor guard to the register path — detect project flavor from @@ -85,13 +85,18 @@ path here would become a second source of truth competing with `RMGR-WP-0004`. Durable-repo behaviour is untouched. +**Result (2026-08-18):** `statehub register` exits before writing files when any +of those three signals match. Message points at +`rmgr scaffold --path --flavor project --wp-prefix -WP`. +Durable path (`write_registration_files`) is unchanged. + ## Delegate registration scaffolding ```task id: STATE-WP-0080-T02 -status: wait +status: todo priority: high -state_hub_task_id: "bd6a9069-b455-4f5e-b1a5-4a87d014e8f6" +state_hub_task_id: "ab1e079c-b36f-4bfc-a9fd-fb43f7f93924" ``` Once `RMGR-WP-0004-T03` lands, route scaffolding through Repo Manager instead of @@ -101,13 +106,16 @@ does: record the repo, its identity, and its host path. Coordinate the cutover point with `RMGR-WP-0004-T05`. +**Opened (2026-08-18):** T01 guard is live and `RMGR-WP-0004-T03` (`rmgr scaffold`) +has landed. Ready to replace the refusal with a delegated scaffold call. + ## Correct the inventory disposition ```task id: STATE-WP-0080-T03 status: wait priority: medium -state_hub_task_id: "b141b378-10d0-4865-a67b-159806bcf037" +state_hub_task_id: "91cb1a33-7748-4d65-bfbc-bad21234e364" ``` Confirm the register/scaffolding capability is dispositioned `move` → @@ -122,7 +130,7 @@ the boundary violation during cutover. id: STATE-WP-0080-T04 status: wait priority: low -state_hub_task_id: "d933ae91-ad40-4a88-9ce3-90872cd1f3d7" +state_hub_task_id: "342aa5e8-96d0-4832-be60-f48815a86f71" ``` After delegation is live and `RMGR-WP-0004-T06` compatibility tests pass, delete @@ -139,7 +147,7 @@ retirement rather than as an isolated breaking change. id: STATE-WP-0080-T05 status: cancel priority: medium -state_hub_task_id: "9eb5ddb5-28d9-4244-8a4f-8fe2a50b5d27" +state_hub_task_id: "03224fa5-56f1-4e32-bc2e-40457dceb9cc" ``` Cancelled — moved to **`RMGR-WP-0004-T04`** by decision `747011c6`. @@ -155,7 +163,7 @@ registration, and it must not be lost in the handoff. id: STATE-WP-0080-T06 status: cancel priority: low -state_hub_task_id: "3467ae11-1301-46ab-9e7e-75c4f55f53fe" +state_hub_task_id: "47adb8e3-2a03-45b9-9647-fc4e42c58cd1" ``` Cancelled — moved to **`RMGR-WP-0004-T02`** by decision `747011c6`. @@ -171,7 +179,7 @@ the result rather than computing it. id: STATE-WP-0080-T07 status: cancel priority: medium -state_hub_task_id: "126ddd07-df9d-4b65-a651-fff0ad2d4074" +state_hub_task_id: "c056b8fd-d91b-4cf0-973c-d74d8e718dd1" ``` Cancelled — moved to **`RMGR-WP-0004-T06`** by decision `747011c6`. @@ -185,7 +193,7 @@ the handoff must not change what ordinary repositories receive. id: STATE-WP-0080-T08 status: cancel priority: medium -state_hub_task_id: "405526b7-b133-4746-aab5-6301c1a0b72e" +state_hub_task_id: "15eca52e-7c3b-40a7-b2a8-38b0313f57a8" ``` Cancelled — superseded by decision `747011c6`, which answered this task's open