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.
- package/dist/agent-config.js +291 -17
- package/dist/cli.js +88 -16
- package/dist/doctor.js +120 -0
- package/dist/hook-template.js +52 -9
- package/dist/mcp-supervisor.js +10 -2
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -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
|
|
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) &&
|
|
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:
|
|
1870
|
-
|
|
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
|
|
1885
|
-
|
|
1886
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
console.log(chalk.dim(`
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
package/dist/hook-template.js
CHANGED
|
@@ -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 =
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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({
|
package/dist/mcp-supervisor.js
CHANGED
|
@@ -15,7 +15,9 @@ function tokenFilePath() {
|
|
|
15
15
|
const home = os.homedir();
|
|
16
16
|
return path.join(home, ".vexp", "mcp.token");
|
|
17
17
|
}
|
|
18
|
-
|
|
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.
|
|
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.
|
|
109
|
-
"@vexp/core-linux-arm64": "2.
|
|
110
|
-
"@vexp/core-darwin-x64": "2.
|
|
111
|
-
"@vexp/core-darwin-arm64": "2.
|
|
112
|
-
"@vexp/core-win32-x64": "2.
|
|
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
|
}
|