task-pipeline-skill 1.94.1 → 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 (58) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/CONTRIBUTING.md +32 -0
  3. package/HOW-IT-WORKS.md +4 -3
  4. package/README.md +19 -7
  5. package/SKILL-CARD.md +2 -2
  6. package/evals/RESULTS.md +55 -8
  7. package/evals/cases/task-pipeline.json +139 -0
  8. package/evals/fixtures/lessons/expect-match.sh +30 -0
  9. package/evals/fixtures/lessons/index.jsonl +2 -0
  10. package/evals/fixtures/waves-unclaimed/expect-refusal.sh +26 -0
  11. package/evals/fixtures/waves-unclaimed/inventory.json +33 -0
  12. package/evals/fixtures/waves-unclaimed/modules.json +4 -0
  13. package/evals/fixtures/waves-unclaimed/plan.json +5 -0
  14. package/evals/task-pipeline.evals.json +104 -0
  15. package/package.json +1 -1
  16. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  17. package/plugins/task-pipeline/agents/verifier.md +1 -1
  18. package/plugins/task-pipeline/commands/task-pipeline.md +3 -2
  19. package/plugins/task-pipeline/hooks/release-gate.sh +1 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +16 -11
  21. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +19 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +1 -1
  24. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +5 -2
  25. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +1 -1
  27. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +1 -1
  28. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +3 -2
  29. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +10 -2
  30. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +20 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +145 -0
  32. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
  33. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +9 -5
  34. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
  35. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +13 -1
  36. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +8 -0
  37. package/plugins/task-pipeline/skills/task-pipeline/references/review.md +7 -2
  38. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +7 -5
  39. package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +158 -0
  40. package/plugins/task-pipeline/skills/task-pipeline/references/stage-01-docs-study.md +10 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/references/stage-02-brainstorm.md +52 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/references/stage-03-spec.md +66 -0
  43. package/plugins/task-pipeline/skills/task-pipeline/references/stage-04-plan.md +11 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +55 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/references/stage-06-tests.md +11 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/references/stage-07-lint-deploy.md +15 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +29 -0
  48. package/plugins/task-pipeline/skills/task-pipeline/references/stage-09-docs-wiki.md +45 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/references/stage-10-acceptance.md +91 -0
  50. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +49 -484
  51. package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +1 -1
  52. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +9 -6
  53. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
  54. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +156 -33
  55. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +219 -3
  56. package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +14 -0
  57. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +15 -1
  58. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +26 -2
@@ -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 41 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
 
@@ -10,9 +10,9 @@ run goes on exactly as before, and the ledger says memory was unavailable.
10
10
  This script never talks to Observatory: the agent calls the tool. The script does the two
11
11
  deterministic halves around that call, so nothing about the checkpoint is left to recall:
12
12
 
13
- stage_checkpoint.py emit [--ledger PATH] [--project project:<slug>]
13
+ stage_checkpoint.py emit [--ledger PATH] [--project-root PATH] [--project project:<slug>]
14
14
  [--constraint TEXT ...] [--credential PROJECT/ENV/NAME ...]
15
- prints the tool's arguments as JSON, from the ledger's last `stage:` line
15
+ prints the tool's arguments as JSON, from the ledger and selected project profile
16
16
  stage_checkpoint.py record --answer FILE|- [--ledger PATH]
17
17
  reads the tool's answer and keeps `workflowId` and `leaseId` for the next boundary
18
18
  stage_checkpoint.py record --unavailable "<reason>" [--for-run] [--ledger PATH]
@@ -24,13 +24,17 @@ deterministic halves around that call, so nothing about the checkpoint is left t
24
24
 
25
25
  What it keeps, and where:
26
26
 
27
- - The arguments carry no prose the ledger does not hold: the run's topic is the goal, the
27
+ - The ledger supplies the observed verdicts; the selected project `pipeline.json` supplies
28
+ stage order and names (bundled default only when project config is absent). The run's topic is the goal, the
28
29
  ledger's verdicts are `done`, the next stage is `open`, the operator's constraints come in
29
30
  as `--constraint`, keys are passed by NAME only (`--credential`), and the git checkout is
30
31
  an artifact (branch and a 12-character head). Observatory redacts what it stores as well.
31
- - `idempotencyKey` is derived from the run's topic and the exact `stage:` line, so a retry
32
+ - `idempotencyKey` is derived from the run's topic and the exact `stage:` line (also the
33
+ profile digest for a configured profile), so a retry
32
34
  of the same boundary replays the first answer, and a stage that runs again (a new line, a
33
35
  new time) is a new checkpoint.
36
+ - The first emit saves a run-scoped profile binding before returning arguments. A changed
37
+ profile/run or unreadable saved state refuses instead of switching an existing workflow.
34
38
  - `workflowId` and `leaseId` live in `.task-pipeline/memory.json`, mode 0600, beside the
35
39
  ledger in the git-ignored run directory, with the constraints and key names given so far:
36
40
  they are stated once and carried to every later boundary. The lease token is a write right: it is never
@@ -60,6 +64,7 @@ import json
60
64
  import os
61
65
  import pathlib
62
66
  import re
67
+ import stat
63
68
  import subprocess
64
69
  import sys
65
70
  from datetime import datetime, timezone
@@ -80,15 +85,70 @@ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
80
85
  MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
81
86
 
82
87
 
83
- def _stages() -> dict[int, str]:
84
- """The stage names by id, from the bundle's own `pipeline.example.json` — the file the
85
- validator compares with `references/stages.md`, so this list cannot drift from it."""
86
- path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
88
+ def _profile(root: pathlib.Path) -> tuple[list[dict], dict]:
89
+ """Admit the selected profile for the numeric checkpoint ledger, not execute gates."""
90
+ path = root / "pipeline.json"
87
91
  try:
92
+ path.lstat()
93
+ configured = True
94
+ except FileNotFoundError:
95
+ configured = False
96
+ except OSError:
97
+ raise SystemExit("stage_checkpoint: selected profile cannot be read")
98
+ if not configured:
99
+ path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
100
+ try:
101
+ if not stat.S_ISREG(path.stat().st_mode):
102
+ raise SystemExit("stage_checkpoint: selected profile is not a regular file")
88
103
  doc = json.loads(path.read_text(encoding="utf-8"))
89
- return {int(s["id"]): str(s["name"]) for s in doc["stages"]}
90
- except (OSError, ValueError, KeyError, TypeError):
91
- return {}
104
+ except (OSError, ValueError, UnicodeError):
105
+ raise SystemExit("stage_checkpoint: selected profile cannot be read as JSON")
106
+ if not isinstance(doc, dict) or not isinstance(doc.get("stages"), list) or not doc["stages"]:
107
+ raise SystemExit("stage_checkpoint: selected profile needs a nonempty stages array")
108
+ mandatory = doc.get("must_understand", [])
109
+ if not isinstance(mandatory, list) or mandatory:
110
+ raise SystemExit("stage_checkpoint: unsupported mandatory profile capabilities")
111
+ stages, ids, states = [], set(), set()
112
+ for ordinal, stage in enumerate(doc["stages"]):
113
+ if not isinstance(stage, dict):
114
+ raise SystemExit("stage_checkpoint: malformed selected stage")
115
+ sid, state = stage.get("id", ordinal), stage.get("state")
116
+ name = stage.get("name", state)
117
+ if isinstance(name, str) and not name.strip():
118
+ name = state # optional human label may be empty; stable state still identifies it
119
+ skills, gate = stage.get("skills"), stage.get("gate")
120
+ if (type(sid) is not int or sid < 0 or sid in ids or
121
+ not isinstance(state, str) or not state.strip() or state in states or
122
+ not isinstance(name, str) or not name.strip() or "\n" in name or "\r" in name or
123
+ not isinstance(skills, list) or not skills or
124
+ any(not isinstance(v, str) or not v.strip() for v in skills) or
125
+ not isinstance(gate, dict) or gate.get("type") not in ("auto", "manual", "judgment") or
126
+ not isinstance(gate.get("check"), str) or not gate["check"].strip() or
127
+ (gate["type"] == "judgment" and
128
+ (not isinstance(gate.get("judge"), str) or not gate["judge"].strip()))):
129
+ raise SystemExit("stage_checkpoint: malformed or ambiguous selected stage")
130
+ ids.add(sid)
131
+ states.add(state)
132
+ stages.append({"id": sid, "name": name, "gate": gate["type"]})
133
+ semantic = {k: doc[k] for k in ("stages", "kernel", "must_understand") if k in doc}
134
+ identity = {"root": str(root), "source": "pipeline.json" if configured else "bundled-default",
135
+ "digest": hashlib.sha256(json.dumps(semantic, sort_keys=True,
136
+ ensure_ascii=False).encode()).hexdigest()}
137
+ return stages, identity
138
+
139
+
140
+ def _root(ledger: pathlib.Path, given: str | None) -> pathlib.Path:
141
+ if given:
142
+ root = pathlib.Path(given).resolve()
143
+ if not root.is_dir() or not ledger.is_relative_to(root):
144
+ raise SystemExit("stage_checkpoint: --project-root must contain the ledger")
145
+ return root
146
+ if ledger.parent.name == ".task-pipeline":
147
+ return ledger.parent.parent
148
+ git_root = _git(ledger.parent, "rev-parse", "--show-toplevel")
149
+ if not git_root:
150
+ raise SystemExit("stage_checkpoint: relocated ledger requires --project-root")
151
+ return pathlib.Path(git_root).resolve()
92
152
 
93
153
 
94
154
  def _now() -> str:
@@ -145,6 +205,8 @@ def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
145
205
  topic, stages = (TOPIC.match(rl).group(1) if rl else None), []
146
206
  for line in lines:
147
207
  s = STAGE.match(line)
208
+ if line.startswith("stage:") and not s:
209
+ raise SystemExit("stage_checkpoint: malformed stage ledger line")
148
210
  if s:
149
211
  stages.append((int(s.group(1)), s.group(2), s.group(3), s.group(4), s.group(5), line))
150
212
  if not topic:
@@ -176,14 +238,48 @@ def marker_for_this_run(ledger: pathlib.Path, state: dict) -> dict | None:
176
238
  return gone
177
239
 
178
240
 
241
+ def _canonical_ledger(ledger: pathlib.Path) -> pathlib.Path:
242
+ # A file alias can put a prior record beside the link while emit read its target.
243
+ # Refuse it rather than choosing one of two potentially live workflow receipts.
244
+ try:
245
+ mode = ledger.lstat().st_mode
246
+ except FileNotFoundError:
247
+ return ledger.resolve()
248
+ except OSError:
249
+ raise SystemExit("stage_checkpoint: ledger cannot be read")
250
+ if stat.S_ISLNK(mode):
251
+ raise SystemExit("stage_checkpoint: ledger file alias is unsupported; reconcile and use the real ledger path")
252
+ if not stat.S_ISREG(mode):
253
+ raise SystemExit("stage_checkpoint: ledger is not a regular file")
254
+ return ledger.resolve()
255
+
256
+
179
257
  def _state_path(ledger: pathlib.Path) -> pathlib.Path:
180
- return ledger.parent / STATE_NAME
258
+ path = _canonical_ledger(ledger).parent / STATE_NAME
259
+ try:
260
+ mode = path.lstat().st_mode
261
+ except FileNotFoundError:
262
+ return path
263
+ except OSError:
264
+ raise SystemExit("stage_checkpoint: memory state cannot be read")
265
+ if stat.S_ISLNK(mode):
266
+ raise SystemExit("stage_checkpoint: memory state link is unsafe; reconcile it before continuing")
267
+ if not stat.S_ISREG(mode):
268
+ raise SystemExit("stage_checkpoint: memory state is not a regular file")
269
+ return path
181
270
 
182
271
 
183
- def _load_state(ledger: pathlib.Path) -> dict:
272
+ def _load_state(ledger: pathlib.Path, strict: bool = False) -> dict:
184
273
  try:
185
- return json.loads(_state_path(ledger).read_text(encoding="utf-8"))
186
- except (OSError, ValueError):
274
+ state = json.loads(_state_path(ledger).read_text(encoding="utf-8"))
275
+ if not isinstance(state, dict):
276
+ raise ValueError("state is not an object")
277
+ return state
278
+ except FileNotFoundError:
279
+ return {}
280
+ except (OSError, ValueError, UnicodeError):
281
+ if strict:
282
+ raise SystemExit("stage_checkpoint: memory state cannot be read")
187
283
  return {}
188
284
 
189
285
 
@@ -224,14 +320,28 @@ def _project(root: pathlib.Path, given: str | None) -> str:
224
320
 
225
321
 
226
322
  def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
227
- credentials: list[str]) -> dict:
323
+ credentials: list[str], project_root: str | None = None) -> dict:
324
+ ledger = _canonical_ledger(ledger)
228
325
  topic, stages = _read_ledger(ledger)
229
- names = _stages()
326
+ root = _root(ledger, project_root)
327
+ profile, identity = _profile(root)
328
+ names = {s["id"]: s["name"] for s in profile}
230
329
  if not stages:
231
330
  raise SystemExit("stage_checkpoint: no `stage:` line yet — a checkpoint follows a gate")
232
331
  sid, name, gate, verdict, at, line = stages[-1]
233
- root = ledger.parent.parent if ledger.parent.name == ".task-pipeline" else ledger.parent
234
- state = _load_state(ledger)
332
+ state = _load_state(ledger, strict=True)
333
+ run = run_key(ledger.read_text(encoding="utf-8").splitlines())
334
+ binding = {**identity, "run": run}
335
+ previous = state.get("profile")
336
+ if previous is None and state.get("workflowId") and identity["source"] != "bundled-default":
337
+ raise SystemExit("stage_checkpoint: legacy workflow has no selected-profile binding; reconcile before emitting")
338
+ if previous is not None and previous != binding:
339
+ raise SystemExit("stage_checkpoint: selected profile or run changed; reconcile the saved workflow before emitting")
340
+ for observed in stages:
341
+ selected = next((s for s in profile if s["id"] == observed[0]), None)
342
+ if selected is None or (identity["source"] != "bundled-default" and
343
+ (selected["name"] != observed[1] or selected["gate"] != observed[2])):
344
+ raise SystemExit("stage_checkpoint: ledger stage does not match the selected profile")
235
345
  # CONSTRAINTS AND KEYS OUTLIVE THE BOUNDARY THEY WERE GIVEN AT. The operator states them
236
346
  # once; a later boundary that dropped them would hand a successor an empty list — the
237
347
  # one thing a successor must never act without (found by the live receipt, 2026-10-05).
@@ -241,12 +351,18 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
241
351
  done = [{"step_id": f"stage-{s[0]}", "result": f"{s[1]}: gate {s[2]}, verdict {s[3]} at {s[4]}",
242
352
  "evidence": [f"ledger: {ledger.name}", *reviews.get(s[0], [])]}
243
353
  for s in stages if s[3] in ("pass", "skip")]
244
- nxt = sid + 1 if verdict in ("pass", "skip") else sid
245
- last = max(names) if names else 10
246
- open_steps = [] if sid >= last and verdict == "pass" else [{
247
- "step_id": f"stage-{nxt}",
248
- "next_action": (f"enter stage {nxt} {names.get(nxt, '')}".strip() if nxt != sid
249
- else f"repair stage {sid} {name}: its gate failed")}]
354
+ position = next(i for i, s in enumerate(profile) if s["id"] == sid)
355
+ terminal = position == len(profile) - 1
356
+ closed = terminal and verdict == "pass"
357
+ if closed:
358
+ open_steps = []
359
+ elif verdict in ("pass", "skip") and not terminal:
360
+ nxt = profile[position + 1]["id"]
361
+ open_steps = [{"step_id": f"stage-{nxt}", "next_action": f"enter stage {nxt} {names[nxt]}"}]
362
+ else:
363
+ action = (f"confirm stage {sid} {name}: final stage was skipped" if verdict == "skip"
364
+ else f"repair stage {sid} {name}: its gate did not pass")
365
+ open_steps = [{"step_id": f"stage-{sid}", "next_action": action}]
250
366
  creds = []
251
367
  for c in credentials:
252
368
  parts = c.split("/")
@@ -258,21 +374,26 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
258
374
  if head:
259
375
  artifacts.append({"kind": "git", "path": str(root), "branch": branch or "", "head": head[:12]})
260
376
  body = {"goal": topic,
261
- "plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in sorted(names)],
377
+ "plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in names],
262
378
  "done": done, "open": open_steps, "constraints": constraints,
263
379
  "artifacts": artifacts, "notes": f"task-pipeline stage boundary; run ledger {ledger}"}
264
380
  if creds:
265
381
  body["credentials"] = creds
382
+ key = f"{topic}\n{line}"
383
+ if identity["source"] != "bundled-default":
384
+ key += "\n" + identity["digest"]
266
385
  args = {"owner": OWNER,
267
- "idempotencyKey": "tp-" + hashlib.sha256(f"{topic}\n{line}".encode()).hexdigest()[:32],
386
+ "idempotencyKey": "tp-" + hashlib.sha256(key.encode()).hexdigest()[:32],
268
387
  "stepId": f"stage-{sid}", "status": STATUS.get(verdict, "in_progress"), "body": body,
269
- "close": sid >= last and verdict == "pass"}
388
+ "close": closed}
270
389
  if state.get("workflowId") and state.get("leaseId"):
271
390
  args["workflowId"], args["leaseId"] = state["workflowId"], state["leaseId"]
272
391
  else:
273
392
  args["projectId"] = _project(root, project)
274
- if constraints != state.get("constraints", []) or credentials != state.get("credentials", []):
275
- _save_state(ledger, {**state, "constraints": constraints, "credentials": credentials})
393
+ if (previous != binding or constraints != state.get("constraints", []) or
394
+ credentials != state.get("credentials", [])):
395
+ _save_state(ledger, {**state, "constraints": constraints, "credentials": credentials,
396
+ "profile": binding})
276
397
  return args
277
398
 
278
399
 
@@ -344,7 +465,7 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
344
465
  lease = answer.get("leaseId") or state.get("leaseId")
345
466
  if not wid:
346
467
  raise SystemExit("stage_checkpoint: the answer names no workflowId")
347
- _save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials")},
468
+ _save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials", "profile")},
348
469
  "workflowId": wid, **({"leaseId": lease} if lease else {})})
349
470
  rev = (answer.get("checkpoint") or {}).get("revision") or answer.get("revision") or "?"
350
471
  step = answer.get("stepId") or "?"
@@ -406,6 +527,7 @@ def main(argv: list[str]) -> int:
406
527
  sub = p.add_subparsers(dest="cmd", required=True)
407
528
  e = sub.add_parser("emit", help="print the checkpoint tool's arguments")
408
529
  e.add_argument("--ledger", default=DEFAULT_LEDGER)
530
+ e.add_argument("--project-root", help="explicit project root for a relocated ledger")
409
531
  e.add_argument("--project", help="project:<slug>; default the checkout's folder name")
410
532
  e.add_argument("--constraint", action="append", default=[], help="repeatable")
411
533
  e.add_argument("--credential", action="append", default=[], help="PROJECT/ENV/NAME, repeatable")
@@ -430,9 +552,10 @@ def main(argv: list[str]) -> int:
430
552
  ac.add_argument("--handoff-id", required=True)
431
553
  ac.add_argument("--session")
432
554
  a = p.parse_args(argv)
433
- ledger = pathlib.Path(a.ledger)
555
+ ledger = _canonical_ledger(pathlib.Path(a.ledger))
556
+ _state_path(ledger) # every command refuses unsafe aliases before producing tool arguments
434
557
  if a.cmd == "emit":
435
- print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
558
+ print(json.dumps(emit(ledger, a.project, a.constraint, a.credential, a.project_root), ensure_ascii=False))
436
559
  return 0
437
560
  if a.cmd == "handoff":
438
561
  print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
@@ -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)