task-pipeline-skill 1.93.3 → 1.94.1

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 (49) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +42 -9
  3. package/SKILL-CARD.md +1 -1
  4. package/bin/task-pipeline.js +64 -33
  5. package/cursor/rules/task-pipeline.mdc +10 -5
  6. package/evals/RESULTS.md +1 -1
  7. package/evals/task-pipeline.evals.json +16 -0
  8. package/package.json +3 -3
  9. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  10. package/plugins/task-pipeline/agents/verifier.md +4 -1
  11. package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
  12. package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
  13. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
  14. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
  15. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
  17. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  19. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +94 -25
  22. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
  23. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
  25. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
  27. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  31. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
  33. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +76 -36
  34. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
  35. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +54 -13
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +108 -11
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
  48. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -0,0 +1,546 @@
1
+ #!/usr/bin/env python3
2
+ """Wave mode's inventory, assigned by tables rather than by hand (`references/waves.md`).
3
+
4
+ Wave 0 ends with one machine-readable inventory (`templates/inventory.schema.json`,
5
+ inventory/1) and every item on a canvas node. Seven hundred hand-edited rows cannot be
6
+ reviewed; a table and a re-run can. This script is that re-run:
7
+
8
+ waves.py merge DIR [--glob PATTERN ...] [--out FILE]
9
+ every survey output in DIR (a JSON array of items, or an inventory/1 document)
10
+ merged into one inventory/1 document; each item keeps `source_file`. A file that is
11
+ not an item list is skipped and named. Refuses (exit 2, every reason) an item that
12
+ fails inventory/1 and an id that appears twice; a refused merge writes nothing.
13
+ waves.py assign --inventory FILE --modules FILE [--plan FILE] [--write]
14
+ writes `module` from the module table ({module: [ids]}) and, with a plan, `node`
15
+ (the nodes whose `closes` name the item, in wave order), `wave` (the last of their
16
+ waves) and `release` (`this`, or `program` when a node is carried). A carried item
17
+ no node closes goes to `program`; a terminal one stays off the canvas. Prints the
18
+ counts. Exit 1 without writing when an item lands on no module, or an open item on no node.
19
+ waves.py count --inventory FILE [--by FIELD ...]
20
+ counts per field (default: module, wave, release, kind, severity).
21
+ waves.py validate --inventory FILE [--modules FILE] [--plan FILE]
22
+ exit 2 on a schema error; exit 1 when an item is on no module or on two, when its
23
+ recorded module, node or wave is not what the tables now say, or when an open item
24
+ lands nowhere. 0 otherwise.
25
+ waves.py sync-graph --plan FILE --graph FILE --why TEXT [--only-present]
26
+ writes each plan node's wave and risk class onto the work graph with graph.py's own
27
+ lock, loader and revision log (one `wave` or `risk` revision per change; lowering a
28
+ risk class is refused without --lower-why), applying every move together and
29
+ judging the end state once with graph.py's order rule, so a valid re-cut is never
30
+ refused halfway and a refused one writes nothing. Plan nodes the graph lacks are named
31
+ and exit 1 before any write, unless --only-present
32
+ (a plan that spans several repositories' graphs).
33
+
34
+ **The plan** is any JSON whose `nodes` list `{id, wave, closes}` — a work graph that carries
35
+ `closes` on its nodes is one, and so is a short hand-written table. A node may carry
36
+ `graph_id`, the work-graph id it is built as, where the canvas id is not graph.py's `N-001`.
37
+ Both node ids and effective graph targets must be unique. A node whose wave is
38
+ not an integer is carried to a later release (`release: program`). Both tables are
39
+ described in `templates/inventory.schema.json` (`$defs.modules`, `$defs.plan`).
40
+
41
+ **Deterministic.** The same inputs give the same bytes: items keep their merge order, nodes
42
+ are listed in (wave, id) order, counts are sorted. A re-cut is a diff to a table and a
43
+ re-run, which a reviewer can read.
44
+
45
+ Exit codes: 0 done; 1 an item lands nowhere, or the tables and the inventory disagree, or a
46
+ graph move was refused; 2 a schema error, a table error (an id on two modules), usage, or an
47
+ unreadable file. Stdlib only (`references/portability.md`).
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import fnmatch
53
+ import json
54
+ import os
55
+ import pathlib
56
+ import re
57
+ import sys
58
+ import tempfile
59
+
60
+ HERE = pathlib.Path(__file__).resolve().parent
61
+ SCHEMA = HERE.parent / "templates" / "inventory.schema.json"
62
+ GRAPH_PY = HERE / "graph.py"
63
+ REQUIRED = ("id", "title", "area", "kind", "severity", "evidence", "proposed_fix")
64
+ OPTIONAL = ("status", "source", "source_file", "repo", "module", "node", "wave", "release",
65
+ "register_id", "blocked_by", "closable_in_one_pass", "needs_operator", "notes")
66
+ SEVERITY = ("critical", "high", "medium", "low", "info")
67
+ STATUS = ("open", "changed", "closed", "fixed", "fixed-elsewhere", "duplicate", "moved", "stale",
68
+ "not-reproduced", "carried")
69
+ OPEN = ("open", "changed")
70
+ ID = re.compile(r"^[A-Za-z][A-Za-z0-9._-]{0,63}$")
71
+ KIND = re.compile(r"^[a-z][a-z0-9-]*$")
72
+ EXT = re.compile(r"^x-[a-z0-9-]+$")
73
+ DEFAULT_COUNTS = ("module", "wave", "release", "kind", "severity")
74
+
75
+
76
+ class Fail(Exception):
77
+ def __init__(self, code: int, reasons: list[str]):
78
+ super().__init__("\n".join(reasons))
79
+ self.code, self.reasons = code, reasons
80
+
81
+
82
+ # ---------------------------------------------------------------------------- inventory/1
83
+
84
+
85
+ def item_errors(x: object) -> list[str]:
86
+ """Every reason inventory/1 refuses one item (empty = valid)."""
87
+ if not isinstance(x, dict):
88
+ return ["an item is a JSON object"]
89
+ iid = x.get("id", "?")
90
+ errs = [f"{iid}: missing `{k}`" for k in REQUIRED if k not in x]
91
+ errs += [f"{iid}: unknown field `{k}` — inventory/1 names its fields, and a project's own go "
92
+ "under an `x-` prefix" for k in x if k not in REQUIRED + OPTIONAL and not EXT.match(k)]
93
+ if "id" in x and (not isinstance(x["id"], str) or not ID.match(x["id"])):
94
+ errs.append(f"{iid}: `id` is not an id (a letter, then letters, digits, . _ -)")
95
+ for k, least in (("title", 3), ("area", 1), ("proposed_fix", 1)):
96
+ if k in x and (not isinstance(x[k], str) or len(x[k].strip()) < least):
97
+ errs.append(f"{iid}: `{k}` is shorter than {least} character{'s' if least > 1 else ''}")
98
+ if "kind" in x and (not isinstance(x["kind"], str) or not KIND.match(x["kind"])):
99
+ errs.append(f"{iid}: `kind` is not a lowercase word")
100
+ if "severity" in x and x["severity"] not in SEVERITY:
101
+ errs.append(f"{iid}: `severity` {x['severity']!r} is not one of {', '.join(SEVERITY)}")
102
+ ev = x.get("evidence")
103
+ if "evidence" in x and not ((isinstance(ev, str) and len(ev.strip()) >= 3) or (
104
+ isinstance(ev, list) and ev and all(isinstance(e, str) and len(e.strip()) >= 3 for e in ev))):
105
+ errs.append(f"{iid}: `evidence` is empty — a finding is a fact with a receipt")
106
+ if "status" in x and x["status"] not in STATUS:
107
+ errs.append(f"{iid}: `status` {x['status']!r} is not one of {', '.join(STATUS)}")
108
+ if "node" in x and (not isinstance(x["node"], list) or not all(isinstance(n, str) for n in x["node"])):
109
+ errs.append(f"{iid}: `node` is not a list of node ids")
110
+ if "wave" in x and x["wave"] is not None and (not isinstance(x["wave"], int)
111
+ or isinstance(x["wave"], bool) or x["wave"] < 1):
112
+ errs.append(f"{iid}: `wave` is not an integer from 1, or null")
113
+ if "release" in x and x["release"] not in ("this", "program"):
114
+ errs.append(f"{iid}: `release` is neither this nor program")
115
+ for k in ("closable_in_one_pass", "needs_operator"):
116
+ if k in x and not isinstance(x[k], bool):
117
+ errs.append(f"{iid}: `{k}` is not a boolean")
118
+ return errs
119
+
120
+
121
+ def schema_errors(items: list) -> list[str]:
122
+ """inventory/1 itself, applied where `jsonschema` is installed. `item_errors` is the
123
+ hand copy every install has; test/waves_test.py holds the two to one verdict."""
124
+ try:
125
+ import jsonschema
126
+ schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
127
+ except (ImportError, OSError, ValueError):
128
+ return []
129
+ v = jsonschema.Draft202012Validator(schema)
130
+ out = []
131
+ for e in sorted(v.iter_errors({"schema": "inventory/1", "items": items}), key=lambda e: list(e.path)):
132
+ where = "/".join(str(x) for x in e.path) or "(top)"
133
+ out.append(f"schema: {where}: {e.message[:200]}")
134
+ return out
135
+
136
+
137
+ def read_json(path: pathlib.Path, what: str):
138
+ try:
139
+ return json.loads(path.read_text(encoding="utf-8"))
140
+ except (OSError, ValueError) as exc:
141
+ raise Fail(2, [f"{what} {path} cannot be read ({type(exc).__name__})"])
142
+
143
+
144
+ def items_of(doc) -> list | None:
145
+ if isinstance(doc, list) and all(isinstance(x, dict) for x in doc):
146
+ return doc
147
+ if isinstance(doc, dict) and doc.get("schema") == "inventory/1" and isinstance(doc.get("items"), list):
148
+ return doc["items"]
149
+ return None
150
+
151
+
152
+ def load_inventory(path: pathlib.Path) -> tuple[dict, list[dict]]:
153
+ doc = read_json(path, "the inventory")
154
+ items = items_of(doc)
155
+ if items is None:
156
+ raise Fail(2, [f"{path} is neither an inventory/1 document nor an array of items"])
157
+ errs = [e for x in items for e in item_errors(x)]
158
+ errs += schema_errors(items)
159
+ ids = [x.get("id") for x in items]
160
+ errs += [f"{i}: appears {ids.count(i)} times — an id is unique across the inventory"
161
+ for i in sorted({i for i in ids if ids.count(i) > 1}, key=str)]
162
+ if errs:
163
+ raise Fail(2, errs)
164
+ out = doc if isinstance(doc, dict) else {"schema": "inventory/1", "items": items}
165
+ return out, items
166
+
167
+
168
+ def write_json(path: pathlib.Path, doc) -> None:
169
+ path = pathlib.Path(os.path.realpath(path))
170
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".inventory-", suffix=".tmp")
171
+ try:
172
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
173
+ f.write(json.dumps(doc, ensure_ascii=False, indent=1) + "\n")
174
+ os.replace(tmp, path)
175
+ except BaseException:
176
+ try:
177
+ os.unlink(tmp)
178
+ except OSError:
179
+ pass
180
+ raise
181
+
182
+
183
+ # ---------------------------------------------------------------------------- tables
184
+
185
+
186
+ def load_modules(path: pathlib.Path) -> dict[str, str]:
187
+ table = read_json(path, "the module table")
188
+ if not isinstance(table, dict) or not all(isinstance(v, list) for v in table.values()):
189
+ raise Fail(2, [f"{path}: the module table is {{module: [item ids]}}"])
190
+ owner: dict[str, str] = {}
191
+ errs = []
192
+ for mod in sorted(table):
193
+ for i in table[mod]:
194
+ if i in owner and owner[i] != mod:
195
+ errs.append(f"{i}: claimed by both {owner[i]} and {mod} — an item sits in exactly "
196
+ "one module")
197
+ owner.setdefault(i, mod)
198
+ if errs:
199
+ raise Fail(2, errs)
200
+ return owner
201
+
202
+
203
+ def load_plan(path: pathlib.Path) -> list[dict]:
204
+ doc = read_json(path, "the plan")
205
+ nodes = doc.get("nodes") if isinstance(doc, dict) else None
206
+ if isinstance(nodes, dict):
207
+ nodes = [dict(v, id=k) for k, v in nodes.items()]
208
+ if not isinstance(nodes, list) or not all(isinstance(n, dict) and isinstance(n.get("id"), str)
209
+ for n in nodes):
210
+ raise Fail(2, [f"{path}: a plan is an object whose `nodes` list {{id, wave, closes}}"])
211
+ # The declared values, checked rather than skipped: a risk of `r1`, `high` or null was
212
+ # silently ignored and the sync exited 0, leaving the node with no class to enforce.
213
+ errs = []
214
+ ids, targets = set(), set()
215
+ for n in nodes:
216
+ nid = n["id"]
217
+ target = n.get("graph_id", nid)
218
+ if not nid or nid in ids:
219
+ errs.append(f"{path}: repeated or empty node id {nid!r}")
220
+ ids.add(nid)
221
+ if not isinstance(target, str) or not target:
222
+ errs.append(f"{path}: node {nid}: graph_id must be a non-empty string")
223
+ elif target in targets:
224
+ errs.append(f"{path}: repeated effective graph target {target!r}")
225
+ else:
226
+ targets.add(target)
227
+ if "risk" in n and n["risk"] not in ("R1", "R2", "R3"):
228
+ errs.append(f"{path}: node {n['id']}: risk {n['risk']!r} is not R1, R2 or R3")
229
+ if "wave" in n and n["wave"] is not None and _wave(n) is None:
230
+ errs.append(f"{path}: node {n['id']}: wave {n['wave']!r} is not an integer from 1, or null")
231
+ if "closes" in n and not (isinstance(n["closes"], list) and all(isinstance(i, str) for i in n["closes"])):
232
+ errs.append(f"{path}: node {n['id']}: closes is not a list of item ids")
233
+ if "release" in n and n["release"] not in ("this", "program"):
234
+ errs.append(f"{path}: node {n['id']}: release {n['release']!r} is neither this nor program")
235
+ if errs:
236
+ raise Fail(2, errs)
237
+ return nodes
238
+
239
+
240
+ def _wave(n: dict) -> int | None:
241
+ w = n.get("wave")
242
+ return w if isinstance(w, int) and not isinstance(w, bool) and w >= 1 else None
243
+
244
+
245
+ def placement(nodes: list[dict]) -> dict[str, dict]:
246
+ """item id -> {node, wave, release}, from the plan's `closes`."""
247
+ by_item: dict[str, list[dict]] = {}
248
+ for n in nodes:
249
+ for i in n.get("closes") or []:
250
+ by_item.setdefault(i, []).append(n)
251
+ out = {}
252
+ for i, ns in by_item.items():
253
+ ns = sorted(ns, key=lambda n: (_wave(n) is None, _wave(n) or 0, n["id"]))
254
+ waves = [_wave(n) for n in ns]
255
+ carried = any(w is None for w in waves)
256
+ out[i] = {"node": list(dict.fromkeys(n["id"] for n in ns)),
257
+ "wave": None if carried else max(waves),
258
+ "release": "program" if carried else "this"}
259
+ return out
260
+
261
+
262
+ def _status(x: dict) -> str:
263
+ return x.get("status", "open")
264
+
265
+
266
+ def _carried(x: dict) -> bool:
267
+ """The ONE predicate for an item that goes to the program without a node. `assign`
268
+ writes `release: program` from it and `validate` excuses an item by it — the same
269
+ test in both, so a hand-written `release: program` on an open item excuses nothing."""
270
+ return _status(x) == "carried"
271
+
272
+
273
+ def _needs_node(x: dict) -> bool:
274
+ return _status(x) in OPEN
275
+
276
+
277
+ def counts(items: list[dict], fields) -> dict:
278
+ out = {}
279
+ for f in fields:
280
+ c: dict[str, int] = {}
281
+ for x in items:
282
+ v = x.get(f)
283
+ k = "none" if v is None else (",".join(v) if isinstance(v, list) else str(v))
284
+ c[k] = c.get(k, 0) + 1
285
+ out[f"by_{f}"] = dict(sorted(c.items(), key=lambda kv: (kv[0] == "none", _natural(kv[0]))))
286
+ return out
287
+
288
+
289
+ def _natural(s: str):
290
+ return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", s)]
291
+
292
+
293
+ # ---------------------------------------------------------------------------- verbs
294
+
295
+
296
+ def cmd_merge(a) -> int:
297
+ src = pathlib.Path(a.dir)
298
+ if not src.is_dir():
299
+ raise Fail(2, [f"{src} is not a directory"])
300
+ globs = a.glob or ["*.json"]
301
+ files = sorted(p for p in src.iterdir() if p.is_file() and any(fnmatch.fnmatch(p.name, g) for g in globs))
302
+ items, errs, skipped = [], [], []
303
+ for f in files:
304
+ got = items_of(read_json(f, "the survey output"))
305
+ if got is None:
306
+ skipped.append(f.name)
307
+ continue
308
+ for x in got:
309
+ x = dict(x)
310
+ x.setdefault("source_file", f.name)
311
+ errs += [f"{f.name}: {e}" for e in item_errors(x)]
312
+ items.append(x)
313
+ seen: dict[str, list[str]] = {}
314
+ for x in items:
315
+ seen.setdefault(x.get("id"), []).append(x.get("source_file"))
316
+ errs += [f"{i}: appears twice — in {', '.join(fs)}; an id is unique across the inventory"
317
+ for i, fs in seen.items() if len(fs) > 1]
318
+ for s in skipped:
319
+ print(f"waves: skipped {s} — not an item list", file=sys.stderr)
320
+ if errs:
321
+ raise Fail(2, errs)
322
+ doc = {"schema": "inventory/1", "items": items}
323
+ if a.out:
324
+ write_json(pathlib.Path(a.out), doc)
325
+ print(json.dumps({"items": len(items), "files": len(files) - len(skipped),
326
+ "skipped": skipped, **counts(items, ("kind", "severity"))}, ensure_ascii=False))
327
+ else:
328
+ print(json.dumps(doc, ensure_ascii=False, indent=1))
329
+ return 0
330
+
331
+
332
+ def _assign(items: list[dict], owner: dict[str, str], nodes: list[dict] | None) -> tuple[list, list, list]:
333
+ unclaimed, unplaced, out = [], [], []
334
+ place = placement(nodes) if nodes is not None else {}
335
+ for x in items:
336
+ x = dict(x)
337
+ mod = owner.get(x["id"])
338
+ if mod is None:
339
+ unclaimed.append(x["id"])
340
+ x.pop("module", None)
341
+ else:
342
+ x["module"] = mod
343
+ if nodes is not None:
344
+ for k in ("node", "wave", "release"):
345
+ x.pop(k, None)
346
+ p = place.get(x["id"])
347
+ if p:
348
+ x.update(node=p["node"], wave=p["wave"], release=p["release"])
349
+ elif _carried(x):
350
+ x.update(wave=None, release="program")
351
+ elif _needs_node(x):
352
+ unplaced.append(x["id"])
353
+ out.append(x)
354
+ return out, unclaimed, unplaced
355
+
356
+
357
+ def cmd_assign(a) -> int:
358
+ path = pathlib.Path(a.inventory)
359
+ doc, items = load_inventory(path)
360
+ owner = load_modules(pathlib.Path(a.modules))
361
+ nodes = load_plan(pathlib.Path(a.plan)) if a.plan else None
362
+ new, unclaimed, unplaced = _assign(items, owner, nodes)
363
+ if a.write and not (unclaimed or unplaced):
364
+ write_json(path, {**doc, "items": new})
365
+ fields = ("module", "wave", "release") if nodes is not None else ("module",)
366
+ print(json.dumps({"items": len(new), **counts(new, fields), "unclaimed": unclaimed,
367
+ "unplaced": unplaced}, ensure_ascii=False))
368
+ for i in unclaimed:
369
+ print(f"waves: {i} lands on no module — the canvas has a gap, or the module table a "
370
+ "missing row", file=sys.stderr)
371
+ for i in unplaced:
372
+ print(f"waves: {i} is open and no node closes it — put it on a node, carry it "
373
+ "(`status: carried`), or close it with its receipt", file=sys.stderr)
374
+ return 1 if unclaimed or unplaced else 0
375
+
376
+
377
+ def cmd_count(a) -> int:
378
+ _, items = load_inventory(pathlib.Path(a.inventory))
379
+ print(json.dumps({"items": len(items), **counts(items, a.by or DEFAULT_COUNTS)}, ensure_ascii=False))
380
+ return 0
381
+
382
+
383
+ def cmd_validate(a) -> int:
384
+ _, items = load_inventory(pathlib.Path(a.inventory))
385
+ problems = []
386
+ known = {x["id"] for x in items}
387
+ if a.modules:
388
+ owner = load_modules(pathlib.Path(a.modules))
389
+ problems += [f"{i}: the module table puts it on {owner[i]}, and the inventory has no "
390
+ "such item" for i in sorted(set(owner) - known)]
391
+ for x in items:
392
+ mod = owner.get(x["id"])
393
+ if mod is None:
394
+ problems.append(f"{x['id']}: on no module — every item lands in exactly one")
395
+ elif "module" in x and x["module"] != mod:
396
+ problems.append(f"{x['id']}: recorded on {x['module']}, the table says {mod} — re-run assign")
397
+ if a.plan:
398
+ plan_nodes = load_plan(pathlib.Path(a.plan))
399
+ problems += [f"{i}: node {n['id']} closes it, and the inventory has no such item"
400
+ for n in plan_nodes for i in (n.get("closes") or []) if i not in known]
401
+ place = placement(plan_nodes)
402
+ for x in items:
403
+ p = place.get(x["id"])
404
+ if p:
405
+ for k in ("node", "wave", "release"):
406
+ if k in x and x[k] != p[k]:
407
+ problems.append(f"{x['id']}: `{k}` is {x[k]!r}, the plan now says {p[k]!r} — "
408
+ "re-run assign")
409
+ elif "node" in x and x["node"]:
410
+ problems.append(f"{x['id']}: recorded on {', '.join(x['node'])}, which no longer "
411
+ "close it")
412
+ elif _needs_node(x):
413
+ problems.append(f"{x['id']}: open, and no node closes it — it lands nowhere "
414
+ "(carry it with `status: carried`, or put it on a node)")
415
+ elif _carried(x) and x.get("release") != "program":
416
+ problems.append(f"{x['id']}: carried, and its release is not program — re-run assign")
417
+ for p in problems:
418
+ print(f"waves: {p}", file=sys.stderr)
419
+ print(json.dumps({"items": len(items), "problems": len(problems)}))
420
+ return 1 if problems else 0
421
+
422
+
423
+ def _graph_module():
424
+ """graph.py, imported: its lock, its loader, its revision log and its order rule."""
425
+ import importlib.util
426
+ spec = importlib.util.spec_from_file_location("tp_graph", GRAPH_PY)
427
+ mod = importlib.util.module_from_spec(spec)
428
+ spec.loader.exec_module(mod)
429
+ return mod
430
+
431
+
432
+ def cmd_sync_graph(a) -> int:
433
+ why = " ".join((a.why or "").split())
434
+ if not why:
435
+ raise Fail(2, ["--why needs a reason: each move enters the graph's revision log"])
436
+ nodes = load_plan(pathlib.Path(a.plan))
437
+ gpath = os.path.realpath(a.graph)
438
+ G = _graph_module()
439
+ # ALL MOVES AT ONCE, JUDGED ONCE. Applied one at a time through `graph.py wave`, a valid
440
+ # re-cut could be refused halfway: moving A(w1)->B(w2) to A:3, B:4 passes through A:3,
441
+ # B:2, which breaks the order rule although the end state keeps it. So every move is
442
+ # applied in memory, under graph.py's own lock, each with its revision entry, and
443
+ # graph.py's `violations` judges the end state once. A refusal writes nothing.
444
+ with G.held(gpath):
445
+ try:
446
+ graph = G.shape(G.load(gpath), gpath)
447
+ except SystemExit as exc:
448
+ raise Fail(2, [f"the work graph {gpath} cannot be read ({exc})"])
449
+ before = G.violations(graph)
450
+ if before:
451
+ raise Fail(1, [f"{gpath} was already invalid — nothing was written:"] + before)
452
+ current = {n.get("id"): n for n in graph.get("nodes") or [] if isinstance(n, dict)}
453
+
454
+ def gid(n: dict) -> str: # a canvas id need not be graph.py's `N-001` shape
455
+ return n.get("graph_id") or n["id"]
456
+
457
+ absent = [gid(n) for n in nodes if (_wave(n) is not None or "risk" in n)
458
+ and gid(n) not in current]
459
+ if absent and not a.only_present:
460
+ raise Fail(1, [f"{nid} is in the plan and not in {pathlib.Path(gpath).name} — "
461
+ "nothing was written; add it, or pass --only-present for a plan "
462
+ "that spans several graphs" for nid in absent])
463
+ moves = sorted(((gid(n), _wave(n)) for n in nodes if _wave(n) is not None
464
+ and gid(n) in current and current[gid(n)].get("wave") != _wave(n)
465
+ and current[gid(n)].get("status") != "done"))
466
+ # RISK, from the plan: the class `close` enforces (graph.py `owed_shortfall`). Nothing
467
+ # else writes it onto a graph node, so without this the gate fired only on a
468
+ # hand-edited graph. A lowering owes its own reason — a class is never lowered to save
469
+ # time (references/iteration.md) — so it is refused without --lower-why.
470
+ rank = {"R1": 1, "R2": 2, "R3": 3}
471
+ risk_moves, lowered = [], []
472
+ for n in nodes:
473
+ r, nid = n.get("risk"), gid(n)
474
+ if r not in rank or nid not in current or current[nid].get("risk") == r:
475
+ continue
476
+ was = current[nid].get("risk")
477
+ if was in rank and rank[r] < rank[was]:
478
+ lowered.append(f"{nid}: {was} → {r}")
479
+ risk_moves.append((nid, was, r))
480
+ lower_why = " ".join((a.lower_why or "").split())
481
+ if lowered and not lower_why:
482
+ raise Fail(1, ["lowering a risk class needs its own reason (--lower-why) — nothing "
483
+ "was written: " + ", ".join(lowered)])
484
+ lines = []
485
+ for nid, was, r in sorted(risk_moves):
486
+ current[nid]["risk"] = r
487
+ low = was in rank and rank[r] < rank[was]
488
+ G.revise(graph, "risk", nid, f"{why}; lowered: {lower_why}" if low else why)
489
+ lines.append(f"{nid}: risk {was or 'none'} → {r}")
490
+ for nid, wave in moves:
491
+ old = current[nid].get("wave")
492
+ current[nid]["wave"] = wave
493
+ G.revise(graph, "wave", nid, why)
494
+ lines.append(f"{nid}: wave {old if old is not None else 'none'} → {wave}: {why}")
495
+ bad = G.violations(graph)
496
+ if bad:
497
+ raise Fail(1, ["the re-cut would break the graph — nothing was written:"] + bad)
498
+ if moves or risk_moves:
499
+ G.save(gpath, graph)
500
+ for ln in lines:
501
+ print(ln)
502
+ print(f"waves: {len(moves)} moved · {len(risk_moves)} risk class(es) set · graph.py's order "
503
+ "rule holds on the result")
504
+ return 0
505
+
506
+
507
+ def main(argv: list[str]) -> int:
508
+ p = argparse.ArgumentParser(prog="waves.py", description=__doc__.split("\n")[0])
509
+ sub = p.add_subparsers(dest="cmd", required=True)
510
+ m = sub.add_parser("merge", help="merge survey outputs into one inventory/1 document")
511
+ m.add_argument("dir")
512
+ m.add_argument("--glob", action="append", default=[], help="file pattern; repeatable (default *.json)")
513
+ m.add_argument("--out", help="write the inventory here (default: print it)")
514
+ asg = sub.add_parser("assign", help="write module, node, wave and release from the tables")
515
+ asg.add_argument("--inventory", required=True)
516
+ asg.add_argument("--modules", required=True)
517
+ asg.add_argument("--plan")
518
+ asg.add_argument("--write", action="store_true", help="write the inventory back (default: counts only)")
519
+ c = sub.add_parser("count", help="counts per field")
520
+ c.add_argument("--inventory", required=True)
521
+ c.add_argument("--by", action="append", default=[])
522
+ v = sub.add_parser("validate", help="schema, then every item on one module and one placement")
523
+ v.add_argument("--inventory", required=True)
524
+ v.add_argument("--modules")
525
+ v.add_argument("--plan")
526
+ s = sub.add_parser("sync-graph", help="write the plan's waves onto the work graph via graph.py wave")
527
+ s.add_argument("--plan", required=True)
528
+ s.add_argument("--graph", required=True)
529
+ s.add_argument("--why", required=True)
530
+ s.add_argument("--lower-why", help="the reason a node's risk class is lowered; without it "
531
+ "a lowering is refused")
532
+ s.add_argument("--only-present", action="store_true",
533
+ help="ignore plan nodes this graph does not hold")
534
+ a = p.parse_args(argv)
535
+ verbs = {"merge": cmd_merge, "assign": cmd_assign, "count": cmd_count, "validate": cmd_validate,
536
+ "sync-graph": cmd_sync_graph}
537
+ try:
538
+ return verbs[a.cmd](a)
539
+ except Fail as exc:
540
+ for r in exc.reasons:
541
+ print(f"waves: {r}", file=sys.stderr)
542
+ return exc.code
543
+
544
+
545
+ if __name__ == "__main__":
546
+ sys.exit(main(sys.argv[1:]))
@@ -30,6 +30,12 @@ from `super-ux`.
30
30
  | `routing-rule.md` | the operator's `CLAUDE.md` — **offered by `setup`, never written silently** | 0 / `setup` |
31
31
  | `retro.md` | `docs/evidence/retro.md` — **one per project, not per run** | 10 writes (stamp → prune → entry), 0 reads it in full |
32
32
  | `retro-archive.md` | `docs/evidence/retro/YYYY-QN.md` | 10 rotates into it, 0 **queries** it |
33
+ | `inventory.schema.json` | nothing is seeded: the shape of wave 0's `docs/evidence/specs/YYYY-MM-DD-<topic>-inventory.json` (inventory/1), and of the module table and plan `scripts/waves.py` reads | 0 (wave mode) — `waves.py merge` writes it, `assign` places it, `validate` refuses an item that lands nowhere |
34
+ | `canvas.md` | `docs/evidence/specs/YYYY-MM-DD-<topic>-canvas.md` | 2–4 (wave mode, iteration step 3) — the target state over today's logic |
35
+ | `questions.md` | `docs/evidence/specs/<topic>-questions.md` | 0–4, then every question round — the one queue: closed from sources, operator, peers, assumed |
36
+ | `dossier.md` | `docs/evidence/specs/<topic>-dossiers/<letter>-<module>.md` | iteration step 2 — one per cluster, written in parallel; a risk class per node |
37
+ | `backlog-sources.schema.json` | nothing is seeded: the shape of a project's `docs/backlog-sources.json` (backlog-sources/1) | 0 — the harvest reads exactly the sources it declares |
38
+ | `lesson.schema.json` | nothing is seeded: the shape of one *lesson* (lesson/1), a retro entry's class carried by its `**Lesson:**` line into the local index (`scripts/lessons.py`; `~/.sshlg-skills/lessons/index.jsonl` where the family's directory exists, else `~/.task-pipeline/lessons/index.jsonl`) and, where the host lists one, the shared store | 10 writes (`lessons.py add`), 0 **queries** it (`lessons.py query`) |
33
39
 
34
40
  The documentation-track templates (`docmap.md`, `decisions.md`,
35
41
  `open-questions.md`, `docgate.sh`) are seeded **together**, and they are useful at
@@ -0,0 +1,96 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/ssheleg/task-pipeline/blob/main/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json",
4
+ "title": "backlog-sources/1 — docs/backlog-sources.json",
5
+ "description": "The declared task sources a wave-0 harvest reads (references/waves.md, Wave 0; references/backlog.md, A declared source list). Where a repository carries docs/backlog-sources.json, the harvest reads exactly the sources it lists, with the id, title and status columns each names, and a source of open work it does not list is reported as a finding. This schema is the rule for anyone using this skill. It is compatible with the Fabric workspace's source contract of the same name (cited as provenance, not authority), with one deliberate widening: `repository` may be any GitHub repository. Two rules a schema cannot state are the harvest's: each `path` exists as a regular file reached through no symlink, and no two declarations are identical.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "repository", "sources"],
9
+ "properties": {
10
+ "schema": {"const": 1},
11
+ "repository": {
12
+ "description": "The canonical URL of the repository the manifest lives in.",
13
+ "type": "string",
14
+ "pattern": "^https://github\\.com/[A-Za-z0-9][A-Za-z0-9-]{0,38}/\\.?[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"
15
+ },
16
+ "sources": {
17
+ "description": "1-100 declarations. A context-only repository still declares one.",
18
+ "type": "array",
19
+ "minItems": 1,
20
+ "maxItems": 100,
21
+ "items": {"$ref": "#/$defs/source"}
22
+ }
23
+ },
24
+ "$defs": {
25
+ "path": {
26
+ "description": "Relative, inside the repository, with no hidden, `.` or `..` segment.",
27
+ "type": "string",
28
+ "maxLength": 700,
29
+ "pattern": "^(?![./])(?!.*(?:^|/)\\.)(?!.*//)[^\\\\\\x00-\\x1f\\x7f?#%:]+$"
30
+ },
31
+ "source": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["path", "goal"],
35
+ "properties": {
36
+ "path": {"$ref": "#/$defs/path"},
37
+ "goal": {
38
+ "description": "The goal anchor the source serves.",
39
+ "enum": ["agent-workplace", "workflow-control", "interoperability", "reliable-work", "shared-context"]
40
+ },
41
+ "kind": {"enum": ["tasks", "context"], "default": "tasks"},
42
+ "format": {"enum": ["table", "checklist", "json-tasks"]},
43
+ "heading": {"type": "string", "description": "The section of the file the tasks sit under."},
44
+ "idColumn": {"type": "string", "minLength": 1, "pattern": "\\S"},
45
+ "titleColumn": {"type": "string", "minLength": 1, "pattern": "\\S"},
46
+ "statusColumn": {"type": "string", "minLength": 1, "pattern": "\\S"},
47
+ "defaultStatus": {"const": "open"},
48
+ "idPattern": {
49
+ "description": "A literal prefix, optionally followed by [0-9]+ or \\d+, anchored at both ends.",
50
+ "type": "string",
51
+ "pattern": "^\\^[A-Za-z0-9._/-]+(?:\\[0-9\\]\\+|\\\\d\\+)?\\$$"
52
+ },
53
+ "excludeIds": {
54
+ "type": "array",
55
+ "uniqueItems": true,
56
+ "items": {"type": "string", "minLength": 1, "pattern": "\\S"}
57
+ }
58
+ },
59
+ "allOf": [
60
+ {
61
+ "if": {"properties": {"kind": {"const": "context"}}, "required": ["kind"]},
62
+ "then": {"properties": {"path": {"pattern": "\\.md$"}}},
63
+ "else": {"required": ["format"]}
64
+ },
65
+ {
66
+ "if": {"properties": {"kind": {"const": "tasks"}, "format": {"const": "json-tasks"}}, "required": ["format"]},
67
+ "then": {"properties": {"path": {"pattern": "\\.json$"}}}
68
+ },
69
+ {
70
+ "if": {"properties": {"format": {"enum": ["table", "checklist"]}}, "required": ["format"]},
71
+ "then": {"properties": {"path": {"pattern": "\\.md$"}}}
72
+ },
73
+ {
74
+ "if": {"properties": {"format": {"const": "table"}}, "required": ["format"]},
75
+ "then": {
76
+ "required": ["idColumn", "titleColumn"],
77
+ "oneOf": [
78
+ {"required": ["statusColumn"], "not": {"required": ["defaultStatus"]}},
79
+ {"required": ["defaultStatus"], "not": {"required": ["statusColumn"]}}
80
+ ]
81
+ }
82
+ }
83
+ ]
84
+ }
85
+ },
86
+ "examples": [
87
+ {
88
+ "schema": 1,
89
+ "repository": "https://github.com/example-org/example-repo",
90
+ "sources": [
91
+ {"path": "docs/BACKLOG.md", "goal": "reliable-work", "format": "table", "heading": "Open", "idColumn": "id", "titleColumn": "title", "statusColumn": "status", "idPattern": "^B-[0-9]+$"},
92
+ {"path": "docs/ROADMAP.md", "goal": "shared-context", "kind": "context"}
93
+ ]
94
+ }
95
+ ]
96
+ }