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.
@@ -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.