diff --git a/docs/evidence/2026-10-03-dependency-graph-triage/README.md b/docs/evidence/2026-10-03-dependency-graph-triage/README.md
index 76a7827a..74ad1c52 100644
--- a/docs/evidence/2026-10-03-dependency-graph-triage/README.md
+++ b/docs/evidence/2026-10-03-dependency-graph-triage/README.md
@@ -25,3 +25,39 @@ Run with `TRIAGE_SCRATCH=
` holding `repos.json`, `wps.json`, `waits.json`,
- Top roots by transitive fan-in: `SAND-WP-0015-T04` (7 workplans),
`SECRETS-WP-0009-T03` (5), `GLAS-WP-0012` (4), `NK-WP-0040` (4),
`FEP-WP-0002-T03` (4, a `todo` — not blocked at all), `RAPPS-WP-0014-T03` (3).
+
+## Repeatable monitor (CUST-WP-0075-T05)
+
+From the Custodian checkout, with Python and PyYAML installed:
+
+```bash
+python3 scripts/monitor_dependency_triage.py --output /tmp/dependency-triage
+```
+
+The monitor makes GET requests only. It captures the repository registry,
+scans available owner files, compares every open non-residual wait's status
+and qualifiers with its hub projection, and records the three T06 handoffs.
+`monitor.json` contains counts and actionable findings; `file-view.txt` contains
+the graph analysis. Raw registry and file indexes stay in the scratch directory.
+No schedule, worker injection, owner-file change, or decision resolution is enabled.
+
+`unavailable_checkouts` and `parse_warnings` make coverage limits explicit.
+The scanner now reads YAML lists and quoted text correctly; historic task
+fences containing prose use a reported fallback. The detailed file view includes
+residuals for observation; the monitor's actionable counts exclude them.
+Terminal predecessors are candidates for review: independent human and owner
+gates remain binding. Replies newer than a proposal are candidates, not evidence
+of acceptance; reviewed owner workplan IDs or declines still belong in T06.
+
+The refreshed snapshot is [monitor-2026-10-03.json](monitor-2026-10-03.json).
+It supersedes the earlier headline counts for the visible checkouts. Four new
+unrouted human gates, sixteen unqualified waits, and twelve qualifier differences
+need follow-up through T05; the earlier T02/T03 completion records are historical.
+Six registered checkouts were unavailable. The sixteen degraded parses are in
+historic Vergabe workplans. These results do not establish fleet-wide completeness.
+
+Focused regression checks:
+
+```bash
+python3 -m unittest discover -s tests -p test_dependency_triage.py
+```
diff --git a/docs/evidence/2026-10-03-dependency-graph-triage/monitor-2026-10-03.json b/docs/evidence/2026-10-03-dependency-graph-triage/monitor-2026-10-03.json
new file mode 100644
index 00000000..23d1a8d9
--- /dev/null
+++ b/docs/evidence/2026-10-03-dependency-graph-triage/monitor-2026-10-03.json
@@ -0,0 +1,225 @@
+{
+ "open_non_residual_workplans": 100,
+ "workplan_statuses": {
+ "active": 1,
+ "backlog": 5,
+ "blocked": 94
+ },
+ "wait_tasks": 232,
+ "human_gates": 79,
+ "unrouted_human_gates": [
+ "FEP-WP-0009-T01",
+ "INFD-WP-0005-T05",
+ "INFD-WP-0005-T10",
+ "RPF-WP-0035-T08"
+ ],
+ "unqualified_waits": [
+ "EMERGENT-WP-0002-T02",
+ "EMERGENT-WP-0002-T05",
+ "EMERGENT-WP-0002-T07",
+ "EMERGENT-WP-0002-T08",
+ "EMERGENT-WP-0002-T09",
+ "EMERGENT-WP-0002-T10",
+ "NK-WP-0011-T02",
+ "NK-WP-0011-T03",
+ "NK-WP-0011-T04",
+ "NK-WP-0011-T05",
+ "NK-WP-0011-T06",
+ "NK-WP-0011-T07",
+ "NK-WP-0011-T08",
+ "NK-WP-0011-T09",
+ "QONTO-WP-0006-T02",
+ "QONTO-WP-0006-T03"
+ ],
+ "owner_return_waits": 56,
+ "terminal_dependency_waits": [
+ {
+ "task": "ACTIVITY-WP-0042-T05",
+ "depends_on": [
+ "ACTIVITY-WP-0042-T01"
+ ],
+ "needs_human": false,
+ "blocked_on": null,
+ "wait_until": null
+ },
+ {
+ "task": "ACTIVITY-WP-0042-T08",
+ "depends_on": [
+ "RPF-WP-0049-T05"
+ ],
+ "needs_human": false,
+ "blocked_on": null,
+ "wait_until": null
+ },
+ {
+ "task": "INFD-WP-0005-T05",
+ "depends_on": [
+ "INFD-WP-0005-T04"
+ ],
+ "needs_human": true,
+ "blocked_on": "message-from:railiance-platform",
+ "wait_until": null
+ },
+ {
+ "task": "USER-WP-0037-T03",
+ "depends_on": [
+ "USER-WP-0037-T02"
+ ],
+ "needs_human": false,
+ "blocked_on": "message-from:state-hub",
+ "wait_until": null
+ }
+ ],
+ "qualifier_drift": [
+ {
+ "task": "ACTIVITY-WP-0042-T08",
+ "field": "blocking_reason",
+ "file": "Provider prerequisite verified at lane KV4; scoped consumer bundle/hostPath and env refresh remain unperformed. Exact primary projection also blocked by existing historical identifier compatibility in repo-manager.",
+ "hub": "Provider repair verified: lane KV4 coordinates-only CAS, encrypted upload/readback and independent decryption. Remaining reviewed consumer source/env refresh and acceptance unperformed; exact primary projection blocked by existing repo-manager historical identifier compatibility."
+ },
+ {
+ "task": "ADAPTIVE-WP-0010-T01",
+ "field": "blocked_on",
+ "file": "message-from:tenant-engine",
+ "hub": null
+ },
+ {
+ "task": "FI-WP-0005-T04",
+ "field": "blocked_on",
+ "file": "message-from:activity-core",
+ "hub": null
+ },
+ {
+ "task": "FI-WP-0005-T08",
+ "field": "blocked_on",
+ "file": "message-from:rein-aharness",
+ "hub": null
+ },
+ {
+ "task": "INFO-WP-0029-T05",
+ "field": "blocked_on",
+ "file": "message-from:audit-core",
+ "hub": null
+ },
+ {
+ "task": "KG-WP-0005-T03",
+ "field": "blocked_on",
+ "file": "message-from:qonto-assistant",
+ "hub": null
+ },
+ {
+ "task": "NK-WP-0027-T06",
+ "field": "blocked_on",
+ "file": "message-from:info-tech-canon",
+ "hub": "message-from:ops-warden"
+ },
+ {
+ "task": "NK-WP-0035-T04",
+ "field": "blocked_on",
+ "file": "message-from:approval-engine",
+ "hub": "message-from:info-tech-canon"
+ },
+ {
+ "task": "RAPP-QONTO-WP-0002-T01",
+ "field": "wait_until",
+ "file": "2026-10-31",
+ "hub": null
+ },
+ {
+ "task": "RAPP-TELEMETRY-WP-0001-T04",
+ "field": "blocked_on",
+ "file": "message-from:railiance-platform",
+ "hub": null
+ },
+ {
+ "task": "REINAH-WP-0003-T04",
+ "field": "blocked_on",
+ "file": "message-from:activity-core",
+ "hub": null
+ },
+ {
+ "task": "SAND-WP-0015-T04",
+ "field": "blocked_on",
+ "file": "message-from:railiance-platform",
+ "hub": null
+ }
+ ],
+ "parse_warnings": [
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0001-projektgeruest.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0002-fachmodelle.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0003-basis-ui.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0005-lose-anforderungen.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0006-aufgaben-bieterfragen.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0007-dokumente.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0008-preise.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0009-abgabe-nachbetrachtung.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0010-partner-bibliothek.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0011-marktbegleiter.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0012-querschnitt.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0013-feedback-bugs.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0014-aufgaben-lose-scores.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0015-aufgaben-verknuepfungen-frist-issuefacade.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0016-issue-facade-integration.md",
+ "/home/worsch/vergabe-teilnahme/workplans/VT-WP-0017-whynot-design-tokens.md"
+ ],
+ "captured_at": "2026-10-03T21:08:51.190098+00:00",
+ "registered_repos": 146,
+ "unavailable_checkouts": [
+ "access-engine",
+ "inter-hub",
+ "markitect-project",
+ "railiance-bootstrap",
+ "railiance-hosts",
+ "repo-manager"
+ ],
+ "handoffs": [
+ {
+ "case_id": "case:cross-owner:CUST-WP-0075-T06:state-hub",
+ "scenario_type": "cross_owner_wait",
+ "beneficiary_workplan_id": "CUST-WP-0075",
+ "obligor_repo": "state-hub",
+ "obligor_workplan_id": null,
+ "state": "waiting",
+ "reason": "Owner acceptance or decline not yet recorded",
+ "proposal_message_id": "b4825539-1425-4028-a2e9-9243785a344b",
+ "proposal_read_at": null,
+ "reply_candidates": []
+ },
+ {
+ "case_id": "case:cross-owner:CUST-WP-0075-T06:railiance-platform",
+ "scenario_type": "cross_owner_wait",
+ "beneficiary_workplan_id": "CUST-WP-0075",
+ "obligor_repo": "railiance-platform",
+ "obligor_workplan_id": null,
+ "state": "waiting",
+ "reason": "Owner acceptance or decline not yet recorded",
+ "proposal_message_id": "f84637fa-2355-4971-a2dc-7f1fd01710a8",
+ "proposal_read_at": null,
+ "reply_candidates": [
+ {
+ "id": "84cb5557-cc89-43fa-b8b8-13508c5ea52b",
+ "subject": "Re: D1 readiness: two lanes need a reviewed command before they can be approved",
+ "thread_id": "208c18ec-79fe-4b21-8cc8-928ecccfd4c2"
+ }
+ ]
+ },
+ {
+ "case_id": "case:cross-owner:CUST-WP-0075-T06:ops-warden",
+ "scenario_type": "cross_owner_wait",
+ "beneficiary_workplan_id": "CUST-WP-0075",
+ "obligor_repo": "ops-warden",
+ "obligor_workplan_id": null,
+ "state": "waiting",
+ "reason": "Owner acceptance or decline not yet recorded",
+ "proposal_message_id": "79549660-4fc8-44d5-989e-074f6c7b6f07",
+ "proposal_read_at": null,
+ "reply_candidates": [
+ {
+ "id": "94d07ae0-a2a4-47f8-8bf7-9957aaf2a84f",
+ "subject": "Re: BUG: warden secret-fetch policy check is sent to the SSH signing pin and always 403s for non-ops-warden owners",
+ "thread_id": "faf11061-b490-435a-b472-cb315a459760"
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py b/docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py
index da5a8cbf..6bc4a0b6 100644
--- a/docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py
+++ b/docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py
@@ -1,15 +1,31 @@
import json,re,os,glob,collections,datetime,sys
+import yaml
import os; S=os.environ.get('TRIAGE_SCRATCH','.')
repos=json.load(open(f'{S}/repos.json')); repos=repos if isinstance(repos,list) else repos['items']
FM=re.compile(r'^---\n(.*?)\n---',re.S); TB=re.compile(r'```task\n(.*?)```',re.S)
+parse_warnings=[]
def kv(block):
- d={}
- for line in block.splitlines():
- m=re.match(r'^([A-Za-z_]+):\s*(.*?)\s*(#.*)?$',line)
- if m and not line.startswith(' ') : d[m.group(1)]=m.group(2).strip().strip('"')
+ try:
+ d=yaml.safe_load(block)
+ except yaml.YAMLError:
+ # Historic records mix prose into task fences. Preserve their fields,
+ # but expose the degraded parse rather than silently certify the scan.
+ parse_warnings.append(f)
+ d={}
+ for match in re.finditer(r'^([A-Za-z_]+):\s*([^\n]*)$',block,re.M):
+ key,value=match.groups()
+ if not value.strip():
+ tail=block[match.end():]
+ items=re.match(r'\n((?:[ \t]+-[ \t]+[^\n]*\n?)+)',tail)
+ value='\n'+items.group(1) if items else ''
+ try: d[key]=yaml.safe_load(value)
+ except yaml.YAMLError: d[key]=value.strip().strip('"')
+ if not isinstance(d,dict): raise ValueError('work record must be a YAML mapping')
return d
def lst(v):
if not v: return []
+ if isinstance(v,list): return [str(x) for x in v]
+ v=str(v)
v=v.strip()
if v.startswith('['): return [x.strip().strip('"\'') for x in v[1:-1].split(',') if x.strip()]
return [v]
@@ -30,8 +46,8 @@ for r in repos:
for tb in TB.findall(txt):
d=kv(tb); tid=d.get('id')
if not tid: continue
- T[tid]={'wp':wid,'repo':r['slug'],'status':d.get('status'),'depends_on':lst(d.get('depends_on')),'needs_human':d.get('needs_human','').lower()=='true','blocked_on':d.get('blocked_on') or None,'wait_until':d.get('wait_until') or None,'decision_id':d.get('decision_id') or None,'reason':d.get('blocking_reason') or ''}
-json.dump({'WP':WP,'T':T},open(f'{S}/files.json','w'))
+ T[tid]={'wp':wid,'repo':r['slug'],'status':d.get('status'),'depends_on':lst(d.get('depends_on')),'needs_human':str(d.get('needs_human','')).lower()=='true','blocked_on':d.get('blocked_on') or None,'wait_until':str(d['wait_until']) if d.get('wait_until') else None,'decision_id':d.get('decision_id') or None,'reason':d.get('blocking_reason') or '', 'hub_id':d.get('state_hub_task_id')}
+json.dump({'WP':WP,'T':T,'parse_warnings':sorted(set(parse_warnings))},open(f'{S}/files.json','w'))
TW={'finished','archived'}; TT={'done','cancel'}
def term(x):
if x in T: return T[x]['status'] in TT
@@ -100,7 +116,7 @@ for n in nodes:
if any(not term(b) for b in dep.get(n,[])): continue
f=fanin(n)
if f: rows.append((len(f),n,sorted(f)))
-rows.sort(key=lambda r:-r[0])
+rows.sort(key=lambda r:(-r[0],r[1]))
for n,k,f in rows[:45]:
t=T.get(k);
desc=(f"wait h={t['needs_human']} on={t['blocked_on']} dec={t['decision_id']} | {t['reason'][:70]}" if t and t['status']=='wait' else (f"{t['status']}" if t else f"WP {WP[k]['status']} {WP[k]['repo']}" if k in WP else 'UNKNOWN'))
diff --git a/scripts/monitor_dependency_triage.py b/scripts/monitor_dependency_triage.py
new file mode 100644
index 00000000..21121992
--- /dev/null
+++ b/scripts/monitor_dependency_triage.py
@@ -0,0 +1,140 @@
+#!/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()
diff --git a/tests/test_dependency_triage.py b/tests/test_dependency_triage.py
new file mode 100644
index 00000000..b51f7253
--- /dev/null
+++ b/tests/test_dependency_triage.py
@@ -0,0 +1,98 @@
+import importlib.util
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch, MagicMock
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("monitor", ROOT / "scripts/monitor_dependency_triage.py")
+monitor = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(monitor)
+
+
+class DependencyTriageTests(unittest.TestCase):
+ def test_yaml_task_lists_and_quoted_hash_are_preserved(self):
+ with tempfile.TemporaryDirectory() as directory:
+ scratch = Path(directory)
+ repo = scratch / "repo"
+ (repo / "workplans").mkdir(parents=True)
+ (scratch / "repos.json").write_text(json.dumps([{"slug": "example", "local_path": str(repo)}]))
+ (repo / "workplans/plan.md").write_text('''---
+id: EX-WP-0001
+type: workplan
+status: blocked
+flavor: implementation
+---
+```task
+id: EX-WP-0001-T01
+status: wait
+depends_on:
+ - EX-WP-0002-T01
+needs_human: true
+blocking_reason: "Review packet #1"
+wait_until: 2026-10-04
+```
+''')
+ subprocess.run([sys.executable, str(monitor.SCANNER)], check=True, capture_output=True,
+ env=dict(os.environ, TRIAGE_SCRATCH=directory))
+ task = json.loads((scratch / "files.json").read_text())["T"]["EX-WP-0001-T01"]
+ self.assertEqual(task["depends_on"], ["EX-WP-0002-T01"])
+ self.assertEqual(task["reason"], "Review packet #1")
+ self.assertTrue(task["needs_human"])
+ self.assertEqual(task["wait_until"], "2026-10-04")
+
+ def records(self):
+ task = {"wp": "plan", "status": "wait", "hub_id": "uuid", "needs_human": True,
+ "reason": "Review", "decision_id": "decision", "blocked_on": None,
+ "wait_until": None, "depends_on": []}
+ return {"WP": {"plan": {"status": "blocked", "flavor": "implementation"}}, "T": {"task": task}}
+
+ def test_missing_projection_is_visible(self):
+ result = monitor.audit(self.records(), [])
+ self.assertEqual(result["qualifier_drift"][0]["field"], "projection")
+
+ def test_decision_sync_gap_is_detected(self):
+ result = monitor.audit(self.records(), [{"id": "uuid", "status": "wait",
+ "needs_human": True, "blocking_reason": "Review"}])
+ self.assertEqual(result["qualifier_drift"], [{"task": "task", "field": "decision_id",
+ "file": "decision", "hub": None}])
+ self.assertEqual(result["unrouted_human_gates"], [])
+
+ def test_residuals_are_excluded_from_actionable_findings(self):
+ records = self.records()
+ records["WP"]["plan"]["flavor"] = "residual"
+ result = monitor.audit(records, [])
+ self.assertEqual(result["wait_tasks"], 0)
+ self.assertEqual(result["qualifier_drift"], [])
+
+ def test_terminal_predecessor_keeps_separate_human_gate(self):
+ records = self.records()
+ records["T"]["task"]["depends_on"] = ["predecessor"]
+ records["T"]["predecessor"] = {"wp": "plan", "status": "done"}
+ result = monitor.audit(records, [])
+ self.assertTrue(result["terminal_dependency_waits"][0]["needs_human"])
+ self.assertEqual(result["human_gates"], 1)
+
+ def test_tasks_follow_offset_pagination(self):
+ response = MagicMock()
+ with patch.object(monitor, "urlopen", return_value=response) as fetch, patch.object(
+ monitor.json, "load", side_effect=[[{"id": str(i)} for i in range(200)], [{"id": "last"}]]):
+ rows = monitor.get_rows("http://hub", "tasks")
+ self.assertEqual(len(rows), 201)
+ self.assertIn("offset=200", fetch.call_args_list[1].args[0])
+
+ def test_messages_do_not_use_unsupported_offset(self):
+ with patch.object(monitor, "urlopen", return_value=MagicMock()) as fetch, patch.object(
+ monitor.json, "load", return_value=[{"id": str(i)} for i in range(200)]):
+ rows = monitor.get_rows("http://hub", "messages", from_agent="owner")
+ self.assertEqual(len(rows), 200)
+ self.assertEqual(fetch.call_count, 1)
+ self.assertNotIn("offset", fetch.call_args.args[0])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md b/workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md
index 8e874790..79b466a8 100644
--- a/workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md
+++ b/workplans/CUST-WP-0075-dependency-graph-driven-unblocking.md
@@ -126,6 +126,16 @@ After every slice: commit, `statehub fix-consistency` for each touched
repo, one progress event on this workplan, and the headline numbers
appended below so progress is visible from the hub.
+2026-10-03 Codex continuation: repeatable read-only monitor implemented in
+`scripts/monitor_dependency_triage.py`, with seven regression checks covering
+YAML lists/quoting, pagination, missing projections, decision drift, residual
+exclusion and independent human gates. Run it with `--output `;
+review `monitor.json` before acting. Current live findings are retained in
+`docs/evidence/2026-10-03-dependency-graph-triage/monitor-2026-10-03.json`:
+four unrouted human gates, sixteen unqualified waits and twelve qualifier
+differences. This task remains in progress until those findings are reconciled;
+the earlier T02/T03 results are point-in-time, not a current clean scan.
+
## Hand the infrastructure proposals to their owners
```task
@@ -147,6 +157,14 @@ qualifier sync gap, already reported to state-hub as message `70d08f35`).
the proposal and the evidence; record their workplan ids here. Do not
write their workplans for them.
+2026-10-03 Codex continuation: all three original proposal messages remain
+unread; no acceptance, owner workplan ID or decline found. Three local derived
+`cross_owner_wait` observations are attached under `handoffs` in the monitor
+snapshot, with stable case IDs and original message IDs. Obligor workplan IDs
+remain unknown pending owner identification. Newer platform/warden replies
+concern D1 readiness and the fetch-policy bug, respectively; neither answers
+these proposals. No worker was woken. T06 remains `wait`.
+
## Progress log
- 2026-10-03 start: 108 open / 100 blocked / 232 wait / 69 human gates /
@@ -193,4 +211,11 @@ write their workplans for them.
and two unread owner replies: GH-WP-0004-T06 (informed-decision) and
NK-WP-0042-T02 (user-engine). Follow-up on the CCR-2026-0031 lane: ops-warden
catalog entry plus fetch-pin regeneration (checklist row 6).
-
+- 2026-10-03 Codex continuation: monitored 146 registered repos; six checkouts
+ unavailable. Visible file view: 107 open plans including residuals; actionable
+ non-residual view: 100 open / 94 blocked / 232 wait / 79 human gates, four
+ unrouted; 56 owner returns. Twelve qualifier differences and sixteen
+ unqualified waits are recorded as T05 follow-up. Four waits have terminal
+ predecessors, but separate human/owner gates and task prose still need review.
+ T06's three proposals remain unanswered. The monitor and seven regression
+ checks are implemented; no fleet cleanup or proposal acceptance is claimed.