task-pipeline-skill 1.100.0 → 1.101.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,43 @@
1
+ ## v1.101.1 (2026-10-23)
2
+
3
+ Guards: 530 → **530**; property checks 15 → **15**. No negative was added: the new checks are
4
+ two cases in `test/release_gate_test.py` (65 to 67).
5
+
6
+ - **B-153:** when the release gate's python3 decision fails, its fallback refused any command
7
+ that spelled `tag`, `push`, `release` or `publish` anywhere, so reading
8
+ `.git/sshlg-release-candidates/` was refused as an outward act (2026-10-22). It now matches the
9
+ command shapes (`git … tag|push`, `gh … release`, `npm … publish`); every outward act it
10
+ refused before is still refused. Filed as B-152, an id v1.91.1 had already used.
11
+
12
+ ## v1.101.0 (2026-10-22)
13
+
14
+ Guards: 530 → **530**; property checks 15 → **15**. No negative was added: the new checks are
15
+ sixteen cases in `test/design_gate_test.py` (75 to 91) and two in
16
+ `test/audit_regressions/diet_retro_budget.py`.
17
+
18
+ **Cancel parity and one clear offer on every subscription surface** (hub milestone M2, G8 and
19
+ R2, from GP Bullhound's *Consumer Subscription Software*, October 2025, p. 18).
20
+
21
+ - **`design_gate.py report --subscription`** (or a `subscription` block in the report): the
22
+ surface sells, renews or cancels a subscription, so the report counts the steps to subscribe
23
+ and to cancel. Cancelling in more steps fails, a `pass` over a longer cancel path is refused, a
24
+ paywall without one clear offer fails, a check not walked is NOT_RUN with its reason, and a
25
+ missing pause is reported, not gated. `agents/design-tester.md` step 7 walks it;
26
+ `references/design-review.md` and the stage 6 gate in `references/stages.md` state it.
27
+ - **The panel counts locals and tourists** (M2 R3): `design_gate.py panel` requires each
28
+ retention line to open with `local` or `tourist` and at least two of each, the rule super-ux
29
+ 0.65.0's test panel template states.
30
+ - **One rotation line per archive** (B-151, found as sshlg-skills B-246): `retro_budget.py
31
+ migrate` re-archives a quarter's earlier rotated stamps with the new ones as one block and
32
+ keeps one `Rotated out:` line per archive, so a floor that rotates every run no longer grows by
33
+ a line per run. A line whose block no longer hashes stays as its own line.
34
+ - **Board:** B-044 collapsed into B-040 (the same finding); B-150 files the two fw07 branches
35
+ kept on purpose.
36
+ - **A test that raced the clock:** `test/lessons_test.py` H1 waited a fixed 1.5 s for a rebuild
37
+ to reach its read; under a load average near 300 the add ran first and the tagged gate failed
38
+ on negative 502. The rebuild's read now blocks on a handshake until the add has landed (431
39
+ cases).
40
+
1
41
  ## v1.100.0 (2026-10-22)
2
42
 
3
43
  Guards: 530 → **530**; property checks 15 → **15**. No negative was added: the new checks are
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.100.0 |
15
+ | **Version** | 1.101.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.100.0",
3
+ "version": "1.101.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.100.0",
6
+ "version": "1.101.1",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -49,8 +49,14 @@ Your inputs, and nothing else is yours:
49
49
  5. **Close what you opened.** `resource_guard.py close` then `resource_guard.py verify --json`;
50
50
  its output is the report's `resources` block. A live process is a failed gate, not a note.
51
51
  6. **Gate yourself.** `python3 scripts/design_gate.py report <report> --class <surface_class>
52
- --panel docs/ux/test-panel.md --scope <ids> --require design|build`. Quote its output as
53
- your first evidence. NOT_RUN is said as NOT_RUN.
52
+ --panel docs/ux/test-panel.md --scope <ids> --require design|build`, plus `--subscription`
53
+ when the surface sells, renews or cancels a subscription. Quote its output as your first
54
+ evidence. NOT_RUN is said as NOT_RUN.
55
+ 7. **A subscription surface: walk the way out.** Count the steps from the paywall to an active
56
+ subscription and from the account to a confirmed cancellation, as one persona, in the same
57
+ pass. Cancelling takes no more steps than subscribing (California's click-to-cancel rule
58
+ from 2025-07-01 says the same), the paywall makes one clear offer, and note whether a pause
59
+ is offered. This is the report's `subscription` block.
54
60
 
55
61
  ## The report, `design-review/1`
56
62
 
@@ -63,9 +69,16 @@ Your inputs, and nothing else is yours:
63
69
  "focus": "pass", "states": "na", "text_fit": "pass", "outcome": "pass"},
64
70
  "findings": [{"severity": "breaks", "what": "no way back from the summary",
65
71
  "where": "summary screen", "status": "open"}]}],
66
- "resources": {"started": [], "live": [], "closed": true}}
72
+ "resources": {"started": [], "live": [], "closed": true},
73
+ "subscription": {"cancel_parity": "pass", "subscribe_steps": 3, "cancel_steps": 2,
74
+ "cancel_path": "Settings > Subscription > Cancel", "single_offer": "pass",
75
+ "pause": "offered"}}
67
76
  ```
68
77
 
78
+ `subscription` is present only on a subscription surface. `cancel_parity` and `single_offer` are
79
+ pass, fail or NOT_RUN (with `reason`); `pause` is offered, absent or na. A `pass` over a cancel
80
+ path longer than the subscribe path is refused.
81
+
69
82
  `breaks` stops the user in a scenario step or contradicts the scenario's outcome. `risk` makes a
70
83
  step slower, unclear or easy to get wrong. `nit` is polish. Taste is none of them: a finding
71
84
  names the step, the persona and the checklist item it failed.
@@ -252,10 +252,13 @@ status=$?
252
252
  if [ $status -ne 0 ] || [ -z "$verdict" ]; then
253
253
  [ -f "$ledger" ] || exit 0
254
254
  # B-152: without python3 the command cannot be classified, and refusing EVERY
255
- # Bash call took the whole session down on a full disk. A word-level pre-filter
256
- # keeps fail-closed exactly where it matters: anything that could tag, push,
257
- # release or publish is still refused; `ls` is not.
258
- if ! printf '%s' "$input" | grep -Eq '(^|[^[:alnum:]_])(tag|tags|push|release|publish)([^[:alnum:]_]|$)'; then
255
+ # Bash call took the whole session down on a full disk. A pre-filter keeps
256
+ # fail-closed exactly where it matters: anything that could tag, push, release or
257
+ # publish is still refused; `ls` is not.
258
+ # B-153: it matched the WORDS, so `ls .git/sshlg-release-candidates/` was refused
259
+ # as an outward act. It now matches the command shapes: `git … tag|push`,
260
+ # `gh … release`, `npm … publish`, each word standing alone as an argument.
261
+ if ! printf '%s' "$input" | grep -Eq '(^|[^[:alnum:]_./-])(git([[:space:]]+[^[:space:];&|]+)*[[:space:]]+(tag|push)|gh([[:space:]]+[^[:space:];&|]+)*[[:space:]]+release|npm([[:space:]]+[^[:space:];&|]+)*[[:space:]]+publish)([[:space:]"]|$)'; then
259
262
  exit 0
260
263
  fi
261
264
  echo "task-pipeline: the stage-7 gate could not run (python3 missing or failed), and this project has a run in flight ($ledger). Refusing an outward act rather than failing open." >&2
@@ -26,9 +26,11 @@ unmentioned one does not. Waves and iterations owe it per UI node, never once pe
26
26
  `docs/ux/test-panel.md`, owned by super-ux (`ux-foundation` → *The test panel*): ten portraits,
27
27
  each with **basis** (`evidence RE-NN` or `hypothesis`), **situation**, **goals**, **features**,
28
28
  **scenarios** (composite chains by `SCR-` id), **activation** (steps to first value, and where
29
- it drops), **retention** (trigger → action → reward → what brings it back) and **needs**
30
- (device, engine, locale, accessibility). The operator confirms it once; a later change of the
31
- product revises it. `python3 scripts/design_gate.py panel docs/ux/test-panel.md` gates it.
29
+ it drops), **retention** (opening with `local` or `tourist`, then trigger → action → reward →
30
+ what brings it back) and **needs** (device, engine, locale, accessibility). At least two
31
+ portraits are locals and two are tourists: lifetime value rests on the first, churn on the
32
+ second (since 1.101.0). The operator confirms it once; a later change of the product revises
33
+ it. `python3 scripts/design_gate.py panel docs/ux/test-panel.md` gates it.
32
34
 
33
35
  A portrait marked `hypothesis` chooses what to test, never what to build: the super-ux rule
34
36
  that a synthetic persona is never evidence still holds.
@@ -47,6 +49,16 @@ finding; a `breaks` finding names its failed item. NOT_RUN is never PASS.
47
49
  With Figma, pass D judges what the designer drew and pass B how it was built against it.
48
50
  Without Figma, pass D judges the mockups and pass B the build against them.
49
51
 
52
+ ## A subscription surface: cancel parity and one clear offer
53
+
54
+ A surface that sells, renews or cancels a subscription (paywall, trial, pricing, cancel) adds
55
+ `--subscription` to both gates. The report's `subscription` block counts the steps to subscribe
56
+ and to cancel, walked by one persona in the same pass: cancelling takes no more steps than
57
+ subscribing (the click-to-cancel standard, law in California from 2025-07-01), the paywall makes
58
+ one clear offer, and whether a pause is offered is noted. A missing block, a longer cancel path
59
+ or a paywall without one offer fails; a check not walked is NOT_RUN with its reason; a missing
60
+ pause is reported, not gated. The block's fields are in `agents/design-tester.md`.
61
+
50
62
  ## The budget, and where findings go
51
63
 
52
64
  One pass D and one pass B per surface per round; a re-review reads only the steps the fix
@@ -208,7 +208,11 @@ narrative are how a capped list grows anyway.
208
208
  An older retro whose standing instructions are a numbered list and whose stamps are
209
209
  `- **date: title**` items is read the same way: the item keeps its bold rule and its
210
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.
211
+ and, over budget, the Run stamps section's loose prune paragraphs move. Rotation keeps
212
+ **one `Rotated out:` line per archive**: a later rotation re-archives the quarter's
213
+ earlier rotated stamps with its own as one block and the line names that block, so a
214
+ floor that rotates every run does not grow by a line per run. A line whose block no
215
+ longer hashes stays as its own line.
212
216
  - **A prune that retires nothing writes its count into the stamp row**, as
213
217
  `prune: 0 retired`, and no paragraph. Repeated "nothing to prune" notes were a
214
218
  measured share of the floor.
@@ -308,7 +308,9 @@ never that the work was skipped quietly.
308
308
  **On any class but `none` the design review's pass B ran over the build** (since
309
309
  1.100.0): the same `design_gate.py report` with `--require build` exits 0, every capture
310
310
  names its engine, and `python3 scripts/resource_guard.py verify <ledger>` is clean
311
- ([`design-review.md`](design-review.md)).
311
+ ([`design-review.md`](design-review.md)). A subscription surface (paywall, trial, pricing,
312
+ cancel) adds `--subscription`: cancelling takes no more steps than subscribing and the
313
+ paywall makes one clear offer (since 1.101.0).
312
314
  - **What the look finds is fixed here.** A rendering defect found at stage 6 is a
313
315
  stage-6 finding: fix it, look again, then call the stage green. Filing it to the
314
316
  board and advancing is how a run reports *checked in a browser* for a page it has
@@ -6,14 +6,18 @@ with an exit code. Stdlib only.
6
6
  the test panel the design-tester walks: ten portraits (or --min), each with its
7
7
  basis, situation, goals, features, composite scenarios, activation funnel,
8
8
  retention loop and needs. A portrait without a basis line is refused: a panel is a
9
- hypothesis instrument, and saying so is part of the portrait.
9
+ hypothesis instrument, and saying so is part of the portrait. Each retention line
10
+ opens with `local` or `tourist`, and the panel holds at least two of each (1.101.0).
10
11
  design_gate.py report <design-review.json> --class <surface_class>
11
12
  [--panel <test-panel.md>] [--scope SCR-01,SCR-02] [--require build]
12
- [--max-passes 1] [--json]
13
+ [--max-passes 1] [--subscription] [--json]
13
14
  stage 3 (`--require design`) and stage 6 (`--require build`): the report covers
14
15
  every scope scenario × every panel persona that uses it, every step carries the
15
16
  full checklist, no `breaks` finding is open, each round holds at most --max-passes
16
17
  passes of each kind, and the resource receipt shows every process it started closed.
18
+ `--subscription` (or a `subscription` block in the report): the surface sells, renews
19
+ or cancels a subscription, so cancelling takes no more steps than subscribing and the
20
+ paywall makes one clear offer (1.101.0).
17
21
 
18
22
  design_gate.py browser <pipeline.json> [--repo DIR] [--json]
19
23
  the recorded test browser: `browser.primary` is set (NOT_RUN when the block is
@@ -52,6 +56,7 @@ ALIASES = {"composite scenarios": "scenarios", "activation funnel": "activation"
52
56
  FIELD = re.compile(r"^\s*[-*]\s+\*\*(%s)\s*:?\*\*\s*[:—–-]?\s*(.+?)\s*$"
53
57
  % "|".join(sorted(list(FIELDS) + list(ALIASES), key=len, reverse=True)), re.I)
54
58
  SCR = re.compile(r"\bSCR-\d+\b")
59
+ SEGMENT = re.compile(r"^\s*(local|tourist)\b\s*:?", re.I)
55
60
 
56
61
 
57
62
  def read_text(path):
@@ -110,6 +115,17 @@ def panel_problems(panel, minimum):
110
115
  out.append("%s: basis is `evidence RE-NN` or `hypothesis`, not %r" % (pid, basis[:40]))
111
116
  if p.get("scenarios") and not SCR.search(p["scenarios"]):
112
117
  out.append("%s: composite scenarios cite no SCR- id" % pid)
118
+ # M2 R3 (1.101.0): lifetime value rests on locals and churn on tourists, so the panel
119
+ # walks both. The retention line opens with the segment (super-ux templates/test-panel.md).
120
+ seg = {pid: SEGMENT.match(p.get("retention", "")) for pid, p in panel.items()}
121
+ for pid, m in sorted(seg.items()):
122
+ if panel[pid].get("retention") and not m:
123
+ out.append("%s: the retention line opens with `local` or `tourist`" % pid)
124
+ locals_ = sum(1 for m in seg.values() if m and m.group(1).lower() == "local")
125
+ tourists = sum(1 for m in seg.values() if m and m.group(1).lower() == "tourist")
126
+ if panel and (locals_ < 2 or tourists < 2):
127
+ out.append("the panel carries %d local%s and %d tourist%s; it owes at least two of each"
128
+ % (locals_, "" if locals_ == 1 else "s", tourists, "" if tourists == 1 else "s"))
113
129
  return out
114
130
 
115
131
 
@@ -174,10 +190,52 @@ def row_problems(r, i):
174
190
  return out
175
191
 
176
192
 
177
- def report_check(doc, surface_class, panel=None, scope=(), require=None, max_passes=1):
193
+ PARITY = ("pass", "fail", "NOT_RUN")
194
+ PAUSE = ("offered", "absent", "na")
195
+
196
+
197
+ def subscription_problems(sub):
198
+ """M2 G8/R2: cancelling is as easy as subscribing, and the paywall makes one clear offer.
199
+ Returns (problems, notes, not_run)."""
200
+ if sub is None:
201
+ return (["a subscription surface carries its cancel parity check (`subscription` block: "
202
+ "cancel_parity, subscribe_steps, cancel_steps, single_offer, pause)"], [], False)
203
+ if not isinstance(sub, dict):
204
+ return ["`subscription` is an object, not %r" % (sub,)], [], False
205
+ out, notes = [], []
206
+ for k in ("cancel_parity", "single_offer"):
207
+ if sub.get(k) not in PARITY:
208
+ out.append("subscription.%s is %r, not one of %s" % (k, sub.get(k), "/".join(PARITY)))
209
+ steps = {k: _posint(sub.get(k)) for k in ("subscribe_steps", "cancel_steps")}
210
+ for k, v in steps.items():
211
+ if v is None and sub.get("cancel_parity") != "NOT_RUN":
212
+ out.append("subscription.%s is a positive integer, not %r" % (k, sub.get(k)))
213
+ s, c = steps["subscribe_steps"], steps["cancel_steps"]
214
+ if sub.get("cancel_parity") == "fail":
215
+ out.append("cancelling takes %s steps against %s to subscribe: cancel parity fails" % (c, s))
216
+ elif sub.get("cancel_parity") == "pass" and s and c and c > s:
217
+ out.append("cancel_parity `pass` contradicts %d cancel steps against %d to subscribe" % (c, s))
218
+ if sub.get("single_offer") == "fail":
219
+ out.append("the paywall makes no single clear offer")
220
+ if sub.get("pause") not in PAUSE:
221
+ out.append("subscription.pause is offered, absent or na, not %r" % (sub.get("pause"),))
222
+ elif sub.get("pause") == "absent":
223
+ notes.append("no pause offered on the cancel path (a save offer, not gated)")
224
+ idle = [k for k in ("cancel_parity", "single_offer") if sub.get(k) == "NOT_RUN"]
225
+ if idle and not sub.get("reason"):
226
+ out.append("a NOT_RUN %s carries its reason" % " and ".join(idle))
227
+ return out, notes, bool(idle)
228
+
229
+
230
+ def report_check(doc, surface_class, panel=None, scope=(), require=None, max_passes=1, subscription=False):
178
231
  problems, notes = [], []
179
232
  if not isinstance(doc, dict) or doc.get("schema") != "design-review/1":
180
233
  return "FAIL", ["the report is not design-review/1"], notes
234
+ sub_idle = False
235
+ if subscription or "subscription" in doc:
236
+ sp, sn, sub_idle = subscription_problems(doc.get("subscription"))
237
+ problems += sp
238
+ notes += sn
181
239
  rows = doc.get("rows") or []
182
240
  if not isinstance(rows, list) or any(not isinstance(r, dict) for r in rows):
183
241
  return "FAIL", ["`rows` is a list of objects"], notes
@@ -252,6 +310,8 @@ def report_check(doc, surface_class, panel=None, scope=(), require=None, max_pas
252
310
  return "NOT_RUN", ["every %s step is NOT_RUN: the %s pass did not run" % (k, k)], notes
253
311
  if k in kinds and not mine:
254
312
  return "NOT_RUN", ["no %s step in the report" % k], notes
313
+ if sub_idle:
314
+ return "NOT_RUN", ["the subscription checks were not walked: %s" % doc["subscription"].get("reason")], notes
255
315
  partial = ["%s step %s for %s (%s)" % (r.get("scenario"), r.get("step"), r.get("persona"), r.get("reason", "no reason"))
256
316
  for r in rows if any(v == "NOT_RUN" for v in (r.get("checks") or {}).values())]
257
317
  if partial:
@@ -322,6 +382,8 @@ def main(argv=None):
322
382
  p2.add_argument("--scope", default="")
323
383
  p2.add_argument("--require", choices=PASSES)
324
384
  p2.add_argument("--max-passes", type=int, default=1)
385
+ p2.add_argument("--subscription", action="store_true",
386
+ help="the surface sells, renews or cancels a subscription: cancel parity and one clear offer are owed")
325
387
  p2.add_argument("--json", action="store_true")
326
388
  a = ap.parse_args(argv)
327
389
  if a.cmd == "panel":
@@ -354,7 +416,7 @@ def main(argv=None):
354
416
  return 2
355
417
  panel = parse_panel(read_text(a.panel)) if a.panel and os.path.exists(a.panel) else None
356
418
  scope = [s.strip() for s in a.scope.split(",") if s.strip()]
357
- verdict, probs, notes = report_check(doc, a.cls, panel, scope, a.require, a.max_passes)
419
+ verdict, probs, notes = report_check(doc, a.cls, panel, scope, a.require, a.max_passes, a.subscription)
358
420
  return emit(verdict, probs, notes, a.json)
359
421
 
360
422
 
@@ -252,6 +252,32 @@ def block(kind, label, text):
252
252
  % (datetime.date.today().isoformat(), kind, label, h, text))
253
253
 
254
254
 
255
+ ROTATED = re.compile(r"^Rotated out: ([0-9]+) oldest run stamps, verbatim in (\S+) sha256:([0-9a-f]{12,64})$")
256
+ ARCHIVED = re.compile(r"<!-- sha256:([0-9a-f]{64}) -->\n(.*?)\n(?=\n#{2,3} |\Z)", re.S)
257
+
258
+
259
+ def rotation_lines(lines, span, rel):
260
+ """(line index, stamp count, short sha) of each rotation pointer into `rel`."""
261
+ out = []
262
+ for k in range(span[0], span[1]):
263
+ r = ROTATED.match(lines[k])
264
+ if r and r.group(2) == rel:
265
+ out.append((k, int(r.group(1)), r.group(3)))
266
+ return out
267
+
268
+
269
+ def archived_text(path, short):
270
+ """(offset, text) of the archive block whose text hashes to `short`, or None."""
271
+ try:
272
+ arch = read(path)
273
+ except Unmeasurable:
274
+ return None
275
+ for m in ARCHIVED.finditer(arch):
276
+ if m.group(1).startswith(short) and digest(m.group(2)) == m.group(1):
277
+ return m.start(), m.group(2)
278
+ return None
279
+
280
+
255
281
  def cmd_check(a):
256
282
  m = measure(read(a.retro), a.budget, a.row, a.stamp)
257
283
  print(summary(m, a.row, a.stamp))
@@ -332,11 +358,28 @@ def cmd_migrate(a):
332
358
  j += 1
333
359
  if all((r, r + 1) in rotated for r in range(k + 2, j)):
334
360
  moved.append((k, k + 2, [], None, None)) # archived above, with its rows
335
- h, b = block("stamps", "%d oldest rotated" % len(rotated), text)
361
+ # B-246: one pointer line per archive, not one per rotation. Earlier rotations
362
+ # into the same archive are re-archived with this one as a single block, and
363
+ # their lines give way to one line naming it. The archive stays append-only:
364
+ # the earlier blocks remain, only the floor stops pointing at them.
365
+ count, earlier = len(rotated), []
366
+ for k, n, short in rotation_lines(lines, m["spans"][STAMPS], rel):
367
+ prior = archived_text(archive, short)
368
+ if prior is None:
369
+ print("migrate: the rotation line sha256:%s does not resolve in %s; "
370
+ "it stays as its own line" % (short, rel))
371
+ continue
372
+ earlier.append((prior[0], k, prior[1]))
373
+ count += n
374
+ for _, k, _ in earlier:
375
+ moved.append((k, k + 1, [], None, None))
376
+ if earlier:
377
+ text = "\n".join([t for _, _, t in sorted(earlier)] + [text])
378
+ h, b = block("stamps", "%d oldest rotated" % count, text)
336
379
  for sp in rotated:
337
380
  moved.append((sp[0], sp[1], [], None, None))
338
381
  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)))
382
+ % (count, rel, h[:12]), b, "%d oldest run stamps rotated" % len(rotated)))
340
383
  for sp in m["stm"]:
341
384
  i = sp[0]
342
385
  if sp in rotated or len("\n".join(lines[sp[0]:sp[1]])) <= a.stamp:
@@ -502,7 +545,7 @@ def verify(retro):
502
545
  bad.append("sha256:%s points at %s, which does not exist" % (short, rel))
503
546
  continue
504
547
  ok = False
505
- for m in re.finditer(r"<!-- sha256:([0-9a-f]{64}) -->\n(.*?)\n(?=\n#{2,3} |\Z)", arch, re.S):
548
+ for m in ARCHIVED.finditer(arch):
506
549
  if m.group(1).startswith(short) and digest(m.group(2)) == m.group(1):
507
550
  ok = True
508
551
  break