opencode-gbuild 0.5.0 → 0.6.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.
@@ -4,6 +4,9 @@ description: Reports a gbuild feature's graph — frontier, blocked, in-flight,
4
4
 
5
5
  Report status for `$ARGUMENTS`.
6
6
 
7
+ Read `${CLAUDE_PLUGIN_ROOT}/reference/cypher.md` first and run its version check. This skill only reads
8
+ the store; `DB` below is `.gbuild/<feature>/db`.
9
+
7
10
  ## Mode
8
11
 
9
12
  | arguments | mode |
@@ -15,71 +18,57 @@ Report status for `$ARGUMENTS`.
15
18
 
16
19
  ### 1. Load
17
20
 
18
- `.gbuild/<feature>/db/graph.json` (a CypherLite GraphData file — GbuildNodes have a `slug` property and
19
- are linked by `DEPENDS_ON` relationships; feature-level acceptance is `Acceptance` nodes linked by
20
- `HAS_ACCEPTANCE`/`SATISFIES`) and every `.gbuild/<feature>/nodes/*.json` checkpoint (keyed by slug; the
21
- `nodes/` dir is a sibling of the `db/` graph folder). This is a small, bounded read — status doesn't
22
- scale with graph size the way loading full node prose would.
23
-
24
- ### 2. Compute
25
-
26
21
  ```
27
- python3 ${CLAUDE_PLUGIN_ROOT}/scripts/graph.py .gbuild/<feature>/db/graph.json --status
22
+ cypherlite DB -json < ${CLAUDE_PLUGIN_ROOT}/cypher/status.cypher
23
+ cypherlite DB -json < ${CLAUDE_PLUGIN_ROOT}/cypher/validate.cypher
28
24
  ```
29
25
 
30
- Gives `frontier` / `blocked` / `in_flight` / `completed` / `cancelled` / `failed` / `waves` (all as slug
31
- lists) directly — don't re-derive these by hand.
26
+ One call after the other. `status.cypher` gives one row per node with its derived `state`
27
+ (`frontier` / `blocked` / `in_progress` / `completed` / `cancelled` / `failed`), `wave`, `waiting_on`,
28
+ review summary and timestamps — don't re-derive any of these by hand. This is a small, bounded read —
29
+ status doesn't scale with graph size the way loading full node prose would.
32
30
 
33
- ### 3. Render the graph
31
+ ### 2. Render the graph
34
32
 
35
- Draw the DAG with a marker per node status:
33
+ Draw the DAG with a marker per node state:
36
34
 
37
35
  ```
38
- ✓ completed ◐ in_progress ○ pending (frontier) ● pending (blocked) ✗ failed
36
+ ✓ completed ◐ in_progress ○ frontier ● blocked ✗ failed – cancelled
39
37
  ```
40
38
 
41
- Annotate blocked nodes with what they're waiting on: `● d-join-and-sum ← b-consume-doubled, c-consume-squared`.
39
+ Annotate blocked nodes with what they're waiting on (`waiting_on`):
40
+ `● d-join-and-sum ← b-consume-doubled, c-consume-squared`.
42
41
 
43
- Group by wave (from `--waves`) so fan-out is visible in the layout, not just in the dependency list.
42
+ Group by `wave` so fan-out is visible in the layout, not just in the dependency list.
44
43
 
45
- ### 4. Review verdicts
44
+ ### 3. Review verdicts
46
45
 
47
- For every `completed` node, one line: pass outright, or passed after N repair attempts, from its
48
- checkpoint's `review` field. For `failed` nodes, the last review's failed acceptance bullets verbatim —
49
- this is what a human needs to make the `escalate`/`stop` call.
46
+ For every `completed` node, one line: passed outright (`review_attempts: 1`), or passed after N repair
47
+ attempts. For `failed` nodes, `last_failed_criteria` verbatim — this is what a human needs to make the
48
+ `escalate`/`stop` call.
50
49
 
51
- ### 5. Concurrency evidence
50
+ ### 4. Concurrency evidence
52
51
 
53
- For each wave with more than one node, compare `started_at` timestamps across that wave's checkpoints.
54
- Report whether they actually overlapped (real concurrent dispatch) or ran sequentially despite being in
55
- the same wave (a sign the fallback backend was invoked one node at a time — a bug in how `run` was
56
- driven, not a graph problem).
52
+ For each wave with more than one completed node, compare the implementing agents' `started_at` /
53
+ `completed_at` intervals. Report whether they actually overlapped (real concurrent dispatch) or ran
54
+ back-to-back despite being in the same wave (a sign the fallback backend was invoked one node at a time —
55
+ a bug in how `run` was driven, not a graph problem).
57
56
 
58
- ### 6. Next action
57
+ ### 5. Next action
59
58
 
60
59
  One priority-ordered list: what `/gbuild:run <feature>` would do next (the current frontier), and
61
- anything needing a human decision first (`failed`/`escalate`d nodes, an empty frontier with incomplete
62
- nodes remaining).
63
-
64
- ### 7. Invariant checks
65
-
66
- Numbered, only reporting violations (silence on an item means it passed, not that it was skipped):
60
+ anything needing a human decision first (`failed` nodes, an empty frontier with incomplete nodes
61
+ remaining, `in_progress` nodes when no run is active — an interrupted wave `run` will reset).
67
62
 
68
- 1. Every GbuildNode has non-empty `acceptance` (should already be caught by `validate_format.py`'s own
69
- validation — report if a node manages to lack it anyway, since that means the file was hand-edited or
70
- written by something other than `plan`).
71
- 2. Every `Acceptance` node is `SATISFIES`-covered by at least one GbuildNode (enforced by
72
- `validate_format.py` — report if one slips through anyway).
73
- 3. `DEPENDS_ON` is acyclic (validated by `validate_format.py`/`graph.py` already; report if it somehow
74
- wasn't checked).
75
- 4. No `completed` node's checkpoint is missing a `review` field with `verdict: pass` — a node marked
76
- complete without a passing review means `run` skipped the review step.
77
- 5. Every `chore`-type GbuildNode is the target of at least one `DEPENDS_ON` edge.
63
+ ### 6. Invariant checks
78
64
 
79
- If all five are clear, say so in one line — don't let silence be ambiguous between "checked, clear" and
80
- "not checked."
65
+ `validate.cypher` is the invariant check — it covers non-empty acceptance, acceptance coverage,
66
+ acyclicity, completed nodes without a passing review or missing an output value, orphan chores, and the
67
+ `FROM` cut test. Report every row it returned, numbered. If it returned `[]`, say so in one line — don't
68
+ let silence be ambiguous between "checked, clear" and "not checked."
81
69
 
82
70
  ## All-features mode
83
71
 
84
- For each `.gbuild/<slug>/db/graph.json` found, one line: slug, node counts by status, and whether the
85
- frontier is empty (done or stuck) or has ready work. Point at `/gbuild:status <slug>` for detail.
72
+ For each `.gbuild/<slug>/db` found, run `status.cypher` — one store at a time — and print one line: slug,
73
+ node counts by state, and whether the frontier is empty (done or stuck) or has ready work. Point at
74
+ `/gbuild:status <slug>` for detail.
@@ -0,0 +1,57 @@
1
+ // A worked 4-node diamond: A produces a value, B and C each consume it
2
+ // independently, D joins both. This is exactly the script plan writes for a
3
+ // new feature — one atomic statement, piped into the store:
4
+ // cypherlite .gbuild/<slug>/db < plan.cypher
5
+ // Variables (f, a1, a, a_value, ...) are only scoped to this one statement, so
6
+ // the whole graph is created in a single CREATE chain.
7
+ .begin
8
+ CREATE (f:Feature {
9
+ feature: 'example-diamond',
10
+ destination: 'A tiny worked example: A produces a shared value, B and C each consume it independently, D joins both.',
11
+ context: "Ships with the plugin as the toy graph used in gbuild's own tests. Not a real feature.",
12
+ out_of_scope: ['Anything beyond demonstrating fan-out and join scheduling.']
13
+ })
14
+ CREATE (a1:Acceptance {id: 'a-1', text: 'run dispatches B and C concurrently, not one after the other'})
15
+ CREATE (a2:Acceptance {id: 'a-2', text: 'D does not start until both B and C are recorded completed'})
16
+ CREATE (f)-[:HAS_ACCEPTANCE]->(a1), (f)-[:HAS_ACCEPTANCE]->(a2)
17
+
18
+ CREATE (a:GbuildNode:Chore {
19
+ slug: 'a-produce-shared-value', title: 'Produce the shared value B and C both need', type: 'chore',
20
+ acceptance: ['output.value is a single integer'],
21
+ failure_policy: 'retry', model_tier: 'cheap', status: 'pending'
22
+ })
23
+ CREATE (a)-[:OUTPUT]->(a_value:Field {name: 'value', shape: '<integer>'})
24
+
25
+ CREATE (b:GbuildNode:Code {
26
+ slug: 'b-consume-doubled', title: 'Consume the shared value, produce its double', type: 'code',
27
+ acceptance: ['output.doubled equals input.value multiplied by 2'],
28
+ failure_policy: 'repair', model_tier: 'cheap', status: 'pending'
29
+ })
30
+ CREATE (b)-[:DEPENDS_ON]->(a)
31
+ CREATE (b)-[:INPUT]->(:Field {name: 'value', shape: '<integer>'})-[:FROM]->(a_value)
32
+ CREATE (b)-[:OUTPUT]->(b_doubled:Field {name: 'doubled', shape: '<integer>'})
33
+ CREATE (b)-[:SATISFIES]->(a1)
34
+
35
+ CREATE (c:GbuildNode:Code {
36
+ slug: 'c-consume-squared', title: 'Consume the shared value, produce its square', type: 'code',
37
+ acceptance: ['output.squared equals input.value raised to the power of 2'],
38
+ failure_policy: 'repair', model_tier: 'cheap', status: 'pending'
39
+ })
40
+ CREATE (c)-[:DEPENDS_ON]->(a)
41
+ CREATE (c)-[:INPUT]->(:Field {name: 'value', shape: '<integer>'})-[:FROM]->(a_value)
42
+ CREATE (c)-[:OUTPUT]->(c_squared:Field {name: 'squared', shape: '<integer>'})
43
+ CREATE (c)-[:SATISFIES]->(a1)
44
+
45
+ CREATE (d:GbuildNode:Code {
46
+ slug: 'd-join-and-sum', title: 'Join B and C, produce their sum', type: 'code',
47
+ acceptance: ['output.sum equals input.doubled plus input.squared',
48
+ 'only runs after both b-consume-doubled and c-consume-squared are recorded completed'],
49
+ failure_policy: 'repair', model_tier: 'cheap', status: 'pending'
50
+ })
51
+ CREATE (d)-[:DEPENDS_ON]->(b), (d)-[:DEPENDS_ON]->(c)
52
+ CREATE (d)-[:INPUT]->(:Field {name: 'doubled', shape: '<integer>'})-[:FROM]->(b_doubled)
53
+ CREATE (d)-[:INPUT]->(:Field {name: 'squared', shape: '<integer>'})-[:FROM]->(c_squared)
54
+ CREATE (d)-[:OUTPUT]->(:Field {name: 'sum', shape: '<integer>'})
55
+ CREATE (d)-[:SATISFIES]->(a2);
56
+ .commit
57
+ .checkpoint
package/scripts/graph.py DELETED
@@ -1,239 +0,0 @@
1
- #!/usr/bin/env python3
2
- """gbuild graph utility: validate, topo-order, and query a graph.json feature graph.
3
-
4
- graph.json is a CypherLite GraphData file. It lives at
5
- `.gbuild/<feature>/db/graph.json` so CypherLite can open the `db/` folder as a
6
- JSON-store database (CypherLite opens a *directory*, not a bare `.json` file)
7
- and query it with Cypher. This module is the query layer (topo waves, frontier,
8
- status report) over that file. Format validation lives in validate_format.py
9
- (the single source of truth for the contract); this module imports and re-exports
10
- its validate() and enum sets.
11
-
12
- Stdlib only, no pip install required. Schema: ../reference/graph-format.md
13
-
14
- CLI:
15
- python3 graph.py <feature>/db/graph.json --waves print the topological wave decomposition
16
- python3 graph.py <feature>/db/graph.json --frontier print node ids ready to run right now
17
- python3 graph.py <feature>/db/graph.json --status print the full status report (default)
18
-
19
- Checkpoints are read from <feature>/nodes/<slug>.json by default, or --state-dir.
20
- """
21
- import argparse
22
- import json
23
- import sys
24
- from pathlib import Path
25
-
26
- # Format contract + enum sets live in validate_format (single definition site).
27
- from validate_format import (
28
- GraphError,
29
- SATISFIED_STATUSES,
30
- VALID_FAILURE_POLICIES,
31
- VALID_MODEL_TIERS,
32
- VALID_NODE_TYPES,
33
- VALID_TYPE_LABELS,
34
- validate,
35
- )
36
-
37
-
38
- # --- helpers over the GraphData shape ---------------------------------------
39
-
40
- def _labels(node):
41
- return set(node.get("labels", []))
42
-
43
-
44
- def _props(node):
45
- return node.get("properties", {}) if isinstance(node.get("properties"), dict) else {}
46
-
47
-
48
- def gbuild_nodes(graph):
49
- """GbuildNode-labelled nodes, as a list."""
50
- return [n for n in graph.get("nodes", []) if "GbuildNode" in _labels(n)]
51
-
52
-
53
- def node_by_id(graph, nid):
54
- for n in graph.get("nodes", []):
55
- if n.get("id") == nid:
56
- return n
57
- return None
58
-
59
-
60
- def node_by_slug(graph, slug):
61
- for n in gbuild_nodes(graph):
62
- if _props(n).get("slug") == slug:
63
- return n
64
- return None
65
-
66
-
67
- def slug_of(graph, nid):
68
- n = node_by_id(graph, nid)
69
- return _props(n).get("slug") if n else None
70
-
71
-
72
- def id_of_slug(graph, slug):
73
- n = node_by_slug(graph, slug)
74
- return n.get("id") if n else None
75
-
76
-
77
- def deps(graph, node_id):
78
- """node_ids this node depends on (DEPENDS_ON end_nodes)."""
79
- return [
80
- r["end_node"]
81
- for r in graph.get("relationships", [])
82
- if r.get("type") == "DEPENDS_ON" and r.get("start_node") == node_id
83
- ]
84
-
85
-
86
- def satisfies(graph, node_id):
87
- """acceptance node_ids this node covers (SATISFIES end_nodes)."""
88
- return [
89
- r["end_node"]
90
- for r in graph.get("relationships", [])
91
- if r.get("type") == "SATISFIES" and r.get("start_node") == node_id
92
- ]
93
-
94
-
95
- def feature(graph):
96
- """the single Feature node, or None."""
97
- for n in graph.get("nodes", []):
98
- if "Feature" in _labels(n):
99
- return n
100
- return None
101
-
102
-
103
- def acceptance_nodes(graph):
104
- return [n for n in graph.get("nodes", []) if "Acceptance" in _labels(n)]
105
-
106
-
107
- # --- core query layer --------------------------------------------------------
108
-
109
- def load_graph(path):
110
- with open(path) as f:
111
- return json.load(f)
112
-
113
-
114
- def topo_waves(graph):
115
- """Layer GbuildNodes into parallel-safe waves by slug.
116
-
117
- wave N contains every node whose DEPENDS_ON targets all resolved in <N.
118
- Returns a list of lists of slugs.
119
- """
120
- gn = gbuild_nodes(graph)
121
- # map node id -> slug, and slug -> dep slugs
122
- id_to_slug = {n["id"]: _props(n).get("slug") for n in gn}
123
- remaining = {id_to_slug[n["id"]]: n for n in gn}
124
- resolved = set()
125
- waves = []
126
-
127
- while remaining:
128
- wave = []
129
- for slug, node in remaining.items():
130
- dep_ids = deps(graph, node["id"])
131
- dep_slugs = {id_to_slug.get(d) for d in dep_ids}
132
- if dep_slugs.issubset(resolved):
133
- wave.append(slug)
134
- if not wave:
135
- raise GraphError(f"cycle detected among: {', '.join(sorted(remaining))}")
136
- wave.sort()
137
- waves.append(wave)
138
- for slug in wave:
139
- resolved.add(slug)
140
- del remaining[slug]
141
-
142
- return waves
143
-
144
-
145
- def read_checkpoint(state_dir, slug):
146
- path = Path(state_dir) / f"{slug}.json"
147
- if not path.exists():
148
- return {"status": "pending"}
149
- with open(path) as f:
150
- return json.load(f)
151
-
152
-
153
- def node_statuses(graph, state_dir):
154
- """slug -> status, for every GbuildNode."""
155
- return {
156
- _props(n).get("slug"): read_checkpoint(state_dir, _props(n).get("slug")).get("status", "pending")
157
- for n in gbuild_nodes(graph)
158
- }
159
-
160
-
161
- def compute_report(graph, state_dir):
162
- statuses = node_statuses(graph, state_dir)
163
- id_slug = {n["id"]: _props(n).get("slug") for n in gbuild_nodes(graph)}
164
- frontier, blocked, in_flight, completed, cancelled, failed = [], [], [], [], [], []
165
-
166
- for node in gbuild_nodes(graph):
167
- slug = _props(node).get("slug")
168
- status = statuses.get(slug, "pending")
169
- dep_ids = deps(graph, node["id"])
170
- dep_statuses = [statuses.get(id_slug.get(d), "pending") for d in dep_ids]
171
- if status == "completed":
172
- completed.append(slug)
173
- elif status == "cancelled":
174
- cancelled.append(slug)
175
- elif status == "in_progress":
176
- in_flight.append(slug)
177
- elif status == "failed":
178
- failed.append(slug)
179
- elif all(ds in SATISFIED_STATUSES for ds in dep_statuses):
180
- frontier.append(slug)
181
- else:
182
- blocked.append(slug)
183
-
184
- return {
185
- "frontier": sorted(frontier),
186
- "blocked": sorted(blocked),
187
- "in_flight": sorted(in_flight),
188
- "completed": sorted(completed),
189
- "cancelled": sorted(cancelled),
190
- "failed": sorted(failed),
191
- "waves": topo_waves(graph),
192
- }
193
-
194
-
195
- def compute_frontier(graph, state_dir):
196
- return compute_report(graph, state_dir)["frontier"]
197
-
198
-
199
- def default_state_dir(graph_path):
200
- """Checkpoints live in the sibling `nodes/` dir, next to the `db/` graph folder.
201
-
202
- The plan is `<feature>/db/graph.json` and state is `<feature>/nodes/`; the
203
- two are siblings so CypherLite's `nodes/` Parquet-shard detection never sees
204
- the checkpoint dir inside the folder it opens.
205
- """
206
- return Path(graph_path).resolve().parent.parent / "nodes"
207
-
208
-
209
- def main(argv=None):
210
- parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
211
- parser.add_argument("graph_path")
212
- parser.add_argument("--state-dir", default=None, help="checkpoint dir, default: <feature>/nodes")
213
- group = parser.add_mutually_exclusive_group()
214
- group.add_argument("--waves", action="store_true")
215
- group.add_argument("--frontier", action="store_true")
216
- group.add_argument("--status", action="store_true")
217
- args = parser.parse_args(argv)
218
-
219
- graph = load_graph(args.graph_path)
220
- try:
221
- validate(graph)
222
- except GraphError as e:
223
- print(f"invalid graph: {e}", file=sys.stderr)
224
- return 1
225
-
226
- state_dir = args.state_dir or default_state_dir(args.graph_path)
227
-
228
- if args.waves:
229
- print(json.dumps(topo_waves(graph)))
230
- elif args.frontier:
231
- print(json.dumps(compute_frontier(graph, state_dir)))
232
- else:
233
- print(json.dumps(compute_report(graph, state_dir), indent=2))
234
-
235
- return 0
236
-
237
-
238
- if __name__ == "__main__":
239
- sys.exit(main())