task-pipeline-skill 1.92.0 → 1.93.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.
Files changed (26) hide show
  1. package/CHANGELOG.md +147 -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 +12 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +171 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +8 -2
  24. package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +4 -2
  25. package/plugins/task-pipeline/skills/task-pipeline/scripts/plan_audit.py +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +76 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,150 @@
1
+ ## v1.93.3 — the wave-2 evidence declares its dead commits; a retried handoff stays one offer
2
+
3
+ A re-certification reading of v1.93.2 and an independent observer found six things.
4
+
5
+ - **The v1.93.2 evidence rewrite passed the documentation gate by de-backticking.** Six
6
+ pre-squash SHAs of PR #109 (`941bed8`, `36cfc1c`, `ffa42ab`, `933cf63`, `340556e`,
7
+ `7357522`) were left as plain text beside a branch that no longer exists; `docgate.sh`
8
+ only resolves backticked SHAs, so it read none of them. They are backticked again and
9
+ listed in a `docgate:known-dead` marker in `docs/evidence/retro.md`, with the reason and
10
+ the way to reach them: `git fetch origin pull/109/head` (all six are ancestors of that
11
+ ref). The deleted branch name is gone. The make-skill commit `60ca8e2` in row R09 and
12
+ B-120 is declared there too: it is make-skill's commit, not this repository's. A fresh
13
+ clone still cannot resolve the seven; the gate now passes because they are declared.
14
+ - **A retried handoff no longer replaces itself (R11).** `stage_checkpoint.py handoff` put
15
+ the ledger's whole length in the idempotency key, so a `gate:` line from
16
+ `hooks/gate-observer.sh` or a `record --unavailable` line between two retries made the
17
+ retry a new offer, and Observatory replaces an unaccepted older offer with a new one.
18
+ The key now counts only memory events (checkpoint, refused, handoff offered), so
19
+ unrelated appends leave it alone and a recorded offer still moves it.
20
+ `test/stage_checkpoint_test.py` pins both; `references/continuity.md` Part 3 says it.
21
+ - **Declared task sources have a mechanism and a test (R13).**
22
+ `scripts/backlog_sources.py` reads `docs/backlog-sources.json` (the Fabric workspace's
23
+ `knowledge/backlog.md` contract), prints the sources a wave-0 harvest reads, refuses a
24
+ manifest the harvest could not follow (exit 1) and reports a discovered source the
25
+ manifest does not list (exit 3). `test/backlog_sources_test.py` builds fixture
26
+ repositories: 19 malformed manifests, a symlinked source, the finding and the no-manifest
27
+ case. The script accepted all 22 manifests in the operator's checkouts.
28
+ - **The release approval mode has a test (R16).** `test/release_approval_test.py` pins
29
+ the schema's `checks | reviewers` enum (six other values refused under `jsonschema`),
30
+ the example's value and the doctrine section in `references/deploy-targets.md`. No
31
+ script reads the mode, because the agent reads it at stage 7, so the test pins the
32
+ schema, the example and the doctrine.
33
+ - **CI runs every suite of the `npm test` equation.** The validate job's explicit list
34
+ lacked `memory_nudge_test.py`, which `npm test` has run since v1.93.0. It now runs that
35
+ suite and the two new ones.
36
+ - **Recorded late, from v1.93.0:** the eight `negatives` shards report as one status,
37
+ *Negative self-tests*, and the ruleset on `main` requires it beside `validate` and
38
+ *House skill audit*, so a red shard blocks a merge.
39
+ - **Correction to v1.93.2's entry:** it says the SHAs "now name the squash commit". They
40
+ were still unresolvable, and only the gate stopped seeing them. This entry fixes that.
41
+
42
+ Guards: 438 → **438**; property checks 15 → **15**. No plant changed; two suites were added
43
+ to `npm test` and `test:all`.
44
+
45
+ ## v1.93.2 — v1.93.0's payload, with evidence a clone can follow
46
+
47
+ `v1.93.1` published nothing either: its release suite reached the documentation gate
48
+ (`npm run test:docs`, run by `release.yml` and not by the PR's checks) and refused 17
49
+ commit references in `docs/evidence/` — the wave-2 branch SHAs became unreachable when
50
+ PR #109 was squash-merged as `8260042`, and five CI run ids in backticks read as SHAs.
51
+ They now name the squash commit and the runs as plain numbers (corrected in v1.93.3: the six SHAs
52
+ were de-backticked, not resolved). Same payload; this is
53
+ the third tag for it, and `docgate.sh` passed locally before this one was pushed.
54
+
55
+ Guards: 438 → **438**; property checks 15 → **15**. No check changed.
56
+
57
+ ## v1.93.1 — v1.93.0's payload, released with its stamp declaration
58
+
59
+ `v1.93.0` published nothing: `release.yml` ran the suite on the tag's own tree and
60
+ `validate.py` refused it, because `docs/evidence/retro.md` → *Releases that carry no
61
+ stamp* did not name it — the check working as written (the same shape as `v1.91.1`).
62
+ The tag cannot be removed, so this release ships the same payload with that declaration.
63
+ Everything below under v1.93.0 is what 1.93.1 contains.
64
+
65
+ Guards: 438 → **438**; property checks 15 → **15**. No check changed.
66
+
67
+ ## v1.93.0 — the negative self-tests leave the workflow; temp state leaves with the run
68
+
69
+ Integration wave 2 of the family's waves run (R07–R16), one release. The headline is
70
+ the workflow: `.github/workflows/validate.yml` was **507,836 of GitHub's 512,000 bytes**,
71
+ and past that limit a workflow does not go red — it creates no runs and `gh pr checks`
72
+ says *no checks reported*. 448 of its 464 steps were plants and property checks, run one
73
+ after another in one job for 90–111 minutes (runs 38127381666, 38112068258,
74
+ 38104711459).
75
+
76
+ - **Every plant is a file (R08, #91).** The 433 negative self-tests and 15 property checks
77
+ moved verbatim into `test/negatives/NNN-<slug>.sh`: line 1 the shebang, line 2
78
+ `# name: <the step's name>`, then the step's `run` value exactly as GitHub parsed it —
79
+ checked byte for byte against the YAML on the commit that moved them. The directory is
80
+ the only list. `validate.yml` fell to 12,181 bytes.
81
+ - **CI shards them.** A `negatives` job runs `python3 test/negatives.py --shard N/8` on an
82
+ eight-value matrix, fail-fast off; each shard is its own runner, so the fixed
83
+ `/tmp/<name>-copy` scratch names still never meet. `--shard I/N` takes every Nth check
84
+ by position, so a red shard is the same set of checks on a maintainer's machine.
85
+ `--list` and `-k` work as before and with `--shard`.
86
+ - **The runner reads the directory, and refuses what it cannot read.** A file with no
87
+ `# name:` header, a name that is neither kind, a bad file name, a duplicate number or
88
+ name — each one stops the run before it starts, and `test/validate.py` fails on the
89
+ same list, because a check the loader drops is a guard nobody proves while every count
90
+ stays green. MIN_EXPECTED and MIN_PROPS count the directory, before any shard.
91
+ - **The runner runs `bash -e`, what GitHub ran.** A `run:` block with no `shell:` is
92
+ `bash -e {0}`; the local runner used plain `bash -c`, and it is now the thing CI runs.
93
+ - **Scratch copies are judged and removed inside each worker.** They were swept only
94
+ after the whole pool returned, so one local run held every copy at once — 433 copies of
95
+ the tree with its `.git`, ~6 GB, measured filling a disk with 4 GB free.
96
+ - **Every check that read the workflow reads the directory**, through the runner's own
97
+ loader: the guard and property counts, the newest CHANGELOG section's counts, the
98
+ floors, `sed -i`, scratch-directory reuse, a scratch dir wiped by a step that does not
99
+ own it, the plant-landed assertion, and the anchor census (`test/anchors.py`). The
100
+ plant-landed check used to skip a name with `)` inside its parentheses; it reads every
101
+ one now. The ten plants whose payload was the workflow text were re-pointed to the
102
+ check files and assert on the value the re-pointed guard reads; the YAML plant breaks
103
+ the first plain step name that is still in the workflow.
104
+ - **New guards, each with its plant:** a `Negative self-test` or `Property check` written
105
+ inline in the workflow again; a shard matrix that does not list every slice the
106
+ runner cuts; a check file the loader cannot read. The workflow-size guard had no
107
+ plant while the file sat 4 KB under the limit — it has one now.
108
+ - **Temp state leaves with the run (R07).** `tp-b80` in `test/validate.py`, the
109
+ `tp-stage-ckpt` trees and 15 test files with bare `mkdtemp` clean up after
110
+ themselves; one machine's TMPDIR held 15,146 `tp-b80-*` trees. A new validator check
111
+ refuses a test file under `test/` — the check files included — that calls `mkdtemp(`,
112
+ `mkstemp(` or keeps a `NamedTemporaryFile(..., delete=False)` and carries no cleanup.
113
+ It is per file, so a file with one cleanup and two leaks passes; writing it found two
114
+ such leaks in `test/validate.py` itself — the graph-coverage tree and the installers'
115
+ fake HOME, ~1.8 MB per validator run and so per plant — and both are fixed.
116
+ - **Budget (R09).** The description is 890 of 1024 chars (909 before; ≤ 900 was the
117
+ target, B-154 in the family board), and the `SKILL.md` body 4646 of the 4750-token
118
+ working limit (4736 before; B-120 asked for 100 spare, and 104 are). The Optional-bridge
119
+ paragraph is trimmed to a pointer (its full text was already in
120
+ `references/companion-skills.md`); the kernel-precedence rule stays
121
+ in `SKILL.md`, which `fix-tp-03.01` pins.
122
+ - **Execution packets carry a namespaced id (R14):** `task-pipeline/execution-packet/1`.
123
+ Fabric's former packet used the bare `execution-packet/1` until it renamed its format
124
+ `session-handoff/1`; `packet.py`, `context_packets.py` and the schema still read the bare
125
+ id: 19 live packets in one consumer repository carry it (B-135), so it is refused only
126
+ after they are migrated.
127
+ - **Wave mode reads declared task sources (R13).** Where a repository declares them in
128
+ `docs/backlog-sources.json`, the harvest reads exactly those, updates status only in
129
+ the owning files and never edits a generated view.
130
+ - **The release stage reads the approval mode (R16).** `pipeline.json` `release.approval`
131
+ is `checks` (tag, watch, report) or `reviewers` (the direct link to the run that is
132
+ actually waiting); absent, it is read from the organisation or the environment, never
133
+ assumed.
134
+ - **Fabric's pipeline adapter is a pinned contract (R15).** `test/fixtures/
135
+ fabric-pipeline-contract.json` pins `passioncode-ai/fabric` main `5bf15e5c`, and
136
+ `fix-pf-06.01` checks the gate types and the example config against it with no Fabric
137
+ checkout.
138
+ - **Continuity through Observatory (R11).** `stage_checkpoint.py` gains `handoff` and
139
+ `accept`; `hooks/memory-nudge.sh` reminds once per returned gate that wrote no memory
140
+ event.
141
+ - **Ledgers (R10).** The verification headers say *released*; this repository's merged
142
+ branches are gone.
143
+
144
+ Guards: 433 → **438**; property checks 15 → **15**. The five new plants are files in
145
+ `test/negatives/` (449–453); every guard's plant was run on this tree, and the CI wall
146
+ clock per shard is in `docs/evidence/verification.md`.
147
+
1
148
  ## v1.92.0 — wave mode: the whole board, closed in its final form; the grill asks one batch
2
149
 
3
150
  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.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. 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.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"
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/backlog_sources_test.py && python3 test/release_approval_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/backlog_sources_test.py && python3 test/release_approval_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.3",
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. A retried offer keeps its idempotency key until a memory event (a checkpoint, a refusal, a recorded offer — never `unavailable`) is appended, so a `gate:` or `stage:` line between retries does not turn it into a new offer that replaces the unaccepted one.
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,18 @@ 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
+ `scripts/backlog_sources.py --root . [--found <path> ...]` reads the manifest first: it
82
+ prints the sources the harvest reads, refuses a manifest the harvest could not follow
83
+ (exit 1, every reason named) and reports each `--found` source it does not list (exit 3).
84
+ No manifest is discovery, said aloud (exit 0).
85
+
74
86
  **One machine-readable inventory, nothing outside it.** A single committed file,
75
87
  `<artifacts>/specs/YYYY-MM-DD-<topic>-inventory.json`, one entry per open item:
76
88
 
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """The declared task sources a wave-0 harvest reads (`references/waves.md`, R13) — stdlib only.
3
+
4
+ Where a repository carries `docs/backlog-sources.json`, the harvest reads exactly the sources
5
+ it lists, with the id, title and status columns each one names, and a source of open work the
6
+ manifest does not list is a finding, not a silent addition. This script is the mechanical half
7
+ of that sentence: it reads the manifest, refuses one the harvest could not follow, and prints
8
+ the list the harvest works from.
9
+
10
+ backlog_sources.py [--root DIR] [--found PATH ...]
11
+
12
+ The contract is not this file's: it is owned by the Fabric workspace's `knowledge/backlog.md`
13
+ (section *Source contract*; read at fabric-workspace d49b6f58, 2026-10-08), and its collector
14
+ there is the authority. This checker refuses what that contract states in prose, with one
15
+ deliberate widening: `repository` may be any `https://github.com/<owner>/<repo>`, because this
16
+ skill runs outside one organisation.
17
+
18
+ - `schema` is 1; `repository` is a canonical GitHub URL; `sources` holds 1–100 entries.
19
+ - Each `path` is relative, inside the repository, with no hidden, `.` or `..` segment and no
20
+ symlink on the way; it exists as a regular file — `.md`, or `.json` for `json-tasks`.
21
+ - `goal` is one of the five anchors; `kind` is `tasks` (the default) or `context`.
22
+ - A `tasks` source is `table`, `checklist` or `json-tasks`. A table names non-empty
23
+ `idColumn` and `titleColumn` and exactly one of `statusColumn` or `defaultStatus: "open"`.
24
+ - `excludeIds` is a unique list of strings; `idPattern` is a literal prefix, optionally with
25
+ `[0-9]+` or `\\d+`, anchored at both ends. Two identical declarations are refused.
26
+
27
+ Output, on success: one JSON object — `repository`, `tasks` (each declaration as given, the
28
+ fields the harvest reads), `context` (paths), `undeclared` (each `--found` path the manifest
29
+ does not list, as `file:line`-free repository paths).
30
+
31
+ Exit codes: 0 the manifest is readable and nothing undeclared was found, or there is no
32
+ manifest (discovery then, as `references/knowledge-sources.md` describes — printed, not
33
+ silent); 1 the manifest is malformed, every reason printed; 3 the manifest is fine and a
34
+ `--found` source is not in it — the finding the doctrine asks for.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import pathlib
41
+ import re
42
+ import sys
43
+
44
+ MANIFEST = "docs/backlog-sources.json"
45
+ GOALS = ("agent-workplace", "workflow-control", "interoperability", "reliable-work", "shared-context")
46
+ FORMATS = ("table", "checklist", "json-tasks")
47
+ REPOSITORY = re.compile(r"^https://github\.com/[A-Za-z0-9][A-Za-z0-9-]{0,38}/\.?[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
48
+ ID_PATTERN = re.compile(r"^\^[A-Za-z0-9._/-]+(?:\[0-9\]\+|\\d\+)?\$$")
49
+ UNSAFE = re.compile(r"[\\\x00-\x1f\x7f?#%:]")
50
+
51
+
52
+ def _safe(path: object) -> bool:
53
+ if not isinstance(path, str) or not path or len(path) > 700 or UNSAFE.search(path):
54
+ return False
55
+ if path.startswith("/"):
56
+ return False
57
+ return all(seg and seg not in (".", "..") and not seg.startswith(".") for seg in path.split("/"))
58
+
59
+
60
+ def _regular(root: pathlib.Path, path: str) -> bool:
61
+ """The file exists and nothing on the way to it is a symlink."""
62
+ cur = root
63
+ for seg in path.split("/"):
64
+ cur = cur / seg
65
+ if cur.is_symlink():
66
+ return False
67
+ return cur.is_file()
68
+
69
+
70
+ def _nonempty(v: object) -> bool:
71
+ return isinstance(v, str) and bool(v.strip())
72
+
73
+
74
+ def check(root: pathlib.Path, found: list[str]) -> tuple[int, dict | None, list[str]]:
75
+ """(exit code, the harvest list or None, reasons)."""
76
+ mf = root / MANIFEST
77
+ if not mf.exists():
78
+ return 0, None, [f"no {MANIFEST} — the harvest discovers its sources"]
79
+ try:
80
+ doc = json.loads(mf.read_text(encoding="utf-8"))
81
+ except (OSError, ValueError) as exc:
82
+ return 1, None, [f"{MANIFEST}: not readable JSON ({type(exc).__name__})"]
83
+ if not isinstance(doc, dict):
84
+ return 1, None, [f"{MANIFEST}: not an object"]
85
+ errs: list[str] = []
86
+ if doc.get("schema") != 1:
87
+ errs.append(f"{MANIFEST}: `schema` is {doc.get('schema')!r}, not 1")
88
+ if not REPOSITORY.match(str(doc.get("repository") or "")):
89
+ errs.append(f"{MANIFEST}: `repository` {doc.get('repository')!r} is not a canonical "
90
+ "https://github.com/<owner>/<repo> URL")
91
+ sources = doc.get("sources")
92
+ if not isinstance(sources, list) or not 1 <= len(sources) <= 100:
93
+ errs.append(f"{MANIFEST}: `sources` must list 1–100 entries — a context-only "
94
+ "repository still declares one")
95
+ sources = sources if isinstance(sources, list) else []
96
+ tasks, context, seen = [], [], set()
97
+ for i, s in enumerate(sources):
98
+ where = f"{MANIFEST}: sources[{i}]"
99
+ if not isinstance(s, dict):
100
+ errs.append(f"{where}: not an object")
101
+ continue
102
+ path, kind, fmt = s.get("path"), s.get("kind", "tasks"), s.get("format")
103
+ where = f"{where} ({path})"
104
+ if kind not in ("tasks", "context"):
105
+ errs.append(f"{where}: `kind` {kind!r} is neither tasks nor context")
106
+ if s.get("goal") not in GOALS:
107
+ errs.append(f"{where}: `goal` {s.get('goal')!r} is not one of {', '.join(GOALS)}")
108
+ ext = ".json" if (kind == "tasks" and fmt == "json-tasks") else ".md"
109
+ if not _safe(path) or not str(path).endswith(ext):
110
+ errs.append(f"{where}: `path` must be a relative {ext} path with no hidden, `.` "
111
+ "or `..` segment")
112
+ elif not _regular(root, path):
113
+ errs.append(f"{where}: the declared source is missing, or reached through a symlink")
114
+ if kind == "tasks":
115
+ if fmt not in FORMATS:
116
+ errs.append(f"{where}: `format` {fmt!r} is not one of {', '.join(FORMATS)} — "
117
+ "an unsupported source is migrated or declared as context")
118
+ if fmt == "table":
119
+ has_status, has_default = "statusColumn" in s, "defaultStatus" in s
120
+ if not (_nonempty(s.get("idColumn")) and _nonempty(s.get("titleColumn"))):
121
+ errs.append(f"{where}: a table names non-empty `idColumn` and `titleColumn`")
122
+ if has_status == has_default or (has_status and not _nonempty(s.get("statusColumn"))) \
123
+ or (has_default and s.get("defaultStatus") != "open"):
124
+ errs.append(f"{where}: a table names exactly one of a non-empty "
125
+ "`statusColumn` or `defaultStatus: \"open\"`")
126
+ ex = s.get("excludeIds")
127
+ if ex is not None and (not isinstance(ex, list) or not all(_nonempty(x) for x in ex)
128
+ or len(set(ex)) != len(ex)):
129
+ errs.append(f"{where}: `excludeIds` is a unique list of stable ids")
130
+ if "idPattern" in s and not ID_PATTERN.match(str(s.get("idPattern"))):
131
+ errs.append(f"{where}: `idPattern` {s.get('idPattern')!r} is not a literal "
132
+ "prefix with an optional [0-9]+, anchored at both ends")
133
+ key = json.dumps([path, s.get("heading", ""), s.get("idPattern", ""), fmt or "",
134
+ s.get("idColumn", ""), s.get("titleColumn", ""),
135
+ s.get("statusColumn", ""), s.get("excludeIds", [])])
136
+ if key in seen:
137
+ errs.append(f"{where}: a duplicate declaration")
138
+ seen.add(key)
139
+ if kind == "tasks":
140
+ tasks.append({k: s[k] for k in ("path", "format", "heading", "idColumn", "titleColumn",
141
+ "statusColumn", "defaultStatus", "idPattern",
142
+ "excludeIds") if k in s})
143
+ elif isinstance(path, str):
144
+ context.append(path)
145
+ if errs:
146
+ return 1, None, errs
147
+ declared = {t["path"] for t in tasks} | set(context)
148
+ undeclared = sorted({f.strip().lstrip("./") for f in found if f.strip()} - declared)
149
+ out = {"repository": doc["repository"], "tasks": tasks, "context": context,
150
+ "undeclared": undeclared}
151
+ return (3 if undeclared else 0), out, [f"finding: {u} holds open work and is not declared "
152
+ f"in {MANIFEST}" for u in undeclared]
153
+
154
+
155
+ def main(argv: list[str]) -> int:
156
+ p = argparse.ArgumentParser(prog="backlog_sources.py",
157
+ description="The declared task sources a wave-0 harvest reads.")
158
+ p.add_argument("--root", default=".", help="the repository root (default: .)")
159
+ p.add_argument("--found", nargs="*", default=[],
160
+ help="sources of open work discovery found, as repository paths")
161
+ a = p.parse_args(argv)
162
+ code, out, notes = check(pathlib.Path(a.root), a.found)
163
+ for n in notes:
164
+ print(f"backlog-sources: {n}", file=sys.stderr)
165
+ if out is not None:
166
+ print(json.dumps(out, ensure_ascii=False))
167
+ return code
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main(sys.argv[1:]))
@@ -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)},
@@ -71,6 +71,10 @@ TOPIC = re.compile(r"^Run:\s*`([^`]+)`")
71
71
  # passes a surface took is measured at the boundary rather than remembered at the end.
72
72
  REVIEW = re.compile(r"^review:\s*(\d+)\s+—\s+surface\s+(.+?)\s+—\s+rounds\s+(\d+)\s+—\s+(\S+)\s+—\s+\S+\s*$")
73
73
  STATUS = {"pass": "done", "skip": "done", "fail": "blocked"}
74
+ # What `handoff` counts for its idempotency key: the memory events this script appended
75
+ # (checkpoint, refused, handoff offered) — never `unavailable`, which records no state change.
76
+ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
77
+ MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
74
78
 
75
79
 
76
80
  def _stages() -> dict[int, str]:
@@ -242,7 +246,13 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
242
246
  f"{f' (kept as {kept})' if kept else ''} — {_now()}")
243
247
  _append(ledger, line)
244
248
  return line
245
- wid = answer.get("workflowId") or state.get("workflowId")
249
+ if answer.get("handoffId") and not answer.get("leaseId"):
250
+ # An offer: the lease stays with this run until the successor accepts.
251
+ line = f"event: memory — handoff offered {answer['handoffId']} — {_now()}"
252
+ _append(ledger, line)
253
+ return line
254
+ wid = (answer.get("workflowId") or (answer.get("checkpoint") or {}).get("workflowId")
255
+ or state.get("workflowId"))
246
256
  lease = answer.get("leaseId") or state.get("leaseId")
247
257
  if not wid:
248
258
  raise SystemExit("stage_checkpoint: the answer names no workflowId")
@@ -255,6 +265,53 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
255
265
  return line
256
266
 
257
267
 
268
+ def handoff(ledger: pathlib.Path, provider: str, model: str | None, reason: str,
269
+ ttl: int) -> dict:
270
+ """`observatory_handoff_create` arguments for a PLANNED change of executor (R11).
271
+
272
+ Without this the only way a workflow moved was a refusal: the next executor wrote,
273
+ got `LeaseLost`, and read the workflow after the fact. A planned move offers it
274
+ instead — the lease stays with this run until the successor accepts, and the pack
275
+ the successor receives is assembled by Observatory from the last checkpoint.
276
+ """
277
+ state = _load_state(ledger)
278
+ if not state.get("workflowId"):
279
+ raise SystemExit("stage_checkpoint: no workflow yet — write a checkpoint first "
280
+ "(emit → observatory_checkpoint_write → record)")
281
+ to = {"provider": provider, **({"model": model} if model else {})}
282
+ # Observatory digests the whole request, TTL included, so the TTL is in the key (another
283
+ # TTL is another offer, not an idempotency conflict). So is the count of memory events the
284
+ # ledger holds: once an offer (or a checkpoint) is recorded that count grows, and offering
285
+ # again after it lapsed is a new offer — the same key would replay the first, expired
286
+ # answer. ONLY memory events count. The whole ledger length did, until 1.93.3: a `gate:`
287
+ # line from hooks/gate-observer.sh or a `record --unavailable` line between two retries
288
+ # made the retry a NEW offer, and Observatory replaces an unaccepted older offer with it.
289
+ try:
290
+ seen = sum(1 for ln in ledger.read_text(encoding="utf-8").splitlines()
291
+ if MEMORY_EVENT.match(ln) and not MEMORY_UNAVAILABLE.match(ln))
292
+ except OSError:
293
+ seen = 0
294
+ key = hashlib.sha256(f"{state['workflowId']}\n{json.dumps(to, sort_keys=True)}\n{reason}"
295
+ f"\n{ttl}\n{seen}".encode()).hexdigest()[:32]
296
+ args = {"owner": OWNER, "idempotencyKey": "tp-ho-" + key, "workflowId": state["workflowId"],
297
+ "to": to, "reason": reason, "offerTtlSeconds": ttl}
298
+ if state.get("leaseId"):
299
+ args["leaseId"] = state["leaseId"]
300
+ return args
301
+
302
+
303
+ def accept(ledger: pathlib.Path, handoff_id: str, session: str | None) -> dict:
304
+ """`observatory_handoff_accept` arguments for the successor, before its first write."""
305
+ if not handoff_id.startswith("handoff:"):
306
+ raise SystemExit("stage_checkpoint: a handoff id starts with 'handoff:'")
307
+ args = {"owner": OWNER,
308
+ "idempotencyKey": "tp-ac-" + hashlib.sha256(handoff_id.encode()).hexdigest()[:32],
309
+ "handoffId": handoff_id}
310
+ if session:
311
+ args["sessionId"] = session
312
+ return args
313
+
314
+
258
315
  def main(argv: list[str]) -> int:
259
316
  p = argparse.ArgumentParser(prog="stage_checkpoint.py",
260
317
  description="A workflow checkpoint at every stage boundary.")
@@ -271,11 +328,29 @@ def main(argv: list[str]) -> int:
271
328
  g.add_argument("--unavailable", help="why the tools could not be used")
272
329
  s = sub.add_parser("state", help="the workflow this run writes to; never the token")
273
330
  s.add_argument("--ledger", default=DEFAULT_LEDGER)
331
+ h = sub.add_parser("handoff", help="observatory_handoff_create arguments: a PLANNED executor change")
332
+ h.add_argument("--ledger", default=DEFAULT_LEDGER)
333
+ h.add_argument("--to-provider", required=True)
334
+ h.add_argument("--to-model")
335
+ h.add_argument("--reason", default="plan_route",
336
+ choices=["limit", "plan_route", "operator", "crash", "restart"])
337
+ h.add_argument("--offer-ttl", type=int, default=3600)
338
+ ac = sub.add_parser("accept", help="observatory_handoff_accept arguments, before the first write")
339
+ ac.add_argument("--ledger", default=DEFAULT_LEDGER)
340
+ ac.add_argument("--handoff-id", required=True)
341
+ ac.add_argument("--session")
274
342
  a = p.parse_args(argv)
275
343
  ledger = pathlib.Path(a.ledger)
276
344
  if a.cmd == "emit":
277
345
  print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
278
346
  return 0
347
+ if a.cmd == "handoff":
348
+ print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
349
+ ensure_ascii=False))
350
+ return 0
351
+ if a.cmd == "accept":
352
+ print(json.dumps(accept(ledger, a.handoff_id, a.session), ensure_ascii=False))
353
+ return 0
279
354
  if a.cmd == "state":
280
355
  st = _load_state(ledger)
281
356
  print(json.dumps({"workflowId": st.get("workflowId"), "holdsLease": bool(st.get("leaseId")),