task-pipeline-skill 1.68.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.
- package/CHANGELOG.md +799 -0
- package/README.md +25 -0
- package/SKILL-CARD.md +1 -1
- package/bin/task-pipeline.js +30 -0
- package/package.json +4 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +88 -0
- package/plugins/task-pipeline/commands/task-pipeline.md +22 -0
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/graph.example.json +73 -0
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +253 -0
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +46 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +36 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +9 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +17 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +46 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +41 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +11 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +52 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +121 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1113 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/carryover.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/convergence.sh +146 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/exposure.sh +104 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/hooks.example.json +13 -1
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +32 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/verification.md +67 -5
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The work graph, walked by a script so the model never reads it.
|
|
3
|
+
|
|
4
|
+
`.task-pipeline/graph.json` is the queue. A graph for a real release is hundreds of
|
|
5
|
+
nodes; a model that re-reads it every iteration spends its context on ground it has
|
|
6
|
+
already walked, and the cost grows with the programme. This script answers one
|
|
7
|
+
question — *which nodes are runnable right now* — and prints the answer. What enters
|
|
8
|
+
a context each iteration is then bounded by the **frontier's width**, not by the
|
|
9
|
+
graph's size: four hundred nodes and four cost the same to walk.
|
|
10
|
+
|
|
11
|
+
That is the whole design rationale, and it is why `next` prints the frontier and
|
|
12
|
+
nothing else. Anything else printed there is paid on every turn of every loop.
|
|
13
|
+
|
|
14
|
+
**Stdlib only.** `references/portability.md`: `scripts/` is the one Claude-Code
|
|
15
|
+
capability that travels, because it lives inside the skill directory and every
|
|
16
|
+
channel ships it. A dependency here would make the graph Claude-Code-shaped.
|
|
17
|
+
|
|
18
|
+
**What this file checks, and what it deliberately does not.** `graph.schema.json`
|
|
19
|
+
states everything JSON Schema can — the required fields, `owner` non-empty, an edge's
|
|
20
|
+
payload, and `done` implying non-empty evidence. What a schema cannot reach is
|
|
21
|
+
cross-document and cross-node: whether an `owner` names a role that EXISTS, whether
|
|
22
|
+
`serves` resolves, and whether the edges cycle. Those three are here. The split is
|
|
23
|
+
where the format actually puts it, which is not where the first draft drew it.
|
|
24
|
+
|
|
25
|
+
Exit codes are the contract (standing instruction R-004 — the next command is
|
|
26
|
+
conditional on the code, never merely sequenced after it):
|
|
27
|
+
|
|
28
|
+
0 the verb succeeded; for `next`, runnable nodes were printed
|
|
29
|
+
1 a refusal — an invariant violated, a malformed verdict, a missing reason
|
|
30
|
+
2 usage
|
|
31
|
+
3 nothing left to do: every node is done
|
|
32
|
+
4 nothing runnable: what remains is blocked or parked
|
|
33
|
+
|
|
34
|
+
**The mutation verbs never leave a half-written queue.** `add` and `park` write to a
|
|
35
|
+
temp file in the same directory and `os.replace` it into place, so a crash mid-write
|
|
36
|
+
loses the mutation rather than the graph. Both refuse before writing rather than
|
|
37
|
+
writing and repairing: a refusal leaves the file byte-identical, which is what lets a
|
|
38
|
+
caller retry without first checking what the failed attempt did.
|
|
39
|
+
|
|
40
|
+
And both refuse outright on a graph that was **already** invalid, naming it as such.
|
|
41
|
+
A mutation that reports the pre-existing damage as though the caller's node caused it
|
|
42
|
+
sends the next fix to the wrong place.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
import sys
|
|
51
|
+
import tempfile
|
|
52
|
+
|
|
53
|
+
# Who may OWN a node — which is a different axis from who ships as a subagent.
|
|
54
|
+
#
|
|
55
|
+
# The brief closed the role set at thirteen and the first draft of this set held ten,
|
|
56
|
+
# because it silently conflated "is an agent" with "can own work". `manager` and
|
|
57
|
+
# `business-analyst` are main-thread doctrine precisely BECAUSE their job is talking
|
|
58
|
+
# to the operator, and that is still work a node can be owned by — the refusal message
|
|
59
|
+
# below even named the manager while this set rejected it.
|
|
60
|
+
#
|
|
61
|
+
# `project` is the one deliberate absence: the brief defers it for having no bounded
|
|
62
|
+
# job stated, and a role that cannot say what it does cannot own a node either.
|
|
63
|
+
#
|
|
64
|
+
# It is the ONE place the set lives; a second copy is what
|
|
65
|
+
# `references/documentation.md` calls a fact with two homes.
|
|
66
|
+
ROLES = {
|
|
67
|
+
# execution — references/build.md
|
|
68
|
+
"implementer", "reviewer", "fixer",
|
|
69
|
+
# main-thread doctrine: these own nodes, they just are not dispatched as agents,
|
|
70
|
+
# because a subagent cannot reach the operator and their job is to ask
|
|
71
|
+
"manager", "business-analyst",
|
|
72
|
+
# dispatched as agents — voluminous reading, small answer
|
|
73
|
+
"verifier", "decomposer", "ux", "ui", "researcher", "market-analyst", "bug-analyst",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
TERMINAL = {"done", "parked"}
|
|
77
|
+
NO_GRAPH = {"producer", "doctrine"}
|
|
78
|
+
# One place, and the schema enumerates the same three. Two homes for this set is
|
|
79
|
+
# what let `close` write a verb the format forbade.
|
|
80
|
+
REVISION_VERBS = {"add", "park", "close"}
|
|
81
|
+
# Parking a node PROMOTES its dependents: `parked` is terminal, so anything
|
|
82
|
+
# blocked on it becomes runnable even though the payload it waited on never
|
|
83
|
+
# arrived. That is deliberate — `can_continue_around` in the verdict is the
|
|
84
|
+
# same idea — but it is a real consequence of parking a blocker rather than a
|
|
85
|
+
# leaf, and it is written here because the frontier will not explain it.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def die(msg, code=1):
|
|
89
|
+
print(msg, file=sys.stderr)
|
|
90
|
+
sys.exit(code)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load(path):
|
|
94
|
+
if not os.path.isfile(path):
|
|
95
|
+
die(f"no graph at {path} — stage 2 writes it", 2)
|
|
96
|
+
try:
|
|
97
|
+
with open(path, encoding="utf-8") as fh:
|
|
98
|
+
return json.load(fh)
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
die(f"{path}: not readable as JSON — {e}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def shape(graph, path):
|
|
104
|
+
"""The graph is the shape the rest of this script assumes. Say so, do not crash.
|
|
105
|
+
|
|
106
|
+
`nodes` as an object map, `nodes: ["N-001"]`, a top-level list and a top-level
|
|
107
|
+
`null` each produced an `AttributeError` — a traceback carries the same exit code
|
|
108
|
+
as a documented refusal and none of its information.
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(graph, dict):
|
|
111
|
+
die("%s: the graph must be a JSON object; this is a %s"
|
|
112
|
+
% (path, type(graph).__name__))
|
|
113
|
+
for key in ("nodes", "edges"):
|
|
114
|
+
val = graph.get(key)
|
|
115
|
+
if val is not None and not isinstance(val, list):
|
|
116
|
+
die("%s: `%s` must be a list; this is a %s. An object map here is the shape "
|
|
117
|
+
"that makes every per-element check vacuous" % (path, key, type(val).__name__))
|
|
118
|
+
for i, item in enumerate(val or []):
|
|
119
|
+
if not isinstance(item, dict):
|
|
120
|
+
die("%s: %s[%d] is a %s, not an object"
|
|
121
|
+
% (path, key, i, type(item).__name__))
|
|
122
|
+
return graph
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def save(path, graph):
|
|
126
|
+
"""Write via a temp file in the same directory, then `os.replace`.
|
|
127
|
+
|
|
128
|
+
`os.replace` is atomic on the same filesystem, so a reader either sees the old
|
|
129
|
+
graph or the new one and never a truncated one. Writing in place is the version
|
|
130
|
+
that costs a queue: this repository has destroyed a file that way twice, and both
|
|
131
|
+
times what saved it was a copy somebody had made by hand.
|
|
132
|
+
"""
|
|
133
|
+
# A FIXED temp name (`path + ".tmp"`) was the first draft, and the R-005 reader
|
|
134
|
+
# measured what it costs: two concurrent `add`s share one inode, `os.replace`
|
|
135
|
+
# installs whichever bytes were last in it, and the exit codes then lie in BOTH
|
|
136
|
+
# directions — a run exiting 0 whose node is absent, and a run exiting 1 whose node
|
|
137
|
+
# is present. The second is the dangerous one: the docstring promises a refusal
|
|
138
|
+
# leaves the file untouched, so a caller retries and double-adds. A unique temp per
|
|
139
|
+
# writer costs one call and removes the shared inode entirely.
|
|
140
|
+
#
|
|
141
|
+
# `realpath` first: `os.replace` replaces the LINK, not its target, so a graph that
|
|
142
|
+
# is a symlink into a shared directory would silently fork into two queues.
|
|
143
|
+
path = os.path.realpath(path)
|
|
144
|
+
d = os.path.dirname(path) or "."
|
|
145
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".graph-", suffix=".tmp")
|
|
146
|
+
try:
|
|
147
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
148
|
+
json.dump(graph, fh, indent=2, ensure_ascii=False)
|
|
149
|
+
fh.write("\n")
|
|
150
|
+
fh.flush()
|
|
151
|
+
os.fsync(fh.fileno())
|
|
152
|
+
os.replace(tmp, path)
|
|
153
|
+
except OSError as e:
|
|
154
|
+
# A traceback is indistinguishable from a documented refusal by exit code, and
|
|
155
|
+
# this one is reachable from an ordinary read-only directory.
|
|
156
|
+
try:
|
|
157
|
+
os.unlink(tmp)
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
die("could not write %s — nothing was changed: %s" % (path, e))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- the three a schema cannot reach ------------------------------------------
|
|
164
|
+
|
|
165
|
+
def violations(graph):
|
|
166
|
+
"""Every cross-node and cross-document violation, in a stable order.
|
|
167
|
+
|
|
168
|
+
All of them, not the first: a caller that fixes one and re-runs to find the next
|
|
169
|
+
is a caller doing the loop this function exists to do once.
|
|
170
|
+
"""
|
|
171
|
+
out = []
|
|
172
|
+
nodes = graph.get("nodes") or []
|
|
173
|
+
ids = [n.get("id") for n in nodes]
|
|
174
|
+
known = set(ids)
|
|
175
|
+
|
|
176
|
+
seen = set()
|
|
177
|
+
for i in ids:
|
|
178
|
+
if i in seen:
|
|
179
|
+
out.append(f"duplicate node id {i} — two nodes with one id is a graph "
|
|
180
|
+
"nobody can cite, and a verdict naming it would close the wrong one")
|
|
181
|
+
seen.add(i)
|
|
182
|
+
|
|
183
|
+
declared = set(graph.get("requirements") or []) | set(graph.get("goal_clauses") or [])
|
|
184
|
+
if not (graph.get("requirements") or []):
|
|
185
|
+
out.append("the graph declares no `requirements` — `serves` then resolves against "
|
|
186
|
+
"nothing, and that field is the one edge joining the intent graph to "
|
|
187
|
+
"this one. Copy the REQ ids the brief froze")
|
|
188
|
+
|
|
189
|
+
if not (graph.get("goal") or "").strip():
|
|
190
|
+
out.append("the graph has no `goal` — every iteration is supposed to print it "
|
|
191
|
+
"above the frontier, and a queue that cannot say what it serves is a "
|
|
192
|
+
"queue nothing can be parked against")
|
|
193
|
+
|
|
194
|
+
for n in nodes:
|
|
195
|
+
nid = n.get("id")
|
|
196
|
+
if not isinstance(nid, str) or not ID_SHAPE.match(nid):
|
|
197
|
+
out.append(f"node id {nid!r} is not the shape `N-001` the schema requires — "
|
|
198
|
+
"and an id nothing can parse is an id no verdict can cite")
|
|
199
|
+
owner = n.get("owner")
|
|
200
|
+
if owner not in ROLES:
|
|
201
|
+
near = [r for r in sorted(ROLES) if r[:4] == (owner or "")[:4]]
|
|
202
|
+
hint = f" — did you mean {near[0]}?" if near else ""
|
|
203
|
+
out.append(f"{nid}: owner {owner!r} is not a role this pipeline "
|
|
204
|
+
f"ships{hint}. A node nobody can dispatch never leaves the frontier, "
|
|
205
|
+
"and nothing says why")
|
|
206
|
+
|
|
207
|
+
# `graph.schema.json` states the four rules below, and until the R-005 read
|
|
208
|
+
# nothing applied that schema to a LIVE graph — only to the shipped example, at
|
|
209
|
+
# build time. So both `done → evidence` and `parked → reason` rested entirely on
|
|
210
|
+
# the scripts behaving, which is exactly what the validator's own message about
|
|
211
|
+
# them claimed was no longer true. It is true here, where the run actually looks.
|
|
212
|
+
blockers = n.get("blocked_by") or []
|
|
213
|
+
if len(set(blockers)) != len(blockers):
|
|
214
|
+
dupes = sorted({b for b in blockers if blockers.count(b) > 1})
|
|
215
|
+
out.append(f"{nid}: blocked_by repeats {', '.join(dupes)} — the schema calls "
|
|
216
|
+
"the list unique, and a dependency counted twice is one nobody "
|
|
217
|
+
"removes on the first pass")
|
|
218
|
+
for b in blockers:
|
|
219
|
+
if b not in known:
|
|
220
|
+
out.append(f"{nid}: blocked_by names {b}, which is not in this graph")
|
|
221
|
+
|
|
222
|
+
for field in ("title", "serves"):
|
|
223
|
+
val = n.get(field)
|
|
224
|
+
if not isinstance(val, str) or not val.strip():
|
|
225
|
+
out.append(f"{nid}: `{field}` is {val!r}. The schema requires a non-empty "
|
|
226
|
+
"string and never ran against a live graph, so this passed — a "
|
|
227
|
+
"node with no title is a frontier row nobody can act on, and a "
|
|
228
|
+
"node serving nothing is REQ-012's park case")
|
|
229
|
+
elif any(c in val for c in "\n\r"):
|
|
230
|
+
out.append(f"{nid}: `{field}` contains a line break. `next` prints one row "
|
|
231
|
+
"per node and the loop reads those rows, so a break here forges "
|
|
232
|
+
"a row for a node that does not exist")
|
|
233
|
+
elif field == "serves" and declared and val not in declared:
|
|
234
|
+
near = [d for d in sorted(declared) if d[:5] == val[:5]]
|
|
235
|
+
hint = f" — did you mean {near[0]}?" if near else ""
|
|
236
|
+
out.append(f"{nid}: serves {val!r}, which is neither a declared requirement "
|
|
237
|
+
f"nor a declared goal clause{hint}. A node serving something "
|
|
238
|
+
"nobody asked for is work the brief cannot account for, and the "
|
|
239
|
+
"coverage relation cannot reach it")
|
|
240
|
+
|
|
241
|
+
if n.get("status") == "done":
|
|
242
|
+
ev = n.get("evidence")
|
|
243
|
+
if not isinstance(ev, list) or not [e for e in ev
|
|
244
|
+
if isinstance(e, str) and e.strip()]:
|
|
245
|
+
out.append(f"{nid}: status is done and `evidence` carries nothing readable "
|
|
246
|
+
"— a node called done by assertion is what evidence exists for")
|
|
247
|
+
if n.get("status") == "parked":
|
|
248
|
+
# `isinstance` rather than truthiness: `null` is the shape a schema whose
|
|
249
|
+
# `parked_reason` was typed nullable would let through, and it satisfies
|
|
250
|
+
# every string-only assertion vacuously.
|
|
251
|
+
reason = n.get("parked_reason")
|
|
252
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
253
|
+
out.append(f"{nid}: status is parked and `parked_reason` is {reason!r} — "
|
|
254
|
+
"REQ-012: a park without a reason is indistinguishable from "
|
|
255
|
+
"work that was quietly dropped")
|
|
256
|
+
|
|
257
|
+
# An edge with no payload, and a dependency with no edge. The graph stored the same
|
|
258
|
+
# fact in two unlinked places — `blocked_by`, which `frontier()` obeys, and `edges`,
|
|
259
|
+
# which carries the payload and which nothing read past a from/to existence check.
|
|
260
|
+
# So `references/planning.md`'s fake-edge test was stated for the markdown plan and
|
|
261
|
+
# unenforceable on the artifact that replaced it. Found by a four-way audit,
|
|
262
|
+
# 2026-08-17, which measured this pipeline's own `add` producing one every run.
|
|
263
|
+
carried = {}
|
|
264
|
+
for e in graph.get("edges") or []:
|
|
265
|
+
for end in ("from", "to"):
|
|
266
|
+
if e.get(end) not in known:
|
|
267
|
+
out.append(f"edge {e.get('from')}→{e.get('to')}: {end} names "
|
|
268
|
+
f"{e.get(end)}, which is not in this graph")
|
|
269
|
+
pay = e.get("payload")
|
|
270
|
+
if not isinstance(pay, str) or not pay.strip():
|
|
271
|
+
out.append(f"edge {e.get('from')}→{e.get('to')}: `payload` is {pay!r}. An edge "
|
|
272
|
+
"carrying no named artifact is chronology drawn as architecture — "
|
|
273
|
+
"`references/planning.md`'s fake-edge test, on the graph rather than "
|
|
274
|
+
"on the plan")
|
|
275
|
+
else:
|
|
276
|
+
carried[(e.get("from"), e.get("to"))] = pay
|
|
277
|
+
|
|
278
|
+
for n in nodes:
|
|
279
|
+
for b in n.get("blocked_by") or []:
|
|
280
|
+
if b in known and (b, n.get("id")) not in carried:
|
|
281
|
+
out.append(f"{n.get('id')}: blocked_by names {b} and no edge {b}→"
|
|
282
|
+
f"{n.get('id')} carries a payload. The dependency the frontier "
|
|
283
|
+
"obeys and the payload that justifies it are separate fields, "
|
|
284
|
+
"and a dependency handing over nothing is the one this check "
|
|
285
|
+
"exists to refuse")
|
|
286
|
+
|
|
287
|
+
for i, r in enumerate(graph.get("revisions") or []):
|
|
288
|
+
if not isinstance(r, dict):
|
|
289
|
+
out.append(f"revisions[{i}] is not an object")
|
|
290
|
+
continue
|
|
291
|
+
if r.get("verb") not in REVISION_VERBS:
|
|
292
|
+
out.append(f"revisions[{i}] records verb {r.get('verb')!r}, which is not one of "
|
|
293
|
+
f"{sorted(REVISION_VERBS)} — the schema enumerates them and "
|
|
294
|
+
"`violations()` never reached the enum, so `close` wrote a revision "
|
|
295
|
+
"its own shipped schema rejected (found by a probe, not by the "
|
|
296
|
+
"fixture that asserts the graph still validates)")
|
|
297
|
+
if not isinstance(r.get("why"), str) or not r["why"].strip():
|
|
298
|
+
out.append(f"revisions[{i}] records {r.get('verb')} on {r.get('node')} with "
|
|
299
|
+
f"`why` = {r.get('why')!r} — a revision log whose reasons are blank "
|
|
300
|
+
"is the log's own failure mode, not a record")
|
|
301
|
+
|
|
302
|
+
out.extend(cycles(nodes))
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cycles(nodes):
|
|
307
|
+
"""Cycles, named by the nodes in them.
|
|
308
|
+
|
|
309
|
+
A cycle is why a frontier can be non-empty forever while nothing is runnable —
|
|
310
|
+
the one failure of this design that looks exactly like slow progress.
|
|
311
|
+
"""
|
|
312
|
+
dep = {n.get("id"): [b for b in (n.get("blocked_by") or [])] for n in nodes}
|
|
313
|
+
found, state = [], {}
|
|
314
|
+
|
|
315
|
+
def walk(nid, stack):
|
|
316
|
+
if state.get(nid) == "done":
|
|
317
|
+
return
|
|
318
|
+
if state.get(nid) == "open":
|
|
319
|
+
ring = stack[stack.index(nid):]
|
|
320
|
+
found.append("cycle: " + " → ".join(ring + [nid]))
|
|
321
|
+
return
|
|
322
|
+
state[nid] = "open"
|
|
323
|
+
for nxt in dep.get(nid, []):
|
|
324
|
+
if nxt in dep:
|
|
325
|
+
walk(nxt, stack + [nid])
|
|
326
|
+
state[nid] = "done"
|
|
327
|
+
|
|
328
|
+
for nid in dep:
|
|
329
|
+
walk(nid, [])
|
|
330
|
+
# One ring reports once however many entry points reach it.
|
|
331
|
+
return sorted(set(found))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def unblocks(nodes):
|
|
335
|
+
"""How many nodes each node stands in front of, transitively.
|
|
336
|
+
|
|
337
|
+
This is the priority, and it is COMPUTED rather than declared. A `priority` field
|
|
338
|
+
would be a number somebody typed once and nobody revisits; this one moves on its
|
|
339
|
+
own when the graph does, which is what REQ-011 means by *re-prioritised after every
|
|
340
|
+
task*. Add a node that waits on N-002 and N-002 rises — no re-ranking pass, no
|
|
341
|
+
field to forget to update.
|
|
342
|
+
"""
|
|
343
|
+
dependents = {}
|
|
344
|
+
for n in nodes:
|
|
345
|
+
for b in n.get("blocked_by") or []:
|
|
346
|
+
dependents.setdefault(b, set()).add(n.get("id"))
|
|
347
|
+
|
|
348
|
+
def reach(nid):
|
|
349
|
+
seen, stack = set(), [nid]
|
|
350
|
+
while stack:
|
|
351
|
+
for d in dependents.get(stack.pop(), ()):
|
|
352
|
+
if d not in seen:
|
|
353
|
+
seen.add(d)
|
|
354
|
+
stack.append(d)
|
|
355
|
+
return seen
|
|
356
|
+
|
|
357
|
+
return {n.get("id"): len(reach(n.get("id"))) for n in nodes}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def collisions(ready):
|
|
361
|
+
"""Pairs of simultaneously-runnable nodes that mutate the same thing — B-093.
|
|
362
|
+
|
|
363
|
+
Only among nodes that are ready TOGETHER: a pair where one waits on the other never
|
|
364
|
+
holds the target at once, and reporting it would be a warning nobody can act on, which
|
|
365
|
+
is how a warning becomes noise.
|
|
366
|
+
|
|
367
|
+
Returns (pairs, undeclared) — and the second is why this function returns two things.
|
|
368
|
+
A frontier whose nodes declare no `touches` produces no pairs, which looks exactly like
|
|
369
|
+
a frontier that was checked and found clean. So the count of nodes that said nothing is
|
|
370
|
+
reported beside the pairs, for the same reason `doctrine` refuses to print `0`.
|
|
371
|
+
"""
|
|
372
|
+
out, undeclared = [], []
|
|
373
|
+
for n in ready:
|
|
374
|
+
if not (n.get("touches") or []):
|
|
375
|
+
undeclared.append(n.get("id"))
|
|
376
|
+
for i, a in enumerate(ready):
|
|
377
|
+
for b in ready[i + 1:]:
|
|
378
|
+
shared = sorted(set(a.get("touches") or []) & set(b.get("touches") or []))
|
|
379
|
+
if shared:
|
|
380
|
+
out.append((a.get("id"), b.get("id"), shared))
|
|
381
|
+
return out, undeclared
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def frontier(graph):
|
|
385
|
+
nodes = graph.get("nodes") or []
|
|
386
|
+
by_id = {n.get("id"): n for n in nodes}
|
|
387
|
+
ready = []
|
|
388
|
+
for n in nodes:
|
|
389
|
+
if n.get("status") in TERMINAL or n.get("status") == "running":
|
|
390
|
+
continue
|
|
391
|
+
blockers = n.get("blocked_by") or []
|
|
392
|
+
if all(by_id.get(b, {}).get("status") in TERMINAL for b in blockers):
|
|
393
|
+
ready.append(n)
|
|
394
|
+
rank = unblocks(nodes)
|
|
395
|
+
order = {n.get("id"): i for i, n in enumerate(nodes)}
|
|
396
|
+
# Declaration order breaks the tie, so the frontier is stable across runs. An
|
|
397
|
+
# unstable order costs more than it looks: an agent that re-reads `next` between
|
|
398
|
+
# two ties gets a different first row and starts the other one.
|
|
399
|
+
ready.sort(key=lambda n: (-rank.get(n.get("id"), 0), order.get(n.get("id"), 0)))
|
|
400
|
+
return ready
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --- the verdict ---------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
VERDICT_KEYS = ("node", "done", "not_done", "not_verified", "blockers", "replan", "evidence")
|
|
406
|
+
NODE_ID = "N-"
|
|
407
|
+
ID_SHAPE = re.compile(r"^N-[0-9]{3,}$")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def verdict_violations(v):
|
|
411
|
+
"""Everything wrong with a verdict, in a stable order.
|
|
412
|
+
|
|
413
|
+
The verifier's output is the one artifact in this design a human does not read
|
|
414
|
+
before it acts: `close` consumes it and the graph moves. So the shape is checked
|
|
415
|
+
rather than trusted, and every refusal names the key, because a verdict rejected
|
|
416
|
+
without naming its fault is a verdict the next attempt reproduces.
|
|
417
|
+
|
|
418
|
+
The rule that matters most is the smallest: **a `done` claim with no evidence is
|
|
419
|
+
refused.** Everything else here is structure; that one is the difference between
|
|
420
|
+
a node that was verified and a node that was asserted.
|
|
421
|
+
"""
|
|
422
|
+
out = []
|
|
423
|
+
if not isinstance(v, dict):
|
|
424
|
+
return ["verdict is not an object"]
|
|
425
|
+
|
|
426
|
+
for k in VERDICT_KEYS:
|
|
427
|
+
if k not in v:
|
|
428
|
+
out.append(f"verdict has no `{k}` — all seven are required, because a "
|
|
429
|
+
"verdict that omits one is silent about it rather than clear")
|
|
430
|
+
if out:
|
|
431
|
+
return out
|
|
432
|
+
|
|
433
|
+
if not isinstance(v["node"], str) or not v["node"].startswith(NODE_ID):
|
|
434
|
+
out.append(f"verdict `node` is {v['node']!r}, which is not a node id")
|
|
435
|
+
|
|
436
|
+
for k in ("done", "not_done", "not_verified", "evidence"):
|
|
437
|
+
if not isinstance(v[k], list):
|
|
438
|
+
out.append(f"verdict `{k}` must be a list")
|
|
439
|
+
|
|
440
|
+
if isinstance(v.get("done"), list) and isinstance(v.get("evidence"), list):
|
|
441
|
+
if v["done"] and not v["evidence"]:
|
|
442
|
+
out.append("verdict claims `done` with empty `evidence` — the field exists "
|
|
443
|
+
"for exactly this, and a node closed on an unevidenced claim is "
|
|
444
|
+
"the thing the whole ledger is built to prevent")
|
|
445
|
+
# And each entry must be a non-empty string, because `graph.schema.json`
|
|
446
|
+
# requires exactly that of the node this verdict closes. The first draft
|
|
447
|
+
# checked only that the list was non-empty — so `['', ' ']` passed here and
|
|
448
|
+
# was refused by the schema, and `close` would have written a node its own
|
|
449
|
+
# shipped schema rejects. That is the same class the schema's own prose
|
|
450
|
+
# records fixing one level up: a rule stated about the container while the
|
|
451
|
+
# contents go unchecked. Found by the wave-2 convergence check, not by the
|
|
452
|
+
# per-task review that had already read this function.
|
|
453
|
+
for i, e in enumerate(v["evidence"]):
|
|
454
|
+
if not isinstance(e, str) or not e.strip():
|
|
455
|
+
out.append(f"verdict `evidence[{i}]` is {e!r} — every entry must be a "
|
|
456
|
+
"non-empty string, and a list of blanks is the shape a script "
|
|
457
|
+
"emitting an empty command output produces")
|
|
458
|
+
|
|
459
|
+
for b in v.get("blockers") or []:
|
|
460
|
+
if not isinstance(b, dict) or "what" not in b:
|
|
461
|
+
out.append("a blocker must say `what` it is")
|
|
462
|
+
continue
|
|
463
|
+
if "blocks" not in b or "can_continue_around" not in b:
|
|
464
|
+
out.append(f"blocker {b.get('what')!r} does not say what it `blocks` and "
|
|
465
|
+
"whether the run `can_continue_around` it — without both, the "
|
|
466
|
+
"manager cannot tell a pause from a stop")
|
|
467
|
+
|
|
468
|
+
rp = v.get("replan")
|
|
469
|
+
if not isinstance(rp, dict):
|
|
470
|
+
out.append("verdict `replan` must be an object")
|
|
471
|
+
else:
|
|
472
|
+
if "possible" not in rp:
|
|
473
|
+
out.append("verdict `replan` does not say whether a re-plan is `possible`")
|
|
474
|
+
elif rp.get("possible") is False and not (rp.get("why") or "").strip():
|
|
475
|
+
out.append("verdict says a re-plan is not possible and gives no `why` — a "
|
|
476
|
+
"stop with no reason is indistinguishable from a stall")
|
|
477
|
+
for nid in rp.get("park") or []:
|
|
478
|
+
if not isinstance(nid, str) or not nid.startswith(NODE_ID):
|
|
479
|
+
out.append(f"replan.park names {nid!r}, which is not a node id")
|
|
480
|
+
for nid in rp.get("add") or []:
|
|
481
|
+
if not isinstance(nid, dict) or "title" not in nid:
|
|
482
|
+
out.append("replan.add entries must be nodes with at least a `title`")
|
|
483
|
+
return out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# --- verbs --------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def cmd_validate(graph, args):
|
|
489
|
+
bad = violations(graph)
|
|
490
|
+
for line in bad:
|
|
491
|
+
print(line, file=sys.stderr)
|
|
492
|
+
return 1 if bad else 0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def cmd_next(graph, args):
|
|
496
|
+
if violations(graph):
|
|
497
|
+
die("graph does not validate — run `validate` first", 1)
|
|
498
|
+
nodes = graph.get("nodes") or []
|
|
499
|
+
if nodes and all(n.get("status") in TERMINAL for n in nodes):
|
|
500
|
+
return 3
|
|
501
|
+
ready = frontier(graph)
|
|
502
|
+
if not ready:
|
|
503
|
+
return 4
|
|
504
|
+
# The frontier and nothing else. This is the line that enters a context on
|
|
505
|
+
# every iteration of every loop, so every word here is paid for repeatedly.
|
|
506
|
+
for n in ready:
|
|
507
|
+
print(f"{n['id']} {n['owner']} {n['title']}")
|
|
508
|
+
|
|
509
|
+
# On stderr, always. The frontier rows are the one line paid for on every iteration of
|
|
510
|
+
# every loop, and a warning inside them would be paid for the same way — and read as a
|
|
511
|
+
# node by anything parsing rows.
|
|
512
|
+
pairs, undeclared = collisions(ready)
|
|
513
|
+
for a, b, shared in pairs:
|
|
514
|
+
print(f"collision: {a} and {b} are both runnable and both mutate "
|
|
515
|
+
f"{', '.join(shared)} — dispatching them together is the false parallelism "
|
|
516
|
+
"`references/planning.md` refuses: distinct is not independent, and the check "
|
|
517
|
+
"is what they touch", file=sys.stderr)
|
|
518
|
+
if undeclared:
|
|
519
|
+
print(f"undeclared: {len(undeclared)} of {len(ready)} runnable node(s) declare no "
|
|
520
|
+
f"`touches` ({', '.join(undeclared[:6])}) — a frontier nobody described cannot "
|
|
521
|
+
"be checked for collisions, and no warning here is not the same as no "
|
|
522
|
+
"collision", file=sys.stderr)
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def cmd_goal(graph, args):
|
|
527
|
+
goal = (graph.get("goal") or "").strip()
|
|
528
|
+
if not goal:
|
|
529
|
+
return 3
|
|
530
|
+
print(goal)
|
|
531
|
+
return 0
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class held:
|
|
535
|
+
"""An exclusive lock around a mutation's whole read-modify-write.
|
|
536
|
+
|
|
537
|
+
A unique temp file fixes CORRUPTION and the exit-1-with-the-node-present lie. It
|
|
538
|
+
does not fix the lost update: two processes read the same graph, both append, and
|
|
539
|
+
the second write drops the first node while both exit 0. Measured with four
|
|
540
|
+
concurrent `add`s — 40001 nodes where 40004 had been added.
|
|
541
|
+
|
|
542
|
+
That is not a theoretical concurrency: this programme is explicitly built for
|
|
543
|
+
several agents walking one graph, so the queue has to survive two of them arriving
|
|
544
|
+
at once. `flock` releases when the process dies, which is why it is preferred over a
|
|
545
|
+
lock FILE somebody has to clean up after a crash.
|
|
546
|
+
|
|
547
|
+
Where `fcntl` does not exist the mutation still runs and the run is TOLD it ran
|
|
548
|
+
unlocked — a silent downgrade is how a queue loses a node and nobody learns why.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __init__(self, path):
|
|
552
|
+
self.path = os.path.realpath(path)
|
|
553
|
+
self.fh = None
|
|
554
|
+
|
|
555
|
+
def __enter__(self):
|
|
556
|
+
try:
|
|
557
|
+
import fcntl
|
|
558
|
+
except ImportError:
|
|
559
|
+
print("note: no file locking on this platform — a concurrent mutation of "
|
|
560
|
+
"%s could lose an update" % self.path, file=sys.stderr)
|
|
561
|
+
return self
|
|
562
|
+
d = os.path.dirname(self.path) or "."
|
|
563
|
+
try:
|
|
564
|
+
self.fh = open(os.path.join(d, ".graph.lock"), "a+")
|
|
565
|
+
fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX)
|
|
566
|
+
except OSError as e:
|
|
567
|
+
if self.fh:
|
|
568
|
+
self.fh.close()
|
|
569
|
+
self.fh = None
|
|
570
|
+
print("note: could not take the graph lock (%s) — proceeding unlocked" % e,
|
|
571
|
+
file=sys.stderr)
|
|
572
|
+
return self
|
|
573
|
+
|
|
574
|
+
def __exit__(self, *exc):
|
|
575
|
+
if self.fh:
|
|
576
|
+
self.fh.close()
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def revise(graph, verb, node, why):
|
|
581
|
+
"""Append the revision. Both verbs call it; neither may skip it.
|
|
582
|
+
|
|
583
|
+
`park` demanded a reason from the start and `add` demanded nothing, so half the
|
|
584
|
+
graph's revision surface was silent — and a graph that changed for reasons nobody
|
|
585
|
+
recorded can always explain its own completion by appealing to a plan that existed
|
|
586
|
+
only at the end.
|
|
587
|
+
"""
|
|
588
|
+
graph.setdefault("revisions", []).append(
|
|
589
|
+
{"verb": verb, "node": node, "why": why})
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def guard(graph, path):
|
|
593
|
+
"""Refuse a mutation on a graph that was already invalid, and SAY it was already.
|
|
594
|
+
|
|
595
|
+
Without this a caller adding a well-formed node to a broken graph reads the
|
|
596
|
+
breakage as their own — and goes looking in the one place the fault is not.
|
|
597
|
+
"""
|
|
598
|
+
bad = violations(graph)
|
|
599
|
+
if bad:
|
|
600
|
+
die("%s was ALREADY invalid before this mutation — nothing was written. Fix "
|
|
601
|
+
"these first:\n %s" % (path, "\n ".join(bad)))
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def cmd_park(graph, args):
|
|
605
|
+
"""Park one node, carrying the reason — REQ-012.
|
|
606
|
+
|
|
607
|
+
Parking is not a soft delete. The reason is the artifact: a node parked without one
|
|
608
|
+
is indistinguishable next week from work that was quietly dropped, which is the
|
|
609
|
+
exact failure parking exists instead of.
|
|
610
|
+
"""
|
|
611
|
+
guard(graph, args.graph)
|
|
612
|
+
reason = (args.reason or "").strip()
|
|
613
|
+
if any(c in reason for c in "\n\r"):
|
|
614
|
+
die("--reason contains a line break, and a reason is read back beside the node "
|
|
615
|
+
"it explains — nothing was written")
|
|
616
|
+
if not reason:
|
|
617
|
+
die("`park` needs a --reason with something in it. The flag being present is "
|
|
618
|
+
"not a reason being given, and an empty one parks the node while recording "
|
|
619
|
+
"nothing about why — which is the shape parking exists to prevent")
|
|
620
|
+
|
|
621
|
+
node = next((n for n in graph.get("nodes") or [] if n.get("id") == args.node), None)
|
|
622
|
+
if node is None:
|
|
623
|
+
die("no node %s in this graph — nothing was written" % args.node)
|
|
624
|
+
if node.get("status") == "done":
|
|
625
|
+
die("%s is done — parking it would overwrite a closed result and its evidence. "
|
|
626
|
+
"If the close was wrong, say so in a verdict rather than here" % args.node)
|
|
627
|
+
if node.get("status") == "parked":
|
|
628
|
+
die("%s is already parked, and the reason recorded is: %r. A second park would "
|
|
629
|
+
"replace it, and the first reason is the one somebody wrote at the time"
|
|
630
|
+
% (args.node, node.get("parked_reason")))
|
|
631
|
+
|
|
632
|
+
node["status"] = "parked"
|
|
633
|
+
node["parked_reason"] = reason
|
|
634
|
+
revise(graph, "park", args.node, reason)
|
|
635
|
+
bad = violations(graph)
|
|
636
|
+
if bad:
|
|
637
|
+
die("parking %s would break the graph — nothing was written:\n %s"
|
|
638
|
+
% (args.node, "\n ".join(bad)))
|
|
639
|
+
save(args.graph, graph)
|
|
640
|
+
print("%s parked: %s" % (args.node, reason))
|
|
641
|
+
return 0
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def next_id(known):
|
|
645
|
+
"""The next id, from the MAXIMUM rather than from the count.
|
|
646
|
+
|
|
647
|
+
Ids stop being contiguous the first time anything is renumbered or imported, and
|
|
648
|
+
from that moment counting hands out one that already exists — the duplicate
|
|
649
|
+
`validate` reports as a graph nobody can cite.
|
|
650
|
+
"""
|
|
651
|
+
used = [int(i[2:]) for i in known if i and i.startswith(NODE_ID) and i[2:].isdigit()]
|
|
652
|
+
return "N-%03d" % ((max(used) + 1) if used else 1)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def cmd_add(graph, args):
|
|
656
|
+
"""Add a node mid-flight — the dynamic backlog, REQ-011.
|
|
657
|
+
|
|
658
|
+
Everything is checked BEFORE the append, so a refusal leaves the file untouched and
|
|
659
|
+
the caller can retry without first working out what the failed attempt did.
|
|
660
|
+
"""
|
|
661
|
+
guard(graph, args.graph)
|
|
662
|
+
title = (args.title or "").strip()
|
|
663
|
+
serves = (args.serves or "").strip()
|
|
664
|
+
if not title:
|
|
665
|
+
die("`add` needs a --title with something in it")
|
|
666
|
+
if not serves:
|
|
667
|
+
die("`add` needs --serves: the REQ or goal clause this node exists for. A node "
|
|
668
|
+
"that serves nothing is not a node to add — it is REQ-012's park case, and "
|
|
669
|
+
"adding it hides the decision that ought to be recorded")
|
|
670
|
+
declared = set(graph.get("requirements") or []) | set(graph.get("goal_clauses") or [])
|
|
671
|
+
if declared and serves not in declared:
|
|
672
|
+
die("--serves %r is neither a declared requirement nor a declared goal clause. The "
|
|
673
|
+
"REQ table is frozen at stage 0 — adding to it is free and the BRIEF does it, "
|
|
674
|
+
"not a node. Amend the brief and the graph's `requirements`, or serve one of: "
|
|
675
|
+
"%s. Nothing was written" % (serves, ", ".join(sorted(declared)[:6])))
|
|
676
|
+
|
|
677
|
+
if args.owner not in ROLES:
|
|
678
|
+
near = [r for r in sorted(ROLES) if r[:4] == (args.owner or "")[:4]]
|
|
679
|
+
die("owner %r is not a role this pipeline ships%s — nothing was written"
|
|
680
|
+
% (args.owner, (" — did you mean %s?" % near[0]) if near else ""))
|
|
681
|
+
|
|
682
|
+
for name, val in (("--title", title), ("--serves", serves)):
|
|
683
|
+
if any(c in val for c in "\n\r"):
|
|
684
|
+
die("%s contains a line break. `next` prints one row per node and the loop "
|
|
685
|
+
"reads those rows, so a break here forges a row for a node that does not "
|
|
686
|
+
"exist — nothing was written" % name)
|
|
687
|
+
|
|
688
|
+
nodes = graph.setdefault("nodes", [])
|
|
689
|
+
known = {n.get("id") for n in nodes}
|
|
690
|
+
# Dedupe rather than refuse: `argparse action="append"` makes a repeat trivially easy
|
|
691
|
+
# to type, the intent is unambiguous, and the schema calls the list unique — so the
|
|
692
|
+
# repair is exact. A hand-written graph with the same repeat is caught by `violations`.
|
|
693
|
+
why = (args.why or "").strip()
|
|
694
|
+
if not why:
|
|
695
|
+
die("`add` needs a --why with something in it: a node appearing mid-run is a "
|
|
696
|
+
"revision of the plan, and a revision nobody recorded a reason for is how a "
|
|
697
|
+
"run explains its own completion by a plan that existed only at the end")
|
|
698
|
+
if any(c in why for c in "\n\r"):
|
|
699
|
+
die("--why contains a line break — nothing was written")
|
|
700
|
+
|
|
701
|
+
# `dict.fromkeys` for the dependency, and the payloads must survive the same
|
|
702
|
+
# deduplication or they stop pairing by index.
|
|
703
|
+
raw_blocked = args.blocked_by or []
|
|
704
|
+
raw_carries = args.carries or []
|
|
705
|
+
if len(raw_blocked) != len(raw_carries):
|
|
706
|
+
die("--blocked-by was given %d time(s) and --carries %d. Each dependency names "
|
|
707
|
+
"what it hands over, and they pair in the order written — an edge carrying no "
|
|
708
|
+
"named artifact is chronology drawn as architecture. Nothing was written"
|
|
709
|
+
% (len(raw_blocked), len(raw_carries)))
|
|
710
|
+
pairs, seen_b = [], set()
|
|
711
|
+
for b, c in zip(raw_blocked, raw_carries):
|
|
712
|
+
if b in seen_b:
|
|
713
|
+
continue
|
|
714
|
+
seen_b.add(b)
|
|
715
|
+
if not c.strip():
|
|
716
|
+
die("--carries for %s is blank. A payload nobody named is the fake edge with a "
|
|
717
|
+
"field around it — nothing was written" % b)
|
|
718
|
+
pairs.append((b, c.strip()))
|
|
719
|
+
blocked = [b for b, _ in pairs]
|
|
720
|
+
for b in blocked:
|
|
721
|
+
if b not in known:
|
|
722
|
+
die("--blocked-by names %s, which is not in this graph — nothing was written" % b)
|
|
723
|
+
|
|
724
|
+
nid = args.id or next_id(known)
|
|
725
|
+
if not ID_SHAPE.match(nid):
|
|
726
|
+
die("--id %r is not a node id; the shape is N-001 and the schema enforces it" % nid)
|
|
727
|
+
if nid in known:
|
|
728
|
+
die("node id %s already exists — nothing was written. Omit --id and one is "
|
|
729
|
+
"allocated from the highest in use" % nid)
|
|
730
|
+
|
|
731
|
+
new = {"id": nid, "title": title, "owner": args.owner, "status": "pending",
|
|
732
|
+
"blocked_by": blocked, "serves": serves, "evidence": None}
|
|
733
|
+
touches = list(dict.fromkeys(t.strip() for t in (args.touches or []) if t.strip()))
|
|
734
|
+
if touches:
|
|
735
|
+
new["touches"] = touches
|
|
736
|
+
nodes.append(new)
|
|
737
|
+
# The edge lands WITH the node. Writing `blocked_by` and leaving `edges` for later is
|
|
738
|
+
# what made every mid-run node a fake edge by construction.
|
|
739
|
+
edges = graph.setdefault("edges", [])
|
|
740
|
+
for b, payload in pairs:
|
|
741
|
+
edges.append({"from": b, "to": nid, "payload": payload})
|
|
742
|
+
revise(graph, "add", nid, why)
|
|
743
|
+
bad = violations(graph)
|
|
744
|
+
if bad:
|
|
745
|
+
# Belt over the braces: every shape enumerated above is checked before this
|
|
746
|
+
# point, so this fires only on one that was NOT enumerated — and the right
|
|
747
|
+
# answer to an unenumerated shape is to refuse rather than to write and hope.
|
|
748
|
+
die("adding %s would break the graph — nothing was written:\n %s"
|
|
749
|
+
% (nid, "\n ".join(bad)))
|
|
750
|
+
save(args.graph, graph)
|
|
751
|
+
print("%s added: %s" % (nid, title))
|
|
752
|
+
return 0
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def cmd_coverage(graph, args):
|
|
756
|
+
"""The coverage relation, computed rather than walked by hand.
|
|
757
|
+
|
|
758
|
+
`references/acceptance.md` defines the path a requirement takes — decision, spec
|
|
759
|
+
section, contract and its failure behaviour, task, change, executed test, surface —
|
|
760
|
+
and until this verb existed an agent walked it one REQ at a time from a checklist,
|
|
761
|
+
which is the pipeline's own definition of a rule that should have been a mechanism.
|
|
762
|
+
|
|
763
|
+
**Three of the four directions are here. The fourth is not, and this says so.**
|
|
764
|
+
An evidence row that closes no requirement lives in `docs/evidence/verification.md`,
|
|
765
|
+
which this script does not read; a report silent about that reads as the whole
|
|
766
|
+
relation and is the more dangerous half.
|
|
767
|
+
"""
|
|
768
|
+
if violations(graph):
|
|
769
|
+
die("graph does not validate — run `validate` first", 1)
|
|
770
|
+
nodes = graph.get("nodes") or []
|
|
771
|
+
reqs = list(graph.get("requirements") or []) + list(graph.get("goal_clauses") or [])
|
|
772
|
+
by_req = {r: [] for r in reqs}
|
|
773
|
+
for n in nodes:
|
|
774
|
+
by_req.setdefault(n.get("serves"), []).append(n)
|
|
775
|
+
|
|
776
|
+
bad = []
|
|
777
|
+
for r in reqs:
|
|
778
|
+
served = by_req.get(r) or []
|
|
779
|
+
if not served:
|
|
780
|
+
bad.append(f"{r}: no node serves it")
|
|
781
|
+
continue
|
|
782
|
+
live = [n for n in served if n.get("status") != "parked"]
|
|
783
|
+
marks = " ".join(f"{n['id']}({n.get('status')})" for n in served)
|
|
784
|
+
if not live:
|
|
785
|
+
bad.append(f"{r}: every node serving it is parked — {marks}. Covered on paper "
|
|
786
|
+
"and by nothing that will run")
|
|
787
|
+
else:
|
|
788
|
+
print(f"{r} {marks}")
|
|
789
|
+
|
|
790
|
+
for line in bad:
|
|
791
|
+
print(line, file=sys.stderr)
|
|
792
|
+
print("not read here: whether an evidence row in docs/evidence/verification.md closes "
|
|
793
|
+
"no requirement — that is the fourth direction of this relation and it lives in "
|
|
794
|
+
"the ledger, not the graph", file=sys.stderr)
|
|
795
|
+
return 1 if bad else 0
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def cmd_producer(graph, args):
|
|
799
|
+
"""What produced this proof — B-086.
|
|
800
|
+
|
|
801
|
+
Every artifact this pipeline writes records what was done, what proved it, and whether
|
|
802
|
+
a person looked. None recorded what PRODUCED it, so two runs six months apart under
|
|
803
|
+
different generations of this doctrine leave indistinguishable coverage tables, and a
|
|
804
|
+
defect traced to a doctrine change cannot be scoped to the runs that carried it.
|
|
805
|
+
|
|
806
|
+
**Every field prints, and one that cannot be resolved says why.** An omitted field is
|
|
807
|
+
indistinguishable from a field that was checked and found empty — the same rule the
|
|
808
|
+
gate disclosures live by. The reason is what tells an operator whether the value is
|
|
809
|
+
wirable or genuinely absent here.
|
|
810
|
+
|
|
811
|
+
Four values the harness owns are read from the environment, because a script cannot
|
|
812
|
+
know its own model or trace id. The names are a contract a project wires once; unset,
|
|
813
|
+
each says so rather than guessing. `model` is deliberately not inferred: naming a
|
|
814
|
+
vendor id anywhere in a shipped skill is forbidden, and inferring the wrong one is
|
|
815
|
+
worse than saying nothing.
|
|
816
|
+
"""
|
|
817
|
+
import hashlib
|
|
818
|
+
|
|
819
|
+
def env(var):
|
|
820
|
+
v = os.environ.get(var, "").strip()
|
|
821
|
+
return v or f"unavailable: {var} is not set by this harness"
|
|
822
|
+
|
|
823
|
+
def skill_version():
|
|
824
|
+
# `plugin.json` sits two levels above the bundle in a plugin install and does not
|
|
825
|
+
# exist at all in a plain-skill install — so this resolves on one channel and
|
|
826
|
+
# honestly does not on the others.
|
|
827
|
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
828
|
+
man = os.path.join(os.path.dirname(os.path.dirname(here)),
|
|
829
|
+
".claude-plugin", "plugin.json")
|
|
830
|
+
if not os.path.isfile(man):
|
|
831
|
+
return ("unavailable: no plugin manifest above this bundle — the skill is "
|
|
832
|
+
"installed as a plain directory, which carries no version of its own")
|
|
833
|
+
try:
|
|
834
|
+
with open(man, encoding="utf-8") as fh:
|
|
835
|
+
v = json.load(fh).get("version")
|
|
836
|
+
return f"task-pipeline@{v}" if v else "unavailable: the manifest states no version"
|
|
837
|
+
except (OSError, ValueError) as e:
|
|
838
|
+
return f"unavailable: the plugin manifest is unreadable — {e}"
|
|
839
|
+
|
|
840
|
+
def config_digest():
|
|
841
|
+
for name in ("pipeline.json", os.path.join(".task-pipeline", "pipeline.json")):
|
|
842
|
+
if os.path.isfile(name):
|
|
843
|
+
with open(name, "rb") as fh:
|
|
844
|
+
return "sha256:" + hashlib.sha256(fh.read()).hexdigest()[:16]
|
|
845
|
+
return ("unavailable: no pipeline.json in this directory — the run's stage and gate "
|
|
846
|
+
"configuration cannot be fingerprinted")
|
|
847
|
+
|
|
848
|
+
def commit():
|
|
849
|
+
import subprocess
|
|
850
|
+
try:
|
|
851
|
+
r = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
|
|
852
|
+
except OSError as e:
|
|
853
|
+
return f"unavailable: git is not runnable here — {e}"
|
|
854
|
+
out = r.stdout.strip()
|
|
855
|
+
if r.returncode or not out:
|
|
856
|
+
return "unavailable: not inside a git checkout, so no commit identifies the tree"
|
|
857
|
+
return out
|
|
858
|
+
|
|
859
|
+
for key, val in (("actor", env("TASK_PIPELINE_ACTOR")),
|
|
860
|
+
("model", env("TASK_PIPELINE_MODEL")),
|
|
861
|
+
("runtime", env("TASK_PIPELINE_RUNTIME")),
|
|
862
|
+
("skill", skill_version()),
|
|
863
|
+
("config", config_digest()),
|
|
864
|
+
("commit", commit()),
|
|
865
|
+
("trace", env("TASK_PIPELINE_TRACE"))):
|
|
866
|
+
print(f"{key}: {val}")
|
|
867
|
+
return 0
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def cmd_doctrine(graph, args):
|
|
871
|
+
"""Which doctrine this run actually read — B-061.
|
|
872
|
+
|
|
873
|
+
The bundle is 34 reference files. A run reads some subset and nothing recorded which,
|
|
874
|
+
so **a skipped file and a read one were indistinguishable** — the class every guard in
|
|
875
|
+
this repository exists to catch, left standing over the doctrine itself.
|
|
876
|
+
|
|
877
|
+
`read:` lines in the run ledger are written by a hook, never by the agent, for the same
|
|
878
|
+
reason `gate:` is: a claim about what somebody read, written by the party the claim is
|
|
879
|
+
about, is not evidence.
|
|
880
|
+
|
|
881
|
+
**The one rule that matters here: no `read:` lines means UNMEASURED, never «read
|
|
882
|
+
nothing».** Zero would be the reassuring answer to a question nobody asked, and this
|
|
883
|
+
verb exists because that shape had gone unnoticed for a whole bundle.
|
|
884
|
+
|
|
885
|
+
It reports and never scores. There is no per-file floor in this pipeline, and inventing
|
|
886
|
+
one here would be a doctrine decision smuggled in as a measurement — stage 0's
|
|
887
|
+
mandatory items are the floor that exists, and they are not per-file.
|
|
888
|
+
"""
|
|
889
|
+
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
890
|
+
refs_dir = os.path.join(here, "references")
|
|
891
|
+
if not os.path.isdir(refs_dir):
|
|
892
|
+
die("no references/ beside this script — cannot say what the bundle holds", 2)
|
|
893
|
+
refs = sorted(f for f in os.listdir(refs_dir) if f.endswith(".md"))
|
|
894
|
+
|
|
895
|
+
ledger = args.ledger
|
|
896
|
+
if not os.path.isfile(ledger):
|
|
897
|
+
print(f"doctrine: unmeasured — no run ledger at {ledger}")
|
|
898
|
+
print(f" the bundle holds {len(refs)} reference files; nothing records "
|
|
899
|
+
"which of them this run opened")
|
|
900
|
+
return 0
|
|
901
|
+
|
|
902
|
+
read = set()
|
|
903
|
+
with open(ledger, encoding="utf-8") as fh:
|
|
904
|
+
for line in fh:
|
|
905
|
+
if not line.startswith("read:"):
|
|
906
|
+
continue
|
|
907
|
+
val = line.split(":", 1)[1].strip().split(" ")[0]
|
|
908
|
+
read.add(os.path.basename(val))
|
|
909
|
+
read &= set(refs)
|
|
910
|
+
|
|
911
|
+
if not read:
|
|
912
|
+
# The whole point. An empty set here has two causes with opposite meanings and the
|
|
913
|
+
# ledger cannot tell them apart, so this says so instead of printing 0.
|
|
914
|
+
print("doctrine: unmeasured — the ledger carries no `read:` lines")
|
|
915
|
+
print(" Either the hook that writes them is not installed (see "
|
|
916
|
+
"templates/hooks.example.json) or this run opened no doctrine at all. Those "
|
|
917
|
+
"are opposite facts and nothing here can separate them, so neither is claimed.")
|
|
918
|
+
print(f" the bundle holds {len(refs)} reference files")
|
|
919
|
+
return 0
|
|
920
|
+
|
|
921
|
+
unread = [r for r in refs if r not in read]
|
|
922
|
+
print(f"doctrine: {len(read)} of {len(refs)} reference files read")
|
|
923
|
+
print(" a disclosure: no floor, no direction, never a target. A run that needs "
|
|
924
|
+
"four files and reads four is not worse than one that reads thirty.")
|
|
925
|
+
for r in unread:
|
|
926
|
+
print(f" unread: {r}")
|
|
927
|
+
return 0
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def cmd_close(graph, args):
|
|
931
|
+
"""Consume a verdict, close one node, and re-plan — T-5, REQ-007.
|
|
932
|
+
|
|
933
|
+
The verdict is the one artifact in this design a human does not read before it acts, so
|
|
934
|
+
its shape is checked rather than trusted. `verdict_violations()` had no CLI verb until
|
|
935
|
+
now: the gate this module's docstring calls *the thing `close` consumes* was reachable
|
|
936
|
+
only from the test suite, while `agents/verifier.md` told an agent to run this command.
|
|
937
|
+
|
|
938
|
+
**`close` stamps the commit; the verifier never supplies it.** Evidence is prose, and a
|
|
939
|
+
verdict written after the tree moved is evidence about a different tree. An agent cannot
|
|
940
|
+
claim the wrong commit if it is never the one naming it. Outside a checkout the stamp
|
|
941
|
+
says `unavailable` and why — canon 9a.
|
|
942
|
+
|
|
943
|
+
**A stop closes the node and refuses the NEXT step.** `replan.possible: false` means the
|
|
944
|
+
run cannot continue around what it found, not that the work just verified did not
|
|
945
|
+
happen. Exiting 0 there would let the loop carry on past a stop; discarding the close
|
|
946
|
+
would throw away a verdict somebody earned.
|
|
947
|
+
"""
|
|
948
|
+
guard(graph, args.graph)
|
|
949
|
+
try:
|
|
950
|
+
with open(args.verdict, encoding="utf-8") as fh:
|
|
951
|
+
v = json.load(fh)
|
|
952
|
+
except OSError as e:
|
|
953
|
+
die("cannot read the verdict at %s — %s" % (args.verdict, e), 2)
|
|
954
|
+
except ValueError as e:
|
|
955
|
+
die("%s: not readable as JSON — %s" % (args.verdict, e))
|
|
956
|
+
|
|
957
|
+
bad = verdict_violations(v)
|
|
958
|
+
if bad:
|
|
959
|
+
die("the verdict is malformed — nothing was written:\n " + "\n ".join(bad))
|
|
960
|
+
|
|
961
|
+
nid = v["node"]
|
|
962
|
+
by_id = {n.get("id"): n for n in graph.get("nodes") or []}
|
|
963
|
+
node = by_id.get(nid)
|
|
964
|
+
if node is None:
|
|
965
|
+
die("no node %s in this graph — nothing was written" % nid)
|
|
966
|
+
if node.get("status") in TERMINAL:
|
|
967
|
+
die("%s is already %s — a second close would overwrite the record of the first"
|
|
968
|
+
% (nid, node.get("status")))
|
|
969
|
+
open_blockers = [b for b in node.get("blocked_by") or []
|
|
970
|
+
if by_id.get(b, {}).get("status") not in TERMINAL]
|
|
971
|
+
if open_blockers:
|
|
972
|
+
die("%s waits on %s, which %s not closed — a verdict about work that could not have "
|
|
973
|
+
"run is a verdict about nothing" % (nid, ", ".join(open_blockers),
|
|
974
|
+
"is" if len(open_blockers) == 1 else "are"))
|
|
975
|
+
|
|
976
|
+
# The stamp. Read here, never accepted from the verdict.
|
|
977
|
+
import subprocess
|
|
978
|
+
try:
|
|
979
|
+
r = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
|
|
980
|
+
head = r.stdout.strip() if r.returncode == 0 else ""
|
|
981
|
+
except OSError:
|
|
982
|
+
head = ""
|
|
983
|
+
stamp = ("observed at " + head) if head else \
|
|
984
|
+
"observed at unavailable — not inside a git checkout, so no commit identifies the tree"
|
|
985
|
+
|
|
986
|
+
node["status"] = "done"
|
|
987
|
+
node["evidence"] = list(v["evidence"]) + [stamp]
|
|
988
|
+
|
|
989
|
+
rp = v["replan"]
|
|
990
|
+
why = (rp.get("why") or "").strip()
|
|
991
|
+
added, parked = [], []
|
|
992
|
+
for spec in rp.get("add") or []:
|
|
993
|
+
title = str(spec.get("title", "")).strip()
|
|
994
|
+
owner = str(spec.get("owner", "implementer")).strip()
|
|
995
|
+
serves = str(spec.get("serves", node.get("serves"))).strip()
|
|
996
|
+
if not title:
|
|
997
|
+
die("replan.add carries an entry with no title — nothing was written")
|
|
998
|
+
if owner not in ROLES:
|
|
999
|
+
die("replan.add names owner %r, which is not a role this pipeline ships" % owner)
|
|
1000
|
+
new_id = next_id({n.get("id") for n in graph["nodes"]})
|
|
1001
|
+
graph["nodes"].append({"id": new_id, "title": title, "owner": owner,
|
|
1002
|
+
"status": "pending", "blocked_by": [], "serves": serves,
|
|
1003
|
+
"evidence": None})
|
|
1004
|
+
revise(graph, "add", new_id, why or ("re-planned by the verdict on " + nid))
|
|
1005
|
+
added.append(new_id)
|
|
1006
|
+
for pid in rp.get("park") or []:
|
|
1007
|
+
target = by_id.get(pid)
|
|
1008
|
+
if target is None:
|
|
1009
|
+
die("replan.park names %s, which is not in this graph — nothing was written" % pid)
|
|
1010
|
+
if target.get("status") in TERMINAL:
|
|
1011
|
+
continue
|
|
1012
|
+
if not why:
|
|
1013
|
+
die("replan.park names %s and the verdict gives no `why` — a park without a "
|
|
1014
|
+
"reason is indistinguishable from work quietly dropped" % pid)
|
|
1015
|
+
target["status"] = "parked"
|
|
1016
|
+
target["parked_reason"] = why
|
|
1017
|
+
revise(graph, "park", pid, why)
|
|
1018
|
+
parked.append(pid)
|
|
1019
|
+
|
|
1020
|
+
revise(graph, "close", nid, why or "closed with no re-plan")
|
|
1021
|
+
|
|
1022
|
+
bad = violations(graph)
|
|
1023
|
+
if bad:
|
|
1024
|
+
die("closing %s would break the graph — nothing was written:\n %s"
|
|
1025
|
+
% (nid, "\n ".join(bad)))
|
|
1026
|
+
save(args.graph, graph)
|
|
1027
|
+
|
|
1028
|
+
goal = (graph.get("goal") or "").strip()
|
|
1029
|
+
print("goal: %s" % (goal or "unstated"))
|
|
1030
|
+
print("%s closed · added %d · parked %d · frontier %d"
|
|
1031
|
+
% (nid, len(added), len(parked), len(frontier(graph))))
|
|
1032
|
+
if v.get("not_verified"):
|
|
1033
|
+
print("not verified: " + "; ".join(str(x) for x in v["not_verified"]))
|
|
1034
|
+
else:
|
|
1035
|
+
# Canon 9a, one artifact over: an empty list is a claim with a subject, and it says
|
|
1036
|
+
# so rather than printing nothing.
|
|
1037
|
+
print("not verified: none within the scope this verdict names")
|
|
1038
|
+
if rp.get("possible") is False:
|
|
1039
|
+
print("STOP — the run cannot continue around what this verdict found: %s"
|
|
1040
|
+
% (why or "no reason given"), file=sys.stderr)
|
|
1041
|
+
return 1
|
|
1042
|
+
return 0
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
VERBS = {
|
|
1046
|
+
"validate": (cmd_validate, "every invariant a schema cannot state"),
|
|
1047
|
+
"next": (cmd_next, "the frontier, ordered by what it unblocks"),
|
|
1048
|
+
"goal": (cmd_goal, "the release goal this graph serves"),
|
|
1049
|
+
"doctrine": (cmd_doctrine, "which of the bundle's reference files this run opened"),
|
|
1050
|
+
"producer": (cmd_producer, "what produced this proof: actor, model, runtime, skill, "
|
|
1051
|
+
"config digest, commit, trace"),
|
|
1052
|
+
"coverage": (cmd_coverage, "every requirement and the nodes serving it; exits 1 on a gap"),
|
|
1053
|
+
"add": (cmd_add, "add a node mid-run"),
|
|
1054
|
+
"park": (cmd_park, "park a node, carrying the reason"),
|
|
1055
|
+
"close": (cmd_close, "consume a verdict, close one node and re-plan"),
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def main(argv=None):
|
|
1060
|
+
ap = argparse.ArgumentParser(prog="graph.py", description=__doc__.split("\n")[0])
|
|
1061
|
+
# `--graph` hangs off every verb rather than off the top level, so it can be passed
|
|
1062
|
+
# after the verb — which is the order every caller writes without thinking.
|
|
1063
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
1064
|
+
common.add_argument("--graph", default=os.path.join(".task-pipeline", "graph.json"))
|
|
1065
|
+
sub = ap.add_subparsers(dest="verb", required=True)
|
|
1066
|
+
|
|
1067
|
+
# The subparsers are built FROM the dispatch table, so a verb argparse accepts and the
|
|
1068
|
+
# dispatch lacks cannot exist. It could before: renaming one key raised KeyError, which
|
|
1069
|
+
# is a traceback where a named refusal belongs.
|
|
1070
|
+
made = {n: sub.add_parser(n, parents=[common], help=h)
|
|
1071
|
+
for n, (_, h) in VERBS.items()}
|
|
1072
|
+
|
|
1073
|
+
p_add = made["add"]
|
|
1074
|
+
p_add.add_argument("--title", required=True)
|
|
1075
|
+
p_add.add_argument("--owner", required=True)
|
|
1076
|
+
p_add.add_argument("--serves", required=True)
|
|
1077
|
+
p_add.add_argument("--blocked-by", dest="blocked_by", action="append", default=[])
|
|
1078
|
+
p_add.add_argument("--carries", action="append", default=[],
|
|
1079
|
+
help="what each --blocked-by hands over; pairs in the order written")
|
|
1080
|
+
p_add.add_argument("--why", required=True,
|
|
1081
|
+
help="why this node appeared mid-run — it enters the revision log")
|
|
1082
|
+
p_add.add_argument("--id", default=None, help="omit to allocate the next one")
|
|
1083
|
+
p_add.add_argument("--touches", action="append", default=[],
|
|
1084
|
+
help="a path, register or resource this node mutates; repeat per target")
|
|
1085
|
+
|
|
1086
|
+
made["doctrine"].add_argument(
|
|
1087
|
+
"--ledger", default=os.path.join(".task-pipeline", "run.md"),
|
|
1088
|
+
help="the run ledger whose `read:` lines record what was opened")
|
|
1089
|
+
|
|
1090
|
+
made["close"].add_argument("--verdict", required=True,
|
|
1091
|
+
help="path to the verifier's seven-key verdict JSON")
|
|
1092
|
+
|
|
1093
|
+
p_park = made["park"]
|
|
1094
|
+
p_park.add_argument("node")
|
|
1095
|
+
# `required=True` makes the MISSING flag a usage error (exit 2). The empty and
|
|
1096
|
+
# whitespace ones reach `cmd_park`, which refuses them — argparse cannot tell a flag
|
|
1097
|
+
# that was given from a reason that was written, and only the second is REQ-012.
|
|
1098
|
+
p_park.add_argument("--reason", required=True)
|
|
1099
|
+
|
|
1100
|
+
args = ap.parse_args(argv)
|
|
1101
|
+
verbs = {k: v[0] for k, v in VERBS.items()}
|
|
1102
|
+
if args.verb in NO_GRAPH:
|
|
1103
|
+
return verbs[args.verb](None, args)
|
|
1104
|
+
if args.verb in ("add", "park", "close"):
|
|
1105
|
+
# The READ happens inside the lock too. Loading first and locking second is the
|
|
1106
|
+
# same lost update with an extra step: the stale copy is already in memory.
|
|
1107
|
+
with held(args.graph):
|
|
1108
|
+
return verbs[args.verb](shape(load(args.graph), args.graph), args)
|
|
1109
|
+
return verbs[args.verb](shape(load(args.graph), args.graph), args)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
if __name__ == "__main__":
|
|
1113
|
+
sys.exit(main())
|