feat: delegate project register; registrar-only ID minting
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 39s

STATE-WP-0080-T02: statehub register routes project-flavor scaffolding
through rmgr scaffold and keeps only repo + host-path registration.
T01 refuse remains when GOAL.md is missing and --wp-prefix is not set.

RMGR-WP-0005-T01: C-06/C-11/C-32 skip mint+writeback unless this
instance is the identifier registrar (STATEHUB_REGISTRAR or railiance
hostname).
This commit is contained in:
tegwick 2026-08-18 21:49:15 +02:00
parent 71ad6c5d17
commit d8e0eddb22
6 changed files with 419 additions and 25 deletions

View file

@ -105,15 +105,82 @@ def refuse_project_flavor_scaffold(
signal = detect_project_flavor_signal(project_path, repo_slug)
if signal is None:
return
raise SystemExit(
raise SystemExit(_project_scaffold_refusal_message(project_path, signal))
def project_already_scaffolded(project_path: Path) -> bool:
"""True when the project purpose document already exists."""
return (project_path / "GOAL.md").is_file()
def project_registration_plan(
project_path: Path,
repo_slug: str | None = None,
wp_prefix: str | None = None,
) -> tuple[str, str | None]:
"""Decide how ``statehub register`` treats a checkout.
Returns one of:
- ``("durable", None)`` existing hub templating for non-project repos
- ``("refuse", signal)`` project flavor, not yet scaffolded, no prefix
- ``("delegate", prefix)`` call ``rmgr scaffold``, then register only
- ``("register-only", None)`` files already exist; hub records the repo
"""
signal = detect_project_flavor_signal(project_path, repo_slug)
if signal is None:
return "durable", None
if project_already_scaffolded(project_path):
return "register-only", None
prefix = (wp_prefix or "").strip()
if not prefix:
return "refuse", signal
return "delegate", prefix
def _project_scaffold_refusal_message(project_path: Path, signal: str) -> str:
return (
"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"
" Then re-run ``statehub register`` (or pass --wp-prefix now to delegate).\n"
" Choose --wp-prefix from the project identity, never PRJ-WP-."
)
def delegate_project_scaffold(
project_path: Path,
*,
slug: str,
wp_prefix: str,
domain: str = "infotech",
force: bool = False,
) -> dict[str, Any]:
"""Call ``rmgr scaffold`` via the dual-run adapter (STATE-WP-0080-T02)."""
from api.services.repo_manager_dual_run import record_mutation, rm_scaffold
result = rm_scaffold(
repo_path=project_path,
flavor="project",
wp_prefix=wp_prefix,
slug=slug,
domain=domain,
force=force,
commit=False,
)
record_mutation(
source="state-hub-register",
kind="project_scaffold_delegated",
repo_slug=slug,
detail={
"status": result.get("status"),
"wp_prefix": wp_prefix,
"exit_code": result.get("exit_code"),
},
)
return result
def _classification_category(project_path: Path) -> str | None:
doc = load_classification_document(project_path / CLASSIFICATION_FILENAME)
block = extract_classification_block(doc)
@ -146,7 +213,12 @@ def run_register(args: argparse.Namespace) -> None:
print(f"ERROR: {project_path} is not a directory.")
sys.exit(1)
refuse_project_flavor_scaffold(project_path, args.repo_slug)
explicit_prefix = getattr(args, "wp_prefix", None)
plan, _ = project_registration_plan(
project_path, getattr(args, "repo_slug", None), explicit_prefix
)
if plan == "refuse":
refuse_project_flavor_scaffold(project_path, getattr(args, "repo_slug", None))
snapshot = collect_repo_snapshot(project_path)
print(f"==> Inspecting repo at {snapshot.path}")
@ -157,8 +229,16 @@ 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)
plan, delegated_prefix = project_registration_plan(
project_path, repo_slug, explicit_prefix
)
if plan == "refuse":
refuse_project_flavor_scaffold(project_path, repo_slug)
wp_prefix = (
explicit_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 = (
args.description
@ -170,29 +250,50 @@ def run_register(args: argparse.Namespace) -> None:
if domain not in domain_slugs:
domain = _ask_for_domain(domain, domain_slugs)
intent_markdown = _resolve_intent_markdown(snapshot, inference, args, project_description)
topic = _find_or_create_topic(domain, snapshot.project_name, repo_slug, inference, args.api_base)
topic_id = topic["id"]
topic_slug = topic.get("slug") or domain
print(f"==> Writing State Hub agent files for '{repo_slug}'")
written = write_registration_files(
project_path=project_path,
project_name=snapshot.project_name,
project_description=project_description,
domain=domain,
topic_id=topic_id,
topic_slug=topic_slug,
repo_slug=repo_slug,
wp_prefix=wp_prefix,
intent_markdown=intent_markdown,
inference=inference,
force=args.force,
)
for path in written:
print(f" wrote {path}")
if not written:
print(" files already present; nothing overwritten")
skip_hub_templating = plan in {"delegate", "register-only"}
if plan == "delegate":
print(f"==> Delegating project scaffold to rmgr (prefix {delegated_prefix})")
result = delegate_project_scaffold(
project_path,
slug=repo_slug,
wp_prefix=delegated_prefix or wp_prefix,
domain=domain,
force=bool(getattr(args, "force", False)),
)
status = result.get("status")
if status != "applied" and not project_already_scaffolded(project_path):
err = (result.get("error") or {}).get("message") or result
raise SystemExit(f"ERROR: rmgr scaffold failed: {err}")
print(f" rmgr scaffold status={status}")
elif plan == "register-only":
print("==> Project files already present; skipping hub templating")
if not skip_hub_templating:
intent_markdown = _resolve_intent_markdown(
snapshot, inference, args, project_description
)
print(f"==> Writing State Hub agent files for '{repo_slug}'")
written = write_registration_files(
project_path=project_path,
project_name=snapshot.project_name,
project_description=project_description,
domain=domain,
topic_id=topic_id,
topic_slug=topic_slug,
repo_slug=repo_slug,
wp_prefix=wp_prefix,
intent_markdown=intent_markdown,
inference=inference,
force=args.force,
)
for path in written:
print(f" wrote {path}")
if not written:
print(" files already present; nothing overwritten")
repo = _register_or_update_repo(
domain=domain,