wicked-brain 0.15.2 → 0.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/package.json +1 -1
- package/server/bin/wicked-brain-call.mjs +211 -20
- package/server/package.json +2 -2
- package/skills/wicked-brain-graph/SKILL.md +25 -0
package/README.md
CHANGED
|
@@ -159,8 +159,50 @@ The viewer has no auth. It's localhost-only, same-machine trust.
|
|
|
159
159
|
| `wicked-brain:retag` | Backfill synonym-expanded tags across all chunks for better search recall |
|
|
160
160
|
| `wicked-brain:update` | Check npm for updates and reinstall skills across all detected CLIs |
|
|
161
161
|
| `wicked-brain:lsp` | Universal code intelligence via LSP — hover, go-to-definition, diagnostics, completions |
|
|
162
|
+
| `wicked-brain:graph` | Code-relationship graph — blast radius, callers, lineage — backed by a static code graph |
|
|
162
163
|
| `wicked-brain:ui` | Open the read-only browser viewer — Material-styled Search + Wiki tabs over `http://localhost:<port>/` |
|
|
163
164
|
|
|
165
|
+
## Code Graph (offline / air-gapped)
|
|
166
|
+
|
|
167
|
+
`wicked-brain:graph` (blast radius, callers, lineage) is backed by the
|
|
168
|
+
[`@colbymchenry/codegraph`](https://www.npmjs.com/package/@colbymchenry/codegraph)
|
|
169
|
+
CLI. By **default** the brain resolves that CLI by shelling out to
|
|
170
|
+
`npx @colbymchenry/codegraph` — which **fetches from the npm registry and will
|
|
171
|
+
not work air-gapped**. On an offline/air-gapped machine, point the brain at a
|
|
172
|
+
pre-installed binary with the **`WICKED_CODEGRAPH_BIN`** environment variable.
|
|
173
|
+
|
|
174
|
+
`WICKED_CODEGRAPH_BIN` sits at the **top** of the resolution ladder:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
WICKED_CODEGRAPH_BIN → brain _meta/codegraph.json {bin} → PATH → source node_modules/.bin/codegraph → npx (last resort, network)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Offline install path:**
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# 1. On a connected machine, install codegraph globally (or vendor it):
|
|
184
|
+
npm install -g @colbymchenry/codegraph # provides a `codegraph` on PATH
|
|
185
|
+
# …or install it into the project: npm install @colbymchenry/codegraph
|
|
186
|
+
|
|
187
|
+
# 2. On the air-gapped machine, point the brain at the binary:
|
|
188
|
+
export WICKED_CODEGRAPH_BIN=/usr/local/bin/codegraph # macOS/Linux
|
|
189
|
+
# A .mjs/.js path is run via node; any other path is executed directly.
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```powershell
|
|
193
|
+
# Windows (PowerShell)
|
|
194
|
+
$env:WICKED_CODEGRAPH_BIN = "C:\tools\codegraph\codegraph.cmd"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Notes:
|
|
198
|
+
- Setting `WICKED_CODEGRAPH_BIN` to an **empty string** is a deliberate **kill
|
|
199
|
+
switch** — graph queries return `engine: "unavailable"` instead of falling
|
|
200
|
+
through to the network `npx` path.
|
|
201
|
+
- A per-brain alternative to the env var is `_meta/codegraph.json` with
|
|
202
|
+
`{ "bin": "/path/to/codegraph" }`.
|
|
203
|
+
- If nothing resolves, graph queries degrade gracefully to
|
|
204
|
+
`engine: "unavailable"` rather than returning a misleading empty graph.
|
|
205
|
+
|
|
164
206
|
## Multi-Brain Federation
|
|
165
207
|
|
|
166
208
|
Brains can link to other brains. A personal research brain can reference a team standards brain. A client brain can inherit from a company knowledge base.
|
package/package.json
CHANGED
|
@@ -106,6 +106,20 @@ function readMetaConfig(brainPath) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// The brain the caller EXPECTS to be on this port. The server derives its
|
|
110
|
+
// brain_id from brain.json's `id` field (see wicked-brain-server.mjs), so we
|
|
111
|
+
// read the same source here. Fall back to the per-project basename convention
|
|
112
|
+
// when brain.json is absent — that's the id a freshly-init'd brain will carry.
|
|
113
|
+
function readExpectedBrainId(brainPath) {
|
|
114
|
+
try {
|
|
115
|
+
const cfg = JSON.parse(readFileSync(join(brainPath, "brain.json"), "utf-8"));
|
|
116
|
+
if (cfg && typeof cfg.id === "string" && cfg.id) return cfg.id;
|
|
117
|
+
} catch {
|
|
118
|
+
// brain.json missing/unreadable — fall through to the basename convention.
|
|
119
|
+
}
|
|
120
|
+
return basename(brainPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
// ---------- HTTP ----------
|
|
110
124
|
|
|
111
125
|
async function callApi(port, action, params, { timeoutMs = 30000, auditFile } = {}) {
|
|
@@ -135,6 +149,40 @@ async function healthCheck(port, { timeoutMs = 800 } = {}) {
|
|
|
135
149
|
}
|
|
136
150
|
}
|
|
137
151
|
|
|
152
|
+
// Like healthCheck, but returns the full health body (which carries brain_id)
|
|
153
|
+
// so callers can confirm WHICH brain is answering the port — not just that
|
|
154
|
+
// SOMETHING is. Returns null when nothing responds healthily.
|
|
155
|
+
async function healthInfo(port, { timeoutMs = 800 } = {}) {
|
|
156
|
+
try {
|
|
157
|
+
const r = await callApi(port, "health", {}, { timeoutMs });
|
|
158
|
+
return r && r.status === "ok" ? r : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Raised by ensureServer when a server answers the persisted port but it
|
|
165
|
+
// belongs to a DIFFERENT brain than the one we resolved. The data path
|
|
166
|
+
// (search/index/remove/forget/query/...) routes through ensureServer, so this
|
|
167
|
+
// guard is the single choke point that stops a destructive op from silently
|
|
168
|
+
// hitting the wrong brain on a shared/recycled port. Fail closed — never
|
|
169
|
+
// operate on a mismatched brain.
|
|
170
|
+
class PortConflictError extends Error {
|
|
171
|
+
constructor({ port, expectedBrainId, actualBrainId }) {
|
|
172
|
+
super(
|
|
173
|
+
`port_conflict: port ${port} is held by a different brain ` +
|
|
174
|
+
`(expected brain_id "${expectedBrainId}", got "${actualBrainId ?? "unknown"}"). ` +
|
|
175
|
+
`Refusing the operation so it can't hit the wrong brain. ` +
|
|
176
|
+
`Stop the other server, free the port, or pass the correct --port for this brain.`,
|
|
177
|
+
);
|
|
178
|
+
this.name = "PortConflictError";
|
|
179
|
+
this.code = "port_conflict";
|
|
180
|
+
this.port = port;
|
|
181
|
+
this.expectedBrainId = expectedBrainId;
|
|
182
|
+
this.actualBrainId = actualBrainId ?? null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
138
186
|
// ---------- spawn lock ----------
|
|
139
187
|
// Cross-platform exclusive-create lock via { flag: "wx" }. Stale entries
|
|
140
188
|
// (older than STALE_LOCK_MS) are reaped on contention so a crashed CLI
|
|
@@ -207,12 +255,42 @@ function openServerLog(brainPath) {
|
|
|
207
255
|
}
|
|
208
256
|
}
|
|
209
257
|
|
|
258
|
+
// Reconcile the responding server's brain_id against the brain we resolved.
|
|
259
|
+
// Returns true when the port is ours (or when expectedBrainId is unknown and
|
|
260
|
+
// we choose not to gate). Throws PortConflictError when a DIFFERENT brain
|
|
261
|
+
// answers — the fail-closed path that protects the data plane. One health
|
|
262
|
+
// round-trip per resolution; cheap enough that we don't cache across calls
|
|
263
|
+
// (each CLI invocation resolves the server exactly once anyway).
|
|
264
|
+
function reconcileHealth(health, { port, expectedBrainId }) {
|
|
265
|
+
// No expected id to compare against (no brain.json id, no basename) — can't
|
|
266
|
+
// meaningfully gate, so don't. In practice readExpectedBrainId always yields
|
|
267
|
+
// at least the basename, so this is a defensive fallback only.
|
|
268
|
+
if (!expectedBrainId) return true;
|
|
269
|
+
if (health.brain_id === expectedBrainId) return true;
|
|
270
|
+
throw new PortConflictError({
|
|
271
|
+
port,
|
|
272
|
+
expectedBrainId,
|
|
273
|
+
actualBrainId: health.brain_id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
210
277
|
async function ensureServer(brainPath, opts) {
|
|
211
278
|
const { explicitPort, sourceOverride, noSpawn, log, spawnTimeoutMs = 10_000 } = opts;
|
|
212
279
|
const meta = readMetaConfig(brainPath);
|
|
213
280
|
const port = explicitPort || meta.server_port || 4242;
|
|
214
|
-
|
|
215
|
-
|
|
281
|
+
// The brain we EXPECT on this port — same source --status/--stop reconcile
|
|
282
|
+
// against (brain.json `id`, falling back to the per-project basename).
|
|
283
|
+
const expectedBrainId = opts.expectedBrainId ?? readExpectedBrainId(brainPath);
|
|
284
|
+
|
|
285
|
+
// Warm path: a server already answers the port. Confirm it's OUR brain before
|
|
286
|
+
// handing the port to the data plane. A foreign brain can occupy this port
|
|
287
|
+
// (stale config, manual restart, EADDRINUSE probe-up), and routing by port
|
|
288
|
+
// alone would let a destructive op (remove/forget) silently hit it.
|
|
289
|
+
const warmHealth = await healthInfo(port);
|
|
290
|
+
if (warmHealth) {
|
|
291
|
+
reconcileHealth(warmHealth, { port, expectedBrainId });
|
|
292
|
+
return port;
|
|
293
|
+
}
|
|
216
294
|
if (noSpawn) {
|
|
217
295
|
throw new Error(`server not reachable on port ${port} and --no-spawn was set`);
|
|
218
296
|
}
|
|
@@ -222,14 +300,26 @@ async function ensureServer(brainPath, opts) {
|
|
|
222
300
|
|
|
223
301
|
if (!tryLock(lockPath)) {
|
|
224
302
|
log(`another process is starting the server; waiting...`);
|
|
225
|
-
|
|
303
|
+
const concurrentHealth = await waitForHealthInfo(port, spawnTimeoutMs);
|
|
304
|
+
if (concurrentHealth) {
|
|
305
|
+
// The peer that held the lock brought a server up — confirm it's OUR
|
|
306
|
+
// brain before we route the data plane at it.
|
|
307
|
+
reconcileHealth(concurrentHealth, { port, expectedBrainId });
|
|
308
|
+
return port;
|
|
309
|
+
}
|
|
226
310
|
throw new Error(`concurrent spawn timed out on port ${port}`);
|
|
227
311
|
}
|
|
228
312
|
|
|
229
313
|
try {
|
|
230
314
|
// Re-check after acquiring the lock — another process might have started
|
|
231
|
-
// and finished while we were contending.
|
|
232
|
-
|
|
315
|
+
// and finished while we were contending. Reconcile brain_id here too: the
|
|
316
|
+
// server that came up while we waited could be a different brain probing up
|
|
317
|
+
// onto this port.
|
|
318
|
+
const relockHealth = await healthInfo(port);
|
|
319
|
+
if (relockHealth) {
|
|
320
|
+
reconcileHealth(relockHealth, { port, expectedBrainId });
|
|
321
|
+
return port;
|
|
322
|
+
}
|
|
233
323
|
|
|
234
324
|
const pidPath = join(brainPath, "_meta", "server.pid");
|
|
235
325
|
if (existsSync(pidPath)) {
|
|
@@ -273,7 +363,14 @@ async function ensureServer(brainPath, opts) {
|
|
|
273
363
|
}
|
|
274
364
|
if (logFd !== null) log(`server logs -> ${logPath}`);
|
|
275
365
|
|
|
276
|
-
|
|
366
|
+
const ready = await waitForHealthInfo(port, spawnTimeoutMs);
|
|
367
|
+
if (ready) {
|
|
368
|
+
// We spawned with --brain brainPath, so the server that comes up should
|
|
369
|
+
// be ours. Reconcile anyway: a foreign brain could have won the bind in
|
|
370
|
+
// the race window before our child listened. Fail closed if so.
|
|
371
|
+
reconcileHealth(ready, { port, expectedBrainId });
|
|
372
|
+
return port;
|
|
373
|
+
}
|
|
277
374
|
throw new Error(
|
|
278
375
|
`server did not become ready within ${spawnTimeoutMs}ms on port ${port}` +
|
|
279
376
|
(logFd !== null ? ` — see ${logPath} for the cause` : ""),
|
|
@@ -292,6 +389,18 @@ async function waitForHealth(port, timeoutMs) {
|
|
|
292
389
|
return false;
|
|
293
390
|
}
|
|
294
391
|
|
|
392
|
+
// Like waitForHealth but returns the health body (carrying brain_id) once the
|
|
393
|
+
// port answers, so the spawn path can confirm WHICH brain came up.
|
|
394
|
+
async function waitForHealthInfo(port, timeoutMs) {
|
|
395
|
+
const deadline = Date.now() + timeoutMs;
|
|
396
|
+
while (Date.now() < deadline) {
|
|
397
|
+
const h = await healthInfo(port, { timeoutMs: 500 });
|
|
398
|
+
if (h) return h;
|
|
399
|
+
await sleep(150);
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
295
404
|
function sleep(ms) {
|
|
296
405
|
return new Promise(r => setTimeout(r, ms));
|
|
297
406
|
}
|
|
@@ -495,15 +604,44 @@ Examples:
|
|
|
495
604
|
if (args.flags.status) {
|
|
496
605
|
const meta = readMetaConfig(brainPath);
|
|
497
606
|
const port = args.flags.port || meta.server_port || 4242;
|
|
498
|
-
const
|
|
607
|
+
const expectedBrainId = readExpectedBrainId(brainPath);
|
|
608
|
+
// Ask the responding server WHICH brain it is rather than trusting that the
|
|
609
|
+
// persisted port still belongs to us. A different brain's server can have
|
|
610
|
+
// claimed this port (probe-up on EADDRINUSE, stale config, manual restart),
|
|
611
|
+
// and a blind `running:true` would point an operator at the wrong process.
|
|
612
|
+
const health = await healthInfo(port);
|
|
499
613
|
let pid = null;
|
|
500
614
|
try { pid = parseInt(readFileSync(join(brainPath, "_meta", "server.pid"), "utf-8").trim(), 10); } catch {}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
615
|
+
|
|
616
|
+
let payload;
|
|
617
|
+
if (!health) {
|
|
618
|
+
payload = {
|
|
619
|
+
brain_path: brainPath,
|
|
620
|
+
brain_id: expectedBrainId,
|
|
621
|
+
port,
|
|
622
|
+
running: false,
|
|
623
|
+
pid: null,
|
|
624
|
+
};
|
|
625
|
+
} else if (health.brain_id !== expectedBrainId) {
|
|
626
|
+
// Port is occupied — but by a DIFFERENT brain than the one we resolved.
|
|
627
|
+
payload = {
|
|
628
|
+
brain_path: brainPath,
|
|
629
|
+
brain_id: expectedBrainId,
|
|
630
|
+
port,
|
|
631
|
+
running: false,
|
|
632
|
+
port_conflict: true,
|
|
633
|
+
actual_brain_id: health.brain_id ?? null,
|
|
634
|
+
pid: null,
|
|
635
|
+
};
|
|
636
|
+
} else {
|
|
637
|
+
payload = {
|
|
638
|
+
brain_path: brainPath,
|
|
639
|
+
brain_id: expectedBrainId,
|
|
640
|
+
port,
|
|
641
|
+
running: true,
|
|
642
|
+
pid: pidAlive(pid) ? pid : null,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
507
645
|
process.stdout.write(
|
|
508
646
|
(args.flags.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)) + "\n",
|
|
509
647
|
);
|
|
@@ -514,9 +652,32 @@ Examples:
|
|
|
514
652
|
if (args.flags.stop) {
|
|
515
653
|
const meta = readMetaConfig(brainPath);
|
|
516
654
|
const port = args.flags.port || meta.server_port || 4242;
|
|
655
|
+
const expectedBrainId = readExpectedBrainId(brainPath);
|
|
517
656
|
const pidPath = join(brainPath, "_meta", "server.pid");
|
|
518
657
|
let pid = null;
|
|
519
658
|
try { pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); } catch {}
|
|
659
|
+
|
|
660
|
+
// Reconcile the port's actual occupant before signalling anything. If a
|
|
661
|
+
// DIFFERENT brain answers this port, refuse — killing our persisted PID
|
|
662
|
+
// (or, worse, mistaking the port owner for ours) would take down an
|
|
663
|
+
// unrelated process. The operator must target the right brain or port.
|
|
664
|
+
const health = await healthInfo(port);
|
|
665
|
+
if (health && health.brain_id !== expectedBrainId) {
|
|
666
|
+
const payload = {
|
|
667
|
+
stopped: false,
|
|
668
|
+
reason: "port_conflict",
|
|
669
|
+
port,
|
|
670
|
+
brain_id: expectedBrainId,
|
|
671
|
+
actual_brain_id: health.brain_id ?? null,
|
|
672
|
+
};
|
|
673
|
+
process.stdout.write(
|
|
674
|
+
(args.flags.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)) + "\n",
|
|
675
|
+
);
|
|
676
|
+
// Refused on purpose — surface a non-zero exit so scripted callers notice.
|
|
677
|
+
process.exitCode = 1;
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
520
681
|
if (!pid || !pidAlive(pid)) {
|
|
521
682
|
process.stdout.write(JSON.stringify({ stopped: false, reason: "not running", port }) + "\n");
|
|
522
683
|
return;
|
|
@@ -566,13 +727,43 @@ Examples:
|
|
|
566
727
|
}
|
|
567
728
|
}
|
|
568
729
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
730
|
+
let port;
|
|
731
|
+
try {
|
|
732
|
+
port = await ensureServer(brainPath, {
|
|
733
|
+
explicitPort: args.flags.port,
|
|
734
|
+
sourceOverride: args.flags.source,
|
|
735
|
+
noSpawn: args.flags.noSpawn,
|
|
736
|
+
spawnTimeoutMs: args.flags.spawnTimeoutMs,
|
|
737
|
+
log,
|
|
738
|
+
});
|
|
739
|
+
} catch (err) {
|
|
740
|
+
if (err instanceof PortConflictError) {
|
|
741
|
+
// Fail closed: the persisted port is held by a different brain. Refuse the
|
|
742
|
+
// op (read OR mutate) so a destructive call (remove/forget/index) can't
|
|
743
|
+
// silently hit the wrong brain. Emit a structured payload on stdout that
|
|
744
|
+
// mirrors --stop/--status, plus a non-zero exit for scripted callers.
|
|
745
|
+
const payload = {
|
|
746
|
+
error: err.message,
|
|
747
|
+
action,
|
|
748
|
+
refused: true,
|
|
749
|
+
reason: "port_conflict",
|
|
750
|
+
port: err.port,
|
|
751
|
+
brain_id: err.expectedBrainId,
|
|
752
|
+
actual_brain_id: err.actualBrainId,
|
|
753
|
+
};
|
|
754
|
+
// Leave a refusal breadcrumb so the audit trail shows the op was blocked.
|
|
755
|
+
if (auditEnabled(args.flags.noAudit)) {
|
|
756
|
+
const a = writeAuditOpen(brainPath, action, params, err.port);
|
|
757
|
+
writeAuditClose(a, { exitCode: 1, durationMs: 0, response: payload, error: err.message });
|
|
758
|
+
}
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
(args.flags.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)) + "\n",
|
|
761
|
+
);
|
|
762
|
+
process.exitCode = 1;
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
die(err.message);
|
|
766
|
+
}
|
|
576
767
|
|
|
577
768
|
// Open audit BEFORE the call so a crash mid-flight still leaves a partial
|
|
578
769
|
// record. Audit is best-effort — write failures never block the request.
|
package/server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-brain-server",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SQLite FTS5 search server for wicked-brain digital knowledge bases",
|
|
6
6
|
"keywords": [
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"wicked-bus": "^2.0.0"
|
|
41
41
|
},
|
|
42
42
|
"engines": {
|
|
43
|
-
"node": ">=
|
|
43
|
+
"node": ">=20.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -52,3 +52,28 @@ empty graph.
|
|
|
52
52
|
Backed by the `@colbymchenry/codegraph` CLI (resolved at runtime via
|
|
53
53
|
`WICKED_CODEGRAPH_BIN` → brain config → PATH → `npx`). The brain reads codegraph's
|
|
54
54
|
SQLite graph directly; it shells the CLI only to (re)build.
|
|
55
|
+
|
|
56
|
+
### Offline / air-gapped install
|
|
57
|
+
|
|
58
|
+
By **default** the engine is resolved by shelling out to
|
|
59
|
+
`npx @colbymchenry/codegraph`, which **downloads from the npm registry and will
|
|
60
|
+
not work on an air-gapped machine**. To run offline, install the CLI ahead of
|
|
61
|
+
time and point the brain at the binary with **`WICKED_CODEGRAPH_BIN`** — the
|
|
62
|
+
**highest-priority** entry in the resolution ladder:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
WICKED_CODEGRAPH_BIN → _meta/codegraph.json {bin} → PATH → source node_modules/.bin/codegraph → npx (network, last resort)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Pre-install on a connected machine, then on the air-gapped host:
|
|
70
|
+
export WICKED_CODEGRAPH_BIN=/usr/local/bin/codegraph # macOS/Linux
|
|
71
|
+
```
|
|
72
|
+
```powershell
|
|
73
|
+
$env:WICKED_CODEGRAPH_BIN = "C:\tools\codegraph\codegraph.cmd" # Windows
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- A `.mjs`/`.js` target is invoked via `node`; any other path is executed directly.
|
|
77
|
+
- An **empty** `WICKED_CODEGRAPH_BIN` is a **kill switch** — queries return
|
|
78
|
+
`engine: "unavailable"` rather than reaching for the network `npx` path.
|
|
79
|
+
- Per-brain alternative: write `_meta/codegraph.json` with `{ "bin": "/path/to/codegraph" }`.
|