task-pipeline-skill 1.91.2 → 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 +168 -0
- package/CONTRIBUTING.md +12 -6
- package/HOW-IT-WORKS.md +9 -4
- package/README.md +32 -4
- package/SKILL-CARD.md +1 -1
- package/cursor/rules/task-pipeline.mdc +11 -2
- package/evals/RESULTS.md +1 -1
- package/evals/task-pipeline.evals.json +16 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
- package/plugins/task-pipeline/commands/task-pipeline.md +23 -8
- 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/project-audit/SKILL.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +10 -20
- 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/graph.schema.json +8 -2
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +4 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +6 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/build.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +20 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +4 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +31 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +14 -5
- 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/planning.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +15 -7
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +233 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +3 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +8 -2
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +89 -5
- 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/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Wave mode — closing a whole board in its final form
|
|
2
|
+
|
|
3
|
+
`/task-pipeline waves`. The request is not one change but **the whole board**: every open
|
|
4
|
+
item a project carries — the board and its lanes, carry-over ledgers, plan rows,
|
|
5
|
+
verification rows still at `never`, scenarios with no coverage, open PRs and unlanded
|
|
6
|
+
branches, asks from other products' sessions — plus the operator's new requests.
|
|
7
|
+
|
|
8
|
+
**It is a program mode of the existing stages, not a parallel cycle.** Stages 0–4 run
|
|
9
|
+
once, for the whole board. Stages 5–8 run once per wave. Stages 9–10 close the release.
|
|
10
|
+
Every gate in [`stages.md`](stages.md) still holds where its stage runs; what the mode
|
|
11
|
+
changes is how many times each stage runs, and over what.
|
|
12
|
+
|
|
13
|
+
Decided by the operator on 2026-10-11 and first applied that day to a board of 701 items.
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
- The rule it exists for
|
|
18
|
+
- How the stages map
|
|
19
|
+
- Wave 0 — the survey and the inventory
|
|
20
|
+
- The questions — closed from sources, then one batch
|
|
21
|
+
- The canvas — the target state over the current logic
|
|
22
|
+
- Cutting the waves
|
|
23
|
+
- The wave loop
|
|
24
|
+
- The close pass, after every wave
|
|
25
|
+
- A board bigger than one release
|
|
26
|
+
- What stays human
|
|
27
|
+
- The graph carries the wave
|
|
28
|
+
- Rationalizations
|
|
29
|
+
|
|
30
|
+
## The rule it exists for
|
|
31
|
+
|
|
32
|
+
**Build the system in its final form, never through intermediate states.** An
|
|
33
|
+
intermediate state that a later task undoes is the most expensive thing an agent
|
|
34
|
+
produces: the build, the review, the migration and the operator's attention are each
|
|
35
|
+
paid twice, and the second payment is for removing the first. So every item is designed
|
|
36
|
+
against the target state of the whole system first, and only then cut into waves.
|
|
37
|
+
|
|
38
|
+
**No wave builds a shape a later wave undoes.** A stub is allowed only at a contract the
|
|
39
|
+
canvas declares, and the wave that removes it is named beside it. A stub with no named
|
|
40
|
+
removal is the intermediate state this mode exists to refuse.
|
|
41
|
+
|
|
42
|
+
What the mode buys, in the order the operator named it: transparency (the inventory,
|
|
43
|
+
the canvas and the wave plan live in the repository, so any agent can resume from them),
|
|
44
|
+
stability, autonomy, predictability (each wave says before it starts what it closes and
|
|
45
|
+
how that is proved), minimum operator time, fast real results, and a strategy corrected
|
|
46
|
+
on real agents, tasks and users rather than on the first guess.
|
|
47
|
+
|
|
48
|
+
## How the stages map
|
|
49
|
+
|
|
50
|
+
| Stage | In wave mode |
|
|
51
|
+
|---|---|
|
|
52
|
+
| 0 Intake | once, over the whole board: wave 0's survey is the harvest, the inventory is the work-list, and every in-scope item becomes or joins a REQ row in the operator's words |
|
|
53
|
+
| 1 Docs study | once, for every contract the canvas locks |
|
|
54
|
+
| 2 Brainstorm + decompose | once: the canvas extends the module map ([`decomposition.md`](decomposition.md)), and the work graph carries each node's wave |
|
|
55
|
+
| 3 Spec | once: the canvas is the committed target-state spec; a wave's dossier may deepen a section, never contradict it |
|
|
56
|
+
| 4 Plan | once: every wave's nodes, its exit criteria and the question batch. Re-cut at each wave boundary, as a recorded revision |
|
|
57
|
+
| 5–8 Build → post-deploy | per wave, then the close pass |
|
|
58
|
+
| 9 Docs + wiki · 10 Acceptance | once, to close the release, over everything the waves shipped |
|
|
59
|
+
|
|
60
|
+
**One planning gate, not three prompts.** The manual gates of stages 0, 2 and 3 are
|
|
61
|
+
presented together at the end of planning: the brief, the canvas, the wave plan and the
|
|
62
|
+
question batch in one message. Each gate's criteria are still checked and printed; the
|
|
63
|
+
operator is asked once, and the go names the gates it closes. Silence closes none.
|
|
64
|
+
|
|
65
|
+
## Wave 0 — the survey and the inventory
|
|
66
|
+
|
|
67
|
+
**Harvest every source of open work, by id** ([`knowledge-sources.md`](knowledge-sources.md)
|
|
68
|
+
supplies the sources; [`backlog.md`](backlog.md) the board). For whole-project scope, run
|
|
69
|
+
`project-audit` beside it as a read-only sidecar: its HTML and JSON are measured
|
|
70
|
+
findings, and its proposed rows join the inventory like any other source. Where the
|
|
71
|
+
product has users, walk their real path in the built app on a fresh profile with
|
|
72
|
+
disposable data. A finding is a fact with a receipt, never an impression.
|
|
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
|
+
|
|
82
|
+
**One machine-readable inventory, nothing outside it.** A single committed file,
|
|
83
|
+
`<artifacts>/specs/YYYY-MM-DD-<topic>-inventory.json`, one entry per open item:
|
|
84
|
+
|
|
85
|
+
| Field | Carries |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `id` | the item's id in its owning register; an item without one gets one there, in the same change |
|
|
88
|
+
| `source` | where it was found, as `file:line` |
|
|
89
|
+
| `status` | as measured, not as the register last said |
|
|
90
|
+
| `area` | the part of the system it touches |
|
|
91
|
+
| `node` | the canvas node it lands on |
|
|
92
|
+
| `wave` | the wave that closes it, or the release it is carried to |
|
|
93
|
+
| `release` | this release, or `program` |
|
|
94
|
+
|
|
95
|
+
`blocked_by`, `closable_in_one_pass` and `notes` are worth carrying beside them.
|
|
96
|
+
|
|
97
|
+
**Verify each open item against the code before trusting it.** A stale item is closed
|
|
98
|
+
in its owning register with the receipt that shows it done; duplicates are clustered,
|
|
99
|
+
both ids kept. A register that says *open* about shipped work is the commonest thing a
|
|
100
|
+
survey finds.
|
|
101
|
+
|
|
102
|
+
**Assign nodes and waves with a committed script, not by hand.** A deterministic script
|
|
103
|
+
reads the inventory and a short wave table and writes `node`, `wave` and `release` onto
|
|
104
|
+
every entry, then prints the counts per wave and per node. A re-cut is then a diff to a
|
|
105
|
+
table and a re-run, which a reviewer can read; seven hundred hand-edited rows are not.
|
|
106
|
+
|
|
107
|
+
## The questions — closed from sources, then one batch
|
|
108
|
+
|
|
109
|
+
**Close every question from the sources first**, in this order: the operator's recorded
|
|
110
|
+
decisions, ADRs and principles, the knowledge base, the code, the neighbours' contracts.
|
|
111
|
+
A question answered there is recorded with its citation — *resolved from the sources,
|
|
112
|
+
not asked* — and closed. What remains is only what is genuinely the operator's: access,
|
|
113
|
+
consent, money, a business or product direction, an outward or irreversible act.
|
|
114
|
+
|
|
115
|
+
**Those go once, as one batch, at the end of planning.** Each carries a recommended
|
|
116
|
+
answer and the consequence of each answer, ordered so prerequisite decisions come first
|
|
117
|
+
([`grill.md`](grill.md)). A go that leaves a question unanswered takes the
|
|
118
|
+
recommendation, recorded as **assumed** in the decisions table, and every wave that
|
|
119
|
+
depends on an assumption names it — so a different answer later re-plans exactly those
|
|
120
|
+
waves and no others. After planning, a question is asked only at a wave boundary, in the
|
|
121
|
+
hand-back's DECISIONS WAITING ([`progress.md`](progress.md)).
|
|
122
|
+
|
|
123
|
+
**An outward or irreversible act is never assumed.** Publishing, tagging, deploying
|
|
124
|
+
without a specific standing authorization, sending to a person, spending money,
|
|
125
|
+
deleting data: each waits for the operator's explicit go, asked with a verified direct
|
|
126
|
+
link. A recommendation is not an authorization.
|
|
127
|
+
|
|
128
|
+
## The canvas — the target state over the current logic
|
|
129
|
+
|
|
130
|
+
One committed spec, `<artifacts>/specs/YYYY-MM-DD-<topic>-canvas.md`, extending the module
|
|
131
|
+
map. It is the current system with every change laid over it:
|
|
132
|
+
|
|
133
|
+
1. **As-is → to-be per module and per contract** — data and events, screens and flows,
|
|
134
|
+
cross-product links.
|
|
135
|
+
2. **The integration seams** — how each integration happens, who owns each side, what
|
|
136
|
+
crosses it.
|
|
137
|
+
3. **REQ → module → wave**, one row per requirement.
|
|
138
|
+
4. **Deleted or superseded** — what the new version removes (code, screens, contracts,
|
|
139
|
+
docs) and which wave removes it. A removal nobody wrote down survives the release.
|
|
140
|
+
|
|
141
|
+
**Every inventory item lands on a node.** An item that lands nowhere is either out of
|
|
142
|
+
scope — said, with a register row — or a gap in the canvas. **The canvas is reviewed for
|
|
143
|
+
coherence before any wave is cut:** two items that change one seam are designed together,
|
|
144
|
+
because designed apart they are the intermediate state the rule above forbids.
|
|
145
|
+
|
|
146
|
+
## Cutting the waves
|
|
147
|
+
|
|
148
|
+
**A wave holds only items closable in one pass.** All four, for every item in it:
|
|
149
|
+
|
|
150
|
+
- its dependencies are done, or inside the same wave;
|
|
151
|
+
- it touches one seam, or a few adjacent ones;
|
|
152
|
+
- its verification can run together with the rest of the wave;
|
|
153
|
+
- the wave leaves the system working — landable, with no half-built shape exposed.
|
|
154
|
+
|
|
155
|
+
**Wave 1 is the walking skeleton**: the thinnest end-to-end path of the target that
|
|
156
|
+
proves the spine, for the reason [`decomposition.md`](decomposition.md) gives for a
|
|
157
|
+
platform's first brick. **Each wave names its exit criteria before it starts** — the
|
|
158
|
+
tests, the live walk, the gates and the documents that will prove it closed. Criteria
|
|
159
|
+
written after the wave are a description of what happened.
|
|
160
|
+
|
|
161
|
+
An item that cannot close in one pass because it needs a person stays on its node,
|
|
162
|
+
marked `needs-operator`, out of every wave until it is unblocked.
|
|
163
|
+
|
|
164
|
+
## The wave loop
|
|
165
|
+
|
|
166
|
+
Each wave runs stages 5–8 as [`stages.md`](stages.md) states them, then the close pass.
|
|
167
|
+
Between waves the run does not ask whether to continue: it takes the next wave
|
|
168
|
+
([`continuity.md`](continuity.md)). A genuine ambiguity parks its node `needs-operator`
|
|
169
|
+
and the wave goes on with the nodes it does not block; the run stops only when the whole
|
|
170
|
+
frontier is blocked. When the board is empty, it says so once.
|
|
171
|
+
|
|
172
|
+
Research, the audit and the blind readings run in parallel agents; builds stay sequential
|
|
173
|
+
wherever two of them share a seam ([`planning.md`](planning.md) → file ownership).
|
|
174
|
+
|
|
175
|
+
## The close pass, after every wave
|
|
176
|
+
|
|
177
|
+
In this order, inside the wave rather than after it:
|
|
178
|
+
|
|
179
|
+
1. **Verify.** Every node the wave closes is certified ([`certification.md`](certification.md)),
|
|
180
|
+
and [`audit.md`](audit.md)'s exit is re-measured over what the wave touched.
|
|
181
|
+
2. **Finish and fix — one fix round**, inside [`loop-guard.md`](loop-guard.md)'s caps.
|
|
182
|
+
Every finding is fixed in the wave or parked with a board row and its reason; nothing
|
|
183
|
+
is dropped silently.
|
|
184
|
+
3. **Document** — the Doc Loop and the stage-9 propagation matrix
|
|
185
|
+
([`documentation.md`](documentation.md)), scoped to the wave: scenarios, decisions,
|
|
186
|
+
docs and registers in the same change as the code.
|
|
187
|
+
4. **Mark the canvas.** Each node the wave closed carries its status and the commit.
|
|
188
|
+
5. **Re-read the goal** ([`continuity.md`](continuity.md) → *The goal is re-read between
|
|
189
|
+
items, not only the board*). A re-plan is a committed `revisions` entry plus a canvas
|
|
190
|
+
edit, never a quiet change.
|
|
191
|
+
6. **Re-measure the inventory and re-cut the remaining waves** from the updated target —
|
|
192
|
+
never from the original guess. New findings join the inventory with ids.
|
|
193
|
+
|
|
194
|
+
## A board bigger than one release
|
|
195
|
+
|
|
196
|
+
**It becomes a program.** The release takes the first waves; the rest stays on the
|
|
197
|
+
canvas by node, `release: program`, never dropped. The next release resumes from the
|
|
198
|
+
canvas: it re-measures the inventory rather than re-running wave 0 from nothing, and its
|
|
199
|
+
waves are cut from the nodes still open.
|
|
200
|
+
|
|
201
|
+
## What stays human
|
|
202
|
+
|
|
203
|
+
Sign-ins, consents, payments, sending to people, and every outward or irreversible act.
|
|
204
|
+
The mode removes *"shall I continue?"* between waves and *"what did you mean?"* where a
|
|
205
|
+
source already says; it never removes a person from a decision that is theirs. A
|
|
206
|
+
`manual` gate still waits for its go ([`continuity.md`](continuity.md) → *The limit,
|
|
207
|
+
before the capability*).
|
|
208
|
+
|
|
209
|
+
## The graph carries the wave
|
|
210
|
+
|
|
211
|
+
The work graph ([`work-graph.md`](work-graph.md)) carries `wave` on a node, an integer from
|
|
212
|
+
1. Three verbs read it:
|
|
213
|
+
|
|
214
|
+
- `graph.py validate` refuses an edge from a later wave's node into an earlier wave's
|
|
215
|
+
node: an earlier wave that waits on a later one cannot close in its own pass.
|
|
216
|
+
- `graph.py next --wave N` prints wave N's frontier only. Exit `3` says the wave is done,
|
|
217
|
+
`4` that nothing in it is runnable, `1` that no node is in wave N.
|
|
218
|
+
- `graph.py wave N-007 --to 3 --why "<reason>"` moves a node and writes the revision.
|
|
219
|
+
It refuses a move that would break the order above.
|
|
220
|
+
|
|
221
|
+
Without python3 the same three facts live in the wave plan's table, checked by hand and
|
|
222
|
+
said to be checked by hand.
|
|
223
|
+
|
|
224
|
+
## Rationalizations
|
|
225
|
+
|
|
226
|
+
| Excuse | Reality |
|
|
227
|
+
|---|---|
|
|
228
|
+
| "Ship the simple version now, the real one later" | That is the intermediate state. Its removal is a second build nobody planned |
|
|
229
|
+
| "Ask each question as it comes up" | Each one is a human round. Close it from the sources, or put it in the batch |
|
|
230
|
+
| "The board is too big to inventory" | The inventory is the plan. Without it the waves are cut from memory |
|
|
231
|
+
| "Re-cutting waves makes the pipeline dynamic" | A re-cut is a committed revision and a canvas edit. The static-graph decision in [`planning.md`](planning.md) holds |
|
|
232
|
+
| "The recommendation is obviously right, so assume the deploy go" | An outward act is never assumed. Ask for the specific go once, in the batch |
|
|
233
|
+
| "Wave 3 can stub what wave 5 will build properly" | Only at a declared contract, with wave 5 named as the stub's removal |
|
|
@@ -41,6 +41,7 @@ turn of every loop.
|
|
|
41
41
|
| `nodes[].blocked_by` | what must PRODUCE first. The frontier obeys a satisfaction predicate, and only `done` satisfies — a PARKED producer blocks its consumer, because a park is a decision not to produce, not a production. `next` names each held consumer with the park's reason; a valid alternative producer is an explicit, versioned edge change, never an implicit unblock |
|
|
42
42
|
| `nodes[].touches` | what it **mutates**. Two runnable nodes writing one file is the false parallelism [`planning.md`](planning.md) refuses — *distinct is not the same as independent, and the check is what they touch, never what they are called* |
|
|
43
43
|
| `nodes[].check` | **how this node will be closed** — one command, or the named judgement where no command can decide it. Required on every node except a `parked` one. The certification's `unit` tier runs it and reports its output as the evidence row ([`certification.md`](certification.md) — three blind tiers close a node, not one reader); before this field existed that instruction pointed at an absence, leaving a verifier the two things it forbids — invent a check, or run everything (B-080) |
|
|
44
|
+
| `nodes[].wave` | wave mode only ([`waves.md`](waves.md)): the pass of stages 5–8 that closes the node, from 1. **An earlier wave may not wait on a later one** — `validate` refuses the edge, because a wave holds only what closes in its own pass |
|
|
44
45
|
| `nodes[].evidence` | required when `status` is `done`. A node called done by assertion is what evidence exists to prevent |
|
|
45
46
|
| `nodes[].parked_reason` | required when `status` is `parked`. A park with no reason is indistinguishable, a week later, from work quietly dropped |
|
|
46
47
|
| `edges[].payload` | what the dependency hands over. **An edge carrying no named artifact is chronology drawn as architecture** |
|
|
@@ -54,11 +55,12 @@ conditional on the code, never merely sequenced after it.**
|
|
|
54
55
|
| Verb | Prints | Codes |
|
|
55
56
|
|---|---|---|
|
|
56
57
|
| `validate` | every violation, in a stable order | `0` clean · `1` any |
|
|
57
|
-
| `next` | the frontier, ordered by how much each node unblocks — **and nothing else
|
|
58
|
+
| `next` | the frontier, ordered by how much each node unblocks — **and nothing else**. `--wave N` narrows it to one wave, and `3` then means *this wave is done* | `0` printed · `3` all done · `4` nothing runnable · `1` with `--wave`, a wave no node is in |
|
|
58
59
|
| `coverage` | each requirement with the nodes serving it | `0` covered · `1` a gap, named |
|
|
59
60
|
| `goal` | the release goal | `0` · `3` unstated |
|
|
60
61
|
| `add` | the id it allocated | `0` · `1` refused |
|
|
61
62
|
| `park` | the id and the reason | `0` · `1` refused |
|
|
63
|
+
| `wave` | wave mode's re-cut: the node, its old and new wave, and the reason, which enters the revision log. Refuses a move that would leave an earlier wave waiting on a later one, and a `done` node | `0` · `1` refused |
|
|
62
64
|
| `certify` | the round, and on a failure every `breaks` finding with its fix and its check | `0` every owed tier passed — three, or four with `visual` on a flagship or product node · `1` a tier failed, a required one is missing, or a report is malformed |
|
|
63
65
|
| `close` | the goal, the new frontier count, and what was not verified | `0` · `1` refused **or the verdict stops the run** |
|
|
64
66
|
| `producer` | what produced this proof — actor, model, runtime, skill, config, commit, trace | `0` |
|
|
@@ -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 = {
|
|
@@ -88,7 +88,7 @@ TERMINAL = {"done", "parked"}
|
|
|
88
88
|
NO_GRAPH = {"producer", "doctrine"}
|
|
89
89
|
# One place, and the schema enumerates the same three. Two homes for this set is
|
|
90
90
|
# what let `close` write a verb the format forbade.
|
|
91
|
-
REVISION_VERBS = {"add", "park", "close", "invalidate", "waive"}
|
|
91
|
+
REVISION_VERBS = {"add", "park", "close", "invalidate", "waive", "wave"}
|
|
92
92
|
# Parking a node PROMOTES its dependents: `parked` is terminal, so anything
|
|
93
93
|
# blocked on it becomes runnable even though the payload it waited on never
|
|
94
94
|
# arrived. That is deliberate — `can_continue_around` in the verdict is the
|
|
@@ -276,6 +276,13 @@ def violations(graph):
|
|
|
276
276
|
f"{', '.join(SURFACE_CLASSES)}. A class nobody recognises requires no "
|
|
277
277
|
"visual reading, so a typo here would drop the tier that reads the pixels")
|
|
278
278
|
|
|
279
|
+
# Wave mode (`references/waves.md`). `True` is an int in Python, and a wave of
|
|
280
|
+
# `true` is a typo the ordering check below would read as wave 1.
|
|
281
|
+
if "wave" in n and not _is_wave(n["wave"]):
|
|
282
|
+
out.append(f"{nid}: wave is {n['wave']!r} — it must be an integer from 1. A wave "
|
|
283
|
+
"nobody can order is a node no `next --wave` ever prints, and the "
|
|
284
|
+
"order check below cannot see it")
|
|
285
|
+
|
|
279
286
|
if n.get("status") == "done":
|
|
280
287
|
ev = n.get("evidence")
|
|
281
288
|
if not isinstance(ev, list) or not [e for e in ev
|
|
@@ -322,6 +329,17 @@ def violations(graph):
|
|
|
322
329
|
"and a dependency handing over nothing is the one this check "
|
|
323
330
|
"exists to refuse")
|
|
324
331
|
|
|
332
|
+
# An earlier wave may not wait on a later one: a wave holds only what closes in one
|
|
333
|
+
# pass, and a node whose producer is scheduled after it cannot close in its own.
|
|
334
|
+
wave_of = {n.get("id"): n.get("wave") for n in nodes if _is_wave(n.get("wave"))}
|
|
335
|
+
for e in graph.get("edges") or []:
|
|
336
|
+
src, dst = e.get("from"), e.get("to")
|
|
337
|
+
if src in wave_of and dst in wave_of and wave_of[src] > wave_of[dst]:
|
|
338
|
+
out.append(f"edge {src}→{dst}: {src} is in wave {wave_of[src]} and {dst} in "
|
|
339
|
+
f"wave {wave_of[dst]} — an earlier wave would wait on a later one, "
|
|
340
|
+
"so it cannot close in its own pass (references/waves.md). Move "
|
|
341
|
+
f"{dst} to wave {wave_of[src]} or later, or {src} earlier")
|
|
342
|
+
|
|
325
343
|
for i, r in enumerate(graph.get("revisions") or []):
|
|
326
344
|
if not isinstance(r, dict):
|
|
327
345
|
out.append(f"revisions[{i}] is not an object")
|
|
@@ -603,6 +621,11 @@ TIERS = ("unit", "seam", "product")
|
|
|
603
621
|
VISUAL_TIER = "visual"
|
|
604
622
|
ALL_TIERS = TIERS + (VISUAL_TIER,)
|
|
605
623
|
SURFACE_CLASSES = ("flagship", "product", "internal", "ad")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _is_wave(w):
|
|
627
|
+
"""A wave is an integer from 1 — and never a bool, which Python counts as an int."""
|
|
628
|
+
return isinstance(w, int) and not isinstance(w, bool) and w >= 1
|
|
606
629
|
VISUAL_REQUIRED = ("flagship", "product")
|
|
607
630
|
TIER_KEYS = ("node", "tier", "verdict", "scope", "confirms", "findings",
|
|
608
631
|
"evidence", "not_examined")
|
|
@@ -746,13 +769,24 @@ def cmd_next(graph, args):
|
|
|
746
769
|
if violations(graph):
|
|
747
770
|
die("graph does not validate — run `validate` first", 1)
|
|
748
771
|
nodes = graph.get("nodes") or []
|
|
772
|
+
wave = getattr(args, "wave", None)
|
|
773
|
+
if wave is not None:
|
|
774
|
+
# One wave's frontier, and its own endings: `3` is *this wave is done*, which is
|
|
775
|
+
# the close pass's cue, not the release's (references/waves.md).
|
|
776
|
+
if not _is_wave(wave):
|
|
777
|
+
die("--wave %r is not a wave — waves are integers from 1" % wave, 2)
|
|
778
|
+
nodes = [n for n in nodes if n.get("wave") == wave]
|
|
779
|
+
if not nodes:
|
|
780
|
+
die("no node is in wave %d — nothing to walk, and an empty wave is a wave plan "
|
|
781
|
+
"and a graph that disagree" % wave)
|
|
749
782
|
if nodes and all(n.get("status") in TERMINAL for n in nodes):
|
|
750
783
|
return 3
|
|
751
|
-
|
|
784
|
+
in_scope = {n.get("id") for n in nodes}
|
|
785
|
+
ready = [n for n in frontier(graph) if n.get("id") in in_scope]
|
|
752
786
|
# A consumer whose producer is PARKED is not runnable and never will be by
|
|
753
787
|
# itself (FIX-PF-04.01) — say so BEFORE the empty-frontier exit, or the one
|
|
754
788
|
# moment the operator most needs the reason is the one moment it is silent.
|
|
755
|
-
_by_id = {n.get("id"): n for n in nodes}
|
|
789
|
+
_by_id = {n.get("id"): n for n in graph.get("nodes") or []}
|
|
756
790
|
for n in nodes:
|
|
757
791
|
if n.get("status") in TERMINAL or n.get("status") == "running":
|
|
758
792
|
continue
|
|
@@ -1123,6 +1157,46 @@ def cmd_park(graph, args):
|
|
|
1123
1157
|
return 0
|
|
1124
1158
|
|
|
1125
1159
|
|
|
1160
|
+
def cmd_wave(graph, args):
|
|
1161
|
+
"""Move one node to another wave, carrying the reason — wave mode's re-cut.
|
|
1162
|
+
|
|
1163
|
+
The re-cut after every wave is a plan revision, and a revision nobody recorded is
|
|
1164
|
+
the dynamic graph `references/planning.md` refuses. So the move is a verb: it
|
|
1165
|
+
writes the `revisions` entry, and it refuses a move that would leave an earlier
|
|
1166
|
+
wave waiting on a later one (`references/waves.md`).
|
|
1167
|
+
"""
|
|
1168
|
+
guard(graph, args.graph)
|
|
1169
|
+
why = (args.why or "").strip()
|
|
1170
|
+
if not why:
|
|
1171
|
+
die("`wave` needs a --why with something in it: a re-cut nobody gave a reason for "
|
|
1172
|
+
"is the quiet edit the revision log exists to refuse — nothing was written")
|
|
1173
|
+
if any(c in why for c in "\n\r"):
|
|
1174
|
+
die("--why contains a line break — nothing was written")
|
|
1175
|
+
if not _is_wave(args.to):
|
|
1176
|
+
die("--to %r is not a wave — waves are integers from 1. Nothing was written"
|
|
1177
|
+
% args.to)
|
|
1178
|
+
node = next((n for n in graph.get("nodes") or [] if n.get("id") == args.node), None)
|
|
1179
|
+
if node is None:
|
|
1180
|
+
die("no node %s in this graph — nothing was written" % args.node)
|
|
1181
|
+
if node.get("status") == "done":
|
|
1182
|
+
die("%s is done, in wave %s — moving it would rewrite which pass closed it. "
|
|
1183
|
+
"Nothing was written" % (args.node, node.get("wave")))
|
|
1184
|
+
if node.get("wave") == args.to:
|
|
1185
|
+
die("%s is already in wave %d — nothing was written" % (args.node, args.to))
|
|
1186
|
+
|
|
1187
|
+
before = node.get("wave")
|
|
1188
|
+
node["wave"] = args.to
|
|
1189
|
+
revise(graph, "wave", args.node, why)
|
|
1190
|
+
bad = violations(graph)
|
|
1191
|
+
if bad:
|
|
1192
|
+
die("moving %s to wave %d would break the graph — nothing was written:\n %s"
|
|
1193
|
+
% (args.node, args.to, "\n ".join(bad)))
|
|
1194
|
+
save(args.graph, graph)
|
|
1195
|
+
print("%s: wave %s → %d: %s" % (args.node, before if before is not None else "none",
|
|
1196
|
+
args.to, why))
|
|
1197
|
+
return 0
|
|
1198
|
+
|
|
1199
|
+
|
|
1126
1200
|
def next_id(known):
|
|
1127
1201
|
"""The next id, from the MAXIMUM rather than from the count.
|
|
1128
1202
|
|
|
@@ -1359,7 +1433,7 @@ def cmd_producer(graph, args):
|
|
|
1359
1433
|
def cmd_doctrine(graph, args):
|
|
1360
1434
|
"""Which doctrine this run actually read — B-061.
|
|
1361
1435
|
|
|
1362
|
-
The bundle is
|
|
1436
|
+
The bundle is 40 reference files. A run reads some subset and nothing recorded which,
|
|
1363
1437
|
so **a skipped file and a read one were indistinguishable** — the class every guard in
|
|
1364
1438
|
this repository exists to catch, left standing over the doctrine itself.
|
|
1365
1439
|
|
|
@@ -1825,6 +1899,7 @@ VERBS = {
|
|
|
1825
1899
|
"coverage": (cmd_coverage, "every requirement and the nodes serving it; exits 1 on a gap"),
|
|
1826
1900
|
"add": (cmd_add, "add a node mid-run"),
|
|
1827
1901
|
"park": (cmd_park, "park a node, carrying the reason"),
|
|
1902
|
+
"wave": (cmd_wave, "move a node to another wave, carrying the reason"),
|
|
1828
1903
|
"certify": (cmd_certify, "require three independent tier reports (four on a "
|
|
1829
1904
|
"flagship or product surface), then emit the verdict "
|
|
1830
1905
|
"`close` consumes"),
|
|
@@ -1905,6 +1980,15 @@ def main(argv=None):
|
|
|
1905
1980
|
made["waive"].add_argument("--by", required=True,
|
|
1906
1981
|
help="the identity that authorized the exception")
|
|
1907
1982
|
|
|
1983
|
+
made["next"].add_argument("--wave", type=int, default=None,
|
|
1984
|
+
help="print only this wave's frontier; exit 3 when the wave "
|
|
1985
|
+
"is done (references/waves.md)")
|
|
1986
|
+
p_wave = made["wave"]
|
|
1987
|
+
p_wave.add_argument("node")
|
|
1988
|
+
p_wave.add_argument("--to", type=int, required=True, help="the wave to move it to")
|
|
1989
|
+
p_wave.add_argument("--why", required=True,
|
|
1990
|
+
help="why the re-cut moved it — it enters the revision log")
|
|
1991
|
+
|
|
1908
1992
|
p_park = made["park"]
|
|
1909
1993
|
p_park.add_argument("node")
|
|
1910
1994
|
# `required=True` makes the MISSING flag a usage error (exit 2). The empty and
|
|
@@ -1916,7 +2000,7 @@ def main(argv=None):
|
|
|
1916
2000
|
verbs = {k: v[0] for k, v in VERBS.items()}
|
|
1917
2001
|
if args.verb in NO_GRAPH:
|
|
1918
2002
|
return verbs[args.verb](None, args)
|
|
1919
|
-
if args.verb in ("add", "park", "close", "certify", "invalidate", "waive"):
|
|
2003
|
+
if args.verb in ("add", "park", "wave", "close", "certify", "invalidate", "waive"):
|
|
1920
2004
|
# The READ happens inside the lock too. Loading first and locking second is the
|
|
1921
2005
|
# same lost update with an extra step: the stale copy is already in memory.
|
|
1922
2006
|
with held(args.graph):
|
|
@@ -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")),
|
|
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
|
|
|
20
20
|
|
|
21
21
|
## `read:` — which doctrine this run actually opened
|
|
22
22
|
|
|
23
|
-
The bundle is
|
|
23
|
+
The bundle is 40 reference files and nothing recorded which of them a run read, so **a
|
|
24
24
|
skipped file and a read one were indistinguishable** — the class every guard in this
|
|
25
25
|
pipeline exists to catch, left standing over the doctrine itself.
|
|
26
26
|
|
|
@@ -48,7 +48,7 @@ same claim one level down.
|
|
|
48
48
|
|---|---|---|
|
|
49
49
|
| `unmeasured — no run ledger` | there is no ledger | nothing to read from |
|
|
50
50
|
| `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
|
|
51
|
-
| `N of
|
|
51
|
+
| `N of 40 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
|
|
52
52
|
|
|
53
53
|
**It is a disclosure: no floor, no direction, never a target.** A run that needs four files
|
|
54
54
|
and reads four is not worse than one that reads thirty — and the moment the number becomes
|