task-pipeline-skill 1.97.1 → 1.98.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 +53 -0
- package/HOW-IT-WORKS.md +17 -13
- package/README.md +12 -9
- package/SKILL-CARD.md +1 -1
- package/cursor/rules/task-pipeline.mdc +22 -14
- package/evals/task-pipeline.evals.json +3 -3
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
- package/plugins/task-pipeline/commands/task-pipeline.md +14 -219
- package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +8 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +1 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +24 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +21 -576
- package/plugins/task-pipeline/skills/project-audit/SKILL.md +8 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +162 -230
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +9 -9
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +10 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/build.md +7 -7
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +49 -44
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +13 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +20 -23
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +5 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +24 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +17 -7
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +47 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +5 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +39 -25
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +31 -20
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +6 -4
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +77 -21
- package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +305 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/short_path.py +70 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +22 -10
- package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +9 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +10 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""retro_budget.py: the byte budget of the retro's floor, measured and enforced.
|
|
3
|
+
|
|
4
|
+
The floor is what stage 0 reads in full: the *Standing instructions* section and the
|
|
5
|
+
*Run stamps* section of `<artifacts>/retro.md` (`references/retrospective.md` ->
|
|
6
|
+
*The floor has a byte budget*). A cap of ten rows never bounded their SIZE: measured
|
|
7
|
+
on 2026-10-20 across ~50 projects the floor ran from ~3k to ~76k tokens, one of them
|
|
8
|
+
304 KB. This script is the bound.
|
|
9
|
+
|
|
10
|
+
retro_budget.py check [--retro PATH] [--budget N] [--row N] [--stamp N]
|
|
11
|
+
prints one line: the floor's bytes, bytes/4 tokens, and the largest row of
|
|
12
|
+
each kind. Exit 0 within budget; 1 over budget (every offender listed);
|
|
13
|
+
2 could not measure (no file, or a floor section is missing).
|
|
14
|
+
retro_budget.py migrate [--retro PATH] [--archive PATH] [--write]
|
|
15
|
+
moves narrative out of the floor VERBATIM into the archive, each block
|
|
16
|
+
carrying the sha256 of the text it holds, and leaves a pointer in its place:
|
|
17
|
+
a stamp row over the stamp limit keeps Date, Topic and Commit; a standing row
|
|
18
|
+
over the row limit loses its `Because` cell. Without --write it prints the
|
|
19
|
+
plan and writes nothing. Exit 0 when the floor is then within budget, 1 when a
|
|
20
|
+
row is still over (an instruction too long to shorten by moving its story:
|
|
21
|
+
rewrite it by hand), 2 could not measure.
|
|
22
|
+
retro_budget.py verify [--retro PATH]
|
|
23
|
+
every `sha256:` pointer in the floor resolves to an archive block whose text
|
|
24
|
+
hashes to it. Exit 0, or 1 naming each pointer that does not.
|
|
25
|
+
|
|
26
|
+
Standard library only, like every script this skill ships.
|
|
27
|
+
"""
|
|
28
|
+
import argparse
|
|
29
|
+
import datetime
|
|
30
|
+
import hashlib
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
BUDGET = 12000 # bytes of floor (standing instructions + run stamps)
|
|
36
|
+
ROW = 600 # chars per standing-instruction row
|
|
37
|
+
STAMP = 300 # chars per run-stamp row
|
|
38
|
+
CAP = 10 # rows per table, the existing cap
|
|
39
|
+
|
|
40
|
+
STANDING = "## Standing instructions"
|
|
41
|
+
STAMPS = "## Run stamps"
|
|
42
|
+
POINTER = re.compile(r"(retro/[0-9]{4}-Q[1-4]\.md) sha256:([0-9a-f]{12,64})")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Unmeasurable(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sections(text):
|
|
50
|
+
"""{name: (start, end)} line spans of the two floor sections, end exclusive."""
|
|
51
|
+
lines = text.split("\n")
|
|
52
|
+
spans = {}
|
|
53
|
+
for i, line in enumerate(lines):
|
|
54
|
+
for name in (STANDING, STAMPS):
|
|
55
|
+
if line.startswith(name) and name not in spans:
|
|
56
|
+
j = i + 1
|
|
57
|
+
while j < len(lines) and not (lines[j].startswith("## ")
|
|
58
|
+
or lines[j].startswith("# ")):
|
|
59
|
+
j += 1
|
|
60
|
+
spans[name] = (i, j)
|
|
61
|
+
missing = [n for n in (STANDING, STAMPS) if n not in spans]
|
|
62
|
+
if missing:
|
|
63
|
+
raise Unmeasurable("no `%s` section, so the floor cannot be measured"
|
|
64
|
+
% "` or `".join(missing))
|
|
65
|
+
return lines, spans
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def table_rows(lines, span):
|
|
69
|
+
"""Indices of the data rows of every table inside a span (header and rule skipped)."""
|
|
70
|
+
out = []
|
|
71
|
+
start, end = span
|
|
72
|
+
i = start
|
|
73
|
+
while i < end:
|
|
74
|
+
if lines[i].startswith("|") and i + 1 < end and re.match(r"^\|\s*:?-{3}", lines[i + 1]):
|
|
75
|
+
i += 2
|
|
76
|
+
while i < end and lines[i].startswith("|"):
|
|
77
|
+
out.append(i)
|
|
78
|
+
i += 1
|
|
79
|
+
continue
|
|
80
|
+
i += 1
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cells(row):
|
|
85
|
+
parts = row.strip().split("|")
|
|
86
|
+
return [c.strip() for c in parts[1:-1]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def header_cols(lines, i):
|
|
90
|
+
"""Column count of the table holding row i, read from its header (a `|` inside a
|
|
91
|
+
cell's text must not widen the row that replaces it)."""
|
|
92
|
+
k = i
|
|
93
|
+
while k > 0 and lines[k - 1].startswith("|"):
|
|
94
|
+
k -= 1
|
|
95
|
+
return len(cells(lines[k]))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def measure(text, budget=BUDGET, row=ROW, stamp=STAMP):
|
|
99
|
+
lines, spans = sections(text)
|
|
100
|
+
floor = "\n".join(lines[spans[STANDING][0]:spans[STANDING][1]]) + "\n" + \
|
|
101
|
+
"\n".join(lines[spans[STAMPS][0]:spans[STAMPS][1]])
|
|
102
|
+
nbytes = len(floor.encode("utf-8"))
|
|
103
|
+
std = table_rows(lines, spans[STANDING])
|
|
104
|
+
stm = table_rows(lines, spans[STAMPS])
|
|
105
|
+
over = []
|
|
106
|
+
if nbytes > budget:
|
|
107
|
+
over.append("floor is %d bytes, over the budget of %d" % (nbytes, budget))
|
|
108
|
+
for i in std:
|
|
109
|
+
if len(lines[i]) > row:
|
|
110
|
+
over.append("standing row %s is %d chars, over %d"
|
|
111
|
+
% ((cells(lines[i]) or ["?"])[0], len(lines[i]), row))
|
|
112
|
+
for i in stm:
|
|
113
|
+
if len(lines[i]) > stamp:
|
|
114
|
+
c = cells(lines[i])
|
|
115
|
+
over.append("stamp %s %s is %d chars, over %d"
|
|
116
|
+
% (c[0] if c else "?", c[2] if len(c) > 2 else "", len(lines[i]), stamp))
|
|
117
|
+
if len(std) > CAP:
|
|
118
|
+
over.append("%d standing rows, over the cap of %d" % (len(std), CAP))
|
|
119
|
+
if len(stm) > CAP:
|
|
120
|
+
over.append("%d run stamps, over the cap of %d" % (len(stm), CAP))
|
|
121
|
+
return {
|
|
122
|
+
"bytes": nbytes, "tokens": nbytes // 4, "budget": budget,
|
|
123
|
+
"standing": len(std), "standing_max": max([len(lines[i]) for i in std] or [0]),
|
|
124
|
+
"stamps": len(stm), "stamps_max": max([len(lines[i]) for i in stm] or [0]),
|
|
125
|
+
"over": over, "lines": lines, "spans": spans, "std": std, "stm": stm,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def summary(m, row=ROW, stamp=STAMP):
|
|
130
|
+
return ("retro floor: %d bytes · ~%d tokens (bytes/4) · budget %d · standing %d/%d rows, "
|
|
131
|
+
"largest %d/%d · stamps %d/%d, largest %d/%d"
|
|
132
|
+
% (m["bytes"], m["tokens"], m["budget"], m["standing"], CAP, m["standing_max"], row,
|
|
133
|
+
m["stamps"], CAP, m["stamps_max"], stamp))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read(path):
|
|
137
|
+
try:
|
|
138
|
+
with open(path, encoding="utf-8") as fh:
|
|
139
|
+
return fh.read()
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise Unmeasurable("cannot read %s (%s)" % (path, exc.strerror or exc))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def default_archive(retro):
|
|
145
|
+
today = datetime.date.today()
|
|
146
|
+
q = (today.month - 1) // 3 + 1
|
|
147
|
+
return os.path.join(os.path.dirname(os.path.abspath(retro)), "retro",
|
|
148
|
+
"%d-Q%d.md" % (today.year, q))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def digest(text):
|
|
152
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def block(kind, label, text):
|
|
156
|
+
h = digest(text)
|
|
157
|
+
return h, ("\n### retro_budget migrate · %s · %s · %s\n\n<!-- sha256:%s -->\n%s\n"
|
|
158
|
+
% (datetime.date.today().isoformat(), kind, label, h, text))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_check(a):
|
|
162
|
+
m = measure(read(a.retro), a.budget, a.row, a.stamp)
|
|
163
|
+
print(summary(m, a.row, a.stamp))
|
|
164
|
+
if m["over"]:
|
|
165
|
+
for o in m["over"]:
|
|
166
|
+
print(" over: " + o)
|
|
167
|
+
print(" remedy: retro_budget.py migrate --write moves the narrative to the archive "
|
|
168
|
+
"verbatim; an instruction still over the row limit is rewritten by hand")
|
|
169
|
+
return 1
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_migrate(a):
|
|
174
|
+
text = read(a.retro)
|
|
175
|
+
m = measure(text, a.budget, a.row, a.stamp)
|
|
176
|
+
lines = m["lines"]
|
|
177
|
+
archive = a.archive or default_archive(a.retro)
|
|
178
|
+
rel = os.path.relpath(os.path.abspath(archive),
|
|
179
|
+
os.path.dirname(os.path.abspath(a.retro))).replace(os.sep, "/")
|
|
180
|
+
moved = []
|
|
181
|
+
for i in m["stm"]:
|
|
182
|
+
if len(lines[i]) <= a.stamp:
|
|
183
|
+
continue
|
|
184
|
+
c = cells(lines[i])
|
|
185
|
+
if len(c) < 3:
|
|
186
|
+
continue
|
|
187
|
+
h, b = block("stamp", "%s %s" % (c[0], c[2]), lines[i])
|
|
188
|
+
ptr = "moved: %s sha256:%s" % (rel, h[:12])
|
|
189
|
+
# the table keeps its column count: the pointer fills Verdict, `·` the rest
|
|
190
|
+
tail = ["·"] * max(0, header_cols(lines, i) - 4)
|
|
191
|
+
|
|
192
|
+
def row_of(topic):
|
|
193
|
+
return "| " + " | ".join([c[0], topic, c[2], ptr] + tail) + " |"
|
|
194
|
+
|
|
195
|
+
topic = c[1]
|
|
196
|
+
new = row_of(topic)
|
|
197
|
+
if len(new) > a.stamp:
|
|
198
|
+
keep = max(8, len(topic) - (len(new) - a.stamp) - 1)
|
|
199
|
+
new = row_of(topic[:keep].rstrip() + "…")
|
|
200
|
+
moved.append((i, new, b, "stamp %s %s" % (c[0], c[2])))
|
|
201
|
+
for i in m["std"]:
|
|
202
|
+
if len(lines[i]) <= a.row:
|
|
203
|
+
continue
|
|
204
|
+
c = cells(lines[i])
|
|
205
|
+
if len(c) < 5:
|
|
206
|
+
continue
|
|
207
|
+
h, b = block("standing", c[0], c[4])
|
|
208
|
+
c[4] = "story: %s sha256:%s" % (rel, h[:12])
|
|
209
|
+
moved.append((i, "| " + " | ".join(c) + " |", b, "standing %s Because" % c[0]))
|
|
210
|
+
if not moved:
|
|
211
|
+
print(summary(m, a.row, a.stamp))
|
|
212
|
+
print("migrate: nothing to move")
|
|
213
|
+
return 1 if m["over"] else 0
|
|
214
|
+
for i, new, _, label in moved:
|
|
215
|
+
print("migrate: %s -> %s (%d -> %d chars)" % (label, rel, len(lines[i]), len(new)))
|
|
216
|
+
if not a.write:
|
|
217
|
+
print("migrate: dry run, nothing written (pass --write)")
|
|
218
|
+
return 0
|
|
219
|
+
os.makedirs(os.path.dirname(os.path.abspath(archive)), exist_ok=True)
|
|
220
|
+
if not os.path.exists(archive):
|
|
221
|
+
with open(archive, "w", encoding="utf-8") as fh:
|
|
222
|
+
fh.write("# Retro archive · %s\n\n**Append-only. Queried, never read in full.** "
|
|
223
|
+
"Blocks moved here by `retro_budget.py migrate` carry the sha256 of the "
|
|
224
|
+
"text they hold; the floor keeps a pointer to it.\n"
|
|
225
|
+
% os.path.basename(archive)[:-3])
|
|
226
|
+
with open(archive, "a", encoding="utf-8") as fh:
|
|
227
|
+
for _, _, b, _ in moved:
|
|
228
|
+
fh.write(b)
|
|
229
|
+
for i, new, _, _ in moved:
|
|
230
|
+
lines[i] = new
|
|
231
|
+
tmp = a.retro + ".tmp"
|
|
232
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
233
|
+
fh.write("\n".join(lines))
|
|
234
|
+
os.replace(tmp, a.retro)
|
|
235
|
+
after = measure(read(a.retro), a.budget, a.row, a.stamp)
|
|
236
|
+
print(summary(after, a.row, a.stamp))
|
|
237
|
+
bad = verify(a.retro)
|
|
238
|
+
if bad:
|
|
239
|
+
for b in bad:
|
|
240
|
+
print(" unproved: " + b)
|
|
241
|
+
return 1
|
|
242
|
+
for o in after["over"]:
|
|
243
|
+
print(" still over: " + o)
|
|
244
|
+
return 1 if after["over"] else 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def verify(retro):
|
|
248
|
+
text = read(retro)
|
|
249
|
+
lines, spans = sections(text)
|
|
250
|
+
floor = "\n".join(lines[spans[STANDING][0]:spans[STANDING][1]] +
|
|
251
|
+
lines[spans[STAMPS][0]:spans[STAMPS][1]])
|
|
252
|
+
base = os.path.dirname(os.path.abspath(retro))
|
|
253
|
+
bad, cache = [], {}
|
|
254
|
+
for rel, short in POINTER.findall(floor):
|
|
255
|
+
path = os.path.join(base, rel)
|
|
256
|
+
if path not in cache:
|
|
257
|
+
try:
|
|
258
|
+
cache[path] = read(path)
|
|
259
|
+
except Unmeasurable:
|
|
260
|
+
cache[path] = None
|
|
261
|
+
arch = cache[path]
|
|
262
|
+
if arch is None:
|
|
263
|
+
bad.append("sha256:%s points at %s, which does not exist" % (short, rel))
|
|
264
|
+
continue
|
|
265
|
+
ok = False
|
|
266
|
+
for m in re.finditer(r"<!-- sha256:([0-9a-f]{64}) -->\n(.*?)\n(?=\n#{2,3} |\Z)", arch, re.S):
|
|
267
|
+
if m.group(1).startswith(short) and digest(m.group(2)) == m.group(1):
|
|
268
|
+
ok = True
|
|
269
|
+
break
|
|
270
|
+
if not ok:
|
|
271
|
+
bad.append("sha256:%s has no block in %s whose text hashes to it" % (short, rel))
|
|
272
|
+
return bad
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_verify(a):
|
|
276
|
+
bad = verify(a.retro)
|
|
277
|
+
for b in bad:
|
|
278
|
+
print("unproved: " + b)
|
|
279
|
+
if not bad:
|
|
280
|
+
print("verify: every archive pointer in the floor resolves and its hash matches")
|
|
281
|
+
return 1 if bad else 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main(argv=None):
|
|
285
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
286
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
287
|
+
for name in ("check", "migrate", "verify"):
|
|
288
|
+
s = sub.add_parser(name)
|
|
289
|
+
s.add_argument("--retro", default="docs/evidence/retro.md")
|
|
290
|
+
s.add_argument("--budget", type=int, default=BUDGET)
|
|
291
|
+
s.add_argument("--row", type=int, default=ROW)
|
|
292
|
+
s.add_argument("--stamp", type=int, default=STAMP)
|
|
293
|
+
if name == "migrate":
|
|
294
|
+
s.add_argument("--archive")
|
|
295
|
+
s.add_argument("--write", action="store_true")
|
|
296
|
+
a = p.parse_args(argv)
|
|
297
|
+
try:
|
|
298
|
+
return {"check": cmd_check, "migrate": cmd_migrate, "verify": cmd_verify}[a.cmd](a)
|
|
299
|
+
except Unmeasurable as exc:
|
|
300
|
+
print("retro_budget: could not measure: %s" % exc, file=sys.stderr)
|
|
301
|
+
return 2
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
sys.exit(main())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""short_path.py: stage 0's proportional decision, computed rather than proposed.
|
|
3
|
+
|
|
4
|
+
`references/stage-00-intake.md` -> Phase 1d. Until 1.98.0 the short path was only
|
|
5
|
+
*proposed*, and silence took the full flow; under a standing "decide and act"
|
|
6
|
+
instruction silence is the normal answer, so a small fix met eleven stages every time.
|
|
7
|
+
This script takes the five triage answers and DECIDES, printing the one line the run
|
|
8
|
+
shows the operator.
|
|
9
|
+
|
|
10
|
+
short_path.py --files PATH [PATH ...] --contract yes|no --user-facing yes|no
|
|
11
|
+
--deploy yes|no --risk R1|R2|R3 [--unknowns N]
|
|
12
|
+
|
|
13
|
+
Exit 0: the short path (brief in the run ledger, build with tests, one verification,
|
|
14
|
+
docs and handoff). Exit 3: the full flow, with every answer that ruled the short path
|
|
15
|
+
out. Exit 2: usage. Standard library only.
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
MAX_FILES = 5
|
|
21
|
+
SMALL_RISK = "R1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decide(files, contract, user_facing, deploy, risk, unknowns=0):
|
|
25
|
+
"""(short?, line) for the five answers plus the gap check's unknowns."""
|
|
26
|
+
n = len(set(files))
|
|
27
|
+
why = []
|
|
28
|
+
if unknowns:
|
|
29
|
+
why.append("%d material unknown(s) left by the gap check" % unknowns)
|
|
30
|
+
if n > MAX_FILES:
|
|
31
|
+
why.append("%d files, over %d" % (n, MAX_FILES))
|
|
32
|
+
if n == 0:
|
|
33
|
+
why.append("no file named, so the change is not bounded")
|
|
34
|
+
if contract:
|
|
35
|
+
why.append("a public contract is touched")
|
|
36
|
+
if user_facing:
|
|
37
|
+
why.append("a user-observable change")
|
|
38
|
+
if deploy:
|
|
39
|
+
why.append("a deploy target")
|
|
40
|
+
if risk != SMALL_RISK:
|
|
41
|
+
why.append("risk %s" % risk)
|
|
42
|
+
if why:
|
|
43
|
+
return False, "full flow: " + "; ".join(why)
|
|
44
|
+
return True, ("short path: %s · %d file%s · no contract · no user-facing change · "
|
|
45
|
+
"no deploy" % (risk, n, "" if n == 1 else "s"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def yes_no(v):
|
|
49
|
+
v = v.strip().lower()
|
|
50
|
+
if v not in ("yes", "no"):
|
|
51
|
+
raise argparse.ArgumentTypeError("answer yes or no, not %r" % v)
|
|
52
|
+
return v == "yes"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv=None):
|
|
56
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
57
|
+
p.add_argument("--files", nargs="*", default=[], help="the paths the change touches")
|
|
58
|
+
p.add_argument("--contract", type=yes_no, required=True)
|
|
59
|
+
p.add_argument("--user-facing", type=yes_no, required=True)
|
|
60
|
+
p.add_argument("--deploy", type=yes_no, required=True)
|
|
61
|
+
p.add_argument("--risk", choices=("R1", "R2", "R3"), required=True)
|
|
62
|
+
p.add_argument("--unknowns", type=int, default=0)
|
|
63
|
+
a = p.parse_args(argv)
|
|
64
|
+
short, line = decide(a.files, a.contract, a.user_facing, a.deploy, a.risk, a.unknowns)
|
|
65
|
+
print(line)
|
|
66
|
+
return 0 if short else 3
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
sys.exit(main())
|
|
@@ -32,12 +32,14 @@ reviewed; a table and a re-run can. This script is that re-run:
|
|
|
32
32
|
(a plan that spans several repositories' graphs).
|
|
33
33
|
waves.py finish --inventory FILE --max-rounds N --max-releases M (--rounds K | --ledger RUN.md)
|
|
34
34
|
--checks K/M --artefact NAME@SHA [--releases R] [--operator-go]
|
|
35
|
+
[--clean-rounds 1|2]
|
|
35
36
|
the finish-line verdict (`references/finish-line.md`) from the triaged items, and the
|
|
36
37
|
finish block printed. Only `triage: blocking` items count, less those closed as
|
|
37
38
|
duplicate, stale or not-reproduced; deferred and noise never change the verdict. The
|
|
38
39
|
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
|
|
40
|
-
rounds found no new blocking item
|
|
40
|
+
blocking <k>` lines, whose counts must match the inventory. Exit 0 done (the last
|
|
41
|
+
--clean-rounds rounds found no new blocking item: one by default, two when the
|
|
42
|
+
iteration carries an R3 or flagship node; none is open, --checks is all green, and the rounds
|
|
41
43
|
are within --max-rounds or --operator-go is passed); 5 the budget reached before done
|
|
42
44
|
(rounds at --max-rounds, releases at --max-releases with a blocker open, or done past
|
|
43
45
|
the round budget without --operator-go); 6 diverging (the blocking count did not fall
|
|
@@ -598,7 +600,7 @@ def ledger_rounds(path: pathlib.Path) -> dict[int, int]:
|
|
|
598
600
|
|
|
599
601
|
def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases: int,
|
|
600
602
|
releases: int, checks: tuple[int, int] | None,
|
|
601
|
-
operator_go: bool = False) -> tuple[int, str, dict]:
|
|
603
|
+
operator_go: bool = False, clean_rounds: int = 1) -> tuple[int, str, dict]:
|
|
602
604
|
"""(exit code, word, facts) — the finish line computed from the triaged items alone.
|
|
603
605
|
Deferred and noise items are counted for the block and never read for the verdict."""
|
|
604
606
|
triaged = [x for x in items if "triage" in x]
|
|
@@ -612,7 +614,9 @@ def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases
|
|
|
612
614
|
open_b = [x for x in blocking if _needs_node(x)]
|
|
613
615
|
facts = {"rounds": rounds, "new": new, "open": open_b, "fixed": len(blocking) - len(open_b),
|
|
614
616
|
"self_inflicted": selfinf}
|
|
615
|
-
clean
|
|
617
|
+
# One clean round ends an iteration; two only where it carries an R3 or flagship node
|
|
618
|
+
# (`--clean-rounds 2`). Two for everything was the loop the 2026-10-20 cost audit measured.
|
|
619
|
+
clean = rounds >= clean_rounds and all(n == 0 for n in new[-clean_rounds:]) and not open_b
|
|
616
620
|
green = checks is not None and checks[0] == checks[1]
|
|
617
621
|
if clean and green:
|
|
618
622
|
if rounds <= max_rounds:
|
|
@@ -625,7 +629,7 @@ def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases
|
|
|
625
629
|
if rounds >= max_rounds:
|
|
626
630
|
owed = (f"{len(open_b)} blocker(s) open" if open_b else
|
|
627
631
|
"exit checks not supplied" if checks is None else
|
|
628
|
-
"exit checks not all green" if not green else "
|
|
632
|
+
"exit checks not all green" if not green else "a clean round still owed")
|
|
629
633
|
return 5, "budget", dict(facts, why=f"rounds {rounds}/{max_rounds} reached before done — {owed}")
|
|
630
634
|
if open_b and releases >= max_releases:
|
|
631
635
|
return 5, "budget", dict(facts, why=f"releases {releases}/{max_releases} reached before "
|
|
@@ -640,7 +644,8 @@ def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases
|
|
|
640
644
|
why = (f"{len(open_b)} blocker(s) open" if open_b else
|
|
641
645
|
"exit checks not supplied — pass --checks K/M read on the artefact" if checks is None else
|
|
642
646
|
"exit checks not all green" if not green else
|
|
643
|
-
"a second round with no new blocking finding is owed"
|
|
647
|
+
("a second round with no new blocking finding is owed" if clean_rounds > 1 else
|
|
648
|
+
"a round with no new blocking finding is owed"))
|
|
644
649
|
return 3, "continue", dict(facts, why=why)
|
|
645
650
|
|
|
646
651
|
|
|
@@ -649,11 +654,14 @@ def cmd_finish(a) -> int:
|
|
|
649
654
|
_, items = load_inventory(pathlib.Path(a.inventory))
|
|
650
655
|
except Fail as exc:
|
|
651
656
|
raise Fail(1, exc.reasons) # a malformed inventory is malformed here, never usage
|
|
652
|
-
|
|
657
|
+
if a.clean_rounds not in (1, 2):
|
|
658
|
+
raise Fail(1, [f"--clean-rounds {a.clean_rounds} is not 1 or 2"])
|
|
659
|
+
for flag, v, least in (("--max-rounds", a.max_rounds, a.clean_rounds),
|
|
660
|
+
("--max-releases", a.max_releases, 1),
|
|
653
661
|
("--releases", a.releases, 0), ("--rounds", a.rounds, 0)):
|
|
654
662
|
if v is not None and v < least:
|
|
655
663
|
raise Fail(1, [f"{flag} {v} is below {least}" + (
|
|
656
|
-
" — done needs
|
|
664
|
+
f" — done needs {a.clean_rounds} clean rounds, so a smaller budget can never be met"
|
|
657
665
|
if flag == "--max-rounds" else "")])
|
|
658
666
|
# THE ROUND COUNT IS EXPLICIT. A clean round leaves no item, so a count derived from the
|
|
659
667
|
# items can never see the two clean rounds `done` needs: the verdict would stay
|
|
@@ -684,7 +692,7 @@ def cmd_finish(a) -> int:
|
|
|
684
692
|
if a.artefact and not ARTEFACT.match(a.artefact):
|
|
685
693
|
raise Fail(1, [f"--artefact {a.artefact!r} is not NAME@SHA (a commit of 7-40 hex digits)"])
|
|
686
694
|
code, word, f = finish_verdict(items, rounds, a.max_rounds, a.max_releases, a.releases, checks,
|
|
687
|
-
a.operator_go)
|
|
695
|
+
a.operator_go, a.clean_rounds)
|
|
688
696
|
deferred = sorted((x for x in items if x.get("triage") == "deferred"), key=_priority)
|
|
689
697
|
noise = [x for x in items if x.get("triage") == "noise"]
|
|
690
698
|
risks = [x for x in deferred if x.get("blast") == 3 or x.get("rung") in ("I4", "I5")]
|
|
@@ -738,7 +746,11 @@ def main(argv: list[str]) -> int:
|
|
|
738
746
|
help="ignore plan nodes this graph does not hold")
|
|
739
747
|
f = sub.add_parser("finish", help="the finish-line verdict from the triaged items, and the block")
|
|
740
748
|
f.add_argument("--inventory", required=True)
|
|
741
|
-
f.add_argument("--max-rounds", type=int, required=True,
|
|
749
|
+
f.add_argument("--max-rounds", type=int, required=True,
|
|
750
|
+
help="run.finish.maxRounds (default 3, at least --clean-rounds)")
|
|
751
|
+
f.add_argument("--clean-rounds", type=int, default=1,
|
|
752
|
+
help="run.finish.cleanRounds: clean rounds that end the iteration, 1 by "
|
|
753
|
+
"default, 2 when it carries an R3 or flagship node")
|
|
742
754
|
f.add_argument("--max-releases", type=int, required=True, help="run.finish.maxReleases (default 2)")
|
|
743
755
|
f.add_argument("--releases", type=int, default=0, help="releases this iteration made so far")
|
|
744
756
|
f.add_argument("--rounds", type=int, help="rounds run, clean ones included")
|
|
@@ -79,7 +79,7 @@ attached.
|
|
|
79
79
|
Cut per `references/waves.md` → *Cutting the waves*: a wave holds only items closable in one
|
|
80
80
|
pass; wave 1 is the walking skeleton; lanes run in parallel inside a wave and respect file
|
|
81
81
|
ownership. Each node carries its own risk class (`references/iteration.md` → *Verification
|
|
82
|
-
starts when a node lands*): R1 one passing round, R2
|
|
82
|
+
starts when a node lands*): R1 one passing round on one tier, R2 one, R3 two, one of them recorded adversarial.
|
|
83
83
|
|
|
84
84
|
**Counts:** <from `waves.py count` and the graph: nodes by risk, by wave, out of waves>.
|
|
85
85
|
|
|
@@ -25,6 +25,8 @@ resolve — the documentation gate checks it.
|
|
|
25
25
|
one, and it produces no fix)
|
|
26
26
|
- **Fix:** grade 1 (mechanical) — … / grade 2 → `R-00N` / grade 3, expires in 2 runs
|
|
27
27
|
- **The check:** … (what catches this the first time, from now on)
|
|
28
|
+
- **Class:** one of the classes in `references/retrospective.md` · **Cost:** rounds,
|
|
29
|
+
burned tags, time
|
|
28
30
|
- **Commit:** `<sha>` — the change that fixed it (a real short SHA, and it must
|
|
29
31
|
resolve: the gate runs `git rev-parse --verify` over every one)
|
|
30
32
|
- **Upstream?** … (a lesson true in any repository belongs in the skill's own
|
|
@@ -40,6 +42,13 @@ history attached**, which is what this section is for.
|
|
|
40
42
|
|---|---|---|---|---|
|
|
41
43
|
| <YYYY-MM-DD> | R-000 | … | became a check (`npm run lint:paths`) | `<sha>` |
|
|
42
44
|
|
|
45
|
+
## Moved by `retro_budget.py migrate`
|
|
46
|
+
|
|
47
|
+
Narrative the floor's byte budget moved out of `retro.md`: each block is the original
|
|
48
|
+
text, verbatim, under a `<!-- sha256:… -->` line that hashes it, and the floor keeps a
|
|
49
|
+
pointer (`retro/YYYY-QN.md sha256:<prefix>`). `retro_budget.py verify` proves every
|
|
50
|
+
pointer. Blocks are appended at the end of this file.
|
|
51
|
+
|
|
43
52
|
## Run stamps
|
|
44
53
|
|
|
45
54
|
Rotated out of `retro.md` when its live table passes ten. Append-only, whole rows —
|
|
@@ -13,6 +13,11 @@ not negotiable. The **Recent log** and the **archive**
|
|
|
13
13
|
read end to end: nothing caps the log's length, and an uncapped section read in
|
|
14
14
|
full is the volume that stops the ten binding rows being read.
|
|
15
15
|
|
|
16
|
+
**The floor has a byte budget:** standing instructions plus run stamps under 12 000
|
|
17
|
+
bytes, a standing row at most 600 characters (the story goes to the archive), a stamp
|
|
18
|
+
one line of at most 300. `scripts/retro_budget.py check` measures it at stage 0 and at
|
|
19
|
+
gate 10; `migrate --write` moves narrative to the archive verbatim, hash-proved.
|
|
20
|
+
|
|
16
21
|
## Standing instructions (max 10 — in force right now)
|
|
17
22
|
|
|
18
23
|
Every row is a rule an agent must follow, that **no check can decide**. A rule a
|
|
@@ -49,6 +54,10 @@ stays short precisely so that reading it in full at stage 0 stays cheap.
|
|
|
49
54
|
- **Fix:** grade 1 (mechanical) — … / grade 2 → `R-00N` / grade 3, expires in
|
|
50
55
|
2 runs
|
|
51
56
|
- **The check:** … (what catches this the first time, from now on)
|
|
57
|
+
- **Class:** `exit-code` | `unwatched-check` | `release` | `collision` |
|
|
58
|
+
`destructive-undo` | `claim-by-say-so` | `stale-doc` | `wrong-copy` | `sibling` |
|
|
59
|
+
`truncated-output` | `shell-instrument` | `handback` | `loop` (recurrence is counted
|
|
60
|
+
by class) · **Cost:** … (rounds, burned tags, time)
|
|
52
61
|
- **Lesson:** `<class-key>` — <the class, one sentence> · **When:** <trigger> · **Nouns:** <a>, <b>, <c>
|
|
53
62
|
(optional, and on ONE line: write it only when the class would be true in another
|
|
54
63
|
project — it makes the entry a lesson for `scripts/lessons.py add`, lesson/1. Without
|
|
@@ -74,7 +83,7 @@ this section is read in full at stage 0.
|
|
|
74
83
|
| Date | Topic | Commit | Verdict | Retro |
|
|
75
84
|
|---|---|---|---|---|
|
|
76
85
|
| 2026-05-04 | `<topic>` | `<sha>` | 14/14 verified | 1 entry · 7 standing (was 9) · retired 3 · added 1 |
|
|
77
|
-
| 2026-05-02 | `<topic>` | `<sha>` | 9/9 verified | no divergence · 9 standing ·
|
|
86
|
+
| 2026-05-02 | `<topic>` | `<sha>` | 9/9 verified | no divergence · 9 standing · prune: 0 retired |
|
|
78
87
|
|
|
79
88
|
The `Commit` column is what turns a stamp from a date into a navigable point in
|
|
80
89
|
history: `git show <sha>` is the run, and `git log <sha>..HEAD` is everything that
|