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.
- package/CHANGELOG.md +102 -0
- package/CONTRIBUTING.md +12 -6
- package/HOW-IT-WORKS.md +2 -1
- package/README.md +3 -1
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/hooks/hooks.json +11 -0
- package/plugins/task-pipeline/hooks/memory-nudge.sh +49 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +3 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +2 -11
- package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +31 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +3 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +5 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +8 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +4 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/plan_audit.py +1 -1
- 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 [
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
620
|
-
asserts the validator fails, then watch it with
|
|
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
|
|
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`
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
**
|
|
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
|
|
@@ -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
|
|
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
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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")),
|