task-pipeline-skill 1.86.3 → 1.87.0

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 CHANGED
@@ -1,3 +1,38 @@
1
+ ## v1.87.0 — stage 4 stops handing over a plan the next agent cannot execute
2
+
3
+ The operator's finding, 2026-09-13: **agents change between sessions, and whatever a
4
+ task does not say, the next one re-derives or gets wrong.** The doctrine for this was
5
+ already here — the cold reader's eight questions, the leaf compiler, executor-sized
6
+ tasks and their context budgets — and none of it was mechanical. Nothing read the plan
7
+ and refused it. A task whose context lived in the planning conversation reached an
8
+ executor and failed there, four stages after it was written.
9
+
10
+ Guards: 429 → **429**. The gate ships its own eleven-case `--self-test` plus
11
+ `test/audit_regressions/fix-hk-20.py`, not a workflow step: `validate.yml` sits 59
12
+ bytes under GitHub's ceiling (#91).
13
+
14
+ - **`scripts/plan_audit.py`** — stage 4's gate, reading the plan the way somebody who
15
+ was not there will read it. Four questions no other gate asks: does every live node
16
+ carry a packet a **cold reader** can execute; do two nodes the graph leaves
17
+ **unordered** name one edit target (the parallel-safety rule reads the groups the
18
+ planner declared — this reads the graph, and two nodes in different groups race just
19
+ as hard); what does each node **unblock**, transitively, so priority is computed
20
+ rather than declared; and does a recorded per-stage **model map** name stages this
21
+ pipeline has. A `done`/`parked`/`waived` node needs no packet; a packet attached to
22
+ no node is reported, never guessed at. It imports the eight questions from
23
+ `context_packets.py` rather than restating them — one home for the contract, or the
24
+ gate drifts from the compiler that fills it.
25
+ - **`references/planning.md` → *Plan audit*** states the four questions, what the gate
26
+ deliberately does NOT judge (a packet can answer all eight wrongly and pass), and
27
+ where its four numbers go in `## Self-review`. `references/stages.md`'s stage-4 GATE
28
+ and the SKILL.md stage row both name the script.
29
+ - **`references/model-tiering.md` → *The plan-then-execute profile*** — the top
30
+ reasoning tier for stages 0–4 and 10, the strongest working tier for 5–9, with the
31
+ basis written down: stages 0–4 produce judgment the later stages consume, and a weak
32
+ plan is not recovered by a strong build. It changes nothing about the rule it sits
33
+ under — still an override the operator states and the brief records, and task size
34
+ still switches nothing.
35
+
1
36
  ## v1.86.3 — the release declares the stamp it does not carry, eighth time
2
37
 
3
38
  `v1.86.2` was tagged and refused by its own release workflow: `npm run test:all` against
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.86.3 |
15
+ | **Version** | 1.87.0 |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.86.3",
3
+ "version": "1.87.0",
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"
@@ -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). Retro insights can publish upstream as issues, opt-in and redacted.",
6
- "version": "1.86.3",
6
+ "version": "1.87.0",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -211,7 +211,7 @@ capable available — see `references/model-tiering.md`).
211
211
  | 1 | Docs study | contracts grounded on fetched docs | auto |
212
212
  | 2 | Brainstorm + decompose | design approved; UI verdict recorded; every REQ answered; **the queue is an artifact** — a work graph validates and its coverage names no unserved REQ; platform: module map approved | manual |
213
213
  | 3 | Spec | committed + reviewed; UI: chain validated, linter green, scenarios and `SCR-` traced; COPY and VISUAL are a parallel layer after UX, and where both ran their convergence check is recorded | manual |
214
- | 4 | Plan | parallel-ready, DoD per task; **every edge names what it carries** — the fake-edge test run and its `Edges:` count computed | auto |
214
+ | 4 | Plan | parallel-ready, DoD per task; **every edge names what it carries** — the fake-edge test run and its `Edges:` count computed; **`scripts/plan_audit.py` clean** — every live node's packet survives a cold reader, and no two unordered nodes edit one file | auto |
215
215
  | 5 | Dev | tasks DONE, TDD green per task, branch integrated per the brief; a fanned-out group gets **one convergence check over all its diffs together** before the first worktree lands | auto |
216
216
  | 6 | Tests | full suite green, new and changed code covered, every new check probed both ways and asserted on its exit code; **a web surface is checked in a browser, not in the diff** — where a browser channel is connected; absent, the weaker claim is recorded | auto |
217
217
  | 7 | Lint + deploy | lint clean and suite green before deploy; deploy needs a go, or the brief's specific standing authorization | manual |
@@ -1,5 +1,16 @@
1
1
  # Model policy
2
2
 
3
+ ## Contents
4
+
5
+ - The default
6
+ - Never hardcode a model id
7
+ - The operator's explicit choice is inherited, verbatim
8
+ - Mechanic — confirm at preflight, then stop asking
9
+ - The plan-then-execute profile
10
+ - Subagents
11
+ - Degradation
12
+ - A harness clause encodes a model's weakness — stress-test it per generation
13
+
3
14
  **One model, confirmed once, before the run starts.** Not a per-stage tier list,
4
15
  not a hardcoded vendor id — a single decision the operator makes at preflight and
5
16
  the pipeline then honors without nagging.
@@ -71,6 +82,35 @@ that:
71
82
  model**; only the operator can, via `/model` (or `/fast`). Preflight is
72
83
  interactive anyway, so this costs one exchange.
73
84
 
85
+ ## The plan-then-execute profile
86
+
87
+ The per-stage override the *Mechanic* block offers is usually declined, and the
88
+ default — one tier for the whole run — is the right answer when one tier is what the
89
+ environment has. Where two are available, one override has a basis worth writing down,
90
+ so it ships as a named profile the operator can ask for by name instead of composing:
91
+
92
+ > **plan-then-execute** — the most capable reasoning tier for stages 0–4 (intake,
93
+ > docs, brainstorm, spec, plan) and stage 10 (acceptance); the strongest *working*
94
+ > tier for stages 5–9 (build, tests, deploy, post-deploy, docs).
95
+
96
+ **Its basis, which is the part that makes it a decision rather than a preference:**
97
+ what stages 0–4 produce is *judgment that the later stages consume* — a brief that
98
+ hears what was not said, contracts a zero-context implementer will follow, and a plan
99
+ whose packets survive a stranger. A weak plan is not recovered by a strong build; a
100
+ strong plan executed by a working tier is ordinary delivery. Stage 10 is back on the
101
+ planning tier because acceptance is a judgment too: it decides whether the evidence
102
+ answers the claim.
103
+
104
+ **It changes nothing about how a switch is recorded.** This is still an override the
105
+ operator states at preflight and the brief records with its basis, exactly as the rule
106
+ above demands; naming it here saves the composing, not the recording. Task size still
107
+ switches nothing. And it is still ADVICE — an environment with one tier runs the whole
108
+ pipeline on that tier and says so.
109
+
110
+ `scripts/plan_audit.py --models <map.json>` checks the recorded map at stage 4: a stage
111
+ number this pipeline does not have, or an entry with no model behind it, fails there
112
+ rather than at the boundary it was written for.
113
+
74
114
  ## Subagents
75
115
 
76
116
  Stage 5 spawns subagents; the orchestrator pins them to the **run's confirmed
@@ -22,6 +22,7 @@ Built into this skill; nothing to install.
22
22
  - The UI handoff annex — a packet an executor can build without re-deriving
23
23
  - Pre-dispatch — the last gate before a claim
24
24
  - The leaf compiler — a slice survives a cold reader or it does not dispatch
25
+ - Plan audit — the plan read as the NEXT agent will read it
25
26
  - GATE (auto)
26
27
  - Execution packets
27
28
 
@@ -284,6 +285,7 @@ before the gate; every line a **computed number, not a tick**.
284
285
  - Hygiene: <n> checks, <n> findings, <n> open
285
286
  - Edges: <n> declared, <n> data, <n> control, <n> resource, <n> removed with reasons
286
287
  - Placeholders: <n> · Ambiguity: <n> found, <n> resolved inline
288
+ - Plan audit: <n> live nodes, <n> with packets, <n> context findings, <n> collisions — top of the computed priority: <id> (unblocks <n>)
287
289
  ```
288
290
 
289
291
  ## This stage settles nothing — and that is a rule, not an omission
@@ -345,8 +347,62 @@ neither a design flow nor a `.design/TASKS.md` becomes a parallel plan
345
347
  authority — leaves come from the plan through the compiler or they are not
346
348
  leaves.
347
349
 
350
+ ## Plan audit — the plan read as the NEXT agent will read it
351
+
352
+ Everything above is written for the planner. This is the pass that reads the same
353
+ plan as somebody who was not there — because that is who executes it. **Agents change
354
+ between sessions, and whatever a task does not say, the next one re-derives or gets
355
+ wrong.** The self-review checks the plan against the brief; this checks it against a
356
+ stranger.
357
+
358
+ Run it, and record its four numbers in the `## Self-review` block:
359
+
360
+ ```bash
361
+ python3 scripts/plan_audit.py --graph .task-pipeline/graph.json --packets <dir>
362
+ python3 scripts/plan_audit.py --self-test # the gate's own guards, 11 cases
363
+ ```
364
+
365
+ It asks four questions no other gate asks:
366
+
367
+ 1. **Context — can a cold reader execute this node?** Every live node must carry an
368
+ execution packet, and the packet must answer the eight questions of
369
+ [the leaf compiler](#the-leaf-compiler--a-slice-survives-a-cold-reader-or-it-does-not-dispatch)
370
+ — goal, inputs, decisions, scope, outputs, acceptance, guards, resume. The check
371
+ imports that list from `scripts/context_packets.py` rather than restating it: one
372
+ home for the contract, so the gate cannot drift from the compiler that fills it.
373
+ A node whose context lives in the planning conversation fails here, where it costs
374
+ a paragraph, instead of at dispatch, where it costs a wrong implementation.
375
+ 2. **Contradiction — do two unordered nodes edit one file?** The parallel-safety rule
376
+ in the self-review reads the groups the planner *declared*; this reads the GRAPH.
377
+ Two nodes the planner never put in one group and never connected either race
378
+ exactly as hard, and the run finds out by losing an edit.
379
+ 3. **Priority — computed, never declared.** How many nodes each one unblocks,
380
+ transitively. A hand-assigned number is an opinion wearing a number's clothes
381
+ ([`prioritisation.md`](prioritisation.md)); this one is derived from the graph and
382
+ printed, so the order a run takes is a fact the plan states rather than a choice it
383
+ makes silently. Cycles terminate the walk instead of hanging it — the cycle itself
384
+ is `graph.py validate`'s finding, not this one's.
385
+ 4. **Model — is the recorded map real?** When the brief records a per-stage override
386
+ ([`model-tiering.md`](model-tiering.md) → *The plan-then-execute profile*), a stage
387
+ number this pipeline does not have, or an entry with no model behind it, fails here
388
+ rather than at the boundary it was written for.
389
+
390
+ **A node that is `done`, `parked`, `waived`, `closed` or `superseded` needs no
391
+ packet** — finished work is history, not a plan to hand over. **A packet attached to
392
+ no node is reported, never guessed at**: a packet nothing can attach to and a node
393
+ with no packet look identical from the outside and have different fixes.
394
+
395
+ **What this gate does not do.** It does not judge whether the plan is a good plan, and
396
+ it cannot: a packet that answers all eight questions wrongly passes. It refuses the
397
+ plan that cannot be handed over at all, which is the failure that was actually
398
+ costing runs.
399
+
348
400
  ## GATE (auto)
349
401
 
402
+ **The plan audit runs and is clean** — `python3 scripts/plan_audit.py` exits 0, and
403
+ its four numbers are in the self-review. A finding there is fixed here; stage 5 does
404
+ not open over a plan a stranger cannot execute.
405
+
350
406
  **Set equality first:** the REQ ids in the brief equal the union of `Implements:`
351
407
  across the plan's tasks. A non-empty difference fails the gate and is reported as
352
408
  the explicit list of dropped (or invented) requirements — this seam is where scope
@@ -387,7 +387,12 @@ never that the work was skipped quietly.
387
387
  paths, complete code in every step, TDD steps with expected output, DoD each,
388
388
  dependency graph + parallel groups, non-overlapping file ownership, and the
389
389
  Global Constraints block copied verbatim from the spec.
390
- - **GATE (auto):** the **`## Self-review` section written with computed values**,
390
+ - **GATE (auto):** `python3 scripts/plan_audit.py` clean the plan read as the agent
391
+ who was NOT there will read it: every live node carries a packet answering the cold
392
+ reader's eight questions, no two nodes the graph leaves unordered edit one file, the
393
+ priority is computed from what each node unblocks, and a recorded per-stage model map
394
+ names stages this pipeline has ([`planning.md`](planning.md) → *Plan audit*). Then:
395
+ the **`## Self-review` section written with computed values**,
391
396
  every command, path and file a DoD names resolving; **set equality — the REQ ids in the brief equal the union of
392
397
  `Implements:` across plan tasks.** A non-empty difference fails the gate and is
393
398
  reported as the explicit list of dropped requirements; this is the seam where
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python3
2
+ """Plan audit — the stage-4 gate that reads the plan the way the NEXT agent will.
3
+
4
+ Stage 4 already refuses a plan that drops a REQ, carries a placeholder, or draws an
5
+ edge that hands nothing over. What it could not refuse until now is the failure the
6
+ operator named on 2026-09-13: **an agent that was not there cannot execute a task
7
+ whose context lives in the planning conversation.** Agents change between sessions;
8
+ whatever a task does not say, the next one re-derives or gets wrong.
9
+
10
+ So this reads the work graph and asks four questions no other gate asks:
11
+
12
+ 1. **Context** — is every node's packet answerable by a cold reader? The eight
13
+ questions are `context_packets.py`'s, imported rather than re-listed: one home for
14
+ the contract, so the gate cannot drift from the compiler that produces it.
15
+ 2. **Contradiction** — do two nodes with no ordering between them name the same edit
16
+ target? The parallel-group rule catches the same file inside ONE group; two nodes
17
+ in different groups with no path between them race just as hard, and nothing looked.
18
+ 3. **Priority** — computed, never declared: how many nodes each one unblocks,
19
+ transitively. A hand-assigned number is an opinion wearing a number's clothes
20
+ (`references/prioritisation.md`); this one is derived from the graph and printed,
21
+ so the order a run takes is a fact the plan states rather than a choice it makes
22
+ silently.
23
+ 4. **Model** — when the run records a per-stage model map, the map is checked against
24
+ the stages this pipeline has, so a profile naming a stage that does not exist
25
+ fails here instead of at the boundary it was written for.
26
+
27
+ Exit 0 when the plan can be handed to a stranger, 1 when it cannot, 2 on usage.
28
+
29
+ python3 scripts/plan_audit.py [--graph .task-pipeline/graph.json]
30
+ [--packets <dir>] [--models <map.json>]
31
+ [--json] [--self-test]
32
+
33
+ Standard library only, like every script in this bundle.
34
+ """
35
+ import argparse
36
+ import json
37
+ import os
38
+ import sys
39
+
40
+ HERE = os.path.dirname(os.path.abspath(__file__))
41
+ sys.path.insert(0, HERE)
42
+
43
+ # One home for the cold-reader contract. A second copy of the eight questions is a
44
+ # second thing to drift, and this gate exists to catch drift.
45
+ from context_packets import COLD_READER_QUESTIONS, leaf_readiness # noqa: E402
46
+
47
+ STAGES = tuple(str(i) for i in range(11))
48
+ DEFAULT_GRAPH = os.path.join(".task-pipeline", "graph.json")
49
+ # A node in one of these states is finished or deliberately out of the run; its packet
50
+ # is history, and demanding one would refuse a plan for work nobody will do.
51
+ SETTLED = {"done", "parked", "waived", "closed", "superseded"}
52
+
53
+
54
+ def load_json(path):
55
+ with open(path, encoding="utf-8") as fh:
56
+ return json.load(fh)
57
+
58
+
59
+ def _dependents(nodes):
60
+ """node id → the ids that directly wait on it."""
61
+ out = {n.get("id"): set() for n in nodes}
62
+ for n in nodes:
63
+ for b in n.get("blocked_by") or []:
64
+ if b in out:
65
+ out[b].add(n.get("id"))
66
+ return out
67
+
68
+
69
+ def unblocks(nodes):
70
+ """How many nodes each one unblocks, transitively. The computed priority."""
71
+ dep = _dependents(nodes)
72
+ memo = {}
73
+
74
+ def reach(nid, seen):
75
+ if nid in memo:
76
+ return memo[nid]
77
+ if nid in seen: # a cycle is graph.py's finding, not this one's
78
+ return set()
79
+ acc = set()
80
+ for nxt in dep.get(nid, ()): # direct dependents first
81
+ acc.add(nxt)
82
+ acc |= reach(nxt, seen | {nid})
83
+ memo[nid] = acc
84
+ return acc
85
+
86
+ return {nid: len(reach(nid, set())) for nid in dep}
87
+
88
+
89
+ def _ordered_before(nodes):
90
+ """(a, b) pairs where a is reachable from b or b from a — i.e. ordered."""
91
+ dep = {n.get("id"): set(n.get("blocked_by") or []) for n in nodes}
92
+ ancestors = {}
93
+
94
+ def anc(nid, seen):
95
+ if nid in ancestors:
96
+ return ancestors[nid]
97
+ if nid in seen:
98
+ return set()
99
+ acc = set()
100
+ for b in dep.get(nid, ()):
101
+ if b in dep:
102
+ acc.add(b)
103
+ acc |= anc(b, seen | {nid})
104
+ ancestors[nid] = acc
105
+ return acc
106
+
107
+ return {nid: anc(nid, set()) for nid in dep}
108
+
109
+
110
+ def edit_targets(packet):
111
+ """What a packet says it will edit or create — the scope a collision is about."""
112
+ scope = (packet or {}).get("source_scope") or {}
113
+ out = set()
114
+ for key in ("edit_targets", "create_targets"):
115
+ for t in scope.get(key) or []:
116
+ if isinstance(t, str):
117
+ out.add(t)
118
+ elif isinstance(t, dict) and t.get("path"):
119
+ out.add(t["path"])
120
+ return out
121
+
122
+
123
+ def collisions(nodes, packets):
124
+ """Two nodes that touch one file with no ordering between them.
125
+
126
+ Not the same rule as the parallel-group check in `planning.md`: that one reads a
127
+ group the planner declared, this one reads the GRAPH. Two nodes the planner never
128
+ put in one group, and never connected either, race exactly as hard — and the run
129
+ finds out by losing an edit.
130
+ """
131
+ anc = _ordered_before(nodes)
132
+ ids = [n.get("id") for n in nodes if n.get("id")]
133
+ out = []
134
+ for i, a in enumerate(ids):
135
+ for b in ids[i + 1:]:
136
+ if b in anc.get(a, ()) or a in anc.get(b, ()):
137
+ continue
138
+ shared = edit_targets(packets.get(a)) & edit_targets(packets.get(b))
139
+ for path in sorted(shared):
140
+ out.append((a, b, path))
141
+ return out
142
+
143
+
144
+ def packets_by_node(directory):
145
+ """Every execution packet under `directory`, keyed by the node it serves.
146
+
147
+ A packet names its node in `node` or `parent_node`; one that names neither is
148
+ reported rather than guessed at, because a packet nothing can attach to is
149
+ indistinguishable from a node with no packet — and those two failures have
150
+ different fixes.
151
+ """
152
+ found, orphans = {}, []
153
+ if not directory or not os.path.isdir(directory):
154
+ return found, orphans
155
+ for root, _dirs, files in os.walk(directory):
156
+ for name in sorted(files):
157
+ if not name.endswith(".json"):
158
+ continue
159
+ path = os.path.join(root, name)
160
+ try:
161
+ doc = load_json(path)
162
+ except Exception as e:
163
+ orphans.append((path, f"does not parse ({e})"))
164
+ continue
165
+ if not isinstance(doc, dict):
166
+ orphans.append((path, "is not an object"))
167
+ continue
168
+ nid = doc.get("node") or doc.get("parent_node")
169
+ if not nid:
170
+ orphans.append((path, "names no node (`node` / `parent_node`)"))
171
+ continue
172
+ found[nid] = doc
173
+ return found, orphans
174
+
175
+
176
+ def audit(graph, packets, orphans=(), model_map=None):
177
+ """Every finding, as (kind, node, message). Pure: no I/O, no exit."""
178
+ findings = []
179
+ nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
180
+ if not nodes:
181
+ findings.append(("plan", "-", "the graph declares no nodes — a plan with no "
182
+ "tasks is not a plan this gate can hand to anyone"))
183
+ return findings
184
+ live = [n for n in nodes if str(n.get("status", "")).lower() not in SETTLED]
185
+
186
+ for path, why in orphans:
187
+ findings.append(("packet", "-", f"{path} {why}"))
188
+
189
+ for n in live:
190
+ nid = n.get("id") or "?"
191
+ pkt = packets.get(nid)
192
+ if pkt is None:
193
+ findings.append((
194
+ "context", nid,
195
+ "no execution packet — the next agent would start from the title. "
196
+ "Compile one with `context_packets.py compile-leaf`; the eight "
197
+ "questions it must answer are " + ", ".join(COLD_READER_QUESTIONS)))
198
+ continue
199
+ for problem in leaf_readiness(pkt):
200
+ findings.append(("context", nid, problem))
201
+
202
+ for a, b, path in collisions(live, packets):
203
+ findings.append((
204
+ "contradiction", f"{a}+{b}",
205
+ f"both name {path} as an edit target and the graph orders neither before "
206
+ "the other — whichever runs second overwrites the first, and no gate "
207
+ "before this one looked outside a declared parallel group"))
208
+
209
+ if model_map is not None:
210
+ if not isinstance(model_map, dict):
211
+ findings.append(("model", "-", "the model map is not an object"))
212
+ else:
213
+ for stage in sorted(model_map):
214
+ if str(stage) not in STAGES:
215
+ findings.append((
216
+ "model", "-",
217
+ f"the model map names stage {stage!r}, which this pipeline does "
218
+ f"not have (stages are {STAGES[0]}–{STAGES[-1]}) — a profile that "
219
+ "names a stage nobody runs is an override that never fires"))
220
+ elif not str(model_map[stage] or "").strip():
221
+ findings.append((
222
+ "model", "-",
223
+ f"stage {stage} maps to {model_map[stage]!r} — an entry with no "
224
+ "model is silence wearing a record's clothes"))
225
+ return findings
226
+
227
+
228
+ def report(graph, packets, findings, stream=sys.stdout):
229
+ nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
230
+ live = [n for n in nodes if str(n.get("status", "")).lower() not in SETTLED]
231
+ rank = unblocks(live)
232
+ print("plan audit — %d node(s), %d live" % (len(nodes), len(live)), file=stream)
233
+ print("\n priority (computed: how many nodes each unblocks, transitively)",
234
+ file=stream)
235
+ for n in sorted(live, key=lambda x: (-rank.get(x.get("id"), 0), x.get("id") or "")):
236
+ nid = n.get("id") or "?"
237
+ print(" %-8s unblocks %-3d packet %-7s %s"
238
+ % (nid, rank.get(nid, 0), "yes" if nid in packets else "NO",
239
+ (n.get("title") or "")[:54]), file=stream)
240
+ if not findings:
241
+ print("\nPASS: every live node carries a packet a cold reader can execute, "
242
+ "no two unordered nodes edit one file, and the model map (if any) names "
243
+ "stages this pipeline has", file=stream)
244
+ return
245
+ print("", file=stream)
246
+ for kind, nid, msg in findings:
247
+ print(" %-13s %-10s %s" % (kind.upper(), nid, msg), file=stream)
248
+ kinds = sorted({k for k, _, _ in findings})
249
+ print("\nFAIL: %d finding(s) across %s — stage 5 does not open. The plan is fixed "
250
+ "here, not worked around there." % (len(findings), ", ".join(kinds)),
251
+ file=stream)
252
+
253
+
254
+ def _self_test():
255
+ """Each case is a plan that would have reached an executor, and the finding it earns."""
256
+ cases, failures = [], []
257
+
258
+ def case(name, fn):
259
+ cases.append(name)
260
+ try:
261
+ fn()
262
+ print(" ok %s" % name)
263
+ except AssertionError as e:
264
+ failures.append("%s: %s" % (name, e))
265
+ print("FAIL %s: %s" % (name, e))
266
+
267
+ def pkt(node, targets=(), full=True):
268
+ p = {"schema_version": "execution-packet/1", "node": node,
269
+ "intent": "do the thing", "inputs": [{"address": "a", "sha256": "0" * 64}],
270
+ "decision_refs": [{"address": "d", "sha256": "1" * 64}],
271
+ "source_scope": {"edit_targets": list(targets)},
272
+ "outputs": ["x"], "acceptance": [{"kind": "positive"}, {"kind": "negative"}],
273
+ "guards": ["g"], "resume": "from the top"}
274
+ if not full:
275
+ p.pop("acceptance")
276
+ return p
277
+
278
+ def graph(nodes):
279
+ return {"nodes": nodes}
280
+
281
+ def t_missing_packet():
282
+ g = graph([{"id": "N-001", "title": "t", "status": "pending"}])
283
+ f = audit(g, {})
284
+ assert any(k == "context" and "no execution packet" in m for k, _, m in f), f
285
+
286
+ def t_packet_missing_an_answer():
287
+ g = graph([{"id": "N-001", "title": "t", "status": "pending"}])
288
+ f = audit(g, {"N-001": pkt("N-001", full=False)})
289
+ assert any("acceptance" in m for _, _, m in f), f
290
+
291
+ def t_unordered_nodes_sharing_a_file():
292
+ g = graph([{"id": "N-001", "status": "pending"}, {"id": "N-002", "status": "pending"}])
293
+ p = {"N-001": pkt("N-001", ["src/a.py"]), "N-002": pkt("N-002", ["src/a.py"])}
294
+ f = audit(g, p)
295
+ assert any(k == "contradiction" for k, _, _ in f), f
296
+
297
+ def t_ordered_nodes_sharing_a_file_are_fine():
298
+ g = graph([{"id": "N-001", "status": "pending"},
299
+ {"id": "N-002", "status": "pending", "blocked_by": ["N-001"]}])
300
+ p = {"N-001": pkt("N-001", ["src/a.py"]), "N-002": pkt("N-002", ["src/a.py"])}
301
+ assert not audit(g, p), "an ordered pair is a sequence, not a race"
302
+
303
+ def t_settled_nodes_need_no_packet():
304
+ g = graph([{"id": "N-001", "status": "done"}, {"id": "N-002", "status": "parked"}])
305
+ assert not audit(g, {}), "finished work is history, not a plan to hand over"
306
+
307
+ def t_priority_is_transitive():
308
+ g = graph([{"id": "N-001", "status": "pending"},
309
+ {"id": "N-002", "status": "pending", "blocked_by": ["N-001"]},
310
+ {"id": "N-003", "status": "pending", "blocked_by": ["N-002"]}])
311
+ r = unblocks(g["nodes"])
312
+ assert r["N-001"] == 2 and r["N-002"] == 1 and r["N-003"] == 0, r
313
+
314
+ def t_priority_survives_a_cycle():
315
+ g = graph([{"id": "N-001", "status": "pending", "blocked_by": ["N-002"]},
316
+ {"id": "N-002", "status": "pending", "blocked_by": ["N-001"]}])
317
+ r = unblocks(g["nodes"]) # graph.py reports the cycle; this must not hang
318
+ assert set(r) == {"N-001", "N-002"}, r
319
+
320
+ def t_model_map_naming_a_stage_that_does_not_exist():
321
+ g = graph([{"id": "N-001", "status": "done"}])
322
+ f = audit(g, {}, model_map={"11": "opus"})
323
+ assert any(k == "model" for k, _, _ in f), f
324
+
325
+ def t_a_real_model_map_passes():
326
+ g = graph([{"id": "N-001", "status": "done"}])
327
+ assert not audit(g, {}, model_map={"4": "fable", "5": "opus"})
328
+
329
+ def t_an_orphan_packet_is_reported_not_guessed():
330
+ g = graph([{"id": "N-001", "status": "done"}])
331
+ f = audit(g, {}, orphans=[("x.json", "names no node (`node` / `parent_node`)")])
332
+ assert any(k == "packet" for k, _, _ in f), f
333
+
334
+ def t_an_empty_graph_is_a_finding():
335
+ f = audit({"nodes": []}, {})
336
+ assert any(k == "plan" for k, _, _ in f), "an empty plan passed"
337
+
338
+ case("a live node with no packet is refused", t_missing_packet)
339
+ case("a packet missing one of the eight answers is refused", t_packet_missing_an_answer)
340
+ case("two unordered nodes editing one file are refused", t_unordered_nodes_sharing_a_file)
341
+ case("an ordered pair editing one file is not a collision", t_ordered_nodes_sharing_a_file_are_fine)
342
+ case("a done or parked node needs no packet", t_settled_nodes_need_no_packet)
343
+ case("priority counts what a node unblocks transitively", t_priority_is_transitive)
344
+ case("priority terminates on a cycle instead of hanging", t_priority_survives_a_cycle)
345
+ case("a model map naming a stage this pipeline lacks is refused",
346
+ t_model_map_naming_a_stage_that_does_not_exist)
347
+ case("a model map over real stages passes", t_a_real_model_map_passes)
348
+ case("a packet attached to no node is reported, never guessed",
349
+ t_an_orphan_packet_is_reported_not_guessed)
350
+ case("an empty graph is a finding, not a pass", t_an_empty_graph_is_a_finding)
351
+
352
+ if failures:
353
+ print("\n%d of %d failed" % (len(failures), len(cases)))
354
+ return 1
355
+ print("\nSELF-TEST PASS: %d case(s)" % len(cases))
356
+ return 0
357
+
358
+
359
+ def main(argv=None):
360
+ p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
361
+ p.add_argument("--graph", default=DEFAULT_GRAPH)
362
+ p.add_argument("--packets", default=os.path.join(".task-pipeline", "packets"))
363
+ p.add_argument("--models", default=None,
364
+ help="a JSON object mapping stage number → model, as the "
365
+ "per-stage override map the brief records")
366
+ p.add_argument("--json", action="store_true")
367
+ p.add_argument("--self-test", action="store_true")
368
+ args = p.parse_args(argv)
369
+
370
+ if args.self_test:
371
+ return _self_test()
372
+
373
+ if not os.path.isfile(args.graph):
374
+ print("no work graph at %s — stage 2 writes it and stage 4 audits it. Nothing "
375
+ "was read." % args.graph, file=sys.stderr)
376
+ return 2
377
+ graph = load_json(args.graph)
378
+ packets, orphans = packets_by_node(args.packets)
379
+ model_map = load_json(args.models) if args.models else None
380
+ findings = audit(graph, packets, orphans, model_map)
381
+ if args.json:
382
+ nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)]
383
+ live = [n for n in nodes if str(n.get("status", "")).lower() not in SETTLED]
384
+ print(json.dumps({
385
+ "nodes": len(nodes), "live": len(live),
386
+ "priority": unblocks(live),
387
+ "packets": sorted(packets),
388
+ "findings": [{"kind": k, "node": n, "message": m} for k, n, m in findings],
389
+ }, indent=2, sort_keys=True))
390
+ else:
391
+ report(graph, packets, findings)
392
+ return 1 if findings else 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ sys.exit(main())