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.
Files changed (25) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/CONTRIBUTING.md +61 -0
  3. package/README.md +1 -0
  4. package/SKILL-CARD.md +1 -1
  5. package/package.json +4 -3
  6. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  7. package/plugins/task-pipeline/agents/verifier-product.md +96 -0
  8. package/plugins/task-pipeline/agents/verifier-seam.md +98 -0
  9. package/plugins/task-pipeline/agents/verifier-unit.md +86 -0
  10. package/plugins/task-pipeline/agents/verifier.md +9 -0
  11. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +1 -0
  12. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +167 -72
  13. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +23 -0
  14. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +15 -6
  15. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +146 -0
  16. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +9 -0
  17. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +9 -3
  19. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +1 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +6 -0
  21. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +3 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +326 -6
  23. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +1 -1
  24. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +22 -8
  25. 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 34 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
 
916
- `read:` lines in the run ledger are written by a hook, never by the agent, for the same
917
- reason `gate:` is: a claim about what somebody read, written by the party the claim is
918
- about, is not evidence.
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 from the last five run stamps (newest first)
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 34 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
 
27
- `read:` is **hook-written, never agent-written**, for the same reason `gate:` is: a claim
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 34 reference files read`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where |
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-written, deduplicated, one per file opened
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:`** — written by `hooks/gate-observer.sh`, never by an agent. It is the
68
- only line here that records what a command **did** rather than what somebody
69
- concluded: the exit code of the stage's declared `gate.command`, observed. The
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
- | REQ | What | Run | Shipped in | Observed at | Auto | Human | Note |
75
- |---|---|---|---|---|---|---|---|
76
- | REQ-001 | CSV export from a report | `2026-07-28-export` | v1.4.0 | `5f21ac3` | pass | 2026-07-30 | opened the deployed page, exported, opened the file |
77
- | REQ-004 | XLSX export | `2026-07-28-export` | v1.4.0 | `5f21ac3` | pass | **never** | — |
78
- | REQ-007 | Export respects active filters | `2026-07-28-export` | v1.4.0 | `5f21ac3` | partial | **never** | CSV path only |
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