From d8e0eddb220a518edb6154ef08af35f91d064e40 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 21:49:15 +0200 Subject: [PATCH] feat: delegate project register; registrar-only ID minting 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). --- api/services/repo_manager_dual_run.py | 49 ++++++ scripts/consistency_check.py | 42 +++++ statehub_register.py | 147 +++++++++++++++--- tests/test_consistency_check.py | 64 ++++++++ tests/test_statehub_register_cli.py | 127 +++++++++++++++ ...-0080-register-project-flavor-awareness.md | 15 +- 6 files changed, 419 insertions(+), 25 deletions(-) diff --git a/api/services/repo_manager_dual_run.py b/api/services/repo_manager_dual_run.py index 3af5e40..2d2e8ce 100644 --- a/api/services/repo_manager_dual_run.py +++ b/api/services/repo_manager_dual_run.py @@ -249,6 +249,55 @@ def rm_update_task_status( return result +def rm_scaffold( + *, + repo_path: str | Path, + flavor: str, + wp_prefix: str | None = None, + slug: str | None = None, + domain: str = "infotech", + force: bool = False, + commit: bool = False, +) -> dict[str, Any]: + """Delegate repository scaffolding to ``rmgr scaffold`` (STATE-WP-0080-T02).""" + args = [ + "scaffold", + "--path", + str(repo_path), + "--flavor", + flavor, + "--domain", + domain, + ] + if slug: + args.extend(["--slug", slug]) + if wp_prefix: + args.extend(["--wp-prefix", wp_prefix]) + if force: + args.append("--force") + if not commit: + args.append("--no-commit") + code, out, err = run_rmgr(args) + try: + result = json.loads(out.strip() or "{}") + except json.JSONDecodeError: + result = { + "status": "failed", + "error": { + "code": "internal", + "message": f"rmgr non-json exit={code} stderr={err!r} stdout={out[:500]!r}", + }, + } + if code != 0 and result.get("status") not in ("applied", "rejected"): + result.setdefault("status", "failed") + result.setdefault( + "error", + {"code": "internal", "message": f"rmgr exit={code} stderr={err!r}"}, + ) + result["exit_code"] = code + return result + + def rm_reconcile(*, repo_path: str | Path, repo_slug: str | None = None) -> dict[str, Any]: args = ["reconcile", "--path", str(repo_path)] if repo_slug: diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 3818007..65cf3d4 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -11,6 +11,7 @@ Checks: C-04 workstream-status-drift WARN Yes File status != DB status (file wins) C-05 workstream-title-drift WARN Yes File title != DB title (file wins) C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id + Identifier minting (--fix create+writeback) is registrar-only (ADR-007) C-07 orphan-db-active FAIL No Active DB workstream, no backing file C-08 orphan-db-closed INFO No Finished/archived DB workstream, no file C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location @@ -44,6 +45,11 @@ Usage: python scripts/consistency_check.py --all [--fix] [--no-writeback] [--json] [--api-base URL] python scripts/consistency_check.py --here [PATH] [--fix] [--no-writeback] [--json] [--api-base URL] +Registrar (ADR-007 interim / RMGR-WP-0005-T01): + C-06 / C-11 / C-32 mint hub IDs into files only when this instance is the + identifier registrar. Default: hostname starts with ``railiance``. + Override with STATEHUB_REGISTRAR=1 or =0. Read/project checks are unchanged. + Exit codes (single-repo / local CLI): 0 — clean (no FAILs or WARNs; INFOs are allowed) 1 — one or more assessment FAILs or automation ERRORs (C-00) present @@ -2601,6 +2607,36 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo return True +# --------------------------------------------------------------------------- +# Identifier registrar (ADR-007 decision 2 / RMGR-WP-0005-T01) +# --------------------------------------------------------------------------- + +def _is_identifier_registrar() -> bool: + """True when this instance may mint hub IDs into repository files.""" + try: + from repo_manager.registrar import is_identifier_registrar + + return is_identifier_registrar() + except ImportError: + raw = os.environ.get("STATEHUB_REGISTRAR", "").strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return socket.gethostname().lower().startswith("railiance") + + +def _skip_non_registrar_mint(report: "ConsistencyReport", check_id: str, label: str) -> bool: + if _is_identifier_registrar(): + return False + report.fixes_applied.append( + f"{check_id} skipped: this instance is not the identifier registrar " + f"({label}; set STATEHUB_REGISTRAR=1 on the production instance; " + "ADR-007 interim / RMGR-WP-0005-T01)" + ) + return True + + # --------------------------------------------------------------------------- # Fix engine # --------------------------------------------------------------------------- @@ -2748,6 +2784,8 @@ def fix_repo( ) elif issue.check_id == "C-06": + if _skip_non_registrar_mint(report, "C-06", "workplan UUID"): + continue wp_file = Path(ctx["wp_file"]) meta = ctx["meta"] domain = ctx["domain"] @@ -2877,6 +2915,8 @@ def fix_repo( ) elif issue.check_id == "C-32": + if _skip_non_registrar_mint(report, "C-32", "work-record UUID"): + continue md_path = ctx["md_path"] kind = ctx["kind"] rid = ctx["rid"] @@ -2998,6 +3038,8 @@ def fix_repo( ) elif issue.check_id == "C-11": + if _skip_non_registrar_mint(report, "C-11", "task UUID"): + continue ws_id = ctx["ws_id"] ws_status = ctx.get("ws_status", "") task = ctx["task"] diff --git a/statehub_register.py b/statehub_register.py index fcf61e9..c42ffe6 100644 --- a/statehub_register.py +++ b/statehub_register.py @@ -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 -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, diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 6916a58..b4aa344 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -1299,6 +1299,7 @@ class TestC06WorkstreamCreation: monkeypatch.setattr("consistency_check._detect_ahead_of_remote", lambda _repo_path: 0) monkeypatch.setattr("consistency_check._write_custodian_brief", lambda *args, **kwargs: False) monkeypatch.setattr("consistency_check._git_push", lambda _repo_path: (True, "pushed")) + monkeypatch.setenv("STATEHUB_REGISTRAR", "1") report = fix_repo("http://unused", "demo-repo") @@ -1309,6 +1310,69 @@ class TestC06WorkstreamCreation: assert 'state_hub_task_id: "new-task"' in patched assert any("C-06 fixed" in fix for fix in report.fixes_applied) + def test_fix_repo_skips_c06_mint_when_not_registrar(self, tmp_path, monkeypatch): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + wp = workplans / "STATE-WP-0001-demo.md" + wp.write_text( + "---\n" + "id: STATE-WP-0001\n" + "type: workplan\n" + "title: Demo Workplan\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: ready\n" + "owner: codex\n" + "---\n\n" + "## Implement Demo\n\n" + "```task\n" + "id: STATE-WP-0001-T01\n" + "status: todo\n" + "priority: high\n" + "```\n", + encoding="utf-8", + ) + + created = [] + + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + import socket + + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {socket.gethostname(): str(repo)}, + "domain_slug": "financials", + } + if path == "/topics": + return [{"id": "topic-1", "domain_slug": "financials"}] + return [] + + def fake_post(_api_base, path, body): + created.append((path, body)) + return {"id": "should-not-mint", **body} + + monkeypatch.setattr("consistency_check._api_get", fake_get) + monkeypatch.setattr("consistency_check._api_post", fake_post) + monkeypatch.setattr("consistency_check._api_patch", lambda *args, **kwargs: {"ok": True}) + monkeypatch.setattr("consistency_check._detect_behind_remote", lambda _repo_path: False) + monkeypatch.setattr("consistency_check._detect_ahead_of_remote", lambda _repo_path: 0) + monkeypatch.setattr("consistency_check._write_custodian_brief", lambda *args, **kwargs: False) + monkeypatch.setattr("consistency_check._git_push", lambda _repo_path: (True, "pushed")) + monkeypatch.setenv("STATEHUB_REGISTRAR", "0") + + report = fix_repo("http://unused", "demo-repo") + + assert created == [] + patched = wp.read_text(encoding="utf-8") + assert "state_hub_workstream_id" not in patched + assert "state_hub_task_id" not in patched + assert any("not the identifier registrar" in fix for fix in report.fixes_applied) + assert any(issue.check_id == "C-06" for issue in report.issues) + # --------------------------------------------------------------------------- # _git_pull (T02 remote fix helper) # --------------------------------------------------------------------------- diff --git a/tests/test_statehub_register_cli.py b/tests/test_statehub_register_cli.py index af76404..7b4574d 100644 --- a/tests/test_statehub_register_cli.py +++ b/tests/test_statehub_register_cli.py @@ -12,6 +12,7 @@ from custodian_cli import cmd_fix_consistency from statehub_register import ( RegisterInference, detect_project_flavor_signal, + project_registration_plan, refuse_project_flavor_scaffold, run_register, _invoke_llm, @@ -205,6 +206,132 @@ def test_run_register_refuses_prj_repo_without_writing_or_calling_api( assert not (repo / "workplans").exists() +def test_project_registration_plan_durable_vs_refuse_vs_delegate(tmp_path: Path): + durable = tmp_path / "demo-service" + durable.mkdir() + assert project_registration_plan(durable) == ("durable", None) + + fresh = tmp_path / "prj-example" + fresh.mkdir() + assert project_registration_plan(fresh)[0] == "refuse" + assert project_registration_plan(fresh, wp_prefix="EXCO-WP") == ( + "delegate", + "EXCO-WP", + ) + + (fresh / "GOAL.md").write_text( + "---\nrepo_flavor: project\n---\n# Goal\n", encoding="utf-8" + ) + assert project_registration_plan(fresh) == ("register-only", None) + assert project_registration_plan(fresh, wp_prefix="EXCO-WP") == ( + "register-only", + None, + ) + + +def _stub_register_io(monkeypatch: pytest.MonkeyPatch): + written: list[dict] = [] + registered: list[tuple] = [] + + monkeypatch.setattr("statehub_register._check_api", lambda *_a, **_k: None) + monkeypatch.setattr( + "statehub_register._api_get", + lambda path, *_a, **_k: ( + [{"slug": "infotech", "id": "dom-1"}] + if str(path).startswith("/domains") + else [{"id": "topic-1", "slug": "infotech", "domain_slug": "infotech"}] + ), + ) + monkeypatch.setattr( + "statehub_register.write_registration_files", + lambda **kwargs: written.append(kwargs) or [], + ) + monkeypatch.setattr( + "statehub_register._register_or_update_repo", + lambda **kwargs: registered.append(("repo", kwargs)) or {"id": "repo-1"}, + ) + monkeypatch.setattr( + "statehub_register._register_host_path", + lambda *a, **k: registered.append(("path", a, k)), + ) + monkeypatch.setattr("statehub_register._record_progress", lambda *_a, **_k: None) + return written, registered + + +def _register_args(path: Path, **overrides): + values = { + "path": str(path), + "repo_slug": None, + "wp_prefix": None, + "domain": "infotech", + "description": "Test project.", + "intent": None, + "api_base": "http://unused", + "no_llm": True, + "force": False, + "llm_provider": "mock", + "llm_model": None, + "llm_api_key": None, + "llm_timeout": 5, + } + values.update(overrides) + return argparse.Namespace(**values) + + +def test_run_register_delegates_project_scaffold_and_skips_templating( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + repo = tmp_path / "prj-example" + repo.mkdir() + written, registered = _stub_register_io(monkeypatch) + delegated: list[dict] = [] + + def fake_delegate(project_path, **kwargs): + delegated.append(kwargs) + (project_path / "GOAL.md").write_text( + "---\nrepo_flavor: project\n---\n# Goal\n", encoding="utf-8" + ) + return {"status": "applied"} + + monkeypatch.setattr("statehub_register.delegate_project_scaffold", fake_delegate) + + run_register(_register_args(repo, wp_prefix="EXCO-WP")) + + assert delegated == [ + { + "slug": "prj-example", + "wp_prefix": "EXCO-WP", + "domain": "infotech", + "force": False, + } + ] + assert written == [] + assert [kind for kind, *_ in registered] == ["repo", "path"] + assert not (repo / "INTENT.md").exists() + assert (repo / "GOAL.md").is_file() + + +def test_run_register_existing_project_skips_scaffold_and_templating( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + repo = tmp_path / "prj-example" + repo.mkdir() + (repo / "GOAL.md").write_text( + "---\nrepo_flavor: project\n---\n# Goal\n", encoding="utf-8" + ) + written, registered = _stub_register_io(monkeypatch) + monkeypatch.setattr( + "statehub_register.delegate_project_scaffold", + lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("should not scaffold")), + ) + + run_register(_register_args(repo)) + + assert written == [] + assert [kind for kind, *_ in registered] == ["repo", "path"] + assert not (repo / "INTENT.md").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 f448dfc..d550c03 100644 --- a/workplans/STATE-WP-0080-register-project-flavor-awareness.md +++ b/workplans/STATE-WP-0080-register-project-flavor-awareness.md @@ -94,7 +94,7 @@ Durable path (`write_registration_files`) is unchanged. ```task id: STATE-WP-0080-T02 -status: todo +status: done priority: high state_hub_task_id: "ab1e079c-b36f-4bfc-a9fd-fb43f7f93924" ``` @@ -109,11 +109,17 @@ 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. +**Result (2026-08-18):** `statehub register` on a project-flavor checkout: +refuses without `--wp-prefix` if `GOAL.md` is missing (T01); with +`--wp-prefix` calls `rmgr scaffold` via the dual-run adapter and skips +`write_registration_files`; if `GOAL.md` already exists, registers the +repo + host path only. Durable repos still use hub templating. + ## Correct the inventory disposition ```task id: STATE-WP-0080-T03 -status: wait +status: done priority: medium state_hub_task_id: "91cb1a33-7748-4d65-bfbc-bad21234e364" ``` @@ -124,6 +130,11 @@ recorded as `keep`, correct it and cite decision `747011c6` — `SHR-INV-0001` i the retirement lane's source of truth, and a stale disposition would resurrect the boundary violation during cutover. +**Result (2026-08-18):** Already `move`. `jobs-callers-ops.yaml` +`caller:custodian-cli` (`custodian_cli / statehub register`) is +`disposition: move`, `owner: repo-manager`, capability `repo-onboarding`. +No inventory edit required. + ## Remove the templating path ```task