fix: refuse to scaffold prj- repos from statehub register
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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.
This commit is contained in:
tegwick 2026-08-18 21:36:53 +02:00
parent 131b824dd3
commit f564e99a14
4 changed files with 216 additions and 12 deletions

View file

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

View file

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

View file

@ -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 = {

View file

@ -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 <repo> --flavor project --wp-prefix <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