task-pipeline-skill 1.72.0 → 1.74.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.
- package/CHANGELOG.md +176 -0
- package/CONTRIBUTING.md +61 -0
- package/README.md +1 -0
- package/SKILL-CARD.md +1 -1
- package/package.json +4 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier-product.md +96 -0
- package/plugins/task-pipeline/agents/verifier-seam.md +98 -0
- package/plugins/task-pipeline/agents/verifier-unit.md +86 -0
- package/plugins/task-pipeline/agents/verifier.md +9 -0
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +167 -72
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +23 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +15 -6
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +146 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +9 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +9 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +3 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +326 -6
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +22 -8
- package/plugins/task-pipeline/skills/task-pipeline/templates/verification.md +38 -5
|
@@ -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,13 +1042,21 @@ 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
|
|
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
|
|
|
916
|
-
`read:` lines
|
|
917
|
-
|
|
918
|
-
|
|
1049
|
+
`read:` lines are written by a hook rather than by the agent, for the same reason `gate:`
|
|
1050
|
+
is: a claim about what somebody read, written by the party the claim is about, is not
|
|
1051
|
+
evidence.
|
|
1052
|
+
|
|
1053
|
+
**And that is an intent, not a proof, so every line here is reported as UNATTESTED.**
|
|
1054
|
+
The ledger is `.task-pipeline/run.md` — the file the agent appends to at every stage —
|
|
1055
|
+
so nothing in it distinguishes a hook-written line from one an agent typed. The doctrine
|
|
1056
|
+
said *hook-written, never agent-written*, which is a provenance claim this script cannot
|
|
1057
|
+
check and no format here carries; B-014's class, committed by the mechanism built to
|
|
1058
|
+
close it. Until the ledger can attest a writer, the honest output is the count plus the
|
|
1059
|
+
word: read as *this is what the ledger says, and the ledger cannot say who wrote it*.
|
|
919
1060
|
|
|
920
1061
|
**The one rule that matters here: no `read:` lines means UNMEASURED, never «read
|
|
921
1062
|
nothing».** Zero would be the reassuring answer to a question nobody asked, and this
|
|
@@ -958,7 +1099,10 @@ def cmd_doctrine(graph, args):
|
|
|
958
1099
|
return 0
|
|
959
1100
|
|
|
960
1101
|
unread = [r for r in refs if r not in read]
|
|
961
|
-
print(f"doctrine: {len(read)} of {len(refs)} reference files read")
|
|
1102
|
+
print(f"doctrine: {len(read)} of {len(refs)} reference files read — unattested")
|
|
1103
|
+
print(" unattested: the ledger is the file the agent appends to at every stage, "
|
|
1104
|
+
"so nothing in it proves the hook wrote these lines rather than an agent. The "
|
|
1105
|
+
"count is what the ledger says; who wrote it is not recorded.")
|
|
962
1106
|
print(" a disclosure: no floor, no direction, never a target. A run that needs "
|
|
963
1107
|
"four files and reads four is not worse than one that reads thirty.")
|
|
964
1108
|
for r in unread:
|
|
@@ -966,6 +1110,170 @@ def cmd_doctrine(graph, args):
|
|
|
966
1110
|
return 0
|
|
967
1111
|
|
|
968
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
|
+
|
|
969
1277
|
def cmd_close(graph, args):
|
|
970
1278
|
"""Consume a verdict, close one node, and re-plan — T-5, REQ-007.
|
|
971
1279
|
|
|
@@ -1094,6 +1402,8 @@ VERBS = {
|
|
|
1094
1402
|
"coverage": (cmd_coverage, "every requirement and the nodes serving it; exits 1 on a gap"),
|
|
1095
1403
|
"add": (cmd_add, "add a node mid-run"),
|
|
1096
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"),
|
|
1097
1407
|
"close": (cmd_close, "consume a verdict, close one node and re-plan"),
|
|
1098
1408
|
}
|
|
1099
1409
|
|
|
@@ -1136,6 +1446,16 @@ def main(argv=None):
|
|
|
1136
1446
|
made["close"].add_argument("--verdict", required=True,
|
|
1137
1447
|
help="path to the verifier's seven-key verdict JSON")
|
|
1138
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
|
+
|
|
1139
1459
|
p_park = made["park"]
|
|
1140
1460
|
p_park.add_argument("node")
|
|
1141
1461
|
# `required=True` makes the MISSING flag a usage error (exit 2). The empty and
|
|
@@ -1147,7 +1467,7 @@ def main(argv=None):
|
|
|
1147
1467
|
verbs = {k: v[0] for k, v in VERBS.items()}
|
|
1148
1468
|
if args.verb in NO_GRAPH:
|
|
1149
1469
|
return verbs[args.verb](None, args)
|
|
1150
|
-
if args.verb in ("add", "park", "close"):
|
|
1470
|
+
if args.verb in ("add", "park", "close", "certify"):
|
|
1151
1471
|
# The READ happens inside the lock too. Loading first and locking second is the
|
|
1152
1472
|
# same lost update with an extra step: the stale copy is already in memory.
|
|
1153
1473
|
with held(args.graph):
|
|
@@ -31,7 +31,7 @@ has not fired in the last five run stamps, or in the last sixty days. At eleven
|
|
|
31
31
|
rows, the oldest never-fired
|
|
32
32
|
row goes — the cap is not negotiable, ranking is.
|
|
33
33
|
|
|
34
|
-
## Recent log — entries
|
|
34
|
+
## Recent log — narrative entries, uncapped and queried rather than read (newest first)
|
|
35
35
|
|
|
36
36
|
Older entries and every retirement **move** to `docs/evidence/retro/YYYY-QN.md`
|
|
37
37
|
at the prune. Moving is not deleting: the archive is append-only and holds the
|
|
@@ -20,23 +20,35 @@ 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
|
|
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
|
|
|
27
|
-
`read:` is **
|
|
28
|
-
about what somebody read, written by the party the claim is about, is not evidence. The
|
|
27
|
+
`read:` is **written by a hook rather than by the agent**, for the same reason `gate:` is: a
|
|
28
|
+
claim about what somebody read, written by the party the claim is about, is not evidence. The
|
|
29
29
|
hook is in `hooks.example.json`, matches `Read`, records the path only
|
|
30
30
|
when it is inside the bundle's `references/`, deduplicates, and **always exits 0** — a hook
|
|
31
31
|
that can fail a `Read` would break every turn in every session.
|
|
32
32
|
|
|
33
|
+
**That is an intent, not a proof, and the line says so.** This file is the one the agent
|
|
34
|
+
appends to at every stage, so nothing in it distinguishes a hook-written line from one an
|
|
35
|
+
agent typed: there is no writer field, and `scripts/graph.py` has no provenance check
|
|
36
|
+
because the format gives it nothing to check. The doctrine here said *hook-written, never
|
|
37
|
+
agent-written* until 2026-08-20 — a provenance claim in the file whose whole subject is that
|
|
38
|
+
a claim by the interested party is not evidence, which is B-014's class committed by the
|
|
39
|
+
mechanism built to close it. So `doctrine` and the `gate:` reader both report **`unattested`**
|
|
40
|
+
beside their counts, and the two claims that survive are the ones the ledger can support:
|
|
41
|
+
*this line is in the ledger*, and *nobody recorded who wrote it*. Attesting a writer needs a
|
|
42
|
+
field this format does not have; inventing one that an agent can also fill would restate the
|
|
43
|
+
same claim one level down.
|
|
44
|
+
|
|
33
45
|
`scripts/graph.py doctrine` reads these lines and prints one of three things:
|
|
34
46
|
|
|
35
47
|
| It prints | When | Why not just a number |
|
|
36
48
|
|---|---|---|
|
|
37
49
|
| `unmeasured — no run ledger` | there is no ledger | nothing to read from |
|
|
38
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 |
|
|
39
|
-
| `N of
|
|
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 |
|
|
40
52
|
|
|
41
53
|
**It is a disclosure: no floor, no direction, never a target.** A run that needs four files
|
|
42
54
|
and reads four is not worse than one that reads thirty — and the moment the number becomes
|
|
@@ -58,15 +70,17 @@ hand: <N|10> — task "<quoted>" — done <n> — surfaced <n> — decisions <n
|
|
|
58
70
|
holds: <stage id> — <n> (<class: what, owner>; … or "none") — enumerated <n>/8 classes, <unlooked: classes not enumerable>
|
|
59
71
|
gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
|
|
60
72
|
event: <compact|session-end|subagent> — <detail> — <ISO-8601>
|
|
61
|
-
read: references/<file>.md # hook-
|
|
73
|
+
read: references/<file>.md # hook-appended, deduped, UNATTESTED (no writer field)
|
|
62
74
|
```
|
|
63
75
|
|
|
64
76
|
- **`stage:`** — written when a gate **returns**, not when the stage is entered. The
|
|
65
77
|
rail's `✓` is derived from this line and from nothing else; a glyph set from memory
|
|
66
78
|
is a summary that is confidently wrong exactly when it matters.
|
|
67
|
-
- **`gate:`** —
|
|
68
|
-
|
|
69
|
-
|
|
79
|
+
- **`gate:`** — appended by `hooks/gate-observer.sh` rather than by an agent, and
|
|
80
|
+
**unattested** for the reason `read:` is: this file is agent-written at every stage
|
|
81
|
+
and carries no writer field, so the line's provenance is an intent the format cannot
|
|
82
|
+
prove. It is the only line here that records what a command **did** rather than what
|
|
83
|
+
somebody concluded: the exit code of the stage's declared `gate.command`, observed. The
|
|
70
84
|
`stage:` line above it is the agent's claim, and the release gate requires the
|
|
71
85
|
two to agree — without this, a gate reads a claim written by the party it
|
|
72
86
|
constrains and confirms an assertion with itself. Absent where the project
|
|
@@ -41,6 +41,7 @@ worse than saying nothing.
|
|
|
41
41
|
## Contents
|
|
42
42
|
|
|
43
43
|
- [Staleness — a row is true about the tree it OBSERVED](#staleness--a-row-is-true-about-the-tree-it-observed)
|
|
44
|
+
- [Environment — a proof is only valid where it ran](#environment--a-proof-is-only-valid-where-it-ran)
|
|
44
45
|
- the ledger itself — one row per REQ, appended by stage 8
|
|
45
46
|
- [What `Human` means, and what it does not](#what-human-means-and-what-it-does-not)
|
|
46
47
|
|
|
@@ -71,11 +72,39 @@ one. Four things overtake a row, and naming which one applies is the note's job:
|
|
|
71
72
|
change in what it covers, a **dependency** change, an **environment** change, and a
|
|
72
73
|
**policy** change — the last being the rule under which the evidence was accepted.
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
## Environment — a proof is only valid where it ran
|
|
76
|
+
|
|
77
|
+
`Observed at` says which tree the check saw. It does not say **where**, and without that a
|
|
78
|
+
smoke test against a preview URL enters the record in a shape indistinguishable from one
|
|
79
|
+
against production, and a suite green on a laptop with accumulated state is
|
|
80
|
+
indistinguishable from one green on a runner that started clean. Those are the two halves
|
|
81
|
+
of the same question, and only one was instrumented.
|
|
82
|
+
|
|
83
|
+
**`Environment` is a required cell on every row.** Its vocabulary is the project's own,
|
|
84
|
+
declared in the runbook and not invented per row — this file ships with four, and a project
|
|
85
|
+
that deploys differently declares different ones:
|
|
86
|
+
|
|
87
|
+
| Value | What it means |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `production` | the deployed target real users reach |
|
|
90
|
+
| `preview` | a per-branch or per-PR deployment; proves the build, never the release |
|
|
91
|
+
| `ci` | a clean runner, no accumulated state |
|
|
92
|
+
| `local` | a developer machine, with whatever state it has |
|
|
93
|
+
| `—` | **recorded absence** — a row written before this column existed, or one whose environment nobody recorded. Not a value, and never a default to reach for |
|
|
94
|
+
|
|
95
|
+
A missing cell is not the same as `—`: the first is a row that forgot the question, and it is
|
|
96
|
+
**refused**. The second is an answer.
|
|
97
|
+
|
|
98
|
+
**A REQ claiming production behaviour may not be closed on a non-production
|
|
99
|
+
observation.** Stage 8 writes the row and refuses that pairing rather than recording it —
|
|
100
|
+
`pass` in `ci` against a requirement about the deployed product is the exact substitution
|
|
101
|
+
this column exists to make visible.
|
|
102
|
+
|
|
103
|
+
| REQ | What | Run | Shipped in | Observed at | Environment | Auto | Human | Note |
|
|
104
|
+
|---|---|---|---|---|---|---|---|---|
|
|
105
|
+
| REQ-001 | CSV export from a report | `2026-07-28-export` | v1.4.0 | `5f21ac3` | production | pass | 2026-07-30 | opened the deployed page, exported, opened the file |
|
|
106
|
+
| REQ-004 | XLSX export | `2026-07-28-export` | v1.4.0 | `5f21ac3` | ci | pass | **never** | — |
|
|
107
|
+
| REQ-007 | Export respects active filters | `2026-07-28-export` | v1.4.0 | `5f21ac3` | preview | partial | **never** | CSV path only |
|
|
79
108
|
|
|
80
109
|
## Columns
|
|
81
110
|
|
|
@@ -86,6 +115,10 @@ change in what it covers, a **dependency** change, an **environment** change, an
|
|
|
86
115
|
- **Run** — the brief's topic slug, so the context is one file away.
|
|
87
116
|
- **Shipped in** — the tag or commit that carried it. Where a project does not tag,
|
|
88
117
|
the commit, and the same value every row of that run carries.
|
|
118
|
+
- **Observed at** — the commit the check ran against. `—` where nobody recorded one.
|
|
119
|
+
- **Environment** — where it ran, from the project's declared vocabulary. Required; `—` is
|
|
120
|
+
the recorded absence and an omitted cell is refused. See *Environment — a proof is only
|
|
121
|
+
valid where it ran*.
|
|
89
122
|
- **Auto** — what the run's own gate said: `pass` · `partial` · `none`. Copied from the
|
|
90
123
|
coverage table rather than re-derived; where the two disagree the coverage table wins
|
|
91
124
|
and the disagreement is a finding. A coverage verdict of **`review`** — *no check can
|