vexp-cli 2.5.3 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpStopGateHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
13
+ import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Constants
16
16
  // ---------------------------------------------------------------------------
@@ -133,6 +133,17 @@ const AGENT_DETECTORS = [
133
133
  configFile: ".continue/config.json",
134
134
  templateName: "continue",
135
135
  },
136
+ {
137
+ // Cline keeps rules in the project (.clinerules) but its MCP registry is
138
+ // machine-global VS Code storage — handled by configureClineMcp() below.
139
+ // Detected either by an existing .clinerules or by the extension's
140
+ // globalStorage directory (Cline installed on this machine).
141
+ agent: "Cline",
142
+ detectPath: ".clinerules",
143
+ configFile: ".clinerules",
144
+ templateName: "cline",
145
+ detectAbsPaths: clineStorageDirs,
146
+ },
136
147
  {
137
148
  agent: "Augment",
138
149
  detectPath: ".augment",
@@ -221,6 +232,72 @@ const AGENT_DETECTORS = [
221
232
  // ---------------------------------------------------------------------------
222
233
  // Public API
223
234
  // ---------------------------------------------------------------------------
235
+ /**
236
+ * Everything a real (non dry-run) setup writes for one agent, as
237
+ * human-readable lines.
238
+ *
239
+ * This exists because `--dry-run` used to print only `configFile` and
240
+ * `mcpConfigFile` from the detector metadata, so hooks, plugins and the
241
+ * user-scope MCP entry were invisible. A user comparing that output
242
+ * against his `.claude/settings.json` concluded the hint hook was never
243
+ * installed by design (field report, 2026-08) — the dry run was telling a
244
+ * true but badly incomplete story. Kept next to the installers, with a
245
+ * lockstep test asserting every detector has an entry.
246
+ */
247
+ export function plannedWrites(agent, guard = guardMode()) {
248
+ const det = AGENT_DETECTORS.find((d) => d.agent === agent);
249
+ if (!det)
250
+ return [];
251
+ const out = [`${det.configFile} (instructions)`];
252
+ if (det.mcpConfigFile)
253
+ out.push(`${det.mcpConfigFile} (MCP server entry)`);
254
+ switch (agent) {
255
+ case "Claude Code":
256
+ out.push("~/.claude.json (MCP server entry, user scope)");
257
+ out.push(".claude/hooks/vexp-hint.sh + settings.json hooks.UserPromptSubmit (orientation)");
258
+ out.push(".claude/hooks/vexp-verify.sh + settings.json hooks.Stop (verification gate)");
259
+ out.push(".claude/hooks/vexp-restore.sh + settings.json hooks.SessionStart (context restore)");
260
+ if (guard === "strict") {
261
+ out.push(".claude/hooks/vexp-guard.sh + settings.json hooks.PreToolUse (guard, opt-in)");
262
+ }
263
+ break;
264
+ case "Codex":
265
+ out.push("~/.codex/config.toml or .codex/config.toml (MCP server entry)");
266
+ out.push(".codex/vexp-hint.sh + .codex/hooks.json hooks.UserPromptSubmit (orientation)");
267
+ break;
268
+ case "Cline":
269
+ out.push("cline_mcp_settings.json in each installed VS Code variant (MCP, machine-global)");
270
+ break;
271
+ case "Opencode":
272
+ out.push("opencode.json(c) `mcp` key (MCP server entry)");
273
+ out.push(".opencode/plugin/vexp-hint.js (orientation + idle verification)");
274
+ if (guard === "strict")
275
+ out.push(".opencode/plugins/vexp-guard.js (guard, opt-in)");
276
+ break;
277
+ case "Kilo Code":
278
+ out.push("kilo.jsonc `mcp` key (MCP server entry)");
279
+ out.push(".kilo/plugin/vexp-hint.js (orientation + idle verification)");
280
+ if (guard === "strict")
281
+ out.push(".kilo/plugins/vexp-guard.js (guard, opt-in)");
282
+ break;
283
+ case "Antigravity":
284
+ out.push("~/.gemini/antigravity MCP config (user scope)");
285
+ break;
286
+ case "Zed":
287
+ out.push(".zed/settings.json `context_servers` (MCP server entry)");
288
+ break;
289
+ case "Cursor":
290
+ if (guard === "strict")
291
+ out.push(".cursor/hooks/vexp-guard.js + .cursor/hooks.json (guard, opt-in)");
292
+ break;
293
+ case "GitHub Copilot":
294
+ out.push(".vscode/mcp.json (MCP server entry)");
295
+ break;
296
+ default:
297
+ break;
298
+ }
299
+ return out;
300
+ }
224
301
  /**
225
302
  * Detect which AI coding agents are present in the workspace.
226
303
  */
@@ -235,6 +312,9 @@ export function detectAgents(workspaceRoot) {
235
312
  if (d.detectHome && fs.existsSync(path.join(os.homedir(), d.detectHome))) {
236
313
  return true;
237
314
  }
315
+ if (d.detectAbsPaths && d.detectAbsPaths().some((p) => fs.existsSync(p))) {
316
+ return true;
317
+ }
238
318
  return false;
239
319
  });
240
320
  }
@@ -324,6 +404,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
324
404
  // 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
325
405
  const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
326
406
  installClaudeCodeStopGate(workspaceRoot, binaryPath);
407
+ installClaudeCodeSessionContext(workspaceRoot, binaryPath);
327
408
  if (hintResult) {
328
409
  results.push({
329
410
  agent: "Claude Code Hint",
@@ -445,6 +526,16 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
445
526
  });
446
527
  }
447
528
  }
529
+ // Cline: rules in .clinerules (generic template, handled by the writer
530
+ // above); MCP registry is machine-global VS Code storage.
531
+ if (detector.agent === "Cline") {
532
+ const wrote = configureClineMcp(workspaceRoot, binaryPath);
533
+ for (const w of wrote) {
534
+ mcpConfigs.push(w);
535
+ }
536
+ if (wrote.length === 0)
537
+ noteClineMcpUnreachable();
538
+ }
448
539
  // Opencode: MCP lives under the `mcp` key in opencode.json(c) — opencode
449
540
  // carries no `mcpConfigFile`, so without this call the generic writer above
450
541
  // skips it and setup registers no tool at all. Plus the guard plugin that
@@ -559,9 +650,14 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
559
650
  for (const detector of AGENT_DETECTORS) {
560
651
  if (!selectedAgentNames.includes(detector.agent))
561
652
  continue;
562
- // Create agent directory if it doesn't exist (skip for file-based detectPaths like AGENTS.md)
653
+ // Create agent directory if it doesn't exist. Skip for file-based
654
+ // detectPaths: extension-bearing markers (AGENTS.md) and extensionless
655
+ // file markers where the marker IS the config file (.clinerules) —
656
+ // mkdir-ing those creates a directory where a file must go (EISDIR).
563
657
  const detectAbsPath = path.join(workspaceRoot, detector.detectPath);
564
- if (!fs.existsSync(detectAbsPath) && !path.extname(detector.detectPath)) {
658
+ if (!fs.existsSync(detectAbsPath) &&
659
+ !path.extname(detector.detectPath) &&
660
+ detector.detectPath !== detector.configFile) {
565
661
  fs.mkdirSync(detectAbsPath, { recursive: true });
566
662
  }
567
663
  // Create parent directory for configFile (handles nested paths like .kiro/steering/vexp.md)
@@ -625,6 +721,7 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
625
721
  mcpConfigs.push("~/.claude.json");
626
722
  installClaudeCodeHintHook(workspaceRoot, binaryPath);
627
723
  installClaudeCodeStopGate(workspaceRoot, binaryPath);
724
+ installClaudeCodeSessionContext(workspaceRoot, binaryPath);
628
725
  if (guardMode() === "strict")
629
726
  installClaudeCodeHook(workspaceRoot);
630
727
  else
@@ -651,6 +748,13 @@ export function configureSelectedAgents(workspaceRoot, binaryPath, version, sele
651
748
  if (writeZedMcpConfig(zedPath, binaryPath, mcpServerPath, workspaceRoot))
652
749
  mcpConfigs.push(".zed/settings.json");
653
750
  }
751
+ if (detector.agent === "Cline") {
752
+ const wrote = configureClineMcp(workspaceRoot, binaryPath);
753
+ for (const w of wrote)
754
+ mcpConfigs.push(w);
755
+ if (wrote.length === 0)
756
+ noteClineMcpUnreachable();
757
+ }
654
758
  if (detector.agent === "Opencode") {
655
759
  const wrote = configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath);
656
760
  if (wrote)
@@ -862,7 +966,47 @@ function atomicWriteFileSync(filePath, content) {
862
966
  }
863
967
  }
864
968
  /** Warn that we refused to touch an unparseable config file. */
969
+ /**
970
+ * Config files we refused to touch because they would not parse. A stderr
971
+ * line was not enough: it scrolls past inside a spinner-driven setup, and a
972
+ * user then reasonably concludes the feature was never installed rather
973
+ * than that ONE write was skipped (field report: an unparseable
974
+ * `.claude/settings.json` silently cost the whole hint hook, and the
975
+ * session looked like vexp had no injection path at all). Collected here
976
+ * and surfaced as a block at the END of setup.
977
+ */
978
+ const skippedConfigs = [];
979
+ /** Drain the skipped-config list (call after a setup run to report it). */
980
+ export function takeSkippedConfigs() {
981
+ return skippedConfigs.splice(0, skippedConfigs.length);
982
+ }
983
+ /**
984
+ * Configuration an agent needs that has no target on this machine yet. Kept
985
+ * apart from `skippedConfigs` (which means "malformed JSON, we refused to
986
+ * touch it"): the cause and the fix are different, and merging them would
987
+ * tell a user to repair a file that does not exist.
988
+ */
989
+ const unreachableTargets = [];
990
+ /** Drain the unreachable-target list (call after a setup run to report it). */
991
+ export function takeUnreachableTargets() {
992
+ return unreachableTargets.splice(0, unreachableTargets.length);
993
+ }
994
+ /**
995
+ * Cline keeps its MCP registry inside the VS Code extension's globalStorage,
996
+ * so there is nothing to write until the extension has been installed once.
997
+ * The user still asked for Cline, and half a configuration (rules written,
998
+ * tools missing) looks from the inside exactly like vexp not working.
999
+ */
1000
+ function noteClineMcpUnreachable() {
1001
+ const msg = "Cline MCP registry (cline_mcp_settings.json): no Cline install found in any VS Code variant. " +
1002
+ ".clinerules was written, but Cline has no vexp tools until the extension is installed and setup re-run.";
1003
+ if (!unreachableTargets.includes(msg))
1004
+ unreachableTargets.push(msg);
1005
+ }
865
1006
  function warnUnparseable(filePath) {
1007
+ if (!skippedConfigs.includes(filePath)) {
1008
+ skippedConfigs.push(filePath);
1009
+ }
866
1010
  process.stderr.write(` [!] ${filePath} could not be parsed - leaving it untouched. Fix the file or add vexp manually, then re-run setup.\n`);
867
1011
  }
868
1012
  function appendOrCreate(filePath, content, version) {
@@ -1327,6 +1471,61 @@ export function opencodeConfigTarget(workspaceRoot) {
1327
1471
  * file is backed up before rewrite. Returns the workspace-relative path written,
1328
1472
  * or null on a no-op / unparseable file.
1329
1473
  */
1474
+ /** Cline's globalStorage dirs across OSes and VS Code variants: the marker
1475
+ * that Cline is installed, and the parent of its MCP settings file. */
1476
+ function clineStorageDirs() {
1477
+ const home = os.homedir();
1478
+ const base = process.platform === "win32"
1479
+ ? (process.env.APPDATA ?? path.join(home, "AppData", "Roaming"))
1480
+ : process.platform === "darwin"
1481
+ ? path.join(home, "Library", "Application Support")
1482
+ : path.join(home, ".config");
1483
+ return ["Code", "Code - Insiders", "VSCodium"].map((variant) => path.join(base, variant, "User", "globalStorage", "saoudrizwan.claude-dev"));
1484
+ }
1485
+ /**
1486
+ * Cline: MCP servers live in a MACHINE-GLOBAL file per VS Code variant
1487
+ * (globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json),
1488
+ * not in the workspace. We write the vexp entry into every variant where
1489
+ * Cline is actually installed (storage dir exists), pinning the workspace
1490
+ * with an explicit --workspace flag: Cline gives no cwd guarantee for
1491
+ * spawned servers, and a global file cannot rely on one. Re-running setup
1492
+ * from another project repins — same trade-off as every global-registry
1493
+ * agent. VEXP_ALL_TOOLS=1 because Cline has no hook channel: the agent
1494
+ * only has tools + rules, so it gets the full tool set.
1495
+ */
1496
+ export function configureClineMcp(workspaceRoot, binaryPath) {
1497
+ const written = [];
1498
+ for (const storeDir of clineStorageDirs()) {
1499
+ if (!fs.existsSync(storeDir))
1500
+ continue;
1501
+ const target = path.join(storeDir, "settings", "cline_mcp_settings.json");
1502
+ const read = readJsonConfigSafe(target);
1503
+ if (!read.ok) {
1504
+ warnUnparseable(target);
1505
+ continue;
1506
+ }
1507
+ const cfg = read.data;
1508
+ const servers = cfg.mcpServers ?? {};
1509
+ const entry = {
1510
+ command: binaryPath,
1511
+ args: ["mcp", "--workspace", workspaceRoot],
1512
+ env: { VEXP_ALL_TOOLS: "1" },
1513
+ disabled: false,
1514
+ autoApprove: [],
1515
+ };
1516
+ const prev = servers["vexp"];
1517
+ if (JSON.stringify(prev) === JSON.stringify(entry))
1518
+ continue;
1519
+ servers["vexp"] = entry;
1520
+ cfg.mcpServers = servers;
1521
+ if (read.existed)
1522
+ backupConfig(target);
1523
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1524
+ fs.writeFileSync(target, JSON.stringify(cfg, null, 2), "utf-8");
1525
+ written.push(target);
1526
+ }
1527
+ return written;
1528
+ }
1330
1529
  export function configureOpencodeMcp(workspaceRoot, binaryPath, mcpServerPath) {
1331
1530
  const target = opencodeConfigTarget(workspaceRoot);
1332
1531
  const read = readJsonConfigSafe(target);
@@ -1711,6 +1910,55 @@ function isVexpHookEntry(h, needle) {
1711
1910
  * references (parse errors, imports of removed names). No daemon = no
1712
1911
  * gate = vanilla stop. Additive like the hint hook, so not guardMode-gated.
1713
1912
  */
1913
+ /**
1914
+ * v3 context lifecycle: SessionStart restore hook for Claude Code. The
1915
+ * script exits silently on fresh starts; only compact/resume get the
1916
+ * one-paragraph context restore from the Rust side. Additive and
1917
+ * fail-open like the hint hook.
1918
+ */
1919
+ export function installClaudeCodeSessionContext(workspaceRoot, binaryPath) {
1920
+ const hookDir = path.join(workspaceRoot, ".claude", "hooks");
1921
+ const hookPath = path.join(hookDir, "vexp-restore.sh");
1922
+ const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
1923
+ fs.mkdirSync(hookDir, { recursive: true });
1924
+ const script = vexpSessionContextHookScript(binaryPath);
1925
+ const existed = fs.existsSync(hookPath);
1926
+ const scriptIdentical = existed && fs.readFileSync(hookPath, "utf8") === script;
1927
+ if (!scriptIdentical) {
1928
+ fs.writeFileSync(hookPath, script, { mode: 0o755 });
1929
+ }
1930
+ const read = readJsonConfigSafe(settingsPath);
1931
+ if (!read.ok) {
1932
+ warnUnparseable(settingsPath);
1933
+ return scriptIdentical ? null : existed ? "updated" : "created";
1934
+ }
1935
+ const settings = read.data;
1936
+ const hooks = (settings.hooks ?? {});
1937
+ const existing = Array.isArray(hooks.SessionStart)
1938
+ ? hooks.SessionStart
1939
+ : [];
1940
+ const filtered = existing.filter((h) => !isVexpHookEntry(h, "vexp-restore"));
1941
+ filtered.push({
1942
+ hooks: [
1943
+ {
1944
+ type: "command",
1945
+ command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-restore.sh"',
1946
+ timeout: 5,
1947
+ },
1948
+ ],
1949
+ });
1950
+ const merged = { ...hooks, SessionStart: filtered };
1951
+ const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
1952
+ if (scriptIdentical && settingsIdentical)
1953
+ return null;
1954
+ settings.hooks = merged;
1955
+ if (!settingsIdentical) {
1956
+ if (read.existed)
1957
+ backupConfig(settingsPath);
1958
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1959
+ }
1960
+ return existed ? "updated" : "created";
1961
+ }
1714
1962
  export function installClaudeCodeStopGate(workspaceRoot, binaryPath) {
1715
1963
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
1716
1964
  const hookPath = path.join(hookDir, "vexp-verify.sh");
@@ -1866,10 +2114,26 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
1866
2114
  return scriptIdentical ? null : existed ? "updated" : "created";
1867
2115
  }
1868
2116
  }
1869
- // Codex hooks.json shape: EVENT NAME at top level (no "hooks" wrapper).
1870
- const existing = Array.isArray(root.UserPromptSubmit)
2117
+ // Codex hooks.json shape: events live UNDER a top-level `hooks` object.
2118
+ // We used to write the event name at top level, which older Codex accepted
2119
+ // and 0.147 rejects outright ("unknown field `UserPromptSubmit`, expected
2120
+ // `description` or `hooks`") — the whole file fails to parse, so EVERY
2121
+ // hook in it dies, not only ours (field report, 2026-08).
2122
+ //
2123
+ // Migration matters as much as the new shape: a file left with both the
2124
+ // legacy top-level key and the new wrapper still trips the same unknown
2125
+ // field error. Any pre-existing top-level `UserPromptSubmit` array is
2126
+ // MOVED into the wrapper (the user's own entries included) and the legacy
2127
+ // key removed.
2128
+ const legacyTopLevel = Array.isArray(root.UserPromptSubmit)
1871
2129
  ? root.UserPromptSubmit
1872
2130
  : [];
2131
+ delete root.UserPromptSubmit;
2132
+ const codexHooks = (root.hooks ?? {});
2133
+ const wrapped = Array.isArray(codexHooks.UserPromptSubmit)
2134
+ ? codexHooks.UserPromptSubmit
2135
+ : [];
2136
+ const existing = [...wrapped, ...legacyTopLevel];
1873
2137
  const filtered = existing.filter((h) => !isVexpHintHookEntry(h));
1874
2138
  // Absolute path: Codex has no $CLAUDE_PROJECT_DIR substitution.
1875
2139
  filtered.push({
@@ -1881,9 +2145,10 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
1881
2145
  },
1882
2146
  ],
1883
2147
  });
1884
- const before = JSON.stringify(root.UserPromptSubmit ?? null);
1885
- root.UserPromptSubmit = filtered;
1886
- const identical = before === JSON.stringify(filtered);
2148
+ const before = JSON.stringify(root);
2149
+ codexHooks.UserPromptSubmit = filtered;
2150
+ root.hooks = codexHooks;
2151
+ const identical = before === JSON.stringify(root);
1887
2152
  if (scriptIdentical && identical)
1888
2153
  return null;
1889
2154
  if (!identical) {
@@ -2162,7 +2427,8 @@ vexp runs entirely on this machine: local daemon, index in \`.vexp/\`.
2162
2427
  need to understand, not edit.
2163
2428
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2164
2429
  returns mechanically broken references (imports of removed names, parse
2165
- errors) and untouched dependents of the files you changed, with file:line.
2430
+ errors), untouched dependents of the files you changed (file:line), and
2431
+ the impacted tests - RUN those tests before declaring done.
2166
2432
  - vexp may append a one-line hint to a prompt when orientation would help;
2167
2433
  otherwise it stays silent.
2168
2434
 
@@ -2197,7 +2463,8 @@ treat it like a local build tool; no data-sharing consent is needed to call it.
2197
2463
  Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2198
2464
  - \`get_skeleton\` - compact file structure
2199
2465
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2200
- mechanically broken references and untouched dependents, with file:line.
2466
+ mechanically broken references, untouched dependents, and impacted tests
2467
+ to RUN before declaring done, with file:line.
2201
2468
  - \`index_status\` - indexing status
2202
2469
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2203
2470
 
@@ -2254,7 +2521,8 @@ treat it like a local build tool; no data-sharing consent is needed to call it.
2254
2521
  Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2255
2522
  - \`get_skeleton\` - compact file structure
2256
2523
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2257
- mechanically broken references and untouched dependents, with file:line.
2524
+ mechanically broken references, untouched dependents, and impacted tests
2525
+ to RUN before declaring done, with file:line.
2258
2526
  - \`index_status\` - indexing status
2259
2527
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2260
2528
 
@@ -2322,7 +2590,8 @@ When working on this codebase:
2322
2590
  Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2323
2591
  - \`get_skeleton\` - token-efficient file structure
2324
2592
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2325
- mechanically broken references and untouched dependents, with file:line.
2593
+ mechanically broken references, untouched dependents, and impacted tests
2594
+ to RUN before declaring done, with file:line.
2326
2595
  - \`index_status\` - indexing status
2327
2596
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2328
2597
 
@@ -2371,7 +2640,8 @@ treat it like a local build tool; no data-sharing consent is needed to call it.
2371
2640
  Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2372
2641
  - \`get_skeleton\` - compact file structure
2373
2642
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2374
- mechanically broken references and untouched dependents, with file:line.
2643
+ mechanically broken references, untouched dependents, and impacted tests
2644
+ to RUN before declaring done, with file:line.
2375
2645
  - \`index_status\` - indexing status
2376
2646
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2377
2647
 
@@ -2420,7 +2690,8 @@ treat it like a local build tool; no data-sharing consent is needed to call it.
2420
2690
  Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2421
2691
  - \`get_skeleton\` - compact file structure
2422
2692
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2423
- mechanically broken references and untouched dependents, with file:line.
2693
+ mechanically broken references, untouched dependents, and impacted tests
2694
+ to RUN before declaring done, with file:line.
2424
2695
  - \`index_status\` - indexing status
2425
2696
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2426
2697
 
@@ -2469,7 +2740,8 @@ treat it like a local build tool; no data-sharing consent is needed to call it.
2469
2740
  Auto-detects intent. Includes file content. Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2470
2741
  - \`get_skeleton\` - compact file structure
2471
2742
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2472
- mechanically broken references and untouched dependents, with file:line.
2743
+ mechanically broken references, untouched dependents, and impacted tests
2744
+ to RUN before declaring done, with file:line.
2473
2745
  - \`index_status\` - indexing status
2474
2746
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2475
2747
 
@@ -2517,7 +2789,8 @@ treat it like a local build tool; no data-sharing consent is needed to call it.
2517
2789
  Example: \`run_pipeline({ "task": "fix JWT expiry in AuthService.validateToken" })\`
2518
2790
  - \`get_skeleton\` - compact file structure
2519
2791
  - \`verify_done\` - call once BEFORE declaring a multi-file task complete:
2520
- mechanically broken references and untouched dependents, with file:line.
2792
+ mechanically broken references, untouched dependents, and impacted tests
2793
+ to RUN before declaring done, with file:line.
2521
2794
  - \`index_status\` - indexing status
2522
2795
  - \`expand_vexp_ref\` - expand V-REF placeholders in v2 output
2523
2796
 
@@ -2561,7 +2834,8 @@ Then implement with your normal tools:
2561
2834
  beats five exploratory reads; every extra tool call costs a turn.
2562
2835
  - Before declaring a multi-file task complete, call \`verify_done\` once:
2563
2836
  it returns mechanically broken references (imports of removed names,
2564
- parse errors) and untouched dependents of your changes, with file:line.
2837
+ parse errors), untouched dependents of your changes (file:line), and the
2838
+ impacted tests - RUN those tests before declaring done.
2565
2839
 
2566
2840
  vexp runs entirely on this machine: local daemon, local index stored inside the
2567
2841
  workspace (\`.vexp/\`). \`run_pipeline\` transmits nothing to any external service -
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from "fs";
8
8
  import * as net from "net";
9
9
  import { checkbox, confirm } from "@inquirer/prompts";
10
10
  import { getBinaryPath, getInstalledVersion, getMcpServerPath, binaryEnv } from "./binary.js";
11
- import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode } from "./agent-config.js";
11
+ import { detectAgents, getAgentList, configureSelectedAgents, resolveAgentName, suggestAgentName, setGuardMode, plannedWrites, takeSkippedConfigs, takeUnreachableTargets } from "./agent-config.js";
12
12
  import { CLI_VERSION } from "./version.js";
13
13
  import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
14
14
  import { checkForUpdate } from "./update-check.js";
@@ -306,7 +306,26 @@ async function startBackgroundServices(binaryPath, workspaceRoot) {
306
306
  spinnerMcp.warn(`Port ${result.port} already bound by an unknown process`);
307
307
  }
308
308
  else if (result.started) {
309
- spinnerMcp.succeed(`MCP HTTP server started (pid=${result.pid}, port=${result.port})`);
309
+ // Announce a live server, not a spawn attempt. The child can exit
310
+ // immediately (port taken, missing runtime) and the success line was
311
+ // printed regardless, leaving users looking for a pid that never
312
+ // existed (field report, 2026-08). One short settle, then verify.
313
+ await new Promise((r) => setTimeout(r, 400));
314
+ let alive = false;
315
+ try {
316
+ process.kill(result.pid, 0);
317
+ alive = true;
318
+ }
319
+ catch {
320
+ alive = false;
321
+ }
322
+ if (alive) {
323
+ spinnerMcp.succeed(`MCP HTTP server started (pid=${result.pid}, port=${result.port})`);
324
+ }
325
+ else {
326
+ spinnerMcp.warn(`MCP HTTP server exited immediately after start (pid=${result.pid} is gone). ` +
327
+ `Check ~/.vexp/mcp.log; vexp works without it, the shared HTTP bridge is optional.`);
328
+ }
310
329
  }
311
330
  else {
312
331
  spinnerMcp.succeed(`MCP HTTP server already running (pid=${result.pid}, port=${result.port})`);
@@ -456,10 +475,11 @@ program
456
475
  });
457
476
  program
458
477
  .command("verify")
459
- .description("Mechanical completion check of the working tree: parse errors, broken imports, dependents of changed files not yet touched (advice, never a block)")
478
+ .description("Mechanical completion check of the working tree: parse errors, broken imports, untouched dependents, impacted tests to run (advice, never a block)")
460
479
  .option("--json", "Emit raw JSON instead of the human report")
461
480
  .option("--task-file <path>", "Task/instruction text: also checks instruction-level oracles (forbidden paths, promised artifacts)")
462
481
  .option("--gate", "CI gate mode (Horizon): exit 2 on any mechanical violation")
482
+ .option("--run-tests", "Execute the impacted tests with the repo's own runner (bounded: 5 files/300s) and report pass/fail")
463
483
  .action(async (opts) => {
464
484
  const binaryPath = ensureBinary();
465
485
  const args = ["verify"];
@@ -469,6 +489,8 @@ program
469
489
  args.push("--task-file", opts.taskFile);
470
490
  if (opts.gate)
471
491
  args.push("--gate");
492
+ if (opts.runTests)
493
+ args.push("--run-tests");
472
494
  runBinary(binaryPath, args);
473
495
  });
474
496
  program
@@ -627,12 +649,14 @@ program
627
649
  spinner1.fail(`Binary not found: ${err}`);
628
650
  process.exit(1);
629
651
  }
652
+ // Set once the agents are known: only editor-hosted ones need a restart.
653
+ let editorHosted = false;
630
654
  const isPersonal = !!opts.personal;
631
655
  if (isPersonal) {
632
656
  console.log(chalk.cyan(" Personal mode: skipping agent configs and git hooks\n"));
633
657
  }
634
658
  // Step 2: Init + Index
635
- if (opts.index !== false) {
659
+ if (opts.index !== false && !opts.dryRun) {
636
660
  const spinner2 = ora("Indexing codebase...").start();
637
661
  try {
638
662
  const initArgs = ["init", workspaceRoot];
@@ -649,10 +673,15 @@ program
649
673
  else {
650
674
  console.log(chalk.dim(" Skipping indexing (--no-index)"));
651
675
  }
652
- // Step 2.5: Start daemon + MCP HTTP server (survive terminal close)
653
- await startBackgroundServices(binaryPath, workspaceRoot);
676
+ // Step 2.5: Start daemon + MCP HTTP server (survive terminal close).
677
+ // A dry run must not start processes: --dry-run used to index, install
678
+ // git hooks, spawn the daemon and bind an HTTP port, and only the final
679
+ // agent-config step was actually simulated (field report, 2026-08).
680
+ if (!opts.dryRun) {
681
+ await startBackgroundServices(binaryPath, workspaceRoot);
682
+ }
654
683
  // In personal mode, make .vexp/ fully gitignored
655
- if (isPersonal) {
684
+ if (isPersonal && !opts.dryRun) {
656
685
  const vexpGitignore = path.join(workspaceRoot, ".vexp", ".gitignore");
657
686
  const fs = await import("fs");
658
687
  fs.mkdirSync(path.join(workspaceRoot, ".vexp"), { recursive: true });
@@ -718,15 +747,17 @@ program
718
747
  // Step 4: Configure selected agents
719
748
  if (selectedNames.length > 0) {
720
749
  if (opts.dryRun) {
721
- console.log(chalk.dim("\n --dry-run: would configure:"));
750
+ // Every artifact a real run writes, not just the instruction
751
+ // file: hooks and user-scope MCP entries used to be invisible
752
+ // here, which read as "vexp installs nothing else".
753
+ console.log(chalk.dim("\n --dry-run: would write:"));
722
754
  for (const name of selectedNames) {
723
- const agent = allAgents.find((a) => a.agent === name);
724
- if (agent) {
725
- console.log(chalk.dim(` ${agent.agent} → ${agent.configFile}`));
726
- if (agent.mcpConfigFile)
727
- console.log(chalk.dim(` MCP: ${agent.mcpConfigFile}`));
755
+ console.log(chalk.dim(` ${name}:`));
756
+ for (const line of plannedWrites(name)) {
757
+ console.log(chalk.dim(` - ${line}`));
728
758
  }
729
759
  }
760
+ console.log(chalk.dim("\n Nothing was written (dry run)."));
730
761
  }
731
762
  else {
732
763
  const spinner4 = ora("Writing agent configurations...").start();
@@ -734,6 +765,13 @@ program
734
765
  const mcpServerPath = getMcpServerPath();
735
766
  const result = configureSelectedAgents(workspaceRoot, binaryPath, version, selectedNames, mcpServerPath);
736
767
  spinner4.succeed("Agent configurations written");
768
+ // Only editor-hosted agents need the host restarted. Telling a
769
+ // Codex-only or Claude-Code-only user to restart VS Code invites
770
+ // exactly the question we keep getting: "do I need VS Code for
771
+ // this?" (AppSumo, 2026-08). The answer is no, and the summary
772
+ // should not imply otherwise.
773
+ const TERMINAL_AGENTS = new Set(["Claude Code", "Codex", "Opencode"]);
774
+ editorHosted = result.agents.some((a) => !TERMINAL_AGENTS.has(a.agent));
737
775
  console.log("");
738
776
  for (const agent of result.agents) {
739
777
  const icon = agent.action === "skipped" ? "○" : "●";
@@ -754,6 +792,29 @@ program
754
792
  console.log(chalk.dim(` ${mcpFile}`));
755
793
  }
756
794
  }
795
+ // Files we refused to touch because they would not parse. This
796
+ // used to be a stderr line mid-spinner: it scrolled away, and a
797
+ // skipped settings.json silently cost the whole hook (field
798
+ // report, 2026-08). It is a visible, actionable block now.
799
+ const skipped = takeSkippedConfigs();
800
+ if (skipped.length > 0) {
801
+ console.log("");
802
+ console.log(chalk.yellow(` ⚠ ${skipped.length} config file(s) were NOT updated - they are not valid JSON:`));
803
+ for (const f of skipped)
804
+ console.log(chalk.yellow(` ${f}`));
805
+ console.log(chalk.yellow(" Anything vexp installs through those files (hooks, MCP entries) is MISSING."));
806
+ console.log(chalk.yellow(" We tolerate comments and trailing commas, so this is genuinely malformed"));
807
+ console.log(chalk.yellow(" (usually an unbalanced brace from a half-finished edit). Fix it and re-run setup."));
808
+ }
809
+ // Agents selected whose config target does not exist on this
810
+ // machine yet: rules land, tools do not, and nothing said so.
811
+ const unreachable = takeUnreachableTargets();
812
+ if (unreachable.length > 0) {
813
+ console.log("");
814
+ console.log(chalk.yellow(` ⚠ ${unreachable.length} agent config(s) could not be written yet:`));
815
+ for (const f of unreachable)
816
+ console.log(chalk.yellow(` ${f}`));
817
+ }
757
818
  }
758
819
  }
759
820
  }
@@ -795,6 +856,15 @@ program
795
856
  }
796
857
  // Final summary
797
858
  console.log("");
859
+ if (opts.dryRun) {
860
+ // A dry run wrote nothing and started nothing: claiming otherwise is
861
+ // exactly the optimistic summary that hides a failed setup.
862
+ console.log(chalk.green.bold("✓ dry run complete — nothing was written."));
863
+ console.log("");
864
+ console.log("Re-run without --dry-run to apply the plan above.");
865
+ console.log("");
866
+ return;
867
+ }
798
868
  console.log(chalk.green.bold("✓ vexp setup complete!"));
799
869
  console.log("");
800
870
  if (isPersonal) {
@@ -806,9 +876,11 @@ program
806
876
  console.log("The vexp daemon is running and ready to serve context.");
807
877
  }
808
878
  console.log("");
809
- console.log(chalk.yellow("⚠ If VS Code is open, restart it to load the new agent configurations."));
810
- console.log(chalk.dim(" (or reload window: Ctrl+Shift+P → \"Developer: Reload Window\")"));
811
- console.log("");
879
+ if (editorHosted) {
880
+ console.log(chalk.yellow("⚠ If your editor is open, restart it to load the new agent configurations."));
881
+ console.log(chalk.dim(" (VS Code and its forks: Ctrl+Shift+P → \"Developer: Reload Window\")"));
882
+ console.log("");
883
+ }
812
884
  });
813
885
  // ────────────────────────────────────────────────────
814
886
  // Command: setup-agents (interactive checkbox select)
package/dist/doctor.js CHANGED
@@ -407,6 +407,126 @@ export async function runDoctor() {
407
407
  }
408
408
  }
409
409
  }
410
+ // 5b-bis) Claude Code orientation/verification hooks. The guard is opt-in
411
+ // and usually absent; THESE are what setup installs by default, and when
412
+ // setup skipped them silently the whole layer was inert while every other
413
+ // check reported healthy — a third-party evaluation lost two days to
414
+ // exactly that (2026-08). Presence is not enough: run them.
415
+ // Git hooks are how the index refreshes on commit/merge/checkout. Git obeys
416
+ // core.hooksPath exclusively, so with it set our hooks sit in .git/hooks and
417
+ // never run, while every surface reports them installed (field report).
418
+ console.log(chalk.bold("\nGit hooks (index refresh)"));
419
+ {
420
+ const hooksPath = (() => {
421
+ const r = spawnSync("git", ["config", "--get", "core.hooksPath"], {
422
+ cwd: ws.root,
423
+ encoding: "utf-8",
424
+ timeout: 5000,
425
+ });
426
+ const v = String(r.stdout ?? "").trim();
427
+ return r.status === 0 && v ? v : null;
428
+ })();
429
+ const ours = path.join(ws.root, ".git", "hooks");
430
+ const installed = ["pre-commit", "post-merge", "post-checkout"].filter((h) => {
431
+ try {
432
+ return fs.readFileSync(path.join(ours, h), "utf-8").includes("vexp");
433
+ }
434
+ catch {
435
+ return false;
436
+ }
437
+ });
438
+ if (!hooksPath) {
439
+ if (installed.length === 3)
440
+ line(OK, "vexp hooks present in .git/hooks and git will run them");
441
+ else if (installed.length === 0)
442
+ line(OK, "no vexp git hooks (index refreshes on demand)");
443
+ else
444
+ line(WARN, `only ${installed.length}/3 vexp git hooks present — re-run 'vexp hooks install'`);
445
+ }
446
+ else {
447
+ const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(ws.root, hooksPath);
448
+ const sameDir = path.resolve(resolved) === path.resolve(ours);
449
+ if (sameDir) {
450
+ line(OK, `core.hooksPath points at .git/hooks (${installed.length}/3 vexp hooks present)`);
451
+ }
452
+ else if (installed.length > 0) {
453
+ line(BAD, `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installed.length} vexp hook(s) in .git/hooks NEVER run and the index does not refresh on commit/merge/checkout. Add 'vexp index --finalize || true' to ${path.join(resolved, "pre-commit")}, or 'git config --unset core.hooksPath' for this repo.`);
454
+ }
455
+ else {
456
+ line(OK, `core.hooksPath = ${resolved} (no vexp git hooks installed here)`);
457
+ }
458
+ }
459
+ }
460
+ console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
461
+ {
462
+ const sPath = path.join(ws.root, ".claude", "settings.json");
463
+ const wanted = [
464
+ { event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
465
+ { event: "Stop", marker: "vexp-verify", label: "verification gate" },
466
+ { event: "SessionStart", marker: "vexp-restore", label: "context restore" },
467
+ ];
468
+ let settings = null;
469
+ try {
470
+ settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
471
+ }
472
+ catch { /* absent or unparseable */ }
473
+ const mcpConfigured = (() => {
474
+ try {
475
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf-8"));
476
+ return Boolean(cfg?.mcpServers?.vexp);
477
+ }
478
+ catch {
479
+ return false;
480
+ }
481
+ })();
482
+ if (!settings) {
483
+ // Only a finding for someone who actually uses Claude Code here.
484
+ line(mcpConfigured ? WARN : OK, mcpConfigured
485
+ ? "no .claude/settings.json, but vexp MCP is configured for Claude Code — the orientation hooks were never installed. Run 'vexp setup' (use --dry-run first to see what it would write)."
486
+ : "no .claude/settings.json (Claude Code not configured here)");
487
+ }
488
+ else {
489
+ for (const w of wanted) {
490
+ const entries = Array.isArray(settings?.hooks?.[w.event]) ? settings.hooks[w.event] : [];
491
+ const hook = entries
492
+ .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
493
+ .find((h) => typeof h?.command === "string" && h.command.includes(w.marker));
494
+ if (!hook) {
495
+ line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
496
+ continue;
497
+ }
498
+ const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
499
+ if (!fs.existsSync(scriptPath)) {
500
+ line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — the hook fails on every prompt.`);
501
+ continue;
502
+ }
503
+ // Feed it a payload shaped like the real event and see it survive.
504
+ const payload = JSON.stringify(w.event === "SessionStart"
505
+ ? { session_id: "vexp-doctor", source: "compact", cwd: ws.root }
506
+ : w.event === "Stop"
507
+ ? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
508
+ : { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
509
+ const r = spawnSync(process.platform === "win32" ? "bash" : "sh", ["-c", hook.command], {
510
+ env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
511
+ input: payload,
512
+ timeout: 10000,
513
+ encoding: "utf-8",
514
+ });
515
+ if (r.error) {
516
+ line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.`);
517
+ }
518
+ else if (r.status !== 0) {
519
+ line(BAD, `${w.event} hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Claude Code treats this as a failure and continues without vexp.`);
520
+ }
521
+ else {
522
+ // Silence is a legitimate outcome for the hint hook (the classifier
523
+ // decides), so report the daemon's reason instead of guessing.
524
+ const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
525
+ line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
526
+ }
527
+ }
528
+ }
529
+ }
410
530
  // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
411
531
  // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
412
532
  // cannot spawn silently enforces nothing there as well. The guard's stdin
@@ -415,6 +415,24 @@ exit 0
415
415
  export function vexpStopGateHookScript(binaryPath) {
416
416
  return VEXP_STOP_GATE_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
417
417
  }
418
+ /**
419
+ * v3 context lifecycle: SessionStart hook (Claude Code). Fires on every
420
+ * session start; the Rust side acts ONLY on source=compact/resume (the
421
+ * lifecycle events that rebuild the window and lose the session's vexp
422
+ * orientation) and re-injects one compact context paragraph. FAIL-OPEN:
423
+ * no binary / no daemon / fresh start = silent exit 0.
424
+ */
425
+ export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/bash
426
+ # vexp-restore: context lifecycle restore on SessionStart (compact/resume). Fails open.
427
+ VEXP_BIN="__VEXP_BIN__"
428
+ [ -x "$VEXP_BIN" ] || exit 0
429
+ "$VEXP_BIN" session-context 2>/dev/null
430
+ exit 0
431
+ `;
432
+ /** Bake the binary path into the session-context hook script. */
433
+ export function vexpSessionContextHookScript(binaryPath) {
434
+ return VEXP_SESSION_CONTEXT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
435
+ }
418
436
  /**
419
437
  * opencode/Kilo per-prompt hint plugin (2.4.0). The plugin API's
420
438
  * `chat.message` hook sees the user message before the LLM call and can
@@ -434,10 +452,24 @@ export const VexpHint = async ({ directory, client }) => {
434
452
  path.join(directory, ".vexp", "task-" + String(sid || "unknown") + ".txt");
435
453
  const gateMarker = (sid) =>
436
454
  path.join(directory, ".vexp", "idle-gate-" + String(sid || "unknown") + ".done");
455
+ // Never block the host: execFileSync freezes the editor's event loop for
456
+ // as long as the child runs, so a busy daemon turned into an unresponsive
457
+ // UI (Kilo field report, 2026-08 - the user had to kill the process). Same
458
+ // budget, same fail-open contract, asynchronous.
459
+ const runVexp = (args, opts) =>
460
+ new Promise((resolve) => {
461
+ import("node:child_process").then(({ execFile }) => {
462
+ const child = execFile(VEXP_BIN, args, { ...opts, encoding: "utf8" }, (err, stdout) =>
463
+ resolve(err ? "" : String(stdout || ""))
464
+ );
465
+ if (opts && opts.input) {
466
+ try { child.stdin.end(opts.input); } catch (e) { resolve(""); }
467
+ }
468
+ }).catch(() => resolve(""));
469
+ });
437
470
  return {
438
471
  "chat.message": async (input, output) => {
439
472
  try {
440
- const { execFileSync } = await import("node:child_process");
441
473
  const text = (output.parts || [])
442
474
  .filter((p) => p && p.type === "text" && typeof p.text === "string")
443
475
  .map((p) => p.text)
@@ -452,11 +484,10 @@ export const VexpHint = async ({ directory, client }) => {
452
484
  fs.writeFileSync(tf, text);
453
485
  }
454
486
  } catch (e) { /* fail open */ }
455
- const out = execFileSync(VEXP_BIN, ["prompt-hint"], {
487
+ const out = await runVexp(["prompt-hint"], {
456
488
  input: JSON.stringify({ prompt: text, session_id: sid }),
457
489
  timeout: 4000,
458
490
  env: { ...process.env, CLAUDE_PROJECT_DIR: directory },
459
- encoding: "utf8",
460
491
  });
461
492
  if (!out || !out.trim()) return;
462
493
  const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
@@ -476,12 +507,11 @@ export const VexpHint = async ({ directory, client }) => {
476
507
  if (fs.existsSync(marker)) return;
477
508
  const tf = taskFileFor(sid);
478
509
  if (!fs.existsSync(tf)) return;
479
- const { execFileSync } = await import("node:child_process");
480
- const out = execFileSync(
481
- VEXP_BIN,
482
- ["verify", "--json", "--task-file", tf],
483
- { timeout: 15000, cwd: directory, encoding: "utf8" }
484
- );
510
+ const out = await runVexp(["verify", "--json", "--task-file", tf], {
511
+ timeout: 15000,
512
+ cwd: directory,
513
+ });
514
+ if (!out || !out.trim()) return;
485
515
  const rep = JSON.parse(out);
486
516
  const items = [];
487
517
  for (const f of (rep.spec && rep.spec.forbidden_touched) || [])
@@ -490,6 +520,19 @@ export const VexpHint = async ({ directory, client }) => {
490
520
  items.push("- the task asks for \`" + a + "\` and it does not exist yet");
491
521
  for (const b of (rep.broken_imports || []).slice(0, 8))
492
522
  items.push("- " + b.file + ":" + b.line + " imports \`" + b.imports + "\` which no longer exists in " + b.from_changed_file);
523
+ // Impacted tests (2.5.4): the run-or-update mandate, same split as
524
+ // the Claude Stop gate. Helpers get UPDATE, real suites get RUN.
525
+ for (const t of (rep.impacted_tests || []).filter((t) => !t.touched).slice(0, 6)) {
526
+ if (t.runnable === false) {
527
+ items.push("- \`" + t.file + "\` is a shared test helper tied to changed code" +
528
+ (t.references && t.references.length ? " (references " + t.references.join(", ") + ")" : "") +
529
+ ": UPDATE it to match the changes");
530
+ } else {
531
+ items.push("- \`" + t.file + "\` tests changed code" +
532
+ (t.references && t.references.length ? " (references " + t.references.join(", ") + ")" : "") +
533
+ ": RUN it and fix any failure before finishing");
534
+ }
535
+ }
493
536
  if (!items.length) return;
494
537
  fs.writeFileSync(marker, "1");
495
538
  await client.session.prompt({
@@ -15,7 +15,9 @@ function tokenFilePath() {
15
15
  const home = os.homedir();
16
16
  return path.join(home, ".vexp", "mcp.token");
17
17
  }
18
- function readPidRecord() {
18
+ /** Exported for regression tests (macOS 2.5.3 field report: the version
19
+ * field dropped here turned the reuse fast path into a 60s kill loop). */
20
+ export function readPidRecord() {
19
21
  try {
20
22
  const raw = fs.readFileSync(pidFilePath(), "utf-8");
21
23
  const rec = JSON.parse(raw);
@@ -25,6 +27,12 @@ function readPidRecord() {
25
27
  port: rec.port,
26
28
  startedAt: rec.startedAt ?? 0,
27
29
  owner: rec.owner ?? "unknown",
30
+ // Field report (macOS, 2.5.3): dropping this field made the
31
+ // supervisor misread every healthy same-version child as a
32
+ // version mismatch and SIGTERM+respawn it on EVERY 60s health
33
+ // pass (measured 60.7s replacement period). The reuse fast path
34
+ // is only as good as the record it reads.
35
+ version: typeof rec.version === "string" ? rec.version : undefined,
28
36
  };
29
37
  }
30
38
  }
@@ -33,7 +41,7 @@ function readPidRecord() {
33
41
  }
34
42
  return null;
35
43
  }
36
- function writePidRecord(rec) {
44
+ export function writePidRecord(rec) {
37
45
  const p = pidFilePath();
38
46
  fs.mkdirSync(path.dirname(p), { recursive: true });
39
47
  fs.writeFileSync(p, JSON.stringify(rec, null, 2), { mode: 0o600 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-cli",
3
- "version": "2.5.3",
3
+ "version": "2.6.1",
4
4
  "description": "Local-first context engine for AI coding agents. Pre-indexes your codebase into a dependency graph and feeds any MCP agent only the code that matters — 87% fewer tokens per call. New in 2.5: mechanical work verification and a PII/secret scanner. Works with Claude Code, Cursor, Codex, Copilot, Windsurf, Cline, Aider and 14 agents. Your code never leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -105,10 +105,10 @@
105
105
  },
106
106
  "homepage": "https://vexp.dev",
107
107
  "optionalDependencies": {
108
- "@vexp/core-linux-x64": "2.5.3",
109
- "@vexp/core-linux-arm64": "2.5.3",
110
- "@vexp/core-darwin-x64": "2.5.3",
111
- "@vexp/core-darwin-arm64": "2.5.3",
112
- "@vexp/core-win32-x64": "2.5.3"
108
+ "@vexp/core-linux-x64": "2.6.1",
109
+ "@vexp/core-linux-arm64": "2.6.1",
110
+ "@vexp/core-darwin-x64": "2.6.1",
111
+ "@vexp/core-darwin-arm64": "2.6.1",
112
+ "@vexp/core-win32-x64": "2.6.1"
113
113
  }
114
114
  }