task-pipeline-skill 1.93.3 → 1.94.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +42 -9
  3. package/SKILL-CARD.md +1 -1
  4. package/bin/task-pipeline.js +64 -33
  5. package/cursor/rules/task-pipeline.mdc +10 -5
  6. package/evals/RESULTS.md +1 -1
  7. package/evals/task-pipeline.evals.json +16 -0
  8. package/package.json +3 -3
  9. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  10. package/plugins/task-pipeline/agents/verifier.md +4 -1
  11. package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
  12. package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
  13. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
  14. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
  15. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
  17. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  19. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +94 -25
  22. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
  23. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
  25. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
  27. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  31. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
  33. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +76 -36
  34. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
  35. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +54 -13
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +108 -11
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
  48. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -32,7 +32,9 @@ conditional on the code, never merely sequenced after it):
32
32
  1 a refusal — an invariant violated, a malformed verdict, a missing reason
33
33
  2 usage
34
34
  3 nothing left to do: every node is done
35
- 4 nothing runnable: what remains is blocked or parked
35
+ 4 nothing runnable: an unclassified, running or parked dependency remains
36
+ 5 external execution lease contention
37
+ 6 nothing runnable: all remaining dependency leaves are recorded watched waits
36
38
 
37
39
  **The mutation verbs never leave a half-written queue.** `add` and `park` write to a
38
40
  temp file in the same directory and `os.replace` it into place, so a crash mid-write
@@ -86,14 +88,24 @@ ROLES = {
86
88
  # change (`add`/`invalidate` with a revision), never an implicit unblock.
87
89
  TERMINAL = {"done", "parked"}
88
90
  NO_GRAPH = {"producer", "doctrine"}
89
- # One place, and the schema enumerates the same three. Two homes for this set is
90
- # what let `close` write a verb the format forbade.
91
- REVISION_VERBS = {"add", "park", "close", "invalidate", "waive", "wave"}
92
- # Parking a node PROMOTES its dependents: `parked` is terminal, so anything
93
- # blocked on it becomes runnable even though the payload it waited on never
94
- # arrived. That is deliberate — `can_continue_around` in the verdict is the
95
- # same idea — but it is a real consequence of parking a blocker rather than a
96
- # leaf, and it is written here because the frontier will not explain it.
91
+ STATUSES = {"pending", "running", "done", "blocked", "parked", "waiting"}
92
+ WAIT_KINDS = {"ci", "review", "peer", "release", "time", "lease"}
93
+ # The schema enumerates the same mutation verbs.
94
+ REVISION_VERBS = {"add", "park", "close", "invalidate", "waive", "wave", "risk", "wait", "resume"}
95
+
96
+
97
+ def wait_errors(record):
98
+ if not isinstance(record, dict) or set(record) != {"id", "kind", "watcher"}:
99
+ return ["waiting_on must contain exactly id, kind and watcher"]
100
+ out = []
101
+ if not isinstance(record['id'], str) or not re.fullmatch(r"[a-f0-9]{32}", record['id']):
102
+ out.append("waiting_on.id must be a 32-character hexadecimal wait identity")
103
+ if not isinstance(record['kind'], str) or record['kind'] not in WAIT_KINDS:
104
+ out.append("waiting_on.kind is not a supported external wait")
105
+ if (not isinstance(record['watcher'], str) or not record['watcher'].strip()
106
+ or any(c in record['watcher'] for c in "\r\n")):
107
+ out.append("waiting_on.watcher needs a nonblank single-line locator")
108
+ return out
97
109
 
98
110
 
99
111
  def die(msg, code=1):
@@ -207,6 +219,15 @@ def violations(graph):
207
219
  if not isinstance(nid, str) or not ID_SHAPE.match(nid):
208
220
  out.append(f"node id {nid!r} is not the shape `N-001` the schema requires — "
209
221
  "and an id nothing can parse is an id no verdict can cite")
222
+ status = n.get("status")
223
+ if not isinstance(status, str) or status not in STATUSES:
224
+ out.append(f"{nid}: unknown status {status!r}")
225
+ if status == "waiting":
226
+ out.extend(f"{nid}: {err}" for err in wait_errors(n.get("waiting_on")))
227
+ if n.get("claim") is not None:
228
+ out.append(f"{nid}: a waiting node cannot carry an execution claim")
229
+ elif "waiting_on" in n:
230
+ out.append(f"{nid}: waiting_on is only admitted on a waiting node")
210
231
  owner = n.get("owner")
211
232
  if owner not in ROLES:
212
233
  near = [r for r in sorted(ROLES) if r[:4] == (owner or "")[:4]]
@@ -276,6 +297,14 @@ def violations(graph):
276
297
  f"{', '.join(SURFACE_CLASSES)}. A class nobody recognises requires no "
277
298
  "visual reading, so a typo here would drop the tier that reads the pixels")
278
299
 
300
+ # The risk class (`references/iteration.md` → *Verification starts when a node
301
+ # lands*) sets how many certification rounds a node owes. A class outside the three
302
+ # would owe an unknown number, and the cheapest misreading is one round.
303
+ if "risk" in n and n["risk"] not in RISK_CLASSES:
304
+ out.append(f"{nid}: risk is {n['risk']!r} — it must be one of "
305
+ f"{', '.join(RISK_CLASSES)}; a class nobody recognises owes no known "
306
+ "number of rounds")
307
+
279
308
  # Wave mode (`references/waves.md`). `True` is an int in Python, and a wave of
280
309
  # `true` is a typo the ordering check below would read as wave 1.
281
310
  if "wave" in n and not _is_wave(n["wave"]):
@@ -344,6 +373,8 @@ def violations(graph):
344
373
  if not isinstance(r, dict):
345
374
  out.append(f"revisions[{i}] is not an object")
346
375
  continue
376
+ if "waiting_on" in r:
377
+ out.extend(f"revisions[{i}]: {err}" for err in wait_errors(r['waiting_on']))
347
378
  if r.get("verb") not in REVISION_VERBS:
348
379
  out.append(f"revisions[{i}] records verb {r.get('verb')!r}, which is not one of "
349
380
  f"{sorted(REVISION_VERBS)} — the schema enumerates them and "
@@ -459,7 +490,7 @@ def frontier(graph):
459
490
  by_id = {n.get("id"): n for n in nodes}
460
491
  ready = []
461
492
  for n in nodes:
462
- if n.get("status") in TERMINAL or n.get("status") == "running":
493
+ if n.get("status") not in ("pending", "blocked"):
463
494
  continue
464
495
  blockers = n.get("blocked_by") or []
465
496
  if all(by_id.get(b, {}).get("status") == "done" for b in blockers):
@@ -621,6 +652,45 @@ TIERS = ("unit", "seam", "product")
621
652
  VISUAL_TIER = "visual"
622
653
  ALL_TIERS = TIERS + (VISUAL_TIER,)
623
654
  SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
655
+ RISK_CLASSES = ("R1", "R2", "R3")
656
+ # Passing certification rounds a node owes before `close` accepts it, by risk class
657
+ # (`references/certification.md` → *Rounds owed by risk class*). R3's must include one
658
+ # round recorded `--kind adversarial`.
659
+ ROUNDS_OWED = {"R1": 1, "R2": 2, "R3": 3}
660
+ ROUND_KINDS = ("blind", "adversarial")
661
+
662
+
663
+ def passing_rounds(cert):
664
+ """The kinds of the rounds that PASSED — every tier of the round `pass` — in order.
665
+
666
+ Owed rounds are passing rounds. `certification.round` counts every round, failed ones
667
+ too, so reading it let a fail followed by a pass satisfy an R2 node (verification round 3
668
+ of the release iteration). Rounds recorded before `kinds` existed count as `blind`."""
669
+ cert = cert or {}
670
+ history = list(cert.get("history") or [])
671
+ kinds = list(cert.get("kinds") or [])
672
+ kinds += ["blind"] * (len(history) - len(kinds))
673
+ return [k for h, k in zip(history, kinds) if h and all(v == "pass" for v in h.values())]
674
+
675
+
676
+ def owed_shortfall(node):
677
+ """None when the node's class is satisfied, else a sentence saying what it still owes."""
678
+ owed = ROUNDS_OWED.get(node.get("risk"))
679
+ if not owed:
680
+ return None
681
+ passed = passing_rounds(node.get("certification"))
682
+ if len(passed) < owed:
683
+ return "%d of %d owed passing rounds" % (len(passed), owed)
684
+ if node.get("risk") == "R3" and "adversarial" not in passed:
685
+ cert = node.get("certification") or {}
686
+ kinds = list(cert.get("kinds") or [])
687
+ failed_adv = any(k == "adversarial" and not all(v == "pass" for v in (h or {}).values())
688
+ for h, k in zip(cert.get("history") or [], kinds))
689
+ return ("%d of %d owed passing rounds, but none of them adversarial — %srun one that "
690
+ "passes with `--kind adversarial`"
691
+ % (len(passed), owed,
692
+ "a FAILED adversarial round does not count; " if failed_adv else ""))
693
+ return None
624
694
 
625
695
 
626
696
  def _is_wave(w):
@@ -800,6 +870,36 @@ def cmd_next(graph, args):
800
870
  f"alternative producer exists, change the edge explicitly",
801
871
  file=sys.stderr)
802
872
  if not ready:
873
+ # Follow dependencies outside the selected wave as well. A wait does not
874
+ # hide an independently running/parked prerequisite behind its own label.
875
+ memo = {}
876
+ def held_by_wait(n):
877
+ nid = n['id']
878
+ if nid in memo:
879
+ return memo[nid]
880
+ if n.get('status') not in ('pending', 'blocked', 'waiting'):
881
+ memo[nid] = False
882
+ return False
883
+ deps = [_by_id[b] for b in n.get('blocked_by') or []
884
+ if _by_id[b].get('status') != 'done']
885
+ memo[nid] = (all(held_by_wait(d) for d in deps)
886
+ if deps else n.get('status') == 'waiting')
887
+ return memo[nid]
888
+ unresolved = [n for n in nodes if n.get('status') not in TERMINAL]
889
+ if unresolved and all(held_by_wait(n) for n in unresolved):
890
+ # Show each reachable watched leaf once; never print a runnable row.
891
+ todo = list(unresolved); seen = set()
892
+ while todo:
893
+ n = todo.pop()
894
+ if n['id'] in seen:
895
+ continue
896
+ seen.add(n['id'])
897
+ if n.get('status') == 'waiting':
898
+ w = n['waiting_on']
899
+ print(f"waiting: {n['id']} {w['kind']} {w['id']} {w['watcher']}", file=sys.stderr)
900
+ todo.extend(_by_id[b] for b in n.get('blocked_by') or []
901
+ if _by_id[b].get('status') != 'done')
902
+ return 6
803
903
  return 4
804
904
  # The frontier and nothing else. This is the line that enters a context on
805
905
  # every iteration of every loop, so every word here is paid for repeatedly.
@@ -882,6 +982,11 @@ def cmd_recover(graph, args):
882
982
  """External mode: reclaim an EXPIRED node for a new owner, minting a higher
883
983
  fence. A still-live claim is not recoverable (that is stealing a working
884
984
  node); this exits 5 while the lease holds."""
985
+ if violations(graph):
986
+ die("graph does not validate — run `validate` first", 1)
987
+ node = next((n for n in graph.get('nodes') or [] if n.get('id') == args.node), None)
988
+ if node is not None and node.get('status') == 'waiting':
989
+ die("node is waiting — resolve its recorded wait before recovery", 4)
885
990
  ea = _load_authority(args)
886
991
  try:
887
992
  auth = ea.authority_for(args.authority)
@@ -990,7 +1095,7 @@ def cmd_invalidate(graph, args):
990
1095
  A proof is a claim about a tree; when an upstream contract moves, every
991
1096
  descendant that was certified against the old contract is certified against
992
1097
  a tree that no longer exists. So `invalidate` records a superseding revision
993
- on the changed node and, for the node itself and each `done` DESCENDANT,
1098
+ on the changed node and, for the node itself and each non-parked DESCENDANT,
994
1099
  resets it to `pending`, clears its evidence / proof / certification, and
995
1100
  records why. Nodes NOT downstream of the change keep their proof untouched —
996
1101
  an invalidation that reached the whole graph would be a reason nobody runs
@@ -1013,6 +1118,9 @@ def cmd_invalidate(graph, args):
1013
1118
  if node.get("status") == "parked":
1014
1119
  continue # a parked node stays parked; its reason still stands
1015
1120
  had_proof = node.get("status") == "done" or node.get("proof") or node.get("certification")
1121
+ prior_wait = node.pop('waiting_on', None)
1122
+ if prior_wait is not None:
1123
+ revise(graph, 'invalidate', aid, why, waiting_on=prior_wait)
1016
1124
  node["status"] = "pending"
1017
1125
  node["evidence"] = None
1018
1126
  node["proof"] = None
@@ -1053,8 +1161,9 @@ class held:
1053
1161
  at once. `flock` releases when the process dies, which is why it is preferred over a
1054
1162
  lock FILE somebody has to clean up after a crash.
1055
1163
 
1056
- Where `fcntl` does not exist the mutation still runs and the run is TOLD it ran
1057
- unlocked — a silent downgrade is how a queue loses a node and nobody learns why.
1164
+ A missing or failed lock refuses before reading or writing the graph. An atomic
1165
+ replacement alone cannot prevent concurrent writers from losing an update.
1166
+ Read-only verbs remain available where this platform has no `fcntl` support.
1058
1167
  """
1059
1168
 
1060
1169
  def __init__(self, path):
@@ -1065,9 +1174,8 @@ class held:
1065
1174
  try:
1066
1175
  import fcntl
1067
1176
  except ImportError:
1068
- print("note: no file locking on this platform — a concurrent mutation of "
1069
- "%s could lose an update" % self.path, file=sys.stderr)
1070
- return self
1177
+ die("graph mutation needs file locking on this platform — "
1178
+ "nothing was written: %s" % self.path)
1071
1179
  d = os.path.dirname(self.path) or "."
1072
1180
  try:
1073
1181
  self.fh = open(os.path.join(d, ".graph.lock"), "a+")
@@ -1076,8 +1184,7 @@ class held:
1076
1184
  if self.fh:
1077
1185
  self.fh.close()
1078
1186
  self.fh = None
1079
- print("note: could not take the graph lock (%s) — proceeding unlocked" % e,
1080
- file=sys.stderr)
1187
+ die("could not take the graph lock (%s) — nothing was written" % e)
1081
1188
  return self
1082
1189
 
1083
1190
  def __exit__(self, *exc):
@@ -1086,7 +1193,7 @@ class held:
1086
1193
  return False
1087
1194
 
1088
1195
 
1089
- def revise(graph, verb, node, why, precondition=None):
1196
+ def revise(graph, verb, node, why, precondition=None, waiting_on=None):
1090
1197
  """Append the revision. Both verbs call it; neither may skip it.
1091
1198
 
1092
1199
  `park` demanded a reason from the start and `add` demanded nothing, so half the
@@ -1102,6 +1209,8 @@ def revise(graph, verb, node, why, precondition=None):
1102
1209
  entry = {"verb": verb, "node": node, "why": why}
1103
1210
  if precondition:
1104
1211
  entry["precondition"] = precondition
1212
+ if waiting_on is not None:
1213
+ entry["waiting_on"] = waiting_on
1105
1214
  graph.setdefault("revisions", []).append(entry)
1106
1215
 
1107
1216
 
@@ -1117,6 +1226,44 @@ def guard(graph, path):
1117
1226
  "these first:\n %s" % (path, "\n ".join(bad)))
1118
1227
 
1119
1228
 
1229
+ def cmd_wait(graph, args):
1230
+ """Record or resolve a graph-local wait; never run a watcher locator."""
1231
+ guard(graph, args.graph)
1232
+ why = args.why.strip()
1233
+ if not why or any(c in why for c in "\r\n"):
1234
+ die("wait/resume needs a nonblank single-line --why — nothing was written")
1235
+ node = next((n for n in graph['nodes'] if n['id'] == args.node), None)
1236
+ if node is None:
1237
+ die("no node %s — nothing was written" % args.node)
1238
+ if node.get('claim') is not None:
1239
+ die("node has a recorded execution claim — release it first; nothing was written")
1240
+ if args.verb == 'wait':
1241
+ if node['status'] not in ('pending', 'blocked'):
1242
+ die("only pending or blocked nodes can wait — nothing was written")
1243
+ import uuid
1244
+ record = {'id': uuid.uuid4().hex, 'kind': args.kind, 'watcher': args.watcher}
1245
+ bad = wait_errors(record)
1246
+ if bad:
1247
+ die('; '.join(bad) + ' — nothing was written')
1248
+ node['status'] = 'waiting'
1249
+ node['waiting_on'] = record
1250
+ else:
1251
+ if node['status'] != 'waiting':
1252
+ die("node is not waiting — nothing was written")
1253
+ record = node['waiting_on']
1254
+ if args.wait_id != record['id']:
1255
+ die("stale wait identity — nothing was written")
1256
+ node.pop('waiting_on')
1257
+ node['status'] = 'pending'
1258
+ revise(graph, args.verb, args.node, why, waiting_on=record)
1259
+ bad = violations(graph)
1260
+ if bad:
1261
+ die('wait transition would break the graph — nothing was written: ' + '; '.join(bad))
1262
+ save(args.graph, graph)
1263
+ print('%s %s: %s' % (args.node, args.verb, record['id']))
1264
+ return 0
1265
+
1266
+
1120
1267
  def cmd_park(graph, args):
1121
1268
  """Park one node, carrying the reason — REQ-012.
1122
1269
 
@@ -1147,7 +1294,7 @@ def cmd_park(graph, args):
1147
1294
 
1148
1295
  node["status"] = "parked"
1149
1296
  node["parked_reason"] = reason
1150
- revise(graph, "park", args.node, reason)
1297
+ revise(graph, "park", args.node, reason, waiting_on=node.pop('waiting_on', None))
1151
1298
  bad = violations(graph)
1152
1299
  if bad:
1153
1300
  die("parking %s would break the graph — nothing was written:\n %s"
@@ -1433,7 +1580,7 @@ def cmd_producer(graph, args):
1433
1580
  def cmd_doctrine(graph, args):
1434
1581
  """Which doctrine this run actually read — B-061.
1435
1582
 
1436
- The bundle is 40 reference files. A run reads some subset and nothing recorded which,
1583
+ The bundle is 41 reference files. A run reads some subset and nothing recorded which,
1437
1584
  so **a skipped file and a read one were indistinguishable** — the class every guard in
1438
1585
  this repository exists to catch, left standing over the doctrine itself.
1439
1586
 
@@ -1526,6 +1673,8 @@ def cmd_certify(graph, args):
1526
1673
  node = by_id.get(nid)
1527
1674
  if node is None:
1528
1675
  die("no node %s in this graph — nothing was written" % nid)
1676
+ if node.get("status") == "waiting":
1677
+ die("node is waiting — resolve its recorded wait before certification/close; nothing was written")
1529
1678
  if node.get("status") in TERMINAL:
1530
1679
  die("%s is already %s — certifying it again would overwrite the record of the "
1531
1680
  "close that already happened" % (nid, node.get("status")))
@@ -1597,11 +1746,13 @@ def cmd_certify(graph, args):
1597
1746
  round_no = int(prior.get("round") or 0) + 1
1598
1747
  tiers_now = {x: reports[x]["verdict"] for x in tiers}
1599
1748
  history = list(prior.get("history") or []) + [tiers_now]
1749
+ kinds = list(prior.get("kinds") or ["blind"] * (round_no - 1)) + [args.kind]
1600
1750
  node["certification"] = {
1601
1751
  "round": round_no,
1602
1752
  "tiers": tiers_now,
1603
1753
  "at": head or "unavailable — not inside a git checkout",
1604
1754
  "history": history,
1755
+ "kinds": kinds,
1605
1756
  }
1606
1757
 
1607
1758
  failed = [x for x in tiers if tiers_now[x] == "fail"]
@@ -1692,6 +1843,16 @@ def cmd_certify(graph, args):
1692
1843
  fh.write("\n")
1693
1844
  os.replace(tmp, out)
1694
1845
  print("%s: certified at %s in round %d" % (nid, ", ".join(tiers), round_no))
1846
+ owed = ROUNDS_OWED.get(node.get("risk"))
1847
+ short = owed_shortfall(node)
1848
+ if short:
1849
+ print("round %d: %s — not closable yet. The next round reads the node's whole diff at "
1850
+ "the new HEAD (references/certification.md → *Rounds owed by risk class*)."
1851
+ % (round_no, short))
1852
+ print("verdict written to %s" % out)
1853
+ return 0
1854
+ if owed:
1855
+ print("%d of %d owed passing rounds — closable." % (len(passing_rounds(node["certification"])), owed))
1695
1856
  print("verdict written to %s — close it with:" % out)
1696
1857
  print(" graph.py close --verdict %s" % out)
1697
1858
  return 0
@@ -1733,6 +1894,8 @@ def cmd_close(graph, args):
1733
1894
  node = by_id.get(nid)
1734
1895
  if node is None:
1735
1896
  die("no node %s in this graph — nothing was written" % nid)
1897
+ if node.get("status") == "waiting":
1898
+ die("node is waiting — resolve its recorded wait before certification/close; nothing was written")
1736
1899
  if node.get("status") in TERMINAL:
1737
1900
  die("%s is already %s — a second close would overwrite the record of the first"
1738
1901
  % (nid, node.get("status")))
@@ -1794,6 +1957,14 @@ def cmd_close(graph, args):
1794
1957
  # verifier's judgement and close proceeds as before.
1795
1958
  cert = node.get("certification")
1796
1959
  exc = node.get("exception")
1960
+ # Rounds owed by risk class (`references/certification.md`). Enforced here, in the
1961
+ # mutation, for the reason the failing-tier gate below is: a hand-shaped verdict must
1962
+ # not step around it. An authorized exception (`waive`) stays the only way past.
1963
+ short = owed_shortfall(node)
1964
+ if short and not exc:
1965
+ die("%s is %s: %s — not closable yet. Run the next round with `graph.py certify`, "
1966
+ "or record an authorized exception with `waive`. Nothing was written."
1967
+ % (nid, node["risk"], short))
1797
1968
  if cert:
1798
1969
  tiers = cert.get("tiers") or {}
1799
1970
  failing = sorted(k for k, val in tiers.items() if val != "pass")
@@ -1899,6 +2070,8 @@ VERBS = {
1899
2070
  "coverage": (cmd_coverage, "every requirement and the nodes serving it; exits 1 on a gap"),
1900
2071
  "add": (cmd_add, "add a node mid-run"),
1901
2072
  "park": (cmd_park, "park a node, carrying the reason"),
2073
+ "wait": (cmd_wait, "record a watched external wait, without executing its locator"),
2074
+ "resume": (cmd_wait, "resolve the exact wait identity and return to pending"),
1902
2075
  "wave": (cmd_wave, "move a node to another wave, carrying the reason"),
1903
2076
  "certify": (cmd_certify, "require three independent tier reports (four on a "
1904
2077
  "flagship or product surface), then emit the verdict "
@@ -1951,6 +2124,9 @@ def main(argv=None):
1951
2124
  help="path to one tier report; pass three times, one per tier")
1952
2125
  p_cert.add_argument("--verdict-out", dest="verdict_out", default=None,
1953
2126
  help="where to write the assembled verdict (default: beside the graph)")
2127
+ p_cert.add_argument("--kind", choices=ROUND_KINDS, default="blind",
2128
+ help="the round's kind; R3 owes one `adversarial` round "
2129
+ "(references/certification.md → *Rounds owed by risk class*)")
1954
2130
  p_cert.add_argument("--ceiling", type=int, default=3,
1955
2131
  help="rounds after which the output names the churning tier; it "
1956
2132
  "measures rather than stops (references/loop-guard.md)")
@@ -1996,11 +2172,18 @@ def main(argv=None):
1996
2172
  # that was given from a reason that was written, and only the second is REQ-012.
1997
2173
  p_park.add_argument("--reason", required=True)
1998
2174
 
2175
+ for verb in ('wait', 'resume'):
2176
+ made[verb].add_argument('node')
2177
+ made[verb].add_argument('--why', required=True)
2178
+ made['wait'].add_argument('--kind', required=True, choices=sorted(WAIT_KINDS))
2179
+ made['wait'].add_argument('--watcher', required=True)
2180
+ made['resume'].add_argument('--wait-id', required=True)
2181
+
1999
2182
  args = ap.parse_args(argv)
2000
2183
  verbs = {k: v[0] for k, v in VERBS.items()}
2001
2184
  if args.verb in NO_GRAPH:
2002
2185
  return verbs[args.verb](None, args)
2003
- if args.verb in ("add", "park", "wave", "close", "certify", "invalidate", "waive"):
2186
+ if args.verb in ("add", "park", "wave", "close", "certify", "invalidate", "waive", "wait", "resume"):
2004
2187
  # The READ happens inside the lock too. Loading first and locking second is the
2005
2188
  # same lost update with an extra step: the stale copy is already in memory.
2006
2189
  with held(args.graph):