vexp-cli 2.2.2 → 2.2.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.
@@ -10,7 +10,7 @@ import * as fs from "fs";
10
10
  import * as path from "path";
11
11
  import * as os from "os";
12
12
  import * as crypto from "crypto";
13
- import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD } from "./hook-template.js";
13
+ import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD } from "./hook-template.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Constants
16
16
  // ---------------------------------------------------------------------------
@@ -291,8 +291,20 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
291
291
  // Write MCP config for agents with mcpConfigFile (project-level JSON)
292
292
  if (detector.mcpConfigFile) {
293
293
  const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
294
- const alwaysAllow = detector.agent === "Windsurf" ? VEXP_TOOLS : undefined;
295
- if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot)) {
294
+ const isKiro = detector.agent === "Kiro";
295
+ // Windsurf and Kiro auto-approve vexp's tools so the agent isn't prompted
296
+ // on every run_pipeline call. Kiro's field is `autoApprove`; others use
297
+ // `alwaysAllow`.
298
+ const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
299
+ const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
300
+ // Kiro resolves the MCP `command` via PATH, and a macOS GUI launch has no
301
+ // `node` on PATH — so the `node <mcp-server.cjs>` transport silently fails
302
+ // to start and run_pipeline never appears (the agent then falls back to
303
+ // grep/sed). Point Kiro straight at the vexp binary (`vexp-core mcp`, which
304
+ // reads VEXP_WORKSPACE from env) by withholding mcpServerPath — no `node`
305
+ // needed, an absolute path that always resolves.
306
+ const mcpSrv = isKiro ? undefined : mcpServerPath;
307
+ if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey)) {
296
308
  mcpConfigs.push(detector.mcpConfigFile);
297
309
  }
298
310
  }
@@ -345,10 +357,47 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
345
357
  const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
346
358
  if (wrote)
347
359
  mcpConfigs.push(wrote);
360
+ // Kilo v7 vendors opencode, so it takes the same guard plugin — the rules
361
+ // markdown alone was demonstrably not enough (a reported session loaded
362
+ // vexp.md, quoted it back, and still read five files by hand).
363
+ const kilo = installKiloPlugin(workspaceRoot);
364
+ if (kilo.plugin) {
365
+ results.push({
366
+ agent: "Kilo Code Guard",
367
+ configFile: path.join(".kilo", "plugins", "vexp-guard.js"),
368
+ content: VEXP_OPENCODE_GUARD,
369
+ alreadyExists: kilo.plugin === "updated",
370
+ action: kilo.plugin,
371
+ });
372
+ }
373
+ if (kilo.registered)
374
+ mcpConfigs.push(path.relative(workspaceRoot, kiloConfigTarget(workspaceRoot)));
375
+ }
376
+ // Cursor: preToolUse hook matching Grep. Text search only - Cursor's native
377
+ // semantic search is not hookable.
378
+ if (detector.agent === "Cursor") {
379
+ const cur = installCursorHook(workspaceRoot);
380
+ if (cur.hook) {
381
+ results.push({
382
+ agent: "Cursor Guard",
383
+ configFile: path.join(".cursor", "hooks", "vexp-guard.js"),
384
+ content: VEXP_CURSOR_GUARD,
385
+ alreadyExists: cur.hook === "updated",
386
+ action: cur.hook,
387
+ });
388
+ }
389
+ if (cur.registered)
390
+ mcpConfigs.push(path.join(".cursor", "hooks.json"));
348
391
  }
349
- // Opencode: install the guard plugin that blocks grep/glob while the daemon
350
- // is up (opencode's analogue of the Claude Code PreToolUse hook).
392
+ // Opencode: MCP lives under the `mcp` key in opencode.json(c) — opencode
393
+ // carries no `mcpConfigFile`, so without this call the generic writer above
394
+ // skips it and setup registers no tool at all. Plus the guard plugin that
395
+ // blocks grep/glob while the daemon is up (opencode's analogue of the
396
+ // Claude Code PreToolUse hook) — which is only safe once MCP is registered.
351
397
  if (detector.agent === "Opencode") {
398
+ const wroteMcp = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
399
+ if (wroteMcp)
400
+ mcpConfigs.push(wroteMcp);
352
401
  const pluginResult = installOpencodePlugin(workspaceRoot);
353
402
  if (pluginResult) {
354
403
  results.push({
@@ -492,8 +541,14 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
492
541
  }
493
542
  if (detector.mcpConfigFile) {
494
543
  const mcpConfigPath = path.join(workspaceRoot, detector.mcpConfigFile);
495
- const alwaysAllow = detector.agent === "Windsurf" ? VEXP_TOOLS : undefined;
496
- if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot))
544
+ const isKiro = detector.agent === "Kiro";
545
+ // See configureAgents(): Kiro auto-approves under `autoApprove` and is
546
+ // pointed at the vexp binary (no `node` on macOS GUI PATH) by withholding
547
+ // mcpServerPath.
548
+ const alwaysAllow = (detector.agent === "Windsurf" || isKiro) ? VEXP_TOOLS : undefined;
549
+ const approveKey = isKiro ? "autoApprove" : "alwaysAllow";
550
+ const mcpSrv = isKiro ? undefined : mcpServerPath;
551
+ if (writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpSrv, workspaceRoot, approveKey))
497
552
  mcpConfigs.push(detector.mcpConfigFile);
498
553
  }
499
554
  if (detector.agent === "Claude Code") {
@@ -523,8 +578,24 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
523
578
  mcpConfigs.push(".zed/settings.json");
524
579
  }
525
580
  if (detector.agent === "Opencode") {
581
+ const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
582
+ if (wrote)
583
+ mcpConfigs.push(wrote);
526
584
  installOpencodePlugin(workspaceRoot);
527
585
  }
586
+ if (detector.agent === "Cursor") {
587
+ installCursorHook(workspaceRoot);
588
+ }
589
+ // Kilo carries no `mcpConfigFile` (its MCP lives under the `mcp` key inside
590
+ // kilo.jsonc), so without this branch the generic writer above skips it and
591
+ // picking Kilo explicitly used to write the rules file and NOTHING else — an
592
+ // agent told "always call run_pipeline" while the tool was never registered.
593
+ if (detector.agent === "Kilo Code") {
594
+ const wrote = configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath);
595
+ if (wrote)
596
+ mcpConfigs.push(wrote);
597
+ installKiloPlugin(workspaceRoot);
598
+ }
528
599
  results.push({ agent: detector.agent, configFile: detector.configFile, content, alreadyExists, action });
529
600
  }
530
601
  return { agents: results, mcpConfigs };
@@ -808,7 +879,10 @@ export function configureAntigravityGlobal(binaryPath, mcpServerPath) {
808
879
  }
809
880
  return writeMcpConfig(path.join(cfgDir, "mcp_config.json"), binaryPath, undefined, mcpServerPath, undefined);
810
881
  }
811
- export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot) {
882
+ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServerPath, workspaceRoot,
883
+ // Key under which the auto-approve tool list is written. Cursor/Windsurf use
884
+ // `alwaysAllow`; Kiro uses `autoApprove` (a `.kiro/settings/mcp.json` field).
885
+ approveKey = "alwaysAllow") {
812
886
  const read = readJsonConfigSafe(mcpConfigPath);
813
887
  if (!read.ok) {
814
888
  warnUnparseable(mcpConfigPath);
@@ -847,7 +921,7 @@ export function writeMcpConfig(mcpConfigPath, binaryPath, alwaysAllow, mcpServer
847
921
  command: targetCmd,
848
922
  args: targetArgs,
849
923
  ...(targetEnv ? { env: targetEnv } : {}),
850
- ...(alwaysAllow && alwaysAllow.length > 0 ? { alwaysAllow } : {}),
924
+ ...(alwaysAllow && alwaysAllow.length > 0 ? { [approveKey]: alwaysAllow } : {}),
851
925
  };
852
926
  existing.mcpServers = servers;
853
927
  fs.mkdirSync(path.dirname(mcpConfigPath), { recursive: true });
@@ -1124,14 +1198,93 @@ export function writeVsCodeMcpConfig(p, binaryPath, mcpServerPath, workspaceRoot
1124
1198
  * an existing file is backed up before rewrite. Returns the workspace-relative
1125
1199
  * path written, or null on a no-op / unparseable file.
1126
1200
  */
1127
- export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
1201
+ /**
1202
+ * The kilo.jsonc that Kilo will actually read: `.kilo/kilo.jsonc` wins over a
1203
+ * root `kilo.jsonc` when both exist, and a fresh setup gets the cleaner `.kilo/`
1204
+ * location. Shared by the MCP writer and the guard-plugin installer so the two
1205
+ * can never disagree about which file is live.
1206
+ */
1207
+ export function kiloConfigTarget(workspaceRoot) {
1128
1208
  const dotKilo = path.join(workspaceRoot, ".kilo", "kilo.jsonc");
1129
1209
  const rootKilo = path.join(workspaceRoot, "kilo.jsonc");
1130
- const target = fs.existsSync(dotKilo)
1210
+ return fs.existsSync(dotKilo)
1131
1211
  ? dotKilo
1132
1212
  : fs.existsSync(rootKilo)
1133
1213
  ? rootKilo
1134
- : dotKilo; // fresh setup → the recommended cleaner location
1214
+ : dotKilo;
1215
+ }
1216
+ /**
1217
+ * The opencode config file that opencode will actually read for `mcp`:
1218
+ * `opencode.jsonc` wins over `opencode.json` when both exist (jsonc is the
1219
+ * variant users keep when they want comments, and it is the one they edit),
1220
+ * and a fresh setup gets plain `opencode.json` — opencode's documented default.
1221
+ * Project-scope only: we never touch the machine-global
1222
+ * `~/.config/opencode/opencode.json`, because a per-workspace VEXP_WORKSPACE pin
1223
+ * in a global file would mis-target every other project on the machine.
1224
+ */
1225
+ export function opencodeConfigTarget(workspaceRoot) {
1226
+ const jsonc = path.join(workspaceRoot, "opencode.jsonc");
1227
+ const json = path.join(workspaceRoot, "opencode.json");
1228
+ return fs.existsSync(jsonc) ? jsonc : json;
1229
+ }
1230
+ /**
1231
+ * Opencode MCP setup. Opencode reads MCP servers from the top-level `mcp` key
1232
+ * of `opencode.json` / `opencode.jsonc` — the same shape Kilo Code uses, because
1233
+ * Kilo v7 vendors opencode wholesale: `{type:"local", command:[…], enabled:true}`.
1234
+ *
1235
+ * Until this existed, picking Opencode in `vexp setup` wrote AGENTS.md and the
1236
+ * guard plugin and NOTHING else — the worst possible end state, because the
1237
+ * guard blocks grep/glob whenever the daemon is healthy while `run_pipeline` was
1238
+ * never registered: the agent lost search and gained no replacement. Reported by
1239
+ * an AppSumo user who had to add the `mcp` block by hand.
1240
+ *
1241
+ * Other `mcp` entries and unrelated top-level keys are preserved; an existing
1242
+ * file is backed up before rewrite. Returns the workspace-relative path written,
1243
+ * or null on a no-op / unparseable file.
1244
+ */
1245
+ export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
1246
+ const target = opencodeConfigTarget(workspaceRoot);
1247
+ const read = readJsonConfigSafe(target);
1248
+ if (!read.ok) {
1249
+ warnUnparseable(target);
1250
+ return null;
1251
+ }
1252
+ const cfg = read.data;
1253
+ const useNode = mcpServerPath && fs.existsSync(mcpServerPath);
1254
+ const command = useNode ? ["node", mcpServerPath] : [binaryPath, "mcp"];
1255
+ const env = { VEXP_WORKSPACE: workspaceRoot };
1256
+ const mcp = cfg.mcp ?? {};
1257
+ const prev = mcp["vexp"];
1258
+ const envMatches = JSON.stringify(prev?.["env"]) === JSON.stringify(env);
1259
+ const identical = prev?.["type"] === "local" &&
1260
+ prev?.["enabled"] === true &&
1261
+ Array.isArray(prev?.["command"]) &&
1262
+ JSON.stringify(prev["command"]) === JSON.stringify(command) &&
1263
+ envMatches;
1264
+ if (identical)
1265
+ return null;
1266
+ // A working entry is already here — either another vexp install, or the user's
1267
+ // own hand-written block (this bug made several people write one). Don't fight
1268
+ // it: only the install path may differ, and `envMatches` still gates on the
1269
+ // VEXP_WORKSPACE pin so a moved project is repinned normally.
1270
+ if (envMatches && prev?.["enabled"] === true && vexpEntryStillResolves(prev))
1271
+ return null;
1272
+ mcp["vexp"] = { type: "local", command, env, enabled: true };
1273
+ cfg.mcp = mcp;
1274
+ // `$schema` first if opencode's own key is absent — a fresh file we author
1275
+ // should look like the one opencode's docs tell users to write.
1276
+ if (cfg.$schema === undefined) {
1277
+ cfg.$schema = "https://opencode.ai/config.json";
1278
+ }
1279
+ if (read.existed)
1280
+ backupConfig(target);
1281
+ // A .jsonc target loses its comments here (we reserialize parsed JSON), which
1282
+ // is why the pre-write backup is not optional for this writer.
1283
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
1284
+ return path.relative(workspaceRoot, target);
1285
+ }
1286
+ export function configureKiloMcp(workspaceRoot, binaryPath, mcpServerPath) {
1287
+ const target = kiloConfigTarget(workspaceRoot);
1135
1288
  const read = readJsonConfigSafe(target);
1136
1289
  if (!read.ok) {
1137
1290
  warnUnparseable(target);
@@ -1403,14 +1556,13 @@ export function installClaudeCodeHook(workspaceRoot) {
1403
1556
  * startup — no config-file merge required). Returns the action taken, or null
1404
1557
  * when the file is already byte-identical.
1405
1558
  */
1406
- export function installOpencodePlugin(workspaceRoot) {
1407
- const pluginDir = path.join(workspaceRoot, ".opencode", "plugins");
1408
- const pluginPath = path.join(pluginDir, "vexp-guard.js");
1409
- fs.mkdirSync(pluginDir, { recursive: true });
1559
+ function writeGuardScript(scriptPath, content) {
1560
+ const pluginPath = scriptPath;
1561
+ fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
1410
1562
  const existed = fs.existsSync(pluginPath);
1411
1563
  if (existed) {
1412
1564
  const current = fs.readFileSync(pluginPath, "utf-8");
1413
- if (current === VEXP_OPENCODE_GUARD)
1565
+ if (current === content)
1414
1566
  return null; // identical - skip
1415
1567
  // Content differs: either an older vexp guard, or a copy the user tuned by
1416
1568
  // hand. We refresh either way — refusing to touch a hand-edited plugin would
@@ -1418,9 +1570,118 @@ export function installOpencodePlugin(workspaceRoot) {
1418
1570
  // recoverable at vexp-guard.js.vexp-bak instead of being silently discarded.
1419
1571
  backupConfig(pluginPath);
1420
1572
  }
1421
- fs.writeFileSync(pluginPath, VEXP_OPENCODE_GUARD, "utf-8");
1573
+ fs.writeFileSync(pluginPath, content, "utf-8");
1422
1574
  return existed ? "updated" : "created";
1423
1575
  }
1576
+ export function installOpencodePlugin(workspaceRoot) {
1577
+ return writeGuardScript(path.join(workspaceRoot, ".opencode", "plugins", "vexp-guard.js"), VEXP_OPENCODE_GUARD);
1578
+ }
1579
+ /**
1580
+ * Cursor enforcement — `preToolUse` hook with matcher `Grep`.
1581
+ *
1582
+ * Cursor is the only VS Code fork where this is possible: Windsurf's hooks are
1583
+ * action-category based (twelve fixed events, no matcher, no tool names) and
1584
+ * expose NO search event at all, so there is nothing there to redirect. Cursor
1585
+ * exposes `Grep` as a matchable tool and feeds `agent_message` back to the model,
1586
+ * which is the whole mechanism.
1587
+ *
1588
+ * Scope of the claim, deliberately narrow: this covers TEXT search. Cursor's
1589
+ * native semantic codebase search has no documented tool identifier, so it is not
1590
+ * hookable and not covered. Do not describe this as "Cursor is enforced".
1591
+ *
1592
+ * `command` is `node`, not a shell script: Cursor documents no PowerShell variant
1593
+ * and never says which shell runs `command` on Windows. A bash guard would repeat
1594
+ * the Claude Code hook's Windows bug (silently enforced nothing for releases).
1595
+ * The path is project-root-relative because Cursor runs PROJECT hooks from the
1596
+ * project root — `./hooks/x` there would resolve to `<project>/hooks/x`.
1597
+ *
1598
+ * `failClosed` is left at its default (false): a crashing guard must let the
1599
+ * agent work, not lock it out.
1600
+ */
1601
+ export function installCursorHook(workspaceRoot) {
1602
+ const rel = path.join(".cursor", "hooks", "vexp-guard.js");
1603
+ const hookPath = path.join(workspaceRoot, rel);
1604
+ const hook = writeGuardScript(hookPath, VEXP_CURSOR_GUARD);
1605
+ const cfgPath = path.join(workspaceRoot, ".cursor", "hooks.json");
1606
+ const command = `node ${rel.split(path.sep).join("/")}`;
1607
+ const read = readJsonConfigSafe(cfgPath);
1608
+ if (!read.ok) {
1609
+ warnUnparseable(cfgPath);
1610
+ return { hook, registered: false };
1611
+ }
1612
+ const cfg = read.data;
1613
+ const hooks = cfg.hooks ?? {};
1614
+ const preToolUse = Array.isArray(hooks.preToolUse) ? [...hooks.preToolUse] : [];
1615
+ // Idempotent by COMMAND, not by array position: re-running setup must not
1616
+ // append a second copy, and a user's own preToolUse hooks must survive.
1617
+ const ours = (e) => !!e && typeof e === "object" && e.command === command;
1618
+ const existing = preToolUse.findIndex(ours);
1619
+ const entry = { command, matcher: "Grep" };
1620
+ if (existing >= 0 && JSON.stringify(preToolUse[existing]) === JSON.stringify(entry)) {
1621
+ return { hook, registered: false }; // already exactly right
1622
+ }
1623
+ if (existing >= 0)
1624
+ preToolUse[existing] = entry;
1625
+ else
1626
+ preToolUse.push(entry);
1627
+ hooks.preToolUse = preToolUse;
1628
+ cfg.hooks = hooks;
1629
+ if (cfg.version === undefined)
1630
+ cfg.version = 1;
1631
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
1632
+ if (read.existed)
1633
+ backupConfig(cfgPath);
1634
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), "utf-8");
1635
+ return { hook, registered: true };
1636
+ }
1637
+ /**
1638
+ * Kilo Code enforcement — the same plugin, because Kilo v7 IS opencode: the
1639
+ * rewrite vendors it wholesale (the loader lives at packages/opencode/src/ inside
1640
+ * Kilo-Org/kilocode), so `tool.execute.before`, the `input.tool` ids and the
1641
+ * grep/glob arg schemas are the same code, not merely a similar API.
1642
+ *
1643
+ * Loading is by auto-discovery: the loader globs `{plugin,plugins}/*.{ts,js}`
1644
+ * with cwd = the directory of the kilo.jsonc it read, and every match is
1645
+ * registered at startup with no config entry. So `.kilo/plugins/vexp-guard.js`
1646
+ * is picked up for free — and we prefer that to an explicit `plugin` array entry
1647
+ * because it leaves the user's config untouched.
1648
+ *
1649
+ * The one case auto-discovery misses: a project whose config is the ROOT
1650
+ * `kilo.jsonc`, where cwd is the repo root and the scanned directory would be
1651
+ * `<root>/plugins/` — we will not create a top-level `plugins/` dir in someone's
1652
+ * repo just to be found. There we keep the file in `.kilo/plugins/` and register
1653
+ * it explicitly, with the path relative to the config file (the loader resolves
1654
+ * path specs against the declaring config's own directory, NOT the project root).
1655
+ *
1656
+ * The glob is non-recursive, so the file must sit flat in that directory.
1657
+ *
1658
+ * NOTE: `KILO_PURE=1` skips all external plugins. A CI run with it set gets no
1659
+ * guard — correctly so, but it means "the guard is installed" is not the same
1660
+ * claim as "the guard ran".
1661
+ */
1662
+ export function installKiloPlugin(workspaceRoot) {
1663
+ const pluginPath = path.join(workspaceRoot, ".kilo", "plugins", "vexp-guard.js");
1664
+ const plugin = writeGuardScript(pluginPath, VEXP_OPENCODE_GUARD);
1665
+ // `.kilo/kilo.jsonc` (the common case) → auto-discovery already covers us.
1666
+ const target = kiloConfigTarget(workspaceRoot);
1667
+ if (path.dirname(target) !== workspaceRoot)
1668
+ return { plugin, registered: false };
1669
+ const rel = "./" + path.relative(workspaceRoot, pluginPath).split(path.sep).join("/");
1670
+ const read = readJsonConfigSafe(target);
1671
+ if (!read.ok) {
1672
+ warnUnparseable(target);
1673
+ return { plugin, registered: false };
1674
+ }
1675
+ const cfg = read.data;
1676
+ const list = Array.isArray(cfg.plugin) ? cfg.plugin : [];
1677
+ if (list.includes(rel))
1678
+ return { plugin, registered: false }; // already declared
1679
+ cfg.plugin = [...list, rel];
1680
+ if (read.existed)
1681
+ backupConfig(target);
1682
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
1683
+ return { plugin, registered: true };
1684
+ }
1424
1685
  /**
1425
1686
  * The single highest-leverage instruction we ship, and for a long time the one
1426
1687
  * most agents never saw.
@@ -1802,34 +2063,65 @@ Intent auto-detection, hybrid ranking, session memory, auto-expanding budget.
1802
2063
  <!-- /vexp -->`;
1803
2064
  }
1804
2065
  function genericTemplate(vars) {
1805
- return `## vexp <!-- vexp v${vars.version} -->
2066
+ return `## vexp - Context-Aware AI Coding <!-- vexp v${vars.version} -->
1806
2067
 
1807
- **MANDATORY: use \`run_pipeline\` - do NOT grep or glob the codebase.**
1808
- vexp returns pre-indexed, graph-ranked context in a single call.
2068
+ ### MANDATORY: call run_pipeline FIRST - do NOT grep, glob, or read to explore
2069
+ For every task - bug fixes, features, refactors, questions about the code:
2070
+ **call \`run_pipeline\` before any other tool**. It runs context search + impact
2071
+ analysis + memory recall in a single call and returns compressed, graph-ranked
2072
+ results.
2073
+
2074
+ This holds even when you already know the file path. The pipeline returns the
2075
+ callers, the blast radius and the co-changed files that opening the file cannot.
2076
+ No tool here will stop you from searching manually, so honouring this rule is on
2077
+ you: "I already knew where to look" is not an exception, it is the exact case the
2078
+ rule exists for.
2079
+
2080
+ Do NOT use grep, glob, built-in codebase search, or shell commands to explore.
2081
+ Do NOT open files one by one to find your way around: use \`get_skeleton\`
2082
+ (detail: minimal/standard/detailed, 70-90% fewer tokens than reading the file).
2083
+ Read raw file content only when you need it to edit a specific line.
2084
+
2085
+ vexp indexes source inside the workspace and nothing else. Runtime logs, build
2086
+ output (dist/, .vite/, node_modules/) and files outside the repo are NOT indexed -
2087
+ read those directly, this rule does not cover them.
2088
+
2089
+ ### Primary tool
2090
+ - \`run_pipeline\` - **USE THIS FOR EVERYTHING**. Auto-detects intent
2091
+ (debug/modify/refactor/explore) from your task. Includes file content for pivots.
2092
+ - \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2093
+ - \`run_pipeline({ "task": "refactor db layer", "preset": "refactor" })\`
2094
+ - \`run_pipeline({ "task": "add auth", "observation": "using JWT" })\` - saves an insight in the same call
2095
+
2096
+ ### Other MCP tools (only when run_pipeline is not enough)
2097
+ - \`get_skeleton\` - **preferred over reading a file**: signatures and structure, 3 detail levels
2098
+ - \`index_status\` - indexing status and health check
2099
+ - \`expand_vexp_ref\` - expand V-REF hash placeholders in v2 compact output
1809
2100
 
1810
- ### Workflow
1811
- 1. \`run_pipeline\` with your task description - ALWAYS FIRST (replaces all other tools)
1812
- 2. Make targeted changes based on the context returned
1813
- 3. \`run_pipeline\` again only if you need more context
2101
+ ${QUERY_SHAPE}
1814
2102
 
1815
- ### Available MCP tools
1816
- - \`run_pipeline\` - **PRIMARY TOOL**. Runs capsule + impact + memory in 1 call.
1817
- Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
1818
- - \`get_skeleton\` - compact file structure
1819
- - \`index_status\` - indexing status
1820
- - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2103
+ ### Workflow
2104
+ 1. \`run_pipeline("your task")\` - ALWAYS FIRST. Returns pivots + impact + memories in 1 call
2105
+ 2. Need more on a file? \`get_skeleton({ files: [...], detail: "detailed" })\` - not a raw read
2106
+ 3. Make targeted changes based on the context returned
2107
+ 4. \`run_pipeline\` again ONLY if you need more context while implementing
2108
+ 5. Do NOT chain vexp calls - one \`run_pipeline\` replaces capsule + impact + memory + observation
1821
2109
 
1822
- ${QUERY_SHAPE}
2110
+ ### Sub-agents and background tasks
2111
+ - Sub-agents CAN and MUST call \`run_pipeline\` - always give them the task description
2112
+ - Do NOT spawn an agent to search freely: call \`run_pipeline\` first, then pass the
2113
+ returned context into the agent prompt
1823
2114
 
1824
- ### Agentic search
1825
- - Do NOT use built-in file search, grep, or codebase indexing - always call \`run_pipeline\` first
1826
- - If you spawn sub-agents or background tasks, pass them the context from \`run_pipeline\`
1827
- rather than letting them search the codebase independently
2115
+ ### Fallback
2116
+ If \`run_pipeline\` returns \`status: "degraded"\` or 0 pivots with an INDEX EMPTY warning,
2117
+ the index is empty or still building. Use the built-in search and read tools directly
2118
+ until it is ready - do not stall waiting for vexp.
1828
2119
 
1829
- ### Smart Features
1830
- Intent auto-detection, hybrid ranking, session memory, auto-expanding budget.
2120
+ ### Smart features (automatic - no action needed)
2121
+ Intent detection, hybrid keyword+semantic+graph ranking, session memory,
2122
+ change coupling, auto-expanding budget.
1831
2123
 
1832
- ### Multi-Repo
2124
+ ### Multi-repo
1833
2125
  \`run_pipeline\` auto-queries all indexed repos. Use \`repos: ["alias"]\` to scope. Run \`index_status\` to see aliases.
1834
2126
  <!-- /vexp -->`;
1835
2127
  }
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ import { runServe } from "./serve.js";
18
18
  import { runDoctor } from "./doctor.js";
19
19
  import { socketPathFor } from "./socket-path.js";
20
20
  import { isTraceEnabled } from "./trace.js";
21
+ import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
21
22
  const program = new Command();
22
23
  program
23
24
  .name("vexp")
@@ -54,8 +55,11 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
54
55
  return;
55
56
  if (AUTOSTART_SKIP.has(actionCmd.name()))
56
57
  return;
57
- const workspaceRoot = findConfiguredWorkspace();
58
- if (!workspaceRoot)
58
+ // Effective workspace: inside a connected secondary repo this resolves to
59
+ // the parent, so we boot (or reuse) the parent daemon instead of spawning
60
+ // a conflicting one for the child.
61
+ const eff = findEffectiveWorkspace();
62
+ if (!eff)
59
63
  return;
60
64
  let binaryPath;
61
65
  try {
@@ -66,7 +70,7 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
66
70
  }
67
71
  // Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
68
72
  // internally idempotent — no duplicate processes across invocations.
69
- await ensureBootstrap(workspaceRoot, binaryPath);
73
+ await ensureBootstrap(eff.root, binaryPath);
70
74
  });
71
75
  // ────────────────────────────────────────────────────
72
76
  // Resolve the platform binary (no network — bundled via npm)
@@ -83,7 +87,7 @@ function ensureBinary() {
83
87
  // Tier limits are now enforced directly by the Rust daemon via license.jwt verification.
84
88
  // Track whether we're in interactive REPL mode (don't call process.exit)
85
89
  let replMode = false;
86
- function runBinary(binaryPath, args) {
90
+ function runBinary(binaryPath, args, options) {
87
91
  if (replMode) {
88
92
  // In REPL mode, run synchronously and don't exit the process.
89
93
  // CRITICAL: stdin must be "ignore" (not "inherit") — inheriting stdin
@@ -94,6 +98,7 @@ function runBinary(binaryPath, args) {
94
98
  // CLI, never stdin, so ignoring it is safe.
95
99
  const result = spawnSync(binaryPath, args, {
96
100
  stdio: ["ignore", "inherit", "inherit"],
101
+ cwd: options?.cwd,
97
102
  env: binaryEnv(binaryPath),
98
103
  });
99
104
  if (result.error) {
@@ -103,6 +108,7 @@ function runBinary(binaryPath, args) {
103
108
  }
104
109
  const child = spawn(binaryPath, args, {
105
110
  stdio: "inherit",
111
+ cwd: options?.cwd,
106
112
  env: binaryEnv(binaryPath),
107
113
  });
108
114
  child.on("exit", (code) => {
@@ -143,12 +149,16 @@ function runBinaryAsync(binaryPath, args, options) {
143
149
  // ────────────────────────────────────────────────────
144
150
  /**
145
151
  * Walk up from cwd looking for `.vexp/manifest.json` — the marker that
146
- * `vexp setup` has been run on this tree. Returns null if none found.
152
+ * `vexp setup` has been run on this tree. A `.vexp/parent_workspace.json`
153
+ * counts too: a freshly-connected secondary repo has the parent link before
154
+ * its first index writes a manifest, and it must already resolve to the
155
+ * parent workspace instead of looking unconfigured. Returns null if none found.
147
156
  */
148
157
  function findConfiguredWorkspace(startDir = process.cwd()) {
149
158
  let current = path.resolve(startDir);
150
159
  while (true) {
151
- if (fs.existsSync(path.join(current, ".vexp", "manifest.json")))
160
+ if (fs.existsSync(path.join(current, ".vexp", "manifest.json")) ||
161
+ fs.existsSync(path.join(current, ".vexp", "parent_workspace.json")))
152
162
  return current;
153
163
  const parent = path.dirname(current);
154
164
  if (parent === current)
@@ -156,6 +166,22 @@ function findConfiguredWorkspace(startDir = process.cwd()) {
156
166
  current = parent;
157
167
  }
158
168
  }
169
+ /**
170
+ * Workspace the CLI should actually operate on. When cwd is inside a
171
+ * secondary repo of a multi-repo workspace (it carries a valid
172
+ * `.vexp/parent_workspace.json`), every daemon interaction targets the PARENT:
173
+ * spawning a second daemon in the child made both fight over the same repo
174
+ * DBs (double watchers, duplicate LLM in VRAM) until the user killed
175
+ * everything. `parent` is set only when redirected, so callers can tell the
176
+ * user what happened.
177
+ */
178
+ function findEffectiveWorkspace(startDir = process.cwd()) {
179
+ const ws = findConfiguredWorkspace(startDir);
180
+ if (!ws)
181
+ return null;
182
+ const parent = resolveParentWorkspace(ws);
183
+ return parent ? { root: parent.parentRoot, parent } : { root: ws };
184
+ }
159
185
  /** True if the Unix socket at `socketPath` accepts a connection within `timeoutMs`. */
160
186
  function isSocketAlive(socketPath, timeoutMs = 300) {
161
187
  return new Promise((resolve) => {
@@ -486,7 +512,13 @@ program
486
512
  const args = ["daemon-cmd", action];
487
513
  if (opts.follow)
488
514
  args.push("--follow");
489
- runBinary(binaryPath, args);
515
+ // Inside a connected secondary repo, daemon commands target the PARENT
516
+ // workspace daemon — the child must never run its own competing daemon.
517
+ const eff = findEffectiveWorkspace();
518
+ if (eff?.parent) {
519
+ console.log(chalk.cyan(`This repo is part of workspace "${eff.parent.workspaceName}" — targeting the parent daemon (${shortPath(eff.root)})`));
520
+ }
521
+ runBinary(binaryPath, args, eff?.parent ? { cwd: eff.root } : undefined);
490
522
  });
491
523
  program
492
524
  .command("doctor")
@@ -997,10 +1029,10 @@ await checkForUpdate();
997
1029
  // ── Sub-menu: Setup & Config ──
998
1030
  const MENU_CONFIG = [
999
1031
  { key: "1", label: "init", hint: "Create .vexp/ and run first index", description: "Creates .vexp/ directory, runs a full index, installs git hooks.\n Run once when adding vexp to a new project." },
1000
- { key: "2", label: "index", hint: "Re-index the repository", description: "Scans all source files, builds the AST graph (nodes + edges),\n and saves it to .vexp/index.db.\n Output: file count, node count, edge count, time elapsed." },
1032
+ { key: "2", label: "index", hint: "Re-index the workspace (all connected repos)", description: "Scans all source files, builds the AST graph (nodes + edges),\n and saves it to .vexp/index.db. In a multi-repo workspace every\n connected repo is re-indexed, not just the current one.\n Output: file count, node count, edge count, time elapsed." },
1001
1033
  { key: "3", label: "setup", hint: "Full setup (index + agents + hooks)", description: "One-command bootstrap: indexes the codebase, detects AI agents,\n writes MCP configs, installs git hooks." },
1002
1034
  { key: "4", label: "setup-agents", hint: "Choose which AI agents to configure", description: "Select agents (Claude, Cursor, Windsurf, Copilot, …) to configure.\n Creates config directories and writes MCP settings for each." },
1003
- { key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
1035
+ { key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/restart/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n restart → stop + start (reloads workspace.json, indexes new repos)\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
1004
1036
  { key: "6", label: "setup-llm", hint: "Configure local LLM compression (vexp-devmind)", description: "Install, update, or disable the optional local LLM (vexp-devmind).\n Runs entirely on your machine (~3.5 GB download).\n Enables smart prompt preprocessing and higher-quality memory compression." },
1005
1037
  ];
1006
1038
  // ── Sub-menu: Explore & Analyze ──
@@ -1015,6 +1047,11 @@ const MENU_SAVINGS = [
1015
1047
  { key: "1", label: "summary", hint: "View savings summary", description: "Shows total tokens saved, average saving %, and breakdown by tool.\n Choose a period: 7 / 14 / 30 / 60 / 90 days." },
1016
1048
  { key: "2", label: "export", hint: "Export report to .vexp/token-savings-report.md", description: "Generates a markdown report with breakdown by tool and daily trend,\n then saves it to .vexp/token-savings-report.md." },
1017
1049
  ];
1050
+ // ── Sub-menu: Repos & Workspace ──
1051
+ const MENU_REPOS = [
1052
+ { key: "1", label: "list", hint: "Show repos connected to this workspace", description: "Lists the repositories of this multi-repo workspace\n (.vexp/workspace.json): alias, path, primary marker." },
1053
+ { key: "2", label: "add", hint: "Connect another repository to this workspace", description: "Adds a repository to .vexp/workspace.json and links it back via\n .vexp/parent_workspace.json — same writes as the VS Code\n \"vexp: Add Repository\" command. Restart the daemon to index it." },
1054
+ ];
1018
1055
  // ── Sub-menu: License ──
1019
1056
  const MENU_LICENSE = [
1020
1057
  { key: "1", label: "status", hint: "Show current license plan and limits", description: "Displays your current plan (free / pro / team), node limits,\n repo limits, and license expiry." },
@@ -1056,12 +1093,18 @@ async function printBanner() {
1056
1093
  // banner says "not running", but the Rust daemon-cmd status probes the
1057
1094
  // socket and reports "running". Using the socket here makes the banner
1058
1095
  // agree with status.
1059
- const ws = findConfiguredWorkspace();
1060
- if (!ws) {
1096
+ const eff = findEffectiveWorkspace();
1097
+ if (!eff) {
1061
1098
  console.log(chalk.dim(" ○ Daemon: no workspace configured here"));
1062
1099
  console.log(chalk.dim(" → type '1 > 3' to run full setup, or `vexp setup`"));
1063
1100
  }
1064
1101
  else {
1102
+ // Connected secondary repo: every daemon interaction targets the parent
1103
+ // workspace — say so up front, since the status below is the PARENT's.
1104
+ if (eff.parent) {
1105
+ console.log(chalk.cyan(` ◆ Workspace: this repo is part of "${eff.parent.workspaceName}" — using the parent daemon (${shortPath(eff.root)})`));
1106
+ }
1107
+ const ws = eff.root;
1065
1108
  const socketAlive = await isSocketAlive(socketPathFor(ws));
1066
1109
  if (socketAlive) {
1067
1110
  // Display PID + uptime when we can read them; otherwise a plain "running".
@@ -1097,7 +1140,7 @@ async function printBanner() {
1097
1140
  }
1098
1141
  else if (installed) {
1099
1142
  console.log(chalk.yellow(" ○ LLM: installed but not active"));
1100
- console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --start`"));
1143
+ console.log(chalk.dim(" → type '1 > 6 > start' to start it, or `vexp setup-llm --enable`"));
1101
1144
  }
1102
1145
  else {
1103
1146
  console.log(chalk.dim(" ○ LLM: not installed"));
@@ -1114,6 +1157,7 @@ function printMainMenu() {
1114
1157
  console.log(` ${chalk.cyan.bold("2")} ${chalk.green("Explore & Analyze".padEnd(20))} ${chalk.dim("Query context, skeletons, impact, flow")}`);
1115
1158
  console.log(` ${chalk.cyan.bold("3")} ${chalk.green("Token Savings".padEnd(20))} ${chalk.dim("View savings stats, export report")}`);
1116
1159
  console.log(` ${chalk.cyan.bold("4")} ${chalk.green("License".padEnd(20))} ${chalk.dim("View or activate your license")}`);
1160
+ console.log(` ${chalk.cyan.bold("5")} ${chalk.green("Repos & Workspace".padEnd(20))} ${chalk.dim("List / connect workspace repositories")}`);
1117
1161
  console.log(` ${chalk.cyan.bold("q")} ${chalk.red("Exit".padEnd(20))} ${chalk.dim("Quit")}`);
1118
1162
  console.log("");
1119
1163
  }
@@ -1136,11 +1180,13 @@ async function interactiveMode() {
1136
1180
  // `vexp setup` still get a working state (daemon + MCP :7821 up, OS
1137
1181
  // autostart installed for the next reboot). All steps are idempotent so
1138
1182
  // running this alongside `vexp setup` never creates duplicates.
1139
- const workspaceRoot = findConfiguredWorkspace();
1140
- if (workspaceRoot) {
1183
+ const eff = findEffectiveWorkspace();
1184
+ if (eff) {
1141
1185
  try {
1142
1186
  const binaryPath = getBinaryPath();
1143
- await ensureBootstrap(workspaceRoot, binaryPath);
1187
+ // Effective root: in a connected secondary repo this boots the PARENT
1188
+ // daemon (which serves this repo too) instead of a conflicting child one.
1189
+ await ensureBootstrap(eff.root, binaryPath);
1144
1190
  }
1145
1191
  catch { /* banner will reflect actual state */ }
1146
1192
  }
@@ -1155,6 +1201,7 @@ async function interactiveMode() {
1155
1201
  explore: chalk.cyan.bold(" vexp/explore> "),
1156
1202
  savings: chalk.cyan.bold(" vexp/savings> "),
1157
1203
  license: chalk.cyan.bold(" vexp/license> "),
1204
+ repos: chalk.cyan.bold(" vexp/repos> "),
1158
1205
  };
1159
1206
  while (true) {
1160
1207
  const input = (await ask(rl, prompts[currentMenu])).trim().toLowerCase();
@@ -1190,8 +1237,13 @@ async function interactiveMode() {
1190
1237
  printSubMenu("License", MENU_LICENSE);
1191
1238
  continue;
1192
1239
  }
1240
+ if (input === "5" || input === "repos") {
1241
+ currentMenu = "repos";
1242
+ printSubMenu("Repos & Workspace", MENU_REPOS);
1243
+ continue;
1244
+ }
1193
1245
  // Allow direct command names from any sub-menu
1194
- const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE];
1246
+ const allItems = [...MENU_CONFIG, ...MENU_EXPLORE, ...MENU_SAVINGS, ...MENU_LICENSE, ...MENU_REPOS];
1195
1247
  const direct = allItems.find((m) => m.label === input);
1196
1248
  if (direct) {
1197
1249
  await executeCommand(direct.label, rl);
@@ -1199,12 +1251,12 @@ async function interactiveMode() {
1199
1251
  printMainMenu();
1200
1252
  continue;
1201
1253
  }
1202
- console.log(chalk.dim(" Choose 1-4 or type a command name. 'q' to exit.\n"));
1254
+ console.log(chalk.dim(" Choose 1-5 or type a command name. 'q' to exit.\n"));
1203
1255
  continue;
1204
1256
  }
1205
1257
  // ── Sub-menus ──
1206
- const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE };
1207
- const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License" };
1258
+ const menuMap = { config: MENU_CONFIG, explore: MENU_EXPLORE, savings: MENU_SAVINGS, license: MENU_LICENSE, repos: MENU_REPOS };
1259
+ const titles = { config: "Setup & Config", explore: "Explore & Analyze", savings: "Token Savings", license: "License", repos: "Repos & Workspace" };
1208
1260
  if (input === "b" || input === "back") {
1209
1261
  currentMenu = "main";
1210
1262
  printMainMenu();
@@ -1241,7 +1293,27 @@ async function executeCommand(label, rl) {
1241
1293
  }
1242
1294
  case "index": {
1243
1295
  const binaryPath = ensureBinary();
1244
- runBinary(binaryPath, ["index"]);
1296
+ // Workspace-aware: re-index every connected repo, not just the cwd
1297
+ // one. From a connected child this targets the parent workspace, so
1298
+ // the result matches what the daemon serves.
1299
+ const eff = findEffectiveWorkspace();
1300
+ const repos = eff ? listWorkspaceRepos(eff.root) : [];
1301
+ if (!eff || repos.length === 0) {
1302
+ runBinary(binaryPath, ["index"]);
1303
+ break;
1304
+ }
1305
+ if (eff.parent) {
1306
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — indexing all its repos`));
1307
+ }
1308
+ console.log(chalk.dim(` Indexing ${repos.length} workspace repo(s)…\n`));
1309
+ for (const r of repos) {
1310
+ if (!r.exists) {
1311
+ console.log(chalk.yellow(` ⚠ Skipping "${r.alias}" — directory not found: ${shortPath(r.resolvedPath)}`));
1312
+ continue;
1313
+ }
1314
+ console.log(chalk.bold(` ● ${r.alias}`));
1315
+ runBinary(binaryPath, ["index", r.resolvedPath]);
1316
+ }
1245
1317
  break;
1246
1318
  }
1247
1319
  case "setup": {
@@ -1255,15 +1327,23 @@ async function executeCommand(label, rl) {
1255
1327
  case "daemon-cmd": {
1256
1328
  // Sticky sub-sub-menu: stay here until user types 'b' (back).
1257
1329
  const binaryPath = ensureBinary();
1258
- const valid = new Set(["start", "stop", "status", "logs"]);
1330
+ const valid = new Set(["start", "stop", "restart", "status", "logs"]);
1331
+ // Inside a connected secondary repo every action targets the parent
1332
+ // workspace daemon — running one here would fight over the same DBs.
1333
+ const eff = findEffectiveWorkspace();
1334
+ if (eff?.parent) {
1335
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — daemon commands target the parent (${shortPath(eff.root)})`));
1336
+ }
1337
+ const daemonCwd = eff?.parent ? { cwd: eff.root } : undefined;
1259
1338
  const printDaemonMenu = () => {
1260
1339
  console.log("");
1261
1340
  console.log(chalk.cyan(" ── Daemon ──"));
1262
- console.log(` ${chalk.bold("start")} ${chalk.dim("Launch the daemon for this workspace")}`);
1263
- console.log(` ${chalk.bold("stop")} ${chalk.dim("Stop the running daemon")}`);
1264
- console.log(` ${chalk.bold("status")} ${chalk.dim("Show node/edge/file counts and uptime")}`);
1265
- console.log(` ${chalk.bold("logs")} ${chalk.dim("Tail .vexp/vexp.log")}`);
1266
- console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1341
+ console.log(` ${chalk.bold("start")} ${chalk.dim("Launch the daemon for this workspace")}`);
1342
+ console.log(` ${chalk.bold("stop")} ${chalk.dim("Stop the running daemon")}`);
1343
+ console.log(` ${chalk.bold("restart")} ${chalk.dim("Stop + start (reloads workspace.json, indexes new repos)")}`);
1344
+ console.log(` ${chalk.bold("status")} ${chalk.dim("Show node/edge/file counts and uptime")}`);
1345
+ console.log(` ${chalk.bold("logs")} ${chalk.dim("Tail .vexp/vexp.log")}`);
1346
+ console.log(` ${chalk.dim("b")} ${chalk.dim("Back to Setup & Config")}`);
1267
1347
  console.log("");
1268
1348
  };
1269
1349
  printDaemonMenu();
@@ -1274,11 +1354,11 @@ async function executeCommand(label, rl) {
1274
1354
  if (action === "b" || action === "back")
1275
1355
  break;
1276
1356
  if (valid.has(action)) {
1277
- runBinary(binaryPath, ["daemon-cmd", action]);
1357
+ runBinary(binaryPath, ["daemon-cmd", action], daemonCwd);
1278
1358
  printDaemonMenu();
1279
1359
  continue;
1280
1360
  }
1281
- console.log(chalk.dim(" Unknown action. Use start, stop, status, logs, or 'b' to go back."));
1361
+ console.log(chalk.dim(" Unknown action. Use start, stop, restart, status, logs, or 'b' to go back."));
1282
1362
  }
1283
1363
  break;
1284
1364
  }
@@ -1399,6 +1479,79 @@ async function executeCommand(label, rl) {
1399
1479
  }
1400
1480
  break;
1401
1481
  }
1482
+ // ── Repos & Workspace commands ──
1483
+ case "list": {
1484
+ const eff = findEffectiveWorkspace();
1485
+ if (!eff) {
1486
+ console.log(chalk.yellow(" No configured workspace here — run setup first (1 > 3, or `vexp setup`)."));
1487
+ break;
1488
+ }
1489
+ if (eff.parent) {
1490
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — showing the repos of ${shortPath(eff.root)}`));
1491
+ }
1492
+ const repos = listWorkspaceRepos(eff.root);
1493
+ if (repos.length === 0) {
1494
+ console.log(chalk.dim(" Single-repo workspace (no .vexp/workspace.json yet)."));
1495
+ console.log(chalk.dim(" Use 'add' to connect another repository."));
1496
+ break;
1497
+ }
1498
+ console.log("");
1499
+ for (const r of repos) {
1500
+ const marker = r.isPrimary ? chalk.cyan(" (primary)") : "";
1501
+ const missing = r.exists ? "" : chalk.red(" [missing]");
1502
+ console.log(` ${chalk.green("●")} ${chalk.bold(r.alias.padEnd(20))} ${chalk.dim(shortPath(r.resolvedPath))}${marker}${missing}`);
1503
+ }
1504
+ console.log(chalk.dim(`\n ${repos.length} repo(s) in workspace "${path.basename(eff.root)}"`));
1505
+ break;
1506
+ }
1507
+ case "add": {
1508
+ const eff = findEffectiveWorkspace();
1509
+ if (!eff) {
1510
+ console.log(chalk.yellow(" No configured workspace here — run setup first (1 > 3, or `vexp setup`)."));
1511
+ break;
1512
+ }
1513
+ if (eff.parent) {
1514
+ console.log(chalk.cyan(` This repo is part of workspace "${eff.parent.workspaceName}" — the repository will be added to ${shortPath(eff.root)}`));
1515
+ }
1516
+ const repoPath = (await ask(rl, chalk.cyan(" Path of the repository to connect: "))).trim();
1517
+ if (!repoPath)
1518
+ break;
1519
+ const limits = readLicenseLimits();
1520
+ const result = addRepoToWorkspace(eff.root, repoPath, limits.maxRepos);
1521
+ if (result.status === "invalid") {
1522
+ console.log(chalk.red(` ✗ ${result.reason}`));
1523
+ break;
1524
+ }
1525
+ if (result.status === "limit") {
1526
+ console.log(chalk.yellow(` Your ${limits.plan} plan supports up to ${result.maxRepos} repo${result.maxRepos === 1 ? "" : "s"}.`));
1527
+ console.log(chalk.dim(" Upgrade at https://vexp.dev/#pricing to add more repositories."));
1528
+ break;
1529
+ }
1530
+ if (result.status === "exists") {
1531
+ console.log(chalk.dim(` "${result.alias}" is already part of this workspace.`));
1532
+ break;
1533
+ }
1534
+ console.log(chalk.green(` ✓ Added "${result.alias}" to workspace "${path.basename(eff.root)}"`));
1535
+ // A live 2.2.3+ daemon hot-reloads workspace.json (2s poll): the new
1536
+ // repo is indexed and watched automatically, no restart needed. Only
1537
+ // when the daemon is down do we offer to start it.
1538
+ if (await isSocketAlive(socketPathFor(eff.root))) {
1539
+ console.log(chalk.dim(` The running daemon will detect the change and index "${result.alias}" automatically within seconds.`));
1540
+ }
1541
+ else {
1542
+ const yn = (await ask(rl, chalk.cyan(` Start the daemon now to index "${result.alias}"? [Y/n]: `))).trim().toLowerCase();
1543
+ if (yn === "" || yn === "y" || yn === "yes") {
1544
+ const binaryPath = ensureBinary();
1545
+ console.log(chalk.dim(" Starting vexp daemon…\n"));
1546
+ runBinary(binaryPath, ["daemon-cmd", "start"], { cwd: eff.root });
1547
+ console.log(chalk.green(` ✓ Now indexing "${result.alias}".`));
1548
+ }
1549
+ else {
1550
+ console.log(chalk.dim(` Run 'vexp daemon-cmd start' in ${shortPath(eff.root)} later to index it.`));
1551
+ }
1552
+ }
1553
+ break;
1554
+ }
1402
1555
  // ── License commands ──
1403
1556
  case "status": {
1404
1557
  const limits = readLicenseLimits();
@@ -58,6 +58,12 @@ esac
58
58
  * Error aborts the tool call. Written to `.opencode/plugins/vexp-guard.js` by
59
59
  * `vexp setup-agents` (opencode auto-loads that directory at startup).
60
60
  *
61
+ * The SAME file is installed for Kilo Code at `.kilo/plugins/vexp-guard.js`
62
+ * (installKiloPlugin), because Kilo v7 is opencode vendored — its plugin loader
63
+ * lives at packages/opencode/src/config/plugin.ts inside Kilo-Org/kilocode, so
64
+ * the hook name, the `input.tool` ids and the grep/glob arg schemas are the same
65
+ * code and not merely a compatible API.
66
+ *
61
67
  * It blocks the native `grep`/`glob` tools AND shelled-out tree search
62
68
  * (grep/rg/find/... via bash) WHILE the vexp daemon is healthy, and fails OPEN
63
69
  * when the daemon is down so search still works without an index — mirroring
@@ -66,11 +72,14 @@ esac
66
72
  * The literal body is deliberately ASCII-only and free of backticks, `${...}`
67
73
  * and backslash escapes so it can be embedded verbatim in a TS template literal
68
74
  * (the VS Code extension keeps a byte-identical copy, enforced by the lockstep
69
- * test in packages/vexp-cli/test/hook-template.test.ts). Do NOT introduce any
75
+ * test in packages/vexp-cli/test/guard-lockstep.test.ts — which is where that
76
+ * guarantee actually lives; this comment used to point at hook-template.test.ts,
77
+ * which only ever compared the CLI constant with itself). Do NOT introduce any
70
78
  * of those characters here without updating that test's un-escape logic.
71
79
  */
72
- export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode plugin (generated by 'vexp setup-agents'; it is rewritten
73
- // on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak first).
80
+ export const VEXP_OPENCODE_GUARD = `// vexp-guard - opencode / Kilo Code plugin (generated by 'vexp setup-agents'; it is
81
+ // rewritten on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak
82
+ // first). Kilo v7 vendors opencode, so one plugin serves both unchanged.
74
83
  //
75
84
  // Blocks opencode's native grep/glob ONLY where vexp has a better answer: indexed
76
85
  // source inside the workspace, while the daemon is healthy. Two deliberate limits:
@@ -192,3 +201,176 @@ export const VexpGuard = async ({ directory, worktree }) => {
192
201
  };
193
202
  };
194
203
  `;
204
+ /**
205
+ * Cursor `preToolUse` hook. Same policy as the Claude Code hook and the
206
+ * opencode/Kilo plugin — block text search while the vexp daemon is healthy,
207
+ * never touch the shell, never block a target vexp did not index — expressed in
208
+ * the third dialect we now have to speak.
209
+ *
210
+ * Written in NODE, not bash, and that is deliberate. The Claude Code guard is a
211
+ * bash script and it silently enforced NOTHING on Windows for several releases
212
+ * (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
213
+ * expensive bug this file has produced. Cursor documents no PowerShell variant
214
+ * of `command` and does not say which shell runs it on Windows, so a bash guard
215
+ * here would be that bug again by construction. Anyone who installed vexp has
216
+ * node on PATH; a node script is the one interpreter we can count on everywhere.
217
+ *
218
+ * Denial is emitted on BOTH documented channels — the stdout verdict object and
219
+ * exit code 2 — because Cursor's docs describe both and we cannot test which one
220
+ * this build actually honours. Belt and braces on a schema we are trusting on
221
+ * documentation alone.
222
+ *
223
+ * Fails OPEN in every uncertain case: daemon down, unparseable input, an unknown
224
+ * payload shape. An over-eager guard strands the agent with no way to look at
225
+ * anything; an under-eager one costs tokens. Those are not symmetric.
226
+ *
227
+ * ASCII-only, no backticks and no ${...}: it is embedded verbatim in a TS
228
+ * template literal in two packages (lockstep test in test/guard-lockstep.test.ts).
229
+ */
230
+ export const VEXP_CURSOR_GUARD = `#!/usr/bin/env node
231
+ // vexp-guard - Cursor preToolUse hook (generated by 'vexp setup-agents'; rewritten
232
+ // on every setup, and a modified copy is saved to vexp-guard.js.vexp-bak first).
233
+ //
234
+ // Blocks Cursor's Grep tool ONLY where vexp has a better answer: indexed source
235
+ // inside the workspace, while the daemon is healthy. Never blocks Shell - that
236
+ // valve is what keeps the agent able to reach logs, build output and anything
237
+ // outside the repo, and blocking it teaches evasion instead of redirection.
238
+ //
239
+ // NOTE: Cursor's native semantic codebase search is NOT exposed to hooks, so it
240
+ // cannot be redirected. This guard covers text search only.
241
+ const fs = require("node:fs");
242
+ const path = require("node:path");
243
+
244
+ function findVexpDir(start) {
245
+ let dir = start;
246
+ for (;;) {
247
+ if (fs.existsSync(path.join(dir, ".vexp"))) return path.join(dir, ".vexp");
248
+ const parent = path.dirname(dir);
249
+ if (parent === dir) return null;
250
+ dir = parent;
251
+ }
252
+ }
253
+
254
+ function pidAlive(pidFile) {
255
+ let pid = 0;
256
+ try { pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); } catch (e) { return false; }
257
+ if (!pid) return false;
258
+ try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === "EPERM"); }
259
+ }
260
+
261
+ // The daemon writes .vexp/healthy when up and removes it on graceful shutdown.
262
+ // Unix also requires a live socket AND a live pid: healthy + socket both linger
263
+ // after a kill -9, so the pid check is what makes fail-open actually work.
264
+ // Windows has no socket file, so the daemon drops .vexp/daemon.pipe instead.
265
+ function daemonHealthy(vexpDir) {
266
+ if (!vexpDir || !fs.existsSync(path.join(vexpDir, "healthy"))) return false;
267
+ if (process.platform === "win32") return fs.existsSync(path.join(vexpDir, "daemon.pipe"));
268
+ return fs.existsSync(path.join(vexpDir, "daemon.sock")) && pidAlive(path.join(vexpDir, "daemon.pid"));
269
+ }
270
+
271
+ const NON_INDEXED_DIRS = [
272
+ "node_modules", "dist", "build", "out", "coverage", "target", "vendor",
273
+ ".vite", ".next", ".nuxt", ".output", ".turbo", ".cache", ".git", ".vexp"
274
+ ];
275
+ const NON_INDEXED_HINTS = [".log", ".lock", "-lock.", ".map", ".min.js"];
276
+
277
+ // Is this search target inside what vexp indexed? Only then is "call
278
+ // run_pipeline instead" real advice rather than a dead end. Anything that cannot
279
+ // be resolved confidently returns false (allow).
280
+ function isIndexedTarget(root, target) {
281
+ if (!target) return true; // no path given: a whole-workspace search is vexp's job
282
+ const s = String(target);
283
+ const low = s.toLowerCase();
284
+ for (const h of NON_INDEXED_HINTS) if (low.indexOf(h) !== -1) return false;
285
+ let rel;
286
+ try {
287
+ rel = path.relative(root, path.isAbsolute(s) ? s : path.join(root, s));
288
+ } catch (e) {
289
+ return false;
290
+ }
291
+ if (rel === "..") return false;
292
+ if (rel.indexOf(".." + path.sep) === 0) return false; // escapes the workspace
293
+ if (path.isAbsolute(rel)) return false; // different root or drive
294
+ const segs = rel.split(path.sep);
295
+ for (const g of segs) if (NON_INDEXED_DIRS.indexOf(g) !== -1) return false;
296
+ return true;
297
+ }
298
+
299
+ function allow() {
300
+ process.stdout.write(JSON.stringify({ permission: "allow" }));
301
+ process.exit(0);
302
+ }
303
+
304
+ function deny(toolName) {
305
+ const reason =
306
+ "vexp daemon is running. AGENT INSTRUCTION: call run_pipeline instead of " +
307
+ toolName + " - it returns pre-indexed, graph-ranked context in one call." +
308
+ " This is policy, not a transient failure: do NOT work around it with shell" +
309
+ " search or by writing a script.";
310
+ // Both documented channels: the verdict object AND exit 2.
311
+ process.stdout.write(JSON.stringify({
312
+ permission: "deny",
313
+ agent_message: reason,
314
+ user_message: "vexp blocked a text search; the agent was told to use run_pipeline."
315
+ }));
316
+ process.stderr.write(reason);
317
+ process.exit(2);
318
+ }
319
+
320
+ // Cursor documents tool_input for Shell only; the payload shape for a Grep call
321
+ // appears NOWHERE in its docs. Rather than guess key names - a guess that fires,
322
+ // reads undefined and then blocks everything or nothing - we do not read keys at
323
+ // all. We scan the VALUES of tool_input for anything path-shaped. That is immune
324
+ // to whatever the keys turn out to be called, and to them being renamed later.
325
+ //
326
+ // The search pattern itself is one of those values and may look like a path. It
327
+ // can only ever push us toward ALLOW (a pattern mentioning node_modules reads as
328
+ // a non-indexed target), never toward a wrongful block - which is the direction
329
+ // we want to be wrong in.
330
+ function candidateTargets(toolInput) {
331
+ if (!toolInput) return [];
332
+ // MCP hooks receive tool_input as a JSON *string*, so the type is not uniform.
333
+ let obj = toolInput;
334
+ if (typeof obj === "string") {
335
+ try { obj = JSON.parse(obj); } catch (e) { return []; }
336
+ }
337
+ if (typeof obj !== "object") return [];
338
+ // EVERY string value, with no "does this look like a path" pre-filter. An
339
+ // earlier version required a separator or a dot and therefore missed bare
340
+ // directory names - "dist", "build", "node_modules" - which are exactly the
341
+ // targets the carve-out exists to protect. Handing the search pattern itself
342
+ // to isIndexedTarget is harmless: a pattern that reads as non-indexed only
343
+ // ever produces an ALLOW, and a pattern that reads as indexed changes nothing.
344
+ const out = [];
345
+ for (const v of Object.values(obj)) {
346
+ if (typeof v === "string" && v) out.push(v);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ function main(input) {
352
+ const toolName = input && input.tool_name;
353
+ // Grep only. Shell is never blocked - it is the valve for everything vexp
354
+ // cannot index - and Read is never blocked because an agent must read to edit.
355
+ if (toolName !== "Grep") return allow();
356
+
357
+ const roots = (input && input.workspace_roots) || [];
358
+ const start = roots[0] || (input && input.cwd) || process.cwd();
359
+ const vexpDir = findVexpDir(start);
360
+ if (!daemonHealthy(vexpDir)) return allow(); // no live index -> allow
361
+ const root = path.dirname(vexpDir);
362
+
363
+ for (const t of candidateTargets(input.tool_input)) {
364
+ if (!isIndexedTarget(root, t)) return allow(); // vexp has no answer here
365
+ }
366
+ deny(toolName);
367
+ }
368
+
369
+ let raw = "";
370
+ process.stdin.on("data", (c) => { raw += c; });
371
+ process.stdin.on("end", () => {
372
+ let input;
373
+ try { input = JSON.parse(raw); } catch (e) { return allow(); }
374
+ try { main(input); } catch (e) { allow(); } // any surprise -> fail open
375
+ });
376
+ `;
@@ -0,0 +1,166 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ /** Expand a leading `~` — readline input arrives unexpanded, unlike a shell arg. */
5
+ export function expandHome(p) {
6
+ if (p === "~")
7
+ return os.homedir();
8
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
9
+ return path.join(os.homedir(), p.slice(2));
10
+ }
11
+ return p;
12
+ }
13
+ /**
14
+ * Resolve a `repos[].path` entry like vexp-core's `resolve_repo_path`:
15
+ * absolute paths are preserved, relative paths resolve against the workspace
16
+ * ROOT. Configs written before 2.2.1 stored paths relative to `.vexp/`, so
17
+ * when the root-relative answer is not a directory we fall back to the legacy
18
+ * base — same order as the Rust resolver, so list/add agree with the daemon.
19
+ */
20
+ export function resolveRepoEntryPath(workspaceRoot, raw) {
21
+ if (path.isAbsolute(raw))
22
+ return path.normalize(raw);
23
+ const fromRoot = path.resolve(workspaceRoot, raw);
24
+ if (fs.existsSync(fromRoot))
25
+ return fromRoot;
26
+ const fromLegacy = path.resolve(workspaceRoot, ".vexp", raw);
27
+ if (fs.existsSync(fromLegacy))
28
+ return fromLegacy;
29
+ return fromRoot;
30
+ }
31
+ function readWorkspaceConfig(configPath) {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
34
+ if (!parsed || !Array.isArray(parsed.repos))
35
+ return null;
36
+ return parsed;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /**
43
+ * If `childRoot` is a secondary repo of a multi-repo workspace, return the
44
+ * parent workspace info. The link is `.vexp/parent_workspace.json` (written on
45
+ * add, both by the VS Code plugin and by `addRepoToWorkspace` below).
46
+ *
47
+ * Returns null — i.e. "treat this repo as standalone" — unless the parent
48
+ * config still exists AND still lists this repo: a stale parent_workspace.json
49
+ * left behind after the repo was disconnected must not hijack the CLI.
50
+ */
51
+ export function resolveParentWorkspace(childRoot) {
52
+ let configPath;
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(path.join(childRoot, ".vexp", "parent_workspace.json"), "utf-8"));
55
+ if (typeof raw?.workspace_config !== "string")
56
+ return null;
57
+ configPath = raw.workspace_config;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ if (!fs.existsSync(configPath))
63
+ return null;
64
+ // workspace.json lives in <parentRoot>/.vexp/
65
+ const parentRoot = path.dirname(path.dirname(configPath));
66
+ if (path.resolve(parentRoot) === path.resolve(childRoot))
67
+ return null;
68
+ const config = readWorkspaceConfig(configPath);
69
+ if (!config)
70
+ return null;
71
+ const childResolved = path.resolve(childRoot);
72
+ const listed = config.repos.some((r) => {
73
+ if (typeof r?.path !== "string")
74
+ return false;
75
+ return path.resolve(resolveRepoEntryPath(parentRoot, r.path)) === childResolved;
76
+ });
77
+ if (!listed)
78
+ return null;
79
+ return {
80
+ parentRoot,
81
+ workspaceName: config.name ?? path.basename(parentRoot),
82
+ configPath,
83
+ };
84
+ }
85
+ /**
86
+ * Repos connected to the workspace at `workspaceRoot`. Empty array when there
87
+ * is no workspace.json (single-repo workspace).
88
+ */
89
+ export function listWorkspaceRepos(workspaceRoot) {
90
+ const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
91
+ const config = readWorkspaceConfig(configPath);
92
+ if (!config)
93
+ return [];
94
+ const rootResolved = path.resolve(workspaceRoot);
95
+ return config.repos
96
+ .filter((r) => typeof r?.path === "string" && typeof r?.alias === "string")
97
+ .map((r) => {
98
+ const resolvedPath = resolveRepoEntryPath(workspaceRoot, r.path);
99
+ return {
100
+ alias: r.alias,
101
+ rawPath: r.path,
102
+ resolvedPath,
103
+ isPrimary: path.resolve(resolvedPath) === rootResolved,
104
+ exists: fs.existsSync(resolvedPath),
105
+ };
106
+ });
107
+ }
108
+ /**
109
+ * Connect `newRepoPath` to the workspace at `workspaceRoot` — the exact write
110
+ * sequence of the VS Code `vexp.setupMultiRepo` command:
111
+ * 1. plan-limit guard BEFORE writing anything (maxRepos, 0 = unlimited)
112
+ * 2. read-or-create workspace.json (primary entry is `"."` — the 2.2.1+
113
+ * root-relative convention)
114
+ * 3. append `{ alias, path: <absolute> }` unless already present
115
+ * 4. write `.vexp/parent_workspace.json` in the child so opening the child
116
+ * resolves back to this workspace
117
+ *
118
+ * Indexing is NOT triggered here — the caller decides whether to restart the
119
+ * daemon (mirrors the plugin's "Restart / Later" prompt).
120
+ */
121
+ export function addRepoToWorkspace(workspaceRoot, newRepoPath, maxRepos) {
122
+ const resolved = path.resolve(expandHome(newRepoPath.trim()));
123
+ let stat;
124
+ try {
125
+ stat = fs.statSync(resolved);
126
+ }
127
+ catch {
128
+ return { status: "invalid", reason: `Directory not found: ${resolved}` };
129
+ }
130
+ if (!stat.isDirectory()) {
131
+ return { status: "invalid", reason: `Not a directory: ${resolved}` };
132
+ }
133
+ if (path.resolve(workspaceRoot) === resolved) {
134
+ return { status: "invalid", reason: "That is the workspace root itself — it is already the primary repo." };
135
+ }
136
+ const configPath = path.join(workspaceRoot, ".vexp", "workspace.json");
137
+ const alias = path.basename(resolved);
138
+ // Plan-limit guard: same semantics as the plugin — count existing entries,
139
+ // block only genuinely-new repos, never after a partial write.
140
+ let config = readWorkspaceConfig(configPath);
141
+ const currentCount = config ? config.repos.length : 1; // primary always exists
142
+ const alreadyExists = config?.repos.some((r) => r.alias === alias ||
143
+ r.path === resolved ||
144
+ path.resolve(resolveRepoEntryPath(workspaceRoot, r.path)) === resolved) ?? false;
145
+ if (alreadyExists) {
146
+ return { status: "exists", alias };
147
+ }
148
+ if (maxRepos > 0 && currentCount >= maxRepos) {
149
+ return { status: "limit", maxRepos, current: currentCount };
150
+ }
151
+ if (!config) {
152
+ config = {
153
+ name: path.basename(workspaceRoot),
154
+ repos: [{ alias: path.basename(workspaceRoot), path: "." }],
155
+ };
156
+ }
157
+ config.repos.push({ alias, path: resolved });
158
+ fs.mkdirSync(path.join(workspaceRoot, ".vexp"), { recursive: true });
159
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
160
+ // Bidirectional link in the child, so `vexp` run there targets this
161
+ // workspace's daemon instead of spawning a conflicting one.
162
+ const childVexpDir = path.join(resolved, ".vexp");
163
+ fs.mkdirSync(childVexpDir, { recursive: true });
164
+ fs.writeFileSync(path.join(childVexpDir, "parent_workspace.json"), JSON.stringify({ workspace_config: configPath }, null, 2));
165
+ return { status: "added", alias, configPath };
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-cli",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
4
4
  "description": "Vexp — Context Engine for AI Coding Agents. Pre-indexes your codebase into a dependency graph and delivers ranked context to any MCP-compatible agent. 58% lower cost per task, 90% fewer tool calls (SWE-bench Verified). Works with Claude Code, Cursor, Copilot, Windsurf, Codex, Cline, Aider, and 12+ agents. Local-first. Your code never leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,10 +100,10 @@
100
100
  "node": ">=20.0.0"
101
101
  },
102
102
  "optionalDependencies": {
103
- "@vexp/core-linux-x64": "2.2.2",
104
- "@vexp/core-linux-arm64": "2.2.2",
105
- "@vexp/core-darwin-x64": "2.2.2",
106
- "@vexp/core-darwin-arm64": "2.2.2",
107
- "@vexp/core-win32-x64": "2.2.2"
103
+ "@vexp/core-linux-x64": "2.2.4",
104
+ "@vexp/core-linux-arm64": "2.2.4",
105
+ "@vexp/core-darwin-x64": "2.2.4",
106
+ "@vexp/core-darwin-arm64": "2.2.4",
107
+ "@vexp/core-win32-x64": "2.2.4"
108
108
  }
109
109
  }