task-pipeline-skill 1.102.0 → 1.102.3

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,51 @@
1
+ ## v1.102.3: stamp rows keep their table's shape, one rotation line per quarter, apply_patch gated
2
+
3
+ Guards: 531 → **531**; property checks 15 → **15**. No negative was added: the new checks are
4
+ cases in `test/audit_regressions/diet_retro_budget.py` and `test/release_gate_test.py`.
5
+
6
+ - **A shortened stamp row keeps its own table** (hub board row B-258): `retro_budget.py migrate`
7
+ assumed every stamp table was `Date | Topic | Commit | Verdict`. On sheleg-design-skill's
8
+ `Date | Commit | Task | Diverged?` it cut the Commit cell (`working tree` to `working…`) and kept
9
+ the long Task, so eight rows stayed over 300 characters; on super-ux's three columns it added a
10
+ fourth cell and broke the table. The row now keeps its header's column count, leaves date,
11
+ commit and version cells whole, caps the long prose cells at one common length and ends the
12
+ last cell with the pointer. An escaped `\|` stays inside its cell, and a row that already
13
+ carries a pointer is not archived again. Five new cases in
14
+ `test/audit_regressions/diet_retro_budget.py`, red on 1.102.2.
15
+ - **One `Rotated out:` line per archive quarter, also on floors written before 1.101.0** (hub
16
+ board row B-246): 1.101.0 merged the earlier lines only when a new rotation went into the same
17
+ quarter. A floor that already held several lines for one quarter, or for a quarter this run
18
+ does not rotate into, now has them merged on the next `migrate --write`, each block appended to
19
+ its own quarter's archive; a line whose block no longer hashes still stays. Two new cases: three
20
+ rotations in a row leave one line, and a planted five-line floor over two quarters leaves two.
21
+ - **The build gate acts in Codex** (hub board row B-211, task-pipeline part; CODEX-4 in the hub's
22
+ 2026-10-16 compatibility report): Codex edits through `apply_patch` with the patch text in
23
+ `tool_input.command`, so `hooks/build-gate.sh` read no `file_path` and stayed silent. Its
24
+ matcher and memory-nudge's now name `apply_patch`, and the gate asks when any `*** Add File:`,
25
+ `*** Update File:`, `*** Delete File:` or `*** Move to:` path is gated. Codex has no
26
+ `PostToolUseFailure`, which `references/hooks.md` now states for `gate-observer.sh`. Four new
27
+ checks in `test/release_gate_test.py` (71).
28
+ - **Three more trigger phrases** (hub board row B-84): SKILL.md's `## Triggers` advertises
29
+ 'write tests'/«напиши тесты», 'cut a release'/«зарелизь»/«выпусти релиз» and 'remove dead
30
+ code'/«удали мёртвый код», so the hub's route table may carry them; the description is
31
+ unchanged. Three should-trigger cases in `evals/task-pipeline.evals.json` (TRIG-13 to 15).
32
+
33
+ ## v1.102.2: v1.102.1 republished
34
+
35
+ - **v1.102.1 republished.** Its tag was pushed and the release run (40241842429) refused it before
36
+ publishing: `docs/evidence/retro.md` → *Releases that carry no stamp* did not name v1.102.1. That
37
+ section now names v1.102.1 and v1.102.2, and v1.102.2 ships v1.102.1's content (the three
38
+ descriptions within a 300-character budget). `v1.102.1` stays as an unpublished tag.
39
+
40
+ ## v1.102.1: descriptions within a 300-character budget
41
+
42
+ - **Descriptions within a 300-character budget** (hub board row B-255 (a)): Claude Code lists every
43
+ skill's description in one ~30,000-character budget, and on the operator's machine the listing
44
+ stood at 51,178. Measured the hub's way (whitespace collapsed, front matter only): task-pipeline
45
+ 388 to 294, project-audit 401 to 295, evidence-docs 397 to 283. The full trigger lists stay in each
46
+ `## Triggers`; B-001 and the README conformance sentence carry the new figure. Release impact card:
47
+ `docs/evidence/specs/2026-10-25-v11021-release-impact.md`.
48
+
1
49
  ## v1.102.0 (2026-10-24)
2
50
 
3
51
  Guards: 530 → **531**; property checks 15 → **15**. Negative 550 is new; the release impact
package/README.md CHANGED
@@ -643,7 +643,7 @@ must say so.
643
643
  ### Held to Anthropic's own Skill authoring guidance
644
644
 
645
645
  Audited against the four Agent Skills pages. Most of it already held — `name`
646
- 13/64 chars, `description` 386/1024 chars, `SKILL.md` 207/500 lines, all 55 references
646
+ 13/64 chars, `description` 292/1024 chars, `SKILL.md` 211/500 lines, all 55 references
647
647
  reachable from `SKILL.md` through explicit reference routes, and the bundle far under the 30 MB ceiling. What
648
648
  did not, now does:
649
649
 
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.102.0 |
15
+ | **Version** | 1.102.3 |
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` |
@@ -528,6 +528,45 @@
528
528
  "Does not retry the search with other filters or a project id to force lessons out of it, and does not record the old engine as an outage"
529
529
  ],
530
530
  "why": "Profile B with an old engine: turn records crowd lessons out of an unfiltered match window, so a hit there is not evidence about lessons. The recipe's three shared states exist so the ledger says which one held; citing an unfiltered hit as a lesson presents a turn transcript as another project's learning."
531
+ },
532
+ {
533
+ "id": "TRIG-13",
534
+ "category": "should_trigger",
535
+ "skills": [
536
+ "task-pipeline"
537
+ ],
538
+ "query": "напиши тесты для модуля биллинга, их там нет",
539
+ "expected_behavior": [
540
+ "Invokes the task-pipeline skill rather than editing inline",
541
+ "Runs the stage-0 knowledge harvest before the first interview question"
542
+ ],
543
+ "why": "'write tests' / «напиши тесты» is a repository change advertised in ## Triggers since 1.102.3 (hub B-84)."
544
+ },
545
+ {
546
+ "id": "TRIG-14",
547
+ "category": "should_trigger",
548
+ "skills": [
549
+ "task-pipeline"
550
+ ],
551
+ "query": "зарелизь то, что лежит в main, и выпусти релиз 2.4.0",
552
+ "expected_behavior": [
553
+ "Invokes the task-pipeline skill rather than editing inline",
554
+ "Runs the stage-0 knowledge harvest before the first interview question"
555
+ ],
556
+ "why": "'cut a release' / «зарелизь» / «выпусти релиз» is an outward act the stage 7 gate owns; advertised since 1.102.3 (hub B-84)."
557
+ },
558
+ {
559
+ "id": "TRIG-15",
560
+ "category": "should_trigger",
561
+ "skills": [
562
+ "task-pipeline"
563
+ ],
564
+ "query": "удали мёртвый код в пакете payments",
565
+ "expected_behavior": [
566
+ "Invokes the task-pipeline skill rather than editing inline",
567
+ "Runs the stage-0 knowledge harvest before the first interview question"
568
+ ],
569
+ "why": "'remove dead code' / «удали мёртвый код» changes the repository; advertised since 1.102.3 (hub B-84)."
531
570
  }
532
571
  ]
533
572
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.102.0",
3
+ "version": "1.102.3",
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.102.0",
6
+ "version": "1.102.3",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -38,15 +38,34 @@ try:
38
38
  except Exception:
39
39
  raise SystemExit(0)
40
40
 
41
- ti = data.get("tool_input") or {}
42
- path = ti.get("file_path") or ti.get("notebook_path") or ""
43
- if not path:
41
+ ti = data.get("tool_input") if isinstance(data, dict) else None
42
+ if not isinstance(ti, dict):
44
43
  raise SystemExit(0)
44
+ if data.get("tool_name") == "apply_patch":
45
+ # Codex edits through apply_patch, the patch text in `command` (hub B-211, CODEX-4):
46
+ # every file it adds, updates, deletes or moves to is a path this gate decides.
47
+ txt = ti.get("command") or ti.get("input") or ""
48
+ if not isinstance(txt, str):
49
+ raise SystemExit(0)
50
+ paths = [a or b for a, b in re.findall(
51
+ r"^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$", txt, re.M)]
52
+ else:
53
+ paths = [ti.get("file_path") or ti.get("notebook_path") or ""]
45
54
 
46
- rel = os.path.relpath(path, project) if os.path.isabs(path) else path
47
- rel = rel.replace(os.sep, "/")
48
- # The run writes these; gating them would fire on the work stages 0-4 are for.
49
- if rel.startswith("..") or re.match(r"^(docs/|\.task-pipeline/|\.claude/|CHANGELOG\.md|README\.md)", rel):
55
+
56
+ def gated(path):
57
+ if not isinstance(path, str) or not path.strip():
58
+ return False
59
+ path = path.strip()
60
+ rel = os.path.relpath(path, project) if os.path.isabs(path) else path
61
+ rel = rel.replace(os.sep, "/")
62
+ # The run writes these; gating them would fire on the work stages 0-4 are for.
63
+ return not (rel.startswith("..") or re.match(
64
+ r"^(docs/|\.task-pipeline/|\.claude/|CHANGELOG\.md|README\.md)", rel))
65
+
66
+
67
+ # One gated path is enough: a patch that also touches docs/ still edits the product.
68
+ if not any(gated(p) for p in paths):
50
69
  raise SystemExit(0)
51
70
 
52
71
  try:
@@ -15,7 +15,7 @@
15
15
  ]
16
16
  },
17
17
  {
18
- "matcher": "Edit|Write|MultiEdit|NotebookEdit",
18
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit|apply_patch",
19
19
  "hooks": [
20
20
  {
21
21
  "type": "command",
@@ -39,7 +39,7 @@
39
39
  ]
40
40
  },
41
41
  {
42
- "matcher": "Bash|Edit|Write|MultiEdit",
42
+ "matcher": "Bash|Edit|Write|MultiEdit|apply_patch",
43
43
  "hooks": [
44
44
  {
45
45
  "type": "command",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: evidence-docs
3
- description: "Use when writing or reviewing anything read as true: a decision record, ADR, README, acceptance report, runbook, changelog, audit finding, or a claim that something was verified. Applies the ten canons of evidence-backed documentation and routes to the doctrine enforcing each; also a documentation gate or decision register. Not for: drafts, chat answers, commit messages; 'без доков' opts out."
3
+ description: "Use when writing or reviewing anything read as true: a decision record, ADR, README, acceptance report, runbook, changelog, audit finding, a claim of verification. Applies the ten canons of evidence-backed docs. Not for: drafts, chat answers, commit messages; 'без доков' opts out."
4
4
  license: MIT
5
5
  compatibility: "Doctrine runs on any agent. No bundled scripts — nothing to install and nothing to degrade."
6
6
  ---
@@ -218,11 +218,20 @@ Since v1.50.0 the plugin carries hooks of its own. `hooks/release-gate.sh`
218
218
  (`PreToolUse` on `Bash`) refuses an **outward, irreversible act** — `git tag`, a
219
219
  tag push, `gh release create`, `npm publish` — while the run says the suite has not
220
220
  passed. `hooks/gate-observer.sh` (`PostToolUse` and `PostToolUseFailure`) records
221
- what the declared gate command actually did. `hooks/memory-nudge.sh` (`PostToolUse` on `Bash|Edit|Write|MultiEdit`) reminds,
221
+ what the declared gate command actually did. `hooks/memory-nudge.sh` (`PostToolUse` on `Bash|Edit|Write|MultiEdit|apply_patch`) reminds,
222
222
  once per returned gate, that no workflow-memory line follows it — unless the run recorded
223
223
  memory unavailable with `--for-run`
224
224
  ([`continuity.md`](https://github.com/ssheleg/task-pipeline/blob/main/plugins/task-pipeline/skills/task-pipeline/references/continuity.md), Part 3).
225
225
 
226
+ `hooks/build-gate.sh` (`PreToolUse` on `Edit|Write|MultiEdit|NotebookEdit|apply_patch`)
227
+ asks before the product is edited while the run has not entered its build stage.
228
+ **Codex edits through `apply_patch`**, the patch text in `tool_input.command`, so the
229
+ gate reads every `*** Add File:`, `*** Update File:`, `*** Delete File:` and
230
+ `*** Move to:` path in it and asks when any one is gated (hub B-211). **Codex has no
231
+ `PostToolUseFailure` event:** there `gate-observer.sh` records a failing gate command
232
+ only if Codex's `PostToolUse` reports it, so a missing `gate:` line after a red run
233
+ is not proof the suite never ran (the limit is Codex's event set, not this hook's).
234
+
226
235
  **Two lessons from v1.50.0, both shipped as defects and both fixed in v1.51.0.**
227
236
 
228
237
  *A gate keyed to a stage NUMBER is the rail's mistake with worse consequences.*
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: project-audit
3
- description: "Use when someone asks what is true of a whole project: finished, half-built, broken, unexplored ('project audit', 'аудит проекта'). Discovers the project, runs probes, reads production evidence, leaves a JSON sidecar, an HTML report on request. Read-only: proposes board rows, commits nothing. Not for: one deliverable inside a run, a diff review, a skill's construction; 'без диагностики' opts out."
3
+ description: "Use when someone asks what is true of a whole project ('project audit', 'аудит проекта'): finished, broken, unexplored. Probes it, reads production evidence, leaves a JSON sidecar. Read-only. Not for: one deliverable in a run, a diff review, a skill's construction; 'без диагностики' opts out."
4
4
  license: MIT
5
5
  compatibility: "The collector (scripts/audit.py) needs python3 and reads committed state, so it needs git. Probes needing gh, npm, network or a browser declare it and report blind when it is absent — degraded, never silent."
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: task-pipeline
3
- description: "Use when work changes the repository: feature, fix, bug, refactor, migration, integration, audit, PR review; фича, фикс, баг, рефактор, миграция, интеграция, аудит. Runs a substantial task through gated stages, intake grill to docs/wiki and acceptance; a small change takes a short path. 'waves'/'волнами' closes the board. Not for questions or one-line edits; 'без пайплайна' opts out."
3
+ description: "Use when work changes the repository: feature, fix, refactor, migration, integration, audit, PR review; фича, баг, интеграция, аудит. Runs a substantial task through gated stages to acceptance; 'waves'/'волнами' closes the board. Not for questions or one-line edits; 'без пайплайна' opts out."
4
4
  license: MIT
5
5
  compatibility: "Doctrine runs on any agent. The bundled scripts need python3; the run needs git. Missing either degrades, never blocks — the graph verbs and seeded gates go unused, and the run says so."
6
6
  ---
@@ -205,3 +205,7 @@ The description is the short form. The full trigger list it carried until 1.98.0
205
205
  moved here verbatim, still routes to this skill:
206
206
 
207
207
  "Use when work changes the repository — feature, fix, bug, refactor, migration, integration, rewrite, adoption or hardening; фича, фикс, баг, рефактор, миграция, интеграция, доработать, починить, внедрить, перевести — or a finding that lands in it: audit/аудит, bug hunt/проверь ошибки, production check/проверь прод, PR review/ревью PR — or on 'run this through the pipeline'/'прогони по конвейеру', 'the full cycle'/'полный цикл', /task-pipeline. Runs a substantial task through gated stages: intake grill, docs, brainstorm, spec, plan, build, tests, deploy, post-deploy, docs/wiki, acceptance. 'waves'/'волнами'/'release iteration'/'итерация релиза' closes the board; 'checkup'/'чекап' lists unconfirmed releases; 'setup' audits docs. Not for: answering a question, explaining, a typo, a one-line edit, a rename, recon that lands nothing — say 'no pipeline'/'без пайплайна' to opt out."
208
+
209
+ Added in 1.102.3 (hub board row B-84), each a repository change that runs through the
210
+ stages: 'write tests'/'напиши тесты', 'cut a release'/'зарелизь'/'выпусти релиз',
211
+ 'remove dead code'/'удали мёртвый код'.
@@ -218,11 +218,20 @@ Since v1.50.0 the plugin carries hooks of its own. `hooks/release-gate.sh`
218
218
  (`PreToolUse` on `Bash`) refuses an **outward, irreversible act** — `git tag`, a
219
219
  tag push, `gh release create`, `npm publish` — while the run says the suite has not
220
220
  passed. `hooks/gate-observer.sh` (`PostToolUse` and `PostToolUseFailure`) records
221
- what the declared gate command actually did. `hooks/memory-nudge.sh` (`PostToolUse` on `Bash|Edit|Write|MultiEdit`) reminds,
221
+ what the declared gate command actually did. `hooks/memory-nudge.sh` (`PostToolUse` on `Bash|Edit|Write|MultiEdit|apply_patch`) reminds,
222
222
  once per returned gate, that no workflow-memory line follows it — unless the run recorded
223
223
  memory unavailable with `--for-run`
224
224
  ([`continuity.md`](continuity.md), Part 3).
225
225
 
226
+ `hooks/build-gate.sh` (`PreToolUse` on `Edit|Write|MultiEdit|NotebookEdit|apply_patch`)
227
+ asks before the product is edited while the run has not entered its build stage.
228
+ **Codex edits through `apply_patch`**, the patch text in `tool_input.command`, so the
229
+ gate reads every `*** Add File:`, `*** Update File:`, `*** Delete File:` and
230
+ `*** Move to:` path in it and asks when any one is gated (hub B-211). **Codex has no
231
+ `PostToolUseFailure` event:** there `gate-observer.sh` records a failing gate command
232
+ only if Codex's `PostToolUse` reports it, so a missing `gate:` line after a red run
233
+ is not proof the suite never ran (the limit is Codex's event set, not this hook's).
234
+
226
235
  **Two lessons from v1.50.0, both shipped as defects and both fixed in v1.51.0.**
227
236
 
228
237
  *A gate keyed to a stage NUMBER is the rail's mistake with worse consequences.*
@@ -205,14 +205,20 @@ 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
+ A shortened stamp row keeps its own table's header and column count: date, commit
209
+ and version cells stay whole, the long prose cells are capped at one common length,
210
+ and the pointer ends the last cell. A row that already carries a pointer is not
211
+ moved again.
208
212
  An older retro whose standing instructions are a numbered list and whose stamps are
209
213
  `- **date: title**` items is read the same way: the item keeps its bold rule and its
210
214
  `*(Retire ...)*` trigger, a stamp keeps its title, stamps past the cap rotate out by date,
211
215
  and, over budget, the Run stamps section's loose prune paragraphs move. Rotation keeps
212
216
  **one `Rotated out:` line per archive**: a later rotation re-archives the quarter's
213
217
  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.
218
+ floor that rotates every run does not grow by a line per run. A floor that already
219
+ holds several lines for one quarter has them merged on the next `migrate --write`,
220
+ each in its own quarter's archive. A line whose block no longer hashes stays as its
221
+ own line.
216
222
  - **A prune that retires nothing writes its count into the stamp row**, as
217
223
  `prune: 0 retired`, and no paragraph. Repeated "nothing to prune" notes were a
218
224
  measured share of the floor.
@@ -14,10 +14,13 @@ on 2026-10-20 across ~50 projects the floor ran from ~3k to ~76k tokens, one of
14
14
  retro_budget.py migrate [--retro PATH] [--archive PATH] [--write]
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
- a stamp row over the stamp limit keeps Date, Topic and Commit; a standing row
18
- over the row limit loses its `Because` cell; a standing instruction written as
17
+ a stamp row over the stamp limit keeps its table's own columns and their count,
18
+ its date, commit and version cells whole, caps the long prose cells at one common
19
+ length and ends its last cell with the pointer; a standing row over the row limit
20
+ loses its `Because` cell; a standing instruction written as
19
21
  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
22
+ oldest run stamps past the cap of ten rotate out whole, and rotation leaves one
23
+ `Rotated out:` line per archive quarter, merging any earlier ones. Without --write it prints the
21
24
  plan and writes nothing. Exit 0 when the floor is then within budget, 1 when a
22
25
  row is still over (an instruction too long to shorten by moving its story:
23
26
  rewrite it by hand), 2 could not measure.
@@ -84,17 +87,76 @@ def table_rows(lines, span):
84
87
 
85
88
 
86
89
  def cells(row):
87
- parts = row.strip().split("|")
88
- return [c.strip() for c in parts[1:-1]]
90
+ """A table row's cells. An escaped `\\|` is text inside its cell, not a delimiter
91
+ (B-258: splitting on it widened the row that replaced it)."""
92
+ s = row.strip()
93
+ s = s[1:] if s.startswith("|") else s
94
+ s = s[:-1] if s.endswith("|") and not s.endswith("\\|") else s
95
+ return [c.strip() for c in re.split(r"(?<!\\)\|", s)]
89
96
 
90
97
 
91
- def header_cols(lines, i):
92
- """Column count of the table holding row i, read from its header (a `|` inside a
93
- cell's text must not widen the row that replaces it)."""
98
+ def header_of(lines, i):
99
+ """Index of the header line of the table holding row i."""
94
100
  k = i
95
101
  while k > 0 and lines[k - 1].startswith("|"):
96
102
  k -= 1
97
- return len(cells(lines[k]))
103
+ return k
104
+
105
+
106
+ def header_cols(lines, i):
107
+ """Column count of the table holding row i, read from its header (a `|` inside a
108
+ cell's text must not widen the row that replaces it)."""
109
+ return len(cells(lines[header_of(lines, i)]))
110
+
111
+
112
+ # A short identifier cell (a date, a commit, a version, an id) is never cut to keep a long
113
+ # prose cell (B-258: `working tree` became `working…` while a 200-character Task stayed).
114
+ ID_MAX = 40
115
+ ID_CELL = re.compile(r"^(?:\d{4}-\d{2}-\d{2}\b.*|`?[0-9a-f]{7,40}`?|`?v?\d+\.\d+\.\d+[\w.+-]*`?"
116
+ r"|#\d+|[A-Z]{1,6}-\d+)$")
117
+ ID_HEAD = re.compile(r"^(?:date|when|commit|sha|hash|base|version|ver|tag|release|pr|id)\b", re.I)
118
+ MIN_CAP = 8
119
+
120
+
121
+ def stamp_label(lines, i):
122
+ """`<date> <first other identifier>` of a stamp table row, for messages and archive
123
+ headings: the column holding the commit differs from table to table."""
124
+ c, head = cells(lines[i]), cells(lines[header_of(lines, i)])
125
+ ids = [x for j, x in enumerate(c[1:], 1)
126
+ if is_id(x, head[j] if j < len(head) else "")]
127
+ return " ".join(([c[0]] if c else []) + ids[:1])
128
+
129
+
130
+ def is_id(cell, name):
131
+ return len(cell) <= ID_MAX and bool(ID_CELL.match(cell) or ID_HEAD.match(name.strip()))
132
+
133
+
134
+ def clip(cell, cap):
135
+ """The cell cut to at most `cap` characters, ending in `…`. A cut never ends on a
136
+ backslash: it would escape the delimiter that follows."""
137
+ if len(cell) <= cap:
138
+ return cell
139
+ return cell[:cap - 1].rstrip().rstrip("\\").rstrip() + "…"
140
+
141
+
142
+ def fit_stamp(c, head, ptr, limit):
143
+ """A stamp row in its table's own shape: as many cells as the header, identifier cells
144
+ whole, every prose cell capped at one common length K (the largest K that fits), and
145
+ the pointer appended to the last cell."""
146
+ n = len(head)
147
+ if len(c) > n: # unescaped pipes in the narrative: the overflow is the last cell's text
148
+ c = c[:n - 1] + [" \\| ".join(c[n - 1:])]
149
+ c = c + [""] * (n - len(c))
150
+ prose = [j for j in range(n) if not is_id(c[j], head[j])]
151
+
152
+ def build(cap):
153
+ out = [clip(x, cap) if j in prose else x for j, x in enumerate(c)]
154
+ out[-1] = (out[-1] + " " + ptr) if out[-1] else ptr
155
+ return "| " + " | ".join(out) + " |"
156
+ cap = max([len(c[j]) for j in prose] or [0])
157
+ while cap > MIN_CAP and len(build(cap)) > limit:
158
+ cap -= 1
159
+ return build(cap)
98
160
 
99
161
 
100
162
  ITEM = re.compile(r"^(\d+)\.\s")
@@ -203,8 +265,7 @@ def measure(text, budget=BUDGET, row=ROW, stamp=STAMP):
203
265
  over.append("standing row %s is %d chars, over %d" % (label, width(sp), row))
204
266
  for sp in stm:
205
267
  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 \
268
+ label = stamp_label(lines, sp[0]) if lines[sp[0]].startswith("|") \
208
269
  else "%s (list)" % (stamp_date(lines, sp) or "undated")
209
270
  over.append("stamp %s is %d chars, over %d" % (label.strip(), width(sp), stamp))
210
271
  if len(std) > CAP:
@@ -256,14 +317,31 @@ ROTATED = re.compile(r"^Rotated out: ([0-9]+) oldest run stamps, verbatim in (\S
256
317
  ARCHIVED = re.compile(r"<!-- sha256:([0-9a-f]{64}) -->\n(.*?)\n(?=\n#{2,3} |\Z)", re.S)
257
318
 
258
319
 
259
- def rotation_lines(lines, span, rel):
260
- """(line index, stamp count, short sha) of each rotation pointer into `rel`."""
261
- out = []
320
+ def rotation_lines(lines, span, rel=None):
321
+ """(line index, stamp count, short sha) of each rotation pointer into `rel`; with no
322
+ `rel`, {archive: [those triples]} for every archive a rotation line names."""
323
+ out = {}
262
324
  for k in range(span[0], span[1]):
263
325
  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
326
+ if r:
327
+ out.setdefault(r.group(2), []).append((k, int(r.group(1)), r.group(3)))
328
+ return out if rel is None else out.get(rel, [])
329
+
330
+
331
+ def earlier_rotations(lines, span, rel, path):
332
+ """The rotation lines into `rel` whose archive block still hashes: ([(offset, line
333
+ index, text)], summed stamp count). A line that does not resolve is named and left as
334
+ its own line, never merged away unproved."""
335
+ earlier, count = [], 0
336
+ for k, n, short in rotation_lines(lines, span, rel):
337
+ prior = archived_text(path, short)
338
+ if prior is None:
339
+ print("migrate: the rotation line sha256:%s does not resolve in %s; "
340
+ "it stays as its own line" % (short, rel))
341
+ continue
342
+ earlier.append((prior[0], k, prior[1]))
343
+ count += n
344
+ return earlier, count
267
345
 
268
346
 
269
347
  def archived_text(path, short):
@@ -290,6 +368,22 @@ def cmd_check(a):
290
368
  return 0
291
369
 
292
370
 
371
+ def append_blocks(archive, blocks):
372
+ """Append blocks to an archive, creating it with its header first."""
373
+ if not blocks:
374
+ return
375
+ os.makedirs(os.path.dirname(os.path.abspath(archive)), exist_ok=True)
376
+ if not os.path.exists(archive):
377
+ with open(archive, "w", encoding="utf-8") as fh:
378
+ fh.write("# Retro archive · %s\n\n**Append-only. Queried, never read in full.** "
379
+ "Blocks moved here by `retro_budget.py migrate` carry the sha256 of the "
380
+ "text they hold; the floor keeps a pointer to it.\n"
381
+ % os.path.basename(archive)[:-3])
382
+ with open(archive, "a", encoding="utf-8") as fh:
383
+ for b in blocks:
384
+ fh.write(b)
385
+
386
+
293
387
  def cmd_migrate(a):
294
388
  text = read(a.retro)
295
389
  m = measure(text, a.budget, a.row, a.stamp)
@@ -301,6 +395,8 @@ def cmd_migrate(a):
301
395
  head = m["spans"][STAMPS][0]
302
396
  notes = [] # pointer lines inserted under the Run stamps heading
303
397
  rotated = set()
398
+ merged = set() # archives whose rotation lines this run already merged
399
+ elsewhere = [] # (archive path, block, label, rel): blocks bound for another quarter's file
304
400
  if len(m["stm"]) > CAP:
305
401
  # The cap rotates the OLDEST stamps out whole (references/retrospective.md, *The
306
402
  # stamp table is capped at ten*). Oldest is read from the date, never from the
@@ -362,15 +458,9 @@ def cmd_migrate(a):
362
458
  # into the same archive are re-archived with this one as a single block, and
363
459
  # their lines give way to one line naming it. The archive stays append-only:
364
460
  # 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
461
+ earlier, count = earlier_rotations(lines, m["spans"][STAMPS], rel, archive)
462
+ count += len(rotated)
463
+ merged.add(rel)
374
464
  for _, k, _ in earlier:
375
465
  moved.append((k, k + 1, [], None, None))
376
466
  if earlier:
@@ -380,10 +470,40 @@ def cmd_migrate(a):
380
470
  moved.append((sp[0], sp[1], [], None, None))
381
471
  notes.append(("Rotated out: %d oldest run stamps, verbatim in %s sha256:%s"
382
472
  % (count, rel, h[:12]), b, "%d oldest run stamps rotated" % len(rotated)))
473
+ # B-246: a floor that already holds several rotation lines for one archive (written
474
+ # before 1.101.0, or into a quarter this run does not rotate into) gets one line per
475
+ # archive on this run too: the earlier blocks are re-archived as one, in their own
476
+ # quarter's file, and their lines give way to the line naming it.
477
+ base = os.path.dirname(os.path.abspath(a.retro))
478
+ for other, group in sorted(rotation_lines(lines, m["spans"][STAMPS]).items()):
479
+ # Only an archive a floor pointer can prove (`retro/YYYY-QN.md`) is written to.
480
+ if other in merged or len(group) < 2 or not POINTER.match(other + " sha256:" + "0" * 12):
481
+ continue
482
+ path = os.path.join(base, other)
483
+ earlier, count = earlier_rotations(lines, m["spans"][STAMPS], other, path)
484
+ if len(earlier) < 2:
485
+ continue
486
+ h, b = block("stamps", "%d oldest rotated" % count,
487
+ "\n".join(t for _, _, t in sorted(earlier)))
488
+ for _, k, _ in earlier:
489
+ moved.append((k, k + 1, [], None, None))
490
+ note = "Rotated out: %d oldest run stamps, verbatim in %s sha256:%s" % (count, other, h[:12])
491
+ label = "%d rotation lines merged" % len(earlier)
492
+ if other == rel:
493
+ notes.append((note, b, label))
494
+ else:
495
+ notes.append((note, None, None))
496
+ elsewhere.append((path, b, label, other))
383
497
  for sp in m["stm"]:
384
498
  i = sp[0]
385
499
  if sp in rotated or len("\n".join(lines[sp[0]:sp[1]])) <= a.stamp:
386
500
  continue
501
+ if any(POINTER.search(l) for l in lines[sp[0]:sp[1]]):
502
+ # Already migrated: its narrative is in the archive. Moving it again would
503
+ # archive the same row on every run (B-258).
504
+ print("migrate: stamp %s already points at its archive and is still over %d; "
505
+ "shorten it by hand" % (stamp_date(lines, sp) or "undated", a.stamp))
506
+ continue
387
507
  if not lines[i].startswith("|"):
388
508
  # A list stamp keeps its bold title; the narrative moves.
389
509
  item = "\n".join(lines[sp[0]:sp[1]])
@@ -401,23 +521,17 @@ def cmd_migrate(a):
401
521
  moved.append((sp[0], sp[1], ["- %s%s" % (t, ptr)], b,
402
522
  "stamp %s (list)" % (stamp_date(lines, sp) or "undated")))
403
523
  continue
404
- c = cells(lines[i])
405
- if len(c) < 3:
524
+ head = cells(lines[header_of(lines, i)])
525
+ if len(head) < 2:
526
+ print("migrate: stamp %s sits in a one-column table; shorten it by hand"
527
+ % (stamp_date(lines, sp) or "undated"))
406
528
  continue
407
- h, b = block("stamp", "%s %s" % (c[0], c[2]), lines[i])
408
- ptr = "moved: %s sha256:%s" % (rel, h[:12])
409
- # the table keeps its column count: the pointer fills Verdict, `·` the rest
410
- tail = ["·"] * max(0, header_cols(lines, i) - 4)
411
-
412
- def row_of(topic):
413
- return "| " + " | ".join([c[0], topic, c[2], ptr] + tail) + " |"
414
-
415
- topic = c[1]
416
- new = row_of(topic)
417
- if len(new) > a.stamp:
418
- keep = max(8, len(topic) - (len(new) - a.stamp) - 1)
419
- new = row_of(topic[:keep].rstrip() + "…")
420
- moved.append((i, i + 1, [new], b, "stamp %s %s" % (c[0], c[2])))
529
+ label = stamp_label(lines, i)
530
+ h, b = block("stamp", label, lines[i])
531
+ # B-258: the row keeps its table's own header and column count; identifier cells
532
+ # stay whole, the long prose cells are capped evenly, the pointer ends the last cell.
533
+ new = fit_stamp(cells(lines[i]), head, "moved: %s sha256:%s" % (rel, h[:12]), a.stamp)
534
+ moved.append((i, i + 1, [new], b, "stamp %s" % label))
421
535
  if m["bytes"] > a.budget:
422
536
  # Prune paragraphs between the stamps were a measured share of the floor
423
537
  # (*A prune that retires nothing writes its count into the stamp row*): over
@@ -440,7 +554,8 @@ def cmd_migrate(a):
440
554
  if notes:
441
555
  moved.append((head + 1, head + 1, sum([["", n] for n, _, _ in notes], []), None, None))
442
556
  for n, b, label in notes:
443
- moved.append((head + 1, head + 1, [], b, label))
557
+ if b:
558
+ moved.append((head + 1, head + 1, [], b, label))
444
559
  for s0, s1 in m["std"]:
445
560
  item = "\n".join(lines[s0:s1])
446
561
  if len(item) <= a.row:
@@ -480,20 +595,14 @@ def cmd_migrate(a):
480
595
  if label:
481
596
  print("migrate: %s -> %s%s" % (label, rel, "" if s0 == s1 else " (%d -> %d chars)" % (
482
597
  len("\n".join(lines[s0:s1])), len("\n".join(new)))))
598
+ for _, _, label, other in elsewhere:
599
+ print("migrate: %s -> %s" % (label, other))
483
600
  if not a.write:
484
601
  print("migrate: dry run, nothing written (pass --write)")
485
602
  return 0
486
- os.makedirs(os.path.dirname(os.path.abspath(archive)), exist_ok=True)
487
- if not os.path.exists(archive):
488
- with open(archive, "w", encoding="utf-8") as fh:
489
- fh.write("# Retro archive · %s\n\n**Append-only. Queried, never read in full.** "
490
- "Blocks moved here by `retro_budget.py migrate` carry the sha256 of the "
491
- "text they hold; the floor keeps a pointer to it.\n"
492
- % os.path.basename(archive)[:-3])
493
- with open(archive, "a", encoding="utf-8") as fh:
494
- for _, _, _, b, _ in moved:
495
- if b:
496
- fh.write(b)
603
+ append_blocks(archive, [b for _, _, _, b, _ in moved if b])
604
+ for path, b, _, _ in elsewhere:
605
+ append_blocks(path, [b])
497
606
  # Rebuild from the bottom so earlier spans keep their indices.
498
607
  for s0, s1, new, _, _ in sorted(moved, key=lambda x: (x[0], x[1]), reverse=True):
499
608
  lines[s0:s1] = new