vexp-cli 2.5.2 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +291 -17
- package/dist/cli.js +85 -13
- package/dist/doctor.js +75 -0
- package/dist/hook-template.js +52 -9
- package/dist/license.js +15 -5
- 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,19 @@ 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");
|
|
494
|
+
runBinary(binaryPath, args);
|
|
495
|
+
});
|
|
496
|
+
program
|
|
497
|
+
.command("daemons")
|
|
498
|
+
.description("List every vexp daemon on this machine: which workspace holds each concurrent-workspace slot, version, index size, uptime")
|
|
499
|
+
.option("--json", "Emit machine-readable JSON")
|
|
500
|
+
.action(async (opts) => {
|
|
501
|
+
const binaryPath = ensureBinary();
|
|
502
|
+
const args = ["daemons"];
|
|
503
|
+
if (opts.json)
|
|
504
|
+
args.push("--json");
|
|
472
505
|
runBinary(binaryPath, args);
|
|
473
506
|
});
|
|
474
507
|
program
|
|
@@ -621,7 +654,7 @@ program
|
|
|
621
654
|
console.log(chalk.cyan(" Personal mode: skipping agent configs and git hooks\n"));
|
|
622
655
|
}
|
|
623
656
|
// Step 2: Init + Index
|
|
624
|
-
if (opts.index !== false) {
|
|
657
|
+
if (opts.index !== false && !opts.dryRun) {
|
|
625
658
|
const spinner2 = ora("Indexing codebase...").start();
|
|
626
659
|
try {
|
|
627
660
|
const initArgs = ["init", workspaceRoot];
|
|
@@ -638,10 +671,15 @@ program
|
|
|
638
671
|
else {
|
|
639
672
|
console.log(chalk.dim(" Skipping indexing (--no-index)"));
|
|
640
673
|
}
|
|
641
|
-
// Step 2.5: Start daemon + MCP HTTP server (survive terminal close)
|
|
642
|
-
|
|
674
|
+
// Step 2.5: Start daemon + MCP HTTP server (survive terminal close).
|
|
675
|
+
// A dry run must not start processes: --dry-run used to index, install
|
|
676
|
+
// git hooks, spawn the daemon and bind an HTTP port, and only the final
|
|
677
|
+
// agent-config step was actually simulated (field report, 2026-08).
|
|
678
|
+
if (!opts.dryRun) {
|
|
679
|
+
await startBackgroundServices(binaryPath, workspaceRoot);
|
|
680
|
+
}
|
|
643
681
|
// In personal mode, make .vexp/ fully gitignored
|
|
644
|
-
if (isPersonal) {
|
|
682
|
+
if (isPersonal && !opts.dryRun) {
|
|
645
683
|
const vexpGitignore = path.join(workspaceRoot, ".vexp", ".gitignore");
|
|
646
684
|
const fs = await import("fs");
|
|
647
685
|
fs.mkdirSync(path.join(workspaceRoot, ".vexp"), { recursive: true });
|
|
@@ -707,15 +745,17 @@ program
|
|
|
707
745
|
// Step 4: Configure selected agents
|
|
708
746
|
if (selectedNames.length > 0) {
|
|
709
747
|
if (opts.dryRun) {
|
|
710
|
-
|
|
748
|
+
// Every artifact a real run writes, not just the instruction
|
|
749
|
+
// file: hooks and user-scope MCP entries used to be invisible
|
|
750
|
+
// here, which read as "vexp installs nothing else".
|
|
751
|
+
console.log(chalk.dim("\n --dry-run: would write:"));
|
|
711
752
|
for (const name of selectedNames) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
console.log(chalk.dim(`
|
|
715
|
-
if (agent.mcpConfigFile)
|
|
716
|
-
console.log(chalk.dim(` MCP: ${agent.mcpConfigFile}`));
|
|
753
|
+
console.log(chalk.dim(` ${name}:`));
|
|
754
|
+
for (const line of plannedWrites(name)) {
|
|
755
|
+
console.log(chalk.dim(` - ${line}`));
|
|
717
756
|
}
|
|
718
757
|
}
|
|
758
|
+
console.log(chalk.dim("\n Nothing was written (dry run)."));
|
|
719
759
|
}
|
|
720
760
|
else {
|
|
721
761
|
const spinner4 = ora("Writing agent configurations...").start();
|
|
@@ -743,6 +783,29 @@ program
|
|
|
743
783
|
console.log(chalk.dim(` ${mcpFile}`));
|
|
744
784
|
}
|
|
745
785
|
}
|
|
786
|
+
// Files we refused to touch because they would not parse. This
|
|
787
|
+
// used to be a stderr line mid-spinner: it scrolled away, and a
|
|
788
|
+
// skipped settings.json silently cost the whole hook (field
|
|
789
|
+
// report, 2026-08). It is a visible, actionable block now.
|
|
790
|
+
const skipped = takeSkippedConfigs();
|
|
791
|
+
if (skipped.length > 0) {
|
|
792
|
+
console.log("");
|
|
793
|
+
console.log(chalk.yellow(` ⚠ ${skipped.length} config file(s) were NOT updated - they are not valid JSON:`));
|
|
794
|
+
for (const f of skipped)
|
|
795
|
+
console.log(chalk.yellow(` ${f}`));
|
|
796
|
+
console.log(chalk.yellow(" Anything vexp installs through those files (hooks, MCP entries) is MISSING."));
|
|
797
|
+
console.log(chalk.yellow(" We tolerate comments and trailing commas, so this is genuinely malformed"));
|
|
798
|
+
console.log(chalk.yellow(" (usually an unbalanced brace from a half-finished edit). Fix it and re-run setup."));
|
|
799
|
+
}
|
|
800
|
+
// Agents selected whose config target does not exist on this
|
|
801
|
+
// machine yet: rules land, tools do not, and nothing said so.
|
|
802
|
+
const unreachable = takeUnreachableTargets();
|
|
803
|
+
if (unreachable.length > 0) {
|
|
804
|
+
console.log("");
|
|
805
|
+
console.log(chalk.yellow(` ⚠ ${unreachable.length} agent config(s) could not be written yet:`));
|
|
806
|
+
for (const f of unreachable)
|
|
807
|
+
console.log(chalk.yellow(` ${f}`));
|
|
808
|
+
}
|
|
746
809
|
}
|
|
747
810
|
}
|
|
748
811
|
}
|
|
@@ -784,6 +847,15 @@ program
|
|
|
784
847
|
}
|
|
785
848
|
// Final summary
|
|
786
849
|
console.log("");
|
|
850
|
+
if (opts.dryRun) {
|
|
851
|
+
// A dry run wrote nothing and started nothing: claiming otherwise is
|
|
852
|
+
// exactly the optimistic summary that hides a failed setup.
|
|
853
|
+
console.log(chalk.green.bold("✓ dry run complete — nothing was written."));
|
|
854
|
+
console.log("");
|
|
855
|
+
console.log("Re-run without --dry-run to apply the plan above.");
|
|
856
|
+
console.log("");
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
787
859
|
console.log(chalk.green.bold("✓ vexp setup complete!"));
|
|
788
860
|
console.log("");
|
|
789
861
|
if (isPersonal) {
|
package/dist/doctor.js
CHANGED
|
@@ -407,6 +407,81 @@ 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
|
+
console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
|
|
416
|
+
{
|
|
417
|
+
const sPath = path.join(ws.root, ".claude", "settings.json");
|
|
418
|
+
const wanted = [
|
|
419
|
+
{ event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
|
|
420
|
+
{ event: "Stop", marker: "vexp-verify", label: "verification gate" },
|
|
421
|
+
{ event: "SessionStart", marker: "vexp-restore", label: "context restore" },
|
|
422
|
+
];
|
|
423
|
+
let settings = null;
|
|
424
|
+
try {
|
|
425
|
+
settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
426
|
+
}
|
|
427
|
+
catch { /* absent or unparseable */ }
|
|
428
|
+
const mcpConfigured = (() => {
|
|
429
|
+
try {
|
|
430
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf-8"));
|
|
431
|
+
return Boolean(cfg?.mcpServers?.vexp);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
})();
|
|
437
|
+
if (!settings) {
|
|
438
|
+
// Only a finding for someone who actually uses Claude Code here.
|
|
439
|
+
line(mcpConfigured ? WARN : OK, mcpConfigured
|
|
440
|
+
? "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)."
|
|
441
|
+
: "no .claude/settings.json (Claude Code not configured here)");
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
for (const w of wanted) {
|
|
445
|
+
const entries = Array.isArray(settings?.hooks?.[w.event]) ? settings.hooks[w.event] : [];
|
|
446
|
+
const hook = entries
|
|
447
|
+
.flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
|
|
448
|
+
.find((h) => typeof h?.command === "string" && h.command.includes(w.marker));
|
|
449
|
+
if (!hook) {
|
|
450
|
+
line(WARN, `${w.event} (${w.label}) not installed — re-run 'vexp setup' to write it.`);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
|
|
454
|
+
if (!fs.existsSync(scriptPath)) {
|
|
455
|
+
line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — the hook fails on every prompt.`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// Feed it a payload shaped like the real event and see it survive.
|
|
459
|
+
const payload = JSON.stringify(w.event === "SessionStart"
|
|
460
|
+
? { session_id: "vexp-doctor", source: "compact", cwd: ws.root }
|
|
461
|
+
: w.event === "Stop"
|
|
462
|
+
? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
|
|
463
|
+
: { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
|
|
464
|
+
const r = spawnSync(process.platform === "win32" ? "bash" : "sh", ["-c", hook.command], {
|
|
465
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
|
|
466
|
+
input: payload,
|
|
467
|
+
timeout: 10000,
|
|
468
|
+
encoding: "utf-8",
|
|
469
|
+
});
|
|
470
|
+
if (r.error) {
|
|
471
|
+
line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.`);
|
|
472
|
+
}
|
|
473
|
+
else if (r.status !== 0) {
|
|
474
|
+
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.`);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
// Silence is a legitimate outcome for the hint hook (the classifier
|
|
478
|
+
// decides), so report the daemon's reason instead of guessing.
|
|
479
|
+
const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
|
|
480
|
+
line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
410
485
|
// 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
|
|
411
486
|
// hooks fail OPEN too (`failClosed` defaults to false), so a guard that
|
|
412
487
|
// 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/license.js
CHANGED
|
@@ -5,6 +5,16 @@ import * as os from "os";
|
|
|
5
5
|
import { CLI_VERSION } from "./version.js";
|
|
6
6
|
// Ed25519 PUBLIC key (DER, base64) — same key as vexp-vscode/src/license.ts.
|
|
7
7
|
// Only the server (vexp.dev) holds the matching private key.
|
|
8
|
+
/** Resolve the vexp home dir like the Rust core does: VEXP_HOME (absolute)
|
|
9
|
+
* wins, else the OS home. Without this, sandboxed/container setups that set
|
|
10
|
+
* VEXP_HOME read and roll license tokens in a directory the daemon never
|
|
11
|
+
* looks at. */
|
|
12
|
+
function vexpHomeDir() {
|
|
13
|
+
const env = process.env.VEXP_HOME;
|
|
14
|
+
if (env && path.isAbsolute(env))
|
|
15
|
+
return env;
|
|
16
|
+
return os.homedir();
|
|
17
|
+
}
|
|
8
18
|
const VEXP_LICENSE_PUBLIC_KEY = "MCowBQYDK2VwAyEApwiWYGCyhCaGHAHWn/RTBSGo/1MNmGyZUgSNBQ5YE4g=";
|
|
9
19
|
const VEXP_WEB_ORIGIN = process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
|
|
10
20
|
// 14-day grace window after the last successful online refresh before we
|
|
@@ -15,16 +25,16 @@ const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
|
|
|
15
25
|
// 3 second timeout on validate calls — any slower falls back silently.
|
|
16
26
|
const VALIDATE_TIMEOUT_MS = 3000;
|
|
17
27
|
function getLicensePath() {
|
|
18
|
-
return path.join(
|
|
28
|
+
return path.join(vexpHomeDir(), ".vexp", "license.jwt");
|
|
19
29
|
}
|
|
20
30
|
function getFreshTokenPath() {
|
|
21
|
-
return path.join(
|
|
31
|
+
return path.join(vexpHomeDir(), ".vexp", "fresh.jwt");
|
|
22
32
|
}
|
|
23
33
|
function getLastCheckPath() {
|
|
24
|
-
return path.join(
|
|
34
|
+
return path.join(vexpHomeDir(), ".vexp", "last_online_check");
|
|
25
35
|
}
|
|
26
36
|
function getDeviceIdPath() {
|
|
27
|
-
return path.join(
|
|
37
|
+
return path.join(vexpHomeDir(), ".vexp", "device.id");
|
|
28
38
|
}
|
|
29
39
|
/**
|
|
30
40
|
* Stable anonymous device identifier. First call creates and persists it.
|
|
@@ -99,7 +109,7 @@ function removeFreshToken() {
|
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
function getDeviceBlockedPath() {
|
|
102
|
-
return path.join(
|
|
112
|
+
return path.join(vexpHomeDir(), ".vexp", "device_blocked.json");
|
|
103
113
|
}
|
|
104
114
|
/** Returns the active device-blocked marker, if any. */
|
|
105
115
|
export function readDeviceBlocked() {
|
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.0",
|
|
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.0",
|
|
109
|
+
"@vexp/core-linux-arm64": "2.6.0",
|
|
110
|
+
"@vexp/core-darwin-x64": "2.6.0",
|
|
111
|
+
"@vexp/core-darwin-arm64": "2.6.0",
|
|
112
|
+
"@vexp/core-win32-x64": "2.6.0"
|
|
113
113
|
}
|
|
114
114
|
}
|