task-pipeline-skill 1.99.0 → 1.99.1

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 CHANGED
@@ -1,3 +1,32 @@
1
+ ## v1.99.1 (2026-10-21)
2
+
3
+ Guards: 530 → **530**; property checks 15 → **15**. No negative was added: the new cases
4
+ are regression tests in `test/audit_regressions/diet_retro_budget.py`.
5
+
6
+ **`retro_budget.py` reads an older retro's floor and migrates it** (B-149, from sshlg-skills
7
+ B-244). That retro writes its standing instructions as a numbered list and its run stamps as
8
+ `- **date: title** ...` narrative items beside a table. `check` printed `standing 0/10 rows`
9
+ for ten instructions, and `migrate` answered `nothing to move` on a 103,737-byte floor with 89
10
+ stamps, so stage 10's byte budget could never pass there.
11
+
12
+ - `check` counts numbered-list standing instructions and list stamps, measures each item
13
+ whole and names it (`standing row #6`, `stamp 2026-09-06 (list)`).
14
+ - `migrate` rotates the oldest stamps past the cap of ten, ordered by their date and never by
15
+ position, verbatim into the quarter's archive (a table row travels under its table's
16
+ header); keeps a long list item's bold rule and its `*(Retire ...)*` trigger and moves the
17
+ rest; keeps a long list stamp's bold title; and, only over budget, moves the Run stamps
18
+ section's loose prune paragraphs. Every move leaves a `sha256:` pointer that `verify` proves.
19
+ - Edge rules from two review rounds: a lead of several bold spans stays whole and a date-only
20
+ lead refuses by name; a same-day tie follows its own block's direction; a paragraph holding
21
+ a heading stays in the floor; the script's own pointer notes never move again, so a floor
22
+ still over budget is left alone by the next run; a table's header leaves only with the rows
23
+ this run rotated; a list marker, a quote, a rule or a `*(` note ends an item; a rule still
24
+ over 600 characters after its story moved is reported, never moved again.
25
+ - On a copy of the sshlg-skills retro: 103,737 bytes to 6,446, every pointer proved, no
26
+ non-blank line lost, the text outside the floor unchanged.
27
+ - Tests: `test/audit_regressions/diet_retro_budget.py` gains seventeen cases. Each failed on the
28
+ 1.99.0 script (it crashed with no archive to read) or on a reviewed intermediate version.
29
+
1
30
  ## v1.99.0 (2026-10-21)
2
31
 
3
32
  Guards: 448 → **530**; property checks 15 → **15**. There are 82 new negatives in
package/SKILL-CARD.md CHANGED
@@ -12,7 +12,7 @@ harmless.
12
12
  |---|---|
13
13
  | **Purpose** | Runs a substantial task through ten gated delivery stages — intake grill, docs study, brainstorm, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs+registers, acceptance — refusing to advance until each gate passes |
14
14
  | **Owner** | ssheleg ([github.com/ssheleg/task-pipeline](https://github.com/ssheleg/task-pipeline)) |
15
- | **Version** | 1.99.0 |
15
+ | **Version** | 1.99.1 |
16
16
  | **Surface** | Claude Code (filesystem skill + plugin) and the vercel `skills` CLI. **Not** uploaded to the Skills API; custom Skills do not sync across surfaces |
17
17
  | **Dependencies** | None required. Optional: `context7` (MCP), `figma` (MCP), super-ux, agent-sync, graphify, obsidian-wiki, and **one of two browser channels** — `playwright` (CLI or MCP) or `chrome-devtools` (MCP); either satisfies the browser step and neither is required. The engine is WebKit (Safari) first; `chrome-devtools` is Chrome-only, a stated exception. Every stage's doctrine ships in-repo; user-facing stage 3 requires a valid scenario artifact; super-ux is preferred, with equivalent-provider and inline-fallback paths under the provider contract |
18
18
  | **Evaluation status** | Suite authored, 5 categories. One recorded run, **self-observed by the author**; **zero blind runs on zero of three models** — the split, and the numbers, live in [`evals/RESULTS.md`](evals/RESULTS.md) and are computed by `evals/run.py` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.99.0",
3
+ "version": "1.99.1",
4
4
  "description": "Full-cycle delivery pipeline for coding agents: a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine ships inside the skill — no companion plugin required. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "task-pipeline": "bin/task-pipeline.js"
@@ -3,7 +3,7 @@
3
3
  "name": "task-pipeline",
4
4
  "displayName": "Task Pipeline",
5
5
  "description": "Runs a substantial task through a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine is built into the skill — no companion plugin required — with typed auto/judgment/manual gates, a frozen requirement spine that closes with evidence, a work board and a verification ledger that outlive a run, an exposure line naming what shipped unconfirmed, a progress rail computed from the project's own config, a proportional short path for small changes, a loop guard whose ceilings stop and record a blocker, and stage-3 tracks for what a product does, how it sounds and how it looks. Two modes need no task: `checkup` (what is unverified) and `setup` (audit existing docs); a third, `waves`, takes the whole board and closes it in waves that each finish in one pass. Retro insights can publish upstream as issues, opt-in and redacted.",
6
- "version": "1.99.0",
6
+ "version": "1.99.1",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -205,6 +205,10 @@ narrative are how a capped list grows anyway.
205
205
  `Because`) **verbatim** into the quarter's archive, each block carrying the sha256 of
206
206
  its text, and leaves a pointer; `retro_budget.py verify` proves every pointer. An
207
207
  instruction still over 600 characters after its story moved is rewritten by hand.
208
+ An older retro whose standing instructions are a numbered list and whose stamps are
209
+ `- **date: title**` items is read the same way: the item keeps its bold rule and its
210
+ `*(Retire ...)*` trigger, a stamp keeps its title, stamps past the cap rotate out by date,
211
+ and, over budget, the Run stamps section's loose prune paragraphs move.
208
212
  - **A prune that retires nothing writes its count into the stamp row**, as
209
213
  `prune: 0 retired`, and no paragraph. Repeated "nothing to prune" notes were a
210
214
  measured share of the floor.
@@ -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. Without --write it prints the
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
- stm = table_rows(lines, spans[STAMPS])
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 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))
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([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]),
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, m["standing_max"], row,
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
- for i in m["stm"]:
182
- if len(lines[i]) <= a.stamp:
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
- for i in m["std"]:
202
- if len(lines[i]) <= a.row:
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
- c = cells(lines[i])
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((i, "| " + " | ".join(c) + " |", b, "standing %s Because" % c[0]))
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 i, new, _, label in moved:
215
- print("migrate: %s -> %s (%d -> %d chars)" % (label, rel, len(lines[i]), len(new)))
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
- fh.write(b)
229
- for i, new, _, _ in moved:
230
- lines[i] = new
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))