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.
- package/CHANGELOG.md +105 -0
- package/README.md +42 -9
- package/SKILL-CARD.md +1 -1
- package/bin/task-pipeline.js +64 -33
- package/cursor/rules/task-pipeline.mdc +10 -5
- package/evals/RESULTS.md +1 -1
- package/evals/task-pipeline.evals.json +16 -0
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +4 -1
- package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
- package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +94 -25
- package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +76 -36
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +54 -13
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
- package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +108 -11
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
- 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:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
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
|
|
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):
|