cmd_status read tasks['in_progress'] and tasks['blocked']; the task vocabulary is wait|todo|progress|done|cancel and the totals block never carried those two keys, so the command always raised KeyError. Also accept either the workplans or legacy workstreams totals key, and print the resolved API base so the operator can see which hub instance answered. Refs CUST-WP-0067-T01 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
955 lines
40 KiB
Python
955 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
custodian — CLI for the Custodian State Hub.
|
|
|
|
Usage:
|
|
custodian register-project [--domain DOMAIN] [--path PATH]
|
|
|
|
Run from inside the project directory you want to connect.
|
|
--domain defaults to auto-detection from the project charter.
|
|
--path defaults to current working directory.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from statehub_register import run_register as run_statehub_register
|
|
|
|
STATE_HUB_DIR = Path(__file__).resolve().parent
|
|
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
|
|
_LEGACY_METER_HEADERS = {"X-StateHub-Component": "state-hub.custodian-cli"}
|
|
TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template"
|
|
PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py"
|
|
|
|
_SUGGESTION_PREAMBLE = """\
|
|
<!--
|
|
CUSTODIAN SUGGESTION — generated by `custodian register-project`
|
|
|
|
This file was written by the Custodian State Hub registration workflow.
|
|
It contains the standard State Hub integration block for this repository.
|
|
|
|
A Claude Code agent in this repo should:
|
|
1. Read this file and the existing CLAUDE.md side by side
|
|
2. Integrate the sections below into CLAUDE.md — preserve all existing
|
|
project-specific conventions; add or update the hub integration block
|
|
3. Delete this file once integration is complete and commit the result
|
|
|
|
Do NOT add this file to .gitignore. It is a temporary artefact that signals
|
|
pending integration work to the repo agent.
|
|
-->
|
|
|
|
"""
|
|
|
|
_ONBOARDING_TASKS = [
|
|
(
|
|
"Integrate CLAUDE.custodian.md → CLAUDE.md",
|
|
"high",
|
|
"A CLAUDE.custodian.md suggestion file was written by the custodian registration workflow. "
|
|
"Read both files, merge the hub integration block into the existing CLAUDE.md "
|
|
"(preserve all project-specific conventions), then delete CLAUDE.custodian.md and commit.",
|
|
),
|
|
(
|
|
"Write first workplan and initialise workplans/",
|
|
"high",
|
|
"Create a workplans/ directory and write the first workplan file following ADR-001 "
|
|
"(~/the-custodian/canon/architecture/adr-001-workplans-as-repo-artefacts.md). "
|
|
"Cover the repo's primary near-term work strand. Register the workstream in the state hub via MCP.",
|
|
),
|
|
(
|
|
"Ingest SBOM",
|
|
"medium",
|
|
# path substituted at call time
|
|
"",
|
|
),
|
|
(
|
|
"Register known EPs and TDs",
|
|
"low",
|
|
"Catalogue any known extension points (future enhancement hooks) and technical debt items "
|
|
"using the register_extension_point() and register_technical_debt() MCP tools.",
|
|
),
|
|
]
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
def _api_get(path: str) -> object:
|
|
url = API_BASE.rstrip("/") + path
|
|
try:
|
|
req = urllib.request.Request(url, headers=_LEGACY_METER_HEADERS)
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read())
|
|
except urllib.error.URLError as e:
|
|
print(f"ERROR: Cannot reach API at {API_BASE}: {e}")
|
|
print(f" Start it: cd {STATE_HUB_DIR} && make api")
|
|
sys.exit(1)
|
|
|
|
|
|
def _api_post(path: str, body: dict) -> object:
|
|
url = API_BASE.rstrip("/") + path
|
|
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
headers={"Content-Type": "application/json", **_LEGACY_METER_HEADERS},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def _api_patch(path: str, body: dict) -> object:
|
|
url = API_BASE.rstrip("/") + path
|
|
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
headers={"Content-Type": "application/json", **_LEGACY_METER_HEADERS},
|
|
method="PATCH",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read())
|
|
|
|
|
|
def _find_repo_by_slug(repo_slug: str) -> dict | None:
|
|
repos = _api_get("/repos/")
|
|
return next((r for r in repos if r.get("slug") == repo_slug), None)
|
|
|
|
|
|
def _detect_domain(project_path: Path) -> str | None:
|
|
"""Try to read domain from project charter frontmatter."""
|
|
for charter in project_path.rglob("project_charter_v*.md"):
|
|
text = charter.read_text()
|
|
m = re.search(r"^domain:\s*(\S+)", text, re.MULTILINE)
|
|
if m:
|
|
return m.group(1).strip('"\'')
|
|
return None
|
|
|
|
|
|
def _check_mcp() -> bool:
|
|
from scripts.mcp_registration import load_claude_json, mcp_server_registered
|
|
|
|
return mcp_server_registered(load_claude_json())
|
|
|
|
|
|
# ── Subcommands ────────────────────────────────────────────────────────────────
|
|
|
|
def cmd_register(args: argparse.Namespace) -> None:
|
|
"""Register a project/repo with the State Hub and generate onboarding tasks."""
|
|
project_path = Path(args.path).resolve()
|
|
if not project_path.is_dir():
|
|
print(f"ERROR: {project_path} is not a directory.")
|
|
sys.exit(1)
|
|
|
|
project_name = project_path.name
|
|
repo_slug = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", project_name.lower())).strip("-")
|
|
|
|
# ── Step 1: API health ─────────────────────────────────────────────────────
|
|
print(f"==> Checking API at {API_BASE} ...")
|
|
_api_get("/state/health")
|
|
print(" API OK")
|
|
|
|
# ── Step 2: Domain ─────────────────────────────────────────────────────────
|
|
domain = args.domain
|
|
valid_domains = [d["slug"] for d in _api_get("/domains/?status=active")]
|
|
if not domain:
|
|
print("==> Auto-detecting domain from project charter ...")
|
|
domain = _detect_domain(project_path)
|
|
if domain:
|
|
print(f" Detected: {domain}")
|
|
else:
|
|
print("ERROR: Could not auto-detect domain. Pass --domain explicitly.")
|
|
print(f" Valid: {', '.join(valid_domains)}")
|
|
sys.exit(1)
|
|
|
|
if domain not in valid_domains:
|
|
print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(valid_domains)}")
|
|
sys.exit(1)
|
|
|
|
# ── Step 3: Topic ID lookup (auto-create if new domain) ───────────────────
|
|
print(f"==> Looking up topic for domain '{domain}' ...")
|
|
topics = _api_get("/topics/?status=active")
|
|
match = next((t for t in topics if t.get("domain_slug") == domain), None)
|
|
if not match:
|
|
print(f" No topic found — creating one for domain '{domain}' ...")
|
|
t_slug = re.sub(r"[^a-z0-9]+", "-", domain.lower()).strip("-")
|
|
try:
|
|
match = _api_post("/topics/", {
|
|
"slug": t_slug,
|
|
"title": project_name,
|
|
"domain": domain,
|
|
"status": "active",
|
|
})
|
|
print(f" Topic created: {match['title']} ({match['id']})")
|
|
except Exception as e:
|
|
print(f"ERROR: Could not create topic for domain '{domain}': {e}")
|
|
sys.exit(1)
|
|
topic_id = match["id"]
|
|
print(f" topic_id: {topic_id}")
|
|
|
|
# ── Step 4: MCP check ──────────────────────────────────────────────────────
|
|
print("==> Checking MCP server registration ...")
|
|
if _check_mcp():
|
|
print(" MCP OK")
|
|
else:
|
|
print("WARNING: 'dev-hub' (or legacy 'state-hub') not in ~/.claude.json.")
|
|
print(" Run: python scripts/migrate_mcp_config.py # if upgrading legacy config")
|
|
print(" See ~/.claude/CLAUDE.md → MCP Server Registration section.")
|
|
|
|
# ── Step 5: Write CLAUDE.custodian.md ─────────────────────────────────────
|
|
suggestion_file = project_path / "CLAUDE.custodian.md"
|
|
print(f"==> Writing custodian suggestion to {suggestion_file} ...")
|
|
content = (
|
|
_SUGGESTION_PREAMBLE
|
|
+ TEMPLATE.read_text()
|
|
.replace("{PROJECT_NAME}", project_name)
|
|
.replace("{DOMAIN}", domain)
|
|
.replace("{TOPIC_ID}", topic_id)
|
|
.replace("{REPO_SLUG}", repo_slug)
|
|
)
|
|
suggestion_file.write_text(content)
|
|
print(" Written. The repo agent integrates it into CLAUDE.md then deletes it.")
|
|
|
|
# ── Step 6: Register repo ─────────────────────────────────────────────────
|
|
print(f"==> Registering repo '{repo_slug}' under domain '{domain}' ...")
|
|
repo = None
|
|
try:
|
|
repo = _api_post("/repos/", {
|
|
"domain_slug": domain,
|
|
"slug": repo_slug,
|
|
"name": project_name,
|
|
"local_path": str(project_path),
|
|
})
|
|
print(" Registered.")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code != 409:
|
|
print(f" NOTE: {e} — repo registration failed, continuing.")
|
|
else:
|
|
print(" Repo already registered, reusing existing record.")
|
|
repo = _find_repo_by_slug(repo_slug)
|
|
except Exception as e:
|
|
print(f" NOTE: {e} — repo may already be registered, continuing.")
|
|
repo = _find_repo_by_slug(repo_slug)
|
|
|
|
repo_id = repo.get("id") if isinstance(repo, dict) else None
|
|
if repo_id:
|
|
print(f" repo_id: {repo_id}")
|
|
else:
|
|
print(" WARNING: Could not resolve repo_id; onboarding workstream will remain domain-level.")
|
|
|
|
# ── Step 7: Onboarding workstream + tasks ─────────────────────────────────
|
|
ws_slug = f"repo-integration-{repo_slug}"
|
|
print(f"==> Creating onboarding workstream '{ws_slug}' ...")
|
|
# Check if it already exists
|
|
existing_ws = next(
|
|
(w for w in _api_get("/workplans/") if w.get("slug") == ws_slug and w.get("status") == "active"),
|
|
None,
|
|
)
|
|
if existing_ws:
|
|
print(" Onboarding workstream already exists — skipping task creation.")
|
|
if repo_id and not existing_ws.get("repo_id"):
|
|
existing_owner = existing_ws.get("owner")
|
|
_api_patch(f"/workplans/{existing_ws['id']}/", {
|
|
"repo_id": repo_id,
|
|
"owner": repo_slug if existing_owner in (None, domain) else existing_owner,
|
|
})
|
|
print(" Attached existing onboarding workstream to repo.")
|
|
elif repo_id and existing_ws.get("repo_id") != repo_id:
|
|
print(
|
|
" WARNING: Existing onboarding workstream is attached to a different repo_id; "
|
|
"leaving it unchanged."
|
|
)
|
|
else:
|
|
try:
|
|
ws = _api_post("/workplans/", {
|
|
"topic_id": topic_id,
|
|
"title": f"Repo Integration: {repo_slug}",
|
|
"slug": ws_slug,
|
|
"description": (
|
|
f"Bootstrapping workstream created by the custodian during registration of "
|
|
f"'{repo_slug}'. Contains onboarding tasks for the repo agent to execute. "
|
|
f"ADR-001 exception: this workstream is DB-first because the repo has no "
|
|
f"workplans/ directory yet. Task T2 produces the first workplan file."
|
|
),
|
|
"owner": repo_slug,
|
|
"status": "active",
|
|
"repo_id": repo_id,
|
|
})
|
|
ws_id = ws["id"]
|
|
sbom_desc = (
|
|
f"Capture the repo's dependency snapshot. From state-hub dir: "
|
|
f"make ingest-sbom REPO={repo_slug} SCAN=1 REPO_PATH={project_path}"
|
|
)
|
|
tasks = [
|
|
(_ONBOARDING_TASKS[0][0], _ONBOARDING_TASKS[0][1], _ONBOARDING_TASKS[0][2]),
|
|
(_ONBOARDING_TASKS[1][0], _ONBOARDING_TASKS[1][1], _ONBOARDING_TASKS[1][2]),
|
|
(_ONBOARDING_TASKS[2][0], _ONBOARDING_TASKS[2][1], sbom_desc),
|
|
(_ONBOARDING_TASKS[3][0], _ONBOARDING_TASKS[3][1], _ONBOARDING_TASKS[3][2]),
|
|
]
|
|
for title, priority, description in tasks:
|
|
_api_post("/tasks/", {
|
|
"workplan_id": ws_id,
|
|
"title": title,
|
|
"priority": priority,
|
|
"description": description,
|
|
})
|
|
print(f" Created with {len(tasks)} onboarding tasks.")
|
|
print(f" The {domain} repo agent will see these at next session start.")
|
|
except Exception as e:
|
|
print(f" WARNING: Could not create onboarding tasks: {e}")
|
|
ws_id = None
|
|
|
|
# ── Step 8: Progress event ─────────────────────────────────────────────────
|
|
print("==> Recording registration event ...")
|
|
try:
|
|
_api_post("/progress/", {
|
|
"topic_id": topic_id,
|
|
"event_type": "milestone",
|
|
"summary": f"Repo registered: {project_name} ({domain}) — onboarding tasks created",
|
|
"author": "custodian",
|
|
"detail": {
|
|
"project_path": str(project_path),
|
|
"suggestion_file": str(suggestion_file),
|
|
"repo_slug": repo_slug,
|
|
"domain": domain,
|
|
"onboarding_workstream_slug": ws_slug,
|
|
},
|
|
})
|
|
print(" Event recorded.")
|
|
except Exception as e:
|
|
print(f" WARNING: Could not record progress event: {e}")
|
|
|
|
print()
|
|
print("Registration complete!")
|
|
print(f" Project: {project_name}")
|
|
print(f" Domain: {domain}")
|
|
print(f" Repo slug: {repo_slug}")
|
|
print(f" Topic ID: {topic_id}")
|
|
print(f" Suggestion: {suggestion_file}")
|
|
print()
|
|
print("Next: open the repo in Claude Code.")
|
|
print(" The repo agent will pick up 4 onboarding tasks and integrate autonomously.")
|
|
|
|
|
|
def cmd_ingest_sbom(args: argparse.Namespace) -> None:
|
|
"""Ingest SBOM for the current (or specified) repo. Auto-detects slug from registration."""
|
|
project_path = Path(args.path).resolve()
|
|
|
|
_api_get("/state/health")
|
|
|
|
# Resolve repo slug: explicit override, or look up by local_path
|
|
repo_slug = args.slug
|
|
if not repo_slug:
|
|
repos = _api_get("/repos/")
|
|
repo = next((r for r in repos if r.get("local_path") == str(project_path)), None)
|
|
if not repo:
|
|
print(f"ERROR: No registered repo found for path '{project_path}'.")
|
|
print(" Register first: custodian register-project --domain <slug>")
|
|
print(" Or pass --slug explicitly.")
|
|
sys.exit(1)
|
|
repo_slug = repo["slug"]
|
|
|
|
print(f"==> Ingesting SBOM for '{repo_slug}' from {project_path} ...")
|
|
|
|
python = STATE_HUB_DIR / ".venv" / "bin" / "python"
|
|
ingest_script = STATE_HUB_DIR / "scripts" / "ingest_sbom.py"
|
|
|
|
if not python.exists():
|
|
print(f"ERROR: .venv not found at {STATE_HUB_DIR}. Run 'make install' in the state-hub directory.")
|
|
sys.exit(1)
|
|
|
|
cmd = [str(python), str(ingest_script), "--repo", repo_slug, "--scan", "--repo-path", str(project_path)]
|
|
if args.dry_run:
|
|
cmd.append("--dry-run")
|
|
|
|
result = subprocess.run(cmd)
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
def cmd_fix_consistency(args: argparse.Namespace) -> None:
|
|
"""Run ADR-001 consistency repair from any registered repo checkout."""
|
|
checker = STATE_HUB_DIR / "scripts" / "consistency_check.py"
|
|
if not checker.exists():
|
|
print(f"ERROR: consistency checker not found at {checker}")
|
|
print(" Run this command from an editable state-hub install or the state-hub repo.")
|
|
sys.exit(1)
|
|
|
|
if args.remote and not (args.repo or args.all):
|
|
print("ERROR: --remote requires --repo or --all.")
|
|
print(" From a local checkout, run: statehub fix-consistency")
|
|
print(" For pull-before-fix, run: statehub fix-consistency --repo <slug> --remote")
|
|
sys.exit(1)
|
|
|
|
cmd = [sys.executable, str(checker)]
|
|
if args.all:
|
|
cmd.append("--all")
|
|
elif args.repo:
|
|
cmd.extend(["--repo", args.repo])
|
|
if args.repo_path:
|
|
cmd.extend(["--repo-path", str(Path(args.repo_path).expanduser().resolve())])
|
|
else:
|
|
cmd.append("--here")
|
|
if args.path:
|
|
cmd.append(str(Path(args.path).expanduser().resolve()))
|
|
|
|
cmd.append("--fix")
|
|
if args.remote:
|
|
cmd.append("--remote")
|
|
if args.no_writeback:
|
|
cmd.append("--no-writeback")
|
|
if getattr(args, "bootstrap_empty_projection", False):
|
|
cmd.append("--bootstrap-empty-projection")
|
|
if args.archive_closed:
|
|
cmd.append("--archive-closed")
|
|
if args.archive_workplan:
|
|
cmd.extend(["--archive-workplan", args.archive_workplan])
|
|
if args.archive_date:
|
|
cmd.extend(["--archive-date", args.archive_date])
|
|
if args.api_base:
|
|
cmd.extend(["--api-base", args.api_base])
|
|
if args.as_json:
|
|
cmd.append("--json")
|
|
if args.max_seconds is not None:
|
|
cmd.extend(["--max-seconds", str(args.max_seconds)])
|
|
|
|
result = subprocess.run(cmd)
|
|
exit_code = result.returncode
|
|
if exit_code == 2 and not args.strict_warnings:
|
|
exit_code = 0
|
|
sys.exit(exit_code)
|
|
|
|
|
|
def cmd_quality_debt(args: argparse.Namespace) -> None:
|
|
"""List DoX quality debt (STATE-WP-0077) — ready without DoR-Ok, etc."""
|
|
script = STATE_HUB_DIR / "scripts" / "quality_debt.py"
|
|
if not script.exists():
|
|
print(f"ERROR: quality_debt.py not found at {script}")
|
|
sys.exit(1)
|
|
cmd = [sys.executable, str(script)]
|
|
if args.repo_path:
|
|
cmd.extend(["--repo-path", str(Path(args.repo_path).expanduser().resolve())])
|
|
else:
|
|
cmd.append("--here")
|
|
if args.api_base:
|
|
cmd.extend(["--api-base", args.api_base])
|
|
if args.no_hub:
|
|
cmd.append("--no-hub")
|
|
if args.as_json:
|
|
cmd.append("--json")
|
|
if args.strict:
|
|
cmd.append("--strict")
|
|
result = subprocess.run(cmd)
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
def cmd_promote_intake(args: argparse.Namespace) -> None:
|
|
"""Promote a routed intake into a workplan, task, decision, or engagement."""
|
|
script = STATE_HUB_DIR / "scripts" / "promote_intake.py"
|
|
if not script.exists():
|
|
print(f"ERROR: promote_intake.py not found at {script}")
|
|
sys.exit(1)
|
|
|
|
cmd = [
|
|
sys.executable, str(script), args.intake_id,
|
|
"--to", args.to,
|
|
"--repo-path", str(Path(args.repo_path).expanduser().resolve()),
|
|
"--repo-slug", args.repo_slug,
|
|
"--domain", args.domain,
|
|
"--api-base", args.api_base,
|
|
]
|
|
if args.target_file:
|
|
cmd.extend(["--target-file", args.target_file])
|
|
if args.workplan_file:
|
|
cmd.extend(["--workplan-file", args.workplan_file])
|
|
|
|
result = subprocess.run(cmd)
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
def cmd_create_workstream(args: argparse.Namespace) -> None:
|
|
"""Create a workstream under a domain's topic."""
|
|
_api_get("/state/health")
|
|
|
|
# Resolve topic_id from domain
|
|
topics = _api_get("/topics/?status=active")
|
|
match = next((t for t in topics if t.get("domain_slug") == args.domain), None)
|
|
if not match:
|
|
print(f"ERROR: No active topic for domain '{args.domain}'.")
|
|
sys.exit(1)
|
|
topic_id = match["id"]
|
|
|
|
slug = args.slug or re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
|
|
|
|
ws = _api_post("/workplans/", {
|
|
"topic_id": topic_id,
|
|
"title": args.title,
|
|
"slug": slug,
|
|
"description": args.description,
|
|
"owner": args.owner,
|
|
"status": "active",
|
|
})
|
|
_api_post("/progress/", {
|
|
"topic_id": topic_id,
|
|
"workplan_id": ws["id"],
|
|
"event_type": "workplan_created",
|
|
"summary": f"Workplan created: {args.title}",
|
|
"author": "custodian",
|
|
"detail": {"owner": args.owner, "slug": slug},
|
|
})
|
|
print(f"Created workstream: {ws['title']}")
|
|
print(f" id: {ws['id']}")
|
|
print(f" slug: {ws['slug']}")
|
|
print(f" domain: {args.domain}")
|
|
print(f" owner: {ws.get('owner') or '—'}")
|
|
|
|
|
|
def cmd_create_task(args: argparse.Namespace) -> None:
|
|
"""Create a task under a workstream (by ID or slug)."""
|
|
_api_get("/state/health")
|
|
|
|
# Resolve workstream: accept UUID or slug
|
|
workstream_id = args.workstream
|
|
if not _is_uuid(workstream_id):
|
|
wss = _api_get("/workplans/")
|
|
match = next((w for w in wss if w.get("slug") == workstream_id), None)
|
|
if not match:
|
|
print(f"ERROR: No workstream found with slug '{workstream_id}'.")
|
|
print(" Use 'custodian status' or check the dashboard for valid slugs.")
|
|
sys.exit(1)
|
|
workstream_id = match["id"]
|
|
|
|
task = _api_post("/tasks/", {
|
|
"workplan_id": workstream_id,
|
|
"title": args.title,
|
|
"priority": args.priority,
|
|
"description": args.description,
|
|
"assignee": args.assignee,
|
|
})
|
|
_api_post("/progress/", {
|
|
"workplan_id": workstream_id,
|
|
"task_id": task["id"],
|
|
"event_type": "task_created",
|
|
"summary": f"Task created: {args.title}",
|
|
"author": "custodian",
|
|
"detail": {"priority": args.priority},
|
|
})
|
|
print(f"Created task: {task['title']}")
|
|
print(f" id: {task['id']}")
|
|
print(f" priority: {task['priority']}")
|
|
print(f" status: {task['status']}")
|
|
|
|
|
|
def _is_uuid(s: str) -> bool:
|
|
import uuid as _uuid
|
|
try:
|
|
_uuid.UUID(s)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def cmd_status(_args: argparse.Namespace) -> None:
|
|
"""Quick status: API health + summary totals."""
|
|
health = _api_get("/state/health")
|
|
print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}")
|
|
summary = _api_get("/state/summary")
|
|
t = summary["totals"]
|
|
topics = t.get("topics", {})
|
|
workplans = t.get("workplans") or t.get("workstreams", {})
|
|
tasks = t.get("tasks", {})
|
|
decisions = t.get("decisions", {})
|
|
print(f"API base: {API_BASE}")
|
|
print(f"Topics: {topics.get('active', 0)} active")
|
|
print(f"Workplans: {workplans.get('active', 0)} active, {workplans.get('blocked', 0)} blocked")
|
|
# Task statuses are wait|todo|progress|done|cancel (see workplan-convention.md).
|
|
print(f"Tasks: {tasks.get('progress', 0)} in-progress, {tasks.get('todo', 0)} todo, {tasks.get('wait', 0)} waiting")
|
|
print(f"Decisions: {decisions.get('open', 0)} open, {decisions.get('escalated', 0)} escalated")
|
|
blocking = summary.get("blocking_decisions", [])
|
|
if blocking:
|
|
print(f"\nBlocking decisions ({len(blocking)}):")
|
|
for d in blocking:
|
|
deadline = d.get("deadline") or "no deadline"
|
|
print(f" [{deadline}] {d['title']}")
|
|
|
|
|
|
def _load_json_file(path: str) -> dict:
|
|
target = Path(path)
|
|
try:
|
|
value = json.loads(target.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
print(f"ERROR: invalid JSON file {target}: {exc}")
|
|
sys.exit(2)
|
|
if not isinstance(value, dict):
|
|
print(f"ERROR: {target} must contain a JSON object")
|
|
sys.exit(2)
|
|
return value
|
|
|
|
|
|
def _git_source(path: str, repo_slug: str | None = None) -> tuple[Path, dict]:
|
|
target = Path(path).resolve()
|
|
try:
|
|
root = Path(subprocess.check_output(
|
|
["git", "-C", str(target.parent), "rev-parse", "--show-toplevel"],
|
|
text=True,
|
|
).strip())
|
|
revision = subprocess.check_output(
|
|
["git", "-C", str(root), "rev-parse", "HEAD"], text=True
|
|
).strip()
|
|
relative = target.relative_to(root).as_posix()
|
|
except (subprocess.CalledProcessError, ValueError) as exc:
|
|
print(f"ERROR: {target} must be inside a Git repository: {exc}")
|
|
sys.exit(2)
|
|
return target, {"repo": repo_slug or root.name, "path": relative, "revision": revision}
|
|
|
|
|
|
def cmd_review_project(args: argparse.Namespace) -> None:
|
|
"""Project a file-authoritative contract wrapper through the direct API."""
|
|
target, source = _git_source(args.file, args.source_repo)
|
|
contract = _load_json_file(str(target))
|
|
payload = contract if "contract" in contract and "source" in contract else {
|
|
"contract": contract,
|
|
"source": source,
|
|
"decision_id": args.decision_id,
|
|
"workplan_id": args.workplan_id,
|
|
"task_id": args.task_id,
|
|
"required_for_decision": args.required_for_decision,
|
|
}
|
|
print(json.dumps(
|
|
_api_post("/review-contracts/projections", payload),
|
|
indent=2,
|
|
))
|
|
|
|
|
|
def cmd_review_submit(args: argparse.Namespace) -> None:
|
|
"""Submit a file-authoritative receipt against an active contract."""
|
|
key = urllib.parse.quote(args.contract_key, safe="")
|
|
target, source = _git_source(args.file, args.source_repo)
|
|
receipt = _load_json_file(str(target))
|
|
if "source" not in receipt:
|
|
receipt["source"] = source
|
|
print(json.dumps(
|
|
_api_post(f"/review-contracts/{key}/receipts", receipt),
|
|
indent=2,
|
|
))
|
|
|
|
|
|
def cmd_review_status(args: argparse.Namespace) -> None:
|
|
"""Print the derived owner and gate matrix; never an execution authorization."""
|
|
key = urllib.parse.quote(args.contract_key, safe="")
|
|
print(json.dumps(_api_get(f"/review-contracts/{key}/aggregate"), indent=2))
|
|
|
|
|
|
|
|
def _outbox_store(args):
|
|
from api.edge.outbox import OutboxStore, default_outbox_path
|
|
|
|
return OutboxStore(args.outbox_path or default_outbox_path())
|
|
|
|
|
|
def cmd_outbox_status(args: argparse.Namespace) -> None:
|
|
store = _outbox_store(args)
|
|
print(json.dumps(store.summary(), indent=2))
|
|
|
|
|
|
def cmd_outbox_list(args: argparse.Namespace) -> None:
|
|
store = _outbox_store(args)
|
|
rows = store.export(status=args.status, limit=args.limit)
|
|
print(json.dumps(rows, indent=2))
|
|
|
|
|
|
def cmd_outbox_export(args: argparse.Namespace) -> None:
|
|
store = _outbox_store(args)
|
|
payload = store.export(status=args.status, limit=args.limit)
|
|
if args.output:
|
|
Path(args.output).write_text(json.dumps(payload, indent=2) + "\n")
|
|
print(f"Exported {len(payload)} envelope(s) to {args.output}")
|
|
else:
|
|
print(json.dumps(payload, indent=2))
|
|
|
|
|
|
def cmd_outbox_replay(args: argparse.Namespace) -> None:
|
|
import asyncio
|
|
from api.edge.relay import replay_pending
|
|
|
|
store = _outbox_store(args)
|
|
upstream = args.upstream_url or os.environ.get("STATEHUB_UPSTREAM_URL") or API_BASE
|
|
result = asyncio.run(replay_pending(store, upstream_url=upstream, limit=args.limit))
|
|
print(json.dumps(result, indent=2))
|
|
|
|
|
|
def cmd_outbox_retry(args: argparse.Namespace) -> None:
|
|
store = _outbox_store(args)
|
|
store.retry(args.envelope_id)
|
|
print(f"Queued {args.envelope_id} for retry")
|
|
|
|
|
|
def cmd_outbox_cancel(args: argparse.Namespace) -> None:
|
|
store = _outbox_store(args)
|
|
store.cancel(args.envelope_id)
|
|
print(f"Cancelled {args.envelope_id}")
|
|
|
|
|
|
def cmd_dev_up(args: argparse.Namespace) -> None:
|
|
"""Start files-first local dev hub (compose path via dev_hub_up.sh)."""
|
|
script = STATE_HUB_DIR / "scripts" / "dev_hub_up.sh"
|
|
if not script.exists():
|
|
print(f"ERROR: dev hub script not found at {script}")
|
|
sys.exit(1)
|
|
|
|
env = os.environ.copy()
|
|
if args.with_edge:
|
|
env["WITH_EDGE"] = "1"
|
|
if args.with_mcp:
|
|
env["WITH_MCP"] = "1"
|
|
if args.repo_root:
|
|
env["REPO_ROOT"] = str(Path(args.repo_root).expanduser().resolve())
|
|
|
|
cmd = ["bash", str(script), args.profile]
|
|
result = subprocess.run(cmd, env=env, cwd=STATE_HUB_DIR)
|
|
sys.exit(result.returncode)
|
|
|
|
|
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
prog=Path(sys.argv[0]).name,
|
|
description="Custodian State Hub CLI",
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
# register
|
|
statehub_reg = sub.add_parser(
|
|
"register",
|
|
help="Register the current repo with State Hub and prime it for Codex",
|
|
)
|
|
statehub_reg.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)")
|
|
statehub_reg.add_argument("--domain", default=None, help="State Hub domain slug")
|
|
statehub_reg.add_argument("--repo-slug", default=None, help="Repo slug (auto-detected if omitted)")
|
|
statehub_reg.add_argument("--wp-prefix", default=None, help="Workplan prefix, e.g. STATE-WP")
|
|
statehub_reg.add_argument("--description", default=None, help="One-sentence repo description")
|
|
statehub_reg.add_argument(
|
|
"--intent",
|
|
default=None,
|
|
help="Repo intent text to use when INTENT.md is absent and inference is insufficient",
|
|
)
|
|
statehub_reg.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
|
|
statehub_reg.add_argument(
|
|
"--llm-provider",
|
|
default=os.environ.get("STATEHUB_REGISTER_LLM_PROVIDER", "claude-code"),
|
|
help="llm-connect provider: claude-code, openrouter, openai, gemini, or mock",
|
|
)
|
|
statehub_reg.add_argument(
|
|
"--llm-model",
|
|
default=os.environ.get("STATEHUB_REGISTER_LLM_MODEL"),
|
|
help="Model name passed to llm-connect",
|
|
)
|
|
statehub_reg.add_argument(
|
|
"--llm-api-key",
|
|
default=os.environ.get("STATEHUB_REGISTER_LLM_API_KEY"),
|
|
help="API key for API-backed llm-connect providers",
|
|
)
|
|
statehub_reg.add_argument(
|
|
"--llm-timeout",
|
|
type=int,
|
|
default=int(os.environ.get("STATEHUB_REGISTER_LLM_TIMEOUT", "120")),
|
|
help="LLM timeout in seconds",
|
|
)
|
|
statehub_reg.add_argument("--no-llm", action="store_true", help="Skip LLM inference and use files/prompts")
|
|
statehub_reg.add_argument("--force", action="store_true", help="Overwrite generated repo files")
|
|
|
|
# register-project
|
|
reg = sub.add_parser("register-project", help="Register a project with the State Hub")
|
|
reg.add_argument(
|
|
"--domain",
|
|
default=None,
|
|
help="Project domain slug (auto-detected from charter if omitted)",
|
|
)
|
|
reg.add_argument(
|
|
"--path",
|
|
default=os.getcwd(),
|
|
help="Project directory (defaults to current directory)",
|
|
)
|
|
|
|
# ingest-sbom
|
|
ing = sub.add_parser("ingest-sbom", help="Ingest SBOM for the repo at the current directory")
|
|
ing.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)")
|
|
ing.add_argument("--slug", default=None, help="Repo slug (auto-detected from path if omitted)")
|
|
ing.add_argument("--dry-run", action="store_true", help="Parse lockfiles but do not submit to API")
|
|
|
|
# fix-consistency
|
|
fix = sub.add_parser(
|
|
"fix-consistency",
|
|
help="Reconcile workplan files with State Hub from the current repo",
|
|
)
|
|
target = fix.add_mutually_exclusive_group()
|
|
target.add_argument("--repo", default=None, help="Registered repo slug; defaults to inferring from --path")
|
|
target.add_argument("--all", action="store_true", help="Fix all registered repos with a visible path")
|
|
fix.add_argument("--path", default=os.getcwd(), help="Repo checkout to infer from (defaults to cwd)")
|
|
fix.add_argument("--repo-path", default=None, help="Override repo path when using --repo")
|
|
fix.add_argument("--remote", action="store_true", help="Pull before fixing; requires --repo or --all")
|
|
fix.add_argument("--max-seconds", type=int, default=None, help="Wall-clock budget for --remote --all")
|
|
fix.add_argument("--no-writeback", action="store_true", help="Disable DB-to-file status writeback")
|
|
fix.add_argument(
|
|
"--bootstrap-empty-projection",
|
|
action="store_true",
|
|
help="Rebuild file UUIDs only after proving the repo projection is empty",
|
|
)
|
|
fix.add_argument("--archive-closed", action="store_true", help="Archive closed root workplans after fixing")
|
|
fix.add_argument("--archive-workplan", default=None, help="Archive only the matching workplan id or filename")
|
|
fix.add_argument("--archive-date", default=None, help="YYMMDD archive prefix for --archive-closed")
|
|
fix.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
|
|
fix.add_argument("--json", action="store_true", dest="as_json", help="Output JSON from the checker")
|
|
fix.add_argument(
|
|
"--strict-warnings",
|
|
action="store_true",
|
|
help="Preserve checker exit code 2 for warnings-only runs",
|
|
)
|
|
|
|
# quality-debt (STATE-WP-0077)
|
|
qdebt = sub.add_parser(
|
|
"quality-debt",
|
|
help="List DoX quality debt: ready without DoR-Ok, finished without DoD-Ok, intakes without DoC-Ok",
|
|
)
|
|
qdebt.add_argument("--repo-path", default=None, help="Repo root (default: cwd)")
|
|
qdebt.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
|
|
qdebt.add_argument("--no-hub", action="store_true", help="Skip hub intake scan")
|
|
qdebt.add_argument("--json", action="store_true", dest="as_json")
|
|
qdebt.add_argument("--strict", action="store_true", help="Exit 1 if any debt found")
|
|
qdebt.set_defaults(func=cmd_quality_debt)
|
|
|
|
# promote-intake
|
|
promote = sub.add_parser(
|
|
"promote-intake",
|
|
help="Promote a routed intake into a workplan, task, decision, or engagement (CUST-WP-0061-T03)",
|
|
)
|
|
promote.add_argument("intake_id", help="UUID of the routed intake")
|
|
promote.add_argument("--to", required=True, choices=["workplan", "task", "decision", "engagement"])
|
|
promote.add_argument("--repo-path", default=os.getcwd(), help="Local checkout of the target repo")
|
|
promote.add_argument("--repo-slug", required=True, help="Registered repo slug")
|
|
promote.add_argument("--domain", required=True, help="Market domain slug, e.g. infotech")
|
|
promote.add_argument("--target-file", default=None, help="Repo-relative path (required for decision/engagement)")
|
|
promote.add_argument("--workplan-file", default=None, help="Repo-relative workplan file path (required for task)")
|
|
promote.add_argument("--api-base", default=API_BASE, help="State Hub API base URL")
|
|
|
|
# create-workstream
|
|
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
|
|
cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under")
|
|
cws.add_argument("--title", required=True, help="Workstream title")
|
|
cws.add_argument("--slug", default=None, help="URL slug (auto-generated from title if omitted)")
|
|
cws.add_argument("--owner", default=None, help="Owner name")
|
|
cws.add_argument("--description", default=None, help="Optional description")
|
|
|
|
# create-task
|
|
ctask = sub.add_parser("create-task", help="Create a task under a workstream")
|
|
ctask.add_argument("--workstream", required=True, metavar="ID_OR_SLUG", help="Workstream UUID or slug")
|
|
ctask.add_argument("--title", required=True, help="Task title")
|
|
ctask.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium")
|
|
ctask.add_argument("--assignee", default=None)
|
|
ctask.add_argument("--description", default=None)
|
|
|
|
|
|
# outbox
|
|
outbox = sub.add_parser("outbox", help="Inspect and replay the local State Hub edge outbox")
|
|
outbox.add_argument("--outbox-path", default=None, help="SQLite outbox path (defaults to ~/.statehub/edge-outbox.sqlite3)")
|
|
out_sub = outbox.add_subparsers(dest="outbox_command", required=True)
|
|
|
|
out_status = out_sub.add_parser("status", help="Show pending, conflict, and ack counts")
|
|
out_status.set_defaults(func=cmd_outbox_status)
|
|
|
|
out_list = out_sub.add_parser("list", help="List outbox envelopes as JSON")
|
|
out_list.add_argument("--status", default=None, help="Filter by status")
|
|
out_list.add_argument("--limit", type=int, default=100)
|
|
out_list.set_defaults(func=cmd_outbox_list)
|
|
|
|
out_export = out_sub.add_parser("export", help="Export non-secret envelopes")
|
|
out_export.add_argument("--status", default=None, help="Filter by status")
|
|
out_export.add_argument("--limit", type=int, default=1000)
|
|
out_export.add_argument("--output", default=None, help="Write JSON to a file instead of stdout")
|
|
out_export.set_defaults(func=cmd_outbox_export)
|
|
|
|
out_replay = out_sub.add_parser("replay", help="Replay due queued envelopes")
|
|
out_replay.add_argument("--upstream-url", default=None, help="Central State Hub API base URL")
|
|
out_replay.add_argument("--limit", type=int, default=50)
|
|
out_replay.set_defaults(func=cmd_outbox_replay)
|
|
|
|
out_retry = out_sub.add_parser("retry", help="Force one envelope back to queued")
|
|
out_retry.add_argument("envelope_id")
|
|
out_retry.set_defaults(func=cmd_outbox_retry)
|
|
|
|
out_cancel = out_sub.add_parser("cancel", help="Cancel one envelope")
|
|
out_cancel.add_argument("envelope_id")
|
|
out_cancel.set_defaults(func=cmd_outbox_cancel)
|
|
|
|
# status
|
|
sub.add_parser("status", help="Show State Hub health and summary totals")
|
|
|
|
# review — file-backed multi-owner review projection
|
|
review = sub.add_parser("review", help="Project and inspect multi-owner review evidence")
|
|
review_sub = review.add_subparsers(dest="review_command", required=True)
|
|
review_project = review_sub.add_parser("project", help="Project an authoritative contract wrapper")
|
|
review_project.add_argument("file")
|
|
review_project.add_argument("--source-repo", default=None)
|
|
review_project.add_argument("--decision-id", default=None)
|
|
review_project.add_argument("--workplan-id", default=None)
|
|
review_project.add_argument("--task-id", default=None)
|
|
review_project.add_argument("--required-for-decision", action="store_true")
|
|
review_project.set_defaults(func=cmd_review_project)
|
|
review_submit = review_sub.add_parser("submit", help="Submit an authoritative receipt file")
|
|
review_submit.add_argument("contract_key")
|
|
review_submit.add_argument("file")
|
|
review_submit.add_argument("--source-repo", default=None)
|
|
review_submit.set_defaults(func=cmd_review_submit)
|
|
review_status = review_sub.add_parser("status", help="Print owner and gate status")
|
|
review_status.add_argument("contract_key")
|
|
review_status.set_defaults(func=cmd_review_status)
|
|
|
|
# dev up — files-first local hub (CUST-WP-0054-T07)
|
|
dev = sub.add_parser("dev", help="Local dev-hub commands")
|
|
dev_sub = dev.add_subparsers(dest="dev_command", required=True)
|
|
dev_up = dev_sub.add_parser("up", help="Start local dev hub from repo files")
|
|
dev_up.add_argument(
|
|
"--profile",
|
|
choices=["dev", "fleet"],
|
|
default="dev",
|
|
help="dev=local :8000; fleet=prefer tunnel :18000 when reachable",
|
|
)
|
|
dev_up.add_argument("--with-edge", action="store_true", help="Start edge relay on :18080")
|
|
dev_up.add_argument("--with-mcp", action="store_true", help="Register dev-hub MCP after API is up")
|
|
dev_up.add_argument(
|
|
"--repo-root",
|
|
default=None,
|
|
help="Parent directory for register-from-classification-all (default: $HOME)",
|
|
)
|
|
dev_up.set_defaults(func=cmd_dev_up)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if hasattr(args, "func"):
|
|
args.func(args)
|
|
elif args.command == "register":
|
|
run_statehub_register(args)
|
|
elif args.command == "register-project":
|
|
cmd_register(args)
|
|
elif args.command == "ingest-sbom":
|
|
cmd_ingest_sbom(args)
|
|
elif args.command == "fix-consistency":
|
|
cmd_fix_consistency(args)
|
|
elif args.command == "promote-intake":
|
|
cmd_promote_intake(args)
|
|
elif args.command == "create-workstream":
|
|
cmd_create_workstream(args)
|
|
elif args.command == "create-task":
|
|
cmd_create_task(args)
|
|
elif args.command == "status":
|
|
cmd_status(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|