task-pipeline-skill 1.99.0 → 1.100.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 +60 -0
- package/README.md +2 -1
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/design-tester.md +80 -0
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +73 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +28 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/design-review.md +64 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +9 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +5 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +4 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +15 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +2 -3
- package/plugins/task-pipeline/skills/task-pipeline/scripts/design_gate.py +362 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/resource_guard.py +338 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +269 -30
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +51 -4
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +6 -4
|
@@ -15,7 +15,9 @@ on 2026-10-20 across ~50 projects the floor ran from ~3k to ~76k tokens, one of
|
|
|
15
15
|
moves narrative out of the floor VERBATIM into the archive, each block
|
|
16
16
|
carrying the sha256 of the text it holds, and leaves a pointer in its place:
|
|
17
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
|
|
18
|
+
over the row limit loses its `Because` cell; a standing instruction written as
|
|
19
|
+
a numbered list item keeps its bold rule and its `*(Retire ...)*` trigger; the
|
|
20
|
+
oldest run stamps past the cap of ten rotate out whole. Without --write it prints the
|
|
19
21
|
plan and writes nothing. Exit 0 when the floor is then within budget, 1 when a
|
|
20
22
|
row is still over (an instruction too long to shorten by moving its story:
|
|
21
23
|
rewrite it by hand), 2 could not measure.
|
|
@@ -95,41 +97,133 @@ def header_cols(lines, i):
|
|
|
95
97
|
return len(cells(lines[k]))
|
|
96
98
|
|
|
97
99
|
|
|
100
|
+
ITEM = re.compile(r"^(\d+)\.\s")
|
|
101
|
+
BULLET = re.compile(r"^[-*]\s")
|
|
102
|
+
DATE = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def list_items(lines, span, marker=ITEM):
|
|
106
|
+
"""(start, end) line spans of numbered list items inside a span, end exclusive.
|
|
107
|
+
|
|
108
|
+
An older retro writes its standing instructions as `N. **rule** ...` paragraphs, not
|
|
109
|
+
the template's table. An item runs until the next item, or the first non-indented,
|
|
110
|
+
non-blank line (a vacancy note, a heading's prose); trailing blank lines are not part
|
|
111
|
+
of it. Reading such a floor as zero rows was B-244 in sshlg-skills."""
|
|
112
|
+
out = []
|
|
113
|
+
start, end = span
|
|
114
|
+
i = start
|
|
115
|
+
while i < end:
|
|
116
|
+
if not marker.match(lines[i]):
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
j = i + 1
|
|
120
|
+
while j < end and not marker.match(lines[j]) and (
|
|
121
|
+
not lines[j].strip() or lines[j].startswith((" ", "\t"))
|
|
122
|
+
# a lazy continuation: unindented text directly under the item's last line,
|
|
123
|
+
# unless it opens something of its own (a list, a rule, a note, a table)
|
|
124
|
+
or (lines[j - 1].strip() and not lines[j].startswith(("|", "#", "*("))
|
|
125
|
+
and not ITEM.match(lines[j]) and not BULLET.match(lines[j])
|
|
126
|
+
and not re.match(r"^(?:[+]\s|>|\d+\)\s)", lines[j])
|
|
127
|
+
and not re.match(r"^(?:-{3,}|\*{3,}|_{3,})\s*$", lines[j]))):
|
|
128
|
+
j += 1
|
|
129
|
+
k = j
|
|
130
|
+
while k > i + 1 and not lines[k - 1].strip():
|
|
131
|
+
k -= 1
|
|
132
|
+
out.append((i, k))
|
|
133
|
+
i = j
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def stamp_date(lines, sp):
|
|
138
|
+
"""The date a run stamp was written: a table row's Date cell, or the first date in a
|
|
139
|
+
list stamp's opening line. None when it has none (the stamp cannot be ordered)."""
|
|
140
|
+
if lines[sp[0]].startswith("|"):
|
|
141
|
+
c = cells(lines[sp[0]])
|
|
142
|
+
m = re.match(r"\s*(\d{4}-\d{2}-\d{2})", c[0]) if c else None
|
|
143
|
+
else:
|
|
144
|
+
m = DATE.search(lines[sp[0]])
|
|
145
|
+
return m.group(1) if m else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
LEAD = re.compile(r"^(?:[-*]|\d+\.)\s+((?:\*\*.+?\*\*\s*)+)")
|
|
149
|
+
ONLY_DATE = re.compile(r"^(?:\*\*\s*\(?\d{4}-\d{2}-\d{2}\)?\s*\*\*\s*)+$")
|
|
150
|
+
RETIRE = re.compile(r"\*\((Retire.*?)\)\*")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def lead_of(lines, sp):
|
|
154
|
+
"""The item's leading bold spans, verbatim (`**(date)** **rule.**` keeps both), or None
|
|
155
|
+
when there are none or they hold only a date: the rule must survive in the floor."""
|
|
156
|
+
m = LEAD.match(" ".join(l.strip() for l in lines[sp[0]:sp[1]]))
|
|
157
|
+
if not m or ONLY_DATE.match(m.group(1).strip()):
|
|
158
|
+
return None
|
|
159
|
+
return m.group(1).strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def loose_prose(lines, span, taken):
|
|
163
|
+
"""Paragraphs of a section that are neither table lines nor list stamps: [(start, end)]."""
|
|
164
|
+
out, start, end = [], span[0] + 1, span[1]
|
|
165
|
+
i = start
|
|
166
|
+
while i < end:
|
|
167
|
+
if i in taken or not lines[i].strip() or lines[i].startswith("|"):
|
|
168
|
+
i += 1
|
|
169
|
+
continue
|
|
170
|
+
j = i
|
|
171
|
+
while j < end and j not in taken and lines[j].strip() and not lines[j].startswith("|"):
|
|
172
|
+
j += 1
|
|
173
|
+
# This script's own pointer notes are not prose: moving them again on the next
|
|
174
|
+
# run would make migrate rewrite an over-budget floor forever.
|
|
175
|
+
if not any(POINTER.search(l) for l in lines[i:j]):
|
|
176
|
+
out.append((i, j))
|
|
177
|
+
i = j
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
98
181
|
def measure(text, budget=BUDGET, row=ROW, stamp=STAMP):
|
|
99
182
|
lines, spans = sections(text)
|
|
100
183
|
floor = "\n".join(lines[spans[STANDING][0]:spans[STANDING][1]]) + "\n" + \
|
|
101
184
|
"\n".join(lines[spans[STAMPS][0]:spans[STAMPS][1]])
|
|
102
185
|
nbytes = len(floor.encode("utf-8"))
|
|
103
|
-
std = table_rows(lines, spans[STANDING])
|
|
104
|
-
|
|
186
|
+
std = [(i, i + 1) for i in table_rows(lines, spans[STANDING])]
|
|
187
|
+
form = "table"
|
|
188
|
+
if not std:
|
|
189
|
+
std = list_items(lines, spans[STANDING])
|
|
190
|
+
form = "numbered list" if std else "table"
|
|
191
|
+
# A run stamp is a table row or, in an older retro, a `- **date — title** ...` item.
|
|
192
|
+
stm = [(i, i + 1) for i in table_rows(lines, spans[STAMPS])] + \
|
|
193
|
+
list_items(lines, spans[STAMPS], BULLET)
|
|
194
|
+
stm.sort()
|
|
195
|
+
width = lambda sp: len("\n".join(lines[sp[0]:sp[1]]))
|
|
105
196
|
over = []
|
|
106
197
|
if nbytes > budget:
|
|
107
198
|
over.append("floor is %d bytes, over the budget of %d" % (nbytes, budget))
|
|
108
|
-
for
|
|
109
|
-
if
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
199
|
+
for sp in std:
|
|
200
|
+
if width(sp) > row:
|
|
201
|
+
label = (cells(lines[sp[0]]) or ["?"])[0] if form == "table" \
|
|
202
|
+
else "#" + ITEM.match(lines[sp[0]]).group(1)
|
|
203
|
+
over.append("standing row %s is %d chars, over %d" % (label, width(sp), row))
|
|
204
|
+
for sp in stm:
|
|
205
|
+
if width(sp) > stamp:
|
|
206
|
+
c = cells(lines[sp[0]]) if lines[sp[0]].startswith("|") else []
|
|
207
|
+
label = "%s %s" % (c[0], c[2] if len(c) > 2 else "") if c \
|
|
208
|
+
else "%s (list)" % (stamp_date(lines, sp) or "undated")
|
|
209
|
+
over.append("stamp %s is %d chars, over %d" % (label.strip(), width(sp), stamp))
|
|
117
210
|
if len(std) > CAP:
|
|
118
211
|
over.append("%d standing rows, over the cap of %d" % (len(std), CAP))
|
|
119
212
|
if len(stm) > CAP:
|
|
120
213
|
over.append("%d run stamps, over the cap of %d" % (len(stm), CAP))
|
|
121
214
|
return {
|
|
122
215
|
"bytes": nbytes, "tokens": nbytes // 4, "budget": budget,
|
|
123
|
-
"standing": len(std), "standing_max": max([
|
|
124
|
-
"stamps": len(stm), "stamps_max": max([
|
|
216
|
+
"standing": len(std), "standing_max": max([width(sp) for sp in std] or [0]), "form": form,
|
|
217
|
+
"stamps": len(stm), "stamps_max": max([width(sp) for sp in stm] or [0]),
|
|
125
218
|
"over": over, "lines": lines, "spans": spans, "std": std, "stm": stm,
|
|
126
219
|
}
|
|
127
220
|
|
|
128
221
|
|
|
129
222
|
def summary(m, row=ROW, stamp=STAMP):
|
|
130
|
-
return ("retro floor: %d bytes · ~%d tokens (bytes/4) · budget %d · standing %d/%d rows, "
|
|
223
|
+
return ("retro floor: %d bytes · ~%d tokens (bytes/4) · budget %d · standing %d/%d rows%s, "
|
|
131
224
|
"largest %d/%d · stamps %d/%d, largest %d/%d"
|
|
132
|
-
% (m["bytes"], m["tokens"], m["budget"], m["standing"], CAP,
|
|
225
|
+
% (m["bytes"], m["tokens"], m["budget"], m["standing"], CAP,
|
|
226
|
+
" (numbered list)" if m.get("form") == "numbered list" else "", m["standing_max"], row,
|
|
133
227
|
m["stamps"], CAP, m["stamps_max"], stamp))
|
|
134
228
|
|
|
135
229
|
|
|
@@ -177,9 +271,92 @@ def cmd_migrate(a):
|
|
|
177
271
|
archive = a.archive or default_archive(a.retro)
|
|
178
272
|
rel = os.path.relpath(os.path.abspath(archive),
|
|
179
273
|
os.path.dirname(os.path.abspath(a.retro))).replace(os.sep, "/")
|
|
180
|
-
moved = []
|
|
181
|
-
|
|
182
|
-
|
|
274
|
+
moved = [] # (first line, last line exclusive, replacement lines, block, label)
|
|
275
|
+
head = m["spans"][STAMPS][0]
|
|
276
|
+
notes = [] # pointer lines inserted under the Run stamps heading
|
|
277
|
+
rotated = set()
|
|
278
|
+
if len(m["stm"]) > CAP:
|
|
279
|
+
# The cap rotates the OLDEST stamps out whole (references/retrospective.md, *The
|
|
280
|
+
# stamp table is capped at ten*). Oldest is read from the date, never from the
|
|
281
|
+
# position: tables and lists are written both ways round.
|
|
282
|
+
# A same-day tie is broken by position, the way its own block is written: a list
|
|
283
|
+
# written newest-first and a table written oldest-first can sit side by side.
|
|
284
|
+
def block_of(sp):
|
|
285
|
+
if not lines[sp[0]].startswith("|"):
|
|
286
|
+
return "list"
|
|
287
|
+
k = sp[0]
|
|
288
|
+
while k > 0 and lines[k - 1].startswith("|"):
|
|
289
|
+
k -= 1
|
|
290
|
+
return k
|
|
291
|
+
blocks = {}
|
|
292
|
+
for n, sp in enumerate(m["stm"]):
|
|
293
|
+
blocks.setdefault(block_of(sp), []).append((n, stamp_date(lines, sp)))
|
|
294
|
+
newest_first, votes = {}, []
|
|
295
|
+
for key, seq in blocks.items():
|
|
296
|
+
ds = [d for _, d in seq]
|
|
297
|
+
down = sum(1 for x, y in zip(ds, ds[1:]) if x and y and x > y)
|
|
298
|
+
up = sum(1 for x, y in zip(ds, ds[1:]) if x and y and x < y)
|
|
299
|
+
newest_first[key] = None if down == up else down > up
|
|
300
|
+
if newest_first[key] is not None:
|
|
301
|
+
votes.append(newest_first[key])
|
|
302
|
+
# A block whose dates show no direction takes the section's; with no evidence
|
|
303
|
+
# anywhere, newest first, the template's order.
|
|
304
|
+
fallback = sum(votes) * 2 >= len(votes) if votes else True
|
|
305
|
+
for key in newest_first:
|
|
306
|
+
if newest_first[key] is None:
|
|
307
|
+
newest_first[key] = fallback
|
|
308
|
+
dated = [(stamp_date(lines, sp), -n if newest_first[block_of(sp)] else n, sp)
|
|
309
|
+
for n, sp in enumerate(m["stm"])]
|
|
310
|
+
if any(d is None for d, _, _ in dated):
|
|
311
|
+
print("migrate: a run stamp carries no YYYY-MM-DD date; rotate the stamps by hand")
|
|
312
|
+
else:
|
|
313
|
+
rotated = set(sp for _, _, sp in sorted(dated)[:len(dated) - CAP])
|
|
314
|
+
# A rotated table row travels under its table's header, so the archive keeps
|
|
315
|
+
# a table and the header of a table emptied by the rotation is not lost.
|
|
316
|
+
parts, headed = [], set()
|
|
317
|
+
for sp in m["stm"]:
|
|
318
|
+
if sp not in rotated:
|
|
319
|
+
continue
|
|
320
|
+
if lines[sp[0]].startswith("|"):
|
|
321
|
+
k = sp[0]
|
|
322
|
+
while k > 0 and lines[k - 1].startswith("|"):
|
|
323
|
+
k -= 1
|
|
324
|
+
if k not in headed:
|
|
325
|
+
headed.add(k)
|
|
326
|
+
parts.append("\n".join(lines[k:k + 2]))
|
|
327
|
+
parts.append("\n".join(lines[sp[0]:sp[1]]))
|
|
328
|
+
text = "\n".join(parts)
|
|
329
|
+
for k in headed:
|
|
330
|
+
j = k + 2
|
|
331
|
+
while j < len(lines) and lines[j].startswith("|"):
|
|
332
|
+
j += 1
|
|
333
|
+
if all((r, r + 1) in rotated for r in range(k + 2, j)):
|
|
334
|
+
moved.append((k, k + 2, [], None, None)) # archived above, with its rows
|
|
335
|
+
h, b = block("stamps", "%d oldest rotated" % len(rotated), text)
|
|
336
|
+
for sp in rotated:
|
|
337
|
+
moved.append((sp[0], sp[1], [], None, None))
|
|
338
|
+
notes.append(("Rotated out: %d oldest run stamps, verbatim in %s sha256:%s"
|
|
339
|
+
% (len(rotated), rel, h[:12]), b, "%d oldest run stamps rotated" % len(rotated)))
|
|
340
|
+
for sp in m["stm"]:
|
|
341
|
+
i = sp[0]
|
|
342
|
+
if sp in rotated or len("\n".join(lines[sp[0]:sp[1]])) <= a.stamp:
|
|
343
|
+
continue
|
|
344
|
+
if not lines[i].startswith("|"):
|
|
345
|
+
# A list stamp keeps its bold title; the narrative moves.
|
|
346
|
+
item = "\n".join(lines[sp[0]:sp[1]])
|
|
347
|
+
title = lead_of(lines, sp)
|
|
348
|
+
if not title:
|
|
349
|
+
print("migrate: the %s stamp has no **bold** title to keep; shorten it by hand"
|
|
350
|
+
% (stamp_date(lines, sp) or "undated"))
|
|
351
|
+
continue
|
|
352
|
+
h, b = block("stamp", stamp_date(lines, sp) or "undated", item)
|
|
353
|
+
ptr = " moved: %s sha256:%s" % (rel, h[:12])
|
|
354
|
+
t = title
|
|
355
|
+
if len("- %s%s" % (t, ptr)) > a.stamp:
|
|
356
|
+
inner = re.sub(r"\*\*", "", t)
|
|
357
|
+
t = "**%s…**" % inner[:max(8, a.stamp - len("- ****…") - len(ptr))].rstrip()
|
|
358
|
+
moved.append((sp[0], sp[1], ["- %s%s" % (t, ptr)], b,
|
|
359
|
+
"stamp %s (list)" % (stamp_date(lines, sp) or "undated")))
|
|
183
360
|
continue
|
|
184
361
|
c = cells(lines[i])
|
|
185
362
|
if len(c) < 3:
|
|
@@ -197,22 +374,69 @@ def cmd_migrate(a):
|
|
|
197
374
|
if len(new) > a.stamp:
|
|
198
375
|
keep = max(8, len(topic) - (len(new) - a.stamp) - 1)
|
|
199
376
|
new = row_of(topic[:keep].rstrip() + "…")
|
|
200
|
-
moved.append((i, new, b, "stamp %s %s" % (c[0], c[2])))
|
|
201
|
-
|
|
202
|
-
|
|
377
|
+
moved.append((i, i + 1, [new], b, "stamp %s %s" % (c[0], c[2])))
|
|
378
|
+
if m["bytes"] > a.budget:
|
|
379
|
+
# Prune paragraphs between the stamps were a measured share of the floor
|
|
380
|
+
# (*A prune that retires nothing writes its count into the stamp row*): over
|
|
381
|
+
# budget, every loose paragraph of the Run stamps section moves, verbatim.
|
|
382
|
+
taken = set(k for s0, s1 in m["stm"] for k in range(s0, s1))
|
|
383
|
+
prose = loose_prose(lines, m["spans"][STAMPS], taken)
|
|
384
|
+
# verify ends an archive block at a blank line before a heading, so a paragraph
|
|
385
|
+
# holding a heading stays in the floor rather than moving unprovable.
|
|
386
|
+
held = [pp for pp in prose if any(re.match(r"^#{1,6} ", l) for l in lines[pp[0]:pp[1]])]
|
|
387
|
+
for p0, _ in held:
|
|
388
|
+
print("migrate: a heading inside Run stamps stays in the floor: %s" % lines[p0][:60])
|
|
389
|
+
prose = [pp for pp in prose if pp not in held]
|
|
390
|
+
if prose:
|
|
391
|
+
text = "\n\n".join("\n".join(lines[p0:p1]) for p0, p1 in prose)
|
|
392
|
+
h, b = block("stamps prose", "%d paragraphs" % len(prose), text)
|
|
393
|
+
for p0, p1 in prose:
|
|
394
|
+
moved.append((p0, p1, [], None, None))
|
|
395
|
+
notes.append(("Moved out: %d loose paragraphs, verbatim in %s sha256:%s"
|
|
396
|
+
% (len(prose), rel, h[:12]), b, "%d prose paragraphs" % len(prose)))
|
|
397
|
+
if notes:
|
|
398
|
+
moved.append((head + 1, head + 1, sum([["", n] for n, _, _ in notes], []), None, None))
|
|
399
|
+
for n, b, label in notes:
|
|
400
|
+
moved.append((head + 1, head + 1, [], b, label))
|
|
401
|
+
for s0, s1 in m["std"]:
|
|
402
|
+
item = "\n".join(lines[s0:s1])
|
|
403
|
+
if len(item) <= a.row:
|
|
203
404
|
continue
|
|
204
|
-
|
|
405
|
+
# Already migrated and still too long: its story is gone, the rule itself is the
|
|
406
|
+
# length. Moving the pointer again would rewrite the floor on every run.
|
|
407
|
+
if POINTER.search(item):
|
|
408
|
+
continue
|
|
409
|
+
if m["form"] == "numbered list":
|
|
410
|
+
# Keep the rule (its bold lead) and its retire-when trigger; the story moves.
|
|
411
|
+
flat = " ".join(l.strip() for l in lines[s0:s1])
|
|
412
|
+
num = ITEM.match(lines[s0]).group(1)
|
|
413
|
+
lead = lead_of(lines, (s0, s1))
|
|
414
|
+
retire = RETIRE.findall(flat)
|
|
415
|
+
if not lead:
|
|
416
|
+
print("migrate: standing #%s opens with no **bold** rule (or only a date); "
|
|
417
|
+
"rewrite it by hand" % num)
|
|
418
|
+
continue
|
|
419
|
+
h, b = block("standing", "#" + num, item)
|
|
420
|
+
new = "%s. %s%s story: %s sha256:%s" % (
|
|
421
|
+
num, lead, (" *(%s)*" % retire[-1]) if retire else "", rel, h[:12])
|
|
422
|
+
if not retire:
|
|
423
|
+
print("migrate: standing #%s carries no *(Retire ...)* trigger; add one by hand" % num)
|
|
424
|
+
moved.append((s0, s1, [new], b, "standing #%s story" % num))
|
|
425
|
+
continue
|
|
426
|
+
c = cells(lines[s0])
|
|
205
427
|
if len(c) < 5:
|
|
206
428
|
continue
|
|
207
429
|
h, b = block("standing", c[0], c[4])
|
|
208
430
|
c[4] = "story: %s sha256:%s" % (rel, h[:12])
|
|
209
|
-
moved.append((
|
|
431
|
+
moved.append((s0, s1, ["| " + " | ".join(c) + " |"], b, "standing %s Because" % c[0]))
|
|
210
432
|
if not moved:
|
|
211
433
|
print(summary(m, a.row, a.stamp))
|
|
212
434
|
print("migrate: nothing to move")
|
|
213
435
|
return 1 if m["over"] else 0
|
|
214
|
-
for
|
|
215
|
-
|
|
436
|
+
for s0, s1, new, _, label in moved:
|
|
437
|
+
if label:
|
|
438
|
+
print("migrate: %s -> %s%s" % (label, rel, "" if s0 == s1 else " (%d -> %d chars)" % (
|
|
439
|
+
len("\n".join(lines[s0:s1])), len("\n".join(new)))))
|
|
216
440
|
if not a.write:
|
|
217
441
|
print("migrate: dry run, nothing written (pass --write)")
|
|
218
442
|
return 0
|
|
@@ -224,10 +448,25 @@ def cmd_migrate(a):
|
|
|
224
448
|
"text they hold; the floor keeps a pointer to it.\n"
|
|
225
449
|
% os.path.basename(archive)[:-3])
|
|
226
450
|
with open(archive, "a", encoding="utf-8") as fh:
|
|
227
|
-
for _, _, b, _ in moved:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
451
|
+
for _, _, _, b, _ in moved:
|
|
452
|
+
if b:
|
|
453
|
+
fh.write(b)
|
|
454
|
+
# Rebuild from the bottom so earlier spans keep their indices.
|
|
455
|
+
for s0, s1, new, _, _ in sorted(moved, key=lambda x: (x[0], x[1]), reverse=True):
|
|
456
|
+
lines[s0:s1] = new
|
|
457
|
+
# Removed stamps leave their blank separators behind: within Run stamps, one blank
|
|
458
|
+
# line between blocks is enough. Only a run that removed something tidies, so a
|
|
459
|
+
# table retro that only had a row shortened keeps its bytes (1.99.0 behaviour).
|
|
460
|
+
removed = any(s0 != s1 and not new for s0, s1, new, _, _ in moved)
|
|
461
|
+
_, sp2 = sections("\n".join(lines))
|
|
462
|
+
a0, a1 = sp2[STAMPS]
|
|
463
|
+
seg = lines[a0:a1]
|
|
464
|
+
kept = []
|
|
465
|
+
for k, line in enumerate(seg):
|
|
466
|
+
if removed and not line.strip() and kept and not kept[-1].strip():
|
|
467
|
+
continue
|
|
468
|
+
kept.append(line)
|
|
469
|
+
lines[a0:a1] = kept
|
|
231
470
|
tmp = a.retro + ".tmp"
|
|
232
471
|
with open(tmp, "w", encoding="utf-8") as fh:
|
|
233
472
|
fh.write("\n".join(lines))
|
|
@@ -32,7 +32,7 @@ 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
|
+
[--clean-rounds 1|2] [--max-agents 2]
|
|
36
36
|
the finish-line verdict (`references/finish-line.md`) from the triaged items, and the
|
|
37
37
|
finish block printed. Only `triage: blocking` items count, less those closed as
|
|
38
38
|
duplicate, stale or not-reproduced; deferred and noise never change the verdict. The
|
|
@@ -47,7 +47,10 @@ reviewed; a table and a re-run can. This script is that re-run:
|
|
|
47
47
|
self-inflicted >= new, from round 2 on); 3 continue (including no --checks); 1
|
|
48
48
|
malformed (a schema error, a blocking item without `test` or `round`, B3 with
|
|
49
49
|
base_repro yes, a round past the rounds run, no round count, a ledger that disagrees,
|
|
50
|
-
--max-rounds below 2, an artefact that is not NAME@SHA).
|
|
50
|
+
--max-rounds below 2, an artefact that is not NAME@SHA). Since 1.100.0 the ledger's
|
|
51
|
+
`agents: <N> — <k>` lines (the most sub-agents running at once in round N) are read
|
|
52
|
+
too: a round over --max-agents (run.finish.maxAgents, 2 by default) is a budget
|
|
53
|
+
verdict, exit 5, unless --operator-go.
|
|
51
54
|
|
|
52
55
|
**The plan** is any JSON whose `nodes` list `{id, wave, closes}` — a work graph that carries
|
|
53
56
|
`closes` on its nodes is one, and so is a short hand-written table. A node may carry
|
|
@@ -559,6 +562,7 @@ def cmd_sync_graph(a) -> int:
|
|
|
559
562
|
# A blocking item closed as one of these was never a blocker: it counts in no round.
|
|
560
563
|
NOT_COUNTED = ("duplicate", "stale", "not-reproduced")
|
|
561
564
|
ROUND_LINE = re.compile(r"^round:\s*(\d+)\s*[—-]\s*(.*?)\s*[—-]\s*new blocking\s*(\d+)\s*$")
|
|
565
|
+
AGENTS_LINE = re.compile(r"^agents:\s*(\d+)\s*[—-]\s*(\d+)\b")
|
|
562
566
|
ARTEFACT = re.compile(r"^[^@\s]+@[0-9a-f]{7,40}$")
|
|
563
567
|
|
|
564
568
|
|
|
@@ -598,6 +602,27 @@ def ledger_rounds(path: pathlib.Path) -> dict[int, int]:
|
|
|
598
602
|
return out
|
|
599
603
|
|
|
600
604
|
|
|
605
|
+
def ledger_agents(path: pathlib.Path) -> dict[int, int]:
|
|
606
|
+
"""{round: most sub-agents at once} from the run ledger's `agents:` lines (since 1.100.0).
|
|
607
|
+
A round written twice keeps its larger count: the budget is about the peak."""
|
|
608
|
+
try:
|
|
609
|
+
text = path.read_text(encoding="utf-8")
|
|
610
|
+
except (OSError, UnicodeError) as exc:
|
|
611
|
+
raise Fail(1, [f"the run ledger {path} cannot be read ({type(exc).__name__})"])
|
|
612
|
+
out, errs = {}, []
|
|
613
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
614
|
+
if not line.startswith("agents:"):
|
|
615
|
+
continue
|
|
616
|
+
m = AGENTS_LINE.match(line.strip())
|
|
617
|
+
if not m:
|
|
618
|
+
errs.append(f"{path}:{n}: not `agents: <N> — <k>`")
|
|
619
|
+
continue
|
|
620
|
+
out[int(m[1])] = max(out.get(int(m[1]), 0), int(m[2]))
|
|
621
|
+
if errs:
|
|
622
|
+
raise Fail(1, errs)
|
|
623
|
+
return out
|
|
624
|
+
|
|
625
|
+
|
|
601
626
|
def finish_verdict(items: list[dict], rounds: int, max_rounds: int, max_releases: int,
|
|
602
627
|
releases: int, checks: tuple[int, int] | None,
|
|
603
628
|
operator_go: bool = False, clean_rounds: int = 1) -> tuple[int, str, dict]:
|
|
@@ -693,12 +718,31 @@ def cmd_finish(a) -> int:
|
|
|
693
718
|
raise Fail(1, [f"--artefact {a.artefact!r} is not NAME@SHA (a commit of 7-40 hex digits)"])
|
|
694
719
|
code, word, f = finish_verdict(items, rounds, a.max_rounds, a.max_releases, a.releases, checks,
|
|
695
720
|
a.operator_go, a.clean_rounds)
|
|
721
|
+
# The parallel-agent budget (run.finish.maxAgents): a round that ran more sub-agents at
|
|
722
|
+
# once than the budget allows stops the iteration like any other budget, said with the
|
|
723
|
+
# numbers; the operator's go lifts it, out loud.
|
|
724
|
+
agents = ledger_agents(pathlib.Path(a.ledger)) if a.ledger else {}
|
|
725
|
+
if a.max_agents < 1:
|
|
726
|
+
raise Fail(1, [f"--max-agents {a.max_agents} is below 1"])
|
|
727
|
+
stray = sorted(r for r in agents if r < 1 or r > rounds)
|
|
728
|
+
if stray:
|
|
729
|
+
raise Fail(1, [f"{a.ledger}: an `agents:` line names round {r}, and only rounds 1..{rounds} ran"
|
|
730
|
+
for r in stray])
|
|
731
|
+
over = sorted((r, k) for r, k in agents.items() if k > a.max_agents)
|
|
732
|
+
named = ", ".join(f"round {r}: {k}/{a.max_agents}" for r, k in over)
|
|
733
|
+
if over and not a.operator_go and code in (0, 3):
|
|
734
|
+
code, word = 5, "budget"
|
|
735
|
+
f = dict(f, why="agents over budget — " + named)
|
|
736
|
+
elif over and a.operator_go and code in (0, 3):
|
|
737
|
+
f = dict(f, why=((f.get("why") + "; ") if f.get("why") else "") + "agents over budget on the operator's go — " + named)
|
|
696
738
|
deferred = sorted((x for x in items if x.get("triage") == "deferred"), key=_priority)
|
|
697
739
|
noise = [x for x in items if x.get("triage") == "noise"]
|
|
698
740
|
risks = [x for x in deferred if x.get("blast") == 3 or x.get("rung") in ("I4", "I5")]
|
|
699
741
|
pool = sorted((x for x in items if x.get("triage") != "blocking" and _needs_node(x)
|
|
700
742
|
and x.get("triage") != "noise"), key=_priority)
|
|
701
|
-
|
|
743
|
+
peak = max(agents.values()) if agents else None
|
|
744
|
+
budget = (f"rounds {f['rounds']}/{a.max_rounds} — releases {a.releases}/{a.max_releases} — agents "
|
|
745
|
+
+ (f"{peak}/{a.max_agents}" if peak is not None else f"unrecorded/{a.max_agents}"))
|
|
702
746
|
if word == "continue":
|
|
703
747
|
print(f"next round: {f['rounds'] + 1}/{a.max_rounds} — {f['why']} — {budget}")
|
|
704
748
|
else:
|
|
@@ -756,8 +800,11 @@ def main(argv: list[str]) -> int:
|
|
|
756
800
|
f.add_argument("--rounds", type=int, help="rounds run, clean ones included")
|
|
757
801
|
f.add_argument("--ledger", help="the run ledger; its `round:` lines are the round count")
|
|
758
802
|
f.add_argument("--operator-go", action="store_true",
|
|
759
|
-
help="the operator's recorded go for rounds past --max-rounds"
|
|
803
|
+
help="the operator's recorded go for rounds past --max-rounds and sub-agents past "
|
|
804
|
+
"--max-agents; the block says which budget it lifted")
|
|
760
805
|
f.add_argument("--checks", help="K/M — exit checks green on the artefact")
|
|
806
|
+
f.add_argument("--max-agents", type=int, default=2,
|
|
807
|
+
help="run.finish.maxAgents: sub-agents running at once in one round (default 2)")
|
|
761
808
|
f.add_argument("--artefact", help="what they ran on, as NAME@SHA")
|
|
762
809
|
a = p.parse_args(argv)
|
|
763
810
|
verbs = {"merge": cmd_merge, "assign": cmd_assign, "count": cmd_count, "validate": cmd_validate,
|
|
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
|
|
|
20
20
|
|
|
21
21
|
## `read:` — which doctrine this run actually opened
|
|
22
22
|
|
|
23
|
-
The bundle is
|
|
23
|
+
The bundle is 54 reference files and nothing recorded which of them a run read, so **a
|
|
24
24
|
skipped file and a read one were indistinguishable** — the class every guard in this
|
|
25
25
|
pipeline exists to catch, left standing over the doctrine itself.
|
|
26
26
|
|
|
@@ -48,7 +48,7 @@ same claim one level down.
|
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| `unmeasured — no run ledger` | there is no ledger | nothing to read from |
|
|
50
50
|
| `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
|
|
51
|
-
| `N of
|
|
51
|
+
| `N of 54 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
|
|
52
52
|
|
|
53
53
|
**It is a disclosure: no floor, no direction, never a target.** A run that needs four files
|
|
54
54
|
and reads four is not worse than one that reads thirty — and the moment the number becomes
|
|
@@ -73,7 +73,8 @@ event: <compact|session-end|subagent|memory> — <detail> — <ISO-8601>
|
|
|
73
73
|
review: <stage id> — surface <name> — rounds <N> — <returned|approved|unresolved> — <ISO-8601>
|
|
74
74
|
finding: <id> — blocking:B<n> | deferred:B-NNN | noise — round <N> — base-repro yes|no|n/a
|
|
75
75
|
round: <N> — <reading> — new blocking <k>
|
|
76
|
-
|
|
76
|
+
agents: <N> — <most sub-agents running at once in round N>
|
|
77
|
+
finish: done|budget|blocked — rounds <n>/<max> — releases <n>/<max> — agents <n>/<max>
|
|
77
78
|
read: references/<file>.md # hook-appended, deduped, UNATTESTED (no writer field)
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -161,6 +162,7 @@ review: 6 — surface landing — rounds 1 — returned — 2026-08-10T12:02Z
|
|
|
161
162
|
finding: F-021 — blocking:B3 — round 1 — base-repro no
|
|
162
163
|
finding: F-022 — deferred:B-118 — round 1 — base-repro yes
|
|
163
164
|
round: 1 — exit checks + audit axis seams — new blocking 1
|
|
165
|
+
agents: 1 — 2
|
|
164
166
|
finding: F-023 — noise — round 2 — base-repro n/a
|
|
165
167
|
round: 2 — exit checks + observer — new blocking 0
|
|
166
168
|
round: 3 — exit checks + post-release readback — new blocking 0
|
|
@@ -169,7 +171,7 @@ hand: 3 — task "add CSV export to the orders table" — done 2 — surfaced 1
|
|
|
169
171
|
scope 5f21ac3/node-24-linux/REQ-001,REQ-004 — unverified 1 (XLSX path: no fixture)
|
|
170
172
|
holds: 5 — 2 (worktree: build-csv-export, this run; container: pg-test, this run) — enumerated 8/8 classes
|
|
171
173
|
holds: 10 — none — enumerated 7/8 classes, unlooked: containers (no docker on this host)
|
|
172
|
-
finish: done — rounds 3/3 — releases 1/2
|
|
174
|
+
finish: done — rounds 3/3 — releases 1/2 — agents 2/2
|
|
173
175
|
```
|
|
174
176
|
|
|
175
177
|
The last two lines are a **trip**: the same file, two consecutive passes, the same
|