task-pipeline-skill 1.97.1 → 1.99.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 (44) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/HOW-IT-WORKS.md +17 -13
  3. package/README.md +13 -10
  4. package/SKILL-CARD.md +1 -1
  5. package/cursor/rules/task-pipeline.mdc +22 -14
  6. package/evals/task-pipeline.evals.json +3 -3
  7. package/package.json +1 -1
  8. package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
  9. package/plugins/task-pipeline/commands/task-pipeline.md +14 -219
  10. package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +8 -1
  11. package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +1 -1
  12. package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +24 -1
  13. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +21 -576
  14. package/plugins/task-pipeline/skills/project-audit/SKILL.md +8 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +162 -230
  16. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +1 -1
  17. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +9 -9
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +11 -4
  19. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +2 -2
  20. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +3 -3
  21. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +22 -7
  22. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +49 -44
  23. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +6 -3
  24. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
  25. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +13 -8
  26. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +188 -32
  27. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +5 -3
  28. package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +24 -1
  29. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +17 -7
  30. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +49 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +5 -4
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +39 -25
  33. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +1 -1
  34. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +36 -28
  35. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +8 -5
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +79 -22
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1229 -110
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +305 -0
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/short_path.py +70 -0
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +22 -10
  41. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +1 -1
  42. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +38 -10
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +9 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +10 -1
@@ -7,13 +7,13 @@ The full gate and freedom level remain in [stages.md](stages.md).
7
7
  - 0 — Intake grill — MANDATORY
8
8
 
9
9
  ## 0 — Intake grill — MANDATORY
10
- - **Stage 0 is not optional and not skippable.** There is no "small enough task"
11
- exemption, no "the request was already clear" exemption, no starting stage 1
12
- "while the operator thinks". The only sanctioned bypass is the
13
- entry-from-super-ux short-circuit below, and even that still requires a scope
14
- confirmation and a record of what was adopted vs skipped. A run that reaches
15
- stage 1 without a committed, operator-confirmed brief is a **failed run** —
16
- stop and go back.
10
+ - **Stage 0 always runs, and its depth is proportional.** The gap check, the
11
+ harvest and the brief are never skipped; the interview, the committed brief file
12
+ and stages 1-4 are what a small change does without (Phase 1d below, the
13
+ *proportional run*). A run that starts building without a brief and its REQ rows
14
+ (in the brief file, or on the short path in the run ledger) is a **failed run**:
15
+ stop and go back. The entry-from-super-ux short-circuit below is the other
16
+ sanctioned shortening.
17
17
  - **Entry-from-super-ux short-circuit (check FIRST).** task-pipeline is often
18
18
  launched *from* super-ux — its `/ux` action menu offers "execute autonomously
19
19
  via the task-pipeline plugin" once the UX chain (and often a
@@ -40,8 +40,9 @@ The full gate and freedom level remain in [stages.md](stages.md).
40
40
  `affected` / `god-nodes` answer *reach*, which is what grep cannot), `CLAUDE.md`,
41
41
  `CONTEXT.md`/ADRs, `docs/` + `docs/ux/`, past pipeline briefs and carry-over
42
42
  ledgers, **the retro's standing instructions and run stamps** (`<artifacts>/retro.md`,
43
- read **in full** — ten standing rows and ten stamps, both bounded **by a cap**, and they bind this
44
- run; stamp each instruction as it fires. Its *Recent log* is **queried** by the
43
+ read **in full**: ten standing rows and ten stamps, bounded by a cap and by a byte
44
+ budget; print `scripts/retro_budget.py check` first, which states the floor's bytes and
45
+ bytes/4 tokens. They bind this run; stamp each instruction as it fires. Its *Recent log* is **queried** by the
45
46
  task's nouns, not read: uncapped narrative inside a binding source is what makes the
46
47
  capped part get skimmed, [`retrospective.md`](retrospective.md)), **lessons from other
47
48
  projects** (source 7b, [`knowledge-sources.md`](knowledge-sources.md): `scripts/lessons.py
@@ -81,30 +82,43 @@ The full gate and freedom level remain in [stages.md](stages.md).
81
82
  or they genuinely disagree and that is a decision. There is no fourth option, and
82
83
  starting on an unresolved divergence means building against a system that does
83
84
  not exist.
84
- - **Phase 1d — the short-path triage: printed, proposed, never taken silently.** This
85
- pipeline's own boundary exempts a typo, a one-line fix and a mechanical rename — and
86
- nothing measured that until 2026-08-10, so the exemption depended on an agent
87
- remembering it while eleven stages and four manual gates stood in front of a
88
- one-paragraph edit. Three questions, each with something behind it:
85
+ - **Phase 1d — the short-path triage, and the proportional run: a small change
86
+ takes the short path by default.** This pipeline's boundary exempts a typo and a one-line edit; until 1.98.0
87
+ the next size up still met eleven stages and five manual gates, because the short
88
+ path was only *proposed* and silence took the full flow. Under a standing "decide and
89
+ act" instruction silence is the normal answer, so a proposal was the full flow every
90
+ time. The triage now **decides**, from five answers with something behind each:
89
91
 
90
92
  ```
91
- 1. files the request names, resolved git ls-files -- <paths> -> N
93
+ 1. files the change touches, resolved git ls-files -- <paths> -> N
92
94
  2. any of them a public contract the surfaces the host's release
93
95
  syncs, its schema or config
94
96
  contracts, a shipped command,
95
97
  its README -> yes/no
96
- 3. behaviour a user or a caller observes changes -> yes/no
98
+ 3. behaviour a user observes changes a UI, a message, an output -> yes/no
99
+ 4. a deploy target in the brief stage 7's target exists -> yes/no
100
+ 5. risk class work-graph.md R1-R3 -> R1/R2/R3
97
101
  ```
98
102
 
99
- Few files, no contract, no observable change ⇒ **propose** the short path: stages 1,
100
- 2, 3 and 4 marked `⊘` with the triage answer as the reason, and 5→10 running
101
- unchanged. **Propose, never take.** The answer goes in the brief's autonomy sweep and
102
- silence takes the full flow — the same floor deploy authorization uses. The glyph is
103
- what makes it safe: a skipped stage is printed on the rail **with its reason**
104
- ([`progress.md`](progress.md)), and a skip nobody can see is indistinguishable from a
105
- stage that was never entered.
106
- - **How it runs: [`grill.md`](grill.md)** — the full doctrine, built into this
107
- skill (nothing to install). In short: close what the sources can, then ONE batch
103
+ **Small** means all of: the gap check left zero material unknowns, N ≤ 5, no public
104
+ contract, no user-observable change, no deploy, and risk **R1**. Small ⇒ the run takes
105
+ the short path and prints one line:
106
+ `short path: R1 · N files · no contract · no user-facing change · no deploy`.
107
+ `scripts/short_path.py` computes the decision from the five answers (exit 0 short,
108
+ 3 full) and prints that line.
109
+ The short path is: the brief and its REQ rows written into the run ledger (no grill,
110
+ no committed brief file) → stage 5, build with tests → stage 6, the suite plus **one**
111
+ verification (one blind tier, [`certification.md`](certification.md)) → stage 9, the
112
+ docs the change makes false and the handoff → stage 10, the hand-back and a run stamp;
113
+ a retro entry only when the run taught a new lesson. Stages 1-4, 7 and 8 print `⊘`
114
+ with the triage line as their reason ([`progress.md`](progress.md)).
115
+ **Triggers that bring a skipped stage back:** a deploy target appears (7, 8); a wiki or
116
+ code graph exists and the change touched what it indexes (9 in full); a check reveals a
117
+ contract or a user-facing effect (leave the short path, resume at stage 2); the
118
+ operator asks for the full cycle. Any answer outside *small* takes the full flow,
119
+ printed the same way with the failing answer as its reason.
120
+ - **How it runs: [`grill.md`](grill.md)**, read only when the gap check leaves a
121
+ material unknown (a complete brief asks nothing). In short: close what the sources can, then ONE batch
108
122
  of the material unknowns, ordered by dependency, a recommended answer with each —
109
123
  one at a time only where answers depend on each other or the operator asks;
110
124
  contradictions reconciled on the spot; **every answer that touches
@@ -7,7 +7,7 @@ The full gate and freedom level remain in [stages.md](stages.md).
7
7
  workspace (native worktree tool first, git fallback, baseline tests), keep a
8
8
  ledger under `.task-pipeline/build/<plan>/` so a compacted context can resume,
9
9
  then one fresh implementer subagent per task with a file-based brief and report,
10
- a review after every task ([`review.md`](review.md)), and a five-round fix loop
10
+ a review after every task ([`review.md`](review.md)), and a three-round fix loop
11
11
  with an explicit breaker. TDD per task ([`tdd.md`](tdd.md)): failing test →
12
12
  watch it fail → minimal impl → watch it pass → commit. Pin subagents to the
13
13
  run's confirmed model (`model-tiering.md`). The plan's parallel groups fan out
@@ -1,9 +1,10 @@
1
1
  # Stages — detail & gates
2
2
 
3
- For the selected **default profile**, read this spine at entry, then the one detail
4
- linked in the current stage before work on every entry or resume, including wave
5
- iterations and approved short paths. Do not preload all stage details. Missing or
6
- unreadable detail means stop that stage and repair its route, never guess. A custom
3
+ For the selected **default profile**, read **one stage section of this file when that
4
+ stage starts** (the stage table in `SKILL.md` is the index), then the one detail it
5
+ links, on every entry or resume, including wave iterations and approved short paths
6
+ (the triage approves the short path itself since 1.98.0). Never read this file end to
7
+ end. Do not preload all stage details. Missing or unreadable detail means stop that stage and repair its route, never guess. A custom
7
8
  profile uses its own declared doctrine and stages; numeric ids do not select these
8
9
  default details, and the kernel still applies.
9
10
 
@@ -14,7 +15,10 @@ orchestrator verifies the check itself, pass/fail), `judgment` (a named judge
14
15
  rules where no complete deterministic check exists — [`gates.md`](gates.md) →
15
16
  *The judgment gate*) or `manual` (requires the operator's explicit go, including a
16
17
  specific standing authorization naming the act, cited with its source and scope;
17
- otherwise wait). These stages (0 intake + 1→10) are the plugin's
18
+ otherwise wait). **This example ships every gate `auto`** (since 1.98.0): where it
19
+ names an approval, the operator's answer or the standing authorization recorded in the
20
+ brief satisfies it without a pause. A project makes a gate `manual` in its own
21
+ `pipeline.json`. These stages (0 intake + 1→10) are the plugin's
18
22
  **example** flow, encoded in `pipeline.example.json` against the universal contract
19
23
  `pipeline.schema.json`; a host project replaces it with its own
20
24
  stages/agents/types (see SKILL.md → *Bring your own skills*).
@@ -84,7 +88,11 @@ never that the work was skipped quietly.
84
88
 
85
89
  **On entry or resume:** read [stage-00-intake.md](stage-00-intake.md) before stage work.
86
90
 
87
- - **GATE (manual):** shared understanding reached — **the source ledger is written
91
+ - **On the short path** (`stage-00-intake.md` → Phase 1d) this gate
92
+ is: the triage line printed, the brief and its REQ rows in the run ledger, the retro
93
+ floor read with its byte count, and the source ledger. Everything else below applies
94
+ to the full flow.
95
+ - **GATE (auto):** shared understanding reached — **the source ledger is written
88
96
  (every source consulted, or an explicit "none found")** and **its `Contradictions:`
89
97
  line is written under it** — the harvest is a fan-out that converges on one brief, and
90
98
  nothing else compares the sources *with each other*; `Contradictions: none` is the
@@ -107,8 +115,8 @@ never that the work was skipped quietly.
107
115
  is filled — objective, exit checks on the artefact the operator uses, scope, budget and
108
116
  base** ([`finish-line.md`](finish-line.md)), the carry-over ledger is
109
117
  seeded, **`.task-pipeline/run.md` exists and the header block has been printed**
110
- ([`progress.md`](progress.md)), the model decision is recorded, and the operator
111
- confirms the brief. Stop when a
118
+ ([`progress.md`](progress.md)), the model decision is recorded, and the brief is
119
+ confirmed: the operator's answer, or the standing authorization recorded in it. Stop when a
112
120
  re-scan surfaces no new branches (don't grill past diminishing returns;
113
121
  reversible calls can be deferred with a note). Only then start stage 1.
114
122
  - **The verification ledger is read.** `<artifacts>/verification.md` — the harvest
@@ -140,7 +148,8 @@ never that the work was skipped quietly.
140
148
 
141
149
  **On entry or resume:** read [stage-02-brainstorm.md](stage-02-brainstorm.md) before stage work.
142
150
 
143
- - **GATE (manual):** the user approves the design, the UI verdict is recorded, **the
151
+ - **GATE (auto):** the design is approved (the operator's go, or the brief's standing
152
+ authorization), the UI verdict is recorded, **the
144
153
  queue is an artifact rather than a recollection** — where it is a work graph,
145
154
  `graph.py validate` exits 0 and `graph.py coverage` names any requirement no node serves,
146
155
  **every REQ is answered by the design** — a requirement the design doesn't
@@ -156,10 +165,11 @@ never that the work was skipped quietly.
156
165
 
157
166
  **On entry or resume:** read [stage-03-spec.md](stage-03-spec.md) before stage work.
158
167
 
159
- - **GATE (manual):** the **`## Self-review` section written and committed with
168
+ - **GATE (auto):** the **`## Self-review` section written and committed with
160
169
  computed values** — every check the spec names resolving or marked `review`, the
161
170
  brief's decisions and stage 2's rejected options read back with no unresolved
162
- contradiction, and the cost delta printed; spec committed **and** user-reviewed; **every section carries
171
+ contradiction, and the cost delta printed; spec committed **and** reviewed (by the
172
+ operator, or under the brief's standing authorization by an independent reader); **every section carries
163
173
  `covers: REQ-…` and every REQ appears in at least one section**; for UI tasks
164
174
  additionally: the super-ux chain (foundation → flows → screens → scenarios) is
165
175
  designed, validated and approved; scenarios validated in `docs/ux/scenarios.md`;
@@ -299,20 +309,17 @@ never that the work was skipped quietly.
299
309
 
300
310
  **On entry or resume:** read [stage-07-lint-deploy.md](stage-07-lint-deploy.md) before stage work.
301
311
 
302
- - **GATE (manual):** lint clean (host linter **and**, for UI projects, the super-ux
312
+ - **GATE (auto):** lint clean (host linter **and**, for UI projects, the super-ux
303
313
  linter) **and** suite green **before** deploy, **and no REQ is still `open`** — a
304
314
  `partial` ships only with the operator's explicit acceptance. A gap is cheapest to
305
- close before it ships, and the operator is already present at this gate. **The
315
+ close before it ships. **The
306
316
  carry-over count is printed beside this verdict.** Deploy is outward → explicit
307
317
  operator go, or a specific standing authorization naming that act, cited with its source
308
- and scope. Authorization to release does not silently authorize a separate deploy. If
309
- `release.authorized_by` is present, read that source and verify its authority,
310
- currentness and scope for the tag and publication; the locator is not authorization.
311
- If absent, inspect existing recorded authority before asking for a missing grant.
312
- **Before the tag, read
313
- the release's approval mode** (`pipeline.json` → `release.approval`; then
314
- [`deploy-targets.md`](deploy-targets.md) → *When the release waits for an approval*):
315
- `checks` — push the tag, watch the run selected by the tag, report; `reviewers` — the
318
+ and scope. How a release's authority is established, `release.authorized_by` included,
319
+ has one home: [`deploy-targets.md`](deploy-targets.md) → *When the release waits for an
320
+ approval*. **Before the tag, read the release's approval mode** (`pipeline.json` →
321
+ `release.approval`; the same section): `checks` — push the tag, put the run selected by
322
+ the tag on a watcher and take independent work until it reports; `reviewers` — the
316
323
  direct link to the run that is actually `waiting`, never a request without it. Respect deploy-from-main rules if the project mandates them. **Before
317
324
  tagging, the CI verdict for what was just pushed is READ, not assumed**
318
325
  ([`conventions.md`](conventions.md) → *The CI verdict*) — a tag on a commit whose
@@ -376,7 +383,7 @@ never that the work was skipped quietly.
376
383
 
377
384
  **On entry or resume:** read [stage-10-acceptance.md](stage-10-acceptance.md) before stage work.
378
385
 
379
- - **GATE (manual):** the ladder walk ran and its absences became REQ rows before
386
+ - **GATE (auto):** the ladder walk ran and its absences became REQ rows before
380
387
  the table was written; **the hand-back is written** — the request quoted as it was
381
388
  GIVEN, where the run stands against it, what was solved with its evidence, what
382
389
  surfaced that nobody asked for, every decision still waiting **asked here with
@@ -422,10 +429,11 @@ never that the work was skipped quietly.
422
429
  carries evidence; every `partial` names what's missing and where it's tracked;
423
430
  every `deferred`/`dropped` has the operator's agreement and, for `deferred`, a
424
431
  tracker entry; no carry-over row left `unresolved`, and the ledger's counts are
425
- printed with the verdict; the operator answers the
426
- closing question and signs off. Manual by design — an automated check can prove
427
- the table is well-formed, only the person who asked can confirm it is what they
428
- asked for.
432
+ printed with the verdict; the hand-back asks the closing question and carries
433
+ the answer the operator can give at any time. `auto` by default since 1.98.0: a
434
+ check proves the table well-formed, and only the person who asked can confirm it is
435
+ what they asked for, so the run hands that question back rather than waiting on it.
436
+ A project that wants the wait sets this gate `manual`.
429
437
 
430
438
  ## The program loop — a platform, one brick at a time
431
439
 
@@ -486,8 +494,8 @@ cycle.
486
494
  a feeling, and the ledger is what survives compaction.
487
495
  - **Trips on:** revert-oscillation (A→B→A); the same file edited twice for the same
488
496
  reason; a finding already ADDRESSED or parked coming back; a stage entered a third
489
- time for one artifact; two loops editing one file. Hard caps: 5 fix rounds per
490
- task, 2 re-entries per stage per artifact, 3 passes per module.
497
+ time for one artifact; two loops editing one file. Hard caps, each a stop: 3 fix
498
+ rounds per task, 2 re-entries per stage per artifact, 3 passes per module.
491
499
  - **On a trip: stop editing.** Name shapes A and B with their evidence, escalate to
492
500
  the layer that owns the conflict (rubric → operator → plan → spec → module map),
493
501
  re-plan the check as an ordered one-item-per-line checklist, then go through it in
@@ -57,10 +57,12 @@ on real agents, tasks and users rather than on the first guess.
57
57
  | 5–8 Build → post-deploy | per wave, then the close pass. A wave's stage 7 integrates to the main branch **without tagging**; the release's tag, its approval mode and its go are stage 7's full gate, run once at the release ([`iteration.md`](iteration.md) → *The cycle*, step 10), and stage 8's trio follows on what was released (step 11) |
58
58
  | 9 Docs + wiki · 10 Acceptance | once, to close the release, over everything the waves shipped |
59
59
 
60
- **One planning gate, not three prompts.** The manual gates of stages 0, 2 and 3 are
61
- presented together at the end of planning: the brief, the canvas, the wave plan and the
62
- question batch in one message. Each gate's criteria are still checked and printed; the
63
- operator is asked once, and the go names the gates it closes. Silence closes none.
60
+ **One planning gate, not three prompts.** The gates of stages 0, 2 and 3 are presented
61
+ together at the end of planning: the brief, the canvas, the wave plan and the question
62
+ batch in one message. Each gate's criteria are still checked and printed. They are `auto`
63
+ by default (since 1.98.0): the standing authorization recorded in the brief closes them
64
+ and the run continues; where the project made them `manual`, the operator is asked once,
65
+ the go names the gates it closes, and silence closes none.
64
66
 
65
67
  ## Wave 0 — the survey and the inventory
66
68
 
@@ -261,7 +263,8 @@ The work graph ([`work-graph.md`](work-graph.md)) carries `wave` on a node, an i
261
263
  - `graph.py validate` refuses an edge from a later wave's node into an earlier wave's
262
264
  node: an earlier wave that waits on a later one cannot close in its own pass.
263
265
  - `graph.py next --wave N` prints wave N's frontier only. Exit `3` says the wave is done,
264
- `4` that nothing in it is runnable, `1` that no node is in wave N.
266
+ `4` that nothing in it is runnable behind a mixed or unclassified barrier, `6` that only
267
+ recorded watched waits hold it, `1` that no node is in wave N.
265
268
  - `graph.py wave N-007 --to 3 --why "<reason>"` moves a node and writes the revision.
266
269
  It refuses a move that would break the order above.
267
270
 
@@ -31,10 +31,12 @@ conditional on the code, never merely sequenced after it):
31
31
  0 the verb succeeded; for `next`, runnable nodes were printed
32
32
  1 a refusal — an invariant violated, a malformed verdict, a missing reason
33
33
  2 usage
34
- 3 nothing left to do: every node is done
34
+ 3 nothing left to do: every node in scope is terminal — `done` or `parked`
35
+ (a parked node is finished for the walk; its reason stays on the node)
35
36
  4 nothing runnable: an unclassified, running or parked dependency remains
36
37
  5 external execution lease contention
37
38
  6 nothing runnable: all remaining dependency leaves are recorded watched waits
39
+ 7 `certify` stopped at its ceiling: the node is parked with the evidence
38
40
 
39
41
  **The mutation verbs never leave a half-written queue.** `add` and `park` write to a
40
42
  temp file in the same directory and `os.replace` it into place, so a crash mid-write
@@ -654,10 +656,25 @@ ALL_TIERS = TIERS + (VISUAL_TIER,)
654
656
  SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
655
657
  RISK_CLASSES = ("R1", "R2", "R3")
656
658
  # 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}
659
+ # (`references/certification.md` → *Rounds owed by risk class*). A clean round ends the
660
+ # node unless it is R3 or a flagship surface: those owe a second passing round, and it is
661
+ # the adversarial one, a different question rather than the same reading again. R2 owed
662
+ # two blind rounds until 1.98.0, and a clean round did not cancel the next one: the loop
663
+ # the 2026-10-20 cost audit measured.
664
+ ROUNDS_OWED = {"R1": 1, "R2": 1, "R3": 2}
660
665
  ROUND_KINDS = ("blind", "adversarial")
666
+ # An R1 node certifies with ONE blind tier (any of the three; `unit` is the default
667
+ # reading). Every other node owes all three, and a flagship or product surface the fourth.
668
+ ONE_TIER_RISK = ("R1",)
669
+
670
+
671
+ def owed_rounds(node):
672
+ """(rounds owed, adversarial owed) for a node, or (0, False) when it owes no count."""
673
+ owed = ROUNDS_OWED.get(node.get("risk"), 0)
674
+ adversarial = node.get("risk") == "R3" or node.get("surface_class") == "flagship"
675
+ if node.get("surface_class") == "flagship":
676
+ owed = max(owed, 2)
677
+ return owed, adversarial
661
678
 
662
679
 
663
680
  def passing_rounds(cert):
@@ -675,13 +692,13 @@ def passing_rounds(cert):
675
692
 
676
693
  def owed_shortfall(node):
677
694
  """None when the node's class is satisfied, else a sentence saying what it still owes."""
678
- owed = ROUNDS_OWED.get(node.get("risk"))
695
+ owed, adversarial = owed_rounds(node)
679
696
  if not owed:
680
697
  return None
681
698
  passed = passing_rounds(node.get("certification"))
682
699
  if len(passed) < owed:
683
700
  return "%d of %d owed passing rounds" % (len(passed), owed)
684
- if node.get("risk") == "R3" and "adversarial" not in passed:
701
+ if adversarial and "adversarial" not in passed:
685
702
  cert = node.get("certification") or {}
686
703
  kinds = list(cert.get("kinds") or [])
687
704
  failed_adv = any(k == "adversarial" and not all(v == "pass" for v in (h or {}).values())
@@ -1661,10 +1678,14 @@ def cmd_certify(graph, args):
1661
1678
  the number the ceiling below reads. The node stays `pending` on a failure; the
1662
1679
  round count is the trail.
1663
1680
 
1664
- **The ceiling measures rather than stops** — `references/loop-guard.md`. At the
1665
- ceiling `certify` still runs and still tells the truth about the tiers; what it
1666
- adds is the name of the tier that keeps failing, because a run spinning on one
1667
- level needs the operator to see WHICH level, not to be halted.
1681
+ **The ceiling stops** (`references/loop-guard.md`, since 1.98.0). A round that fails
1682
+ with the node's failing rounds at the ceiling parks the node with the evidence as its
1683
+ reason (exit 7): the run records a blocker row and moves on. It used to measure
1684
+ only, and a measured loop is still a loop.
1685
+
1686
+ **No re-run on unchanged input.** A round of the same kind on the same tree as the
1687
+ round before it is refused before anything is written: a tier's answer about a tree
1688
+ it already read is the answer it gave.
1668
1689
  """
1669
1690
  guard(graph, args.graph)
1670
1691
 
@@ -1721,10 +1742,14 @@ def cmd_certify(graph, args):
1721
1742
  die("the tier reports are malformed — nothing was written:\n " + "\n ".join(bad))
1722
1743
 
1723
1744
  missing = [x for x in TIERS if x not in reports]
1724
- if missing:
1745
+ if node.get("risk") in ONE_TIER_RISK:
1746
+ if len(missing) == len(TIERS):
1747
+ die("certification of an %s node needs one blind tier report (unit, seam or "
1748
+ "product); none was given" % node.get("risk"))
1749
+ elif missing:
1725
1750
  die("certification is missing the %s report(s) — all three are required, because "
1726
- "the level nobody read is the level the defect survives at"
1727
- % ", ".join("`%s`" % m for m in missing))
1751
+ "the level nobody read is the level the defect survives at (an R1 node "
1752
+ "certifies with one)" % ", ".join("`%s`" % m for m in missing))
1728
1753
  sclass = node.get("surface_class")
1729
1754
  if sclass in VISUAL_REQUIRED and VISUAL_TIER not in reports:
1730
1755
  die("certification is missing the `%s` report — %s is a %s surface, and on one the "
@@ -1743,6 +1768,18 @@ def cmd_certify(graph, args):
1743
1768
  head = ""
1744
1769
 
1745
1770
  prior = node.get("certification") or {}
1771
+ try:
1772
+ _t = subprocess.run(["git", "rev-parse", "HEAD^{tree}"], capture_output=True, text=True)
1773
+ tree = _t.stdout.strip() if _t.returncode == 0 else ""
1774
+ except OSError:
1775
+ tree = ""
1776
+ prior_kinds = list(prior.get("kinds") or [])
1777
+ if (tree and prior.get("tree") == tree
1778
+ and (prior_kinds[-1] if prior_kinds else "blind") == args.kind):
1779
+ die("%s: round %d already read tree %s as a %s round: re-running a tier on "
1780
+ "unchanged input repeats its answer. Change the code, run the other kind of round, "
1781
+ "or record a blocker (references/loop-guard.md). Nothing was written."
1782
+ % (nid, int(prior.get("round") or 0), tree[:12], args.kind))
1746
1783
  round_no = int(prior.get("round") or 0) + 1
1747
1784
  tiers_now = {x: reports[x]["verdict"] for x in tiers}
1748
1785
  history = list(prior.get("history") or []) + [tiers_now]
@@ -1754,6 +1791,8 @@ def cmd_certify(graph, args):
1754
1791
  "history": history,
1755
1792
  "kinds": kinds,
1756
1793
  }
1794
+ if tree:
1795
+ node["certification"]["tree"] = tree
1757
1796
 
1758
1797
  failed = [x for x in tiers if tiers_now[x] == "fail"]
1759
1798
 
@@ -1761,6 +1800,20 @@ def cmd_certify(graph, args):
1761
1800
  # operator needs named; counting it here is what makes the loop visible.
1762
1801
  churning = [x for x in tiers
1763
1802
  if len(history) >= 2 and all(h.get(x) == "fail" for h in history)]
1803
+ failed_rounds = sum(1 for h in history if any(v == "fail" for v in h.values()))
1804
+ stop = bool(failed) and failed_rounds >= args.ceiling
1805
+ if stop:
1806
+ first = next((f for x in failed for f in reports[x]["findings"]
1807
+ if f.get("severity") == "breaks"), {})
1808
+ reason = ("certification ceiling: %d failing round(s), ceiling %d; failing tier(s) %s; "
1809
+ "%s; first break %s: %s; check: %s. Blocker row owed on the board"
1810
+ % (failed_rounds, args.ceiling, ", ".join(failed),
1811
+ ("stuck on " + ", ".join(churning)) if churning else "churn across levels",
1812
+ first.get("where", "?"), first.get("what", "?"), first.get("check", "?")))
1813
+ reason = " ".join(reason.split())
1814
+ node["status"] = "parked"
1815
+ node["parked_reason"] = reason
1816
+ revise(graph, "park", nid, reason)
1764
1817
 
1765
1818
  save(args.graph, graph)
1766
1819
 
@@ -1774,9 +1827,10 @@ def cmd_certify(graph, args):
1774
1827
  print(" [%s] %s — %s" % (tier, f["where"], f["what"]), file=sys.stderr)
1775
1828
  print(" fix: %s" % f.get("fix", "(not stated)"), file=sys.stderr)
1776
1829
  print(" check: %s" % f["check"], file=sys.stderr)
1777
- if round_no >= args.ceiling:
1778
- print("\n%s has been certified %d time(s), at or over the ceiling of %d."
1779
- % (nid, round_no, args.ceiling), file=sys.stderr)
1830
+ if stop:
1831
+ print("\n%s has failed %d round(s), at the ceiling of %d. STOPPED: the node is "
1832
+ "parked with this evidence as its reason; record a blocker row and move on."
1833
+ % (nid, failed_rounds, args.ceiling), file=sys.stderr)
1780
1834
  if churning:
1781
1835
  print("The same tier has failed every round: %s. That is not a fix "
1782
1836
  "away — the level itself is being misread, or the node is the "
@@ -1785,6 +1839,8 @@ def cmd_certify(graph, args):
1785
1839
  else:
1786
1840
  print("No single tier is failing every round, so this is churn across "
1787
1841
  "levels rather than one stuck level.", file=sys.stderr)
1842
+ if stop:
1843
+ return 7
1788
1844
  print("\nThe node stays open. Round %d is recorded on it." % round_no,
1789
1845
  file=sys.stderr)
1790
1846
  return 1
@@ -1843,7 +1899,7 @@ def cmd_certify(graph, args):
1843
1899
  fh.write("\n")
1844
1900
  os.replace(tmp, out)
1845
1901
  print("%s: certified at %s in round %d" % (nid, ", ".join(tiers), round_no))
1846
- owed = ROUNDS_OWED.get(node.get("risk"))
1902
+ owed, _adv = owed_rounds(node)
1847
1903
  short = owed_shortfall(node)
1848
1904
  if short:
1849
1905
  print("round %d: %s — not closable yet. The next round reads the node's whole diff at "
@@ -1964,7 +2020,7 @@ def cmd_close(graph, args):
1964
2020
  if short and not exc:
1965
2021
  die("%s is %s: %s — not closable yet. Run the next round with `graph.py certify`, "
1966
2022
  "or record an authorized exception with `waive`. Nothing was written."
1967
- % (nid, node["risk"], short))
2023
+ % (nid, node.get("risk") or "a %s surface" % node.get("surface_class"), short))
1968
2024
  if cert:
1969
2025
  tiers = cert.get("tiers") or {}
1970
2026
  failing = sorted(k for k, val in tiers.items() if val != "pass")
@@ -2073,8 +2129,8 @@ VERBS = {
2073
2129
  "wait": (cmd_wait, "record a watched external wait, without executing its locator"),
2074
2130
  "resume": (cmd_wait, "resolve the exact wait identity and return to pending"),
2075
2131
  "wave": (cmd_wave, "move a node to another wave, carrying the reason"),
2076
- "certify": (cmd_certify, "require three independent tier reports (four on a "
2077
- "flagship or product surface), then emit the verdict "
2132
+ "certify": (cmd_certify, "require three independent tier reports (one on an R1 node, "
2133
+ "four on a flagship or product surface), then emit the verdict "
2078
2134
  "`close` consumes"),
2079
2135
  "close": (cmd_close, "consume a verdict, close one node and re-plan"),
2080
2136
  }
@@ -2128,8 +2184,9 @@ def main(argv=None):
2128
2184
  help="the round's kind; R3 owes one `adversarial` round "
2129
2185
  "(references/certification.md → *Rounds owed by risk class*)")
2130
2186
  p_cert.add_argument("--ceiling", type=int, default=3,
2131
- help="rounds after which the output names the churning tier; it "
2132
- "measures rather than stops (references/loop-guard.md)")
2187
+ help="failing rounds after which certify STOPS: the node is parked "
2188
+ "with the evidence and the run records a blocker row and moves "
2189
+ "on, exit 7 (references/loop-guard.md)")
2133
2190
 
2134
2191
  for verb in ("claim", "release", "recover", "complete"):
2135
2192
  made[verb].add_argument("--authority", required=True,