fix registrar writebacks and own the State Hub access map

Ignore generated WORK-RECORDS.md/.custodian-brief.md in the registrar git
precondition, commit identifier writebacks even when registration is
incomplete, and name the remaining records in the error. Add
config/state-hub-access.yaml as the single source for the AGENTS.md port
map, rendered by rmgr scaffold and refreshed with --refresh-hub-access.

Assistant: grok
Assistant-Session: 01a04996-76e8-7f53-b971-1885cfbed436
This commit is contained in:
tegwick 2026-08-28 20:48:21 +02:00
parent 77caca1ab8
commit 7ea7690dfc
10 changed files with 480 additions and 44 deletions

View file

@ -295,12 +295,21 @@ def main(argv: list[str] | None = None) -> int:
p_scaf = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files")
p_scaf.add_argument("--path", required=True)
p_scaf.add_argument("--flavor", required=True, choices=["experimental", "research", "project", "tooling", "product", "business"])
p_scaf.add_argument(
"--flavor",
default=None,
choices=["experimental", "research", "project", "tooling", "product", "business"],
)
p_scaf.add_argument("--slug", default=None)
p_scaf.add_argument("--domain", default="infotech")
p_scaf.add_argument("--wp-prefix", default=None)
p_scaf.add_argument("--force", action="store_true")
p_scaf.add_argument("--no-commit", action="store_true")
p_scaf.add_argument(
"--refresh-hub-access",
action="store_true",
help="Replace the State Hub access table in AGENTS.md from config/state-hub-access.yaml",
)
p_provenance = sub.add_parser(
"assistant-provenance",
@ -742,17 +751,23 @@ def main(argv: list[str] | None = None) -> int:
return 0 if report.get("ok") else 1
if args.command == "scaffold":
from repo_manager.commands.scaffold import scaffold_repository
from repo_manager.commands.scaffold import refresh_hub_access, scaffold_repository
result = scaffold_repository(
Path(args.path),
flavor=args.flavor,
slug=args.slug,
domain=args.domain,
wp_prefix=args.wp_prefix,
force=args.force,
commit=not args.no_commit,
)
if args.refresh_hub_access and not args.flavor:
result = refresh_hub_access(Path(args.path), commit=not args.no_commit)
else:
if not args.flavor:
print("scaffold: --flavor is required unless --refresh-hub-access is set", file=sys.stderr)
return 2
result = scaffold_repository(
Path(args.path),
flavor=args.flavor,
slug=args.slug,
domain=args.domain,
wp_prefix=args.wp_prefix,
force=args.force,
commit=not args.no_commit,
)
print(json.dumps(result.to_dict(), indent=2))
return 0 if result.status == "applied" else 1

View file

@ -26,6 +26,7 @@ from repo_manager.record_identity import scan_record_identities
LOCK_PATH = Path("/tmp/repo-manager-identifier-registrar.lock")
STATEHUB_TIMEOUT_SECONDS = 900
GENERATED_INDEX_PATHS = frozenset({".custodian-brief.md", "WORK-RECORDS.md"})
@dataclass
@ -175,12 +176,52 @@ def _requested_identity_findings(
)
def _porcelain_paths(stdout: str) -> list[str]:
paths: list[str] = []
for line in stdout.splitlines():
if not line.strip():
continue
if " -> " in line:
paths.append(line.split(" -> ", 1)[1].strip())
continue
paths.append(line[3:] if len(line) > 3 else line.strip())
return paths
def _remaining_record_ids(missing: dict[str, list[str]]) -> list[str]:
remaining: list[str] = []
for key in ("workplans", "tasks", "intakes", "decisions"):
remaining.extend(missing.get(key) or [])
return remaining
def _registration_incomplete_message(after: dict[str, list[str]], stdout: str) -> str:
remaining = _remaining_record_ids(after)
named = ", ".join(remaining) if remaining else "see missing_after"
message = (
"State Hub reconciliation did not assign every requested identifier: "
f"{named}"
)
for line in stdout.splitlines():
stripped = line.strip()
if "not created:" in stripped or "Internal Server Error" in stripped:
return f"{message}. Child: {stripped}"
return message
def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
status = _git(repo, "status", "--porcelain")
if status.returncode != 0:
return {}, status.stderr.strip() or "not a Git repository"
if status.stdout.strip():
return {}, "worktree must be clean before registrar reconciliation"
dirty = _porcelain_paths(status.stdout)
generated = [path for path in dirty if path in GENERATED_INDEX_PATHS]
blocking = [path for path in dirty if path not in GENERATED_INDEX_PATHS]
if blocking:
return (
{"dirty": dirty, "ignored_generated": generated},
"worktree must be clean before registrar reconciliation; "
f"blocking: {', '.join(blocking)}",
)
upstream = _git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if upstream.returncode != 0:
@ -210,6 +251,7 @@ def _check_git(repo: Path) -> tuple[dict[str, Any], str | None]:
"behind": behind,
"ahead": ahead,
"origin": remote_url,
"ignored_generated": generated,
}, None
@ -511,19 +553,34 @@ def _run_statehub(command: list[str], *, env: dict[str, str]) -> subprocess.Comp
def _restore_generated_brief(repo: Path, paths: list[str]) -> tuple[list[str], str | None]:
"""Restore the generated brief after a projection-only repair child."""
"""Restore generated indexes after a projection-only repair child."""
if not paths:
return [], None
if paths != [".custodian-brief.md"]:
return [], "projection repair changed files other than the generated Custodian brief"
original = _git(repo, "show", "HEAD:.custodian-brief.md")
if original.returncode != 0:
return [], original.stderr.strip() or "could not read the committed Custodian brief"
(repo / ".custodian-brief.md").write_text(original.stdout, encoding="utf-8")
generated = [path for path in paths if path in GENERATED_INDEX_PATHS]
other = [path for path in paths if path not in GENERATED_INDEX_PATHS]
if other:
return [], (
"projection repair changed files other than generated indexes: "
+ ", ".join(other)
)
restored: list[str] = []
for path in generated:
original = _git(repo, "show", f"HEAD:{path}")
target = repo / path
if original.returncode != 0:
target.unlink(missing_ok=True)
else:
target.write_text(original.stdout, encoding="utf-8")
restored.append(path)
remaining = _git(repo, "status", "--porcelain")
if remaining.returncode != 0 or remaining.stdout.strip():
return [], remaining.stderr.strip() or "generated brief restoration left a dirty worktree"
return paths, None
leftover = [
path
for path in _porcelain_paths(remaining.stdout)
if path not in GENERATED_INDEX_PATHS
]
if remaining.returncode != 0 or leftover:
return restored, remaining.stderr.strip() or "generated index restoration left a dirty worktree"
return restored, None
def registrar_reconcile(
@ -682,12 +739,14 @@ def registrar_reconcile(
"decisions": len(bootstrap_records["decision"]),
}
if source_error:
return RegistrarResult(
"rejected",
evidence,
{"code": "bootstrap_source_invalid", "message": source_error},
cid,
)
error: dict[str, Any] = {
"code": "bootstrap_source_invalid",
"message": source_error,
}
token = source_error.split()[0]
if token not in {"repository", "a", "an", "the"}:
error["record"] = token
return RegistrarResult("rejected", evidence, error, cid)
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with LOCK_PATH.open("a+", encoding="utf-8") as lock:
@ -792,23 +851,14 @@ def registrar_reconcile(
mode_verification_failed = bool(repair_projection_id and not repair_verified) or (
bootstrap_empty_projection and not bootstrap_verified
)
if (
incomplete = (
completed.returncode not in accepted_exit_codes
or any(after.values())
or mode_verification_failed
):
return RegistrarResult(
"failed",
evidence,
{
"code": "registration_incomplete",
"message": "State Hub reconciliation did not assign every requested identifier",
},
cid,
)
)
changed = _git(repo, "status", "--porcelain")
paths = [line[3:] for line in changed.stdout.splitlines() if len(line) > 3]
paths = _porcelain_paths(changed.stdout)
if (repair_verified or bootstrap_verified) and paths:
restored, restore_error = _restore_generated_brief(repo, paths)
evidence["restored_generated_paths"] = restored
@ -827,6 +877,7 @@ def registrar_reconcile(
paths,
"chore(registrar): assign State Hub identifiers",
)
evidence["committed_paths"] = paths
except GitError as exc:
return RegistrarResult(
"failed",
@ -838,7 +889,7 @@ def registrar_reconcile(
if push:
pushed, message = push_ff(repo)
evidence["push"] = {"ok": pushed, "message": message}
if not pushed:
if not pushed and not incomplete:
return RegistrarResult(
"failed",
evidence,
@ -848,4 +899,19 @@ def registrar_reconcile(
evidence["workplan_bindings"] = _sync_workplan_bindings(repo, api_base, repo.name)
if incomplete:
remaining = _remaining_record_ids(after)
return RegistrarResult(
"failed",
evidence,
{
"code": "registration_incomplete",
"message": _registration_incomplete_message(
after, completed.stdout or ""
),
"records": remaining,
},
cid,
)
return RegistrarResult("applied", evidence, None, cid)

View file

@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any
from repo_manager.gitops import GitError, commit_paths, is_git_repo
from repo_manager.hub_access import apply_hub_access_block, render_hub_access_block
from repo_manager.standards import FLAVOR_MARKER_PREFIX, expected_workplan_prefix
from repo_manager.time import utc_today
@ -121,7 +122,9 @@ def scaffold_repository(
"AGENTS.md",
f"# Agent instructions — {slug}\n\n"
f"Orient: {'GOAL.md' if prj else 'INTENT.md'} → SCOPE.md → workplans/.\n"
f"Workplan prefix: `{prefix}-`.\n",
f"Workplan prefix: `{prefix}-`.\n\n"
"## State Hub Integration\n\n"
f"{render_hub_access_block()}\n",
)
if prj:
today = utc_today().isoformat()
@ -190,3 +193,36 @@ def scaffold_repository(
if not written:
evidence["noop"] = True
return CommandResult("applied", evidence, None, cid)
def refresh_hub_access(path: Path, *, commit: bool = True) -> CommandResult:
"""Replace the State Hub access table in AGENTS.md from the canonical map."""
cid = str(uuid.uuid4())
dest = path.expanduser().resolve()
agents = dest / "AGENTS.md"
evidence: dict[str, Any] = {"path": str(dest), "file": "AGENTS.md"}
if not agents.is_file():
return CommandResult(
"rejected",
evidence,
{"message": "AGENTS.md is missing; scaffold the repository first"},
cid,
)
original = agents.read_text(encoding="utf-8")
updated, action = apply_hub_access_block(original)
evidence["action"] = action
if action == "noop":
evidence["noop"] = True
return CommandResult("applied", evidence, None, cid)
agents.write_text(updated, encoding="utf-8")
evidence["written"] = ["AGENTS.md"]
if commit and is_git_repo(dest):
try:
evidence["git_sha"] = commit_paths(
dest,
["AGENTS.md"],
"docs(agents): refresh State Hub access map from repo-manager",
)
except GitError as exc:
return CommandResult("failed", evidence, {"message": str(exc)}, cid)
return CommandResult("applied", evidence, None, cid)

View file

@ -0,0 +1,111 @@
"""Canonical State Hub access map for agent-facing AGENTS.md (CUST-WP-0067-T07).
The port map used to be prose copied into ~180 AGENTS.md files. Topology
changes then required a fleet-wide rewrite. This module is the single source:
edit ``config/state-hub-access.yaml`` and refresh with
``rmgr scaffold --refresh-hub-access``.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
import yaml
BEGIN = "<!-- BEGIN STATE-HUB-ACCESS -->"
END = "<!-- END STATE-HUB-ACCESS -->"
DEFAULT_CONTRACT = Path(__file__).resolve().parents[2] / "config" / "state-hub-access.yaml"
_FALLBACK: dict[str, Any] = {
"endpoints": [
{"context": "Local workstation", "url": "http://127.0.0.1:8000"},
{"context": "Remote (railiance01, in-cluster)", "url": "http://10.43.68.154:8000"},
{"context": "Optional local edge relay", "url": "http://127.0.0.1:18080"},
],
"retired": [
{
"url": "http://127.0.0.1:18000",
"reason": "reverse tunnel back to the workstation; the hub is in-cluster on railiance01",
}
],
}
_TABLE_RE = re.compile(
r"\| Context \| URL \|\r?\n\|[-:| ]+\|\r?\n(?:\|[^\n]*\|\r?\n)+",
re.MULTILINE,
)
_MARKED_RE = re.compile(
re.escape(BEGIN) + r".*?" + re.escape(END),
re.DOTALL,
)
def load_access(path: Path | None = None) -> dict[str, Any]:
target = path or DEFAULT_CONTRACT
if target.is_file():
payload = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
if isinstance(payload, dict) and payload.get("endpoints"):
return payload
return _FALLBACK
def render_hub_access_block(access: dict[str, Any] | None = None) -> str:
data = access or load_access()
rows = ["| Context | URL |", "|---------|-----|"]
for item in data.get("endpoints") or []:
context = str(item.get("context") or "").strip()
url = str(item.get("url") or "").strip()
rows.append(f"| {context} | `{url}` |")
retired = data.get("retired") or []
note_lines: list[str] = []
for item in retired:
url = str(item.get("url") or "").strip()
if not url:
continue
note_lines.append(
f"Do not instruct a remote agent to use `{url}`. That reverse tunnel "
"is retired; on railiance01 reach the hub at the in-cluster address above."
)
body = "\n".join(rows)
if note_lines:
body = body + "\n\n" + "\n".join(note_lines)
return f"{BEGIN}\n{body}\n{END}"
def _table_is_hub_access(table: str) -> bool:
lowered = table.lower()
return "local workstation" in lowered and (
"railiance01" in lowered or "10.43.68.154" in lowered or "127.0.0.1:18000" in lowered
)
def apply_hub_access_block(text: str, block: str | None = None) -> tuple[str, str]:
"""Insert or replace the canonical access table. Returns (new_text, action)."""
block = block or render_hub_access_block()
if block in text:
return text, "noop"
marked = _MARKED_RE.search(text)
if marked:
return text[: marked.start()] + block + text[marked.end() :], "replaced-marked"
for match in _TABLE_RE.finditer(text):
if _table_is_hub_access(match.group(0)):
return text[: match.start()] + block + "\n" + text[match.end() :], "replaced-table"
heading = re.search(r"^## State Hub Integration\s*$", text, re.MULTILINE)
if heading:
insert_at = heading.end()
return text[:insert_at] + "\n\n" + block + text[insert_at:], "inserted"
if text and not text.endswith("\n"):
text += "\n"
return text + "\n## State Hub Integration\n\n" + block + "\n", "appended"
def live_urls(access: dict[str, Any] | None = None) -> list[str]:
data = access or load_access()
return [str(item.get("url") or "").strip() for item in data.get("endpoints") or []]
def retired_urls(access: dict[str, Any] | None = None) -> list[str]:
data = access or load_access()
return [str(item.get("url") or "").strip() for item in data.get("retired") or []]