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.
Files changed (42) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/CONTRIBUTING.md +12 -6
  3. package/HOW-IT-WORKS.md +9 -4
  4. package/README.md +32 -4
  5. package/SKILL-CARD.md +1 -1
  6. package/cursor/rules/task-pipeline.mdc +11 -2
  7. package/evals/RESULTS.md +1 -1
  8. package/evals/task-pipeline.evals.json +16 -1
  9. package/package.json +3 -3
  10. package/plugins/task-pipeline/.claude-plugin/plugin.json +2 -2
  11. package/plugins/task-pipeline/commands/task-pipeline.md +23 -8
  12. package/plugins/task-pipeline/hooks/hooks.json +11 -0
  13. package/plugins/task-pipeline/hooks/memory-nudge.sh +49 -0
  14. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +3 -1
  15. package/plugins/task-pipeline/skills/project-audit/SKILL.md +2 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +10 -20
  17. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +1 -1
  18. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +2 -2
  19. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +8 -2
  20. package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +2 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +4 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +6 -3
  24. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +2 -2
  25. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +20 -2
  26. package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +4 -0
  27. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +31 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +14 -5
  30. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +3 -1
  31. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  32. package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +8 -0
  33. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  34. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +15 -7
  35. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +233 -0
  36. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +3 -1
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +8 -2
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +89 -5
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +4 -2
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/plan_audit.py +1 -1
  41. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +68 -1
  42. 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** | `0` printed · `3` all done · `4` nothing runnable |
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") != 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 = {
@@ -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
- ready = frontier(graph)
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 39 reference files. A run reads some subset and nothing recorded which,
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
- m = re.match(r"^execution-packet/(\d+)$", version)
72
+ # Namespaced since 1.93.0 (Fabric's former packet id was the bare one); the bare form
73
+ # is still read until the live packets carrying it are migrated (backlog B-135).
74
+ m = re.match(r"^(?:task-pipeline/)?execution-packet/(\d+)$", version)
73
75
  if not m:
74
- out.append(f"schema_version {version!r} is not 'execution-packet/<major>' — "
76
+ out.append(f"schema_version {version!r} is not 'task-pipeline/execution-packet/<major>' — "
75
77
  "a packet without a mandatory version is unversioned, rejected")
76
78
  elif int(m.group(1)) not in KNOWN_MAJORS:
77
79
  out.append(f"schema_version major {m.group(1)} is unknown to this consumer "
@@ -265,7 +265,7 @@ def _self_test():
265
265
  print("FAIL %s: %s" % (name, e))
266
266
 
267
267
  def pkt(node, targets=(), full=True):
268
- p = {"schema_version": "execution-packet/1", "node": node,
268
+ p = {"schema_version": "task-pipeline/execution-packet/1", "node": node,
269
269
  "intent": "do the thing", "inputs": [{"address": "a", "sha256": "0" * 64}],
270
270
  "decision_refs": [{"address": "d", "sha256": "1" * 64}],
271
271
  "source_scope": {"edit_targets": list(targets)},
@@ -242,7 +242,13 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
242
242
  f"{f' (kept as {kept})' if kept else ''} — {_now()}")
243
243
  _append(ledger, line)
244
244
  return line
245
- wid = answer.get("workflowId") or state.get("workflowId")
245
+ if answer.get("handoffId") and not answer.get("leaseId"):
246
+ # An offer: the lease stays with this run until the successor accepts.
247
+ line = f"event: memory — handoff offered {answer['handoffId']} — {_now()}"
248
+ _append(ledger, line)
249
+ return line
250
+ wid = (answer.get("workflowId") or (answer.get("checkpoint") or {}).get("workflowId")
251
+ or state.get("workflowId"))
246
252
  lease = answer.get("leaseId") or state.get("leaseId")
247
253
  if not wid:
248
254
  raise SystemExit("stage_checkpoint: the answer names no workflowId")
@@ -255,6 +261,49 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
255
261
  return line
256
262
 
257
263
 
264
+ def handoff(ledger: pathlib.Path, provider: str, model: str | None, reason: str,
265
+ ttl: int) -> dict:
266
+ """`observatory_handoff_create` arguments for a PLANNED change of executor (R11).
267
+
268
+ Without this the only way a workflow moved was a refusal: the next executor wrote,
269
+ got `LeaseLost`, and read the workflow after the fact. A planned move offers it
270
+ instead — the lease stays with this run until the successor accepts, and the pack
271
+ the successor receives is assembled by Observatory from the last checkpoint.
272
+ """
273
+ state = _load_state(ledger)
274
+ if not state.get("workflowId"):
275
+ raise SystemExit("stage_checkpoint: no workflow yet — write a checkpoint first "
276
+ "(emit → observatory_checkpoint_write → record)")
277
+ to = {"provider": provider, **({"model": model} if model else {})}
278
+ # Observatory digests the whole request, TTL included, so the TTL is in the key (another
279
+ # TTL is another offer, not an idempotency conflict). So is the ledger's length: once an
280
+ # offer is recorded the ledger grows, and offering again after it lapsed is a new offer —
281
+ # the same key would replay the first, expired answer. A retry before recording repeats.
282
+ try:
283
+ seen = len(ledger.read_text(encoding="utf-8").splitlines())
284
+ except OSError:
285
+ seen = 0
286
+ key = hashlib.sha256(f"{state['workflowId']}\n{json.dumps(to, sort_keys=True)}\n{reason}"
287
+ f"\n{ttl}\n{seen}".encode()).hexdigest()[:32]
288
+ args = {"owner": OWNER, "idempotencyKey": "tp-ho-" + key, "workflowId": state["workflowId"],
289
+ "to": to, "reason": reason, "offerTtlSeconds": ttl}
290
+ if state.get("leaseId"):
291
+ args["leaseId"] = state["leaseId"]
292
+ return args
293
+
294
+
295
+ def accept(ledger: pathlib.Path, handoff_id: str, session: str | None) -> dict:
296
+ """`observatory_handoff_accept` arguments for the successor, before its first write."""
297
+ if not handoff_id.startswith("handoff:"):
298
+ raise SystemExit("stage_checkpoint: a handoff id starts with 'handoff:'")
299
+ args = {"owner": OWNER,
300
+ "idempotencyKey": "tp-ac-" + hashlib.sha256(handoff_id.encode()).hexdigest()[:32],
301
+ "handoffId": handoff_id}
302
+ if session:
303
+ args["sessionId"] = session
304
+ return args
305
+
306
+
258
307
  def main(argv: list[str]) -> int:
259
308
  p = argparse.ArgumentParser(prog="stage_checkpoint.py",
260
309
  description="A workflow checkpoint at every stage boundary.")
@@ -271,11 +320,29 @@ def main(argv: list[str]) -> int:
271
320
  g.add_argument("--unavailable", help="why the tools could not be used")
272
321
  s = sub.add_parser("state", help="the workflow this run writes to; never the token")
273
322
  s.add_argument("--ledger", default=DEFAULT_LEDGER)
323
+ h = sub.add_parser("handoff", help="observatory_handoff_create arguments: a PLANNED executor change")
324
+ h.add_argument("--ledger", default=DEFAULT_LEDGER)
325
+ h.add_argument("--to-provider", required=True)
326
+ h.add_argument("--to-model")
327
+ h.add_argument("--reason", default="plan_route",
328
+ choices=["limit", "plan_route", "operator", "crash", "restart"])
329
+ h.add_argument("--offer-ttl", type=int, default=3600)
330
+ ac = sub.add_parser("accept", help="observatory_handoff_accept arguments, before the first write")
331
+ ac.add_argument("--ledger", default=DEFAULT_LEDGER)
332
+ ac.add_argument("--handoff-id", required=True)
333
+ ac.add_argument("--session")
274
334
  a = p.parse_args(argv)
275
335
  ledger = pathlib.Path(a.ledger)
276
336
  if a.cmd == "emit":
277
337
  print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
278
338
  return 0
339
+ if a.cmd == "handoff":
340
+ print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
341
+ ensure_ascii=False))
342
+ return 0
343
+ if a.cmd == "accept":
344
+ print(json.dumps(accept(ledger, a.handoff_id, a.session), ensure_ascii=False))
345
+ return 0
279
346
  if a.cmd == "state":
280
347
  st = _load_state(ledger)
281
348
  print(json.dumps({"workflowId": st.get("workflowId"), "holdsLease": bool(st.get("leaseId")),
@@ -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 39 reference files and nothing recorded which of them a run read, so **a
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 39 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 |
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