task-pipeline-skill 1.95.0 → 1.97.1

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 (40) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +13 -9
  3. package/SKILL-CARD.md +2 -2
  4. package/cursor/rules/task-pipeline.mdc +11 -7
  5. package/evals/RESULTS.md +55 -8
  6. package/evals/cases/task-pipeline.json +139 -0
  7. package/evals/fixtures/lessons/expect-match.sh +30 -0
  8. package/evals/fixtures/lessons/index.jsonl +2 -0
  9. package/evals/fixtures/waves-unclaimed/expect-refusal.sh +26 -0
  10. package/evals/fixtures/waves-unclaimed/inventory.json +33 -0
  11. package/evals/fixtures/waves-unclaimed/modules.json +4 -0
  12. package/evals/fixtures/waves-unclaimed/plan.json +5 -0
  13. package/evals/task-pipeline.evals.json +104 -0
  14. package/package.json +1 -1
  15. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +5 -2
  17. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +19 -0
  19. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +5 -2
  20. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +48 -11
  21. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +32 -24
  22. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +7 -1
  23. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +145 -0
  25. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +9 -5
  26. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
  27. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +8 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/review.md +7 -2
  30. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +2 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +3 -2
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +9 -4
  33. package/plugins/task-pipeline/skills/task-pipeline/references/tdd.md +3 -2
  34. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +9 -6
  35. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +219 -3
  37. package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +14 -0
  38. package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +5 -5
  39. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +15 -1
  40. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +26 -2
@@ -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
 
@@ -19,8 +19,8 @@ The full gate and freedom level remain in [stages.md](stages.md).
19
19
  contradiction that exists only between two of them.
20
20
  - **Web front end? The task's own surface is checked in a browser, not in the diff.**
21
21
  Where a browser channel is connected — `playwright` **or** `chrome-devtools`, either
22
- one, whichever answers first (**how**: [`browser.md`](browser.md); which:
23
- [`companion-skills.md`](companion-skills.md)): after a
22
+ one, on WebKit first with Chromium a stated exception (**how**, and the engine order:
23
+ [`browser.md`](browser.md); which: [`companion-skills.md`](companion-skills.md)): after a
24
24
  task that changes a rendered surface, load it, take a snapshot and read the
25
25
  console and the network log **before the task is marked DONE** — a component can be
26
26
  correct and land under a fixed header, and a review of the diff cannot see that.
@@ -13,8 +13,9 @@ The full gate and freedom level remain in [stages.md](stages.md).
13
13
  - **A deployed web target is opened, not curled.** A `200` proves the server
14
14
  answered; it says nothing about whether the page rendered, whether a bundle 404'd,
15
15
  or whether the console filled with errors on load — all three ship green past a
16
- health check. Where a browser channel is connected — `playwright` **or**
17
- `chrome-devtools`, either one ([`browser.md`](browser.md)) — load the deployed URL and read
16
+ health check. Where a browser channel is connected — `playwright` on WebKit first
17
+ **or** `chrome-devtools` as the stated Chromium exception, and a Chromium-only look
18
+ is reported as such; either one ([`browser.md`](browser.md)) — load the deployed URL and read
18
19
  the console and the network log; quote what you read, not that you looked
19
20
  ([`companion-skills.md`](companion-skills.md)). Absent → say the check was an HTTP
20
21
  response only, which is the honest name for it.
@@ -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
@@ -252,8 +254,9 @@ never that the work was skipped quietly.
252
254
  page rendered — a component can be correct and land under a fixed header, a request
253
255
  can 404 while every unit test mocks it, and a console error costs nothing at test
254
256
  time. Where a browser channel is connected — `playwright` **or** `chrome-devtools`,
255
- either one ([`browser.md`](browser.md) is the four commands this sentence means;
256
- [`companion-skills.md`](companion-skills.md) is which channel):
257
+ either one, on WebKit first with Chromium a stated exception
258
+ ([`browser.md`](browser.md) is the four commands this sentence means and the engine
259
+ order; [`companion-skills.md`](companion-skills.md) is which channel):
257
260
  load the surface, take a snapshot, and read **the console and the network log**
258
261
  before calling it green. Absent, say the surface was verified **by reading the
259
262
  diff** — that is a weaker claim and the close-out records it as one, rather than
@@ -379,7 +382,9 @@ never that the work was skipped quietly.
379
382
  surfaced that nobody asked for, every decision still waiting **asked here with
380
383
  options rather than listed**, and the ambiguity count computed from the four
381
384
  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
385
+ that cannot say what happened has not finished, it has stopped; **the `finish:` line is
386
+ printed beside this verdict — `done`, `budget` or `blocked`, with its block**
387
+ ([`finish-line.md`](finish-line.md) → *The finish block*); **the environment is
383
388
  given back** — all eight classes enumerated, everything this run started ended and
384
389
  verified by re-enumerating rather than by the teardown's reply, an earlier run of
385
390
  this project ended only when **provably spent**, an item this project does not own
@@ -102,8 +102,9 @@ database created ten seconds ago" are different claims, and only the second one
102
102
  code does what its assertions say; it cannot see a component that renders correctly
103
103
  and lands under a fixed header, a request that 404s while every unit test mocks it,
104
104
  or a console error that costs nothing at test time. Where a browser channel is
105
- connected — `playwright` **or** `chrome-devtools`, either one
106
- ([`companion-skills.md`](companion-skills.md)) — open the surface and read
105
+ connected — `playwright` **or** `chrome-devtools`, either one, on WebKit first with
106
+ Chromium a stated exception ([`companion-skills.md`](companion-skills.md);
107
+ [`browser.md`](browser.md) → *Engine order*) — open the surface and read
107
108
  the console and the network log before calling it done — and **quote what you read**,
108
109
  not that you looked. Absent, the honest sentence is *"verified by reading the diff"*,
109
110
  which is a weaker claim and is recorded as one. Same family as a test that passes
@@ -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:
@@ -18,6 +18,7 @@
18
18
  - Documentation (the phase-1b inventory — the four questions)
19
19
  - Scope
20
20
  - Requirements (the REQ spine — every later stage traces to these IDs)
21
+ - Finish line (when this iteration stops)
21
22
  - Users & context
22
23
  - Decisions locked (the grill's output)
23
24
  - Autonomy (the sweep — stages 1→10 read this instead of asking)
@@ -101,6 +102,19 @@ not continuously): `open` → `planned` → `built` → `verified` \| `partial`
101
102
  > task in smaller terms is the failure this table exists to prevent: every gate
102
103
  > after it goes green on the shrunken task and nothing reports the loss.
103
104
 
105
+ ## Finish line (when this iteration stops)
106
+
107
+ The contract the iteration stops against (the skill's `references/finish-line.md`). Written
108
+ here, confirmed with the brief, and revised only as a recorded revision — a finding is not one.
109
+
110
+ - **Objective:** <one sentence: what is true when this iteration is done>
111
+ - **Exit checks:** <REQ id → command, run on the artefact the operator uses: released /
112
+ installed / deployed — one line each>
113
+ - **Scope:** <the modules, paths and REQ ids this iteration may change, as a list>
114
+ - **Budget:** `maxRounds` 3 · `maxReleases` 2 (the defaults; `pipeline.json` →
115
+ `run.finish` overrides them)
116
+ - **Base:** <the commit this iteration started from>
117
+
104
118
  ## Users & context
105
119
 
106
120
  - **Who / for what:** … (personas, the job being done)
@@ -75,7 +75,7 @@
75
75
  "route": "/",
76
76
  "motion": "reduced",
77
77
  "captured_at": "2026-10-07T00:00:00Z",
78
- "source": "the capability that took it, e.g. chrome-devtools take_screenshot"
78
+ "source": "the capability and engine that took it, e.g. playwright-cli screenshot (webkit)"
79
79
  },
80
80
  "figma_frame": "https://www.figma.com/design/<fileKey>/<file>?node-id=<frame>",
81
81
  "baseline": null,
@@ -120,7 +120,7 @@
120
120
  "route": "/",
121
121
  "motion": "reduced",
122
122
  "captured_at": "2026-10-07T00:00:00Z",
123
- "source": "the capability that took it, e.g. chrome-devtools take_screenshot"
123
+ "source": "the capability and engine that took it, e.g. playwright-cli screenshot (webkit)"
124
124
  },
125
125
  "figma_frame": "https://www.figma.com/design/<fileKey>/<file>?node-id=<frame>",
126
126
  "baseline": null,
@@ -165,7 +165,7 @@
165
165
  "route": "/",
166
166
  "motion": "reduced",
167
167
  "captured_at": "2026-10-07T00:00:00Z",
168
- "source": "the capability that took it, e.g. chrome-devtools take_screenshot"
168
+ "source": "the capability and engine that took it, e.g. playwright-cli screenshot (webkit)"
169
169
  },
170
170
  "figma_frame": "https://www.figma.com/design/<fileKey>/<file>?node-id=<frame>",
171
171
  "baseline": null,
@@ -210,7 +210,7 @@
210
210
  "route": "/",
211
211
  "motion": "reduced",
212
212
  "captured_at": "2026-10-07T00:00:00Z",
213
- "source": "the capability that took it, e.g. chrome-devtools take_screenshot"
213
+ "source": "the capability and engine that took it, e.g. playwright-cli screenshot (webkit)"
214
214
  },
215
215
  "figma_frame": null,
216
216
  "baseline": null,
@@ -251,7 +251,7 @@
251
251
  "route": "/",
252
252
  "motion": "reduced",
253
253
  "captured_at": "2026-10-07T00:00:00Z",
254
- "source": "the capability that took it, e.g. chrome-devtools take_screenshot"
254
+ "source": "the capability and engine that took it, e.g. playwright-cli screenshot (webkit)"
255
255
  },
256
256
  "figma_frame": null,
257
257
  "baseline": null,
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/ssheleg/task-pipeline/blob/main/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json",
4
4
  "title": "inventory/1 — <artifacts>/specs/YYYY-MM-DD-<topic>-inventory.json",
5
- "description": "Wave 0's single machine-readable inventory: one item per open item of the board, every survey and every harvested source (references/waves.md, Wave 0). scripts/waves.py merges audit outputs into it (`merge`), writes `module`, `node`, `wave` and `release` from two short tables (`assign`), counts it (`count`) and refuses an item that lands nowhere (`validate`). The document is an object carrying `schema` and `items`; a bare array of items is read as the items of one. Fields a project needs beyond these go under an `x-` prefix.",
5
+ "description": "Wave 0's single machine-readable inventory: one item per open item of the board, every survey and every harvested source (references/waves.md, Wave 0). scripts/waves.py merges audit outputs into it (`merge`), writes `module`, `node`, `wave` and `release` from two short tables (`assign`), counts it (`count`), refuses an item that lands nowhere (`validate`) and computes the finish-line verdict from the triaged items (`finish`, references/finish-line.md). The document is an object carrying `schema` and `items`; a bare array of items is read as the items of one. Fields a project needs beyond these go under an `x-` prefix.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["schema", "items"],
@@ -17,6 +17,14 @@
17
17
  "additionalProperties": false,
18
18
  "patternProperties": {"^x-[a-z0-9-]+$": {}},
19
19
  "required": ["id", "title", "area", "kind", "severity", "evidence", "proposed_fix"],
20
+ "allOf": [
21
+ {"if": {"required": ["triage"], "properties": {"triage": {"const": "blocking"}}},
22
+ "then": {"required": ["test", "round"]}},
23
+ {"if": {"required": ["test"]},
24
+ "then": {"required": ["triage"], "properties": {"triage": {"const": "blocking"}}}},
25
+ {"if": {"required": ["test", "base_repro"], "properties": {"test": {"const": "B3"}}},
26
+ "then": {"properties": {"base_repro": {"not": {"const": "yes"}}}}}
27
+ ],
20
28
  "properties": {
21
29
  "id": {
22
30
  "description": "The item's id in its owning register. An item without one gets one there, in the same change. Unique across the inventory.",
@@ -60,6 +68,12 @@
60
68
  "blocked_by": {"type": "array", "items": {"type": "string"}},
61
69
  "closable_in_one_pass": {"type": "boolean"},
62
70
  "needs_operator": {"type": "boolean"},
71
+ "triage": {"description": "The finish-line class, set when the finding is found (references/finish-line.md → Triage at discovery). Only blocking findings argue for another round; deferred and noise never affect `waves.py finish`.", "enum": ["blocking", "deferred", "noise"]},
72
+ "test": {"description": "The blocking test that matched, first match wins: B1 an exit check is red; B2 a hotfix-lane entry; B3 it passes at Base; B4 a verified REQ or a published claim is false on the artefact. Required on a blocking item, refused on any other.", "enum": ["B1", "B2", "B3", "B4"]},
73
+ "round": {"description": "The finish-line round that found it, from 1. Required on a blocking item.", "type": "integer", "minimum": 1},
74
+ "base_repro": {"description": "Does the failure reproduce at the iteration's Base? `no` is a finding this iteration caused (self-inflicted).", "enum": ["yes", "no", "n/a"]},
75
+ "blast": {"description": "Who it hurts, 1–3 (references/prioritisation.md, backlog `sev × blast`): a priority input of a deferred row. `waves.py finish` lists a deferred item at blast 3 as a known risk.", "type": "integer", "minimum": 1, "maximum": 3},
76
+ "rung": {"description": "The impact rung (references/prioritisation.md): a priority input of a deferred row. `waves.py finish` lists a deferred item at I4 or I5 as a known risk.", "enum": ["I1", "I2", "I3", "I4", "I5"]},
63
77
  "notes": {"type": "string"}
64
78
  }
65
79
  },
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
20
20
 
21
21
  ## `read:` — which doctrine this run actually opened
22
22
 
23
- The bundle is 52 reference files and nothing recorded which of them a run read, so **a
23
+ The bundle is 53 reference files and nothing recorded which of them a run read, so **a
24
24
  skipped file and a read one were indistinguishable** — the class every guard in this
25
25
  pipeline exists to catch, left standing over the doctrine itself.
26
26
 
@@ -48,7 +48,7 @@ same claim one level down.
48
48
  |---|---|---|
49
49
  | `unmeasured — no run ledger` | there is no ledger | nothing to read from |
50
50
  | `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
51
- | `N of 52 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
51
+ | `N of 53 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
52
52
 
53
53
  **It is a disclosure: no floor, no direction, never a target.** A run that needs four files
54
54
  and reads four is not worse than one that reads thirty — and the moment the number becomes
@@ -71,6 +71,9 @@ holds: <stage id> — <n> (<class: what, owner>; … or "none") — enumerated <
71
71
  gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
72
72
  event: <compact|session-end|subagent|memory> — <detail> — <ISO-8601>
73
73
  review: <stage id> — surface <name> — rounds <N> — <returned|approved|unresolved> — <ISO-8601>
74
+ finding: <id> — blocking:B<n> | deferred:B-NNN | noise — round <N> — base-repro yes|no|n/a
75
+ round: <N> — <reading> — new blocking <k>
76
+ finish: done|budget|blocked — rounds <n>/<max> — releases <n>/<max>
74
77
  read: references/<file>.md # hook-appended, deduped, UNATTESTED (no writer field)
75
78
  ```
76
79
 
@@ -118,6 +121,20 @@ read: references/<file>.md # hook-appended, deduped, UNATTESTED (no
118
121
  into the checkpoint of the stage it names, and stage 10 copies the last one per surface
119
122
  into the acceptance file. **It is a measurement, never a target**: a number of passes to
120
123
  beat is an instruction to stop showing the sheet.
124
+ - **`finding:`** — one line per finding, written when it is found and before anyone fixes
125
+ it (`references/finish-line.md` → *Triage at discovery*, inside the skill). The class is
126
+ one of `blocking`, `deferred`, `noise`; a blocking line names its test, one of `B1`, `B2`,
127
+ `B3`, `B4`; a deferred line names its board row. `base-repro` says whether the failure
128
+ reproduces at the iteration's Base — `no` is a finding this iteration caused.
129
+ - **`round:`** — one line per finish-line round, **clean rounds included**: its number, the
130
+ independent reading it ran (an audit axis, an observer, the post-release readback) and how
131
+ many new blocking findings it produced. A clean round leaves no `finding:` line, so without
132
+ this line two clean rounds and no rounds look the same; `scripts/waves.py finish --ledger`
133
+ counts the rounds from it and refuses when its counts disagree with the inventory.
134
+ - **`finish:`** — one line when the iteration stops, beside stage 10's verdict: `done`,
135
+ `budget` or `blocked`, with the rounds and releases spent against their budget. The
136
+ block under it is `references/finish-line.md` → *The finish block*;
137
+ `scripts/waves.py finish` computes the verdict from the inventory.
121
138
  - **`touch:`** — one line per file per pass, and the reason names **what forced the
122
139
  edit**: a finding id, a failed gate item, an operator instruction. *"Cleanup"*,
123
140
  *"polish"* and *"while I was there"* are not reasons; they are churn with better
@@ -141,11 +158,18 @@ event: compact — auto — 2026-08-10T11:58Z
141
158
  event: subagent — general-purpose — 2026-08-10T12:00Z
142
159
  gate: 6 — command "npm test" — exit 0 — 2026-08-10T12:02Z
143
160
  review: 6 — surface landing — rounds 1 — returned — 2026-08-10T12:02Z
161
+ finding: F-021 — blocking:B3 — round 1 — base-repro no
162
+ finding: F-022 — deferred:B-118 — round 1 — base-repro yes
163
+ round: 1 — exit checks + audit axis seams — new blocking 1
164
+ finding: F-023 — noise — round 2 — base-repro n/a
165
+ round: 2 — exit checks + observer — new blocking 0
166
+ round: 3 — exit checks + post-release readback — new blocking 0
144
167
  stage: 6 Tests — gate manual — verdict pass — 2026-08-10T12:03Z
145
168
  hand: 3 — task "add CSV export to the orders table" — done 2 — surfaced 1 — decisions 1 — amb 2 (OQ-0007, ledger row 4)
146
169
  scope 5f21ac3/node-24-linux/REQ-001,REQ-004 — unverified 1 (XLSX path: no fixture)
147
170
  holds: 5 — 2 (worktree: build-csv-export, this run; container: pg-test, this run) — enumerated 8/8 classes
148
171
  holds: 10 — none — enumerated 7/8 classes, unlooked: containers (no docker on this host)
172
+ finish: done — rounds 3/3 — releases 1/2
149
173
  ```
150
174
 
151
175
  The last two lines are a **trip**: the same file, two consecutive passes, the same