opencode-gbuild 0.4.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/README.md +204 -0
- package/agents/gbuild-auditor.md +182 -0
- package/agents/gbuild-reviewer.md +94 -0
- package/index.ts +122 -0
- package/package.json +34 -0
- package/reference/checklist.md +36 -0
- package/reference/cost-model.md +39 -0
- package/reference/failure-policies.md +31 -0
- package/reference/graph-format.md +240 -0
- package/reference/shapes.md +53 -0
- package/scripts/graph.py +231 -0
- package/scripts/test_graph.py +379 -0
- package/scripts/validate_format.py +369 -0
- package/skills/plan/SKILL.md +217 -0
- package/skills/pr/SKILL.md +100 -0
- package/skills/review/SKILL.md +41 -0
- package/skills/run/SKILL.md +99 -0
- package/skills/status/SKILL.md +84 -0
- package/templates/graph.json +110 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""gbuild graph format validator — the authoritative contract for graph.json.
|
|
3
|
+
|
|
4
|
+
graph.json is a CypherLite GraphData file (openable with `cypherlite <path>`).
|
|
5
|
+
This module checks it is both CypherLite-loadable and gbuild-correct: the
|
|
6
|
+
GraphData envelope, node shapes (Feature / Acceptance / GbuildNode), the three
|
|
7
|
+
relationship types (DEPENDS_ON / HAS_ACCEPTANCE / SATISFIES), id/counter
|
|
8
|
+
invariants, acyclicity, and coverage. Stdlib only — same no-install rule as
|
|
9
|
+
graph.py.
|
|
10
|
+
|
|
11
|
+
Single source of truth for the format: graph.py imports VALID_* and
|
|
12
|
+
SATISFIED_STATUSES from here, and its validate() calls validate() below.
|
|
13
|
+
|
|
14
|
+
CLI:
|
|
15
|
+
python3 validate_format.py <graph.json>
|
|
16
|
+
exit 0 + "valid: <feature>" on success
|
|
17
|
+
exit 1 + "invalid: <errors joined>" on failure
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
# --- enum sets (single definition site; graph.py imports these) -------------
|
|
25
|
+
|
|
26
|
+
VALID_NODE_TYPES = {"research", "decision", "code", "test", "verify", "chore"}
|
|
27
|
+
VALID_FAILURE_POLICIES = {"retry", "fallback", "skip", "repair", "escalate", "stop"}
|
|
28
|
+
VALID_MODEL_TIERS = {"cheap", "strong"}
|
|
29
|
+
SATISFIED_STATUSES = {"completed", "cancelled"}
|
|
30
|
+
|
|
31
|
+
# PascalCase type label <-> lowercase type property. Order matches VALID_NODE_TYPES.
|
|
32
|
+
TYPE_TO_LABEL = {
|
|
33
|
+
"research": "Research",
|
|
34
|
+
"decision": "Decision",
|
|
35
|
+
"code": "Code",
|
|
36
|
+
"test": "Test",
|
|
37
|
+
"verify": "Verify",
|
|
38
|
+
"chore": "Chore",
|
|
39
|
+
}
|
|
40
|
+
VALID_TYPE_LABELS = set(TYPE_TO_LABEL.values())
|
|
41
|
+
|
|
42
|
+
VALID_REL_TYPES = {"DEPENDS_ON", "HAS_ACCEPTANCE", "SATISFIES"}
|
|
43
|
+
|
|
44
|
+
SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
45
|
+
ACCEPTANCE_ID_RE = re.compile(r"^a-\d+$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GraphError(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_int(v):
|
|
53
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_str_list(v):
|
|
57
|
+
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate(graph):
|
|
61
|
+
"""Raise GraphError with every problem found, joined, rather than the first.
|
|
62
|
+
|
|
63
|
+
Collects all structural, shape, relationship, counter, acyclicity, and
|
|
64
|
+
coverage errors into one message so a single validate pass tells the author
|
|
65
|
+
everything to fix.
|
|
66
|
+
"""
|
|
67
|
+
errors = []
|
|
68
|
+
|
|
69
|
+
if not isinstance(graph, dict):
|
|
70
|
+
raise GraphError("graph root is not an object")
|
|
71
|
+
|
|
72
|
+
# --- GraphData envelope ---------------------------------------------------
|
|
73
|
+
for key in ("nodes", "relationships", "indexes", "constraints"):
|
|
74
|
+
if not isinstance(graph.get(key), list):
|
|
75
|
+
errors.append(f"top-level {key!r} must be a list")
|
|
76
|
+
for key in ("next_node_id", "next_rel_id"):
|
|
77
|
+
if not _is_int(graph.get(key)):
|
|
78
|
+
errors.append(f"top-level {key!r} must be an integer")
|
|
79
|
+
|
|
80
|
+
nodes = graph.get("nodes") if isinstance(graph.get("nodes"), list) else []
|
|
81
|
+
rels = graph.get("relationships") if isinstance(graph.get("relationships"), list) else []
|
|
82
|
+
|
|
83
|
+
if not nodes:
|
|
84
|
+
errors.append("graph has no nodes")
|
|
85
|
+
|
|
86
|
+
# --- node shape + id uniqueness ------------------------------------------
|
|
87
|
+
node_ids = set()
|
|
88
|
+
for n in nodes:
|
|
89
|
+
if not isinstance(n, dict):
|
|
90
|
+
errors.append("a node is not an object")
|
|
91
|
+
continue
|
|
92
|
+
nid = n.get("id")
|
|
93
|
+
if not _is_int(nid):
|
|
94
|
+
errors.append("a node has a missing or non-integer id")
|
|
95
|
+
continue
|
|
96
|
+
if nid in node_ids:
|
|
97
|
+
errors.append(f"duplicate node id: {nid}")
|
|
98
|
+
node_ids.add(nid)
|
|
99
|
+
if not isinstance(n.get("labels"), list) or not all(isinstance(l, str) for l in n.get("labels", [])):
|
|
100
|
+
errors.append(f"node {nid}: labels must be a list of strings")
|
|
101
|
+
if not isinstance(n.get("properties"), dict):
|
|
102
|
+
errors.append(f"node {nid}: properties must be an object")
|
|
103
|
+
|
|
104
|
+
by_id = {n["id"]: n for n in nodes if isinstance(n, dict) and _is_int(n.get("id"))}
|
|
105
|
+
labels_by_id = {nid: set(n.get("labels", [])) for nid, n in by_id.items()}
|
|
106
|
+
props_by_id = {nid: n.get("properties", {}) for nid, n in by_id.items() if isinstance(n.get("properties"), dict)}
|
|
107
|
+
|
|
108
|
+
# --- Feature node --------------------------------------------------------
|
|
109
|
+
feature_nodes = [nid for nid, ls in labels_by_id.items() if "Feature" in ls]
|
|
110
|
+
if len(feature_nodes) != 1:
|
|
111
|
+
errors.append(f"expected exactly one Feature node, found {len(feature_nodes)}")
|
|
112
|
+
feature_id = feature_nodes[0] if feature_nodes else None
|
|
113
|
+
if feature_id is not None:
|
|
114
|
+
p = props_by_id.get(feature_id, {})
|
|
115
|
+
for key in ("feature", "destination", "context"):
|
|
116
|
+
if not isinstance(p.get(key), str):
|
|
117
|
+
errors.append(f"Feature node: {key!r} must be a string")
|
|
118
|
+
if not _is_str_list(p.get("out_of_scope")):
|
|
119
|
+
errors.append("Feature node: 'out_of_scope' must be a list of strings")
|
|
120
|
+
if "acceptance" in p:
|
|
121
|
+
errors.append("Feature node must not carry an 'acceptance' property (use Acceptance nodes)")
|
|
122
|
+
|
|
123
|
+
# --- Acceptance nodes ----------------------------------------------------
|
|
124
|
+
acceptance_nodes = {} # id property -> node id
|
|
125
|
+
for nid, ls in labels_by_id.items():
|
|
126
|
+
if "Acceptance" not in ls:
|
|
127
|
+
continue
|
|
128
|
+
p = props_by_id.get(nid, {})
|
|
129
|
+
aid = p.get("id")
|
|
130
|
+
if not isinstance(aid, str) or not ACCEPTANCE_ID_RE.match(aid):
|
|
131
|
+
errors.append(f"node {nid} (Acceptance): 'id' must be a string like 'a-1'")
|
|
132
|
+
elif aid in acceptance_nodes:
|
|
133
|
+
errors.append(f"duplicate Acceptance id: {aid}")
|
|
134
|
+
else:
|
|
135
|
+
acceptance_nodes[aid] = nid
|
|
136
|
+
if not isinstance(p.get("text"), str) or not p.get("text", "").strip():
|
|
137
|
+
errors.append(f"node {nid} (Acceptance): 'text' must be a non-blank string")
|
|
138
|
+
|
|
139
|
+
# --- GbuildNode shape ----------------------------------------------------
|
|
140
|
+
gbuild_nodes = {} # slug -> node id
|
|
141
|
+
for nid, ls in labels_by_id.items():
|
|
142
|
+
if "GbuildNode" not in ls:
|
|
143
|
+
continue
|
|
144
|
+
p = props_by_id.get(nid, {})
|
|
145
|
+
slug = p.get("slug")
|
|
146
|
+
if not isinstance(slug, str) or not SLUG_RE.match(slug):
|
|
147
|
+
errors.append(f"node {nid} (GbuildNode): 'slug' must be a kebab-case string")
|
|
148
|
+
elif slug in gbuild_nodes:
|
|
149
|
+
errors.append(f"duplicate GbuildNode slug: {slug}")
|
|
150
|
+
else:
|
|
151
|
+
gbuild_nodes[slug] = nid
|
|
152
|
+
if not isinstance(p.get("title"), str):
|
|
153
|
+
errors.append(f"node {nid} (GbuildNode): 'title' must be a string")
|
|
154
|
+
ntype = p.get("type")
|
|
155
|
+
if ntype not in VALID_NODE_TYPES:
|
|
156
|
+
errors.append(f"node {nid} (GbuildNode): invalid type {ntype!r}")
|
|
157
|
+
contract = p.get("contract")
|
|
158
|
+
if not isinstance(contract, dict) or not isinstance(contract.get("input"), dict) or not isinstance(contract.get("output"), dict):
|
|
159
|
+
errors.append(f"node {nid} (GbuildNode): 'contract' must be {{input:{{}},output:{{}}}}")
|
|
160
|
+
acc = p.get("acceptance")
|
|
161
|
+
if not isinstance(acc, list) or len(acc) == 0:
|
|
162
|
+
errors.append(f"node {nid} (GbuildNode): 'acceptance' must be a non-empty list")
|
|
163
|
+
elif any(not str(c).strip() for c in acc):
|
|
164
|
+
errors.append(f"node {nid} (GbuildNode): 'acceptance' must not contain blank entries")
|
|
165
|
+
if p.get("verify") is not None and not isinstance(p.get("verify"), str):
|
|
166
|
+
errors.append(f"node {nid} (GbuildNode): 'verify' must be a string or null")
|
|
167
|
+
if p.get("failure_policy") not in VALID_FAILURE_POLICIES:
|
|
168
|
+
errors.append(f"node {nid} (GbuildNode): invalid failure_policy {p.get('failure_policy')!r}")
|
|
169
|
+
if p.get("model_tier") not in VALID_MODEL_TIERS:
|
|
170
|
+
errors.append(f"node {nid} (GbuildNode): invalid model_tier {p.get('model_tier')!r}")
|
|
171
|
+
if "satisfies" in p:
|
|
172
|
+
errors.append(f"node {nid} (GbuildNode): must not carry a 'satisfies' property (use SATISFIES edges)")
|
|
173
|
+
# type-label consistency
|
|
174
|
+
if ntype in TYPE_TO_LABEL:
|
|
175
|
+
expected_label = TYPE_TO_LABEL[ntype]
|
|
176
|
+
if expected_label not in ls:
|
|
177
|
+
errors.append(f"node {nid} (GbuildNode): type {ntype!r} requires label {expected_label!r}")
|
|
178
|
+
extra_type_labels = (ls & VALID_TYPE_LABELS) - {expected_label}
|
|
179
|
+
if extra_type_labels:
|
|
180
|
+
errors.append(f"node {nid} (GbuildNode): type labels {extra_type_labels} do not match type {ntype!r}")
|
|
181
|
+
|
|
182
|
+
gbuild_ids = {nid for nid, ls in labels_by_id.items() if "GbuildNode" in ls}
|
|
183
|
+
|
|
184
|
+
# --- relationship shape --------------------------------------------------
|
|
185
|
+
rel_ids = set()
|
|
186
|
+
depends_edges = [] # (start, end) for acyclicity
|
|
187
|
+
has_acc_ends = set() # acceptance node ids targeted by HAS_ACCEPTANCE
|
|
188
|
+
satisfies_ends = set() # acceptance node ids targeted by SATISFIES
|
|
189
|
+
depends_targets = set() # gbuild node ids that are depended upon (chore coverage)
|
|
190
|
+
for r in rels:
|
|
191
|
+
if not isinstance(r, dict):
|
|
192
|
+
errors.append("a relationship is not an object")
|
|
193
|
+
continue
|
|
194
|
+
rid = r.get("id")
|
|
195
|
+
if not _is_int(rid):
|
|
196
|
+
errors.append("a relationship has a missing or non-integer id")
|
|
197
|
+
continue
|
|
198
|
+
if rid in rel_ids:
|
|
199
|
+
errors.append(f"duplicate relationship id: {rid}")
|
|
200
|
+
rel_ids.add(rid)
|
|
201
|
+
rtype = r.get("type")
|
|
202
|
+
if rtype not in VALID_REL_TYPES:
|
|
203
|
+
errors.append(f"relationship {rid}: invalid type {rtype!r}")
|
|
204
|
+
continue
|
|
205
|
+
s, e = r.get("start_node"), r.get("end_node")
|
|
206
|
+
if s not in by_id:
|
|
207
|
+
errors.append(f"relationship {rid}: start_node {s!r} does not exist")
|
|
208
|
+
if e not in by_id:
|
|
209
|
+
errors.append(f"relationship {rid}: end_node {e!r} does not exist")
|
|
210
|
+
continue
|
|
211
|
+
if not isinstance(r.get("properties"), dict):
|
|
212
|
+
errors.append(f"relationship {rid}: properties must be an object")
|
|
213
|
+
# type-specific endpoint constraints
|
|
214
|
+
if rtype == "DEPENDS_ON":
|
|
215
|
+
if s not in gbuild_ids:
|
|
216
|
+
errors.append(f"relationship {rid} (DEPENDS_ON): start_node {s} must be a GbuildNode")
|
|
217
|
+
if e not in gbuild_ids:
|
|
218
|
+
errors.append(f"relationship {rid} (DEPENDS_ON): end_node {e} must be a GbuildNode")
|
|
219
|
+
else:
|
|
220
|
+
depends_edges.append((s, e))
|
|
221
|
+
depends_targets.add(e)
|
|
222
|
+
elif rtype == "HAS_ACCEPTANCE":
|
|
223
|
+
if feature_id is not None and s != feature_id:
|
|
224
|
+
errors.append(f"relationship {rid} (HAS_ACCEPTANCE): start_node {s} must be the Feature node")
|
|
225
|
+
if "Acceptance" not in labels_by_id.get(e, set()):
|
|
226
|
+
errors.append(f"relationship {rid} (HAS_ACCEPTANCE): end_node {e} must be an Acceptance node")
|
|
227
|
+
else:
|
|
228
|
+
has_acc_ends.add(e)
|
|
229
|
+
elif rtype == "SATISFIES":
|
|
230
|
+
if s not in gbuild_ids:
|
|
231
|
+
errors.append(f"relationship {rid} (SATISFIES): start_node {s} must be a GbuildNode")
|
|
232
|
+
if "Acceptance" not in labels_by_id.get(e, set()):
|
|
233
|
+
errors.append(f"relationship {rid} (SATISFIES): end_node {e} must be an Acceptance node")
|
|
234
|
+
else:
|
|
235
|
+
satisfies_ends.add(e)
|
|
236
|
+
|
|
237
|
+
# --- counter invariant ---------------------------------------------------
|
|
238
|
+
if nodes and _is_int(graph.get("next_node_id")):
|
|
239
|
+
expected_n = max(n["id"] for n in nodes if isinstance(n, dict) and _is_int(n.get("id"))) + 1
|
|
240
|
+
if graph["next_node_id"] != expected_n:
|
|
241
|
+
errors.append(f"next_node_id must be {expected_n} (max node id + 1), got {graph['next_node_id']}")
|
|
242
|
+
if rels and _is_int(graph.get("next_rel_id")):
|
|
243
|
+
expected_r = max(r["id"] for r in rels if isinstance(r, dict) and _is_int(r.get("id"))) + 1
|
|
244
|
+
if graph["next_rel_id"] != expected_r:
|
|
245
|
+
errors.append(f"next_rel_id must be {expected_r} (max rel id + 1), got {graph['next_rel_id']}")
|
|
246
|
+
|
|
247
|
+
# --- acyclicity ---------------------------------------------------------
|
|
248
|
+
if depends_edges and not any("DEPENDS_ON" in e for e in errors):
|
|
249
|
+
cycle = _detect_cycle(gbuild_ids, depends_edges)
|
|
250
|
+
if cycle:
|
|
251
|
+
errors.append(f"cycle detected among: {', '.join(sorted(str(c) for c in cycle))}")
|
|
252
|
+
|
|
253
|
+
# --- coverage -----------------------------------------------------------
|
|
254
|
+
for aid, anid in acceptance_nodes.items():
|
|
255
|
+
if anid not in satisfies_ends:
|
|
256
|
+
errors.append(f"Acceptance {aid!r} (node {anid}) is not covered by any SATISFIES edge")
|
|
257
|
+
if anid not in has_acc_ends:
|
|
258
|
+
errors.append(f"Acceptance {aid!r} (node {anid}) is not linked by any HAS_ACCEPTANCE edge")
|
|
259
|
+
# every chore must be depended on
|
|
260
|
+
for nid in gbuild_ids:
|
|
261
|
+
p = props_by_id.get(nid, {})
|
|
262
|
+
if p.get("type") == "chore" and nid not in depends_targets:
|
|
263
|
+
errors.append(f"chore node {nid} (slug {p.get('slug')!r}) is not depended on by any node")
|
|
264
|
+
|
|
265
|
+
if errors:
|
|
266
|
+
raise GraphError("; ".join(errors))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _detect_cycle(node_ids, edges):
|
|
270
|
+
"""Return a set of node ids involved in a cycle, or empty set if acyclic."""
|
|
271
|
+
adj = {nid: [] for nid in node_ids}
|
|
272
|
+
for s, e in edges:
|
|
273
|
+
adj.setdefault(s, []).append(e)
|
|
274
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
275
|
+
color = {nid: WHITE for nid in node_ids}
|
|
276
|
+
in_cycle = set()
|
|
277
|
+
|
|
278
|
+
def dfs(u, stack):
|
|
279
|
+
color[u] = GRAY
|
|
280
|
+
stack.append(u)
|
|
281
|
+
for v in adj.get(u, []):
|
|
282
|
+
if v not in color:
|
|
283
|
+
continue
|
|
284
|
+
if color[v] == GRAY:
|
|
285
|
+
# cycle: from v back to u along the stack
|
|
286
|
+
idx = stack.index(v)
|
|
287
|
+
in_cycle.update(stack[idx:])
|
|
288
|
+
elif color[v] == WHITE:
|
|
289
|
+
dfs(v, stack)
|
|
290
|
+
stack.pop()
|
|
291
|
+
color[u] = BLACK
|
|
292
|
+
|
|
293
|
+
for nid in node_ids:
|
|
294
|
+
if color.get(nid) == WHITE:
|
|
295
|
+
dfs(nid, [])
|
|
296
|
+
return in_cycle
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _cypherlite_cross_check(graph_path):
|
|
300
|
+
"""If cypherlite is on PATH, confirm CypherLite itself loads the file and
|
|
301
|
+
can run a trivial query. Catches serde/encoding issues the stdlib walk can't
|
|
302
|
+
(e.g. a top-level shape CypherLite's GraphData loader rejects). Returns an
|
|
303
|
+
error string on failure, None on success or when cypherlite is absent.
|
|
304
|
+
|
|
305
|
+
Absence is silent — cypherlite is an optional consumer, not a dependency.
|
|
306
|
+
"""
|
|
307
|
+
import shutil
|
|
308
|
+
import subprocess
|
|
309
|
+
bin_path = shutil.which("cypherlite")
|
|
310
|
+
if bin_path is None:
|
|
311
|
+
return None # not installed — skip, not a failure
|
|
312
|
+
res = subprocess.run(
|
|
313
|
+
[bin_path, str(graph_path), "-json",
|
|
314
|
+
"MATCH (n) RETURN count(n) AS c"],
|
|
315
|
+
capture_output=True, text=True, timeout=30,
|
|
316
|
+
)
|
|
317
|
+
# cypherlite returns exit 0 even on some load failures (it prints the error
|
|
318
|
+
# to stderr), so check both the exit code AND the output for failure markers.
|
|
319
|
+
out = (res.stderr + res.stdout).strip()
|
|
320
|
+
if res.returncode != 0:
|
|
321
|
+
return f"cypherlite refused to load the file: {out or 'unknown error'}"
|
|
322
|
+
if "Failed to open database" in out or "failed to parse JSON" in out:
|
|
323
|
+
return f"cypherlite refused to load the file: {out}"
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main(argv=None):
|
|
328
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
329
|
+
parser.add_argument("graph_path")
|
|
330
|
+
parser.add_argument("--no-cypherlite", action="store_true",
|
|
331
|
+
help="skip the optional cypherlite load cross-check even if the binary is present")
|
|
332
|
+
args = parser.parse_args(argv)
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
with open(args.graph_path) as f:
|
|
336
|
+
graph = json.load(f)
|
|
337
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
338
|
+
print(f"invalid: cannot read {args.graph_path}: {e}", file=sys.stderr)
|
|
339
|
+
return 1
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
validate(graph)
|
|
343
|
+
except GraphError as e:
|
|
344
|
+
print(f"invalid: {e}", file=sys.stderr)
|
|
345
|
+
return 1
|
|
346
|
+
|
|
347
|
+
# Optional cross-check: confirm CypherLite itself loads the file (catches
|
|
348
|
+
# serde/encoding issues the stdlib checks above can't). Skipped silently if
|
|
349
|
+
# cypherlite isn't installed — it's an optional consumer, not a dependency.
|
|
350
|
+
if not args.no_cypherlite:
|
|
351
|
+
cl_err = _cypherlite_cross_check(args.graph_path)
|
|
352
|
+
if cl_err is not None:
|
|
353
|
+
print(f"invalid: {cl_err}", file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
# best-effort feature slug for the success line
|
|
357
|
+
feature = "?"
|
|
358
|
+
for n in graph.get("nodes", []):
|
|
359
|
+
if isinstance(n, dict) and "Feature" in n.get("labels", []):
|
|
360
|
+
p = n.get("properties", {})
|
|
361
|
+
if isinstance(p.get("feature"), str):
|
|
362
|
+
feature = p["feature"]
|
|
363
|
+
break
|
|
364
|
+
print(f"valid: {feature}")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
sys.exit(main())
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Decomposes a feature into a real dependency graph in .gbuild/<feature>/graph.json — nodes with typed contracts, mandatory concrete acceptance criteria, and edges that pass the cut test. Use when starting new gbuild work, before /gbuild:run.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Plan the work described in `$ARGUMENTS` as a graph.
|
|
6
|
+
|
|
7
|
+
## The loop
|
|
8
|
+
|
|
9
|
+
The intended workflow: plan in ordinary conversation first, then chart that plan here. After this
|
|
10
|
+
skill: `/gbuild:run <slug>` once — it iterates waves until the graph completes. Then
|
|
11
|
+
`/gbuild:review <slug>`; if it finds issues, reopen with `/gbuild:plan <slug> --add "<fix>"` and
|
|
12
|
+
`/gbuild:run` again; if clean, `/gbuild:pr <slug>`.
|
|
13
|
+
|
|
14
|
+
Read `${CLAUDE_PLUGIN_ROOT}/reference/graph-format.md` before doing anything — it's the normative schema. `${CLAUDE_PLUGIN_ROOT}/reference/shapes.md`,
|
|
15
|
+
`${CLAUDE_PLUGIN_ROOT}/reference/failure-policies.md`, and `${CLAUDE_PLUGIN_ROOT}/reference/cost-model.md` inform the choices below.
|
|
16
|
+
`${CLAUDE_PLUGIN_ROOT}/reference/checklist.md` is the self-check at the end.
|
|
17
|
+
|
|
18
|
+
## Mode
|
|
19
|
+
|
|
20
|
+
| arguments | mode |
|
|
21
|
+
| --------------------------------------------- | ----------------------- |
|
|
22
|
+
| a description, link, or file path | **Chart** — new feature |
|
|
23
|
+
| an existing feature slug with `--add "<req>"` | **Reopen** |
|
|
24
|
+
|
|
25
|
+
A bare slug with no `.gbuild/<slug>/` is a description, not a slug — chart it.
|
|
26
|
+
|
|
27
|
+
## Chart
|
|
28
|
+
|
|
29
|
+
### 1. Slug and branch
|
|
30
|
+
|
|
31
|
+
Derive the feature slug from `$ARGUMENTS`: kebab-case, ≤4 words, names the outcome not the mechanism
|
|
32
|
+
(`oauth-device-flow`, not `add-some-auth-stuff`). It's the `.gbuild/<slug>/` directory name and the
|
|
33
|
+
`feature` field in `graph.json` — immutable once charted.
|
|
34
|
+
|
|
35
|
+
Then get onto a branch for it, so `run`'s commits land somewhere isolated:
|
|
36
|
+
|
|
37
|
+
- Working tree dirty → stop and ask. Don't stash or sweep someone else's changes into your branch.
|
|
38
|
+
- Already on a branch ending in `<slug>` → stay there, say so.
|
|
39
|
+
- Otherwise `git checkout -b <prefix>/<slug>` from current HEAD. Take `<prefix>` from the repo's
|
|
40
|
+
convention — project or user instructions first, else the dominant pattern in `git branch --list`
|
|
41
|
+
— and fall back to `gbuild` when there is none.
|
|
42
|
+
|
|
43
|
+
Report the branch. Everything below happens on it.
|
|
44
|
+
|
|
45
|
+
### 2. Analyse the codebase
|
|
46
|
+
|
|
47
|
+
Read the project manifest(s), directory structure, `README.md`, and any existing `.gbuild/*/graph.json`
|
|
48
|
+
for related in-flight work. Resolve file paths or URLs in `$ARGUMENTS`; if a reference fails to load,
|
|
49
|
+
say so and ask.
|
|
50
|
+
|
|
51
|
+
### 3. Validate and explore
|
|
52
|
+
|
|
53
|
+
Triage `$ARGUMENTS` against what step 2 found. The point is to catch plans that are wrong *before*
|
|
54
|
+
they're charted — not to interrogate every input. If the description is sharp and the scope is
|
|
55
|
+
unambiguous, this step is a quick confirmation and you move on.
|
|
56
|
+
|
|
57
|
+
**Classify the input into one of:**
|
|
58
|
+
|
|
59
|
+
- **Clear** — names a concrete outcome, scope is unambiguous, no assumption risky enough to
|
|
60
|
+
invalidate the graph if wrong. This is the default when a competent spec lands. Skip to step 4.
|
|
61
|
+
Don't manufacture questions for a plan that already makes sense.
|
|
62
|
+
- **Gappy** — missing constraints, ambiguous terms, or a destination that could mean two different
|
|
63
|
+
things. Ask. Batch **all** clarifying questions in one message; cap at ~5. If you need more, the
|
|
64
|
+
input is underspecified — say so and ask the user to rewrite it rather than play 20 questions.
|
|
65
|
+
Fold the answers in, then proceed.
|
|
66
|
+
- **Risky** — the plan rests on an assumption that, if wrong, invalidates nodes or edges (a library
|
|
67
|
+
can do X, a data source exists at a given shape, a perf budget holds). Don't just ask about it —
|
|
68
|
+
**resolve** it. Find evidence in the codebase or docs, or chart a prototype (below). State the
|
|
69
|
+
assumption, what's at risk, and how you resolved it. A resolved assumption is a fact the graph
|
|
70
|
+
rests on; an unresolved one is a hole.
|
|
71
|
+
|
|
72
|
+
**Explore alternatives when the destination doesn't dictate the mechanism.** If two plausible
|
|
73
|
+
approaches reach the outcome, name each in one sentence, pick one, say why in one sentence. Skip
|
|
74
|
+
the survey when there's only one real way — don't perform thoroughness for its own sake.
|
|
75
|
+
|
|
76
|
+
**Prototypes are nodes, not side quests.** An unknown that needs code or a probe to resolve becomes
|
|
77
|
+
a `research`-typed GbuildNode in the first wave — `plan` charts it, `run` executes it. `plan` does
|
|
78
|
+
not run prototypes inline (the "Plan, don't do" rule below). The one exception: a check so cheap it
|
|
79
|
+
finishes in a single tool call (grep, read a file, check an installed version) — do that inline and
|
|
80
|
+
note the result. A prototype worth charting names the specific question it answers in its
|
|
81
|
+
`contract.output`, and its `acceptance` is the answer to that question.
|
|
82
|
+
|
|
83
|
+
### 4. Name the destination
|
|
84
|
+
|
|
85
|
+
Ask, one question at a time, until `destination` fits in one line — the thing that's true once this is
|
|
86
|
+
done. Do not proceed until it's that sharp; everything past it goes in `out_of_scope`.
|
|
87
|
+
|
|
88
|
+
### 5. Decompose into nodes, not steps
|
|
89
|
+
|
|
90
|
+
List the real pieces of work. For each pair, apply the **cut test** (`${CLAUDE_PLUGIN_ROOT}/reference/graph-format.md`):
|
|
91
|
+
does one actually read the other's output? If no, they're independent — same wave, not a chain. This is
|
|
92
|
+
the step hone-ai v1 skipped, and the whole reason gbuild exists: state the true dependency structure,
|
|
93
|
+
don't default to the order you thought of things in.
|
|
94
|
+
|
|
95
|
+
Classify each cluster's shape per `${CLAUDE_PLUGIN_ROOT}/reference/shapes.md` (chain / diamond / router / controlled cycle).
|
|
96
|
+
A controlled cycle needs its round cap and dedup rule decided now, not left to `run` to invent.
|
|
97
|
+
|
|
98
|
+
The graph is a **CypherLite GraphData file** (see `${CLAUDE_PLUGIN_ROOT}/reference/graph-format.md` — normative). You
|
|
99
|
+
write three kinds of nodes and three kinds of relationships:
|
|
100
|
+
|
|
101
|
+
- **`Feature` node** (id 1) — one per graph, properties `feature` (the slug), `destination`, `context`,
|
|
102
|
+
`out_of_scope` (array). No `acceptance` property here.
|
|
103
|
+
- **`Acceptance` nodes** — one per feature-level bullet, properties `id` (`a-1`, `a-2`, …) and `text`.
|
|
104
|
+
Each linked `Feature -[:HAS_ACCEPTANCE]-> Acceptance`.
|
|
105
|
+
- **`GbuildNode`s** — one per unit of work. Integer `id` (sequential after the Feature and Acceptance
|
|
106
|
+
nodes), `labels` `["GbuildNode", <PascalCase type>]`, properties `slug` / `title` / `type` (lowercase
|
|
107
|
+
enum) / `contract` (`{input,output}`) / `acceptance` (node-level local criteria) / `verify` / `failure_policy`
|
|
108
|
+
/ `model_tier`. No `satisfies` property — use a `SATISFIES` edge instead.
|
|
109
|
+
- **`DEPENDS_ON` relationships** — one per dependency that passed the cut test. Direction
|
|
110
|
+
dependent→dependency (`start_node` depends on `end_node`). Both endpoints are GbuildNodes.
|
|
111
|
+
- **`SATISFIES` relationships** — `GbuildNode -[:SATISFIES]-> Acceptance` for each global criterion a
|
|
112
|
+
node covers.
|
|
113
|
+
- **`HAS_ACCEPTANCE` relationships** — `Feature -[:HAS_ACCEPTANCE]-> Acceptance`, one per bullet.
|
|
114
|
+
|
|
115
|
+
For each GbuildNode, write:
|
|
116
|
+
|
|
117
|
+
- `id` — integer, sequential, unique, never reused. Authoring convenience; `slug` is the stable identity.
|
|
118
|
+
- `slug` — filename-safe, kebab-case, immutable once created. This is what skills and checkpoint files
|
|
119
|
+
key on; it's how nodes are referred to in anything the user reads.
|
|
120
|
+
- `type` — `research | decision | code | test | verify | chore`. Set both the lowercase `type` property
|
|
121
|
+
and the matching PascalCase label (`Code`, `Research`, …).
|
|
122
|
+
- `contract.input` / `contract.output` — structured shapes, not prose. A downstream node's `input`
|
|
123
|
+
should be able to reference an upstream node's `output` field directly.
|
|
124
|
+
- `acceptance` — **mandatory, non-empty, concrete.** No vague adjectives ("looks correct", "works
|
|
125
|
+
well"). A criterion a fresh context with no other information couldn't check is not concrete enough.
|
|
126
|
+
Rewrite it until it names a file, a shape, a value, or a command's result.
|
|
127
|
+
- `verify` — only set when this node needs something *beyond* the standard `gbuild-reviewer` pass
|
|
128
|
+
(adversarial check, fact-check, human approval). Usually `null`.
|
|
129
|
+
- `failure_policy` — pick per `${CLAUDE_PLUGIN_ROOT}/reference/failure-policies.md`, don't default everything to `retry`.
|
|
130
|
+
- `model_tier` — `strong` only where judgement, not throughput, is the bottleneck
|
|
131
|
+
(`${CLAUDE_PLUGIN_ROOT}/reference/cost-model.md`).
|
|
132
|
+
|
|
133
|
+
Wire each dependency as a `DEPENDS_ON` relationship (not a `dependencies` array on the node), and each
|
|
134
|
+
criterion a node covers as a `SATISFIES` relationship (not a `satisfies` array). Set `next_node_id` and
|
|
135
|
+
`next_rel_id` to `max(id)+1` for each kind. `${CLAUDE_PLUGIN_ROOT}/templates/graph.json` is a complete worked example.
|
|
136
|
+
|
|
137
|
+
### 6. Write the global acceptance bar
|
|
138
|
+
|
|
139
|
+
The feature-level bar is one `Acceptance` node per bullet, each linked `Feature -[:HAS_ACCEPTANCE]-> Acceptance`,
|
|
140
|
+
with ids `a-1`, `a-2`, …. Every bullet must end up covered by at least one node's `SATISFIES` edge —
|
|
141
|
+
that's checked in step 7 and enforced by the validator.
|
|
142
|
+
|
|
143
|
+
### 7. Self-check against the checklist
|
|
144
|
+
|
|
145
|
+
Walk every item in `${CLAUDE_PLUGIN_ROOT}/reference/checklist.md` against the graph you just wrote. Fix the graph, don't
|
|
146
|
+
annotate around a failing item.
|
|
147
|
+
|
|
148
|
+
### 8. Write and validate
|
|
149
|
+
|
|
150
|
+
Write `.gbuild/<slug>/graph.json` (a CypherLite GraphData file). **Run the format validator first:**
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/validate_format.py .gbuild/<slug>/graph.json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If it prints `invalid: ...`, fix the file and re-run — never hand off or commit a file that fails
|
|
157
|
+
validation. The validator is the format gate; it checks the GraphData envelope, node shapes, the three
|
|
158
|
+
relationship types, id/counter invariants, acyclicity, and coverage. **Run it after every change** —
|
|
159
|
+
initial write, reopen, any hand-edit, any fix-up — before doing anything else with the file.
|
|
160
|
+
|
|
161
|
+
Then confirm run-readiness:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/graph.py .gbuild/<slug>/graph.json --status
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The graph is now queryable with Cypher **if the `cypherlite` binary is on PATH** (`command -v
|
|
168
|
+
cypherlite`) — in that case you can mention it to the user:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
cypherlite .gbuild/<slug>/graph.json "MATCH (n:GbuildNode) RETURN n.slug, n.type"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
If the binary is absent, skip this without comment. CypherLite is an optional consumer, never a
|
|
175
|
+
requirement — `validate_format.py` already cross-checks with it when present and passes without it,
|
|
176
|
+
and the user should never be asked to install it.
|
|
177
|
+
|
|
178
|
+
### 9. Fire research nodes
|
|
179
|
+
|
|
180
|
+
For each `research`-typed node in the initial wave, dispatch a subagent **in parallel** (one message,
|
|
181
|
+
multiple `Agent` calls). Give each the node's `contract.input`, `acceptance`, and enough context to
|
|
182
|
+
work independently. Each writes its `output` to `.gbuild/<slug>/nodes/<id>.json` directly (`status:
|
|
183
|
+
completed`) — these still get a `gbuild-reviewer` pass before `run` will treat them as satisfying
|
|
184
|
+
anyone's dependency, same as every other node type.
|
|
185
|
+
|
|
186
|
+
### 10. Commit and stop
|
|
187
|
+
|
|
188
|
+
Commit `.gbuild/<slug>/` unless the repo ignores it (`git check-ignore -q .gbuild/`; if true, don't
|
|
189
|
+
stage anything under it). Message: `<slug>: chart graph`.
|
|
190
|
+
|
|
191
|
+
Report the branch, the wave decomposition, and the frontier. Close with `next: /gbuild:run <slug>`.
|
|
192
|
+
|
|
193
|
+
## Reopen
|
|
194
|
+
|
|
195
|
+
`--add "<requirement>"`:
|
|
196
|
+
|
|
197
|
+
0. Check out the feature's existing branch if you're not on it. Never chart a reopen onto a different
|
|
198
|
+
branch than the one the graph was charted on.
|
|
199
|
+
1. Add a new `Acceptance` node (next `a-` id) + a `Feature -[:HAS_ACCEPTANCE]-> Acceptance` edge for the
|
|
200
|
+
requirement.
|
|
201
|
+
2. Decompose whatever new GbuildNodes it needs, wire `DEPENDS_ON` edges against the *existing* graph (a
|
|
202
|
+
new node may depend on an already-completed one — that's fine, its dependency is satisfied) and
|
|
203
|
+
`SATISFIES` edges to the new (or existing) `Acceptance` nodes it covers. Assign the next available
|
|
204
|
+
integer ids, bump `next_node_id`/`next_rel_id`. Re-run the checklist against the delta.
|
|
205
|
+
3. **Re-run `validate_format.py`** (counters, coverage, acyclicity all re-checked on the mutated graph),
|
|
206
|
+
then `graph.py --status`. Commit (`<slug>: reopen graph — <what was added>`), report the new frontier.
|
|
207
|
+
|
|
208
|
+
If the requirement needs no new nodes (an existing node's contract already covers it), say so and stop
|
|
209
|
+
— don't manufacture graph churn.
|
|
210
|
+
|
|
211
|
+
## Rules
|
|
212
|
+
|
|
213
|
+
- **Plan, don't do.** Nodes here get *defined*, not executed — even `research` nodes just write
|
|
214
|
+
findings to their own checkpoint, they don't touch anything `run` is responsible for.
|
|
215
|
+
- **Acceptance criteria are the bar, not a formality.** A node with vague acceptance is a node
|
|
216
|
+
`gbuild-reviewer` can't actually check — that's a planning failure, not something to fix later.
|
|
217
|
+
- **Refer to nodes by title in anything the user reads.** IDs are for the graph, not the conversation.
|