task-pipeline-skill 1.73.0 → 1.75.0

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.
@@ -515,6 +515,139 @@ def verdict_violations(v):
515
515
  return out
516
516
 
517
517
 
518
+ # --- certification: three tiers, one node ------------------------------------
519
+ #
520
+ # One verifier reads the diff it was handed. That is the whole limitation this
521
+ # section exists for: a change can be correct where it was made, and wrong one
522
+ # level out — a caller whose contract moved, a module whose invariant the new
523
+ # branch breaks, a documented behaviour nobody re-read. The single verdict cannot
524
+ # see any of it, because the context it was given was the change.
525
+ #
526
+ # So a node is closed by THREE reports at escalating visibility, produced
527
+ # independently and blind to each other:
528
+ #
529
+ # unit the code that changed — the functions, classes and branches in the
530
+ # diff, and the node's own `check`
531
+ # seam one level out — callers, callees, shared state, the contracts and
532
+ # tests of the neighbours the change can reach
533
+ # product one level out again — the documentation, the scenarios, how this
534
+ # behaviour interacts with the rest of the product
535
+ #
536
+ # **All three must pass, and blind is the point.** Three agents that read each
537
+ # other's reports are one opinion with three signatures; the disagreement is the
538
+ # instrument. `certify` refuses a report that cites another tier's verdict.
539
+ #
540
+ # **A tier cannot pass on an empty `scope`.** This is the rule the rest is built
541
+ # around: a report that names nothing it read is a rubber stamp, and a rubber
542
+ # stamp at three levels is worse than one verifier, because it costs three times
543
+ # as much and reads as three times the assurance.
544
+ TIERS = ("unit", "seam", "product")
545
+ TIER_KEYS = ("node", "tier", "verdict", "scope", "confirms", "findings",
546
+ "evidence", "not_examined")
547
+ TIER_VERDICTS = ("pass", "fail")
548
+ SEVERITIES = ("breaks", "risk")
549
+ # A tier report that quotes another tier's verdict was not written blind. Cheap
550
+ # to detect and worth detecting: the failure it prevents is three reports that
551
+ # agree because the second two read the first.
552
+ CROSS_TIER = re.compile(r"\b(?:unit|seam|product)\s+tier\s+(?:passed|failed|says)"
553
+ r"|\btier\s+\d\s+(?:passed|failed)"
554
+ r"|as\s+the\s+(?:unit|seam|product)\s+tier", re.I)
555
+
556
+
557
+ def tier_violations(t):
558
+ """Everything wrong with one tier report, in a stable order.
559
+
560
+ Same law as `verdict_violations`: the shape is checked rather than trusted,
561
+ and every refusal names the key, because a report rejected without naming its
562
+ fault is a report the next attempt reproduces.
563
+ """
564
+ out = []
565
+ if not isinstance(t, dict):
566
+ return ["tier report is not an object"]
567
+
568
+ for k in TIER_KEYS:
569
+ if k not in t:
570
+ out.append("tier report has no `%s` — all eight are required, because a "
571
+ "report that omits one is silent about it rather than clear" % k)
572
+ if out:
573
+ return out
574
+
575
+ if not isinstance(t["node"], str) or not t["node"].startswith(NODE_ID):
576
+ out.append("tier report `node` is %r, which is not a node id" % (t["node"],))
577
+ if t["tier"] not in TIERS:
578
+ out.append("tier report `tier` is %r — it must be one of %s"
579
+ % (t["tier"], ", ".join(TIERS)))
580
+ if t["verdict"] not in TIER_VERDICTS:
581
+ out.append("tier report `verdict` is %r — it must be `pass` or `fail`, because "
582
+ "a certification that admits a third state admits a maybe"
583
+ % (t["verdict"],))
584
+
585
+ for k in ("scope", "confirms", "findings", "evidence", "not_examined"):
586
+ if not isinstance(t[k], list):
587
+ out.append("tier report `%s` must be a list" % k)
588
+ if out:
589
+ return out
590
+
591
+ for k in ("scope", "confirms", "evidence", "not_examined"):
592
+ for i, e in enumerate(t[k]):
593
+ if not isinstance(e, str) or not e.strip():
594
+ out.append("tier report `%s[%d]` is %r — every entry must be a non-empty "
595
+ "string, and a list of blanks is the shape a script emitting "
596
+ "empty output produces" % (k, i, e))
597
+
598
+ findings = []
599
+ for i, f in enumerate(t["findings"]):
600
+ if not isinstance(f, dict):
601
+ out.append("tier report `findings[%d]` is not an object" % i)
602
+ continue
603
+ for k in ("what", "where", "severity"):
604
+ if not str(f.get(k, "")).strip():
605
+ out.append("tier report `findings[%d]` does not say `%s`" % (i, k))
606
+ sev = f.get("severity")
607
+ if sev is not None and sev not in SEVERITIES:
608
+ out.append("tier report `findings[%d].severity` is %r — it must be `breaks` "
609
+ "(the node is not done) or `risk` (found, judged survivable, and "
610
+ "named)" % (i, sev))
611
+ # A break has to say how its fix will be PROVEN, for the same reason
612
+ # `replan.add` does: the node it creates is one the next certification has
613
+ # to close, and handing it the absence is how the defect returns a round
614
+ # later.
615
+ if sev == "breaks" and not str(f.get("check", "")).strip():
616
+ out.append("tier report `findings[%d]` breaks the node and names no `check` "
617
+ "— the fix node it becomes has to say how IT will be closed" % i)
618
+ findings.append(f)
619
+
620
+ breaks = [f for f in findings if isinstance(f, dict) and f.get("severity") == "breaks"]
621
+ if t["verdict"] == "pass":
622
+ # The two rules that make a pass mean something.
623
+ if not t["scope"]:
624
+ out.append("tier report `%s` passes on an empty `scope` — a report that names "
625
+ "nothing it read is a rubber stamp, and three of those cost three "
626
+ "times one verifier and read as three times the assurance"
627
+ % t["tier"])
628
+ if not t["evidence"]:
629
+ out.append("tier report `%s` passes with empty `evidence` — the field exists "
630
+ "for exactly this" % t["tier"])
631
+ if breaks:
632
+ out.append("tier report `%s` passes while carrying %d finding(s) at severity "
633
+ "`breaks`: %s. Those two cannot both be true"
634
+ % (t["tier"], len(breaks),
635
+ "; ".join(str(f.get("what")) for f in breaks)))
636
+ elif t["verdict"] == "fail":
637
+ if not breaks:
638
+ out.append("tier report `%s` fails and names no finding at severity `breaks` "
639
+ "— a fail that does not say what broke is a fail the next round "
640
+ "cannot act on" % t["tier"])
641
+
642
+ # Blind, and checked. Only the prose fields can carry it.
643
+ for k in ("confirms", "evidence", "not_examined"):
644
+ for i, e in enumerate(t[k]):
645
+ if isinstance(e, str) and CROSS_TIER.search(e):
646
+ out.append("tier report `%s[%d]` cites another tier's verdict (%r) — the "
647
+ "three run blind, because three reports that read each other "
648
+ "are one opinion with three signatures" % (k, i, e.strip()[:70]))
649
+ return out
650
+
518
651
  # --- verbs --------------------------------------------------------------------
519
652
 
520
653
  def cmd_validate(graph, args):
@@ -909,7 +1042,7 @@ def cmd_producer(graph, args):
909
1042
  def cmd_doctrine(graph, args):
910
1043
  """Which doctrine this run actually read — B-061.
911
1044
 
912
- The bundle is 35 reference files. A run reads some subset and nothing recorded which,
1045
+ The bundle is 36 reference files. A run reads some subset and nothing recorded which,
913
1046
  so **a skipped file and a read one were indistinguishable** — the class every guard in
914
1047
  this repository exists to catch, left standing over the doctrine itself.
915
1048
 
@@ -977,6 +1110,170 @@ def cmd_doctrine(graph, args):
977
1110
  return 0
978
1111
 
979
1112
 
1113
+ def cmd_certify(graph, args):
1114
+ """Require three independent tier reports, then emit the verdict `close` consumes.
1115
+
1116
+ This is a gate in FRONT of `close`, not a replacement for it. `close`'s contract
1117
+ is unchanged and its seven keys are still the only thing that moves the graph —
1118
+ what changed is that the verdict is now assembled from three readings at
1119
+ different distances instead of written from one.
1120
+
1121
+ **The round is recorded whether it passes or fails.** A failing round that
1122
+ wrote nothing would erase the only evidence that a node is churning, which is
1123
+ the number the ceiling below reads. The node stays `pending` on a failure; the
1124
+ round count is the trail.
1125
+
1126
+ **The ceiling measures rather than stops** — `references/loop-guard.md`. At the
1127
+ ceiling `certify` still runs and still tells the truth about the tiers; what it
1128
+ adds is the name of the tier that keeps failing, because a run spinning on one
1129
+ level needs the operator to see WHICH level, not to be halted.
1130
+ """
1131
+ guard(graph, args.graph)
1132
+
1133
+ nid = args.node
1134
+ by_id = {n.get("id"): n for n in graph.get("nodes") or []}
1135
+ node = by_id.get(nid)
1136
+ if node is None:
1137
+ die("no node %s in this graph — nothing was written" % nid)
1138
+ if node.get("status") in TERMINAL:
1139
+ die("%s is already %s — certifying it again would overwrite the record of the "
1140
+ "close that already happened" % (nid, node.get("status")))
1141
+ open_blockers = [b for b in node.get("blocked_by") or []
1142
+ if by_id.get(b, {}).get("status") not in TERMINAL]
1143
+ if open_blockers:
1144
+ die("%s waits on %s, which %s not closed — certifying work that could not have "
1145
+ "run certifies nothing" % (nid, ", ".join(open_blockers),
1146
+ "is" if len(open_blockers) == 1 else "are"))
1147
+
1148
+ reports, bad = {}, []
1149
+ for path in args.tier:
1150
+ try:
1151
+ with open(path, encoding="utf-8") as fh:
1152
+ t = json.load(fh)
1153
+ except OSError as e:
1154
+ die("cannot read the tier report at %s — %s" % (path, e), 2)
1155
+ except ValueError as e:
1156
+ die("%s: not readable as JSON — %s" % (path, e))
1157
+ v = tier_violations(t)
1158
+ if v:
1159
+ bad += ["%s: %s" % (os.path.basename(path), line) for line in v]
1160
+ continue
1161
+ if t["node"] != nid:
1162
+ bad.append("%s: reports on %s while this certification is for %s — a report "
1163
+ "about another node is not evidence about this one"
1164
+ % (os.path.basename(path), t["node"], nid))
1165
+ continue
1166
+ if t["tier"] in reports:
1167
+ bad.append("%s: a second `%s` report — the three tiers are three distances, "
1168
+ "and two readings at one distance leave another unread"
1169
+ % (os.path.basename(path), t["tier"]))
1170
+ continue
1171
+ reports[t["tier"]] = t
1172
+ if bad:
1173
+ die("the tier reports are malformed — nothing was written:\n " + "\n ".join(bad))
1174
+
1175
+ missing = [x for x in TIERS if x not in reports]
1176
+ if missing:
1177
+ die("certification is missing the %s report(s) — all three are required, because "
1178
+ "the level nobody read is the level the defect survives at"
1179
+ % ", ".join("`%s`" % m for m in missing))
1180
+
1181
+ # The stamp, read here and never accepted from a report — same law as `close`.
1182
+ import subprocess
1183
+ try:
1184
+ r = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
1185
+ head = r.stdout.strip() if r.returncode == 0 else ""
1186
+ except OSError:
1187
+ head = ""
1188
+
1189
+ prior = node.get("certification") or {}
1190
+ round_no = int(prior.get("round") or 0) + 1
1191
+ tiers_now = {x: reports[x]["verdict"] for x in TIERS}
1192
+ history = list(prior.get("history") or []) + [tiers_now]
1193
+ node["certification"] = {
1194
+ "round": round_no,
1195
+ "tiers": tiers_now,
1196
+ "at": head or "unavailable — not inside a git checkout",
1197
+ "history": history,
1198
+ }
1199
+
1200
+ failed = [x for x in TIERS if tiers_now[x] == "fail"]
1201
+
1202
+ # Churn, measured. A tier that has failed in every round so far is the one the
1203
+ # operator needs named; counting it here is what makes the loop visible.
1204
+ churning = [x for x in TIERS
1205
+ if len(history) >= 2 and all(h.get(x) == "fail" for h in history)]
1206
+
1207
+ save(args.graph, graph)
1208
+
1209
+ if failed:
1210
+ print("%s: certification round %d FAILED at %s"
1211
+ % (nid, round_no, ", ".join("`%s`" % f for f in failed)), file=sys.stderr)
1212
+ for tier in failed:
1213
+ for f in reports[tier]["findings"]:
1214
+ if f.get("severity") != "breaks":
1215
+ continue
1216
+ print(" [%s] %s — %s" % (tier, f["where"], f["what"]), file=sys.stderr)
1217
+ print(" fix: %s" % f.get("fix", "(not stated)"), file=sys.stderr)
1218
+ print(" check: %s" % f["check"], file=sys.stderr)
1219
+ if round_no >= args.ceiling:
1220
+ print("\n%s has been certified %d time(s), at or over the ceiling of %d."
1221
+ % (nid, round_no, args.ceiling), file=sys.stderr)
1222
+ if churning:
1223
+ print("The same tier has failed every round: %s. That is not a fix "
1224
+ "away — the level itself is being misread, or the node is the "
1225
+ "wrong shape. references/loop-guard.md."
1226
+ % ", ".join("`%s`" % c for c in churning), file=sys.stderr)
1227
+ else:
1228
+ print("No single tier is failing every round, so this is churn across "
1229
+ "levels rather than one stuck level.", file=sys.stderr)
1230
+ print("\nThe node stays open. Round %d is recorded on it." % round_no,
1231
+ file=sys.stderr)
1232
+ return 1
1233
+
1234
+ # Passed at all three. Assemble the canonical verdict.
1235
+ #
1236
+ # The mapping is deliberate and uses no field for something it does not mean:
1237
+ # confirms -> done (asked for, and now true)
1238
+ # not_examined -> not_verified (present, and no check touched it)
1239
+ # risk findings -> blockers (found, judged survivable, and named, which is
1240
+ # exactly what `can_continue_around: true` says)
1241
+ verdict = {
1242
+ "node": nid,
1243
+ "done": [c for x in TIERS for c in reports[x]["confirms"]],
1244
+ "not_done": [],
1245
+ "not_verified": ["%s: %s" % (x, n)
1246
+ for x in TIERS for n in reports[x]["not_examined"]],
1247
+ "blockers": [
1248
+ {"what": "%s (%s, found by the `%s` tier)" % (f["what"], f["where"], x),
1249
+ "blocks": [], "can_continue_around": True}
1250
+ for x in TIERS for f in reports[x]["findings"]
1251
+ if f.get("severity") == "risk"
1252
+ ],
1253
+ "replan": {"possible": True, "add": [], "park": [],
1254
+ "why": "certified at all three tiers in round %d" % round_no},
1255
+ "evidence": ["%s: %s" % (x, e) for x in TIERS for e in reports[x]["evidence"]],
1256
+ }
1257
+ # Checked against the same gate `close` will apply, HERE, so a certification
1258
+ # cannot hand the run a verdict its own consumer refuses.
1259
+ broken = verdict_violations(verdict)
1260
+ if broken:
1261
+ die("all three tiers passed and the assembled verdict is still malformed — this "
1262
+ "is a defect in `certify`, not in the reports:\n " + "\n ".join(broken))
1263
+
1264
+ out = args.verdict_out or os.path.join(os.path.dirname(args.graph) or ".",
1265
+ "verdict-%s.json" % nid)
1266
+ tmp = out + ".tmp"
1267
+ with open(tmp, "w", encoding="utf-8") as fh:
1268
+ json.dump(verdict, fh, indent=2, ensure_ascii=False)
1269
+ fh.write("\n")
1270
+ os.replace(tmp, out)
1271
+ print("%s: certified at unit, seam and product in round %d" % (nid, round_no))
1272
+ print("verdict written to %s — close it with:" % out)
1273
+ print(" graph.py close --verdict %s" % out)
1274
+ return 0
1275
+
1276
+
980
1277
  def cmd_close(graph, args):
981
1278
  """Consume a verdict, close one node, and re-plan — T-5, REQ-007.
982
1279
 
@@ -1105,6 +1402,8 @@ VERBS = {
1105
1402
  "coverage": (cmd_coverage, "every requirement and the nodes serving it; exits 1 on a gap"),
1106
1403
  "add": (cmd_add, "add a node mid-run"),
1107
1404
  "park": (cmd_park, "park a node, carrying the reason"),
1405
+ "certify": (cmd_certify, "require three independent tier reports, then emit "
1406
+ "the verdict `close` consumes"),
1108
1407
  "close": (cmd_close, "consume a verdict, close one node and re-plan"),
1109
1408
  }
1110
1409
 
@@ -1147,6 +1446,16 @@ def main(argv=None):
1147
1446
  made["close"].add_argument("--verdict", required=True,
1148
1447
  help="path to the verifier's seven-key verdict JSON")
1149
1448
 
1449
+ p_cert = made["certify"]
1450
+ p_cert.add_argument("--node", required=True, help="the node being certified")
1451
+ p_cert.add_argument("--tier", action="append", required=True, default=[],
1452
+ help="path to one tier report; pass three times, one per tier")
1453
+ p_cert.add_argument("--verdict-out", dest="verdict_out", default=None,
1454
+ help="where to write the assembled verdict (default: beside the graph)")
1455
+ p_cert.add_argument("--ceiling", type=int, default=3,
1456
+ help="rounds after which the output names the churning tier; it "
1457
+ "measures rather than stops (references/loop-guard.md)")
1458
+
1150
1459
  p_park = made["park"]
1151
1460
  p_park.add_argument("node")
1152
1461
  # `required=True` makes the MISSING flag a usage error (exit 2). The empty and
@@ -1158,7 +1467,7 @@ def main(argv=None):
1158
1467
  verbs = {k: v[0] for k, v in VERBS.items()}
1159
1468
  if args.verb in NO_GRAPH:
1160
1469
  return verbs[args.verb](None, args)
1161
- if args.verb in ("add", "park", "close"):
1470
+ if args.verb in ("add", "park", "close", "certify"):
1162
1471
  # The READ happens inside the lock too. Loading first and locking second is the
1163
1472
  # same lost update with an extra step: the stale copy is already in memory.
1164
1473
  with held(args.graph):
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
20
20
 
21
21
  ## `read:` — which doctrine this run actually opened
22
22
 
23
- The bundle is 35 reference files and nothing recorded which of them a run read, so **a
23
+ The bundle is 36 reference files and nothing recorded which of them a run read, so **a
24
24
  skipped file and a read one were indistinguishable** — the class every guard in this
25
25
  pipeline exists to catch, left standing over the doctrine itself.
26
26
 
@@ -48,7 +48,7 @@ same claim one level down.
48
48
  |---|---|---|
49
49
  | `unmeasured — no run ledger` | there is no ledger | nothing to read from |
50
50
  | `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
51
- | `N of 35 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
51
+ | `N of 36 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
52
52
 
53
53
  **It is a disclosure: no floor, no direction, never a target.** A run that needs four files
54
54
  and reads four is not worse than one that reads thirty — and the moment the number becomes