opencode-gbuild 0.8.0 → 0.9.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.
@@ -1,30 +1,13 @@
1
- // Claim the next wave: move every frontier node (pending, every DEPENDS_ON
2
- // target completed or cancelled) to in_progress, and return exactly what its
3
- // implementing agent needs — nothing about the rest of the graph. An input
4
- // with a `value` was read from an upstream node; one with a null value is
5
- // external, and its `shape` says where to find it. No rows = nothing is
6
- // ready (the graph is done or stuck — ask progress.cypher / attention.cypher).
7
- // For a router, cancel the unselected branches with set-status.cypher first.
1
+ // Claim every frontier node (pending, every DEPENDS_ON target completed or
2
+ // cancelled): move it to in_progress and return only what the orchestrator
3
+ // routes on. Each agent reads its own definition with brief.cypher, so no
4
+ // title, acceptance, inputs or outputs pass through the orchestrator. Run it
5
+ // at the start of a run and again after every record.cypher / set-status.cypher
6
+ // call. No rows = nothing newly ready (ask progress.cypher / attention.cypher
7
+ // whether the graph is done or stuck).
8
8
  // cypherlite query --database <git-common-dir>/gbuild/<slug>/db --mode jsonl < cypher/dispatch.cypher
9
9
  MATCH (n:GbuildNode {status: 'pending'})
10
10
  WHERE NOT EXISTS { MATCH (n)-[:DEPENDS_ON]->(d:GbuildNode) WHERE NOT d.status IN ['completed', 'cancelled'] }
11
11
  SET n.status = 'in_progress'
12
- RETURN n.slug AS slug,
13
- n.title AS title,
14
- n.type AS type,
15
- n.acceptance AS acceptance,
16
- n.failure_policy AS failure_policy,
17
- n.model_tier AS model_tier,
18
- n.verify AS verify,
19
- COLLECT {
20
- MATCH (n)-[:INPUT]->(i:Field)
21
- OPTIONAL MATCH (i)-[:FROM]->(o:Field)
22
- WITH i, o ORDER BY i.name
23
- RETURN {name: i.name, shape: i.shape, value: o.value}
24
- } AS inputs,
25
- COLLECT {
26
- MATCH (n)-[:OUTPUT]->(f:Field)
27
- WITH f ORDER BY f.name
28
- RETURN {name: f.name, shape: f.shape}
29
- } AS outputs
12
+ RETURN n.slug AS slug, n.type AS type, n.model_tier AS model_tier, n.failure_policy AS failure_policy
30
13
  ORDER BY slug;
@@ -1,8 +1,9 @@
1
1
  // Everything needed to dispatch or review one node, with inputs resolved
2
2
  // through FROM edges to the upstream output values. An input with no FROM is
3
3
  // external (from the codebase / environment): its `from` and `value` are null.
4
- // Used for reopen and ad-hoc inspection; run gets its definitions from
5
- // dispatch.cypher instead.
4
+ // Each review carries the reviewer's checklist ([] when it wrote none). Used
5
+ // for status, reopen and ad-hoc inspection; run's agents read their own
6
+ // definition with brief.cypher instead.
6
7
  // cypherlite query --read-only --database <git-common-dir>/gbuild/<slug>/db --mode jsonl --param 'slug="<node-slug>"' < cypher/node.cypher
7
8
  MATCH (n:GbuildNode {slug: $slug})
8
9
  RETURN n.slug AS slug,
@@ -37,5 +38,5 @@ RETURN n.slug AS slug,
37
38
  COLLECT {
38
39
  MATCH (n)-[:REVIEWED]->(r:Review)
39
40
  WITH r ORDER BY r.attempt
40
- RETURN {attempt: r.attempt, verdict: r.verdict, failed_criteria: r.failed_criteria, at: r.at}
41
+ RETURN {attempt: r.attempt, verdict: r.verdict, failed_criteria: r.failed_criteria, checklist: r.checklist, at: r.at}
41
42
  } AS reviews;
@@ -1,37 +1,90 @@
1
- // Record a whole wave's review verdicts in one atomic call. Each result adds a
2
- // Review (attempt = previous reviews + 1). A pass with a value for every
3
- // OUTPUT field also completes the node: output values stored on its fields,
4
- // the implementing agent's own times on the node. A pass missing outputs is
5
- // recorded as a fail listing them, so the review history stays truthful.
6
- // Only in_progress nodes are recorded: compare the returned slugs with what
7
- // you sent. Output values must be scalars or lists of scalars — one nested
8
- // value rejects the whole batch (InvalidPropertyType); JSON-encode it and
9
- // resend.
10
- // cypherlite query --database <git-common-dir>/gbuild/<slug>/db --mode jsonl --param 'results=[
11
- // {"slug": "a", "verdict": "pass", "failed_criteria": [], "outputs": {"value": 7},
12
- // "started_at": "<iso>", "completed_at": "<iso>"},
13
- // {"slug": "b", "verdict": "fail", "failed_criteria": ["<criterion>"]}
14
- // ]' < cypher/record.cypher
15
- UNWIND $results AS r
16
- MATCH (n:GbuildNode {slug: r.slug})
17
- WHERE n.status = 'in_progress' AND r.verdict IN ['pass', 'fail']
18
- WITH n, r,
1
+ // Record one node attempt per call. Every call adds a Review (attempt =
2
+ // previous reviews + 1) holding the verdict, its failed criteria and the
3
+ // reviewer's checklist. A pass with a value for every OUTPUT field also
4
+ // completes the node: output values stored on its fields, the implementing
5
+ // agent's own times on the node, and its notes and symbols stored as
6
+ // (n)-[:WROTE]->(:Note) and (n)-[:DEFINES]->(:Symbol). A pass missing outputs
7
+ // is recorded as a fail listing them, so the review history stays truthful. A
8
+ // fail stores the Review only: no outputs, notes or symbols.
9
+ // Three params, always all bound (the script can't tell --param from
10
+ // --fetch-params):
11
+ // verdict {"slug", "verdict": "pass"|"fail", "failed_criteria": [...]} — the
12
+ // only source of slug, verdict and failed criteria
13
+ // result <inbox>/<node-slug>.json (outputs, started_at, completed_at, and
14
+ // optional notes and symbols) on a pass whose file exists, else {};
15
+ // any slug/verdict key in it is ignored
16
+ // review <inbox>/<node-slug>.review.json ({checklist}) when it exists, else
17
+ // {}; string entries only, the first 40, each cut to 300 chars
18
+ // Notes keep only maps with a topic of setup|testing|conventions|gotcha and
19
+ // non-blank string text (trimmed, cut to 300 chars): the first 5 distinct ones,
20
+ // skipping any whose exact (topic, text) is already stored. Symbols keep only
21
+ // maps with string name, file and purpose and a non-blank name and file
22
+ // (trimmed, purpose cut to 160 chars): the first 10 distinct (name, file),
23
+ // skipping any whose (name, file) is already stored. The missing-output
24
+ // expression is text-identical to check-result.cypher's (test-pinned).
25
+ // No row unless the node is in_progress: compare the returned slug with the
26
+ // one sent. Output values must be scalars or lists of scalars — a nested value
27
+ // fails the whole call with InvalidPropertyType and writes nothing.
28
+ // `unselected`: for a completed decision node, the pending dependents reading
29
+ // one of its outputs whose string / list-of-strings value names some readers
30
+ // but not them — cancel them with set-status.cypher before the next dispatch.
31
+ // cypherlite query --database <git-common-dir>/gbuild/<slug>/db --mode jsonl \
32
+ // --param 'verdict={"slug":"<node-slug>","verdict":"pass","failed_criteria":[]}' \
33
+ // --fetch-params result=<git-common-dir>/gbuild/<slug>/inbox/<node-slug>.json \
34
+ // --fetch-params review=<git-common-dir>/gbuild/<slug>/inbox/<node-slug>.review.json < cypher/record.cypher
35
+ // cypherlite query --database <git-common-dir>/gbuild/<slug>/db --mode jsonl \
36
+ // --param 'verdict={"slug":"<node-slug>","verdict":"fail","failed_criteria":["implementer failed: <reason>"]}' \
37
+ // --param 'result={}' --param 'review={}' < cypher/record.cypher
38
+ WITH $verdict AS v, $result AS res, $review AS rev
39
+ MATCH (n:GbuildNode {slug: v.slug})
40
+ WHERE n.status = 'in_progress' AND v.verdict IN ['pass', 'fail']
41
+ WITH n, v, res, rev,
19
42
  COUNT { (n)-[:REVIEWED]->(:Review) } + 1 AS attempt,
20
- CASE WHEN r.verdict = 'pass'
21
- THEN [k IN COLLECT { MATCH (n)-[:OUTPUT]->(f:Field) RETURN f.name } WHERE r.outputs[k] IS NULL]
43
+ CASE WHEN v.verdict = 'pass'
44
+ THEN [k IN COLLECT { MATCH (n)-[:OUTPUT]->(f:Field) WITH f ORDER BY f.name RETURN f.name } WHERE res.outputs[k] IS NULL]
22
45
  ELSE [] END AS missing
23
- WITH n, r, attempt, missing, (r.verdict = 'pass' AND size(missing) = 0) AS ok
46
+ WITH n, v, res, rev, attempt, missing, (v.verdict = 'pass' AND size(missing) = 0) AS ok
47
+ WITH n, v, res, attempt, missing, ok,
48
+ [c IN coalesce(rev.checklist, []) WHERE (c IS :: STRING) | left(c, 300)][0..40] AS checklist,
49
+ CASE WHEN ok AND res.notes IS :: LIST<ANY> NOT NULL THEN reduce(acc = [], x IN [x IN res.notes WHERE CASE
50
+ WHEN NOT x IS :: MAP NOT NULL THEN false
51
+ WHEN NOT x.text IS :: STRING NOT NULL THEN false
52
+ WHEN NOT coalesce(x.topic, '') IN ['setup', 'testing', 'conventions', 'gotcha'] THEN false
53
+ ELSE trim(x.text) <> '' END
54
+ | {topic: x.topic, text: left(trim(x.text), 300)}] | CASE WHEN x IN acc THEN acc ELSE acc + [x] END)[0..5] ELSE [] END AS notes,
55
+ CASE WHEN ok AND res.symbols IS :: LIST<ANY> NOT NULL THEN reduce(acc = [], x IN [x IN res.symbols WHERE CASE
56
+ WHEN NOT x IS :: MAP NOT NULL THEN false
57
+ WHEN NOT (x.name IS :: STRING NOT NULL AND x.file IS :: STRING NOT NULL AND x.purpose IS :: STRING NOT NULL) THEN false
58
+ ELSE trim(x.name) <> '' AND trim(x.file) <> '' END
59
+ | {name: trim(x.name), file: trim(x.file), purpose: left(trim(x.purpose), 160)}]
60
+ | CASE WHEN any(y IN acc WHERE y.name = x.name AND y.file = x.file) THEN acc ELSE acc + [x] END)[0..10] ELSE [] END AS symbols
24
61
  CREATE (n)-[:REVIEWED]->(review:Review {
25
62
  attempt: attempt,
26
63
  verdict: CASE WHEN ok THEN 'pass' ELSE 'fail' END,
27
- failed_criteria: coalesce(r.failed_criteria, []) + [k IN missing | 'missing output value: ' + k],
64
+ failed_criteria: coalesce(v.failed_criteria, []) + [k IN missing | 'missing output value: ' + k],
65
+ checklist: checklist,
28
66
  at: toString(datetime())
29
67
  })
30
- WITH n, r, attempt, missing, ok, review
68
+ WITH n, res, attempt, missing, ok, review, notes, symbols
31
69
  OPTIONAL MATCH (n)-[:OUTPUT]->(f:Field) WHERE ok
32
- SET f.value = r.outputs[f.name]
33
- WITH DISTINCT n, r, attempt, missing, ok, review
70
+ SET f.value = res.outputs[f.name]
71
+ WITH DISTINCT n, res, attempt, missing, ok, review, notes, symbols
34
72
  FOREACH (_ IN CASE WHEN ok THEN [1] ELSE [] END |
35
- SET n.status = 'completed', n.started_at = r.started_at, n.completed_at = r.completed_at)
36
- RETURN n.slug AS slug, attempt, review.verdict AS verdict, ok AS completed, missing AS missing_outputs
37
- ORDER BY slug;
73
+ SET n.status = 'completed', n.started_at = res.started_at, n.completed_at = res.completed_at)
74
+ WITH n, attempt, missing, ok, review,
75
+ [x IN notes WHERE NOT EXISTS { MATCH (o:Note) WHERE o.topic = x.topic AND o.text = x.text }] AS notes,
76
+ [x IN symbols WHERE NOT EXISTS { MATCH (o:Symbol) WHERE o.name = x.name AND o.file = x.file }] AS symbols
77
+ FOREACH (x IN notes |
78
+ CREATE (n)-[:WROTE]->(:Note {topic: x.topic, text: x.text, by_node: n.slug, at: review.at}))
79
+ FOREACH (x IN symbols |
80
+ CREATE (n)-[:DEFINES]->(:Symbol {name: x.name, file: x.file, purpose: x.purpose, by_node: n.slug, at: review.at}))
81
+ RETURN n.slug AS slug, attempt, review.verdict AS verdict, ok AS completed, missing AS missing_outputs,
82
+ size(notes) AS notes_added, size(symbols) AS symbols_added,
83
+ CASE WHEN ok AND n.type = 'decision' THEN COLLECT {
84
+ MATCH (n)-[:OUTPUT]->(f:Field)
85
+ WITH f, COLLECT { MATCH (f)<-[:FROM]-(:Field)<-[:INPUT]-(d:GbuildNode {status: 'pending'}) RETURN d.slug } AS readers,
86
+ CASE WHEN f.value IS :: STRING THEN [f.value] WHEN f.value IS :: LIST<STRING> THEN f.value ELSE [] END AS named
87
+ WHERE any(r IN readers WHERE r IN named)
88
+ UNWIND [r IN readers WHERE NOT r IN named] AS u
89
+ RETURN DISTINCT u ORDER BY u
90
+ } ELSE [] END AS unselected;
@@ -42,3 +42,22 @@ CREATE CONSTRAINT review_verdict_exists FOR (n:Review) REQUIRE n.verdict IS NOT
42
42
  CREATE CONSTRAINT review_at FOR (n:Review) REQUIRE n.at IS :: STRING;
43
43
  CREATE CONSTRAINT review_at_exists FOR (n:Review) REQUIRE n.at IS NOT NULL;
44
44
  CREATE CONSTRAINT review_failed_criteria FOR (n:Review) REQUIRE n.failed_criteria IS :: LIST<STRING NOT NULL>;
45
+ CREATE CONSTRAINT review_checklist FOR (n:Review) REQUIRE n.checklist IS :: LIST<STRING NOT NULL>;
46
+ CREATE CONSTRAINT note_topic FOR (n:Note) REQUIRE n.topic IS :: STRING;
47
+ CREATE CONSTRAINT note_topic_exists FOR (n:Note) REQUIRE n.topic IS NOT NULL;
48
+ CREATE CONSTRAINT note_text FOR (n:Note) REQUIRE n.text IS :: STRING;
49
+ CREATE CONSTRAINT note_text_exists FOR (n:Note) REQUIRE n.text IS NOT NULL;
50
+ CREATE CONSTRAINT note_by_node FOR (n:Note) REQUIRE n.by_node IS :: STRING;
51
+ CREATE CONSTRAINT note_by_node_exists FOR (n:Note) REQUIRE n.by_node IS NOT NULL;
52
+ CREATE CONSTRAINT note_at FOR (n:Note) REQUIRE n.at IS :: STRING;
53
+ CREATE CONSTRAINT note_at_exists FOR (n:Note) REQUIRE n.at IS NOT NULL;
54
+ CREATE CONSTRAINT symbol_name FOR (n:Symbol) REQUIRE n.name IS :: STRING;
55
+ CREATE CONSTRAINT symbol_name_exists FOR (n:Symbol) REQUIRE n.name IS NOT NULL;
56
+ CREATE CONSTRAINT symbol_file FOR (n:Symbol) REQUIRE n.file IS :: STRING;
57
+ CREATE CONSTRAINT symbol_file_exists FOR (n:Symbol) REQUIRE n.file IS NOT NULL;
58
+ CREATE CONSTRAINT symbol_purpose FOR (n:Symbol) REQUIRE n.purpose IS :: STRING;
59
+ CREATE CONSTRAINT symbol_purpose_exists FOR (n:Symbol) REQUIRE n.purpose IS NOT NULL;
60
+ CREATE CONSTRAINT symbol_by_node FOR (n:Symbol) REQUIRE n.by_node IS :: STRING;
61
+ CREATE CONSTRAINT symbol_by_node_exists FOR (n:Symbol) REQUIRE n.by_node IS NOT NULL;
62
+ CREATE CONSTRAINT symbol_at FOR (n:Symbol) REQUIRE n.at IS :: STRING;
63
+ CREATE CONSTRAINT symbol_at_exists FOR (n:Symbol) REQUIRE n.at IS NOT NULL;
@@ -4,16 +4,20 @@
4
4
  // otherwise = the stored status (in_progress, completed, failed, cancelled)
5
5
  // `wave` is the longest DEPENDS_ON chain below the node (0 = no dependencies),
6
6
  // so nodes sharing a wave are safe to run concurrently.
7
+ // `last_dependency_at` is the latest completed_at among the node's completed
8
+ // DEPENDS_ON targets (null without any): its gap to `started_at` is how long
9
+ // the node waited for dispatch once its last dependency landed.
7
10
  // cypherlite query --read-only --database <git-common-dir>/gbuild/<slug>/db --mode jsonl < cypher/status.cypher
8
11
  MATCH (n:GbuildNode)
9
12
  OPTIONAL MATCH p = (n)-[:DEPENDS_ON*]->()
10
13
  WITH n, coalesce(max(length(p)), 0) AS wave
11
14
  OPTIONAL MATCH (n)-[:DEPENDS_ON]->(d:GbuildNode)
12
15
  WITH n, wave, d ORDER BY d.slug
13
- WITH n, wave, [x IN collect(d) WHERE NOT x.status IN ['completed', 'cancelled'] | x.slug] AS waiting_on
16
+ WITH n, wave, [x IN collect(d) WHERE NOT x.status IN ['completed', 'cancelled'] | x.slug] AS waiting_on,
17
+ max(CASE WHEN d.status = 'completed' THEN d.completed_at END) AS last_dependency_at
14
18
  OPTIONAL MATCH (n)-[:REVIEWED]->(r:Review)
15
- WITH n, wave, waiting_on, r ORDER BY r.attempt DESC
16
- WITH n, wave, waiting_on, collect(r) AS reviews
19
+ WITH n, wave, waiting_on, last_dependency_at, r ORDER BY r.attempt DESC
20
+ WITH n, wave, waiting_on, last_dependency_at, collect(r) AS reviews
17
21
  RETURN n.slug AS slug,
18
22
  n.title AS title,
19
23
  n.type AS type,
@@ -29,5 +33,6 @@ RETURN n.slug AS slug,
29
33
  reviews[0].verdict AS last_verdict,
30
34
  reviews[0].failed_criteria AS last_failed_criteria,
31
35
  n.started_at AS started_at,
32
- n.completed_at AS completed_at
36
+ n.completed_at AS completed_at,
37
+ last_dependency_at
33
38
  ORDER BY wave, slug;
@@ -0,0 +1,9 @@
1
+ // Helpers, types and utilities completed nodes added, for reuse: the reviewer
2
+ // reads it before grepping for duplication, the implementer before writing a
3
+ // new helper. Only completed (merged) nodes' symbols exist, so nodes running at
4
+ // the same time can't see each other's unmerged helpers. At most 100 rows.
5
+ // cypherlite query --read-only --database <git-common-dir>/gbuild/<slug>/db --mode jsonl < cypher/symbols.cypher
6
+ MATCH (:GbuildNode)-[:DEFINES]->(s:Symbol)
7
+ RETURN s.name AS name, s.file AS file, s.purpose AS purpose, s.by_node AS by
8
+ ORDER BY file, name
9
+ LIMIT 100;
@@ -3,16 +3,17 @@
3
3
  // Required properties, their types and slug/acceptance-id uniqueness are
4
4
  // engine constraints (schema.cypher) — a write that breaks them never lands.
5
5
  // This query checks everything the engine can't: enums, label/type agreement,
6
- // edge shapes, coverage, acyclicity, and the data-flow (FROM) cut test.
6
+ // edge shapes, coverage, acyclicity, the data-flow (FROM) cut test, and the
7
+ // topics and owners of agent notes and symbols.
7
8
  CALL {
8
9
  MATCH (f:Feature) WITH count(f) AS c WHERE c <> 1
9
10
  RETURN 'feature-count' AS check, 'expected exactly 1 Feature node, found ' + toString(c) AS detail
10
11
  UNION ALL
11
- MATCH (n) WHERE NOT (n:Feature OR n:Acceptance OR n:GbuildNode OR n:Field OR n:Review)
12
+ MATCH (n) WHERE NOT (n:Feature OR n:Acceptance OR n:GbuildNode OR n:Field OR n:Review OR n:Note OR n:Symbol)
12
13
  RETURN 'unknown-node' AS check, 'node with labels [' + reduce(s = '', l IN labels(n) | s + ' ' + l) + ' ] is not part of the gbuild model' AS detail
13
14
  UNION ALL
14
- MATCH (n:GbuildNode) WHERE NOT n.slug =~ '^[a-z0-9]+(-[a-z0-9]+)*$'
15
- RETURN 'bad-slug' AS check, n.slug + ': slug must be kebab-case' AS detail
15
+ MATCH (n:GbuildNode) WHERE NOT n.slug =~ '^[a-z0-9]+(-[a-z0-9]+)*$' OR n.slug IN ['tree', 'db', 'inbox']
16
+ RETURN 'bad-slug' AS check, n.slug + ': slug must be kebab-case and not tree, db or inbox' AS detail
16
17
  UNION ALL
17
18
  MATCH (n:GbuildNode) WHERE NOT n.type IN ['research', 'decision', 'code', 'test', 'verify', 'chore']
18
19
  RETURN 'bad-type' AS check, n.slug + ': invalid type ' + n.type AS detail
@@ -54,7 +55,9 @@ UNION ALL
54
55
  OR (type(r) = 'DEPENDS_ON' AND x:GbuildNode AND y:GbuildNode)
55
56
  OR (type(r) IN ['INPUT', 'OUTPUT'] AND x:GbuildNode AND y:Field)
56
57
  OR (type(r) = 'FROM' AND x:Field AND y:Field)
57
- OR (type(r) = 'REVIEWED' AND x:GbuildNode AND y:Review))
58
+ OR (type(r) = 'REVIEWED' AND x:GbuildNode AND y:Review)
59
+ OR (type(r) = 'WROTE' AND x:GbuildNode AND y:Note)
60
+ OR (type(r) = 'DEFINES' AND x:GbuildNode AND y:Symbol))
58
61
  RETURN 'bad-edge' AS check,
59
62
  type(r) + ' from ' + coalesce(x.slug, x.id, x.name, '?') + ' to ' + coalesce(y.slug, y.id, y.name, '?') + ' is not an allowed edge' AS detail
60
63
  UNION ALL
@@ -100,6 +103,18 @@ UNION ALL
100
103
  UNION ALL
101
104
  MATCH (r:Review) WITH r, COUNT { (:GbuildNode)-[:REVIEWED]->(r) } AS c WHERE c <> 1
102
105
  RETURN 'review-owner' AS check, 'review attempt ' + toString(r.attempt) + ' has ' + toString(c) + ' owning nodes, want 1' AS detail
106
+ UNION ALL
107
+ MATCH (nt:Note) WHERE NOT nt.topic IN ['setup', 'testing', 'conventions', 'gotcha'] OR trim(nt.text) = ''
108
+ RETURN 'bad-note' AS check, nt.by_node + ': note topic ' + nt.topic + ' must be setup, testing, conventions or gotcha, with non-blank text' AS detail
109
+ UNION ALL
110
+ MATCH (nt:Note) WITH nt, COLLECT { MATCH (w:GbuildNode)-[:WROTE]->(nt) RETURN w.slug } AS by WHERE by <> [nt.by_node]
111
+ RETURN 'note-owner' AS check, nt.by_node + ': note must have exactly one WROTE edge, from the node named by by_node' AS detail
112
+ UNION ALL
113
+ MATCH (s:Symbol) WHERE trim(s.name) = '' OR trim(s.file) = ''
114
+ RETURN 'bad-symbol' AS check, s.by_node + ': symbol needs a non-blank name and file' AS detail
115
+ UNION ALL
116
+ MATCH (s:Symbol) WITH s, COLLECT { MATCH (w:GbuildNode)-[:DEFINES]->(s) RETURN w.slug } AS by WHERE by <> [s.by_node]
117
+ RETURN 'symbol-owner' AS check, s.by_node + '.' + s.name + ': symbol must have exactly one DEFINES edge, from the node named by by_node' AS detail
103
118
  }
104
119
  RETURN check, detail
105
120
  ORDER BY check, detail;
package/index.ts CHANGED
@@ -29,17 +29,25 @@ const OPENCODE_NOTES = `
29
29
  You are running under OpenCode, not Claude Code. Apply these mappings to the
30
30
  instructions above:
31
31
 
32
- - \`Agent\`/\`Task\` tool dispatches (including run's per-node fan-out) use the
33
- \`subagent\` tool. Fan-out stays real: one \`subagent\` call per frontier node,
34
- all in a single message.
35
- - \`gbuild-reviewer\` and \`gbuild-auditor\` are not registered agents here. When
36
- the instructions say to spawn one, dispatch the built-in \`general\` subagent
37
- and start its prompt with the full text of
38
- \`${join(root, "agents", "gbuild-reviewer.md")}\` (or
39
- \`${join(root, "agents", "gbuild-auditor.md")}\`, minus the frontmatter) as its
40
- instructions, followed by everything the instructions say to give it (node
41
- definition, diff locations, expected verdict format). It is still a fresh,
42
- isolated agent — never the one that implemented the node.
32
+ - \`Agent\`/\`Task\` tool dispatches (including run's per-node dispatch) use the
33
+ \`subagent\` tool: one call per ready node, with \`background: true\` where
34
+ Claude Code says \`run_in_background: true\`. Handle each completion as it
35
+ arrives instead of waiting for a whole wave. The auditor runs in the
36
+ foreground (no background flag).
37
+ - \`gbuild-implementer\`, \`gbuild-reviewer\` and \`gbuild-auditor\` are not
38
+ registered agents here. When the instructions say to spawn one, dispatch the
39
+ built-in \`general\` subagent instead:
40
+ - \`gbuild-implementer\` / \`gbuild-reviewer\`: the prompt is run's dispatch
41
+ template as written, prefixed with \`Read <Agent file> and follow it.\`,
42
+ where \`<Agent file>\` is the template's own \`Agent file:\` path
43
+ (\`${join(root, "agents", "gbuild-implementer.md")}\` /
44
+ \`${join(root, "agents", "gbuild-reviewer.md")}\`).
45
+ - \`gbuild-auditor\`: send
46
+ \`Read ${join(root, "agents", "gbuild-auditor.md")} and follow it.\`
47
+ followed by exactly what the review skill says to pass it.
48
+ Never paste the agent file's text into the prompt; the subagent reads it
49
+ itself. Each is still a fresh, isolated agent — a reviewer is never the one
50
+ that implemented the node.
43
51
  - \`$ARGUMENTS\` refers to the arguments this skill was invoked with in the
44
52
  conversation.
45
53
  - run's "Workflow tool" backend is Claude-specific; always use the fallback
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-gbuild",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "description": "OpenCode plugin for gbuild — graph-native task planning and execution with concurrent fan-out, per-node review, and durable checkpoints.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -3,8 +3,9 @@
3
3
  Every gbuild skill reads and writes the feature store (modelled in `reference/graph-format.md`) with the
4
4
  `cypherlite` CLI and nothing else. `DB` throughout is the store path:
5
5
  `<git-common-dir>/gbuild/<feature>/db` — beside the feature worktree, machine-local, never committed.
6
- This file is the full operating manual; the skills that only run plugin scripts carry the short version
7
- of these rules inline.
6
+ `INBOX` is `<git-common-dir>/gbuild/<feature>/inbox`, beside it: where `run`'s agents leave their result
7
+ files for `record.cypher` to read. This file is the full operating manual; the skills that only run
8
+ plugin scripts carry the short version of these rules inline.
8
9
 
9
10
  ## Requirement
10
11
 
@@ -24,15 +25,19 @@ require the explicit `query` verb. The old path-first form — database as the f
24
25
 
25
26
  ## One writer, one command at a time
26
27
 
27
- CypherLite takes an exclusive lock on the store while a process has it open. A second process — reader
28
- or writer — fails with `storage locked` (exit 5). So:
29
-
30
- - **Only the skill running in the main context runs `cypherlite` against the store.** Subagents never
31
- do: give them what they need in their prompt, have them report results in their final message, and
32
- record those results yourself.
33
- - **Never run two `cypherlite` commands against the same store at once** — no parallel tool calls on it.
34
- Fan-out is for agents, not for store access.
35
- - On `storage locked` (exit 5), another process holds the store (another gbuild command, a REPL left
28
+ The store is a directory store. A writable open takes its exclusive write lock; a `--read-only` open
29
+ does not, so read-only queries open the store beside a writer. Only a second writer fails, with
30
+ `storage locked` (exit 5). So:
31
+
32
+ - **Only the skill running in the main context writes to the store.** It is the only writer: schema,
33
+ dispatch, record, set-status and authored writes run there and nowhere else.
34
+ - **Subagents may run read-only queries, never writes.** `run`'s implementers and reviewers read their
35
+ own brief (`brief.cypher`), the helper registry (`symbols.cypher`) and their result check
36
+ (`check-result.cypher`), always with `--read-only` — their agent files list exactly what they run.
37
+ They report through files in `INBOX` plus a one-line reply; the orchestrator records them.
38
+ - **One `cypherlite` command at a time from the main context** — no parallel tool calls on the store.
39
+ Fan-out is for agents, not for store writes.
40
+ - On `storage locked` (exit 5), another writer holds the store (another gbuild command, a REPL left
36
41
  open). Wait a few seconds and retry, up to 3 times; then stop and report. Never delete
37
42
  `.cypherlite.lock`.
38
43
 
@@ -40,9 +45,9 @@ or writer — fails with `storage locked` (exit 5). So:
40
45
 
41
46
  Always pass `--mode jsonl`: one compact JSON object per row, one row per line, and no output at all when
42
47
  there are no rows. Every call is `cypherlite query [STATEMENT] --database DB …`; with no STATEMENT the
43
- piped stdin is the script list. Pure reads add `--read-only`: no exit flush, a shared lock, and a
44
- fail-fast on a wrong path — a read-only call never creates a missing store. Writes (schema creation,
45
- dispatch, record, set-status, authored heredocs) go without.
48
+ piped stdin is the script list. Pure reads add `--read-only`: no exit flush, no lock (it opens beside
49
+ a writer, as above), and a fail-fast on a wrong path — a read-only call never creates a missing store.
50
+ Writes (schema creation, dispatch, record, set-status, authored heredocs) go without.
46
51
 
47
52
  ```
48
53
  # a plugin read script
@@ -70,6 +75,8 @@ CYPHER
70
75
  - **Parameters are JSON.** Every `--param` value is JSON, single-quoted for the shell: strings in double
71
76
  quotes (`--param 'slug="01-scan-runners"'`), lists and objects as JSON (`--param 'slugs=["a","b"]'`).
72
77
  Bare values that start with a digit — timestamps, `01-…` slugs — are rejected, so always quote.
78
+ `--fetch-params NAME=<file>` binds a JSON file as `$NAME` instead — how `record` and `check-result`
79
+ read inbox files without anyone retyping them.
73
80
  - **String literals** in Cypher you author: single quotes by default; double quotes when the text
74
81
  contains an apostrophe (`"gbuild's"`); backslash-escape (`\'`, `\"`) when it contains both.
75
82
  - **Authored writes are atomic.** Wrap them in `.begin` / `.commit` — if any statement fails, the whole
@@ -119,12 +126,15 @@ All in `${CLAUDE_PLUGIN_ROOT}/cypher/`.
119
126
  | `validate.cypher` | — | one row per violation (`check`, `detail`); no rows = valid | every skill |
120
127
  | `progress.cypher` | — | one row per wave: `total`, counts per state, `open` (slugs not yet completed or cancelled) | every skill |
121
128
  | `attention.cypher` | — | only nodes needing a decision: `slug`, `reason` (`failed` / `in_progress` / `blocked-by-failed`), `detail` | run, status |
122
- | `dispatch.cypher` | — | claims the whole frontier (→ `in_progress`); per claimed node: `slug`, `title`, `type`, `acceptance`, `failure_policy`, `model_tier`, `verify`, `inputs` (`name`, `shape`, `value`), `outputs` (`name`, `shape`) | run |
123
- | `record.cypher` | `results` — list of `{slug, verdict, failed_criteria, outputs, started_at, completed_at}` | per recorded node: `attempt`, `verdict`, `completed`, `missing_outputs` | run |
129
+ | `dispatch.cypher` | — | claims every frontier node (→ `in_progress`); per claimed node only the routing columns `slug`, `type`, `model_tier`, `failure_policy` — agents read the rest with `brief` | run (at start, and after every record / set-status) |
130
+ | `brief.cypher` | `slug` | one node's brief: `slug`, `title`, `type`, `acceptance`, `verify`, `inputs` (`name`, `shape`, `from`, `value`), `outputs` (`name`, `shape`), `failed_criteria` (the latest review's when it failed, else `[]`), `notes` (`topic`, `text`, `by` — every transitive upstream node's notes plus setup and testing notes from any node, upstream first, then newest, at most 20) | run's implementer and reviewer (read-only) |
131
+ | `check-result.cypher` | `slug`; `result` (`--fetch-params result=INBOX/<slug>.json`) | `slug`, `missing_outputs`, `invalid_outputs`, `unknown_outputs`, `has_times` — clean when the three lists are `[]` and `has_times` is `true` | run's implementer and reviewer (read-only) |
132
+ | `record.cypher` | `verdict` — `{slug, verdict, failed_criteria}`; `result` — `--fetch-params result=INBOX/<slug>.json` on a pass whose file exists, else `{}`; `review` — `--fetch-params review=INBOX/<slug>.review.json` when it exists, else `{}` (all three always bound) | one call per node: `slug`, `attempt`, `verdict`, `completed`, `missing_outputs`, `notes_added`, `symbols_added`, `unselected`; the `Review` keeps the review file's `checklist`, and a completing pass stores the result file's outputs, times, `notes` (as `Note`) and `symbols` (as `Symbol`), skipping notes and symbols already stored | run |
133
+ | `symbols.cypher` | — | `name`, `file`, `purpose`, `by` — every completed node's registered helpers, by file and name, at most 100 | run's implementer and reviewer (read-only) |
124
134
  | `set-status.cypher` | `slugs` (list), `status` (`pending` / `in_progress` / `failed` / `cancelled`) | `slug`, `status` per changed node | run |
125
- | `status.cypher` | — | one row per node: `state`, `wave`, `waiting_on`, review summary, timestamps | status (human report) |
126
- | `node.cypher` | `slug` | one node in full: properties, `inputs` (with `from`), `outputs`, `depends_on`, `satisfies`, `reviews` | plan (reopen), ad hoc |
135
+ | `status.cypher` | — | one row per node: `state`, `wave`, `waiting_on`, review summary, timestamps, `last_dependency_at` (latest `completed_at` among its completed dependencies, null without any) | status (human report) |
136
+ | `node.cypher` | `slug` | one node in full: properties, `inputs` (with `from`), `outputs`, `depends_on`, `satisfies`, `reviews` (`attempt`, `verdict`, `failed_criteria`, `checklist`, `at`) | plan (reopen), ad hoc |
127
137
  | `feature.cypher` | — | `destination`, `context`, `out_of_scope`, `acceptance` (with `covered_by`) | review, pr |
128
138
 
129
- Ready-made ad-hoc queries (lineage, critical path, repair history, …) are in
139
+ Ready-made ad-hoc queries (lineage, critical path, repair history, notes by topic, …) are in
130
140
  `${CLAUDE_PLUGIN_ROOT}/reference/queries.md`.
@@ -4,9 +4,9 @@ This file is normative. Where a skill disagrees with this doc, this doc wins.
4
4
 
5
5
  One CypherLite store per feature: `<git-common-dir>/gbuild/<feature>/db/`, beside the feature worktree
6
6
  (`<git-common-dir>/gbuild/<feature>/tree/`, which `plan` creates) — untracked, machine-local, never
7
- committed. `plan` creates and charts it, `run` records progress in it, and every skill reads it — **all
8
- through Cypher**. No skill reads or writes `graph.json` directly; it is CypherLite's local snapshot of
9
- the store, not a gbuild artifact. How to talk to the store (the single-writer rule, invocation, quoting,
7
+ committed. `plan` creates and charts it, `run` records progress in it, and every skill reads it, as do
8
+ `run`'s agents (read-only) — **all through Cypher**. No skill reads or writes `graph.json` directly; it
9
+ is CypherLite's local snapshot of the store, not a gbuild artifact. How to talk to the store (the single-writer rule, invocation, quoting,
10
10
  the script catalog) is in `reference/cypher.md`.
11
11
 
12
12
  `templates/example.cypher` is a complete worked example — a 4-node diamond — written exactly the way
@@ -15,13 +15,21 @@ the script catalog) is in `reference/cypher.md`.
15
15
  ## Store layout
16
16
 
17
17
  ```
18
- <git-common-dir>/gbuild/<feature>/db/
19
- graph.json CypherLite JSON snapshot — the engine's local snapshot; gbuild never reads or writes it
20
- manifest.json engine files
21
- wal/
22
- .cypherlite.lock
18
+ <git-common-dir>/gbuild/<feature>/
19
+ db/ the store
20
+ graph.json CypherLite JSON snapshot — the engine's local snapshot; gbuild never reads or writes it
21
+ manifest.json engine files
22
+ wal/
23
+ .cypherlite.lock
24
+ inbox/ run's hand-off files: <slug>.json (implementer result), <slug>.review.json (reviewer checklist)
25
+ tree/ the feature worktree
26
+ <slug>/ a file-touching node's worktree, during run
23
27
  ```
24
28
 
29
+ The inbox is not part of the store: `run` empties it when it starts, agents write their files there, and
30
+ `record.cypher` reads them with `--fetch-params` — a node's outputs, notes, symbols and checklist reach
31
+ the store only through that record.
32
+
25
33
  The store lives outside every checkout: untracked, machine-local, never committed. It is created with
26
34
  the JSON codec (`--snapshot-format json`, see `reference/cypher.md`) for cross-build compatibility: a
27
35
  parquet-enabled CypherLite build defaults a new store to Parquet, and a JSON-pinned store opens in every
@@ -36,6 +44,8 @@ build.
36
44
  | `GbuildNode` | one unit of work (always with a PascalCase type label) | one per node |
37
45
  | `Field` | one named input or output of a GbuildNode's contract | one per field |
38
46
  | `Review` | one `gbuild-reviewer` verdict on a GbuildNode | one per review attempt |
47
+ | `Note` | a fact an implementer left for later agents | 0–5 per completed node |
48
+ | `Symbol` | a reusable helper a completed node added | 0–10 per completed node |
39
49
 
40
50
  ### `Feature`
41
51
 
@@ -62,7 +72,7 @@ universal label is for `MATCH (n:GbuildNode)`; the type label enables `MATCH (n:
62
72
 
63
73
  | property | type | meaning |
64
74
  | ---------------- | -------------- | -------------------------------------------------------------- |
65
- | `slug` | string | kebab-case, unique, immutable — the identity every skill keys on; `tree` is reserved (the feature worktree's directory name) |
75
+ | `slug` | string | kebab-case, unique, immutable — the identity every skill keys on; `tree`, `db` and `inbox` are reserved (they name the directories beside the node worktrees) |
66
76
  | `title` | string | what the user reads; refer to nodes by title in conversation |
67
77
  | `type` | string | `research \| decision \| code \| test \| verify \| chore` |
68
78
  | `acceptance` | list of string | node-level criteria — mandatory, non-empty, concrete |
@@ -107,8 +117,36 @@ map as a property value. JSON-encode anything nested into a string and say so in
107
117
  ### `Review` — one reviewer verdict
108
118
 
109
119
  `attempt` (integer, 1-based, assigned by `record.cypher`), `verdict` (`pass | fail`),
110
- `failed_criteria` (list of the acceptance criteria the reviewer failed; empty on a pass), `at` (string).
111
- Reviews are never updated or deleted — the repair history stays visible to `status`.
120
+ `failed_criteria` (list of the acceptance criteria the reviewer failed; empty on a pass), `checklist`
121
+ (list of string — the reviewer's per-check lines from `<slug>.review.json`, at most 40, each cut to 300
122
+ chars; `[]` when it wrote none), `at` (string). The verdict comes from the reviewer's reply, never from
123
+ the checklist file. Reviews are never updated or deleted — the repair history stays visible to `status`.
124
+
125
+ ### `Note` — a fact left for later agents
126
+
127
+ | property | type | meaning |
128
+ | --------- | ------ | -------------------------------------------------------------------- |
129
+ | `topic` | string | `setup \| testing \| conventions \| gotcha` |
130
+ | `text` | string | non-blank, trimmed, at most 300 chars |
131
+ | `by_node` | string | slug of the writing GbuildNode — the note's only `WROTE` source |
132
+ | `at` | string | the completing `Review`'s `at` |
133
+
134
+ ### `Symbol` — a registered helper
135
+
136
+ | property | type | meaning |
137
+ | --------- | ------ | -------------------------------------------------------------------- |
138
+ | `name` | string | non-blank identifier, as written in code |
139
+ | `file` | string | non-blank repo-relative path |
140
+ | `purpose` | string | one line, at most 160 chars |
141
+ | `by_node` | string | slug of the defining GbuildNode — the symbol's only `DEFINES` source |
142
+ | `at` | string | the completing `Review`'s `at` |
143
+
144
+ Only `record.cypher` writes them, from the optional `notes` and `symbols` in a node's result file, and
145
+ only when it completes the node — so they exist only for completed nodes; a fail or a downgraded pass
146
+ stores none. A node keeps at most 5 notes (any whose exact `topic` and `text` is already stored is
147
+ skipped) and 10 symbols (the first of each `name` and `file`). `brief.cypher` gives a node its
148
+ transitive upstream nodes' notes plus every `setup` and `testing` note; `symbols.cypher` lists every
149
+ symbol.
112
150
 
113
151
  ## Relationships
114
152
 
@@ -121,6 +159,8 @@ Reviews are never updated or deleted — the repair history stays visible to `st
121
159
  | `OUTPUT` | `GbuildNode` → `Field` | the node produces this field |
122
160
  | `FROM` | `Field` → `Field` | an input field reads an upstream node's output field |
123
161
  | `REVIEWED` | `GbuildNode` → `Review` | a verdict on the node |
162
+ | `WROTE` | `GbuildNode` → `Note` | the node left this note |
163
+ | `DEFINES` | `GbuildNode` → `Symbol` | the node added this helper |
124
164
 
125
165
  No other edge shapes are valid. `MATCH (n)-[:DEPENDS_ON]->(d)` is "what does n need";
126
166
  `MATCH (n)<-[:DEPENDS_ON]-(d)` is "what waits on n".
@@ -175,9 +215,9 @@ with a value for every `OUTPUT` — a pass missing outputs is recorded as a fail
175
215
 
176
216
  | layer | enforces |
177
217
  | ------------------------- | ------------------------------------------------------------------------ |
178
- | engine constraints (`cypher/schema.cypher`) | required properties and their types; unique `slug` and `Acceptance.id` — a violating write never lands |
179
- | `cypher/validate.cypher` | exactly one `Feature`; enums; type label matches `type`; non-blank acceptance; `verify` target exists; acceptance linked and covered; allowed edge shapes only; acyclic `DEPENDS_ON`; every chore depended on; every node has an output; field ownership and uniqueness; the `FROM` cut test; completed nodes have a passing review and all output values |
180
- | write scripts (`cypher/*.cypher`) | `dispatch` claims only frontier nodes; `record` completes a node only on a pass with every output; `set-status` never sets `completed` |
218
+ | engine constraints (`cypher/schema.cypher`) | required properties and their types (including every `Note` and `Symbol` property, and `Review.checklist` as a list of strings); unique `slug` and `Acceptance.id` — a violating write never lands |
219
+ | `cypher/validate.cypher` | exactly one `Feature`; enums; kebab-case slugs other than `tree`, `db`, `inbox` (`bad-slug`); type label matches `type`; non-blank acceptance; `verify` target exists; acceptance linked and covered; allowed labels and edge shapes only (`WROTE`, `DEFINES` included); acyclic `DEPENDS_ON`; every chore depended on; every node has an output; field ownership and uniqueness; the `FROM` cut test; completed nodes have a passing review and all output values; note topic and non-blank text (`bad-note`); non-blank symbol name and file (`bad-symbol`); each note and symbol owned by exactly its `by_node` (`note-owner`, `symbol-owner`) |
220
+ | write scripts (`cypher/*.cypher`) | `dispatch` claims only frontier nodes; `record` completes a node only on a pass with every output, and stores notes and symbols only then; `set-status` never sets `completed` |
181
221
 
182
222
  ## Querying
183
223
 
@@ -35,4 +35,8 @@ MATCH (a:Acceptance)<-[:SATISFIES]-(n:GbuildNode) RETURN a.id, collect(n.slug)
35
35
 
36
36
  -- nodes that needed a repair pass
37
37
  MATCH (n:GbuildNode)-[:REVIEWED]->(r:Review {verdict: 'fail'}) RETURN n.slug, count(r) AS failed_reviews
38
+
39
+ -- notes by topic, newest first (topics: setup, testing, conventions, gotcha)
40
+ MATCH (nt:Note) WHERE nt.topic IN ['setup'] RETURN nt.topic AS topic, nt.text AS text, nt.by_node AS by
41
+ ORDER BY nt.at DESC, text LIMIT 20
38
42
  ```
@@ -7,9 +7,9 @@ Plan the work described in `$ARGUMENTS` as a graph.
7
7
  ## The loop
8
8
 
9
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>`.
10
+ skill: `/gbuild:run <slug>` once — it starts each node as its dependencies complete, until the graph
11
+ completes. Then `/gbuild:review <slug>`; if it finds issues, reopen with
12
+ `/gbuild:plan <slug> --add "<fix>"` and `/gbuild:run` again; if clean, `/gbuild:pr <slug>`.
13
13
 
14
14
  Read `${CLAUDE_PLUGIN_ROOT}/reference/graph-format.md` (the normative graph model) and
15
15
  `${CLAUDE_PLUGIN_ROOT}/reference/cypher.md` (how to talk to the store) before doing anything, and run