wicked-brain 0.14.3 → 0.15.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain",
3
- "version": "0.14.3",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "Digital brain as skills for AI coding CLIs — no vector DB, no embeddings, no infrastructure",
6
6
  "keywords": [
@@ -244,7 +244,15 @@ async function ensureServer(brainPath, opts) {
244
244
  }
245
245
 
246
246
  log(`starting wicked-brain-server (brain=${brainPath} port=${port})`);
247
- const sourcePath = sourceOverride || meta.source_path;
247
+ let sourcePath = sourceOverride || meta.source_path;
248
+ // Per-project brains are keyed on basename(cwd). Configs written before
249
+ // source_path persistence lack the field — derive it from cwd (only when
250
+ // the basename convention confirms cwd is the project root) so the server
251
+ // roots LSP at the project and persists source_path for port-resolution
252
+ // consumers (wicked-garden hooks match configs by source_path).
253
+ if (!sourcePath && basename(brainPath) === basename(process.cwd())) {
254
+ sourcePath = process.cwd();
255
+ }
248
256
  const argv = [SERVER_BIN, "--brain", brainPath, "--port", String(port)];
249
257
  if (sourcePath) argv.push("--source", sourcePath);
250
258
 
@@ -465,18 +473,19 @@ Examples:
465
473
  if (args.flags.version) {
466
474
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
467
475
  process.stdout.write(pkg.version + "\n");
468
- process.exit(0);
476
+ return;
469
477
  }
470
478
 
471
479
  if (args.flags.help) {
472
480
  process.stdout.write(HELP);
473
- process.exit(0);
481
+ return;
474
482
  }
475
483
 
476
484
  const noModeFlag = !args.flags.start && !args.flags.stop && !args.flags.status;
477
485
  if (noModeFlag && args.positional.length === 0) {
478
486
  process.stderr.write(HELP);
479
- process.exit(1);
487
+ process.exitCode = 1;
488
+ return;
480
489
  }
481
490
 
482
491
  const log = (msg) => process.stderr.write(`[wicked-brain-call] ${msg}\n`);
@@ -18,6 +18,7 @@ import { renderViewerHtml } from "../lib/viewer-page.mjs";
18
18
  import { walkBrainContent, purgeBrainContent } from "../lib/brain-walker.mjs";
19
19
  import { runOnboardWiki } from "../lib/onboard-wiki.mjs";
20
20
  import { readFile as readFileAsync } from "node:fs/promises";
21
+ import { makeGraphActions } from "../lib/codegraph-actions.mjs";
21
22
 
22
23
  // Parse args
23
24
  const args = argv.slice(2);
@@ -31,7 +32,7 @@ function getArg(name) {
31
32
  if (args.includes("--version") || args.includes("-v")) {
32
33
  const pkgUrl = new URL("../../package.json", import.meta.url);
33
34
  const pkg = JSON.parse(readFileSync(pkgUrl, "utf-8"));
34
- console.log(pkg.version);
35
+ process.stdout.write(pkg.version + "\n");
35
36
  exit(0);
36
37
  }
37
38
 
@@ -112,13 +113,14 @@ try {
112
113
 
113
114
  // LSP client — pass source path so language servers are rooted at the project, not the brain dir
114
115
  const lsp = new LspClient(brainPath, db, sourcePath);
116
+ const graphActions = makeGraphActions({ sourcePath, brainPath });
115
117
 
116
118
  // Auto-memorize subscriber handle (set after bus init in server.listen callback)
117
119
  let memorySubscriber = null;
118
120
 
119
121
  // Graceful shutdown
120
122
  async function shutdown() {
121
- console.log("Shutting down...");
123
+ process.stdout.write("Shutting down...\n");
122
124
  try { unlinkSync(pidPath); } catch {}
123
125
  watcher.stop();
124
126
  if (memorySubscriber) {
@@ -246,6 +248,8 @@ const actions = {
246
248
  "lsp-call-hierarchy-in": (p) => lsp.callHierarchyIn(p),
247
249
  "lsp-call-hierarchy-out": (p) => lsp.callHierarchyOut(p),
248
250
  "lsp-diagnostics": (p) => lsp.diagnostics(p),
251
+ // Graph (codegraph) actions
252
+ ...graphActions,
249
253
  reonboard: async () => {
250
254
  // Detect mode + stamp the CLAUDE.md/AGENTS.md pointer, then rebuild the
251
255
  // search index from whatever content is on disk in this brain. Does NOT
@@ -318,6 +322,8 @@ const WRITE_ACTIONS = new Set([
318
322
  // DLQ replay/drop mutate the bus DB; list is read-only.
319
323
  "dlq_replay",
320
324
  "dlq_drop",
325
+ // Graph rebuild is a write — shells out to codegraph CLI.
326
+ "graph-index",
321
327
  ]);
322
328
 
323
329
  // HTTP server
@@ -392,12 +398,16 @@ try {
392
398
  let metaConfig = {};
393
399
  try { metaConfig = JSON.parse(readFileSync(metaConfigPath, "utf-8")); } catch {}
394
400
  metaConfig.server_port = port;
401
+ // Persist the source root alongside the port: external port resolution
402
+ // (wicked-garden hooks) matches per-project configs by source_path, and
403
+ // configs created before this field existed would otherwise never gain it.
404
+ if (sourcePath) metaConfig.source_path = sourcePath;
395
405
  writeFileSync(metaConfigPath, JSON.stringify(metaConfig, null, 2) + "\n");
396
406
  } catch (err) {
397
407
  console.error(`Warning: could not write port to config: ${err.message}`);
398
408
  }
399
409
 
400
- console.log(`wicked-brain-server running on port ${port} (brain: ${brainId}, pid: ${pid})`);
410
+ process.stdout.write(`wicked-brain-server running on port ${port} (brain: ${brainId}, pid: ${pid})\n`);
401
411
  watcher.start();
402
412
  const busReady = await waitForBus();
403
413
  emitEvent("wicked.server.started", "brain.system", {
@@ -0,0 +1,34 @@
1
+ import Database from "better-sqlite3";
2
+ import { CodegraphClient } from "./codegraph-client.mjs";
3
+ import { runIndex, staleness, dbPath } from "./codegraph-index.mjs";
4
+ import { runExtractors } from "./codegraph-extract.mjs";
5
+
6
+ /**
7
+ * Build the graph-* action handlers bound to a source repo. A fresh client per
8
+ * call keeps the readonly DB handle short-lived and always reopens after a rebuild.
9
+ */
10
+ export function makeGraphActions({ sourcePath, brainPath } = {}) {
11
+ const withClient = (fn) => {
12
+ const c = new CodegraphClient(sourcePath);
13
+ try { return fn(c); } finally { c.close(); }
14
+ };
15
+ return {
16
+ "graph-blast-radius": (p = {}) => withClient((c) => c.blastRadius({ node: p.node, maxDepth: p.maxDepth })),
17
+ "graph-callers": (p = {}) => withClient((c) => c.callers({ node: p.node })),
18
+ "graph-lineage": (p = {}) => withClient((c) => c.lineage({ node: p.node, maxDepth: p.maxDepth })),
19
+ "graph-index": async () => {
20
+ const r = await runIndex(sourcePath, { brainPath, sourcePath });
21
+ if (!r.ok) {
22
+ return { ...r, staleness: staleness(sourcePath) };
23
+ }
24
+ const db = new Database(dbPath(sourcePath));
25
+ let injected;
26
+ try {
27
+ injected = await runExtractors({ db, sourcePath });
28
+ } finally {
29
+ db.close();
30
+ }
31
+ return { ...r, injected, staleness: staleness(sourcePath) };
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync } from "node:fs";
2
+ import Database from "better-sqlite3";
3
+ import { dbPath, staleness } from "./codegraph-index.mjs";
4
+
5
+ // Pinned by Task 1 (docs/codegraph-contract.md): edges are stored
6
+ // consumer->producer, edge(source=dependent, target=dependency). So dependents
7
+ // of X are rows WHERE target=X (collect source); dependencies are the inverse.
8
+ const DEPENDENTS_BY = "target";
9
+ const DEPENDENCIES_BY = DEPENDENTS_BY === "target" ? "source" : "target";
10
+
11
+ export class CodegraphClient {
12
+ #sourcePath;
13
+ #db = null;
14
+
15
+ constructor(sourcePath) { this.#sourcePath = sourcePath; }
16
+
17
+ #open() {
18
+ if (this.#db) return this.#db;
19
+ const p = dbPath(this.#sourcePath);
20
+ if (!existsSync(p)) return null;
21
+ this.#db = new Database(p, { readonly: true });
22
+ return this.#db;
23
+ }
24
+
25
+ close() { if (this.#db) { this.#db.close(); this.#db = null; } }
26
+
27
+ #nodeRows(ids) {
28
+ if (ids.length === 0) return [];
29
+ const ph = ids.map(() => "?").join(",");
30
+ return this.#db.prepare(
31
+ `SELECT id, kind, name, file_path, start_line, end_line FROM nodes WHERE id IN (${ph})`
32
+ ).all(...ids);
33
+ }
34
+
35
+ // BFS over edges. matchCol = the column matched against the frontier;
36
+ // collectCol = the column collected as the next frontier.
37
+ #walk(start, { matchCol, collectCol, maxDepth }) {
38
+ const stmt = this.#db.prepare(
39
+ `SELECT DISTINCT ${collectCol} AS next FROM edges WHERE ${matchCol} = ?`);
40
+ const seen = new Set();
41
+ let frontier = [start];
42
+ let depth = 0;
43
+ while (frontier.length && depth < maxDepth) {
44
+ const nextFrontier = [];
45
+ for (const node of frontier) {
46
+ for (const row of stmt.all(node)) {
47
+ if (row.next && row.next !== start && !seen.has(row.next)) {
48
+ seen.add(row.next);
49
+ nextFrontier.push(row.next);
50
+ }
51
+ }
52
+ }
53
+ frontier = nextFrontier;
54
+ depth += 1;
55
+ }
56
+ return [...seen];
57
+ }
58
+
59
+ #unavailable() {
60
+ return { engine: "unavailable",
61
+ reason: `no graph at ${dbPath(this.#sourcePath)} — run graph-index`,
62
+ staleness: staleness(this.#sourcePath) };
63
+ }
64
+
65
+ /** Transitive dependents — "what breaks if I change X". */
66
+ blastRadius({ node, maxDepth = 25 }) {
67
+ if (!this.#open()) return this.#unavailable();
68
+ const ids = this.#walk(node, { matchCol: DEPENDENTS_BY, collectCol: DEPENDENCIES_BY, maxDepth });
69
+ return { node, dependents: this.#nodeRows(ids), staleness: staleness(this.#sourcePath) };
70
+ }
71
+
72
+ /** Direct dependents only (depth 1). */
73
+ callers({ node }) {
74
+ if (!this.#open()) return this.#unavailable();
75
+ const ids = this.#walk(node, { matchCol: DEPENDENTS_BY, collectCol: DEPENDENCIES_BY, maxDepth: 1 });
76
+ return { node, callers: this.#nodeRows(ids), staleness: staleness(this.#sourcePath) };
77
+ }
78
+
79
+ /** Transitive dependencies — downstream lineage. */
80
+ lineage({ node, maxDepth = 25 }) {
81
+ if (!this.#open()) return this.#unavailable();
82
+ const ids = this.#walk(node, { matchCol: DEPENDENCIES_BY, collectCol: DEPENDENTS_BY, maxDepth });
83
+ return { node, dependencies: this.#nodeRows(ids), staleness: staleness(this.#sourcePath) };
84
+ }
85
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * codegraph-extract.mjs — extractor registry (builtins + drop-ins).
3
+ *
4
+ * Ships three built-in extractors and discovers per-repo drop-in extractors
5
+ * under <sourcePath>/.codegraph-extractors/*.mjs, each exporting `extract`.
6
+ *
7
+ * Fail-open per extractor: one throwing must not abort the rest.
8
+ *
9
+ * NOTE on drop-in imports: this imports and runs code from the target repo —
10
+ * by design (the repo provides trusted extractors), same trust level as
11
+ * running its tests.
12
+ */
13
+
14
+ import { existsSync, readdirSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { extract as busExtract } from "./codegraph-extractors/bus.mjs";
18
+ import { extract as dispatchExtract } from "./codegraph-extractors/dispatch.mjs";
19
+ import { extract as capabilityExtract } from "./codegraph-extractors/capability.mjs";
20
+ import { ensureFileNode, ensureVirtualNode } from "./codegraph-nodes.mjs";
21
+
22
+ const BUILTINS = [
23
+ { label: "bus", extract: busExtract },
24
+ { label: "dispatch", extract: dispatchExtract },
25
+ { label: "capability", extract: capabilityExtract },
26
+ ];
27
+
28
+ /**
29
+ * Discover drop-in extractors under <sourcePath>/.codegraph-extractors/*.mjs.
30
+ * Each file must export a function `extract`. Sorted for deterministic ordering.
31
+ * Broken files (import error or missing export) are silently skipped.
32
+ *
33
+ * @param {string} sourcePath
34
+ * @returns {Promise<Array<{label:string, extract:Function}>>}
35
+ */
36
+ export async function discoverDropins(sourcePath) {
37
+ const dir = join(sourcePath, ".codegraph-extractors");
38
+ if (!existsSync(dir)) return [];
39
+
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir)
43
+ .filter((f) => f.endsWith(".mjs"))
44
+ .sort();
45
+ } catch {
46
+ return [];
47
+ }
48
+
49
+ const dropins = [];
50
+ for (const filename of entries) {
51
+ const fullpath = join(dir, filename);
52
+ try {
53
+ const mod = await import(pathToFileURL(fullpath).href);
54
+ if (typeof mod.extract === "function") {
55
+ dropins.push({ label: `dropin:${filename}`, extract: mod.extract });
56
+ }
57
+ } catch {
58
+ // Broken drop-in — skip, not fatal
59
+ }
60
+ }
61
+ return dropins;
62
+ }
63
+
64
+ /**
65
+ * Run all extractors (builtins + drop-ins) against the open read-write db.
66
+ * Fail-open per extractor: one throwing must not abort the rest.
67
+ *
68
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
69
+ * @returns {Promise<Record<string, object> & { total_injected_edges: number, dropins: string[] }>}
70
+ */
71
+ export async function runExtractors({ db, sourcePath }) {
72
+ const dropins = await discoverDropins(sourcePath);
73
+ const dropin_labels = dropins.map((d) => d.label);
74
+ const all = [...BUILTINS, ...dropins];
75
+
76
+ const nodes = { ensureFileNode, ensureVirtualNode };
77
+
78
+ const out = {};
79
+ let total = 0;
80
+
81
+ for (const ext of all) {
82
+ try {
83
+ const counts = await ext.extract({ db, sourcePath, nodes });
84
+ out[ext.label] = counts;
85
+ total += counts.edges_added || 0;
86
+ } catch (e) {
87
+ out[ext.label] = { error: String((e && e.message) || e) };
88
+ }
89
+ }
90
+
91
+ return { ...out, total_injected_edges: total, dropins: dropin_labels };
92
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * codegraph-extractors/bus.mjs — wicked-bus producer→consumer injected edges.
3
+ *
4
+ * Port of scripts/codegraph/inject_edges.py with the edge direction CORRECTED:
5
+ *
6
+ * Garden's inject_edges.py inserts (source=producer, target=consumer).
7
+ * The contract doc resolves the ambiguity: edges are stored
8
+ * source=dependent→target=dependency, and DEPENDENTS_BY="target". So
9
+ * blast-radius(X) = WHERE target=X (collect source). For the consumer to
10
+ * surface as a dependent of the producer, the edge must be:
11
+ *
12
+ * source = consumer (dependent — breaks when producer's event changes)
13
+ * target = producer (dependency — the thing being changed)
14
+ *
15
+ * This is the corrected direction. Garden's version inserts the opposite and
16
+ * is a latent bug (blast-radius of the producer would NOT surface the consumer).
17
+ *
18
+ * Algorithm:
19
+ * 1. DELETE edges WHERE provenance='injected:bus' (idempotent)
20
+ * 2. Read <sourcePath>/scripts/_bus_consumers.json
21
+ * 3. Grep <sourcePath>/scripts/**\/*.py for event-string literals
22
+ * 4. For each consumer: confirm node exists; for each producer: confirm node
23
+ * exists; INSERT edge (source=consumer, target=producer)
24
+ */
25
+
26
+ import { readFileSync, readdirSync, statSync } from "node:fs";
27
+ import { join, relative, sep } from "node:path";
28
+
29
+ const EVENT_RE = /["']((?:wicked|wg)\.[a-z0-9_]+(?:\.[a-z0-9_]+)+)["']/g;
30
+ const INJECTED_PROVENANCE = "injected:bus";
31
+
32
+ /**
33
+ * Read and parse _bus_consumers.json. Returns [] on missing or malformed file.
34
+ * @param {string} sourcePath
35
+ * @returns {{ event_filter: string, module: string }[]}
36
+ */
37
+ function readConsumers(sourcePath) {
38
+ const p = join(sourcePath, "scripts", "_bus_consumers.json");
39
+ try {
40
+ const data = JSON.parse(readFileSync(p, "utf8"));
41
+ const consumers = data?.consumers;
42
+ if (!Array.isArray(consumers)) return [];
43
+ return consumers.filter((c) => c?.event_filter && c?.module);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Recursively collect all .py files under dir, skipping __pycache__ dirs
51
+ * and files whose basename ends with _bus_consumers.py.
52
+ * @param {string} dir
53
+ * @returns {string[]} absolute file paths
54
+ */
55
+ function collectPyFiles(dir) {
56
+ const results = [];
57
+ let entries;
58
+ try { entries = readdirSync(dir); } catch { return results; }
59
+ for (const entry of entries) {
60
+ const full = join(dir, entry);
61
+ let st;
62
+ try { st = statSync(full); } catch { continue; }
63
+ if (st.isDirectory()) {
64
+ if (entry === "__pycache__") continue;
65
+ results.push(...collectPyFiles(full));
66
+ } else if (entry.endsWith(".py") && !entry.endsWith("_bus_consumers.py")) {
67
+ results.push(full);
68
+ }
69
+ }
70
+ return results;
71
+ }
72
+
73
+ /**
74
+ * Build event→Set<producerRelpath> map by grepping scripts/**\/*.py.
75
+ * @param {string} sourcePath
76
+ * @param {Set<string>} events
77
+ * @returns {Map<string, Set<string>>}
78
+ */
79
+ function buildProducerMap(sourcePath, events) {
80
+ /** @type {Map<string, Set<string>>} */
81
+ const map = new Map();
82
+ for (const ev of events) map.set(ev, new Set());
83
+
84
+ const scriptsDir = join(sourcePath, "scripts");
85
+ const pyFiles = collectPyFiles(scriptsDir);
86
+
87
+ for (const absPath of pyFiles) {
88
+ let text;
89
+ try { text = readFileSync(absPath, "utf8"); } catch { continue; }
90
+ // posix relpath relative to sourcePath
91
+ const rel = relative(sourcePath, absPath).split(sep).join("/");
92
+ // reset lastIndex between files
93
+ EVENT_RE.lastIndex = 0;
94
+ let m;
95
+ while ((m = EVENT_RE.exec(text)) !== null) {
96
+ const ev = m[1];
97
+ if (map.has(ev)) {
98
+ map.get(ev).add(rel);
99
+ }
100
+ // reset not needed between matches in same string, but be safe
101
+ }
102
+ }
103
+ return map;
104
+ }
105
+
106
+ /**
107
+ * Confirm a file node exists in the graph; return the node id or null.
108
+ * @param {import("better-sqlite3").Database} db
109
+ * @param {string} relpath
110
+ * @returns {string|null}
111
+ */
112
+ function fileNodeId(db, relpath) {
113
+ const id = `file:${relpath}`;
114
+ const row = db.prepare("SELECT 1 FROM nodes WHERE id = ?").get(id);
115
+ return row ? id : null;
116
+ }
117
+
118
+ /**
119
+ * Extract wicked-bus producer→consumer injected edges into the codegraph DB.
120
+ *
121
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
122
+ * @returns {{ edges_added: number, skipped: number, consumers: number }}
123
+ */
124
+ export function extract({ db, sourcePath }) {
125
+ // 1. Idempotent: clear prior bus edges
126
+ db.prepare("DELETE FROM edges WHERE provenance = ?").run(INJECTED_PROVENANCE);
127
+
128
+ // 2. Load consumer registry
129
+ const consumers = readConsumers(sourcePath);
130
+ if (consumers.length === 0) {
131
+ return { edges_added: 0, skipped: 0, consumers: 0 };
132
+ }
133
+
134
+ // 3. Grep for producers
135
+ const events = new Set(consumers.map((c) => c.event_filter));
136
+ const producerMap = buildProducerMap(sourcePath, events);
137
+
138
+ // 4. Insert edges
139
+ const insertEdge = db.prepare(
140
+ "INSERT INTO edges (source, target, kind, metadata, provenance) VALUES (?, ?, ?, ?, ?)"
141
+ );
142
+
143
+ let edges_added = 0;
144
+ let skipped = 0;
145
+
146
+ for (const { event_filter: ev, module: consumerMod } of consumers) {
147
+ // Confirm consumer node exists
148
+ const consumerNodeId = fileNodeId(db, consumerMod);
149
+ if (!consumerNodeId) {
150
+ skipped++;
151
+ continue;
152
+ }
153
+
154
+ const producers = producerMap.get(ev) ?? new Set();
155
+ for (const prodRelpath of [...producers].sort()) {
156
+ // Don't self-link
157
+ if (prodRelpath === consumerMod) continue;
158
+
159
+ // Confirm producer node exists
160
+ const producerNodeId = fileNodeId(db, prodRelpath);
161
+ if (!producerNodeId) {
162
+ skipped++;
163
+ continue;
164
+ }
165
+
166
+ // DIRECTION: source=consumer (dependent), target=producer (dependency)
167
+ // blastRadius(producer) = WHERE target=producer → source=consumer ✓
168
+ insertEdge.run(
169
+ consumerNodeId, // source = consumer (dependent)
170
+ producerNodeId, // target = producer (dependency)
171
+ "references",
172
+ JSON.stringify({ injected: "bus", event: ev }),
173
+ INJECTED_PROVENANCE
174
+ );
175
+ edges_added++;
176
+ }
177
+ }
178
+
179
+ return { edges_added, skipped, consumers: consumers.length };
180
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * codegraph-extractors/capability.mjs — agent→capability injected edges.
3
+ *
4
+ * An agent declares needed capabilities via a `tool-capabilities:` YAML frontmatter
5
+ * block list. This extractor reads those declarations and creates synthetic
6
+ * `capability:<name>` nodes plus edges from the agent file to each capability.
7
+ *
8
+ * Edge direction: source=agent (dependent) → target=capability (dependency).
9
+ * DEPENDENTS_BY="target": blastRadius(capability) = WHERE target=capability → source=agent ✓
10
+ *
11
+ * Frontmatter-only port of wicked-garden's inject_capability_edges.py.
12
+ * No capability registry is imported — brain stays dependency-free.
13
+ *
14
+ * This extractor OWNS the capability nodes:
15
+ * - DELETE edges WHERE provenance='injected:capability'
16
+ * - DELETE nodes WHERE kind='capability'
17
+ * then re-inserts cleanly on each run (fully idempotent).
18
+ */
19
+
20
+ import { readFileSync, readdirSync, statSync } from "node:fs";
21
+ import { join, relative, sep } from "node:path";
22
+ import { ensureFileNode, ensureVirtualNode } from "../codegraph-nodes.mjs";
23
+
24
+ const INJECTED_PROVENANCE = "injected:capability";
25
+
26
+ // Matches the tool-capabilities: YAML block at the start or in frontmatter.
27
+ // Captures the multi-line list body (lines starting with optional whitespace + "- item").
28
+ const CAPS_BLOCK_RE = /(?:^|\n)tool-capabilities:\s*\n((?:[ \t]+-[ \t]*[a-z0-9_-]+[ \t]*\n?)+)/;
29
+
30
+ // Matches individual list items: " - capability-name"
31
+ const ITEM_RE = /-[ \t]*([a-z0-9_-]+)/g;
32
+
33
+ /**
34
+ * Extract the YAML frontmatter block (between leading --- fences) from text.
35
+ * Returns the frontmatter string, or the full text if no fences found.
36
+ * @param {string} text
37
+ * @returns {string}
38
+ */
39
+ function extractFrontmatter(text) {
40
+ const match = text.match(/^---\s*\n([\s\S]*?)\n---/);
41
+ return match ? match[1] : "";
42
+ }
43
+
44
+ /**
45
+ * Parse tool-capabilities list from frontmatter text.
46
+ * Returns a Set of capability names (deduped).
47
+ * @param {string} frontmatter
48
+ * @returns {Set<string>}
49
+ */
50
+ function parseCapabilities(frontmatter) {
51
+ const caps = new Set();
52
+ const blockMatch = CAPS_BLOCK_RE.exec(frontmatter);
53
+ if (!blockMatch) return caps;
54
+
55
+ const block = blockMatch[1];
56
+ ITEM_RE.lastIndex = 0;
57
+ let m;
58
+ while ((m = ITEM_RE.exec(block)) !== null) {
59
+ caps.add(m[1]);
60
+ }
61
+ return caps;
62
+ }
63
+
64
+ /**
65
+ * Recursively collect all .md files under dir.
66
+ * @param {string} dir
67
+ * @returns {string[]} absolute file paths
68
+ */
69
+ function collectMdFiles(dir) {
70
+ const results = [];
71
+ let entries;
72
+ try { entries = readdirSync(dir); } catch { return results; }
73
+ for (const entry of entries) {
74
+ const full = join(dir, entry);
75
+ let st;
76
+ try { st = statSync(full); } catch { continue; }
77
+ if (st.isDirectory()) {
78
+ results.push(...collectMdFiles(full));
79
+ } else if (entry.endsWith(".md")) {
80
+ results.push(full);
81
+ }
82
+ }
83
+ return results;
84
+ }
85
+
86
+ /**
87
+ * Extract agent→capability injected edges into the codegraph DB.
88
+ *
89
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
90
+ * @returns {{ edges_added: number, capabilities: number }}
91
+ */
92
+ export function extract({ db, sourcePath }) {
93
+ // 1. Idempotent: clear prior capability edges and owned nodes
94
+ db.prepare("DELETE FROM edges WHERE provenance = ?").run(INJECTED_PROVENANCE);
95
+ db.prepare("DELETE FROM nodes WHERE kind = 'capability'").run();
96
+
97
+ const insertEdge = db.prepare(
98
+ "INSERT INTO edges (source, target, kind, metadata, provenance) VALUES (?, ?, ?, ?, ?)"
99
+ );
100
+
101
+ let edges_added = 0;
102
+ const distinctCaps = new Set();
103
+
104
+ // 2. Scan agents/**/*.md
105
+ const agentsDir = join(sourcePath, "agents");
106
+ const agentFiles = collectMdFiles(agentsDir);
107
+
108
+ for (const absPath of agentFiles) {
109
+ let text;
110
+ try { text = readFileSync(absPath, "utf8"); } catch { continue; }
111
+
112
+ // Posix relpath relative to sourcePath
113
+ const relpath = relative(sourcePath, absPath).split(sep).join("/");
114
+
115
+ // 3. Parse frontmatter capabilities (deduped per agent)
116
+ const frontmatter = extractFrontmatter(text);
117
+ const caps = parseCapabilities(frontmatter);
118
+ if (caps.size === 0) continue;
119
+
120
+ const src = ensureFileNode(db, relpath);
121
+
122
+ for (const cap of caps) {
123
+ // Ensure the capability virtual node exists
124
+ const tgt = ensureVirtualNode(db, `capability:${cap}`, "capability", cap);
125
+
126
+ insertEdge.run(
127
+ src,
128
+ tgt,
129
+ "references",
130
+ JSON.stringify({ injected: "capability", capability: cap }),
131
+ INJECTED_PROVENANCE
132
+ );
133
+ edges_added++;
134
+ distinctCaps.add(cap);
135
+ }
136
+ }
137
+
138
+ return { edges_added, capabilities: distinctCaps.size };
139
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * codegraph-extractors/dispatch.mjs — command→agent injected edges.
3
+ *
4
+ * A slash command dispatches to a subagent via a `subagent_type: <plugin>:<domain>:<name>`
5
+ * string (in Task(subagent_type="...") or YAML frontmatter). The command file never
6
+ * references the agent file — grep/static can't link them. This extractor injects
7
+ * those edges so blast-radius traversal can surface the dispatching commands when
8
+ * an agent changes.
9
+ *
10
+ * Edge direction: source=command (dependent) → target=agent (dependency).
11
+ * DEPENDENTS_BY="target": blastRadius(agent) = WHERE target=agent → source=command ✓
12
+ *
13
+ * Port of wicked-garden's inject_dispatch_edges.py. Direction is already correct
14
+ * in the garden version for our blast-radius convention (no reversal needed, unlike bus).
15
+ */
16
+
17
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
18
+ import { join, relative, sep } from "node:path";
19
+ import { ensureFileNode } from "../codegraph-nodes.mjs";
20
+
21
+ const INJECTED_PROVENANCE = "injected:dispatch";
22
+
23
+ // Matches subagent_type: "plugin:domain:name" or subagent_type="plugin:domain:name"
24
+ // (with or without quotes, colon or equals separator)
25
+ const SUBAGENT_RE = /subagent_type\s*[:=]\s*["']?([a-z0-9_-]+:[a-z0-9_-]+:[a-z0-9_-]+)["']?/g;
26
+
27
+ /**
28
+ * Recursively collect all .md files under dir.
29
+ * @param {string} dir
30
+ * @returns {string[]} absolute file paths
31
+ */
32
+ function collectMdFiles(dir) {
33
+ const results = [];
34
+ let entries;
35
+ try { entries = readdirSync(dir); } catch { return results; }
36
+ for (const entry of entries) {
37
+ const full = join(dir, entry);
38
+ let st;
39
+ try { st = statSync(full); } catch { continue; }
40
+ if (st.isDirectory()) {
41
+ results.push(...collectMdFiles(full));
42
+ } else if (entry.endsWith(".md")) {
43
+ results.push(full);
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+
49
+ /**
50
+ * Given a handle `plugin:domain:name`, resolve to the agent relpath
51
+ * `agents/<domain>/<name>.md`. Returns the relpath if the file exists, else null.
52
+ * @param {string} sourcePath
53
+ * @param {string} handle e.g. "wicked-garden:d:my-agent"
54
+ * @returns {string|null}
55
+ */
56
+ function resolveHandle(sourcePath, handle) {
57
+ const parts = handle.split(":");
58
+ if (parts.length !== 3) return null;
59
+ const [, domain, name] = parts;
60
+ const relpath = `agents/${domain}/${name}.md`;
61
+ if (existsSync(join(sourcePath, relpath))) return relpath;
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Extract command→agent dispatch injected edges into the codegraph DB.
67
+ *
68
+ * @param {{ db: import("better-sqlite3").Database, sourcePath: string }} opts
69
+ * @returns {{ edges_added: number, dispatches: number }}
70
+ */
71
+ export function extract({ db, sourcePath }) {
72
+ // 1. Idempotent: clear prior dispatch edges
73
+ db.prepare("DELETE FROM edges WHERE provenance = ?").run(INJECTED_PROVENANCE);
74
+
75
+ const insertEdge = db.prepare(
76
+ "INSERT INTO edges (source, target, kind, metadata, provenance) VALUES (?, ?, ?, ?, ?)"
77
+ );
78
+
79
+ let edges_added = 0;
80
+ let dispatches = 0;
81
+
82
+ // 2. Scan commands/**/*.md
83
+ const commandsDir = join(sourcePath, "commands");
84
+ const commandFiles = collectMdFiles(commandsDir);
85
+
86
+ for (const absPath of commandFiles) {
87
+ let text;
88
+ try { text = readFileSync(absPath, "utf8"); } catch { continue; }
89
+
90
+ // Posix relpath relative to sourcePath
91
+ const relpath = relative(sourcePath, absPath).split(sep).join("/");
92
+
93
+ // 3. Find distinct handles in this file
94
+ SUBAGENT_RE.lastIndex = 0;
95
+ const handles = new Set();
96
+ let m;
97
+ while ((m = SUBAGENT_RE.exec(text)) !== null) {
98
+ handles.add(m[1]);
99
+ }
100
+
101
+ for (const handle of handles) {
102
+ // 4. Resolve handle to agent file
103
+ const agentRelpath = resolveHandle(sourcePath, handle);
104
+ if (!agentRelpath) continue; // not an agent file, or file doesn't exist
105
+
106
+ const src = ensureFileNode(db, relpath);
107
+ const tgt = ensureFileNode(db, agentRelpath);
108
+
109
+ insertEdge.run(
110
+ src,
111
+ tgt,
112
+ "references",
113
+ JSON.stringify({ injected: "dispatch", subagent_type: handle }),
114
+ INJECTED_PROVENANCE
115
+ );
116
+ edges_added++;
117
+ dispatches++;
118
+ }
119
+ }
120
+
121
+ return { edges_added, dispatches };
122
+ }
@@ -0,0 +1,56 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { resolveCodegraph } from "./codegraph-resolver.mjs";
5
+
6
+ export function dbPath(sourcePath) {
7
+ return join(sourcePath, ".codegraph", "codegraph.db");
8
+ }
9
+
10
+ /**
11
+ * How far the graph has drifted from HEAD. Fail-open: errors report
12
+ * present-but-unknown, never throw.
13
+ * @returns {{present:boolean, stale:boolean|null, commits_behind:number|null, indexed_at:string|null}}
14
+ */
15
+ export function staleness(sourcePath) {
16
+ const db = dbPath(sourcePath);
17
+ if (!existsSync(db)) {
18
+ return { present: false, stale: null, commits_behind: null, indexed_at: null };
19
+ }
20
+ try {
21
+ const iso = new Date(statSync(db).mtimeMs).toISOString();
22
+ const out = execFileSync("git",
23
+ ["-C", sourcePath, "rev-list", "--count", `--since=${iso}`, "HEAD"],
24
+ { encoding: "utf-8", timeout: 10_000, stdio: ["pipe", "pipe", "pipe"] }).trim();
25
+ const behind = out ? parseInt(out, 10) : 0;
26
+ return { present: true, stale: behind > 0, commits_behind: behind, indexed_at: iso };
27
+ } catch {
28
+ return { present: true, stale: null, commits_behind: null, indexed_at: null };
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Build/refresh the graph by shelling codegraph. `init` bootstraps .codegraph
34
+ * AND indexes; `index` refreshes an existing graph (fails if .codegraph is
35
+ * absent) — so we pick based on db presence (pinned in docs/codegraph-contract.md).
36
+ * Resolves nothing -> {ok:false}. Never throws. `_spawn` is injectable for tests.
37
+ * @returns {Promise<{ok:boolean, subcommand?:string, error?:string}>}
38
+ */
39
+ export function runIndex(sourcePath, opts = {}, _spawn = spawn) {
40
+ return new Promise((resolve) => {
41
+ const argv = resolveCodegraph({ ...opts, sourcePath });
42
+ if (!argv) { resolve({ ok: false, error: "codegraph not resolvable" }); return; }
43
+ const [cmd, ...prefix] = argv;
44
+ const sub = existsSync(dbPath(sourcePath)) ? "index" : "init";
45
+ // stdout is "ignore", not "pipe": codegraph prints a progress banner and on a
46
+ // large repo an undrained stdout pipe can fill and deadlock the child. stderr
47
+ // stays piped so we can surface the failure message.
48
+ const proc = _spawn(cmd, [...prefix, sub, "."], { cwd: sourcePath, stdio: ["ignore", "ignore", "pipe"] });
49
+ let stderr = "";
50
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
51
+ proc.on("error", (e) => resolve({ ok: false, error: e.message }));
52
+ proc.on("close", (code) =>
53
+ resolve(code === 0 ? { ok: true, subcommand: sub }
54
+ : { ok: false, error: stderr.trim() || `exit ${code}` }));
55
+ });
56
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * codegraph-nodes.mjs — self-noding helpers for injected-edge extractors.
3
+ *
4
+ * codegraph indexes *code* (tree-sitter parsed). Injected edges to/from .md
5
+ * files or virtual capability nodes need their endpoints to exist in the nodes
6
+ * table first. These helpers create them idempotently via INSERT OR IGNORE, so
7
+ * real code files codegraph already indexed are never overwritten.
8
+ *
9
+ * Port of scripts/codegraph/_graph_nodes.py, targeting the exact schema in
10
+ * docs/codegraph-contract.md (all NOT NULL columns populated).
11
+ */
12
+
13
+ // Populates every NOT NULL column the codegraph `nodes` schema requires.
14
+ // Columns with defaults (is_exported etc.) are omitted — SQLite fills them.
15
+ const INSERT_NODE =
16
+ "INSERT OR IGNORE INTO nodes " +
17
+ "(id, kind, name, qualified_name, file_path, language, " +
18
+ " start_line, end_line, start_column, end_column, updated_at) " +
19
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)";
20
+
21
+ function nowMs() {
22
+ return Date.now();
23
+ }
24
+
25
+ /**
26
+ * Ensure a `file:<relpath>` node exists in the graph; return its id.
27
+ *
28
+ * For .md command/agent files that codegraph skipped, this creates a synthetic
29
+ * file node so dispatch/capability edges can anchor. For a real source file
30
+ * codegraph already indexed, INSERT OR IGNORE is a no-op (real node preserved).
31
+ *
32
+ * @param {import("better-sqlite3").Database} db - read-write Database
33
+ * @param {string} relpath - POSIX-relative path from repo root
34
+ * @param {string} [language] - language tag (default "markdown")
35
+ * @returns {string} the node id `file:<relpath>`
36
+ */
37
+ export function ensureFileNode(db, relpath, language = "markdown") {
38
+ const id = `file:${relpath}`;
39
+ const name = relpath.split("/").pop();
40
+ db.prepare(INSERT_NODE).run(id, "file", name, relpath, relpath, language, 1, 1, nowMs());
41
+ return id;
42
+ }
43
+
44
+ /**
45
+ * Ensure a synthetic non-file node (e.g. `capability:<name>`) exists; return id.
46
+ *
47
+ * Populates every NOT NULL column the schema demands — the original capability
48
+ * insert set only id/kind/name/file_path and would violate NOT NULL constraints.
49
+ *
50
+ * @param {import("better-sqlite3").Database} db - read-write Database
51
+ * @param {string} id - node id (e.g. "capability:foo")
52
+ * @param {string} kind - node kind (e.g. "capability")
53
+ * @param {string} name - human-readable name
54
+ * @param {string|null} [filePath] - optional file_path; falls back to id
55
+ * @returns {string} the node id
56
+ */
57
+ export function ensureVirtualNode(db, id, kind, name, filePath = null) {
58
+ db.prepare(INSERT_NODE).run(
59
+ id, kind, name, name, filePath ?? id, "virtual",
60
+ 0, 0, nowMs()
61
+ );
62
+ return id;
63
+ }
@@ -0,0 +1,64 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { platform } from "node:os";
5
+
6
+ const PACKAGE = "@colbymchenry/codegraph";
7
+
8
+ function argvFor(target) {
9
+ // A .mjs/.js path is a script -> invoke via node; else run directly.
10
+ return /\.(mjs|js)$/.test(target) ? ["node", target] : [target];
11
+ }
12
+
13
+ function whichDefault(command) {
14
+ try {
15
+ const cmd = platform() === "win32" ? "where" : "which";
16
+ const out = execFileSync(cmd, [command], { encoding: "utf-8", timeout: 5000,
17
+ stdio: ["pipe", "pipe", "pipe"] });
18
+ return out.trim().split("\n")[0] || null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function configBin(brainPath) {
25
+ if (!brainPath) return null;
26
+ try {
27
+ const cfg = JSON.parse(readFileSync(join(brainPath, "_meta", "codegraph.json"), "utf-8"));
28
+ return typeof cfg.bin === "string" && cfg.bin.trim() ? cfg.bin.trim() : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Resolve the argv prefix that invokes codegraph, or null.
36
+ * Ladder: WICKED_CODEGRAPH_BIN (set-but-empty = kill switch) -> brain
37
+ * _meta/codegraph.json {bin} -> PATH -> source node_modules/.bin -> `npx`.
38
+ */
39
+ export function resolveCodegraph(opts = {}) {
40
+ const { env = process.env, brainPath, sourcePath, which = whichDefault,
41
+ allowNpx = true } = opts;
42
+
43
+ if (Object.prototype.hasOwnProperty.call(env, "WICKED_CODEGRAPH_BIN")) {
44
+ const v = (env.WICKED_CODEGRAPH_BIN || "").trim();
45
+ return v ? argvFor(v) : null; // empty == kill switch
46
+ }
47
+ const cfg = configBin(brainPath);
48
+ if (cfg) return argvFor(cfg);
49
+
50
+ const onPath = which("codegraph");
51
+ if (onPath) return [onPath];
52
+
53
+ if (sourcePath) {
54
+ const local = join(sourcePath, "node_modules", ".bin", "codegraph");
55
+ if (existsSync(local)) return [local];
56
+ }
57
+ if (allowNpx && which("npx")) return ["npx", "-y", PACKAGE];
58
+ return null;
59
+ }
60
+
61
+ /** True iff a CONCRETE install resolves (not the npx last resort). */
62
+ export function codegraphAvailable(opts = {}) {
63
+ return resolveCodegraph({ ...opts, allowNpx: false }) !== null;
64
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-brain-server",
3
- "version": "0.14.3",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
6
6
  "keywords": [
@@ -101,6 +101,7 @@ Brain search/query results include `source_type` and `path` fields:
101
101
  ### Rules (follow strictly)
102
102
 
103
103
  - **ALWAYS check the brain BEFORE using Grep, Glob, Read, or Agent(Explore)** — for any find, search, explore, explain, or "what is/how does" request
104
+ - The server auto-starts on any `wicked-brain-call` / skill invocation — a "server not running" notice is NEVER a reason to skip the brain; just call the skill
104
105
  - Use `wicked-brain:search` for finding content ("find X", "where is Y", "look for Z", "show me W")
105
106
  - Use `wicked-brain:query` for questions ("what does X do", "how does Y work", "explain Z")
106
107
  - Use `wicked-brain:agent` (context) when starting a new topic or unfamiliar area
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: wicked-brain:graph
3
+ description: |
4
+ Code-relationship graph queries — blast radius, callers, and lineage — backed
5
+ by a codegraph static graph the brain owns. Answers "what breaks if I change X",
6
+ "who calls X", and "what does X depend on" across the whole repo, including
7
+ relationships a grep or single-file LSP lookup cannot see.
8
+
9
+ Use when: "blast radius", "what breaks if I change", "impact of changing X",
10
+ "who depends on X", "what depends on X", "lineage", "what does X depend on",
11
+ "architecture map", "code relationship graph".
12
+ ---
13
+
14
+ # wicked-brain:graph
15
+
16
+ Relationship-graph intelligence over a codegraph-built SQLite graph. Distinct from
17
+ `wicked-brain:lsp` (live, single-symbol definitions / references / hover /
18
+ diagnostics) — this is the whole-repo relationship graph and the home of
19
+ blast-radius / lineage.
20
+
21
+ ## Cross-Platform Notes
22
+
23
+ This skill uses `npx wicked-brain-call` for all server interaction. The CLI works
24
+ on macOS, Linux, and Windows; it discovers the brain, auto-starts the server, and
25
+ writes a per-call audit record under `{brain}/calls/`.
26
+
27
+ ## Queries
28
+
29
+ - `graph-index` — build/refresh the graph (`codegraph init`/`index`). Run once per
30
+ repo, then on demand when a result reports it is stale.
31
+ - `graph-blast-radius {node}` — transitive **dependents** of `node` ("what breaks
32
+ if I change it").
33
+ - `graph-callers {node}` — direct dependents only (depth 1).
34
+ - `graph-lineage {node}` — transitive **dependencies** (what `node` depends on,
35
+ downstream).
36
+
37
+ `node` ids follow codegraph's convention — e.g. `file:src/app.py`, or a symbol id
38
+ like `function:<hash>` (use `qualified_name` from a search/symbols lookup to find
39
+ the id). Every result carries a `staleness` stamp (`commits_behind`, `indexed_at`);
40
+ when `stale` is true, re-run `graph-index`.
41
+
42
+ ## Freshness
43
+
44
+ Lazy by design — the graph is **never** auto-rebuilt by a file watcher (that path
45
+ is a known CPU-runaway hazard). Results tell you when they are behind HEAD; rebuild
46
+ explicitly with `graph-index` (or wire the optional commit hook). If codegraph is
47
+ not installed, queries return `engine: "unavailable"` rather than a misleading
48
+ empty graph.
49
+
50
+ ## Engine
51
+
52
+ Backed by the `@colbymchenry/codegraph` CLI (resolved at runtime via
53
+ `WICKED_CODEGRAPH_BIN` → brain config → PATH → `npx`). The brain reads codegraph's
54
+ SQLite graph directly; it shells the CLI only to (re)build.
@@ -6,14 +6,18 @@ description: |
6
6
  language servers when missing.
7
7
 
8
8
  Use when: "where is X defined", "who uses X", "what type is X",
9
- "list symbols in", "find symbol", "who calls X", "blast radius",
10
- "architecture map", "code diagnostics", "lsp health".
9
+ "list symbols in", "find symbol", "who calls X",
10
+ "code diagnostics", "lsp health".
11
11
  ---
12
12
 
13
13
  # wicked-brain:lsp
14
14
 
15
15
  Universal code intelligence for any CLI/IDE via the brain's LSP client layer.
16
16
 
17
+ > For whole-repo **relationship** queries — blast radius, lineage, architecture
18
+ > map — use `wicked-brain:graph` (codegraph-backed). LSP here is live,
19
+ > single-symbol intelligence (definitions, references, hover, diagnostics).
20
+
17
21
  ## Cross-Platform Notes
18
22
 
19
23
  This skill uses `npx wicked-brain-call` for all server interaction. The CLI