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 +48 -0
- package/README.md +1 -1
- package/SKILL-CARD.md +1 -1
- package/evals/task-pipeline.evals.json +39 -0
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/hooks/build-gate.sh +26 -7
- package/plugins/task-pipeline/hooks/hooks.json +2 -2
- package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +1 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +10 -1
- package/plugins/task-pipeline/skills/project-audit/SKILL.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +5 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +10 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +8 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/retro_budget.py +163 -54
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`
|
|
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.
|
|
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.
|
|
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.
|
|
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")
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
215
|
-
|
|
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
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
92
|
-
"""
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
405
|
-
if len(
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
# the
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|