wicked-brain 0.14.3 → 0.15.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/package.json +1 -1
- package/server/bin/wicked-brain-server.mjs +6 -0
- package/server/lib/codegraph-actions.mjs +34 -0
- package/server/lib/codegraph-client.mjs +85 -0
- package/server/lib/codegraph-extract.mjs +92 -0
- package/server/lib/codegraph-extractors/bus.mjs +180 -0
- package/server/lib/codegraph-extractors/capability.mjs +139 -0
- package/server/lib/codegraph-extractors/dispatch.mjs +122 -0
- package/server/lib/codegraph-index.mjs +56 -0
- package/server/lib/codegraph-nodes.mjs +63 -0
- package/server/lib/codegraph-resolver.mjs +64 -0
- package/server/package.json +1 -1
- package/skills/wicked-brain-graph/SKILL.md +54 -0
- package/skills/wicked-brain-lsp/SKILL.md +6 -2
package/package.json
CHANGED
|
@@ -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);
|
|
@@ -112,6 +113,7 @@ 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;
|
|
@@ -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
|
|
@@ -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
|
+
}
|
package/server/package.json
CHANGED
|
@@ -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",
|
|
10
|
-
"
|
|
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
|