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

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