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,36 @@
|
|
|
1
|
+
# Ship checklist
|
|
2
|
+
|
|
3
|
+
`plan` runs this checklist against the graph it just wrote, before committing `graph.json`. Any item
|
|
4
|
+
that fails means revise the graph, not add a caveat in prose.
|
|
5
|
+
|
|
6
|
+
1. **Risky assumptions are resolved, not deferred.** Every assumption that would invalidate nodes or
|
|
7
|
+
edges if wrong has either been confirmed against the codebase/docs, or is charted as a `research`
|
|
8
|
+
node that answers it. An assumption left as "probably fine" is a hole.
|
|
9
|
+
2. **Every edge passes the cut test.** For each `DEPENDS_ON` relationship, the dependent node actually
|
|
10
|
+
reads something the dependency produced. No edge exists just because one node was thought of after
|
|
11
|
+
another.
|
|
12
|
+
3. **Independent nodes are actually in the same wave.** Run `python3 scripts/graph.py <path> --waves`
|
|
13
|
+
and confirm nodes with no real edge between them land together, not in a chain.
|
|
14
|
+
4. **Every node has non-empty, concrete acceptance criteria.** `validate_format.py` enforces non-empty;
|
|
15
|
+
`plan` itself is responsible for concrete — no `"looks correct"`, no criterion a fresh context
|
|
16
|
+
couldn't check unaided.
|
|
17
|
+
5. **Every node's `contract.output` is structured, not free text.** A downstream node's `contract.input`
|
|
18
|
+
should be able to reference it directly.
|
|
19
|
+
6. **Every `Acceptance` node is `SATISFIES`-covered** by at least one GbuildNode. An uncovered
|
|
20
|
+
feature-level acceptance bullet means a node is missing. (`validate_format.py` enforces this.)
|
|
21
|
+
7. **No node restates a global acceptance bullet verbatim as its own criterion.** Node-level acceptance
|
|
22
|
+
is the local, observable slice; the `Acceptance` nodes keep the whole-feature bar.
|
|
23
|
+
8. **Every controlled cycle has a hard round cap and a dedup rule**, stated in the node(s) that form the
|
|
24
|
+
cycle — not left implicit.
|
|
25
|
+
9. **`DEPENDS_ON` is acyclic.** `validate_format.py` (and `graph.py --waves`) reject a cycle; confirm
|
|
26
|
+
they don't.
|
|
27
|
+
10. **Every `chore` node is depended on by at least one other node.** A chore nobody depends on isn't in
|
|
28
|
+
scope — it's either wired wrong or shouldn't exist. (`validate_format.py` enforces this.)
|
|
29
|
+
11. **`failure_policy` fits the node type**, per `reference/failure-policies.md` — not defaulted to
|
|
30
|
+
`retry` everywhere without thinking about it.
|
|
31
|
+
12. **`model_tier` is `strong` only where judgement, not throughput, is the bottleneck** — see
|
|
32
|
+
`reference/cost-model.md`.
|
|
33
|
+
13. **The graph, run end to end with every node passing review, actually reaches `destination`.** Read
|
|
34
|
+
the `Feature` node's `destination` property back against the node set: if every node completed, is
|
|
35
|
+
the feature actually done? A graph that's internally consistent but doesn't add up to the
|
|
36
|
+
destination is still wrong.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cost model
|
|
2
|
+
|
|
3
|
+
`plan` sets `model_tier` per node. `run` uses it to route model choice when the backend supports it
|
|
4
|
+
(the `Workflow` tool's `agent()` `model` option); the Agent-tool fallback treats it as a hint in the
|
|
5
|
+
dispatched agent's prompt when the harness offers a model choice, and otherwise ignores it — it is
|
|
6
|
+
never load-bearing for correctness, only cost.
|
|
7
|
+
|
|
8
|
+
## `cheap`
|
|
9
|
+
|
|
10
|
+
Default. Use for:
|
|
11
|
+
|
|
12
|
+
- `chore` nodes — mechanical, no judgement call.
|
|
13
|
+
- `test` nodes covering already-specified behavior.
|
|
14
|
+
- `code` nodes with a narrow, well-specified contract and concrete acceptance criteria (the review pass
|
|
15
|
+
is what catches a cheap model's mistakes, not the model tier).
|
|
16
|
+
- `gbuild-reviewer` passes against short, mechanical acceptance lists.
|
|
17
|
+
|
|
18
|
+
## `strong`
|
|
19
|
+
|
|
20
|
+
Reserve for:
|
|
21
|
+
|
|
22
|
+
- `decision` nodes — the thing being bought is judgement, not throughput.
|
|
23
|
+
- `research` nodes where the answer requires synthesizing several sources, not just one lookup.
|
|
24
|
+
- `code` nodes whose contract is underspecified or whose acceptance criteria require judgement to
|
|
25
|
+
evaluate (if a node needs `strong` for this reason, consider whether `plan` should have written
|
|
26
|
+
tighter acceptance criteria instead).
|
|
27
|
+
- `verify` nodes doing adversarial or fact-checking work beyond the standard review.
|
|
28
|
+
|
|
29
|
+
## Collapsing a cluster to one agent
|
|
30
|
+
|
|
31
|
+
When a wave's nodes are all `cheap`, tightly scoped, and small enough that one context could hold all
|
|
32
|
+
of them without losing precision, `plan` may note in the cluster's shape that `run` can dispatch them
|
|
33
|
+
as one agent handling the whole wave sequentially, rather than N concurrent agents — trading real
|
|
34
|
+
parallelism for lower overhead when the nodes are too small to be worth splitting. This is judgement
|
|
35
|
+
`plan` exercises at authoring time, not something `run` decides on its own; a wave `plan` didn't flag
|
|
36
|
+
this way is dispatched as one agent per node, per the default in `skills/run/SKILL.md`.
|
|
37
|
+
|
|
38
|
+
Never collapse nodes with different `failure_policy` values, or where any node in the cluster is
|
|
39
|
+
`strong` — those need to be dispatched (and possibly retried/repaired) independently.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Failure policies
|
|
2
|
+
|
|
3
|
+
Every node declares exactly one `failure_policy`. `run` applies it when a node's `gbuild-reviewer` pass
|
|
4
|
+
returns `fail`, or the node's own agent errors out.
|
|
5
|
+
|
|
6
|
+
| policy | `run` does |
|
|
7
|
+
| ---------- | ----------------------------------------------------------------------------------------------------- |
|
|
8
|
+
| `retry` | Re-dispatch the same node, same contract, up to 2 more attempts. Same agent role, fresh context. |
|
|
9
|
+
| `fallback` | On failure, dispatch a named alternate approach (`plan` records it in the node's `contract.input`) instead of retrying the same approach. |
|
|
10
|
+
| `skip` | Mark `cancelled`, not `failed`. Downstream nodes treat it as satisfied (see derived queries). Use only when the node is genuinely optional to the destination. |
|
|
11
|
+
| `repair` | Feed the reviewer's `acceptance_results` failures back into a repair pass targeting just the failed criteria, then re-review. Bounded: 2 repair attempts, then escalate. |
|
|
12
|
+
| `escalate` | Stop dispatching this node's dependents, checkpoint it `failed`, and surface it to the user in `/gbuild:status` as needing a decision. `run` does not guess a fix. |
|
|
13
|
+
| `stop` | Halt the entire run — not just this node's downstream. Reserved for failures that put the whole graph's premise in doubt (e.g. a `decision` node's answer contradicts an already-completed node). |
|
|
14
|
+
|
|
15
|
+
## Choosing a policy at plan time
|
|
16
|
+
|
|
17
|
+
- Most `code`/`test` nodes: `repair` — the reviewer's per-criterion verdict is exactly the feedback a
|
|
18
|
+
repair pass needs.
|
|
19
|
+
- `research`/`decision` nodes: `retry` or `escalate` — there's rarely a mechanical "repair" for a wrong
|
|
20
|
+
answer; either try again with a fresh context or hand it to the user.
|
|
21
|
+
- Nodes explicitly marked non-essential to the destination in `plan`'s own judgement: `skip`.
|
|
22
|
+
- Nodes with a known, nameable alternate approach: `fallback`.
|
|
23
|
+
- Anything whose failure would make a sibling's already-completed work suspect: `stop`. Use sparingly —
|
|
24
|
+
this is the escape hatch for "the graph's premise broke," not a normal failure mode.
|
|
25
|
+
|
|
26
|
+
## Bounds are mandatory
|
|
27
|
+
|
|
28
|
+
`retry`, `repair`, and `fallback` all have hard caps enforced by `run` (2 attempts unless the node
|
|
29
|
+
overrides it in `contract.input`). No policy allows an unbounded loop — that's what turns a bounded
|
|
30
|
+
retry into the "controlled cycle" shape's job (`reference/shapes.md`), which has its own explicit round
|
|
31
|
+
cap and dedup rule.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# `graph.json` — normative format
|
|
2
|
+
|
|
3
|
+
This file is normative. Where a skill disagrees with this doc, this doc wins.
|
|
4
|
+
|
|
5
|
+
One file per feature: `.gbuild/<feature>/graph.json`, written once by `plan`, read-only during `run`.
|
|
6
|
+
|
|
7
|
+
**`graph.json` is a CypherLite GraphData file.** Open it directly with CypherLite and query it with Cypher:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
cypherlite .gbuild/<feature>/graph.json "MATCH (n:GbuildNode) RETURN n.slug, n.type"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
JSON, not YAML — `scripts/graph.py` and `scripts/validate_format.py` are stdlib-only and Python's stdlib has no YAML parser. Comments are lost; that's fine, the file is agent-authored, not hand-typed. `/gbuild:status` is the human-facing view.
|
|
14
|
+
|
|
15
|
+
**Validate after every change.** `scripts/validate_format.py <path>` is the format gate — run it after the initial write, after any reopen edit, after any hand-edit, before doing anything else with the file (including committing). It exits 0 + `valid: <feature>` on success, 1 + every error joined on failure. Fix the file and re-run until it passes; never hand off or run an invalid graph.
|
|
16
|
+
|
|
17
|
+
The validator does its stdlib JSON checks always. If the `cypherlite` binary is on PATH, it *also* opens the file with CypherLite and runs a trivial `MATCH` — confirming CypherLite itself loads it, not just that the stdlib walk approves the shape. CypherLite is an optional consumer, not a dependency: if the binary is absent the cross-check is skipped silently (`--no-cypherlite` forces the skip too).
|
|
18
|
+
|
|
19
|
+
## Top level — the GraphData envelope
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
{
|
|
23
|
+
"nodes": [ /* Feature, Acceptance, GbuildNode — see below */ ],
|
|
24
|
+
"relationships": [ /* DEPENDS_ON, HAS_ACCEPTANCE, SATISFIES */ ],
|
|
25
|
+
"indexes": [],
|
|
26
|
+
"constraints": [],
|
|
27
|
+
"next_node_id": 8, // max(node.id) + 1 (1 if empty) — CypherLite trusts this
|
|
28
|
+
"next_rel_id": 10 // max(rel.id) + 1 (1 if empty)
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`indexes` and `constraints` are empty for gbuild graphs — kept only because CypherLite's loader expects them. `next_node_id`/`next_rel_id` are the id allocator counters; they must equal `max(id)+1` for each kind, and the validator enforces this.
|
|
33
|
+
|
|
34
|
+
## Node labels
|
|
35
|
+
|
|
36
|
+
Three node kinds, distinguished by label:
|
|
37
|
+
|
|
38
|
+
| label | meaning | count per graph |
|
|
39
|
+
| ------------ | ---------------------------------------- | --------------- |
|
|
40
|
+
| `Feature` | the one feature this graph is for | exactly 1 |
|
|
41
|
+
| `Acceptance` | one feature-level acceptance bullet | one per bullet |
|
|
42
|
+
| `GbuildNode` | one unit of work in the dependency graph | one per node |
|
|
43
|
+
|
|
44
|
+
### `Feature` node (id 1)
|
|
45
|
+
|
|
46
|
+
```jsonc
|
|
47
|
+
{
|
|
48
|
+
"id": 1,
|
|
49
|
+
"labels": ["Feature"],
|
|
50
|
+
"properties": {
|
|
51
|
+
"feature": "gbuild-mvp", // slug, matches the .gbuild/<feature>/ dir
|
|
52
|
+
"destination": "<one line — what \"done\" looks like>",
|
|
53
|
+
"context": "<why this exists, one or two lines>",
|
|
54
|
+
"out_of_scope": ["<bullet>"]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Keep `destination`/`context`/`out_of_scope` short — bullets, not paragraphs. Detail lives on the nodes, not here. Same discipline as hone's 150-line `spec.md` budget, for the same reason: this block gets re-read every invocation. **Do not put an `acceptance` property here** — feature-level acceptance lives in `Acceptance` nodes.
|
|
60
|
+
|
|
61
|
+
### `Acceptance` nodes
|
|
62
|
+
|
|
63
|
+
One per feature-level acceptance bullet:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
{
|
|
67
|
+
"id": 2,
|
|
68
|
+
"labels": ["Acceptance"],
|
|
69
|
+
"properties": { "id": "a-1", "text": "<bullet>" }
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each is linked `Feature -[:HAS_ACCEPTANCE]-> Acceptance`. A node that serves a criterion gets `GbuildNode -[:SATISFIES]-> Acceptance` (see below). Normalizing the bar into nodes makes it queryable on its own and joinable to the nodes that cover it.
|
|
74
|
+
|
|
75
|
+
### `GbuildNode` — a unit of work
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
{
|
|
79
|
+
"id": 4,
|
|
80
|
+
"labels": ["GbuildNode", "Code"], // PascalCase type label, always alongside GbuildNode
|
|
81
|
+
"properties": {
|
|
82
|
+
"slug": "01-scan-existing-runners", // filename-safe kebab-case, immutable, the stable identity
|
|
83
|
+
"title": "<string>",
|
|
84
|
+
"type": "code", // lowercase enum — kept as a property too (matches the label)
|
|
85
|
+
"contract": { "input": {}, "output": {} }, // structured shapes, not prose
|
|
86
|
+
"acceptance": [ // node-level local criteria (mandatory, concrete)
|
|
87
|
+
"<concrete, checkable-by-a-fresh-context criterion>"
|
|
88
|
+
],
|
|
89
|
+
"verify": null, // slug of an *additional* verify-type node, or null
|
|
90
|
+
"failure_policy": "retry | fallback | skip | repair | escalate | stop",
|
|
91
|
+
"model_tier": "cheap | strong"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `id` — integer, sequential, unique, never reused even if a node is removed. Authoring convenience, not semantic identity — `slug` is the stable identity skills and checkpoints key on.
|
|
97
|
+
- `labels` — `["GbuildNode", <PascalCase type>]`. The universal `GbuildNode` label is for `MATCH (n:GbuildNode)`; the type label (`Research`, `Decision`, `Code`, `Test`, `Verify`, `Chore`) enables `MATCH (n:Code)`.
|
|
98
|
+
- `slug` — the human/inter-skill identifier. Checkpoint files are named `<slug>.json`; skills refer to nodes by slug in anything the user reads. Immutable once created.
|
|
99
|
+
- `type` — lowercase enum, duplicated as a property alongside the PascalCase label so `n.type` works for sort/filter and survives any label-casing edge. The validator checks the label matches the type.
|
|
100
|
+
- `contract.input` / `contract.output` — structured shapes, not prose. A downstream node's `input` should reference an upstream node's `output` field directly (by slug path in the doc string, e.g. `"<integer, from a-produce-shared-value.output.value>"`).
|
|
101
|
+
- `acceptance` — **mandatory, non-empty, concrete.** The validator rejects empty or all-blank lists. Concrete means checkable by a fresh context with no other information — not `"looks correct"` or `"works well"`.
|
|
102
|
+
- `verify` — only set when this node needs something *beyond* the standard `gbuild-reviewer` pass. Usually `null`.
|
|
103
|
+
- **no `satisfies` property** — which global acceptance ids this node serves is expressed as `SATISFIES` edges, not an array. The validator rejects a stray `satisfies` property.
|
|
104
|
+
|
|
105
|
+
### The cut test
|
|
106
|
+
|
|
107
|
+
Before adding an edge `B -[:DEPENDS_ON]-> A`, ask: does B actually read something A produced? If B would run exactly the same way with A deleted, it's not a dependency — it's just the order you thought of them in. Sequence ≠ dependency. Nodes with no real edge between them belong in the same wave, not a chain.
|
|
108
|
+
|
|
109
|
+
### `acceptance` is mandatory and must be concrete
|
|
110
|
+
|
|
111
|
+
`validate_format.py` rejects the file if any GbuildNode has an empty or all-blank `acceptance` list (same rule as hone's invariant: every build task has ≥1 acceptance criterion). Concrete means checkable by a fresh context with no other information — not `"looks correct"` or `"works well"`. Prefer criteria that name a file, a command's exit code, a specific behavior, or a literal output shape.
|
|
112
|
+
|
|
113
|
+
### `verify` is not the review
|
|
114
|
+
|
|
115
|
+
Every node — regardless of `type` — goes through `gbuild-reviewer` before it's checkpointed complete (see `agents/gbuild-reviewer.md`). `verify` is for something *beyond* that default: a dedicated fact-checker, an adversarial multi-vote, a human-approval gate. Most nodes have `verify: null` and are still reviewed.
|
|
116
|
+
|
|
117
|
+
### `type`
|
|
118
|
+
|
|
119
|
+
| type | label | produces |
|
|
120
|
+
| ---------- | ---------- | -------------------------------------------------------------------- |
|
|
121
|
+
| `research` | `Research` | findings — an answer to a question, cited |
|
|
122
|
+
| `decision` | `Decision` | a choice made and recorded, with the alternatives it ruled out |
|
|
123
|
+
| `code` | `Code` | a coherent, reviewable change, committed |
|
|
124
|
+
| `test` | `Test` | coverage for behavior a `code` node landed, when genuinely separable |
|
|
125
|
+
| `verify` | `Verify` | a check on another node's output — never the same agent, never self |
|
|
126
|
+
| `chore` | `Chore` | mechanical work with no behavior change |
|
|
127
|
+
|
|
128
|
+
### `failure_policy`
|
|
129
|
+
|
|
130
|
+
See `reference/failure-policies.md` for how `run` applies each value.
|
|
131
|
+
|
|
132
|
+
### `model_tier`
|
|
133
|
+
|
|
134
|
+
See `reference/cost-model.md` for the cheap/strong split and when a cluster should collapse to one agent.
|
|
135
|
+
|
|
136
|
+
## Relationships
|
|
137
|
+
|
|
138
|
+
Three types. Each gets a sequential integer `id` (never reused), `start_node`/`end_node` reference node ids, `properties` is an object (empty for all gbuild edges today).
|
|
139
|
+
|
|
140
|
+
### `DEPENDS_ON` — the dependency edge
|
|
141
|
+
|
|
142
|
+
```jsonc
|
|
143
|
+
{ "id": 6, "type": "DEPENDS_ON", "start_node": 5, "end_node": 4, "properties": {} }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Direction: **dependent → dependency** (`start_node` depends on `end_node`). So `MATCH (n)-[:DEPENDS_ON]->(d)` is "what does n need", and `MATCH (n)<-[:DEPENDS_ON]-(d)` is "what waits on n". Both endpoints must be `GbuildNode`s — you can't depend on the Feature or an Acceptance. The `DEPENDS_ON` graph must be acyclic (validator enforces).
|
|
147
|
+
|
|
148
|
+
### `HAS_ACCEPTANCE` — feature → acceptance
|
|
149
|
+
|
|
150
|
+
```jsonc
|
|
151
|
+
{ "id": 1, "type": "HAS_ACCEPTANCE", "start_node": 1, "end_node": 2, "properties": {} }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Ties each `Acceptance` node to its `Feature`. `start_node` must be the Feature node, `end_node` an Acceptance node. One per acceptance bullet.
|
|
155
|
+
|
|
156
|
+
### `SATISFIES` — node → acceptance
|
|
157
|
+
|
|
158
|
+
```jsonc
|
|
159
|
+
{ "id": 3, "type": "SATISFIES", "start_node": 5, "end_node": 2, "properties": {} }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Ties a `GbuildNode` to a feature-level criterion it covers (replaces the old `satisfies` array property). `start_node` a GbuildNode, `end_node` an Acceptance. Every `Acceptance` must be the target of at least one `SATISFIES` — an uncovered criterion means a node is missing (validator enforces).
|
|
163
|
+
|
|
164
|
+
## ID authoring rules
|
|
165
|
+
|
|
166
|
+
- The skill authors integer ids directly in the file, sequential: `Feature` = 1, then `Acceptance` nodes, then `GbuildNode`s in any stable order. Relationship ids are sequential across all three types.
|
|
167
|
+
- `next_node_id` / `next_rel_id` must equal `max(id)+1` for each kind (or 1 if empty). The validator enforces this because CypherLite trusts these counters on load — a stale counter can collide with a live id.
|
|
168
|
+
- Never reuse an id, even if a node is removed. Slugs are the stable identity; ids are authoring convenience.
|
|
169
|
+
- On reopen: append new `Acceptance`/`GbuildNode` nodes with the next available ids, add their edges, bump the counters, then re-run `validate_format.py`.
|
|
170
|
+
|
|
171
|
+
## State (not part of `graph.json`)
|
|
172
|
+
|
|
173
|
+
`.gbuild/<feature>/nodes/<slug>.json`, one file per GbuildNode, written by `run`:
|
|
174
|
+
|
|
175
|
+
```jsonc
|
|
176
|
+
{
|
|
177
|
+
"status": "pending | in_progress | completed | failed | cancelled",
|
|
178
|
+
"output": {}, // matches the node's contract.output shape
|
|
179
|
+
"review": {
|
|
180
|
+
"verdict": "pass | fail",
|
|
181
|
+
"acceptance_results": [{ "criterion": "<text>", "passed": true }]
|
|
182
|
+
},
|
|
183
|
+
"started_at": "<ISO8601>",
|
|
184
|
+
"completed_at": "<ISO8601>"
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
A missing checkpoint file means `pending`. Each node owns its own file, so parallel writers never collide. Checkpoints stay as sidecar files by design: `graph.json` is write-once (plan), checkpoints are write-many (run), and only the plan needs to be CypherLite-queryable. Runtime status is joined in by `graph.py --status`, not stored in the graph.
|
|
189
|
+
|
|
190
|
+
## Derived queries
|
|
191
|
+
|
|
192
|
+
`scripts/graph.py` computes all of this (joining the GraphData plan with the sidecar checkpoints):
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
frontier = status pending ∧ every DEPENDS_ON target completed-or-cancelled
|
|
196
|
+
blocked = status pending ∧ some DEPENDS_ON target not completed-or-cancelled
|
|
197
|
+
in_flight = status in_progress
|
|
198
|
+
waves = topological layering — wave N holds every node whose deps all resolved in wave < N
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A `cancelled` dependency counts as satisfied — cancelling a node must not permanently block its dependents.
|
|
202
|
+
|
|
203
|
+
## Querying with Cypher
|
|
204
|
+
|
|
205
|
+
Because `graph.json` is a CypherLite GraphData file, the *plan structure* is queryable directly with Cypher (no conversion). Runtime status is not — it lives in sidecar checkpoint files, so `frontier`/`blocked`/`completed` come from `graph.py --status`, not pure Cypher. Dependency-derived questions are pure Cypher:
|
|
206
|
+
|
|
207
|
+
```cypher
|
|
208
|
+
-- the whole plan
|
|
209
|
+
MATCH (n:GbuildNode) RETURN n.slug, n.type, n.title
|
|
210
|
+
|
|
211
|
+
-- nodes by type
|
|
212
|
+
MATCH (n:Code) RETURN n.slug, n.title
|
|
213
|
+
|
|
214
|
+
-- what does node d depend on (direct)
|
|
215
|
+
MATCH (n {slug:'d-join-and-sum'})-[:DEPENDS_ON]->(d) RETURN d.slug
|
|
216
|
+
|
|
217
|
+
-- full transitive dependency chain
|
|
218
|
+
MATCH (n {slug:'d-join-and-sum'})-[:DEPENDS_ON*]->(d) RETURN DISTINCT d.slug
|
|
219
|
+
|
|
220
|
+
-- what waits on a given node
|
|
221
|
+
MATCH (n)<-[:DEPENDS_ON]-(dependent) WHERE n.slug='a-produce-shared-value' RETURN dependent.slug
|
|
222
|
+
|
|
223
|
+
-- nodes with no dependencies (first wave / roots)
|
|
224
|
+
MATCH (n:GbuildNode) WHERE NOT (n)-[:DEPENDS_ON]->() RETURN n.slug
|
|
225
|
+
|
|
226
|
+
-- nodes nobody depends on (final joins / leaves)
|
|
227
|
+
MATCH (n:GbuildNode) WHERE NOT ()-[:DEPENDS_ON]->(n) RETURN n.slug
|
|
228
|
+
|
|
229
|
+
-- critical path (longest dependency chain)
|
|
230
|
+
MATCH p=(n:GbuildNode)-[:DEPENDS_ON*]->(m:GbuildNode) RETURN length(p) AS d, p ORDER BY d DESC LIMIT 1
|
|
231
|
+
|
|
232
|
+
-- feature-level bar
|
|
233
|
+
MATCH (f:Feature)-[:HAS_ACCEPTANCE]->(a:Acceptance) RETURN a.id, a.text
|
|
234
|
+
|
|
235
|
+
-- which nodes cover each criterion
|
|
236
|
+
MATCH (a:Acceptance)<-[:SATISFIES]-(n:GbuildNode) RETURN a.id, n.slug
|
|
237
|
+
|
|
238
|
+
-- uncovered criteria (a node is missing)
|
|
239
|
+
MATCH (a:Acceptance) WHERE NOT ()-[:SATISFIES]->(a) RETURN a.id, a.text
|
|
240
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Graph shapes
|
|
2
|
+
|
|
3
|
+
`plan` classifies each cluster of nodes it creates into one of these. `run` uses the classification to
|
|
4
|
+
decide how to schedule it. This is a lens for judgement, not a field stored on the node — the shape
|
|
5
|
+
falls out of `dependencies`.
|
|
6
|
+
|
|
7
|
+
## Chain
|
|
8
|
+
|
|
9
|
+
`A -> B -> C`. Each node genuinely reads the previous one's output. Runs serially — there is no
|
|
10
|
+
fan-out to exploit. **Before encoding a chain, run the cut test on every edge** (see
|
|
11
|
+
`graph-format.md`): a chain is often three unrelated nodes someone thought of in order, which should
|
|
12
|
+
instead be three nodes in the same wave.
|
|
13
|
+
|
|
14
|
+
## Diamond (fan-out / join)
|
|
15
|
+
|
|
16
|
+
`A -> {B, C} -> D`. `A` produces something `B` and `C` both need; `D` needs both `B` and `C`. `B` and
|
|
17
|
+
`C` have no edge between them — they land in the same wave and `run` dispatches them concurrently. `D`
|
|
18
|
+
is the join: it waits for every incoming edge, not just the first.
|
|
19
|
+
|
|
20
|
+
This is the shape gbuild exists to execute properly. hone-ai v1 would serialize `B` then `C` even
|
|
21
|
+
though nothing requires it; the whole point of a real dependency graph is that `plan` states the truth
|
|
22
|
+
(`B` and `C` are independent) and `run` acts on it (dispatches both at once).
|
|
23
|
+
|
|
24
|
+
## Router
|
|
25
|
+
|
|
26
|
+
One node whose output selects which of several downstream nodes actually runs — not all of them, not
|
|
27
|
+
none, exactly the branch the output implies. Model this as a `decision`-type node whose `contract.output`
|
|
28
|
+
names the chosen branch, with the downstream nodes each depending on it; `run`'s job is to only dispatch
|
|
29
|
+
the node(s) the router's output actually selected, and mark the un-selected siblings `cancelled` (which,
|
|
30
|
+
per the derived-queries rule, still satisfies anything waiting on "the router resolved").
|
|
31
|
+
|
|
32
|
+
## Controlled cycle
|
|
33
|
+
|
|
34
|
+
A bounded loop — e.g. generate → verify → repair → re-verify. Never an open-ended `while`. Every cycle
|
|
35
|
+
in a graph must have, at plan time:
|
|
36
|
+
|
|
37
|
+
- a **hard round cap** (a number, not "until it looks right")
|
|
38
|
+
- a **dedup rule** against everything already seen this cycle, so repair doesn't re-surface a fix that
|
|
39
|
+
already failed once
|
|
40
|
+
- an explicit **exit node** reached either by the verify passing or by hitting the cap and escalating
|
|
41
|
+
|
|
42
|
+
`run` implements this as a bounded loop (see `skills/run/SKILL.md`), never as extra graph edges that
|
|
43
|
+
loop back on themselves — `scripts/graph.py` rejects literal cycles in `dependencies` (that's how it
|
|
44
|
+
catches a malformed graph). A controlled cycle is a *behavior* `run` executes around a small
|
|
45
|
+
sub-cluster, not a topology `graph.py` has to tolerate.
|
|
46
|
+
|
|
47
|
+
## Choosing a shape
|
|
48
|
+
|
|
49
|
+
Ask, per pair of nodes: does one actually read the other's output (cut test)? If two nodes both come
|
|
50
|
+
out "no" against everything else in the cluster, they're a diamond's fan-out, not a chain. If a node's
|
|
51
|
+
output determines *which* of several next steps applies, it's a router, not a diamond (a diamond runs
|
|
52
|
+
every branch; a router runs one). If the same node needs to run again based on feedback about its own
|
|
53
|
+
prior output, it's a controlled cycle, not an edge back to itself.
|
package/scripts/graph.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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 — openable with `cypherlite <path>` and
|
|
5
|
+
queryable with Cypher. This module is the query layer (topo waves, frontier,
|
|
6
|
+
status report) over that file. Format validation lives in validate_format.py
|
|
7
|
+
(the single source of truth for the contract); this module imports and re-exports
|
|
8
|
+
its validate() and enum sets.
|
|
9
|
+
|
|
10
|
+
Stdlib only, no pip install required. Schema: ../reference/graph-format.md
|
|
11
|
+
|
|
12
|
+
CLI:
|
|
13
|
+
python3 graph.py <graph.json> --waves print the topological wave decomposition
|
|
14
|
+
python3 graph.py <graph.json> --frontier print node ids ready to run right now
|
|
15
|
+
python3 graph.py <graph.json> --status print the full status report (default)
|
|
16
|
+
|
|
17
|
+
Checkpoints are read from <graph dir>/nodes/<slug>.json by default, or --state-dir.
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Format contract + enum sets live in validate_format (single definition site).
|
|
25
|
+
from validate_format import (
|
|
26
|
+
GraphError,
|
|
27
|
+
SATISFIED_STATUSES,
|
|
28
|
+
VALID_FAILURE_POLICIES,
|
|
29
|
+
VALID_MODEL_TIERS,
|
|
30
|
+
VALID_NODE_TYPES,
|
|
31
|
+
VALID_TYPE_LABELS,
|
|
32
|
+
validate,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- helpers over the GraphData shape ---------------------------------------
|
|
37
|
+
|
|
38
|
+
def _labels(node):
|
|
39
|
+
return set(node.get("labels", []))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _props(node):
|
|
43
|
+
return node.get("properties", {}) if isinstance(node.get("properties"), dict) else {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def gbuild_nodes(graph):
|
|
47
|
+
"""GbuildNode-labelled nodes, as a list."""
|
|
48
|
+
return [n for n in graph.get("nodes", []) if "GbuildNode" in _labels(n)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def node_by_id(graph, nid):
|
|
52
|
+
for n in graph.get("nodes", []):
|
|
53
|
+
if n.get("id") == nid:
|
|
54
|
+
return n
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def node_by_slug(graph, slug):
|
|
59
|
+
for n in gbuild_nodes(graph):
|
|
60
|
+
if _props(n).get("slug") == slug:
|
|
61
|
+
return n
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def slug_of(graph, nid):
|
|
66
|
+
n = node_by_id(graph, nid)
|
|
67
|
+
return _props(n).get("slug") if n else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def id_of_slug(graph, slug):
|
|
71
|
+
n = node_by_slug(graph, slug)
|
|
72
|
+
return n.get("id") if n else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def deps(graph, node_id):
|
|
76
|
+
"""node_ids this node depends on (DEPENDS_ON end_nodes)."""
|
|
77
|
+
return [
|
|
78
|
+
r["end_node"]
|
|
79
|
+
for r in graph.get("relationships", [])
|
|
80
|
+
if r.get("type") == "DEPENDS_ON" and r.get("start_node") == node_id
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def satisfies(graph, node_id):
|
|
85
|
+
"""acceptance node_ids this node covers (SATISFIES end_nodes)."""
|
|
86
|
+
return [
|
|
87
|
+
r["end_node"]
|
|
88
|
+
for r in graph.get("relationships", [])
|
|
89
|
+
if r.get("type") == "SATISFIES" and r.get("start_node") == node_id
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def feature(graph):
|
|
94
|
+
"""the single Feature node, or None."""
|
|
95
|
+
for n in graph.get("nodes", []):
|
|
96
|
+
if "Feature" in _labels(n):
|
|
97
|
+
return n
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def acceptance_nodes(graph):
|
|
102
|
+
return [n for n in graph.get("nodes", []) if "Acceptance" in _labels(n)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- core query layer --------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def load_graph(path):
|
|
108
|
+
with open(path) as f:
|
|
109
|
+
return json.load(f)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def topo_waves(graph):
|
|
113
|
+
"""Layer GbuildNodes into parallel-safe waves by slug.
|
|
114
|
+
|
|
115
|
+
wave N contains every node whose DEPENDS_ON targets all resolved in <N.
|
|
116
|
+
Returns a list of lists of slugs.
|
|
117
|
+
"""
|
|
118
|
+
gn = gbuild_nodes(graph)
|
|
119
|
+
# map node id -> slug, and slug -> dep slugs
|
|
120
|
+
id_to_slug = {n["id"]: _props(n).get("slug") for n in gn}
|
|
121
|
+
remaining = {id_to_slug[n["id"]]: n for n in gn}
|
|
122
|
+
resolved = set()
|
|
123
|
+
waves = []
|
|
124
|
+
|
|
125
|
+
while remaining:
|
|
126
|
+
wave = []
|
|
127
|
+
for slug, node in remaining.items():
|
|
128
|
+
dep_ids = deps(graph, node["id"])
|
|
129
|
+
dep_slugs = {id_to_slug.get(d) for d in dep_ids}
|
|
130
|
+
if dep_slugs.issubset(resolved):
|
|
131
|
+
wave.append(slug)
|
|
132
|
+
if not wave:
|
|
133
|
+
raise GraphError(f"cycle detected among: {', '.join(sorted(remaining))}")
|
|
134
|
+
wave.sort()
|
|
135
|
+
waves.append(wave)
|
|
136
|
+
for slug in wave:
|
|
137
|
+
resolved.add(slug)
|
|
138
|
+
del remaining[slug]
|
|
139
|
+
|
|
140
|
+
return waves
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def read_checkpoint(state_dir, slug):
|
|
144
|
+
path = Path(state_dir) / f"{slug}.json"
|
|
145
|
+
if not path.exists():
|
|
146
|
+
return {"status": "pending"}
|
|
147
|
+
with open(path) as f:
|
|
148
|
+
return json.load(f)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def node_statuses(graph, state_dir):
|
|
152
|
+
"""slug -> status, for every GbuildNode."""
|
|
153
|
+
return {
|
|
154
|
+
_props(n).get("slug"): read_checkpoint(state_dir, _props(n).get("slug")).get("status", "pending")
|
|
155
|
+
for n in gbuild_nodes(graph)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def compute_report(graph, state_dir):
|
|
160
|
+
statuses = node_statuses(graph, state_dir)
|
|
161
|
+
id_slug = {n["id"]: _props(n).get("slug") for n in gbuild_nodes(graph)}
|
|
162
|
+
frontier, blocked, in_flight, completed, cancelled, failed = [], [], [], [], [], []
|
|
163
|
+
|
|
164
|
+
for node in gbuild_nodes(graph):
|
|
165
|
+
slug = _props(node).get("slug")
|
|
166
|
+
status = statuses.get(slug, "pending")
|
|
167
|
+
dep_ids = deps(graph, node["id"])
|
|
168
|
+
dep_statuses = [statuses.get(id_slug.get(d), "pending") for d in dep_ids]
|
|
169
|
+
if status == "completed":
|
|
170
|
+
completed.append(slug)
|
|
171
|
+
elif status == "cancelled":
|
|
172
|
+
cancelled.append(slug)
|
|
173
|
+
elif status == "in_progress":
|
|
174
|
+
in_flight.append(slug)
|
|
175
|
+
elif status == "failed":
|
|
176
|
+
failed.append(slug)
|
|
177
|
+
elif all(ds in SATISFIED_STATUSES for ds in dep_statuses):
|
|
178
|
+
frontier.append(slug)
|
|
179
|
+
else:
|
|
180
|
+
blocked.append(slug)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"frontier": sorted(frontier),
|
|
184
|
+
"blocked": sorted(blocked),
|
|
185
|
+
"in_flight": sorted(in_flight),
|
|
186
|
+
"completed": sorted(completed),
|
|
187
|
+
"cancelled": sorted(cancelled),
|
|
188
|
+
"failed": sorted(failed),
|
|
189
|
+
"waves": topo_waves(graph),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def compute_frontier(graph, state_dir):
|
|
194
|
+
return compute_report(graph, state_dir)["frontier"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def default_state_dir(graph_path):
|
|
198
|
+
return Path(graph_path).resolve().parent / "nodes"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv=None):
|
|
202
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
203
|
+
parser.add_argument("graph_path")
|
|
204
|
+
parser.add_argument("--state-dir", default=None, help="checkpoint dir, default: <graph dir>/nodes")
|
|
205
|
+
group = parser.add_mutually_exclusive_group()
|
|
206
|
+
group.add_argument("--waves", action="store_true")
|
|
207
|
+
group.add_argument("--frontier", action="store_true")
|
|
208
|
+
group.add_argument("--status", action="store_true")
|
|
209
|
+
args = parser.parse_args(argv)
|
|
210
|
+
|
|
211
|
+
graph = load_graph(args.graph_path)
|
|
212
|
+
try:
|
|
213
|
+
validate(graph)
|
|
214
|
+
except GraphError as e:
|
|
215
|
+
print(f"invalid graph: {e}", file=sys.stderr)
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
state_dir = args.state_dir or default_state_dir(args.graph_path)
|
|
219
|
+
|
|
220
|
+
if args.waves:
|
|
221
|
+
print(json.dumps(topo_waves(graph)))
|
|
222
|
+
elif args.frontier:
|
|
223
|
+
print(json.dumps(compute_frontier(graph, state_dir)))
|
|
224
|
+
else:
|
|
225
|
+
print(json.dumps(compute_report(graph, state_dir), indent=2))
|
|
226
|
+
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
sys.exit(main())
|