vexp-cli 3.3.7 → 3.3.8
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 +177 -52
- package/dist/doctor.js +173 -12
- package/dist/hook-template.js +56 -11
- package/mcp/mcp-server.cjs +31 -31
- package/package.json +6 -6
package/dist/doctor.js
CHANGED
|
@@ -1308,7 +1308,8 @@ export function claudeHookSpawnSpec(command, root, platform, gitBash, baseEnv =
|
|
|
1308
1308
|
if (!gitBash)
|
|
1309
1309
|
return null;
|
|
1310
1310
|
const env = { ...baseEnv, CLAUDE_PROJECT_DIR: root.replace(/\\/g, "/") };
|
|
1311
|
-
// The `
|
|
1311
|
+
// The `sh` inside the command (`bash` in an older entry) must resolve
|
|
1312
|
+
// beside Git Bash too, as in Claude Code, never to WSL's launcher.
|
|
1312
1313
|
if (path.win32.isAbsolute(gitBash)) {
|
|
1313
1314
|
const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
|
|
1314
1315
|
const dir = path.win32.dirname(gitBash);
|
|
@@ -1330,10 +1331,108 @@ export function claudeShellMissing(what, lookup) {
|
|
|
1330
1331
|
}
|
|
1331
1332
|
return (`${what} cannot run on this PC: on Windows Claude Code runs hooks with Git Bash, and there is none where it looks ` +
|
|
1332
1333
|
`(CLAUDE_CODE_GIT_BASH_PATH, C:\\Program Files\\Git\\bin\\bash.exe, bin\\bash.exe beside the git.exe on PATH). ` +
|
|
1333
|
-
`It then runs them with PowerShell, which cannot run vexp's
|
|
1334
|
+
`It then runs them with PowerShell, which cannot run vexp's sh hooks. A 'bash' on PATH does not count: with WSL installed it is WSL's, which cannot see this project.\n` +
|
|
1334
1335
|
` install Git for Windows (https://git-scm.com/downloads/win) or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe ` +
|
|
1335
1336
|
`(in the environment or the "env" block of ~/.claude/settings.json; a project's settings do not count), then restart Claude Code.`);
|
|
1336
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Does this Claude Code entry start vexp's script with `bash`? Setup wrote
|
|
1340
|
+
* that form until the hooks became POSIX sh; both installers now replace it,
|
|
1341
|
+
* matched by script name, with `sh "..."`. Off Windows Claude Code runs
|
|
1342
|
+
* either as `/bin/sh -c '<command>'`, so the old one keeps working wherever
|
|
1343
|
+
* a bash is on PATH, and doctor says nothing of it there.
|
|
1344
|
+
*/
|
|
1345
|
+
export function isBashHookEntry(command) {
|
|
1346
|
+
return /^\s*bash\s/.test(command);
|
|
1347
|
+
}
|
|
1348
|
+
/** Is `name` an executable file in a folder of PATH? POSIX lookup. */
|
|
1349
|
+
export function onPosixPath(name, env = process.env, canExec = (p) => {
|
|
1350
|
+
try {
|
|
1351
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
1352
|
+
return fs.statSync(p).isFile();
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
}) {
|
|
1358
|
+
return (env.PATH ?? "").split(path.posix.delimiter).some((dir) => dir !== "" && canExec(path.posix.join(dir, name)));
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* What a Claude Code hook that fails at its event costs. "Fails on every
|
|
1362
|
+
* prompt" was the line for the subagent hook too, which never sees a prompt:
|
|
1363
|
+
* on Agent the subagent still starts, only blind (review, 2026-10-24).
|
|
1364
|
+
*/
|
|
1365
|
+
export function claudeHookFailureCost(event) {
|
|
1366
|
+
if (event === "PreToolUse")
|
|
1367
|
+
return "Claude Code reports a hook error on every Agent call and each subagent starts without orientation";
|
|
1368
|
+
if (event === "SessionStart")
|
|
1369
|
+
return "the hook fails at every session start";
|
|
1370
|
+
if (event === "Stop")
|
|
1371
|
+
return "the hook fails at the end of every reply";
|
|
1372
|
+
return "the hook fails on every prompt";
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Which opt-ins this settings.json shows on. Setup keeps no record of its
|
|
1376
|
+
* flags, so the entries are the only answer, and a rewrite has to carry
|
|
1377
|
+
* them: `vexp setup` without --guard-strict or --interventions REMOVES those
|
|
1378
|
+
* hooks, script and entry (review, 2026-10-24).
|
|
1379
|
+
*/
|
|
1380
|
+
export function claudeOptInsOn(settings) {
|
|
1381
|
+
const hooks = settings?.hooks ?? {};
|
|
1382
|
+
const commands = Object.values(hooks)
|
|
1383
|
+
.flatMap((groups) => (Array.isArray(groups) ? groups : []))
|
|
1384
|
+
.flatMap((g) => (Array.isArray(g?.hooks) ? g.hooks : []))
|
|
1385
|
+
.map((h) => (typeof h?.command === "string" ? h.command : ""));
|
|
1386
|
+
return {
|
|
1387
|
+
guard: commands.some((c) => c.includes("vexp-guard")),
|
|
1388
|
+
interventions: commands.some((c) => /vexp-(verify|edit-hint|read-hint|bash-cap)\b/.test(c)),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* How vexp's Claude Code entries get rewritten, with the install at hand:
|
|
1393
|
+
* the setup that writes them, and for someone with the extension a restart
|
|
1394
|
+
* of the window, since the extension rewrites them at every start in a
|
|
1395
|
+
* project where it sets Claude Code up (and the guard when vexp.guardStrict
|
|
1396
|
+
* is on).
|
|
1397
|
+
*
|
|
1398
|
+
* The project's opt-ins ride along (claudeOptInsOn). The plain command,
|
|
1399
|
+
* offered for an old `bash` stop gate, rewrote nothing: a setup without
|
|
1400
|
+
* --interventions takes the gate out, so a user who had chosen it and
|
|
1401
|
+
* followed doctor lost it without a word (review, 2026-10-24). The extension
|
|
1402
|
+
* writes none of the --interventions hooks and takes them out at every
|
|
1403
|
+
* start, which a reopen does too; the line says so rather than call that a
|
|
1404
|
+
* rewrite.
|
|
1405
|
+
*/
|
|
1406
|
+
export function claudeHooksRewriteRemedy(install, root, on = { guard: false, interventions: false }) {
|
|
1407
|
+
const ide = ideName(install.extensions);
|
|
1408
|
+
const reopen = on.guard
|
|
1409
|
+
? `reopen this project in ${ide} with the vexp.guardStrict setting on`
|
|
1410
|
+
: `reopen this project in ${ide} (the vexp extension rewrites them at every start where it sets Claude Code up)`;
|
|
1411
|
+
const takesOut = on.interventions
|
|
1412
|
+
? "; the extension takes out the stop gate and the other opt-in hooks of 'vexp setup --interventions' as it does so, since only the vexp CLI writes them"
|
|
1413
|
+
: "";
|
|
1414
|
+
if (extensionOnly(install)) {
|
|
1415
|
+
return (on.guard ? reopen : `${reopen}, or run "vexp: Setup Agents" from the Command Palette and tick Claude Code`) + takesOut;
|
|
1416
|
+
}
|
|
1417
|
+
const flags = `${on.guard ? " --guard-strict" : ""}${on.interventions ? " --interventions" : ""}`;
|
|
1418
|
+
const setup = `run '${setupAgentsCommand(["Claude Code"], root)}${flags}'`;
|
|
1419
|
+
return install.extensions.length > 0 ? `${setup}, or ${reopen}${takesOut}` : setup;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* The line for a `bash` entry on a machine with no bash. Alpine and the
|
|
1423
|
+
* other BusyBox systems ship sh alone, and the alpine-x64/arm64 builds put
|
|
1424
|
+
* vexp there: `bash "..."` exits 127 at every event, a hook error each time
|
|
1425
|
+
* and no orientation. Off Windows only: there Claude Code runs hooks with
|
|
1426
|
+
* Git Bash, which brings its own bash (claudeShellMissing covers its
|
|
1427
|
+
* absence). Null when the entry is not the old form or a bash is on PATH;
|
|
1428
|
+
* the probe that follows judges it then.
|
|
1429
|
+
*/
|
|
1430
|
+
export function claudeBashEntryLine(what, command, cost, remedy, platform = process.platform, bashOnPath = () => onPosixPath("bash")) {
|
|
1431
|
+
if (platform === "win32" || !isBashHookEntry(command) || bashOnPath())
|
|
1432
|
+
return null;
|
|
1433
|
+
return (`${what} starts vexp's script with bash, and this machine has no bash on PATH (Alpine and other BusyBox systems ship only sh): ${cost}.\n` +
|
|
1434
|
+
` vexp writes this entry with sh now; to fix it: ${remedy}.`);
|
|
1435
|
+
}
|
|
1337
1436
|
/**
|
|
1338
1437
|
* What the ledger adds to a failed hook probe. Its prompts arrive only
|
|
1339
1438
|
* through an agent's hook — doctor's own probe is tagged manual and never
|
|
@@ -1343,14 +1442,36 @@ export function claudeShellMissing(what, lookup) {
|
|
|
1343
1442
|
* probes' own failures: the prompts it counts come from UserPromptSubmit
|
|
1344
1443
|
* hooks, and next to a failure that is real (no Git Bash for Claude Code, an
|
|
1345
1444
|
* exec-form guard Windows cannot spawn) "may be the probe's alone" talked the
|
|
1346
|
-
* reader out of the one line he had to act on (review, 2026-09-24).
|
|
1445
|
+
* reader out of the one line he had to act on (review, 2026-09-24). And only
|
|
1446
|
+
* on the UserPromptSubmit probe's: a count of prompts says nothing of
|
|
1447
|
+
* vexp-subagent.sh, and an exit 2 line read "Claude Code REFUSES the Agent
|
|
1448
|
+
* call" and "may be the probe's alone" at once (review, 2026-10-24).
|
|
1347
1449
|
*/
|
|
1348
|
-
export function hookLedgerNote(hookPrompts7d) {
|
|
1349
|
-
if (!(hookPrompts7d > 0))
|
|
1450
|
+
export function hookLedgerNote(hookPrompts7d, event) {
|
|
1451
|
+
if (event !== "UserPromptSubmit" || !(hookPrompts7d > 0))
|
|
1350
1452
|
return "";
|
|
1351
1453
|
return (`\n note: the savings ledger counted ${hookPrompts7d} prompt(s) in the last 7 days that reached vexp through an agent's hook ` +
|
|
1352
1454
|
`(doctor's own probe is never counted), so an agent's hooks do reach vexp in this project — this failure may be the probe's alone.`);
|
|
1353
1455
|
}
|
|
1456
|
+
/**
|
|
1457
|
+
* The keys of the Agent call the subagent hook's answer leaves out. Claude
|
|
1458
|
+
* Code replaces tool_input with updatedInput instead of merging the two, so
|
|
1459
|
+
* a key missing there is missing from the call itself: the agent type, the
|
|
1460
|
+
* background flag. No output, or output without updatedInput, leaves the
|
|
1461
|
+
* call as the model wrote it and drops nothing.
|
|
1462
|
+
*/
|
|
1463
|
+
export function droppedAgentKeys(stdout, toolInput) {
|
|
1464
|
+
let updated;
|
|
1465
|
+
try {
|
|
1466
|
+
updated = JSON.parse(String(stdout ?? "").trim() || "null")?.hookSpecificOutput?.updatedInput;
|
|
1467
|
+
}
|
|
1468
|
+
catch {
|
|
1469
|
+
return [];
|
|
1470
|
+
}
|
|
1471
|
+
if (!updated || typeof updated !== "object")
|
|
1472
|
+
return [];
|
|
1473
|
+
return Object.keys(toolInput).filter((k) => !(k in updated));
|
|
1474
|
+
}
|
|
1354
1475
|
/** The answers file when it exists but does not read as answers. */
|
|
1355
1476
|
function unreadableAnswers(file) {
|
|
1356
1477
|
// The same loader the CLI and the extension read the answers with: it
|
|
@@ -3820,9 +3941,11 @@ async function doctorChecks(onWorkspace) {
|
|
|
3820
3941
|
const sPath = path.join(ws.root, ".claude", "settings.json");
|
|
3821
3942
|
let guardHooks = [];
|
|
3822
3943
|
let settingsReadable = false;
|
|
3944
|
+
let optIns = { guard: true, interventions: false };
|
|
3823
3945
|
try {
|
|
3824
3946
|
const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
|
|
3825
3947
|
settingsReadable = true;
|
|
3948
|
+
optIns = { ...claudeOptInsOn(settings), guard: true };
|
|
3826
3949
|
const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
|
|
3827
3950
|
for (const m of pre) {
|
|
3828
3951
|
const hks = Array.isArray(m?.hooks) ? m.hooks : [];
|
|
@@ -3852,7 +3975,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3852
3975
|
// all — the entry LOOKS installed and enforces nothing. This was
|
|
3853
3976
|
// invisible for days because doctor used to skip the live check
|
|
3854
3977
|
// on Windows entirely.
|
|
3855
|
-
line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (
|
|
3978
|
+
line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (sh-prefixed shell form).`);
|
|
3856
3979
|
}
|
|
3857
3980
|
if (timeoutS > 600) {
|
|
3858
3981
|
line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
|
|
@@ -3861,6 +3984,15 @@ async function doctorChecks(onWorkspace) {
|
|
|
3861
3984
|
line(INFO, `guard hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
|
|
3862
3985
|
continue;
|
|
3863
3986
|
}
|
|
3987
|
+
// An entry from before the hooks became POSIX sh, on a machine with
|
|
3988
|
+
// no bash (Alpine): the probe would only print sh's exit 127.
|
|
3989
|
+
const noBash = execForm
|
|
3990
|
+
? null
|
|
3991
|
+
: claudeBashEntryLine("the guard hook", cmd, "Claude Code reports a hook error on every search and the guard enforces nothing", claudeHooksRewriteRemedy(install, ws.root, optIns));
|
|
3992
|
+
if (noBash) {
|
|
3993
|
+
line(BAD, noBash);
|
|
3994
|
+
continue;
|
|
3995
|
+
}
|
|
3864
3996
|
// Run it exactly as Claude Code would: exec form = direct spawn with
|
|
3865
3997
|
// the placeholder substituted by the host; shell form = the shell
|
|
3866
3998
|
// Claude Code uses (claudeHookSpawnSpec: Git Bash on Windows, never
|
|
@@ -3948,6 +4080,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
3948
4080
|
// --interventions` installs it for anyone who wants that trade.
|
|
3949
4081
|
const wanted = [
|
|
3950
4082
|
{ event: "UserPromptSubmit", marker: "vexp-hint", label: "orientation" },
|
|
4083
|
+
{ event: "PreToolUse", marker: "vexp-subagent", label: "subagent orientation" },
|
|
3951
4084
|
{ event: "SessionStart", marker: "vexp-restore", label: "context restore" },
|
|
3952
4085
|
{ event: "Stop", marker: "vexp-verify", label: "verification gate", optIn: true },
|
|
3953
4086
|
];
|
|
@@ -3996,15 +4129,31 @@ async function doctorChecks(onWorkspace) {
|
|
|
3996
4129
|
}
|
|
3997
4130
|
const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
|
|
3998
4131
|
if (!fs.existsSync(scriptPath)) {
|
|
3999
|
-
|
|
4132
|
+
// sh exits 127, an error Claude Code reports and steps over each
|
|
4133
|
+
// time the event fires (claudeHookFailureCost says what that costs).
|
|
4134
|
+
line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — ${claudeHookFailureCost(w.event)}.`);
|
|
4135
|
+
continue;
|
|
4136
|
+
}
|
|
4137
|
+
// A `bash` entry from before the hooks became POSIX sh, where there
|
|
4138
|
+
// is no bash: the same 127 at every event, and a rewrite fixes it.
|
|
4139
|
+
const noBash = claudeBashEntryLine(`${w.event} hook`, hook.command, claudeHookFailureCost(w.event), claudeHooksRewriteRemedy(install, ws.root, claudeOptInsOn(settings)));
|
|
4140
|
+
if (noBash) {
|
|
4141
|
+
line(BAD, noBash);
|
|
4000
4142
|
continue;
|
|
4001
4143
|
}
|
|
4002
4144
|
// Feed it a payload shaped like the real event and see it survive.
|
|
4145
|
+
// Like every probe here it carries no hook_event_name and no
|
|
4146
|
+
// transcript_path: those are what tell the engine a call came from an
|
|
4147
|
+
// agent (payload_is_from_hook, main.rs), and a probe with them would
|
|
4148
|
+
// be counted in the activity ledger as an agent's.
|
|
4149
|
+
const agentInput = { description: "vexp doctor probe", prompt: "vexp doctor probe", subagent_type: "general-purpose" };
|
|
4003
4150
|
const payload = JSON.stringify(w.event === "SessionStart"
|
|
4004
4151
|
? { session_id: "vexp-doctor", source: "compact", cwd: ws.root }
|
|
4005
4152
|
: w.event === "Stop"
|
|
4006
4153
|
? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
|
|
4007
|
-
:
|
|
4154
|
+
: w.event === "PreToolUse"
|
|
4155
|
+
? { session_id: "vexp-doctor", tool_name: "Agent", tool_input: agentInput, cwd: ws.root }
|
|
4156
|
+
: { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
|
|
4008
4157
|
// In Claude Code's own shell (claudeHookSpawnSpec), from the project.
|
|
4009
4158
|
const bash = process.platform === "win32" ? claudeGitBash() : null;
|
|
4010
4159
|
const spec = claudeHookSpawnSpec(hook.command, ws.root, process.platform, bash?.path ?? null);
|
|
@@ -4020,15 +4169,27 @@ async function doctorChecks(onWorkspace) {
|
|
|
4020
4169
|
encoding: "utf-8",
|
|
4021
4170
|
windowsHide: true,
|
|
4022
4171
|
});
|
|
4172
|
+
const lost = w.event === "PreToolUse" && !r.error && r.status === 0 ? droppedAgentKeys(r.stdout, agentInput) : [];
|
|
4023
4173
|
if (r.error) {
|
|
4024
|
-
line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d));
|
|
4174
|
+
line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d, w.event));
|
|
4025
4175
|
}
|
|
4026
4176
|
else if (r.status !== 0) {
|
|
4027
|
-
|
|
4177
|
+
// Exit 2 from a PreToolUse hook is not a failure Claude Code steps
|
|
4178
|
+
// over: it refuses the tool call. The script setup writes ends on
|
|
4179
|
+
// `exit 0` for exactly this reason, so one that exits 2 is not it.
|
|
4180
|
+
const consequence = w.event === "PreToolUse" && r.status === 2
|
|
4181
|
+
? "on exit 2 Claude Code REFUSES the Agent call, so no subagent can start"
|
|
4182
|
+
: "Claude Code treats this as a failure and continues without vexp";
|
|
4183
|
+
line(BAD, `${w.event} hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — ${consequence}.` + hookLedgerNote(hookPrompts7d, w.event));
|
|
4184
|
+
}
|
|
4185
|
+
else if (lost.length > 0) {
|
|
4186
|
+
line(BAD, `${w.event} hook rewrites the Agent call without ${lost.join(", ")} — Claude Code replaces the call's input with the hook's, so every subagent loses them; update the vexp engine.`);
|
|
4028
4187
|
}
|
|
4029
4188
|
else {
|
|
4030
4189
|
// Silence is a legitimate outcome for the hint hook (the classifier
|
|
4031
|
-
// decides), so report the daemon's reason instead of guessing.
|
|
4190
|
+
// decides), so report the daemon's reason instead of guessing. The
|
|
4191
|
+
// same for the subagent hook: no seed is no output, and the call
|
|
4192
|
+
// goes ahead as the model wrote it.
|
|
4032
4193
|
const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
|
|
4033
4194
|
line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
|
|
4034
4195
|
}
|
|
@@ -4114,7 +4275,7 @@ async function doctorChecks(onWorkspace) {
|
|
|
4114
4275
|
: [{ shell: "sh", spec: codexHookSpawnSpec(cmdLine) }];
|
|
4115
4276
|
const baked = (() => {
|
|
4116
4277
|
try {
|
|
4117
|
-
// One pattern covers both twins: VEXP_BIN="path" (
|
|
4278
|
+
// One pattern covers both twins: VEXP_BIN="path" (sh) and
|
|
4118
4279
|
// set "VEXP_BIN=path" (cmd).
|
|
4119
4280
|
const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
|
|
4120
4281
|
return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
|
package/dist/hook-template.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* platform uses the right "daemon is listening" signal. The healthy marker
|
|
12
12
|
* (removed by the daemon on graceful shutdown) gates both branches.
|
|
13
13
|
*/
|
|
14
|
-
export const VEXP_GUARD_HOOK = `#!/bin/
|
|
14
|
+
export const VEXP_GUARD_HOOK = `#!/bin/sh
|
|
15
15
|
# vexp-guard: block Grep/Glob/Read when the vexp daemon is running AND healthy.
|
|
16
16
|
# Cross-platform: Unix/macOS use the domain socket file (.vexp/daemon.sock);
|
|
17
17
|
# Windows (Git Bash) has no socket file, so the daemon writes .vexp/daemon.pipe.
|
|
@@ -31,7 +31,7 @@ vexp_allow() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
# Healthy marker is the portable "daemon is up" signal; the daemon removes it on
|
|
34
|
-
# graceful shutdown. Required on every platform
|
|
34
|
+
# graceful shutdown. Required on every platform - if it's missing, fail open.
|
|
35
35
|
[ -f "$HEALTHY" ] || vexp_allow
|
|
36
36
|
|
|
37
37
|
case "$(uname -s 2>/dev/null)" in
|
|
@@ -207,8 +207,8 @@ export const VexpGuard = async ({ directory, worktree }) => {
|
|
|
207
207
|
* never touch the shell, never block a target vexp did not index — expressed in
|
|
208
208
|
* the third dialect we now have to speak.
|
|
209
209
|
*
|
|
210
|
-
* Written in NODE, not
|
|
211
|
-
*
|
|
210
|
+
* Written in NODE, not shell, and that is deliberate. The Claude Code guard is a
|
|
211
|
+
* shell script and it silently enforced NOTHING on Windows for several releases
|
|
212
212
|
* (`[ -S "$SOCK" ]` can never match a named pipe), which is the single most
|
|
213
213
|
* expensive bug this file has produced. Cursor documents no PowerShell variant
|
|
214
214
|
* of `command` and does not say which shell runs it on Windows, so a bash guard
|
|
@@ -381,6 +381,15 @@ process.stdin.on("end", () => {
|
|
|
381
381
|
* hint suggesting run_pipeline. All logic (stdin parsing, classification,
|
|
382
382
|
* hook-JSON output) lives in the Rust binary so the script is OS-neutral;
|
|
383
383
|
* Claude Code runs shell-form hooks through Git Bash on Windows.
|
|
384
|
+
*
|
|
385
|
+
* POSIX sh, like every Claude Code hook script in this file, and registered
|
|
386
|
+
* as `sh "$CLAUDE_PROJECT_DIR/..."`. They were bash scripts run by `bash`,
|
|
387
|
+
* and Alpine (BusyBox) has no bash: on an Alpine remote, the platform of the
|
|
388
|
+
* alpine-x64/arm64 builds, every prompt, session start and Agent call
|
|
389
|
+
* reported a hook error and got no orientation. Nothing here needed bash.
|
|
390
|
+
* Keep it that way: test/hook-posix.test.ts rejects the usual bashisms and
|
|
391
|
+
* runs every baked script under dash.
|
|
392
|
+
*
|
|
384
393
|
* FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
|
|
385
394
|
* timeout) exits 0 with no output = vanilla behavior, never a broken
|
|
386
395
|
* prompt. Keep in lockstep with the VS Code extension copy.
|
|
@@ -392,13 +401,45 @@ process.stdin.on("end", () => {
|
|
|
392
401
|
* line: Codex hashes that line for its trust record, and a change there would
|
|
393
402
|
* make every user approve the hook again. Binaries that predate it ignore it.
|
|
394
403
|
*/
|
|
395
|
-
export const VEXP_HINT_HOOK = `#!/bin/
|
|
404
|
+
export const VEXP_HINT_HOOK = `#!/bin/sh
|
|
396
405
|
# vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
|
|
397
406
|
VEXP_BIN="__VEXP_BIN__"
|
|
398
407
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
399
408
|
VEXP_HOOK_AGENT="__VEXP_AGENT__" "$VEXP_BIN" prompt-hint 2>/dev/null
|
|
400
409
|
exit 0
|
|
401
410
|
`;
|
|
411
|
+
/**
|
|
412
|
+
* Orientation for a delegated task: PreToolUse on Claude Code's Agent tool.
|
|
413
|
+
*
|
|
414
|
+
* A subagent started blind (field report, 2026-10-24). UserPromptSubmit fires
|
|
415
|
+
* for what the user types and never for the prompt the model writes for a
|
|
416
|
+
* subagent; the built-in Explore and Plan agents run without CLAUDE.md; and
|
|
417
|
+
* vexp's tools reach a subagent only as bare deferred names. SubagentStart is
|
|
418
|
+
* no way in either: it is handed the agent's id and type, not its task.
|
|
419
|
+
*
|
|
420
|
+
* PreToolUse on Agent is handed the task, as tool_input.prompt, and Claude
|
|
421
|
+
* Code starts the subagent on `updatedInput` when the hook returns one. It
|
|
422
|
+
* REPLACES the input, it does not merge it, so `subagent-hint` echoes every
|
|
423
|
+
* key with the seed appended to the prompt. It sets no permissionDecision:
|
|
424
|
+
* without one the rewrite is applied silently in default, acceptEdits, auto,
|
|
425
|
+
* plan and bypassPermissions mode (verified on Claude Code 2.1.217 and
|
|
426
|
+
* 2.1.309), and an "allow" would skip the auto-mode classifier. The parent's
|
|
427
|
+
* transcript keeps the prompt the model wrote; only the subagent's first
|
|
428
|
+
* message carries the seed, so only the subagent pays for it.
|
|
429
|
+
*
|
|
430
|
+
* The last `exit 0` matters more here than in any other hook: a PreToolUse
|
|
431
|
+
* hook that exits 2 BLOCKS the tool call, and an engine older than
|
|
432
|
+
* `subagent-hint` exits 2 from its argument parser. Without it, every
|
|
433
|
+
* delegation would be refused until the engine was updated. Claude Code is
|
|
434
|
+
* the only agent that runs this script, hence the agent name written in.
|
|
435
|
+
*/
|
|
436
|
+
export const VEXP_SUBAGENT_HOOK = `#!/bin/sh
|
|
437
|
+
# vexp-subagent: orientation for a delegated task (PreToolUse on Agent). Fails open.
|
|
438
|
+
VEXP_BIN="__VEXP_BIN__"
|
|
439
|
+
[ -x "$VEXP_BIN" ] || exit 0
|
|
440
|
+
VEXP_HOOK_AGENT="claude-code" "$VEXP_BIN" subagent-hint 2>/dev/null
|
|
441
|
+
exit 0
|
|
442
|
+
`;
|
|
402
443
|
/**
|
|
403
444
|
* v5: the coupling, delivered on the edit that needs it (PostToolUse).
|
|
404
445
|
*
|
|
@@ -417,14 +458,14 @@ exit 0
|
|
|
417
458
|
* FAIL-OPEN like every other hook: no binary, no daemon, nothing to say —
|
|
418
459
|
* exit 0 silent.
|
|
419
460
|
*/
|
|
420
|
-
export const VEXP_EDIT_HINT_HOOK = `#!/bin/
|
|
461
|
+
export const VEXP_EDIT_HINT_HOOK = `#!/bin/sh
|
|
421
462
|
# vexp-edit-hint: what else references the file you just changed. Fails open.
|
|
422
463
|
VEXP_BIN="__VEXP_BIN__"
|
|
423
464
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
424
465
|
"$VEXP_BIN" edit-hint 2>/dev/null
|
|
425
466
|
exit 0
|
|
426
467
|
`;
|
|
427
|
-
export const VEXP_READ_HINT_HOOK = `#!/bin/
|
|
468
|
+
export const VEXP_READ_HINT_HOOK = `#!/bin/sh
|
|
428
469
|
# vexp-read-hint: answer a whole-file read of a large file with its skeleton.
|
|
429
470
|
# The only mechanism vexp has that SUBTRACTS tokens. Fails open.
|
|
430
471
|
VEXP_BIN="__VEXP_BIN__"
|
|
@@ -432,7 +473,7 @@ VEXP_BIN="__VEXP_BIN__"
|
|
|
432
473
|
"$VEXP_BIN" read-hint 2>/dev/null
|
|
433
474
|
exit 0
|
|
434
475
|
`;
|
|
435
|
-
export const VEXP_BASH_CAP_HOOK = `#!/bin/
|
|
476
|
+
export const VEXP_BASH_CAP_HOOK = `#!/bin/sh
|
|
436
477
|
# vexp-bash-cap: bound the output of a shell command that has no bound of its
|
|
437
478
|
# own. The other door: narrowing reads alone just moved the work here. Fails
|
|
438
479
|
# open, and never touches a build or a test.
|
|
@@ -548,6 +589,10 @@ export function bakeEditHintHook(binaryPath) {
|
|
|
548
589
|
export function vexpHintHookScript(binaryPath, agent = "claude-code") {
|
|
549
590
|
return VEXP_HINT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/")).replace("__VEXP_AGENT__", agent);
|
|
550
591
|
}
|
|
592
|
+
/** Bake the binary path into the subagent hook script. */
|
|
593
|
+
export function vexpSubagentHookScript(binaryPath) {
|
|
594
|
+
return VEXP_SUBAGENT_HOOK.replace("__VEXP_BIN__", upperDriveLetter(binaryPath).replace(/\\/g, "/"));
|
|
595
|
+
}
|
|
551
596
|
/**
|
|
552
597
|
* Windows twin of the hint hook, for Codex. A Windows user with neither Git
|
|
553
598
|
* Bash nor WSL has no `bash`, so `bash "..."` failed on every prompt and
|
|
@@ -611,7 +656,7 @@ export function codexWindowsHookCommand(cmdPath) {
|
|
|
611
656
|
* the script is a cross-OS wrapper that exits silently when anything is
|
|
612
657
|
* missing.
|
|
613
658
|
*/
|
|
614
|
-
export const VEXP_SEARCH_HOOK = `#!/bin/
|
|
659
|
+
export const VEXP_SEARCH_HOOK = `#!/bin/sh
|
|
615
660
|
# vexp-search: answer a grep with structure instead of lines. Fails open.
|
|
616
661
|
VEXP_BIN="__VEXP_BIN__"
|
|
617
662
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
@@ -629,7 +674,7 @@ export function vexpSearchHookScript(binaryPath) {
|
|
|
629
674
|
* imports. FAIL-OPEN by construction: no binary / no daemon / timeout =
|
|
630
675
|
* silent exit 0 = normal stop.
|
|
631
676
|
*/
|
|
632
|
-
export const VEXP_STOP_GATE_HOOK = `#!/bin/
|
|
677
|
+
export const VEXP_STOP_GATE_HOOK = `#!/bin/sh
|
|
633
678
|
# vexp-verify: mechanical completion gate on Stop (Horizon). Fails open.
|
|
634
679
|
VEXP_BIN="__VEXP_BIN__"
|
|
635
680
|
[ -x "$VEXP_BIN" ] || exit 0
|
|
@@ -647,7 +692,7 @@ export function vexpStopGateHookScript(binaryPath) {
|
|
|
647
692
|
* orientation) and re-injects one compact context paragraph. FAIL-OPEN:
|
|
648
693
|
* no binary / no daemon / fresh start = silent exit 0.
|
|
649
694
|
*/
|
|
650
|
-
export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/
|
|
695
|
+
export const VEXP_SESSION_CONTEXT_HOOK = `#!/bin/sh
|
|
651
696
|
# vexp-restore: context lifecycle restore on SessionStart (compact/resume). Fails open.
|
|
652
697
|
VEXP_BIN="__VEXP_BIN__"
|
|
653
698
|
[ -x "$VEXP_BIN" ] || exit 0
|