task-pipeline-skill 1.67.0 → 1.69.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +856 -0
  2. package/README.md +25 -0
  3. package/SKILL-CARD.md +1 -1
  4. package/bin/task-pipeline.js +30 -0
  5. package/package.json +4 -3
  6. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  7. package/plugins/task-pipeline/agents/verifier.md +88 -0
  8. package/plugins/task-pipeline/commands/task-pipeline.md +22 -0
  9. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +84 -136
  10. package/plugins/task-pipeline/skills/task-pipeline/graph.example.json +73 -0
  11. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +253 -0
  12. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +46 -3
  13. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +69 -0
  14. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +1 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +9 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +17 -0
  17. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +91 -1
  18. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  19. package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +41 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +11 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +52 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +121 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1113 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +1 -0
  25. package/plugins/task-pipeline/skills/task-pipeline/templates/carryover.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/templates/convergence.sh +146 -0
  27. package/plugins/task-pipeline/skills/task-pipeline/templates/exposure.sh +104 -1
  28. package/plugins/task-pipeline/skills/task-pipeline/templates/hooks.example.json +13 -1
  29. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +32 -0
  30. package/plugins/task-pipeline/skills/task-pipeline/templates/verification.md +67 -5
@@ -0,0 +1,73 @@
1
+ {
2
+ "goal": "The pipeline advances a queue without a human between iterations, and says what it did.",
3
+ "requirements": [
4
+ "REQ-001",
5
+ "REQ-002",
6
+ "REQ-005",
7
+ "REQ-016"
8
+ ],
9
+ "nodes": [
10
+ {
11
+ "id": "N-001",
12
+ "title": "The graph schema",
13
+ "owner": "implementer",
14
+ "status": "done",
15
+ "blocked_by": [],
16
+ "serves": "REQ-001",
17
+ "evidence": [
18
+ "npm test → PASS: task-pipeline structure valid, with graph.example.json validated against graph.schema.json"
19
+ ]
20
+ },
21
+ {
22
+ "id": "N-002",
23
+ "title": "graph.py validate — the invariants a schema cannot state",
24
+ "owner": "implementer",
25
+ "status": "running",
26
+ "blocked_by": [
27
+ "N-001"
28
+ ],
29
+ "serves": "REQ-002",
30
+ "evidence": null
31
+ },
32
+ {
33
+ "id": "N-003",
34
+ "title": "The verifier agent and its six-key verdict",
35
+ "owner": "verifier",
36
+ "status": "pending",
37
+ "blocked_by": [
38
+ "N-001"
39
+ ],
40
+ "serves": "REQ-005",
41
+ "evidence": null
42
+ },
43
+ {
44
+ "id": "N-004",
45
+ "title": "Mockups for the run view, before any markup",
46
+ "owner": "ui",
47
+ "status": "parked",
48
+ "blocked_by": [],
49
+ "serves": "REQ-016",
50
+ "evidence": null,
51
+ "parked_reason": "Serves module 2, not this release's goal. Parked rather than dropped quietly — the reason is the point."
52
+ }
53
+ ],
54
+ "edges": [
55
+ {
56
+ "from": "N-001",
57
+ "to": "N-002",
58
+ "payload": "the schema validate validates against"
59
+ },
60
+ {
61
+ "from": "N-001",
62
+ "to": "N-003",
63
+ "payload": "the node shape the verdict's `node` field references"
64
+ }
65
+ ],
66
+ "revisions": [
67
+ {
68
+ "verb": "park",
69
+ "node": "N-004",
70
+ "why": "The verifier's re-plan: this serves module 2, and module 1's goal clause does not reach it. Parked rather than dropped so the decision survives."
71
+ }
72
+ ]
73
+ }
@@ -0,0 +1,253 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/ssheleg/task-pipeline/main/plugins/task-pipeline/skills/task-pipeline/graph.schema.json",
4
+ "title": "task-pipeline work graph",
5
+ "description": "The queue the loop walks, written at stage 2 and re-planned by the verifier at every close. It lives at `.task-pipeline/graph.json` — a RUN artifact, never shipped, never committed by the skill. What ships is this schema and one example.\n\nThe design rule this file exists to serve: the model never reads the graph. `scripts/graph.py next` computes the frontier and prints it, so what enters a context each iteration is bounded by the frontier's width rather than by the programme's size. A graph of four hundred nodes and a graph of four cost the same to walk.\n\nOnly the invariants a schema CAN state live here — and the first draft of this file drew that line in the wrong place. It claimed `done` implying evidence was beyond JSON Schema; draft-07 `if`/`then` states it exactly, and it is stated below. What genuinely cannot be expressed is cross-DOCUMENT: whether an `owner` names a role that exists, whether `serves` resolves to a real REQ, and whether the edges form a cycle. Those are `graph.py validate`'s, and the split is now where the format actually puts it.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "goal",
10
+ "requirements",
11
+ "nodes",
12
+ "edges"
13
+ ],
14
+ "properties": {
15
+ "goal": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Echoed from the project's `pipeline.json` → `release.goal` when the graph is created, so the graph carries the thing it serves rather than pointing at a file that may have moved on. Every loop iteration prints it above the frontier: drift from the goal becomes visible rather than remembered."
19
+ },
20
+ "nodes": {
21
+ "type": "array",
22
+ "items": {
23
+ "$ref": "#/definitions/node"
24
+ },
25
+ "description": "Units of work. A node is what one role does in one dispatch and what one verdict closes."
26
+ },
27
+ "edges": {
28
+ "type": "array",
29
+ "items": {
30
+ "$ref": "#/definitions/edge"
31
+ },
32
+ "description": "Dependencies, each carrying what it hands over."
33
+ },
34
+ "revisions": {
35
+ "type": "array",
36
+ "items": {
37
+ "$ref": "#/definitions/revision"
38
+ },
39
+ "description": "Why the graph is not the graph stage 2 wrote. Every mutation appends one entry, and both verbs refuse without a reason — `park` always did, `add` did not, which left half the revision surface silent. A graph that changed for reasons nobody recorded can always explain its own completion by appealing to a plan that existed only at the end; this log is what makes that checkable. `next` never prints it — the frontier's width is what a loop pays for on every iteration, and this grows."
40
+ },
41
+ "requirements": {
42
+ "type": "array",
43
+ "minItems": 1,
44
+ "uniqueItems": true,
45
+ "items": {
46
+ "type": "string",
47
+ "pattern": "^REQ-[0-9]{3,}$"
48
+ },
49
+ "description": "The REQ ids the brief froze at stage 0, copied here so `serves` has something to resolve against. Until this existed, `serves` was a non-empty string and nothing more — `serves: \"REQ-999\"` and `serves: \"asdf\"` passed every gate identically, and that field is the ONE edge joining the intent graph to the execution graph. Required and non-empty: a graph whose intent side is empty cannot be checked against anything, and a queue that serves nothing is not a plan."
50
+ },
51
+ "goal_clauses": {
52
+ "type": "array",
53
+ "uniqueItems": true,
54
+ "items": {
55
+ "type": "string",
56
+ "minLength": 1,
57
+ "pattern": "\\S"
58
+ },
59
+ "description": "Clauses of the release goal a node may serve instead of a REQ — release work that no requirement names. Enumerated rather than matched against the goal's prose, because substring-matching a sentence is the kind of check that produces confidence without correctness."
60
+ }
61
+ },
62
+ "definitions": {
63
+ "node": {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "required": [
67
+ "id",
68
+ "title",
69
+ "owner",
70
+ "status",
71
+ "serves"
72
+ ],
73
+ "properties": {
74
+ "id": {
75
+ "type": "string",
76
+ "pattern": "^N-[0-9]{3,}$",
77
+ "description": "Addressable, so a verdict, a board row and a commit can all cite the same node."
78
+ },
79
+ "title": {
80
+ "type": "string",
81
+ "minLength": 1
82
+ },
83
+ "owner": {
84
+ "type": "string",
85
+ "minLength": 1,
86
+ "description": "The role that runs it. Required by schema because a node with no owner is a node nobody dispatches — it sits in the frontier forever and the loop looks stalled for a reason no output explains. Whether the name is a role that EXISTS is `graph.py validate`'s question, not this file's."
87
+ },
88
+ "status": {
89
+ "enum": [
90
+ "pending",
91
+ "running",
92
+ "done",
93
+ "blocked",
94
+ "parked"
95
+ ],
96
+ "description": "`blocked` is waiting on an edge; `parked` is the verifier's deliberate *«this is a blocker, continue around it»*. The two are different facts and collapsing them loses the one a person needs."
97
+ },
98
+ "blocked_by": {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "string",
102
+ "pattern": "^N-[0-9]{3,}$"
103
+ },
104
+ "default": [],
105
+ "description": "Node ids. This is what makes the frontier computable at all: a node is runnable when every id here is `done`.",
106
+ "uniqueItems": true
107
+ },
108
+ "serves": {
109
+ "type": "string",
110
+ "minLength": 1,
111
+ "description": "A REQ id, or a clause of the goal. Required, because a node that serves neither is work nobody asked for — and the pipeline's answer to that is to park it WITH THAT AS THE REASON rather than to do it quietly."
112
+ },
113
+ "evidence": {
114
+ "type": [
115
+ "array",
116
+ "null"
117
+ ],
118
+ "items": {
119
+ "type": "string"
120
+ },
121
+ "default": null,
122
+ "description": "The command and its output that proves this node is done. Null until closed. **A `done` node must carry at least one non-empty entry** — stated as `if`/`then` above, so the format refuses it before any script runs. The first draft only promised that `graph.py close` refuses a null, which by its own wording accepted `evidence: []` — the exact shape a script writing an empty list produces."
123
+ },
124
+ "parked_reason": {
125
+ "type": "string",
126
+ "minLength": 1,
127
+ "pattern": "\\S",
128
+ "description": "Why this node is parked, written for the person who will read it weeks later. Required when `status` is `parked` — REQ-012, and the same rule shape as `done` implying `evidence`. A park with no reason is indistinguishable from a node that was quietly dropped, and the whole point of parking rather than deleting is that the reason survives. `scripts/graph.py park` refuses without one and refuses to overwrite one already recorded."
129
+ },
130
+ "touches": {
131
+ "type": "array",
132
+ "uniqueItems": true,
133
+ "items": {
134
+ "type": "string",
135
+ "minLength": 1,
136
+ "pattern": "\\S"
137
+ },
138
+ "description": "What this node MUTATES — paths, register names, remote resource ids. `references/planning.md` states the rule the frontier needs: *distinct is not the same as independent, and the check is what they touch, never what they are called.* That rule lived in the markdown plan, and the graph replaced the plan as the thing deciding what runs next — so `next` could hand two agents two runnable nodes that write the same file, with nothing able to report it.\\n\\nOptional, and its absence is DISCLOSED rather than treated as «touches nothing»: `next` prints how many frontier nodes declared no targets, because a quiet run and a checked one must not look alike."
139
+ }
140
+ },
141
+ "allOf": [
142
+ {
143
+ "if": {
144
+ "properties": {
145
+ "status": {
146
+ "const": "done"
147
+ }
148
+ },
149
+ "required": [
150
+ "status"
151
+ ]
152
+ },
153
+ "then": {
154
+ "required": [
155
+ "evidence"
156
+ ],
157
+ "properties": {
158
+ "evidence": {
159
+ "type": "array",
160
+ "minItems": 1,
161
+ "items": {
162
+ "type": "string",
163
+ "minLength": 1,
164
+ "pattern": "\\S",
165
+ "description": "At least one non-whitespace character. `minLength: 1` alone accepts a single space, which is the one shape where this schema and `graph.py`'s verdict gate disagreed — the gate strips, the schema counted. A cross-check fixture comparing the two found it."
166
+ }
167
+ }
168
+ }
169
+ }
170
+ },
171
+ {
172
+ "if": {
173
+ "properties": {
174
+ "status": {
175
+ "const": "parked"
176
+ }
177
+ },
178
+ "required": [
179
+ "status"
180
+ ]
181
+ },
182
+ "then": {
183
+ "required": [
184
+ "parked_reason"
185
+ ],
186
+ "properties": {
187
+ "parked_reason": {
188
+ "type": "string",
189
+ "minLength": 1,
190
+ "pattern": "\\S"
191
+ }
192
+ }
193
+ }
194
+ }
195
+ ]
196
+ },
197
+ "edge": {
198
+ "type": "object",
199
+ "additionalProperties": false,
200
+ "required": [
201
+ "from",
202
+ "to",
203
+ "payload"
204
+ ],
205
+ "properties": {
206
+ "from": {
207
+ "type": "string",
208
+ "pattern": "^N-[0-9]{3,}$"
209
+ },
210
+ "to": {
211
+ "type": "string",
212
+ "pattern": "^N-[0-9]{3,}$"
213
+ },
214
+ "payload": {
215
+ "type": "string",
216
+ "minLength": 1,
217
+ "description": "What this edge carries. Required, and it is `references/planning.md`'s fake-edge test stated in the schema rather than remembered: an arrow whose payload nobody can name is not a dependency, it is a drawing. Naming it is also how the receiving node's brief gets written."
218
+ }
219
+ }
220
+ },
221
+ "revision": {
222
+ "type": "object",
223
+ "additionalProperties": false,
224
+ "required": [
225
+ "verb",
226
+ "node",
227
+ "why"
228
+ ],
229
+ "properties": {
230
+ "verb": {
231
+ "type": "string",
232
+ "enum": [
233
+ "add",
234
+ "park",
235
+ "close"
236
+ ],
237
+ "description": "Which mutation made this entry. `close` joined the two on 2026-08-17 with T-5 — and it was caught by a probe rather than by the fixture asserting *the graph after a close still validates*, because `violations()` never reaches an enum. The same disagreement B-084 records, one field over."
238
+ },
239
+ "node": {
240
+ "type": "string",
241
+ "pattern": "^N-[0-9]{3,}$",
242
+ "description": "The node the mutation touched."
243
+ },
244
+ "why": {
245
+ "type": "string",
246
+ "minLength": 1,
247
+ "pattern": "\\S",
248
+ "description": "The reason, written for a person reading it later. The non-whitespace pattern is required for the same reason `parked_reason` needs one: `minLength: 1` counts a space."
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
@@ -82,9 +82,10 @@
82
82
  "enum": [
83
83
  "module-map",
84
84
  "plan-tasks",
85
+ "work-graph",
85
86
  "none"
86
87
  ],
87
- "description": "What the loop walks. A loop with no queue is a timer: it says how often to continue and never what the next item is, so the run picks its next move by recollection. module-map = stage 2's decomposition (a platform); plan-tasks = stage 4's task list; none = the mode is armed for the stage boundaries only. Doctrine: references/continuity.md -> Part 1a."
88
+ "description": "What the loop walks. A loop with no queue is a timer: it says how often to continue and never what the next item is, so the run picks its next move by recollection. module-map = stage 2's decomposition (a platform); plan-tasks = stage 4's task list; none = the mode is armed for the stage boundaries only. Doctrine: references/continuity.md -> Part 1a. `work-graph` is `.task-pipeline/graph.json`, walked by `scripts/graph.py next`; doctrine: references/work-graph.md."
88
89
  },
89
90
  "arm": {
90
91
  "enum": [
@@ -145,6 +146,15 @@
145
146
  "minLength": 1
146
147
  },
147
148
  "description": "Post-release smoke checks that must pass after a release — this is the release's own post-deploy gate (stage 8 applied to shipping the package itself)."
149
+ },
150
+ "goal": {
151
+ "type": "string",
152
+ "minLength": 1,
153
+ "description": "What this release is for, in one sentence. Printed beside the queue on every loop iteration so drift from it is visible rather than remembered, and a node serving neither it nor a REQ is parked WITH THAT AS THE REASON. Declared here because the guard that validates this config could not otherwise see the field: `additionalProperties` is true, so an undeclared `goal` could be renamed away and every gate would stay green (found by the wave-2 convergence check)."
154
+ },
155
+ "_goal_note": {
156
+ "type": "string",
157
+ "description": "Free prose beside `goal`."
148
158
  }
149
159
  }
150
160
  },
@@ -195,16 +205,49 @@
195
205
  "type": {
196
206
  "enum": [
197
207
  "auto",
208
+ "judgment",
198
209
  "manual"
199
210
  ],
200
- "description": "auto = the orchestrator verifies `check` itself (pass/fail); manual = wait for the operator's explicit go."
211
+ "description": "auto = the orchestrator verifies `check` itself, pass/fail — a fact a MACHINE established. judgment = there is no complete deterministic check and someone rules on it; the ruling is recorded AS judgement and never as a measurement, and the gate must name its `judge`. manual = wait for the operator's explicit go. Two types were not enough: a reviewer's ruling, a check that scenarios are coherent and a verdict that a mockup is good all rode in `auto`, indistinguishable from an exit code — so a coverage table could not tell a measured row from an opinion."
201
212
  },
202
213
  "check": {
203
214
  "type": "string",
204
215
  "minLength": 1,
205
216
  "description": "The gate condition, in prose."
217
+ },
218
+ "judge": {
219
+ "type": "string",
220
+ "minLength": 1,
221
+ "pattern": "\\S",
222
+ "description": "Who or what renders the judgement — a role, an agent, a person. Required when `type` is `judgment`. Without it the ruling has no author, and a judgement whose author is unknown cannot be weighed against its independence: this pipeline's own `R-005` reader shares a model and a repository with the author it reviews, which is a different kind of evidence from a deterministic runner. Naming the judge is what makes that difference visible."
206
223
  }
207
- }
224
+ },
225
+ "allOf": [
226
+ {
227
+ "if": {
228
+ "properties": {
229
+ "type": {
230
+ "const": "judgment"
231
+ }
232
+ },
233
+ "required": [
234
+ "type"
235
+ ]
236
+ },
237
+ "then": {
238
+ "required": [
239
+ "judge"
240
+ ],
241
+ "properties": {
242
+ "judge": {
243
+ "type": "string",
244
+ "minLength": 1,
245
+ "pattern": "\\S"
246
+ }
247
+ }
248
+ }
249
+ }
250
+ ]
208
251
  }
209
252
  }
210
253
  },
@@ -22,6 +22,7 @@ requirement.** It is what turns the pipeline from a funnel into a circle.
22
22
  - A seam is not a deliverable, and REQ rows are written against deliverables
23
23
  - GATE (manual)
24
24
  - When the answer is "something's missing"
25
+ - A project of several repositories
25
26
 
26
27
  ## Why a stage and not a gate
27
28
 
@@ -187,6 +188,42 @@ A REQ whose evidence lives in an unpushed commit, or in a submodule the parent
187
188
  doesn't point at yet, is `partial` — the evidence is not reachable by anyone but
188
189
  you.
189
190
 
191
+ ### The pointer is not the path — and the pointer check alone was the whole gate
192
+
193
+ Everything above proves **commits**: the parent points at the child's newest one, every
194
+ repository is clean, nothing is unpushed. None of it proves anything *works* at those two
195
+ versions together. A parent can point at a green submodule whose contract the parent's own
196
+ code calls with the previous signature, and every check in this pipeline passes — the
197
+ child's suite ran against the child, the parent's against the parent, and **no check ran
198
+ across the pointer**. Neither repository looks wrong alone, which is why this survived
199
+ being written down twice.
200
+
201
+ So the criterion has a second half, and it fires **only where a component pointer moved in
202
+ the range being accepted**. A range that crossed no component boundary has no seam to
203
+ prove, and demanding a record for it is how a gate becomes noise.
204
+
205
+ Where one moved, the acceptance owes three things:
206
+
207
+ 1. **One named cross-component path** — a command, a test, a request, a scenario that
208
+ traverses both sides. Not each side's suite.
209
+ 2. **The exact versions it observed**, one per component: the commit the *parent pins*,
210
+ not whatever happens to be checked out. Those are the same fact only while they agree,
211
+ and they disagree in precisely the case this exists for.
212
+ 3. **The observation, to the same standard a single REQ meets** — the command and what it
213
+ printed, in `docs/evidence/convergence.md`.
214
+
215
+ `templates/convergence.sh` mechanises the pointer half and requires the record for the
216
+ seam half. It also checks the one thing `git submodule status` cannot see: **whether the
217
+ pinned commit is published at all.** Measured here on 2026-08-16 — a release tag failed CI
218
+ at checkout because the parent pinned a commit that existed only on one machine, and
219
+ `git submodule status` showed no `+` because the pointer matched the *local* head.
220
+
221
+ | Rationalization | Why it is wrong |
222
+ |---|---|
223
+ | *"Both suites are green, so the release is green."* | Each suite ran against one side. The defect lives in the seam, and no suite has an opinion about it |
224
+ | *"The pointer is at the newest commit, so it is current."* | Current and *published* are different facts. A clone gets what the remote has |
225
+ | *"I ran the cross-component check, it passed."* | Then say which versions it observed. A record citing no version cannot say which composition it proved |
226
+
190
227
  ## The closing question
191
228
 
192
229
  The table is preparation. The stage exists for the question that follows it, asked
@@ -361,3 +398,35 @@ or a tracked follow-up. Both are legitimate outcomes of this stage. Closing the
361
398
  run with a known gap is fine **if the gap is written down** — closing it with the
362
399
  gap only in someone's memory is the failure mode this whole spine exists to
363
400
  prevent.
401
+
402
+ ---
403
+
404
+ ## A project of several repositories
405
+
406
+ **A submodule is finished when its parent says so.** A parent repository records each submodule as
407
+ a pointer to one commit, and moving the submodule does not move the pointer. So the work is
408
+ committed, pushed, its CI is green and its own roadmap says done — and anyone who clones the parent
409
+ gets the commit **before** the change. Nothing looks wrong in either repository on its own; the
410
+ disagreement exists only between them, which is why it survives every check that runs inside one.
411
+
412
+ Stage 10 does not close until:
413
+
414
+ ```bash
415
+ git submodule status # no line begins with '+' (a '+' is the missing bump)
416
+ git -C <each repo> status --porcelain && git -C <each repo> log @{u}..HEAD --oneline
417
+ ```
418
+
419
+ report nothing — for the parent as well as every submodule. Where
420
+ [agent-sync](https://github.com/ssheleg/agent-sync) is installed, `/agent-sync finish` runs
421
+ exactly this plus *no lease left held*, and `--gates` adds the project's own gate commands.
422
+
423
+ The fix, when it fails, is two commands and the second is the one that gets forgotten:
424
+
425
+ ```bash
426
+ git -C <submodule> push
427
+ git add <submodule> && git commit -m "chore: bump <name> submodule — <why>"
428
+ ```
429
+
430
+ Moved out of `SKILL.md` on 2026-08-16: the body was 6685 tokens against a
431
+ < 5000 budget, and stage-10 close-out is what this file is for.
432
+
@@ -329,7 +329,7 @@ beside the verdict**:
329
329
  ```
330
330
  GATE 6 tests: PASS — full suite green (247 tests)
331
331
  carry-over: 4 open (was 6) · unresolved: 0 · audit findings deferred: 2
332
- abstained: 1 (1 cannot-verify) · unlooked: 2 dormant
332
+ abstained: 1 (1 cannot-verify) · unlooked: 2 dormant · holds: 1 (container: pg-test, this run)
333
333
  ```
334
334
 
335
335
  The difference from a TODO is not bookkeeping. A TODO is invisible until somebody
@@ -70,7 +70,15 @@ often* to continue and never said *what the next item is*, so the mode could be
70
70
  and still leave the run picking its next move by recollection — which is the failure
71
71
  [`learned.md`](learned.md) rule 16 is about, running once per fire.
72
72
 
73
- **The queue is the module map** ([`decomposition.md`](decomposition.md)) when the brief
73
+ **Where a work graph exists it is the queue** ([`work-graph.md`](work-graph.md)):
74
+ `.task-pipeline/graph.json`, walked by `scripts/graph.py next`, which prints the runnable
75
+ nodes and nothing else. It is preferred over the two below for one measured reason — a
76
+ 400-node graph and a 4-node graph produce the same 27-byte frontier, so the cost of knowing
77
+ what is next does not grow with the programme. `run.loop.queue: work-graph` records it, and
78
+ `next`'s exit codes are what the loop branches on: `3` is *every node is done*, `4` is
79
+ *what remains is blocked or parked*, and those are different endings.
80
+
81
+ **Otherwise the queue is the module map** ([`decomposition.md`](decomposition.md)) when the brief
74
82
  was a platform, and the plan's task list otherwise. Both already exist and both are
75
83
  already ordered; neither was ever named as the thing the loop walks.
76
84
 
@@ -89,6 +89,23 @@ side, so it never surfaces by comparison. Carry it as a named, counted set next
89
89
  verdict — a ratchet, never a TODO — so `PASS` reads as *"green, and here is what nobody
90
90
  looked at"*. → [`learned.md`](learned.md) rule 7; [`gates.md`](gates.md) → *Ratchets*.
91
91
 
92
+ **9a. A measured zero and an unmeasured quantity may not print the same.** Canon 9 says
93
+ carry the absence; this says **refuse the number** when nothing measured it. `0 of 34
94
+ files read` and *the recorder was never installed* are opposite facts, and a `0` claims
95
+ the first while meaning the second — the most reassuring answer available, derived from
96
+ an instrument nobody switched on. So the unmeasured case prints a word, and the word says
97
+ why it cannot be a number.
98
+
99
+ The rule is written down because it arrived three times under three names before anyone
100
+ named it: `references/knowledge-graph.md` → *State zero out loud* (a fresh graph must
101
+ still print `current, 0 commits behind`, or freshness is indistinguishable from a harvest
102
+ that never looked); `references/verification.md` → *Staleness* (`unanchored` and
103
+ `unresolvable` are states, not zeroes); and `scripts/graph.py` → `doctrine`, which prints
104
+ `unmeasured` and its reason rather than `0 of 34`, and `next`, which reports how many
105
+ runnable nodes declared no `touches` so that no collision is not read as no collision
106
+ found. Three sites, one rule, and the fourth will be written from this line rather than
107
+ from the same mistake.
108
+
92
109
  **10. The document ships in the change that made it true.** Not in the next ticket —
93
110
  documentation deferred is documentation that describes a system nobody is running. A
94
111
  correction is **appended**, never edited over: a register that is rewritten loses the