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 +35 -0
- package/SKILL-CARD.md +1 -1
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/model-tiering.md +40 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +56 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +6 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/plan_audit.py +396 -0
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.
|
|
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.
|
|
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.
|
|
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):**
|
|
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())
|