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.
- package/CHANGELOG.md +105 -0
- package/README.md +42 -9
- package/SKILL-CARD.md +1 -1
- package/bin/task-pipeline.js +64 -33
- package/cursor/rules/task-pipeline.mdc +10 -5
- package/evals/RESULTS.md +1 -1
- package/evals/task-pipeline.evals.json +16 -0
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +4 -1
- package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
- package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +94 -25
- package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +76 -36
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +54 -13
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
- package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +108 -11
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
- 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
|
+
}
|