task-pipeline-skill 1.97.1 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/HOW-IT-WORKS.md +17 -13
  3. package/README.md +13 -10
  4. package/SKILL-CARD.md +1 -1
  5. package/cursor/rules/task-pipeline.mdc +22 -14
  6. package/evals/task-pipeline.evals.json +3 -3
  7. package/package.json +1 -1
  8. package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
  9. package/plugins/task-pipeline/commands/task-pipeline.md +14 -219
  10. package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +8 -1
  11. package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +1 -1
  12. package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +24 -1
  13. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +21 -576
  14. package/plugins/task-pipeline/skills/project-audit/SKILL.md +8 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +162 -230
  16. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +1 -1
  17. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +9 -9
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +11 -4
  19. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +2 -2
  20. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +3 -3
  21. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +22 -7
  22. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +49 -44
  23. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +6 -3
  24. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -1
  25. package/plugins/task-pipeline/skills/task-pipeline/references/finish-line.md +13 -8
  26. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +188 -32
  27. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +5 -3
  28. package/plugins/task-pipeline/skills/task-pipeline/references/learned.md +24 -1
  29. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +17 -7
  30. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +49 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +5 -4
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +39 -25
  33. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +1 -1
  34. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +36 -28
  35. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +8 -5
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +79 -22
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1229 -110
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +305 -0
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/short_path.py +70 -0
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +22 -10
  41. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +1 -1
  42. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +38 -10
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/retro-archive.md +9 -0
  44. 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 two
40
- rounds found no new blocking item, none is open, --checks is all green, and the rounds
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 = rounds >= 2 and new[-1] == 0 and new[-2] == 0 and not open_b
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 "the second clean round still owed")
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
- for flag, v, least in (("--max-rounds", a.max_rounds, 2), ("--max-releases", a.max_releases, 1),
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 two clean rounds, so a budget of one can never be met"
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, help="run.finish.maxRounds (default 3, at least 2)")
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 two, R3 three, one of them recorded adversarial.
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
 
@@ -51,7 +51,7 @@
51
51
  "maximum": 3
52
52
  },
53
53
  "stages": {
54
- "description": "The stage that owned the failure and the stage that surfaced it. A profile's stage ids need not be numbers.",
54
+ "description": "The stage that owned the failure and the stage that surfaced it. A profile's stage ids need not be numbers; a string id is one line of at most 32 characters, not blank, and gets the text fields' checks (no path, URL or credential shape). A longer or ill-shaped id an older index holds is normalised when read, never set aside.",
55
55
  "type": "object",
56
56
  "additionalProperties": false,
57
57
  "required": [
@@ -63,13 +63,19 @@
63
63
  "type": [
64
64
  "integer",
65
65
  "string"
66
- ]
66
+ ],
67
+ "minLength": 1,
68
+ "maxLength": 32,
69
+ "pattern": "\\S"
67
70
  },
68
71
  "surfaced": {
69
72
  "type": [
70
73
  "integer",
71
74
  "string"
72
- ]
75
+ ],
76
+ "minLength": 1,
77
+ "maxLength": 32,
78
+ "pattern": "\\S"
73
79
  }
74
80
  }
75
81
  },
@@ -96,7 +102,7 @@
96
102
  ],
97
103
  "properties": {
98
104
  "project": {
99
- "description": "The project's pseudonymous key: `p-` and the first 12 hex digits of the repository's root commit. Never its name: a repository name can be a customer's or a product's (redaction rule 4). The shared store receives the same key, never a `project:<slug>` id.",
105
+ "description": "The project's pseudonymous key: `p-` and the first 12 hex digits of the repository's root commit: the first-parent root of its default branch (origin/HEAD, main, master or the branch init.defaultBranch names; without them the main worktree's HEAD), so every worktree and branch of one repository shares it. Never its name: a repository name can be a customer's or a product's (redaction rule 4). The shared store receives the same key, never a `project:<slug>` id.",
100
106
  "type": "string",
101
107
  "pattern": "^p-[0-9a-f]{12}$"
102
108
  },
@@ -116,7 +122,7 @@
116
122
  },
117
123
  "$defs": {
118
124
  "indexEntry": {
119
- "description": "One line of the local index (lesson-index/1): one entry per classKey, a pure function of its origins, so a rebuild from the retros equals the incremental index byte for byte.",
125
+ "description": "One line of the local index (lesson-index/1): one entry per classKey, a pure function of its origins, so a complete rebuild of the same retros with nothing carried and no memory id recorded equals the incremental index byte for byte.",
120
126
  "type": "object",
121
127
  "additionalProperties": false,
122
128
  "required": [
@@ -169,13 +175,13 @@
169
175
  },
170
176
  "seen": {
171
177
  "type": "integer",
172
- "minimum": 1,
173
- "description": "How many origins hold the class."
178
+ "minimum": 0,
179
+ "description": "How many origins hold the class, counting re-derived and temporarily unavailable carried origins — not those carried as `checkout holds another project`."
174
180
  },
175
181
  "projects": {
176
182
  "type": "integer",
177
- "minimum": 1,
178
- "description": "How many distinct projects hold it."
183
+ "minimum": 0,
184
+ "description": "How many distinct projects hold it, counted like `seen`: an origin carried as `checkout holds another project` is kept but not counted."
179
185
  },
180
186
  "status": {
181
187
  "enum": [
@@ -207,7 +213,29 @@
207
213
  },
208
214
  "memory": {
209
215
  "type": "string",
210
- "pattern": "^mem:[^@\\s]+@[0-9]+$"
216
+ "pattern": "^mem:[^@\\s]+@[1-9][0-9]*$",
217
+ "description": "The shared store's id, `mem:<id>@<revision>` with the revision from 1. One id belongs to one (project, classKey): `record` refuses an id another project's or class's origin holds, and one the loader would refuse."
218
+ },
219
+ "carried": {
220
+ "enum": [
221
+ "checkout not read",
222
+ "checkout holds another project",
223
+ "retro file not read",
224
+ "no provenance",
225
+ "commit does not resolve",
226
+ "retro cites a commit that does not resolve",
227
+ "lesson refused",
228
+ "no checkout read holds it",
229
+ "source not read"
230
+ ],
231
+ "description": "Set by `rebuild` on every previous origin it did not re-derive: a rebuild never removes or moves anything on its own, and `rebuild --prune-carried` (optionally `--prune-carried-reason`) is the only way an origin leaves the index. The origin keeps everything recorded on it, its memory id included. `checkout holds another project` — every checkout in the provenance now holds another project key AND the origin's own commit does not resolve there — is kept but not counted in seen/projects/status; every other reason is a temporary unavailability and is counted. `source not read` is the round-7 label, still accepted. Cleared when a checkout holding the origin reads it again."
232
+ },
233
+ "checkouts": {
234
+ "type": "array",
235
+ "items": {
236
+ "type": "string"
237
+ },
238
+ "description": "Provenance: the real top-level paths of the checkouts this origin was derived from; they decide only the carried reason, never a removal. Local to this machine's index; never emitted to a store, and not printed by `show`."
211
239
  },
212
240
  "lesson": {
213
241
  "type": "object"
@@ -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 · retired 0 |
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