task-pipeline-skill 1.92.0 → 1.93.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/CONTRIBUTING.md +12 -6
  3. package/HOW-IT-WORKS.md +2 -1
  4. package/README.md +3 -1
  5. package/SKILL-CARD.md +1 -1
  6. package/package.json +3 -3
  7. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  8. package/plugins/task-pipeline/hooks/hooks.json +11 -0
  9. package/plugins/task-pipeline/hooks/memory-nudge.sh +49 -0
  10. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +3 -1
  11. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +2 -11
  12. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +1 -1
  13. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +2 -2
  14. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +1 -0
  15. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  16. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +8 -0
  17. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +31 -0
  18. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +3 -1
  19. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +5 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +8 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +8 -2
  23. package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +4 -2
  24. package/plugins/task-pipeline/skills/task-pipeline/scripts/plan_audit.py +1 -1
  25. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +68 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,105 @@
1
+ ## v1.93.2 — v1.93.0's payload, with evidence a clone can follow
2
+
3
+ `v1.93.1` published nothing either: its release suite reached the documentation gate
4
+ (`npm run test:docs`, run by `release.yml` and not by the PR's checks) and refused 17
5
+ commit references in `docs/evidence/` — the wave-2 branch SHAs became unreachable when
6
+ PR #109 was squash-merged as `8260042`, and five CI run ids in backticks read as SHAs.
7
+ They now name the squash commit and the runs as plain numbers. Same payload; this is
8
+ the third tag for it, and `docgate.sh` passed locally before this one was pushed.
9
+
10
+ Guards: 438 → **438**; property checks 15 → **15**. No check changed.
11
+
12
+ ## v1.93.1 — v1.93.0's payload, released with its stamp declaration
13
+
14
+ `v1.93.0` published nothing: `release.yml` ran the suite on the tag's own tree and
15
+ `validate.py` refused it, because `docs/evidence/retro.md` → *Releases that carry no
16
+ stamp* did not name it — the check working as written (the same shape as `v1.91.1`).
17
+ The tag cannot be removed, so this release ships the same payload with that declaration.
18
+ Everything below under v1.93.0 is what 1.93.1 contains.
19
+
20
+ Guards: 438 → **438**; property checks 15 → **15**. No check changed.
21
+
22
+ ## v1.93.0 — the negative self-tests leave the workflow; temp state leaves with the run
23
+
24
+ Integration wave 2 of the family's waves run (R07–R16), one release. The headline is
25
+ the workflow: `.github/workflows/validate.yml` was **507,836 of GitHub's 512,000 bytes**,
26
+ and past that limit a workflow does not go red — it creates no runs and `gh pr checks`
27
+ says *no checks reported*. 448 of its 464 steps were plants and property checks, run one
28
+ after another in one job for 90–111 minutes (runs 38127381666, 38112068258,
29
+ 38104711459).
30
+
31
+ - **Every plant is a file (R08, #91).** The 433 negative self-tests and 15 property checks
32
+ moved verbatim into `test/negatives/NNN-<slug>.sh`: line 1 the shebang, line 2
33
+ `# name: <the step's name>`, then the step's `run` value exactly as GitHub parsed it —
34
+ checked byte for byte against the YAML on the commit that moved them. The directory is
35
+ the only list. `validate.yml` fell to 12,181 bytes.
36
+ - **CI shards them.** A `negatives` job runs `python3 test/negatives.py --shard N/8` on an
37
+ eight-value matrix, fail-fast off; each shard is its own runner, so the fixed
38
+ `/tmp/<name>-copy` scratch names still never meet. `--shard I/N` takes every Nth check
39
+ by position, so a red shard is the same set of checks on a maintainer's machine.
40
+ `--list` and `-k` work as before and with `--shard`.
41
+ - **The runner reads the directory, and refuses what it cannot read.** A file with no
42
+ `# name:` header, a name that is neither kind, a bad file name, a duplicate number or
43
+ name — each one stops the run before it starts, and `test/validate.py` fails on the
44
+ same list, because a check the loader drops is a guard nobody proves while every count
45
+ stays green. MIN_EXPECTED and MIN_PROPS count the directory, before any shard.
46
+ - **The runner runs `bash -e`, what GitHub ran.** A `run:` block with no `shell:` is
47
+ `bash -e {0}`; the local runner used plain `bash -c`, and it is now the thing CI runs.
48
+ - **Scratch copies are judged and removed inside each worker.** They were swept only
49
+ after the whole pool returned, so one local run held every copy at once — 433 copies of
50
+ the tree with its `.git`, ~6 GB, measured filling a disk with 4 GB free.
51
+ - **Every check that read the workflow reads the directory**, through the runner's own
52
+ loader: the guard and property counts, the newest CHANGELOG section's counts, the
53
+ floors, `sed -i`, scratch-directory reuse, a scratch dir wiped by a step that does not
54
+ own it, the plant-landed assertion, and the anchor census (`test/anchors.py`). The
55
+ plant-landed check used to skip a name with `)` inside its parentheses; it reads every
56
+ one now. The ten plants whose payload was the workflow text were re-pointed to the
57
+ check files and assert on the value the re-pointed guard reads; the YAML plant breaks
58
+ the first plain step name that is still in the workflow.
59
+ - **New guards, each with its plant:** a `Negative self-test` or `Property check` written
60
+ inline in the workflow again; a shard matrix that does not list every slice the
61
+ runner cuts; a check file the loader cannot read. The workflow-size guard had no
62
+ plant while the file sat 4 KB under the limit — it has one now.
63
+ - **Temp state leaves with the run (R07).** `tp-b80` in `test/validate.py`, the
64
+ `tp-stage-ckpt` trees and 15 test files with bare `mkdtemp` clean up after
65
+ themselves; one machine's TMPDIR held 15,146 `tp-b80-*` trees. A new validator check
66
+ refuses a test file under `test/` — the check files included — that calls `mkdtemp(`,
67
+ `mkstemp(` or keeps a `NamedTemporaryFile(..., delete=False)` and carries no cleanup.
68
+ It is per file, so a file with one cleanup and two leaks passes; writing it found two
69
+ such leaks in `test/validate.py` itself — the graph-coverage tree and the installers'
70
+ fake HOME, ~1.8 MB per validator run and so per plant — and both are fixed.
71
+ - **Budget (R09).** The description is 890 of 1024 chars (909 before; ≤ 900 was the
72
+ target, B-154 in the family board), and the `SKILL.md` body 4646 of the 4750-token
73
+ working limit (4736 before; B-120 asked for 100 spare, and 104 are). The Optional-bridge
74
+ paragraph is trimmed to a pointer (its full text was already in
75
+ `references/companion-skills.md`); the kernel-precedence rule stays
76
+ in `SKILL.md`, which `fix-tp-03.01` pins.
77
+ - **Execution packets carry a namespaced id (R14):** `task-pipeline/execution-packet/1`.
78
+ Fabric's former packet used the bare `execution-packet/1` until it renamed its format
79
+ `session-handoff/1`; `packet.py`, `context_packets.py` and the schema still read the bare
80
+ id: 19 live packets in one consumer repository carry it (B-135), so it is refused only
81
+ after they are migrated.
82
+ - **Wave mode reads declared task sources (R13).** Where a repository declares them in
83
+ `docs/backlog-sources.json`, the harvest reads exactly those, updates status only in
84
+ the owning files and never edits a generated view.
85
+ - **The release stage reads the approval mode (R16).** `pipeline.json` `release.approval`
86
+ is `checks` (tag, watch, report) or `reviewers` (the direct link to the run that is
87
+ actually waiting); absent, it is read from the organisation or the environment, never
88
+ assumed.
89
+ - **Fabric's pipeline adapter is a pinned contract (R15).** `test/fixtures/
90
+ fabric-pipeline-contract.json` pins `passioncode-ai/fabric` main `5bf15e5c`, and
91
+ `fix-pf-06.01` checks the gate types and the example config against it with no Fabric
92
+ checkout.
93
+ - **Continuity through Observatory (R11).** `stage_checkpoint.py` gains `handoff` and
94
+ `accept`; `hooks/memory-nudge.sh` reminds once per returned gate that wrote no memory
95
+ event.
96
+ - **Ledgers (R10).** The verification headers say *released*; this repository's merged
97
+ branches are gone.
98
+
99
+ Guards: 433 → **438**; property checks 15 → **15**. The five new plants are files in
100
+ `test/negatives/` (449–453); every guard's plant was run on this tree, and the CI wall
101
+ clock per shard is in `docs/evidence/verification.md`.
102
+
1
103
  ## v1.92.0 — wave mode: the whole board, closed in its final form; the grill asks one batch
2
104
 
3
105
  The operator's standard approach, decided on 2026-10-11 and codified here: when the request
package/CONTRIBUTING.md CHANGED
@@ -27,9 +27,13 @@ npm run test:negatives # python3 test/negatives.py
27
27
  npm run test:all # both, in order
28
28
  ```
29
29
 
30
- The corruptions live in [`.github/workflows/validate.yml`](.github/workflows/validate.yml)
31
- and `test/negatives.py` reads them from there — never duplicated, because a second
32
- copy of a corruption is a second thing to drift. The runner also tells a **broken
30
+ The corruptions live in [`test/negatives/`](test/negatives/), one file per check —
31
+ `#!/usr/bin/env bash`, then `# name: Negative self-test (<what is planted>)`, then
32
+ the script, run from the repository root. `test/negatives.py` reads that directory
33
+ and nothing else, so it is the only list; CI runs it in eight shards
34
+ (`--shard N/8`, the `negatives` job in `validate.yml`). Until v1.93.0 every plant was
35
+ an inline workflow step, and the workflow reached 507,836 of GitHub's 512,000 bytes —
36
+ past the limit a workflow creates no runs rather than going red. The runner also tells a **broken
33
37
  test** from a **guard that didn't fire**: if a planted defect changed nothing, the
34
38
  validator passing means the test proved nothing, and it is reported as `BROKEN`
35
39
  rather than as a failure of the guard.
@@ -37,7 +41,7 @@ rather than as a failure of the guard.
37
41
  **Corrupt files in python, never with `sed -i`.** BSD sed needs an argument GNU sed
38
42
  refuses, and `0,/re/` does not exist on BSD at all — there it edits nothing
39
43
  silently, and the test reads as a guard that failed. The validator rejects `sed -i`
40
- in the workflow for exactly this reason: a self-test that only runs on CI cannot be
44
+ in a check file for exactly this reason: a self-test that only runs on CI cannot be
41
45
  used while you are writing the guard, which is the moment it is worth most.
42
46
 
43
47
  To try your change in a real agent:
@@ -616,8 +620,10 @@ pass over an empty set is a refused measurement.
616
620
  `pipeline.example.json`, the command, the Cursor rule and the README before you
617
621
  commit. The validator catches much of this — do not rely on it to think for you.
618
622
  - **A new guard needs a negative self-test.** If you teach `test/validate.py` a new
619
- rule, add a step to `.github/workflows/validate.yml` that corrupts a copy and
620
- asserts the validator fails, then watch it with `npm run test:negatives`. A guard
623
+ rule, add the next-numbered file to `test/negatives/` that corrupts a copy and
624
+ asserts the validator fails, then watch it with
625
+ `python3 test/negatives.py -k <words from its name>`. Never a step inline in the
626
+ workflow: the validator refuses one, because it would run on CI only. A guard
621
627
  nobody proved can fail is decoration. **Check the base is green first** — if the
622
628
  repo already fails your new rule, the self-test passes for the wrong reason and
623
629
  proves nothing.
package/HOW-IT-WORKS.md CHANGED
@@ -242,7 +242,8 @@ This is the part most pipelines skip, and it is the reason this one is trustwort
242
242
  rather than merely tidy.
243
243
 
244
244
  **Every guard is proven against a planted defect.** `npm run test:all` breaks the
245
- thing each of the **309** checks is about, and requires that check to reject it. A
245
+ thing each check is about, and requires that check to reject it — one file per
246
+ check in `test/negatives/`, eight shards on CI. A
246
247
  guard that has never been watched failing is not a guard; it is a comment.
247
248
 
248
249
  **The neighbour probe.** A check has a *subject* — the rule it is about — and
package/README.md CHANGED
@@ -200,6 +200,8 @@ until it is installed.
200
200
  | 3 + 4 · every spec and plan | the self-review reads its rules back — is every named check real, does anything contradict a locked decision or a rejected option, and what does this cost now versus at design time. Answers land as a committed `## Self-review` of computed numbers |
201
201
  | 6–10 + any check you write | [`gates.md`](plugins/task-pipeline/skills/task-pipeline/references/gates.md) — the three axes, the promotion ladder, gate anatomy, the probe recipe, ratchet floors |
202
202
  | any agent-time enforcement | [`hooks.md`](plugins/task-pipeline/skills/task-pipeline/references/hooks.md) — the `PreToolUse` contract, the fail-open hazard, the Claude-Code-only limit |
203
+ | 7 · the tag | [`deploy-targets.md`](plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md) → *When the release waits for an approval* — `pipeline.json` → `release.approval`: `checks` (tag, watch the run selected by the tag, report) or `reviewers` (the direct link to the run that is `waiting`) |
204
+ | between stages | [`continuity.md`](plugins/task-pipeline/skills/task-pipeline/references/continuity.md) Part 3 — workflow memory through Observatory, a planned handoff (`stage_checkpoint.py handoff` / `accept`), and `hooks/memory-nudge.sh`, which reminds once when a gate returned with no memory line after it |
203
205
 
204
206
  **Ported, not depended on.** Stage 0 is adapted from
205
207
  [Matt Pocock's `grilling` / `grill-with-docs`](https://github.com/mattpocock/skills)
@@ -612,7 +614,7 @@ must say so.
612
614
  ### Held to Anthropic's own Skill authoring guidance
613
615
 
614
616
  Audited against the four Agent Skills pages. Most of it already held — `name`
615
- 13/64 chars, `description` 909/1024 chars, `SKILL.md` 277/500 lines, all 40 references
617
+ 13/64 chars, `description` 890/1024 chars, `SKILL.md` 268/500 lines, all 40 references
616
618
  linked **directly** from `SKILL.md`, and the bundle far under the 30 MB ceiling. What
617
619
  did not, now does:
618
620
 
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.92.0 |
15
+ | **Version** | 1.93.2 |
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. Every stage's doctrine ships in-repo; the one conditional requirement is super-ux for the stage-3 UX track on a user-facing task |
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,13 +1,13 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.92.0",
3
+ "version": "1.93.2",
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"
7
7
  },
8
8
  "scripts": {
9
- "test": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && npm run test:audit-regressions",
10
- "test:all": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/negatives.py && npm run test:certify && npm run test:exposure && npm run test:probe && npm run test:anchors && npm run test:runner && npm run test:hooks && npm run test:artifacts && npm run test:docs && npm run test:audit-regressions",
9
+ "test": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/memory_nudge_test.py && npm run test:audit-regressions",
10
+ "test:all": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/memory_nudge_test.py && python3 test/negatives.py && npm run test:certify && npm run test:exposure && npm run test:probe && npm run test:anchors && npm run test:runner && npm run test:hooks && npm run test:artifacts && npm run test:docs && npm run test:audit-regressions",
11
11
  "test:negatives": "python3 test/negatives.py",
12
12
  "test:exposure": "python3 test/exposure_test.py",
13
13
  "test:probe": "python3 test/probe.py --self-test",
@@ -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 loop guard whose review ceiling measures rather than stops, 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.92.0",
6
+ "version": "1.93.2",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -37,6 +37,17 @@
37
37
  "timeout": 20
38
38
  }
39
39
  ]
40
+ },
41
+ {
42
+ "matcher": "Bash|Edit|Write|MultiEdit",
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "shell": "bash",
47
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/memory-nudge.sh\"",
48
+ "timeout": 20
49
+ }
50
+ ]
40
51
  }
41
52
  ],
42
53
  "PostToolUseFailure": [
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse — a stage gate returned and no workflow-memory line follows it.
3
+ #
4
+ # The checkpoint doctrine (references/continuity.md, Part 3) asked every returned gate to
5
+ # write an Observatory checkpoint, or to record that memory is unavailable. It was a step
6
+ # the agent had to remember, and the measured adoption was one write in 293 sessions over
7
+ # fourteen days (sshlg-skills waves, 2026-10-11). This hook reminds at the moment it
8
+ # matters: once per stage line, as context the model reads, and never again for the same
9
+ # line. It decides nothing and blocks nothing.
10
+ #
11
+ # Silent everywhere else: no ledger means exit 0 after one file test.
12
+ set -uo pipefail
13
+ ledger="${CLAUDE_PROJECT_DIR:-$PWD}/.task-pipeline/run.md"
14
+ [ -f "$ledger" ] || exit 0
15
+ python3 - "$ledger" <<'PY' 2>/dev/null || true
16
+ import hashlib, json, os, re, sys
17
+ ledger = sys.argv[1]
18
+ try:
19
+ lines = open(ledger, encoding="utf-8").read().splitlines()
20
+ except OSError:
21
+ sys.exit(0)
22
+ last_stage = last_mem = -1
23
+ for i, l in enumerate(lines):
24
+ if re.match(r"^stage:\s*\S+\s", l):
25
+ last_stage = i
26
+ elif l.startswith("event: memory"):
27
+ last_mem = i
28
+ if last_stage < 0 or last_mem > last_stage:
29
+ sys.exit(0)
30
+ line = lines[last_stage]
31
+ mark = os.path.join(os.path.dirname(ledger), ".memory-nudged")
32
+ key = hashlib.sha256(line.encode()).hexdigest()[:16]
33
+ try:
34
+ if open(mark).read().strip() == key:
35
+ sys.exit(0)
36
+ except OSError:
37
+ pass
38
+ try:
39
+ open(mark, "w").write(key + "\n")
40
+ except OSError:
41
+ pass
42
+ sid = re.match(r"^stage:\s*(\S+)", line).group(1)
43
+ msg = (f"task-pipeline: stage {sid}'s gate returned and no workflow-memory line follows it. "
44
+ "With Observatory's tools: `scripts/stage_checkpoint.py emit` → observatory_checkpoint_write "
45
+ "→ `stage_checkpoint.py record --answer -`. Without them: "
46
+ "`stage_checkpoint.py record --unavailable \"<why>\"`. (references/continuity.md, Part 3)")
47
+ print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": msg}}))
48
+ PY
49
+ exit 0
@@ -218,7 +218,9 @@ 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.
221
+ what the declared gate command actually did. `hooks/memory-nudge.sh` (`PostToolUse` on `Bash|Edit|Write|MultiEdit`) reminds,
222
+ once per returned gate, that no workflow-memory line follows it
223
+ ([`continuity.md`](https://github.com/ssheleg/task-pipeline/blob/main/plugins/task-pipeline/skills/task-pipeline/references/continuity.md), Part 3).
222
224
 
223
225
  **Two lessons from v1.50.0, both shipped as defects and both fixed in v1.51.0.**
224
226
 
@@ -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, rewrite, adoption or hardening; фича, фикс, баг, рефактор, миграция, интеграция, доработать, починить, внедрить, перевести — or when the output is 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' / 'волнами' closes the whole board; 'checkup' / 'чекап' lists unconfirmed releases; 'setup' audits docs. Not for: answering a question, explaining code, a typo, a one-line edit, a mechanical rename, reconnaissance that lands nothing — say 'no pipeline' / 'без пайплайна' to opt out."
3
+ description: "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' / 'волнами' closes the whole board; 'checkup' / 'чекап' lists unconfirmed releases; 'setup' audits docs. Not for: answering a question, explaining code, a typo, a one-line edit, a mechanical rename, reconnaissance that lands nothing — say 'no pipeline' / 'без пайплайна' to opt 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
  ---
@@ -66,16 +66,7 @@ gate stops until it is installed.
66
66
  Which reference owns which stage — all forty-odd of them, by stage number:
67
67
  [`references/doctrine-map.md`](./references/doctrine-map.md).
68
68
 
69
- **Optional bridge.** An equivalent skill set the operator already runs can be mapped
70
- onto stages 2/4/5/6 in `pipeline.json` → `skills[]`. That is a **substitution, never a
71
- requirement**. And the precedence is explicit, because two layers could seem to
72
- disagree: on a CUSTOM profile the **kernel wins** — scope, evidence, deps and
73
- resume are owed by every unit of work — while `references/stages.md` is the
74
- **default profile's** normative gate set, governing the stages that profile
75
- actually selects, not imposing its stage numbers on a profile that dropped
76
- them. The built-in doctrine is normative for the default profile; the kernel is
77
- normative for all. Nothing detects, recommends or waits for an external
78
- provider.
69
+ **Precedence:** on a CUSTOM profile the **kernel wins** — scope, evidence, deps and resume are owed by every unit of work; `references/stages.md` is the **default profile's** normative gate set; the kernel is normative for all. Another skill set on stages 2/4/5/6: `references/companion-skills.md`.
79
70
 
80
71
  **super-ux — the preferred provider for ANY user-facing task.** The moment a task
81
72
  implies an interface (web / mobile / CLI / TUI), the WHY→UI→scenario chain runs
@@ -1,5 +1,5 @@
1
1
  {
2
- "schema_version": "execution-packet/1",
2
+ "schema_version": "task-pipeline/execution-packet/1",
3
3
  "id": "FIX-EX-01.01",
4
4
  "parent_id": "FIX-EX-01",
5
5
  "module": "example-module",
@@ -18,8 +18,8 @@
18
18
  "properties": {
19
19
  "schema_version": {
20
20
  "type": "string",
21
- "pattern": "^execution-packet/[0-9]+$",
22
- "description": "Contract version, `execution-packet/<major>`. A consumer that does not know the major REJECTS the packet — an unknown mandatory version is not a warning."
21
+ "pattern": "^(task-pipeline/)?execution-packet/[0-9]+$",
22
+ "description": "Contract version, `task-pipeline/execution-packet/<major>` (namespaced since 1.93.0 so Fabric's former packet id cannot be mistaken for it; the bare `execution-packet/<major>` is still read until live packets are migrated, backlog B-135). A consumer that does not know the major REJECTS the packet — an unknown mandatory version is not a warning."
23
23
  },
24
24
  "id": {
25
25
  "type": "string",
@@ -183,6 +183,7 @@
183
183
  "release": {
184
184
  "enabled": true,
185
185
  "trigger": "tag",
186
+ "approval": "checks",
186
187
  "steps": [
187
188
  "create a GitHub release from the matching CHANGELOG section",
188
189
  "npm publish (task-pipeline-skill) — a second job in the same workflow, armed per repository by the PUBLISH_NPMJS variable (NPM_TOKEN granular automation token, or trusted publishing via OIDC); unarmed it falls back to a human 2FA step"
@@ -204,6 +204,13 @@
204
204
  "_goal_note": {
205
205
  "type": "string",
206
206
  "description": "Free prose beside `goal`."
207
+ },
208
+ "approval": {
209
+ "enum": [
210
+ "checks",
211
+ "reviewers"
212
+ ],
213
+ "description": "Who admits a release: `checks` — a guard (tag on main, CI green on that commit) and no reviewers, so the run tags, watches and reports; `reviewers` — an environment with required reviewers, so the run reports the direct link to the run that is actually `waiting`. Absent: read the organisation's declaration or the environment's protection rules; never assume `checks` (references/deploy-targets.md)."
207
214
  }
208
215
  }
209
216
  },
@@ -318,6 +318,12 @@ right costs nothing extra and pays twice.
318
318
 
319
319
  ## Part 3 — workflow memory at the stage boundary
320
320
 
321
+ Which of the five handoff mechanisms carries what — the Git record, Observatory's lease
322
+ and checkpoint, a host's launch context, the inbound queue — and the order a successor
323
+ reads them in, has one home:
324
+ [sshlg-skills `docs/working-rules/handoff-layers.md`](https://github.com/ssheleg/sshlg-skills/blob/main/docs/working-rules/handoff-layers.md).
325
+ This part is the checkpoint and lease half of it.
326
+
321
327
  The ledger survives a compaction. It does not survive a quota that ran out on another
322
328
  account, or a run picked up by a different agent on another machine. When the host has
323
329
  Project Observatory's memory tools, **every gate that returns also writes a workflow
@@ -328,6 +334,8 @@ checkpoint**, and the next executor continues from the last finished stage.
328
334
  - **The constraints.** Give the brief's restrictive rules once, as `--constraint`. They are carried to every later boundary, and a successor reads them first.
329
335
  - **Keys.** Pass keys by name, as `--credential PROJECT/ENV/NAME`, never as a value.
330
336
  - **Without the tools.** `stage_checkpoint.py record --unavailable "<why>"` appends one `event: memory — unavailable` line, and the run continues exactly as before. Missing memory is a state the ledger names, not a failure.
337
+ - **A planned change of executor** — a quota running out, a route moving the work to another model or account. `stage_checkpoint.py handoff --to-provider <p> [--to-model <m>] --reason limit|plan_route|operator` prints `observatory_handoff_create`'s arguments; `record --answer -` keeps the offer as one ledger line, and the lease stays with this run until the successor accepts. The successor runs `stage_checkpoint.py accept --handoff-id <id>` → `observatory_handoff_accept` → `record --answer -` **before its first write**, and obeys the constraints the answer carries first. `LeaseLost` is then left for the race nobody planned.
338
+ - **The reminder.** `hooks/memory-nudge.sh` (PostToolUse) reads the ledger after each tool call and, once per `stage:` line with no `event: memory` line after it, puts one sentence in front of the model naming the two commands above. It decides nothing and blocks nothing; adoption measured before it was one write in 293 sessions.
331
339
  - **A refusal.** `LeaseLost` means another executor holds the workflow now. The script drops the token. Read the workflow (`observatory_checkpoint_latest` with `stage_checkpoint.py state`'s id) before doing anything else. A stale writer stops after one refusal.
332
340
  - **The lease token.** It lives in `.task-pipeline/memory.json` (mode 0600, git-ignored) and never in the ledger, a reply or a commit.
333
341
  - **What this is not.** Not a second ledger and not the narrative. The checkpoint is the work's state for the next executor. The wiki and the ledger keep everything else.
@@ -163,6 +163,37 @@ gh run view <run-id> --log-failed # only the failed step's log
163
163
  or failed deploy is the most common way a run reports success while nothing
164
164
  shipped — and it is exactly what the stage-8 gate is for.
165
165
 
166
+ ### When the release waits for an approval
167
+
168
+ **Read the project's approval mode before tagging, and do not guess it.** It is one of
169
+ two, and each owes the operator something different.
170
+
171
+ - **`checks`** — a guard admits the release, with no person on the path. For example,
172
+ `release-guard` checks that the tag is on main and that CI on that commit is green,
173
+ and the `release` environment has no reviewers. PassionCode products declare this in
174
+ `passioncode-ai/.github` `release-signing/products.json` (`"approval": "checks"`).
175
+ Push the tag, watch the run selected **by the tag you pushed**, and report what was
176
+ published, or the guard's refusal and its cause. A refusal is fixed at its cause,
177
+ never worked around. Send no approval request: there is nobody to send it to.
178
+ - **`reviewers`** — the run stops at an environment with required reviewers. Find that
179
+ run by the tag, and confirm its status is `waiting` before saying anything:
180
+
181
+ ```bash
182
+ gh run list -w release.yml --json url,status,headBranch -L 5
183
+ ```
184
+
185
+ Then hand the operator the **direct link** to that run, where they press *Review
186
+ deployments*, together with the tag and the jobs that are waiting. A request without
187
+ the link is incomplete, and so is a link to a run that is not waiting.
188
+
189
+ The mode comes from, in order:
190
+ 1. `pipeline.json` → `release.approval`;
191
+ 2. an organisation declaration such as the file above;
192
+ 3. the environment's own protection rules
193
+ (`gh api repos/<owner>/<repo>/environments/release`).
194
+
195
+ A run that cannot establish the mode says so. It does not assume `checks`.
196
+
166
197
  ## Other platforms, quick verbs
167
198
 
168
199
  | Platform | Deploy | Verify |
@@ -218,7 +218,9 @@ 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.
221
+ what the declared gate command actually did. `hooks/memory-nudge.sh` (`PostToolUse` on `Bash|Edit|Write|MultiEdit`) reminds,
222
+ once per returned gate, that no workflow-memory line follows it
223
+ ([`continuity.md`](continuity.md), Part 3).
222
224
 
223
225
  **Two lessons from v1.50.0, both shipped as defects and both fixed in v1.51.0.**
224
226
 
@@ -42,6 +42,7 @@ makes the grill's answers *checkable* instead of merely confident.
42
42
 
43
43
  | # | Source | How to find it | What it's good for |
44
44
  |---|---|---|---|
45
+ | 0 | **A host's launch context** | a session started by Fabric or Fabric Dashboards (`session-handoff/1`): the brief arrives appended to the system prompt, the task as the first prompt, and `context.md` is the cited context pack | the request in the words the operator gave the host — taken as the request, not re-asked; the context pack is a harvest source like any other and is cited by its section |
45
46
  | 1 | **The code** | the repo you're in | what actually runs — the tiebreaker |
46
47
  | 2 | **The code graph** | `graphify-out/graph.json` — see below | *reach*: what calls this, what breaks if it moves, what every change passes through |
47
48
  | 3 | **Host agent docs** | `CLAUDE.md`, `AGENTS.md`, `.cursor/rules/` | conventions, commands, deploy path, house rules |
@@ -589,7 +589,11 @@ never that the work was skipped quietly.
589
589
  `partial` ships only with the operator's explicit acceptance. A gap is cheapest to
590
590
  close before it ships, and the operator is already present at this gate. **The
591
591
  carry-over count is printed beside this verdict.** Deploy is outward → explicit
592
- operator go. Respect deploy-from-main rules if the project mandates them. **Before
592
+ operator go — unless the operator already authorised releases. **Before the tag, read
593
+ the release's approval mode** (`pipeline.json` → `release.approval`; then
594
+ [`deploy-targets.md`](deploy-targets.md) → *When the release waits for an approval*):
595
+ `checks` — push the tag, watch the run selected by the tag, report; `reviewers` — the
596
+ direct link to the run that is actually `waiting`, never a request without it. Respect deploy-from-main rules if the project mandates them. **Before
593
597
  tagging, the CI verdict for what was just pushed is READ, not assumed**
594
598
  ([`conventions.md`](conventions.md) → *The CI verdict*) — a tag on a commit whose
595
599
  run nobody read is how a red `main` ships.
@@ -71,6 +71,14 @@ findings, and its proposed rows join the inventory like any other source. Where
71
71
  product has users, walk their real path in the built app on a fresh profile with
72
72
  disposable data. A finding is a fact with a receipt, never an impression.
73
73
 
74
+ **A declared source list outranks discovery.** Where the repository carries
75
+ `docs/backlog-sources.json` — the contract is owned by the Fabric workspace's
76
+ `knowledge/backlog.md` — the harvest reads exactly the sources it lists, as the board,
77
+ with the id, title and status columns it names. Status changes land only in those owning
78
+ files, under a lease. A new source joins the manifest in the same change. A generated
79
+ view of them, such as a published common backlog, is never edited. A source of open
80
+ work the manifest does not list is reported as a finding, not silently added.
81
+
74
82
  **One machine-readable inventory, nothing outside it.** A single committed file,
75
83
  `<artifacts>/specs/YYYY-MM-DD-<topic>-inventory.json`, one entry per open item:
76
84
 
@@ -191,7 +191,13 @@ def verify_parents(report, plan):
191
191
  return out
192
192
 
193
193
 
194
- LEAF_SCHEMA = "execution-packet/1"
194
+ LEAF_SCHEMA = "task-pipeline/execution-packet/1"
195
+ # Fabric's content-addressed packet was also `execution-packet/1` until Fabric renamed its
196
+ # format `session-handoff/1` (fabric-agent-contract R-01). The namespace keeps the two from
197
+ # being read as each other. The bare id is still READ, until no live packet carries it:
198
+ # 19 packets in one consumer repository did on 2026-10-12 (backlog B-135); refusing it
199
+ # before they are migrated would break them.
200
+ LEGACY_LEAF_SCHEMAS = ("execution-packet/1",)
195
201
  COLD_READER_QUESTIONS = ("goal", "inputs", "decisions", "scope", "outputs",
196
202
  "acceptance", "guards", "resume")
197
203
  SHA_RE_STR = r"^[0-9a-f]{64}$"
@@ -356,7 +362,7 @@ def leaf_readiness(leaf):
356
362
  problems = []
357
363
  if not isinstance(leaf, dict):
358
364
  return ["leaf: not an object"]
359
- if leaf.get("schema_version") != LEAF_SCHEMA:
365
+ if leaf.get("schema_version") not in (LEAF_SCHEMA,) + LEGACY_LEAF_SCHEMAS:
360
366
  problems.append("schema_version: missing or unknown — an unversioned "
361
367
  "packet fails readiness")
362
368
  answers = {
@@ -69,9 +69,11 @@ def problems(packet):
69
69
  return ["the packet is not a JSON object"]
70
70
 
71
71
  version = str(packet.get("schema_version", ""))
72
- m = re.match(r"^execution-packet/(\d+)$", version)
72
+ # Namespaced since 1.93.0 (Fabric's former packet id was the bare one); the bare form
73
+ # is still read until the live packets carrying it are migrated (backlog B-135).
74
+ m = re.match(r"^(?:task-pipeline/)?execution-packet/(\d+)$", version)
73
75
  if not m:
74
- out.append(f"schema_version {version!r} is not 'execution-packet/<major>' — "
76
+ out.append(f"schema_version {version!r} is not 'task-pipeline/execution-packet/<major>' — "
75
77
  "a packet without a mandatory version is unversioned, rejected")
76
78
  elif int(m.group(1)) not in KNOWN_MAJORS:
77
79
  out.append(f"schema_version major {m.group(1)} is unknown to this consumer "
@@ -265,7 +265,7 @@ def _self_test():
265
265
  print("FAIL %s: %s" % (name, e))
266
266
 
267
267
  def pkt(node, targets=(), full=True):
268
- p = {"schema_version": "execution-packet/1", "node": node,
268
+ p = {"schema_version": "task-pipeline/execution-packet/1", "node": node,
269
269
  "intent": "do the thing", "inputs": [{"address": "a", "sha256": "0" * 64}],
270
270
  "decision_refs": [{"address": "d", "sha256": "1" * 64}],
271
271
  "source_scope": {"edit_targets": list(targets)},
@@ -242,7 +242,13 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
242
242
  f"{f' (kept as {kept})' if kept else ''} — {_now()}")
243
243
  _append(ledger, line)
244
244
  return line
245
- wid = answer.get("workflowId") or state.get("workflowId")
245
+ if answer.get("handoffId") and not answer.get("leaseId"):
246
+ # An offer: the lease stays with this run until the successor accepts.
247
+ line = f"event: memory — handoff offered {answer['handoffId']} — {_now()}"
248
+ _append(ledger, line)
249
+ return line
250
+ wid = (answer.get("workflowId") or (answer.get("checkpoint") or {}).get("workflowId")
251
+ or state.get("workflowId"))
246
252
  lease = answer.get("leaseId") or state.get("leaseId")
247
253
  if not wid:
248
254
  raise SystemExit("stage_checkpoint: the answer names no workflowId")
@@ -255,6 +261,49 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
255
261
  return line
256
262
 
257
263
 
264
+ def handoff(ledger: pathlib.Path, provider: str, model: str | None, reason: str,
265
+ ttl: int) -> dict:
266
+ """`observatory_handoff_create` arguments for a PLANNED change of executor (R11).
267
+
268
+ Without this the only way a workflow moved was a refusal: the next executor wrote,
269
+ got `LeaseLost`, and read the workflow after the fact. A planned move offers it
270
+ instead — the lease stays with this run until the successor accepts, and the pack
271
+ the successor receives is assembled by Observatory from the last checkpoint.
272
+ """
273
+ state = _load_state(ledger)
274
+ if not state.get("workflowId"):
275
+ raise SystemExit("stage_checkpoint: no workflow yet — write a checkpoint first "
276
+ "(emit → observatory_checkpoint_write → record)")
277
+ to = {"provider": provider, **({"model": model} if model else {})}
278
+ # Observatory digests the whole request, TTL included, so the TTL is in the key (another
279
+ # TTL is another offer, not an idempotency conflict). So is the ledger's length: once an
280
+ # offer is recorded the ledger grows, and offering again after it lapsed is a new offer —
281
+ # the same key would replay the first, expired answer. A retry before recording repeats.
282
+ try:
283
+ seen = len(ledger.read_text(encoding="utf-8").splitlines())
284
+ except OSError:
285
+ seen = 0
286
+ key = hashlib.sha256(f"{state['workflowId']}\n{json.dumps(to, sort_keys=True)}\n{reason}"
287
+ f"\n{ttl}\n{seen}".encode()).hexdigest()[:32]
288
+ args = {"owner": OWNER, "idempotencyKey": "tp-ho-" + key, "workflowId": state["workflowId"],
289
+ "to": to, "reason": reason, "offerTtlSeconds": ttl}
290
+ if state.get("leaseId"):
291
+ args["leaseId"] = state["leaseId"]
292
+ return args
293
+
294
+
295
+ def accept(ledger: pathlib.Path, handoff_id: str, session: str | None) -> dict:
296
+ """`observatory_handoff_accept` arguments for the successor, before its first write."""
297
+ if not handoff_id.startswith("handoff:"):
298
+ raise SystemExit("stage_checkpoint: a handoff id starts with 'handoff:'")
299
+ args = {"owner": OWNER,
300
+ "idempotencyKey": "tp-ac-" + hashlib.sha256(handoff_id.encode()).hexdigest()[:32],
301
+ "handoffId": handoff_id}
302
+ if session:
303
+ args["sessionId"] = session
304
+ return args
305
+
306
+
258
307
  def main(argv: list[str]) -> int:
259
308
  p = argparse.ArgumentParser(prog="stage_checkpoint.py",
260
309
  description="A workflow checkpoint at every stage boundary.")
@@ -271,11 +320,29 @@ def main(argv: list[str]) -> int:
271
320
  g.add_argument("--unavailable", help="why the tools could not be used")
272
321
  s = sub.add_parser("state", help="the workflow this run writes to; never the token")
273
322
  s.add_argument("--ledger", default=DEFAULT_LEDGER)
323
+ h = sub.add_parser("handoff", help="observatory_handoff_create arguments: a PLANNED executor change")
324
+ h.add_argument("--ledger", default=DEFAULT_LEDGER)
325
+ h.add_argument("--to-provider", required=True)
326
+ h.add_argument("--to-model")
327
+ h.add_argument("--reason", default="plan_route",
328
+ choices=["limit", "plan_route", "operator", "crash", "restart"])
329
+ h.add_argument("--offer-ttl", type=int, default=3600)
330
+ ac = sub.add_parser("accept", help="observatory_handoff_accept arguments, before the first write")
331
+ ac.add_argument("--ledger", default=DEFAULT_LEDGER)
332
+ ac.add_argument("--handoff-id", required=True)
333
+ ac.add_argument("--session")
274
334
  a = p.parse_args(argv)
275
335
  ledger = pathlib.Path(a.ledger)
276
336
  if a.cmd == "emit":
277
337
  print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
278
338
  return 0
339
+ if a.cmd == "handoff":
340
+ print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
341
+ ensure_ascii=False))
342
+ return 0
343
+ if a.cmd == "accept":
344
+ print(json.dumps(accept(ledger, a.handoff_id, a.session), ensure_ascii=False))
345
+ return 0
279
346
  if a.cmd == "state":
280
347
  st = _load_state(ledger)
281
348
  print(json.dumps({"workflowId": st.get("workflowId"), "holdsLease": bool(st.get("leaseId")),