task-pipeline-skill 1.95.0 → 1.96.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 (33) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +2 -1
  3. package/SKILL-CARD.md +1 -1
  4. package/evals/RESULTS.md +55 -8
  5. package/evals/cases/task-pipeline.json +139 -0
  6. package/evals/fixtures/lessons/expect-match.sh +30 -0
  7. package/evals/fixtures/lessons/index.jsonl +2 -0
  8. package/evals/fixtures/waves-unclaimed/expect-refusal.sh +26 -0
  9. package/evals/fixtures/waves-unclaimed/inventory.json +33 -0
  10. package/evals/fixtures/waves-unclaimed/modules.json +4 -0
  11. package/evals/fixtures/waves-unclaimed/plan.json +5 -0
  12. package/evals/task-pipeline.evals.json +104 -0
  13. package/package.json +1 -1
  14. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +5 -2
  16. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -0
  17. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +19 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +5 -2
  19. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +7 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  21. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +145 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +9 -5
  23. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  25. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +8 -0
  26. package/plugins/task-pipeline/skills/task-pipeline/references/review.md +7 -2
  27. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +6 -2
  28. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +9 -6
  29. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +219 -3
  31. package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +14 -0
  32. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +15 -1
  33. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +26 -2
@@ -97,7 +97,8 @@ This spans the whole run, not one stage:
97
97
  finished.
98
98
 
99
99
  It stops for exactly four things: an unmet `manual` gate, a block it cannot resolve, a
100
- genuine ambiguity the brief does not answer, and completion.
100
+ genuine ambiguity the brief does not answer, and completion — the iteration's finish line
101
+ reached ([`finish-line.md`](finish-line.md)), never a search that ran dry.
101
102
 
102
103
  **A block or an ambiguity stops its node, not the run.** An ambiguity whose answer is
103
104
  internal and reversible is not a stop at all: the run takes the recommended answer, records
@@ -112,6 +113,11 @@ recorded reasons: external waits go on watchers, human decisions go into the que
112
113
  batch. Exit 4 does not prove a human gate or completion. A watcher keeps the recorded
113
114
  next action alive while no independent node is runnable. **When the board is empty, say so once**, and do not invent work to fill it.
114
115
 
116
+ **Stop is not idle.** A finish line reached closes that iteration — stages 9–10, the
117
+ hand-back, the retro — and on an armed loop the highest-priority board row starts a new
118
+ iteration with its own contract. Re-opening the closed one to polish it is not continuing
119
+ ([`finish-line.md`](finish-line.md) → *Stop is not idle*).
120
+
115
121
  **This extends [`build.md`](build.md); it does not replace it.** That file's
116
122
  *Continuous execution* rule governs the inside of a stage-5 execution — do not
117
123
  check in between the tasks of a plan you were told to execute. It is unconditional
@@ -59,6 +59,7 @@ Custom profiles use their own declared stages and doctrine, with the kernel inta
59
59
  | **first run in a project** (new or existing) | `references/adoption.md` |
60
60
  | **first run · the entry audit** (offered once) | `references/setup.md` |
61
61
  | **what travels with the bundle vs stays in a project** | `references/portability.md` |
62
+ | 0–10 · when an iteration stops — the contract fixed at intake, triage at discovery, the round budget | `references/finish-line.md` |
62
63
  | any repeating loop | `references/loop-guard.md` |
63
64
  | run-wide · what the run **leaves running and leaves behind** — every gate, and stage 10 | `references/residue.md` |
64
65
  | run-wide · what the run **prints about itself** — the rail, the iteration line | `references/progress.md` |
@@ -0,0 +1,145 @@
1
+ # The finish line — when an iteration stops
2
+
3
+ An iteration stops when the work it promised is done **on the artefact the operator uses**.
4
+ It does not stop when a search runs dry, because a search never does: every reading finds
5
+ something, every fix is new surface for the next reading, and find → fix → find has no end of
6
+ its own. This file is that end. The verdict is computed by `scripts/waves.py finish`.
7
+
8
+ ## Contents
9
+
10
+ - The contract, fixed at intake
11
+ - Triage at discovery
12
+ - Rounds and the stop
13
+ - Computing the verdict
14
+ - Stop is not idle
15
+ - The finish block
16
+ - Rationalizations
17
+
18
+ ## The contract, fixed at intake
19
+
20
+ The brief's `## Finish line` ([`../templates/brief.md`](../templates/brief.md)) carries five
21
+ fields, written at stage 0 and confirmed with the brief:
22
+
23
+ - **Objective** — one sentence: what is true when this iteration is done.
24
+ - **Exit checks** — REQ ids, each with its command, run on the artefact the operator uses:
25
+ released, installed or deployed. A branch is not that artefact.
26
+ - **Scope** — the modules, paths and REQ ids this iteration may change, as a list.
27
+ - **Budget** — `run.finish.maxRounds` (default 3) and `run.finish.maxReleases` (default 2),
28
+ from `pipeline.json` ([`../pipeline.schema.json`](../pipeline.schema.json)).
29
+ - **Base** — the commit the iteration started from.
30
+
31
+ Widening the scope is a recorded revision of the brief, with the operator's go. A finding is
32
+ not one.
33
+
34
+ ## Triage at discovery
35
+
36
+ Every finding that reaches a round is classed when it is found, before anyone fixes it. First
37
+ match wins. A defect in a node's own diff, caught before the node closes, follows
38
+ [`review.md`](review.md) instead: Critical and Important block that node.
39
+
40
+ **Blocking**, when:
41
+ - **B1** — an exit check is red.
42
+ - **B2** — it is, or once released would be, a hotfix-lane entry. The list's single home is [`iteration.md`](iteration.md)
43
+ → *No fix travels alone*; restated here, kept equal by the validator:
44
+ - production is down
45
+ - data is being lost or corrupted
46
+ - a credential has leaked
47
+ - a security hole is open to someone outside
48
+ - money or a legal obligation is failing
49
+ - **B3** — the failing scenario passes at Base: this iteration broke it.
50
+ - **B4** — a REQ marked `verified`, or a claim this iteration publishes, is false on the
51
+ artefact. A published claim beyond the REQs may instead be narrowed in the same change.
52
+
53
+ **Deferred** — real, with a receipt, and no B test matches. It becomes a board row with its
54
+ evidence, its priority inputs ([`prioritisation.md`](prioritisation.md)) and
55
+ `found-in: <iteration>`. It is not fixed now.
56
+
57
+ **Noise** — no reproducible receipt; a duplicate (append its evidence to the existing row); a
58
+ Minor with no behavioural consequence; or an environment transient named by its cause. One
59
+ ledger line, nothing else.
60
+
61
+ Severity words, effort and "it is a one-line fix" never change the class. Each finding leaves
62
+ one line in the run ledger ([`../templates/run.md`](../templates/run.md)):
63
+
64
+ ```
65
+ finding: <id> — blocking:B<n> | deferred:B-NNN | noise — round <N> — base-repro yes|no|n/a
66
+ ```
67
+
68
+ ## Rounds and the stop
69
+
70
+ A **round** is every exit check run once, plus one independent reading over the Scope: an
71
+ audit axis, an observer, or the post-release readback. Rotating the axis
72
+ ([`audit.md`](audit.md)) never adds a round. Only new blocking findings count.
73
+
74
+ - **Done** — every exit check green on the artefact, and two consecutive rounds with no new
75
+ blocking finding. The second of them may read only the last fix's diff.
76
+ - **Diverging** — new blocking findings did not fall from one round to the next, or
77
+ self-inflicted ≥ new. Run [`loop-guard.md`](loop-guard.md)'s break protocol.
78
+ - **Budget spent** — `maxRounds` or `maxReleases` reached before done: a blocker open, or the
79
+ second clean round still owed. Stop and hand the iteration back: each open blocker becomes a
80
+ top board row marked `needs-operator`. No round runs past the budget without the operator's
81
+ go, given with the numbers; a done reached past it without that go is still `budget`.
82
+
83
+ The finish word is one of three: `done`; `budget`, the budget reached before done; `blocked`,
84
+ the rounds diverging, handed to the loop guard. Deferred findings never extend an iteration.
85
+
86
+ Each round leaves one ledger line, clean rounds included, because a clean round leaves no
87
+ finding to count:
88
+
89
+ ```
90
+ round: <N> — <reading> — new blocking <k>
91
+ ```
92
+
93
+ ## Computing the verdict
94
+
95
+ ```
96
+ python3 scripts/waves.py finish --inventory <inventory.json> --ledger .task-pipeline/run.md \
97
+ --max-rounds 3 --max-releases 2 --releases <n> --checks <k>/<m> --artefact <name>@<sha>
98
+ ```
99
+
100
+ `--rounds N` may stand in for `--ledger`; with neither the count is unknown and the script
101
+ refuses. Exit codes:
102
+
103
+ - `0` — done: the last two rounds found no new blocking item, none is open, and every exit
104
+ check is green on the artefact.
105
+ - `3` — continue: a blocker open, a second clean round owed, or no `--checks` given.
106
+ - `5` — budget: reached before done, or done past it without `--operator-go`.
107
+ - `6` — blocked: diverging; run the loop guard's break protocol.
108
+ - `1` — malformed: the inventory, the ledger or the arguments, each reason named.
109
+
110
+ ## Stop is not idle
111
+
112
+ Reaching the finish line closes the iteration. It does not end the work.
113
+
114
+ 1. Close it: stages 9–10, the hand-back ([`progress.md`](progress.md) → *The hand-back*), the
115
+ retrospective.
116
+ 2. On an armed loop ([`continuity.md`](continuity.md)), take the highest-priority board row as a
117
+ **new** iteration with its own contract. Choose by priority, not recency: the row found last
118
+ is not next because it is fresh.
119
+ 3. Re-opening a closed iteration to polish it is the loop this rule ends.
120
+
121
+ ## The finish block
122
+
123
+ Printed at stage 10, beside the verdict, and in the hand-back's `FINISH` field:
124
+
125
+ ```
126
+ finish: done | budget | blocked — rounds <n>/<max> — releases <n>/<max>
127
+ exit checks: <k>/<m> green on <artefact>@<sha>
128
+ blocking: <n> open · <n> fixed
129
+ deferred: <n> → <ids, top priority first>
130
+ noise: <n>
131
+ known risks: <deferred rows at blast 3 or rung I4+, or none>
132
+ next: B-NNN (new iteration) | board empty
133
+ ```
134
+
135
+ ## Rationalizations
136
+
137
+ | Excuse | Reality |
138
+ |---|---|
139
+ | "One more pass, it keeps finding things" | Every reading finds something. Only a new blocking finding argues for another round |
140
+ | "It is a one-line fix, do it now" | Effort never changes the class. A deferred finding is a board row |
141
+ | "This one is critical" | A severity word is not a B test. Name B1–B4, or defer it |
142
+ | "Rotate the axis and keep going" | Rotation happens inside the round budget, never on top of it |
143
+ | "The budget is nearly enough, one more round" | A round past the budget needs the operator's go, with the numbers |
144
+ | "The deferred list is long, so we are not done" | Deferred findings never extend an iteration |
145
+ | "Re-open the last iteration, it is nearly perfect" | That is the loop this rule ends. Take the top board row as a new iteration |
@@ -36,7 +36,8 @@ A fix designed against today's state is the intermediate state that
36
36
  seam, and the fix is then paid for twice. The finding gets an id in its owning register
37
37
  and lands on a canvas node. Its wave decides when it is built.
38
38
 
39
- **The hotfix lane is the only exception, and it is closed.** It has five entries:
39
+ **The hotfix lane is the only exception, and it is closed.** This list is its single home;
40
+ [`finish-line.md`](finish-line.md)'s blocking test B2 cites it. It has five entries:
40
41
  - production is down;
41
42
  - data is being lost or corrupted;
42
43
  - a credential has leaked;
@@ -57,12 +58,12 @@ reason. "It is quick" is not one of the five entries.
57
58
  | 4 | Question round | answers from the sources, then one batch to the operator | every question is answered, assumed, or blocks a named node |
58
59
  | 5 | Plan | the work graph with waves and risk classes — `scripts/waves.py sync-graph` writes each node's `wave` and `risk` from the plan, so `graph.py close` enforces the rounds it owes — and one packet per node | every REQ lands on a node; [`planning.md`](planning.md)'s set comparison holds |
59
60
  | 6 | Build | parallel agents, each owning disjoint files | each node is committed on its branch, with its own checks green |
60
- | 7 | Verify | rounds per node, starting as each node lands | each node passes its risk class's rounds |
61
+ | 7 | Verify | rounds per node, starting as each node lands; each finding triaged at discovery ([`finish-line.md`](finish-line.md)) | each node passes its risk class's rounds |
61
62
  | 8 | Seam check | cross-module checks per seam group | every seam the canvas names is exercised end to end |
62
63
  | 9 | Docs | stage 9 over everything the release iteration shipped | the doc gate passes and the docs match the code |
63
64
  | 10 | Release | versions, tags, pins, changelogs, in dependency order | the published artefacts resolve |
64
- | 11 | Post-release check | the stage-8 trio on what was released | every item the release claims closed is checked on the released artefact |
65
- | 12 | Next pool | the re-measured inventory and the proposal | the hand-back is sent |
65
+ | 11 | Post-release check | the stage-8 trio on what was released, read as a finish-line round | every item the release claims closed is checked on the released artefact, and `finish:` is computed |
66
+ | 12 | Next pool | the re-measured inventory and the proposal | the hand-back, with its `finish:` line, is sent |
66
67
  | 13 | Retrospective | the project retro, plus the shared lesson store where one exists | written last ([`retrospective.md`](retrospective.md)) |
67
68
 
68
69
  Steps 1–4 are stages 0–4 run once. Steps 6–8 are stages 5–8 per wave, and a wave's
@@ -218,7 +219,10 @@ actually published, installed or deployed — never on the branch. Each item the
218
219
  claims closed is re-checked against the released artefact. For installed packages and
219
220
  skills, follow [`deploy-targets.md`](deploy-targets.md) → *The verification trio*: update
220
221
  within the authorized rollout and read back each claimed installed channel. A failure goes to the hotfix
221
- lane if it is one of its five entries. Otherwise it goes to the next pool, with an id.
222
+ lane if it is one of its five entries. Every post-release failure is triaged at the finish
223
+ line ([`finish-line.md`](finish-line.md) → *Triage at discovery*): a blocking one is fixed in
224
+ this release iteration, and the corrective release counts against `run.finish.maxReleases`; a
225
+ deferred one goes to the next pool, with an id.
222
226
 
223
227
  ## The next pool
224
228
 
@@ -101,6 +101,11 @@ review cap reached — 3 rounds — artifact: test/validate.py
101
101
  - **New > self-inflicted** — the reader is still paying. Continuing is then the
102
102
  operator's call, made with the numbers in hand rather than out of fatigue.
103
103
 
104
+ At the level of a whole iteration only new **blocking** findings argue for continuing; a
105
+ deferred or noise finding is paid for on the board, not with another round
106
+ ([`finish-line.md`](finish-line.md) → *Rounds and the stop*). The class is read from the
107
+ run ledger's `finding:` lines and the rounds from its `round:` lines, never from memory.
108
+
104
109
  **The pair is the whole point.** A round count alone says how tired everyone is; the
105
110
  pair says whether the loop still produces anything. Measured on one file once, the
106
111
  guard's shapes and the run's own prose disagreed — one still paying, one exhausted —
@@ -64,6 +64,7 @@ a row pointing outside the bundle is the defect this file exists to catch.
64
64
  | The entry audit and what it inspects | `references/setup.md` |
65
65
  | The ladder, seams, axis rotation, ratchets | `references/audit.md` |
66
66
  | Loop detection and its caps | `references/loop-guard.md` |
67
+ | **The finish line** — when an iteration stops: the contract, triage at discovery, the round budget | `references/finish-line.md` |
67
68
  | **What the run prints about itself** — the header block, the rail, the iteration line | `references/progress.md` |
68
69
  | **What the run leaves running and leaves behind** — the eight classes, the `holds:` field, the teardown | `references/residue.md` |
69
70
  | **The run mode** — item-by-item pacing, default off, what it never collapses | `references/continuity.md` |
@@ -169,6 +169,9 @@ SCOPE what this claim covers: the commit, the environment, the REQ
169
169
  NOT VERIFIED what was built and NOT checked, or could not be. The literal
170
170
  `none within the stated scope` is a valid answer; an empty
171
171
  field is not
172
+ FINISH finish: done | budget | blocked — the finish block, as
173
+ finish-line.md prints it; mid-iteration, the round and
174
+ the open blocking count
172
175
 
173
176
  DECISIONS WAITING <n> each as a question with options, asked HERE
174
177
  AMBIGUITIES <n> computed, below
@@ -189,6 +192,11 @@ And `none within the stated scope` is a claim with a subject — it says *nothin
189
192
  what SCOPE names is unverified*. Leaving the field empty says nothing at all, which reads
190
193
  as the same thing and is not: canon 9a, one artifact over.
191
194
 
195
+ **`FINISH` says why the iteration stopped** ([`finish-line.md`](finish-line.md) → *The
196
+ finish block*): `done` with its exit checks green on the artefact, `budget` with each open
197
+ blocker a `needs-operator` board row, or `blocked`. A hand-back that cannot fill it has not
198
+ reached a finish line, and says which round it is in.
199
+
192
200
  **Where there is no brief** — `checkup`, `setup`, a short path — TASK quotes the
193
201
  operator's own sentence instead, marked as such. An unquotable TASK is a run that cannot
194
202
  say what it was asked, which is worth its own line.
@@ -108,14 +108,19 @@ Review in this order; stop reading the diff only when you've covered all of it.
108
108
  **Severities:**
109
109
 
110
110
  - **Critical** — wrong behavior, data loss, security hole, a red or dishonest test
111
- suite. Blocks.
111
+ suite. Blocks the node when the defect is in its diff or caused by it.
112
112
  - **Important** — a real defect or spec gap that will bite: missing requirement,
113
- unhandled failure path, a magic value the constraints named. Blocks.
113
+ unhandled failure path, a magic value the constraints named. Blocks the node when the
114
+ defect is in its diff or caused by it.
114
115
  - **Minor** — style, naming, a nit with no behavioral consequence. Never blocks;
115
116
  goes to the ledger.
116
117
  - **⚠️ Cannot verify from diff** — the requirement lives in unchanged code or spans
117
118
  tasks. Not a blocker for the reviewer; the controller resolves it.
118
119
 
120
+ **A pre-existing defect the reviewer notices** — one the diff neither contains nor
121
+ causes — does not block the node, whatever its severity. It goes to finish-line triage
122
+ ([`finish-line.md`](finish-line.md) → *Triage at discovery*).
123
+
119
124
  Formatting nits that don't change meaning are not findings. Praise is not a
120
125
  finding either.
121
126
 
@@ -103,7 +103,9 @@ never that the work was skipped quietly.
103
103
  is now stale), no open contradictions, **every
104
104
  autonomy-sweep row is answered or explicitly marked "stop and ask here"**, **a
105
105
  user-facing task's brief names its `surface_class`**, the
106
- **REQ table is written and every row names its check**, the carry-over ledger is
106
+ **REQ table is written and every row names its check**, **the brief's `## Finish line`
107
+ is filled — objective, exit checks on the artefact the operator uses, scope, budget and
108
+ base** ([`finish-line.md`](finish-line.md)), the carry-over ledger is
107
109
  seeded, **`.task-pipeline/run.md` exists and the header block has been printed**
108
110
  ([`progress.md`](progress.md)), the model decision is recorded, and the operator
109
111
  confirms the brief. Stop when a
@@ -379,7 +381,9 @@ never that the work was skipped quietly.
379
381
  surfaced that nobody asked for, every decision still waiting **asked here with
380
382
  options rather than listed**, and the ambiguity count computed from the four
381
383
  registers with its ids ([`progress.md`](progress.md) → *The hand-back*) — a run
382
- that cannot say what happened has not finished, it has stopped; **the environment is
384
+ that cannot say what happened has not finished, it has stopped; **the `finish:` line is
385
+ printed beside this verdict — `done`, `budget` or `blocked`, with its block**
386
+ ([`finish-line.md`](finish-line.md) → *The finish block*); **the environment is
383
387
  given back** — all eight classes enumerated, everything this run started ended and
384
388
  verified by re-enumerating rather than by the teardown's reply, an earlier run of
385
389
  this project ended only when **provably spent**, an item this project does not own
@@ -211,11 +211,13 @@ In this order, inside the wave rather than after it:
211
211
  1. **Verify.** Every node the wave closes is certified ([`certification.md`](certification.md)),
212
212
  most of it already, as each node landed,
213
213
  and [`audit.md`](audit.md)'s exit is re-measured over what the wave touched.
214
- 2. **Finish and fix — one fix round**, inside [`loop-guard.md`](loop-guard.md)'s caps.
215
- Findings against this wave's nodes are fixed here or parked with a board row and reason.
216
- Findings elsewhere join the inventory for a planned node; only the closed hotfix lane
217
- bypasses that design ([`iteration.md`](iteration.md) → *No fix travels alone*). Nothing
218
- is dropped silently.
214
+ 2. **Finish and fix — one fix round, for blocking findings only**, inside
215
+ [`loop-guard.md`](loop-guard.md)'s caps. Every finding is triaged when found
216
+ ([`finish-line.md`](finish-line.md) → *Triage at discovery*): blocking findings against
217
+ this wave's nodes are fixed here or parked with a board row and reason; deferred ones
218
+ become board rows; noise is one ledger line. Findings elsewhere join the inventory for a
219
+ planned node; only the closed hotfix lane bypasses that design
220
+ ([`iteration.md`](iteration.md) → *No fix travels alone*). Nothing is dropped silently.
219
221
  3. **Document** — the Doc Loop and the stage-9 propagation matrix
220
222
  ([`documentation.md`](documentation.md)), scoped to the wave: scenarios, decisions,
221
223
  docs and registers in the same change as the code.
@@ -224,7 +226,8 @@ In this order, inside the wave rather than after it:
224
226
  items, not only the board*). A re-plan is a committed `revisions` entry plus a canvas
225
227
  edit, never a quiet change.
226
228
  6. **Re-measure the inventory and re-cut the remaining waves** from the updated target —
227
- never from the original guess. New findings join the inventory with ids.
229
+ never from the original guess. New findings join the inventory with ids and their
230
+ triage class; a deferred one never adds a wave to this release.
228
231
 
229
232
  ## A board bigger than one release
230
233
 
@@ -1580,7 +1580,7 @@ def cmd_producer(graph, args):
1580
1580
  def cmd_doctrine(graph, args):
1581
1581
  """Which doctrine this run actually read — B-061.
1582
1582
 
1583
- The bundle is 52 reference files. A run reads some subset and nothing recorded which,
1583
+ The bundle is 53 reference files. A run reads some subset and nothing recorded which,
1584
1584
  so **a skipped file and a read one were indistinguishable** — the class every guard in
1585
1585
  this repository exists to catch, left standing over the doctrine itself.
1586
1586
 
@@ -30,6 +30,22 @@ reviewed; a table and a re-run can. This script is that re-run:
30
30
  refused halfway and a refused one writes nothing. Plan nodes the graph lacks are named
31
31
  and exit 1 before any write, unless --only-present
32
32
  (a plan that spans several repositories' graphs).
33
+ waves.py finish --inventory FILE --max-rounds N --max-releases M (--rounds K | --ledger RUN.md)
34
+ --checks K/M --artefact NAME@SHA [--releases R] [--operator-go]
35
+ the finish-line verdict (`references/finish-line.md`) from the triaged items, and the
36
+ finish block printed. Only `triage: blocking` items count, less those closed as
37
+ duplicate, stale or not-reproduced; deferred and noise never change the verdict. The
38
+ round count is explicit: --rounds, or the ledger's `round: <N> — <reading> — new
39
+ blocking <k>` lines, whose counts must match the inventory. Exit 0 done (the last two
40
+ rounds found no new blocking item, none is open, --checks is all green, and the rounds
41
+ are within --max-rounds or --operator-go is passed); 5 the budget reached before done
42
+ (rounds at --max-rounds, releases at --max-releases with a blocker open, or done past
43
+ the round budget without --operator-go); 6 diverging (the blocking count did not fall
44
+ from one round that found some to the next, or it did not fall and the last round's
45
+ self-inflicted >= new, from round 2 on); 3 continue (including no --checks); 1
46
+ malformed (a schema error, a blocking item without `test` or `round`, B3 with
47
+ base_repro yes, a round past the rounds run, no round count, a ledger that disagrees,
48
+ --max-rounds below 2, an artefact that is not NAME@SHA).
33
49
 
34
50
  **The plan** is any JSON whose `nodes` list `{id, wave, closes}` — a work graph that carries
35
51
  `closes` on its nodes is one, and so is a short hand-written table. A node may carry
@@ -42,7 +58,7 @@ described in `templates/inventory.schema.json` (`$defs.modules`, `$defs.plan`).
42
58
  are listed in (wave, id) order, counts are sorted. A re-cut is a diff to a table and a
43
59
  re-run, which a reviewer can read.
44
60
 
45
- Exit codes: 0 done; 1 an item lands nowhere, or the tables and the inventory disagree, or a
61
+ Exit codes (every verb but `finish`, whose own are above): 0 done; 1 an item lands nowhere, or the tables and the inventory disagree, or a
46
62
  graph move was refused; 2 a schema error, a table error (an id on two modules), usage, or an
47
63
  unreadable file. Stdlib only (`references/portability.md`).
48
64
  """
@@ -62,11 +78,18 @@ SCHEMA = HERE.parent / "templates" / "inventory.schema.json"
62
78
  GRAPH_PY = HERE / "graph.py"
63
79
  REQUIRED = ("id", "title", "area", "kind", "severity", "evidence", "proposed_fix")
64
80
  OPTIONAL = ("status", "source", "source_file", "repo", "module", "node", "wave", "release",
65
- "register_id", "blocked_by", "closable_in_one_pass", "needs_operator", "notes")
81
+ "register_id", "blocked_by", "closable_in_one_pass", "needs_operator", "notes",
82
+ "triage", "test", "round", "base_repro", "blast", "rung")
66
83
  SEVERITY = ("critical", "high", "medium", "low", "info")
67
84
  STATUS = ("open", "changed", "closed", "fixed", "fixed-elsewhere", "duplicate", "moved", "stale",
68
85
  "not-reproduced", "carried")
69
86
  OPEN = ("open", "changed")
87
+ # The finish line's vocabulary (references/finish-line.md → Triage at discovery). The
88
+ # validator holds these to the doctrine, the run-ledger grammar and inventory/1's enums.
89
+ TRIAGE = ("blocking", "deferred", "noise")
90
+ TESTS = ("B1", "B2", "B3", "B4")
91
+ BASE_REPRO = ("yes", "no", "n/a")
92
+ RUNGS = ("I1", "I2", "I3", "I4", "I5")
70
93
  ID = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,63}$")
71
94
  KIND = re.compile(r"^[a-z][a-z0-9-]*$")
72
95
  EXT = re.compile(r"^x-[a-z0-9-]+$")
@@ -115,9 +138,34 @@ def item_errors(x: object) -> list[str]:
115
138
  for k in ("closable_in_one_pass", "needs_operator"):
116
139
  if k in x and not isinstance(x[k], bool):
117
140
  errs.append(f"{iid}: `{k}` is not a boolean")
141
+ if "triage" in x and x["triage"] not in TRIAGE:
142
+ errs.append(f"{iid}: `triage` {x['triage']!r} is not one of {', '.join(TRIAGE)}")
143
+ if "test" in x and x["test"] not in TESTS:
144
+ errs.append(f"{iid}: `test` {x['test']!r} is not one of {', '.join(TESTS)}")
145
+ if "round" in x and not _posint(x["round"]):
146
+ errs.append(f"{iid}: `round` is not an integer from 1")
147
+ if "base_repro" in x and x["base_repro"] not in BASE_REPRO:
148
+ errs.append(f"{iid}: `base_repro` {x['base_repro']!r} is not one of {', '.join(BASE_REPRO)}")
149
+ if "blast" in x and not (_posint(x["blast"]) and x["blast"] <= 3):
150
+ errs.append(f"{iid}: `blast` is not 1, 2 or 3")
151
+ if "rung" in x and x["rung"] not in RUNGS:
152
+ errs.append(f"{iid}: `rung` {x['rung']!r} is not one of {', '.join(RUNGS)}")
153
+ if x.get("triage") == "blocking":
154
+ errs += [f"{iid}: blocking without `{k}` — a blocking finding names the B test that "
155
+ "matched and the round that found it" for k in ("test", "round") if k not in x]
156
+ if x.get("test") == "B3" and x.get("base_repro") == "yes":
157
+ errs.append(f"{iid}: B3 says it passes at Base, and `base_repro: yes` says it fails "
158
+ "there — one of the two is wrong")
159
+ elif "test" in x:
160
+ errs.append(f"{iid}: `test` on an item that is not blocking — a B test is what makes a "
161
+ "finding blocking; without one it is deferred or noise")
118
162
  return errs
119
163
 
120
164
 
165
+ def _posint(v) -> bool:
166
+ return isinstance(v, int) and not isinstance(v, bool) and v >= 1
167
+
168
+
121
169
  def schema_errors(items: list) -> list[str]:
122
170
  """inventory/1 itself, applied where `jsonschema` is installed. `item_errors` is the
123
171
  hand copy every install has; test/waves_test.py holds the two to one verdict."""
@@ -504,6 +552,163 @@ def cmd_sync_graph(a) -> int:
504
552
  return 0
505
553
 
506
554
 
555
+ # ---------------------------------------------------------------------------- the finish line
556
+
557
+ # A blocking item closed as one of these was never a blocker: it counts in no round.
558
+ NOT_COUNTED = ("duplicate", "stale", "not-reproduced")
559
+ ROUND_LINE = re.compile(r"^round:\s*(\d+)\s*[—-]\s*(.*?)\s*[—-]\s*new blocking\s*(\d+)\s*$")
560
+ ARTEFACT = re.compile(r"^[^@\s]+@[0-9a-f]{7,40}$")
561
+
562
+
563
+ def _priority(x: dict):
564
+ """Top priority first: the impact rung, then blast, then severity, then the id. Where an
565
+ item was found is not an input — a fresh finding is not next because it is fresh."""
566
+ rung = RUNGS.index(x["rung"]) + 1 if x.get("rung") in RUNGS else 0
567
+ return (-rung, -(x.get("blast") or 0), SEVERITY.index(x["severity"]), _natural(x["id"]))
568
+
569
+
570
+ def _board_id(x: dict) -> str:
571
+ return x.get("register_id") or x["id"]
572
+
573
+
574
+ def ledger_rounds(path: pathlib.Path) -> dict[int, int]:
575
+ """{round: new blocking} from the run ledger's `round:` lines (templates/run.md)."""
576
+ try:
577
+ text = path.read_text(encoding="utf-8")
578
+ except (OSError, UnicodeError) as exc:
579
+ raise Fail(1, [f"the run ledger {path} cannot be read ({type(exc).__name__})"])
580
+ out, errs = {}, []
581
+ for n, line in enumerate(text.splitlines(), 1):
582
+ if not line.startswith("round:"):
583
+ continue
584
+ m = ROUND_LINE.match(line.strip())
585
+ if not m:
586
+ errs.append(f"{path}:{n}: not `round: <N> — <reading> — new blocking <k>`")
587
+ continue
588
+ r = int(m[1])
589
+ if r < 1 or r in out:
590
+ errs.append(f"{path}:{n}: round {r} is below 1 or written twice")
591
+ out[r] = int(m[3])
592
+ if out and sorted(out) != list(range(1, max(out) + 1)):
593
+ errs.append(f"{path}: rounds {sorted(out)} are not 1..{max(out)} — a round line is missing")
594
+ if errs:
595
+ raise Fail(1, errs)
596
+ return out
597
+
598
+
599
+ def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases: int,
600
+ releases: int, checks: tuple[int, int] | None,
601
+ operator_go: bool = False) -> tuple[int, str, dict]:
602
+ """(exit code, word, facts) — the finish line computed from the triaged items alone.
603
+ Deferred and noise items are counted for the block and never read for the verdict."""
604
+ triaged = [x for x in items if "triage" in x]
605
+ late = [f"{x['id']}: found in round {x['round']}, and only {rounds} round(s) ran"
606
+ for x in triaged if _posint(x.get("round")) and x["round"] > rounds]
607
+ if late:
608
+ raise Fail(1, late)
609
+ blocking = [x for x in triaged if x["triage"] == "blocking" and _status(x) not in NOT_COUNTED]
610
+ new = [sum(1 for x in blocking if x["round"] == r) for r in range(1, rounds + 1)]
611
+ selfinf = sum(1 for x in blocking if x["round"] == rounds and x.get("base_repro") == "no")
612
+ open_b = [x for x in blocking if _needs_node(x)]
613
+ facts = {"rounds": rounds, "new": new, "open": open_b, "fixed": len(blocking) - len(open_b),
614
+ "self_inflicted": selfinf}
615
+ clean = rounds >= 2 and new[-1] == 0 and new[-2] == 0 and not open_b
616
+ green = checks is not None and checks[0] == checks[1]
617
+ if clean and green:
618
+ if rounds <= max_rounds:
619
+ return 0, "done", facts
620
+ if operator_go:
621
+ return 0, "done", dict(facts, why=f"past budget — rounds {rounds}/{max_rounds} on the "
622
+ "operator's go")
623
+ return 5, "budget", dict(facts, why=f"past budget — operator's go required: done at round "
624
+ f"{rounds}/{max_rounds}")
625
+ if rounds >= max_rounds:
626
+ owed = (f"{len(open_b)} blocker(s) open" if open_b else
627
+ "exit checks not supplied" if checks is None else
628
+ "exit checks not all green" if not green else "the second clean round still owed")
629
+ return 5, "budget", dict(facts, why=f"rounds {rounds}/{max_rounds} reached before done — {owed}")
630
+ if open_b and releases >= max_releases:
631
+ return 5, "budget", dict(facts, why=f"releases {releases}/{max_releases} reached before "
632
+ f"done — {len(open_b)} blocker(s) open")
633
+ if rounds >= 2 and new[-1] >= new[-2] > 0:
634
+ return 6, "blocked", dict(facts, why=f"diverging — new blocking {new[-2]} → {new[-1]} "
635
+ "did not fall; run the loop guard's break protocol")
636
+ if rounds >= 2 and new[-1] >= new[-2] and selfinf and selfinf >= new[-1] - selfinf:
637
+ return 6, "blocked", dict(facts, why=f"diverging — round {rounds}: {selfinf} self-inflicted "
638
+ f"≥ {new[-1] - selfinf} new, and the count did not "
639
+ "fall; run the loop guard's break protocol")
640
+ why = (f"{len(open_b)} blocker(s) open" if open_b else
641
+ "exit checks not supplied — pass --checks K/M read on the artefact" if checks is None else
642
+ "exit checks not all green" if not green else
643
+ "a second round with no new blocking finding is owed")
644
+ return 3, "continue", dict(facts, why=why)
645
+
646
+
647
+ def cmd_finish(a) -> int:
648
+ try:
649
+ _, items = load_inventory(pathlib.Path(a.inventory))
650
+ except Fail as exc:
651
+ raise Fail(1, exc.reasons) # a malformed inventory is malformed here, never usage
652
+ for flag, v, least in (("--max-rounds", a.max_rounds, 2), ("--max-releases", a.max_releases, 1),
653
+ ("--releases", a.releases, 0), ("--rounds", a.rounds, 0)):
654
+ if v is not None and v < least:
655
+ raise Fail(1, [f"{flag} {v} is below {least}" + (
656
+ " — done needs two clean rounds, so a budget of one can never be met"
657
+ if flag == "--max-rounds" else "")])
658
+ # THE ROUND COUNT IS EXPLICIT. A clean round leaves no item, so a count derived from the
659
+ # items can never see the two clean rounds `done` needs: the verdict would stay
660
+ # `continue` for ever. It comes from --rounds or from the ledger's `round:` lines.
661
+ led = ledger_rounds(pathlib.Path(a.ledger)) if a.ledger else None
662
+ if led is not None and not led and a.rounds is None:
663
+ raise Fail(1, [f"{a.ledger} carries no `round:` line and --rounds is absent — the round "
664
+ "count cannot be read"])
665
+ if led is None and a.rounds is None:
666
+ raise Fail(1, ["the round count is not given — pass --rounds N, or --ledger with the run "
667
+ "ledger's `round:` lines; clean rounds leave no item to count"])
668
+ rounds = a.rounds if a.rounds is not None else max(led)
669
+ if led and a.rounds is not None and a.rounds != max(led):
670
+ raise Fail(1, [f"--rounds {a.rounds} and the ledger's {max(led)} round(s) disagree"])
671
+ if led:
672
+ counted = {r: sum(1 for x in items if x.get("triage") == "blocking" and x.get("round") == r)
673
+ for r in led}
674
+ bad = [f"round {r}: the ledger says new blocking {k}, the inventory holds {counted[r]}"
675
+ for r, k in sorted(led.items()) if counted[r] != k]
676
+ if bad:
677
+ raise Fail(1, bad)
678
+ checks = None
679
+ if a.checks:
680
+ m = re.fullmatch(r"(\d+)/(\d+)", a.checks.strip())
681
+ if not m or int(m[1]) > int(m[2]) or int(m[2]) == 0:
682
+ raise Fail(1, [f"--checks {a.checks!r} is not K/M green of M exit checks"])
683
+ checks = (int(m[1]), int(m[2]))
684
+ if a.artefact and not ARTEFACT.match(a.artefact):
685
+ raise Fail(1, [f"--artefact {a.artefact!r} is not NAME@SHA (a commit of 7-40 hex digits)"])
686
+ code, word, f = finish_verdict(items, rounds, a.max_rounds, a.max_releases, a.releases, checks,
687
+ a.operator_go)
688
+ deferred = sorted((x for x in items if x.get("triage") == "deferred"), key=_priority)
689
+ noise = [x for x in items if x.get("triage") == "noise"]
690
+ risks = [x for x in deferred if x.get("blast") == 3 or x.get("rung") in ("I4", "I5")]
691
+ pool = sorted((x for x in items if x.get("triage") != "blocking" and _needs_node(x)
692
+ and x.get("triage") != "noise"), key=_priority)
693
+ budget = f"rounds {f['rounds']}/{a.max_rounds} — releases {a.releases}/{a.max_releases}"
694
+ if word == "continue":
695
+ print(f"next round: {f['rounds'] + 1}/{a.max_rounds} — {f['why']} — {budget}")
696
+ else:
697
+ print(f"finish: {word} — {budget}" + (f" — {f['why']}" if f.get("why") else ""))
698
+ print(" exit checks: " + (f"{checks[0]}/{checks[1]} green on {a.artefact or '<artefact not named>'}"
699
+ if checks else "not supplied — read them on the artefact (--checks K/M)"))
700
+ print(f" blocking: {len(f['open'])} open · {f['fixed']} fixed — new per round "
701
+ f"{' · '.join(str(n) for n in f['new']) or 'none'}"
702
+ + (f" — open: {', '.join(x['id'] for x in f['open'])}" if f["open"] else ""))
703
+ top = ", ".join(_board_id(x) for x in deferred[:5]) + (" …" if len(deferred) > 5 else "")
704
+ print(f" deferred: {len(deferred)}" + (f" → {top}" if deferred else ""))
705
+ print(f" noise: {len(noise)}")
706
+ print(" known risks: " + (", ".join(_board_id(x) for x in risks) or "none"))
707
+ print(" next: " + (f"{_board_id(pool[0])} (new iteration)" if pool
708
+ else "highest-priority board row (read the board)"))
709
+ return code
710
+
711
+
507
712
  def main(argv: list[str]) -> int:
508
713
  p = argparse.ArgumentParser(prog="waves.py", description=__doc__.split("\n")[0])
509
714
  sub = p.add_subparsers(dest="cmd", required=True)
@@ -531,9 +736,20 @@ def main(argv: list[str]) -> int:
531
736
  "a lowering is refused")
532
737
  s.add_argument("--only-present", action="store_true",
533
738
  help="ignore plan nodes this graph does not hold")
739
+ f = sub.add_parser("finish", help="the finish-line verdict from the triaged items, and the block")
740
+ f.add_argument("--inventory", required=True)
741
+ f.add_argument("--max-rounds", type=int, required=True, help="run.finish.maxRounds (default 3, at least 2)")
742
+ f.add_argument("--max-releases", type=int, required=True, help="run.finish.maxReleases (default 2)")
743
+ f.add_argument("--releases", type=int, default=0, help="releases this iteration made so far")
744
+ f.add_argument("--rounds", type=int, help="rounds run, clean ones included")
745
+ f.add_argument("--ledger", help="the run ledger; its `round:` lines are the round count")
746
+ f.add_argument("--operator-go", action="store_true",
747
+ help="the operator's recorded go for rounds past --max-rounds")
748
+ f.add_argument("--checks", help="K/M — exit checks green on the artefact")
749
+ f.add_argument("--artefact", help="what they ran on, as NAME@SHA")
534
750
  a = p.parse_args(argv)
535
751
  verbs = {"merge": cmd_merge, "assign": cmd_assign, "count": cmd_count, "validate": cmd_validate,
536
- "sync-graph": cmd_sync_graph}
752
+ "sync-graph": cmd_sync_graph, "finish": cmd_finish}
537
753
  try:
538
754
  return verbs[a.cmd](a)
539
755
  except Fail as exc: