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.
This commit is contained in:
parent
131b824dd3
commit
f564e99a14
4 changed files with 216 additions and 12 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue