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.
- package/CHANGELOG.md +73 -0
- package/CONTRIBUTING.md +32 -0
- package/HOW-IT-WORKS.md +4 -3
- package/README.md +19 -7
- package/SKILL-CARD.md +2 -2
- package/evals/RESULTS.md +55 -8
- package/evals/cases/task-pipeline.json +139 -0
- package/evals/fixtures/lessons/expect-match.sh +30 -0
- package/evals/fixtures/lessons/index.jsonl +2 -0
- package/evals/fixtures/waves-unclaimed/expect-refusal.sh +26 -0
- package/evals/fixtures/waves-unclaimed/inventory.json +33 -0
- package/evals/fixtures/waves-unclaimed/modules.json +4 -0
- package/evals/fixtures/waves-unclaimed/plan.json +5 -0
- package/evals/task-pipeline.evals.json +104 -0
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +1 -1
- package/plugins/task-pipeline/commands/task-pipeline.md +3 -2
- package/plugins/task-pipeline/hooks/release-gate.sh +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +16 -11
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +4 -0
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +19 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +5 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +3 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +10 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +20 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +145 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +9 -5
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +13 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/review.md +7 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +7 -5
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +158 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-01-docs-study.md +10 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-02-brainstorm.md +52 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-03-spec.md +66 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-04-plan.md +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +55 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-06-tests.md +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-07-lint-deploy.md +15 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +29 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-09-docs-wiki.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-10-acceptance.md +91 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +49 -484
- package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +9 -6
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +156 -33
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +219 -3
- package/plugins/task-pipeline/skills/task-pipeline/templates/brief.md +14 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +15 -1
- 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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
is
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
84
|
-
"""
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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(
|
|
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":
|
|
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
|
|
275
|
-
|
|
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)
|