#!/usr/bin/env python3 """Read-only CUST-WP-0075 snapshot. Requires PyYAML for the file scanner. Writes evidence only to --output; never changes owner records or contacts owners. """ import argparse from collections import Counter from datetime import datetime, timezone import json import os from pathlib import Path import subprocess import sys from urllib.parse import urlencode from urllib.request import urlopen ROOT = Path(__file__).resolve().parents[1] SCANNER = ROOT / "docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py" OPEN = {"proposed", "ready", "active", "blocked", "backlog"} HANDOFFS = { "state-hub": "b4825539-1425-4028-a2e9-9243785a344b", "railiance-platform": "f84637fa-2355-4971-a2dc-7f1fd01710a8", "ops-warden": "79549660-4fc8-44d5-989e-074f6c7b6f07", } def get_rows(base, route, **filters): # Only /tasks has offset pagination. /messages uses a caller-selected # limit; /repos returns the complete registry without paging parameters. if route != "tasks": query = urlencode(dict(filters, limit=5000)) if route == "messages" else urlencode(filters) with urlopen(f"{base.rstrip('/')}/{route}/?{query}", timeout=30) as response: rows = json.load(response) if isinstance(rows, dict): rows = rows["items"] if route == "messages" and len(rows) >= 5000: raise ValueError("message limit reached; narrow the owner filter before scanning") return rows rows = [] while True: query = urlencode(dict(filters, limit=200, offset=len(rows))) with urlopen(f"{base.rstrip('/')}/{route}/?{query}", timeout=30) as response: page = json.load(response) if isinstance(page, dict): page = page["items"] if not isinstance(page, list): raise ValueError(f"{route}: expected a list") rows.extend(page) if len(page) < 200: return rows def audit(records, hub_tasks): plans, tasks = records["WP"], records["T"] def terminal(token): if token in tasks: return tasks[token]["status"] in {"done", "cancel"} return records["WP"].get(token, {}).get("status") in {"finished", "archived"} plans = {k: v for k, v in plans.items() if v["status"] in OPEN and v["flavor"] != "residual"} waits = {k: v for k, v in tasks.items() if v["wp"] in plans and v["status"] == "wait"} by_id = {t["id"]: t for t in hub_tasks} drift = [] for tid, task in sorted(waits.items()): hub = by_id.get(task["hub_id"]) if hub is None: drift.append({"task": tid, "field": "projection", "file": task["hub_id"], "hub": None}) continue fields = {"status": "status", "needs_human": "needs_human", "reason": "blocking_reason", "decision_id": "decision_id", "blocked_on": "blocked_on", "wait_until": "wait_until"} for file_key, hub_key in fields.items(): expected = task[file_key] actual = hub.get(hub_key) if file_key == "needs_human": actual = bool(actual) elif file_key != "status": expected, actual = expected or None, actual or None if file_key == "wait_until": expected = str(expected)[:10] if expected else None actual = str(actual)[:10] if actual else None if expected != actual: drift.append({"task": tid, "field": hub_key, "file": expected, "hub": actual}) return { "open_non_residual_workplans": len(plans), "workplan_statuses": dict(sorted(Counter(p["status"] for p in plans.values()).items())), "wait_tasks": len(waits), "human_gates": sum(t["needs_human"] for t in waits.values()), "unrouted_human_gates": sorted(k for k, t in waits.items() if t["needs_human"] and not t["decision_id"]), "unqualified_waits": sorted(k for k, t in waits.items() if not any( t[field] for field in ("depends_on", "needs_human", "blocked_on", "wait_until"))), "owner_return_waits": sum(str(t["blocked_on"] or "").startswith("message-from:") for t in waits.values()), "terminal_dependency_waits": [ {"task": k, "depends_on": t["depends_on"], "needs_human": t["needs_human"], "blocked_on": t["blocked_on"], "wait_until": t["wait_until"]} for k, t in sorted(waits.items()) if t["depends_on"] and all(terminal(x) for x in t["depends_on"])], "qualifier_drift": drift, } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--api-base", default="http://127.0.0.1:8000") parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) repos = get_rows(args.api_base, "repos") (args.output / "repos.json").write_text(json.dumps(repos)) result = subprocess.run([sys.executable, str(SCANNER)], capture_output=True, text=True, env=dict(os.environ, TRIAGE_SCRATCH=str(args.output.resolve())), check=True) (args.output / "file-view.txt").write_text(result.stdout) records = json.loads((args.output / "files.json").read_text()) report = audit(records, get_rows(args.api_base, "tasks")) report["parse_warnings"] = records.get("parse_warnings", []) report["captured_at"] = datetime.now(timezone.utc).isoformat() report["registered_repos"] = len(repos) report["unavailable_checkouts"] = sorted(r["slug"] for r in repos if not r.get("local_path") or not Path(r["local_path"]).is_dir()) report["handoffs"] = [] for owner, message_id in HANDOFFS.items(): incoming = get_rows(args.api_base, "messages", to_agent="the-custodian", from_agent=owner) outgoing = get_rows(args.api_base, "messages", to_agent=owner, from_agent="the-custodian") original = next(m for m in outgoing if m["id"] == message_id) candidates = [m for m in incoming if m["from_agent"] == owner and m["created_at"] > original["created_at"]] report["handoffs"].append({ "case_id": f"case:cross-owner:CUST-WP-0075-T06:{owner}", "scenario_type": "cross_owner_wait", "beneficiary_workplan_id": "CUST-WP-0075", "obligor_repo": owner, "obligor_workplan_id": None, "state": "waiting", "reason": "Owner acceptance or decline not yet recorded", "proposal_message_id": message_id, "proposal_read_at": original.get("read_at"), "reply_candidates": [{"id": m["id"], "subject": m["subject"], "thread_id": m.get("thread_id")} for m in candidates], }) (args.output / "monitor.json").write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report, indent=2)) if __name__ == "__main__": main()