vexp-cli 2.6.3 → 2.7.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.
@@ -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, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
13
+ import { VEXP_GUARD_HOOK, VEXP_OPENCODE_GUARD, VEXP_CURSOR_GUARD, vexpHintHookScript, vexpSearchHookScript, vexpHintHookCmdScript, vexpStopGateHookScript, vexpSessionContextHookScript, vexpOpencodeHintPlugin } from "./hook-template.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Constants
16
16
  // ---------------------------------------------------------------------------
@@ -403,6 +403,7 @@ export function configureAgents(workspaceRoot, binaryPath, version, agentFilter,
403
403
  // Event-driven hint hook: default-on (non-blocking, fail-open — the
404
404
  // 2.3 A2 opt-in applies to DENY hooks, this one cannot block).
405
405
  const hintResult = installClaudeCodeHintHook(workspaceRoot, binaryPath);
406
+ installClaudeCodeSearchHook(workspaceRoot, binaryPath);
406
407
  installClaudeCodeStopGate(workspaceRoot, binaryPath);
407
408
  installClaudeCodeSessionContext(workspaceRoot, binaryPath);
408
409
  if (hintResult) {
@@ -2026,6 +2027,69 @@ function isVexpHintHookEntry(h) {
2026
2027
  * opt-in applies to DENY hooks, which add a failure mode; this one cannot
2027
2028
  * block anything).
2028
2029
  */
2030
+ /** Does this PreToolUse entry belong to us? */
2031
+ function isVexpSearchHookEntry(h) {
2032
+ if (!h || typeof h !== "object")
2033
+ return false;
2034
+ const hks = h.hooks;
2035
+ return Array.isArray(hks) && hks.some((hook) => typeof hook?.command === "string" &&
2036
+ hook.command.includes("vexp-search"));
2037
+ }
2038
+ /**
2039
+ * v4 M1: install the search answer as a PreToolUse hook on Bash.
2040
+ *
2041
+ * Matcher is Bash and nothing else. The hook rewrites only a recursive grep
2042
+ * for names this index defines, keeps the original command after a `||`, and
2043
+ * prints nothing at all in every other case — so a session where vexp has no
2044
+ * opinion is byte-for-byte a session without vexp.
2045
+ *
2046
+ * Why a hook rather than a tool: 9 of 227 measured agent sessions called the
2047
+ * MCP tools. All 227 called Bash.
2048
+ */
2049
+ export function installClaudeCodeSearchHook(workspaceRoot, binaryPath) {
2050
+ const hookDir = path.join(workspaceRoot, ".claude", "hooks");
2051
+ const hookPath = path.join(hookDir, "vexp-search.sh");
2052
+ const settingsPath = path.join(workspaceRoot, ".claude", "settings.json");
2053
+ const script = vexpSearchHookScript(binaryPath);
2054
+ fs.mkdirSync(hookDir, { recursive: true });
2055
+ const existed = fs.existsSync(hookPath);
2056
+ const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
2057
+ if (!scriptIdentical)
2058
+ fs.writeFileSync(hookPath, script, { mode: 0o755 });
2059
+ const read = readJsonConfigSafe(settingsPath);
2060
+ if (!read.ok) {
2061
+ warnUnparseable(settingsPath);
2062
+ return scriptIdentical ? null : existed ? "updated" : "created";
2063
+ }
2064
+ const settings = read.data;
2065
+ const hooks = (settings.hooks ?? {});
2066
+ const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
2067
+ const filtered = existing.filter((h) => !isVexpSearchHookEntry(h));
2068
+ // Same shell-form-with-quoted-path shape as every other hook we write: exec
2069
+ // form cannot run a .sh on Windows, and an unquoted path word-splits on
2070
+ // "C:\Program Files" (both learned the hard way, c4a0b9e).
2071
+ filtered.push({
2072
+ matcher: "Bash",
2073
+ hooks: [
2074
+ {
2075
+ type: "command",
2076
+ command: 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-search.sh"',
2077
+ timeout: 10,
2078
+ },
2079
+ ],
2080
+ });
2081
+ const merged = { ...hooks, PreToolUse: filtered };
2082
+ const settingsIdentical = JSON.stringify(merged) === JSON.stringify(settings.hooks ?? {});
2083
+ if (scriptIdentical && settingsIdentical)
2084
+ return null;
2085
+ settings.hooks = merged;
2086
+ if (!settingsIdentical) {
2087
+ if (read.existed)
2088
+ backupConfig(settingsPath);
2089
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2090
+ }
2091
+ return existed ? "updated" : "created";
2092
+ }
2029
2093
  export function installClaudeCodeHintHook(workspaceRoot, binaryPath) {
2030
2094
  const hookDir = path.join(workspaceRoot, ".claude", "hooks");
2031
2095
  const hookPath = path.join(hookDir, "vexp-hint.sh");
@@ -2100,10 +2164,33 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
2100
2164
  const script = vexpHintHookScript(binaryPath);
2101
2165
  fs.mkdirSync(dir, { recursive: true });
2102
2166
  const existed = fs.existsSync(hookPath);
2103
- const scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
2167
+ let scriptIdentical = existed && fs.readFileSync(hookPath, "utf-8") === script;
2104
2168
  if (!scriptIdentical) {
2105
2169
  fs.writeFileSync(hookPath, script, { mode: 0o755 });
2106
2170
  }
2171
+ // Windows gets a batch twin. Codex runs a hook command through
2172
+ // `cmd.exe /C` (COMSPEC, codex-rs/hooks command_runner), so `bash "..."`
2173
+ // reaches a shell that has never heard of bash: a user with neither Git
2174
+ // Bash nor WSL got "bash not found" on every prompt and orientation was
2175
+ // inert. Codex's own answer is `commandWindows`, an optional per-OS
2176
+ // override on the command handler (openai/codex#22159, ~0.131, months
2177
+ // before the version that requires the nested `hooks` shape we already
2178
+ // write). Older builds ignore the field rather than reject the file —
2179
+ // the handler is not deny_unknown_fields, unlike the top level.
2180
+ const cmdPath = path.join(dir, "vexp-hint.cmd");
2181
+ let cmdCommand;
2182
+ if (process.platform === "win32") {
2183
+ const cmdScript = vexpHintHookCmdScript(binaryPath);
2184
+ const cmdExisted = fs.existsSync(cmdPath);
2185
+ if (!cmdExisted || fs.readFileSync(cmdPath, "utf-8") !== cmdScript) {
2186
+ fs.writeFileSync(cmdPath, cmdScript);
2187
+ scriptIdentical = false;
2188
+ }
2189
+ // Quoted: Codex hands `cmd /C` the whole line wrapped in one more pair
2190
+ // of quotes, and cmd then strips the outermost pair — which leaves this
2191
+ // path quoted and therefore safe to contain spaces.
2192
+ cmdCommand = `"${cmdPath}"`;
2193
+ }
2107
2194
  let root = {};
2108
2195
  if (fs.existsSync(hooksJsonPath)) {
2109
2196
  try {
@@ -2141,6 +2228,7 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
2141
2228
  {
2142
2229
  type: "command",
2143
2230
  command: `bash "${hookPath.replace(/\\/g, "/")}"`,
2231
+ ...(cmdCommand ? { commandWindows: cmdCommand } : {}),
2144
2232
  timeout: 5,
2145
2233
  },
2146
2234
  ],
package/dist/doctor.js CHANGED
@@ -103,6 +103,47 @@ function isAlive(pid) {
103
103
  return false;
104
104
  }
105
105
  }
106
+ /**
107
+ * The git-hooks verdict, as data.
108
+ *
109
+ * Extracted because it was wrong in a way no test could catch: an overriding
110
+ * `core.hooksPath` was reported as a FAILURE claiming "the index does not
111
+ * refresh on commit/merge/checkout", and the remedy offered was to edit a
112
+ * file that moon, husky and lefthook all regenerate. The claim is untrue —
113
+ * the daemon watches the tree live and reconciles it against disk every five
114
+ * minutes — and the remedy was unusable. A user spent a round trip on it.
115
+ */
116
+ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
117
+ const ourHooksDir = path.join(repoRoot, ".git", "hooks");
118
+ if (!hooksPath) {
119
+ if (installedCount === 3) {
120
+ return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
121
+ }
122
+ if (installedCount === 0) {
123
+ return { level: OK, message: "no vexp git hooks (index refreshes on demand)" };
124
+ }
125
+ return {
126
+ level: WARN,
127
+ message: `only ${installedCount}/3 vexp git hooks present — re-run 'vexp hooks install'`,
128
+ };
129
+ }
130
+ const resolved = path.isAbsolute(hooksPath) ? hooksPath : path.join(repoRoot, hooksPath);
131
+ if (path.resolve(resolved) === path.resolve(ourHooksDir)) {
132
+ return {
133
+ level: OK,
134
+ message: `core.hooksPath points at .git/hooks (${installedCount}/3 vexp hooks present)`,
135
+ };
136
+ }
137
+ if (installedCount === 0) {
138
+ return { level: OK, message: `core.hooksPath = ${resolved} (no vexp git hooks installed here)` };
139
+ }
140
+ return {
141
+ level: WARN,
142
+ message: `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installedCount} vexp hook(s) in .git/hooks never run.\n` +
143
+ ` the index still refreshes: the daemon watches the tree live and reconciles it against disk every 5 minutes (VEXP_RECONCILE_INTERVAL_SECS).\n` +
144
+ ` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
145
+ };
146
+ }
106
147
  export async function runDoctor() {
107
148
  const home = vexpHome();
108
149
  let warns = 0;
@@ -149,7 +190,26 @@ export async function runDoctor() {
149
190
  : st.compressor ?? "unknown (older daemon)";
150
191
  line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
151
192
  if (st.llm_configured_but_inactive === true) {
152
- line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
193
+ // The daemon knows WHY, and doctor was throwing it away: it printed
194
+ // the same "restart to load the model" the sidebar prints, to a user
195
+ // whose model fails on every start. Restarting is advice only when
196
+ // the cause can change between starts.
197
+ const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
198
+ const restartIsPointless = why !== undefined &&
199
+ /vanilla build|model load failed|incomplete|did not survive|worker did not start/i.test(why);
200
+ // A daemon still running its startup sync has not REACHED the model
201
+ // yet. Restarting there is worse than useless: it starts the sync
202
+ // over, and the user never arrives.
203
+ const stillStarting = why !== undefined && /still completing its startup/i.test(why);
204
+ line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed.` +
205
+ (why ? `\n reason: ${why}` : "") +
206
+ (stillStarting
207
+ ? `\n wait for the sync to finish — do NOT restart, that begins it again` +
208
+ `\n the daemon logs 'LlmWorker active' when the model is up`
209
+ : restartIsPointless
210
+ ? `\n restarting will NOT change this — fix the cause above`
211
+ : `\n run 'vexp daemon-cmd restart' to load the model`) +
212
+ `\n daemon: v${st.daemon_version ?? "?"} at ${st.workspace_root ?? "?"}`);
153
213
  }
154
214
  // 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
155
215
  // serving the OLD feature set and its gaps read as product bugs
@@ -435,27 +495,8 @@ export async function runDoctor() {
435
495
  return false;
436
496
  }
437
497
  });
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
- }
498
+ const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length);
499
+ line(verdict.level, verdict.message);
459
500
  }
460
501
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
461
502
  {
@@ -578,12 +619,23 @@ export async function runDoctor() {
578
619
  else if (!fs.existsSync(scriptPath)) {
579
620
  line(BAD, "hooks.json points at .codex/vexp-hint.sh but the script is missing — the hook fails on every prompt.");
580
621
  }
622
+ else if (process.platform === "win32" && typeof hook.commandWindows !== "string") {
623
+ // On Windows Codex runs the command through cmd.exe, which has no
624
+ // bash: without the commandWindows override the entry is inert on
625
+ // every prompt unless Git Bash happens to be on PATH. Probing it
626
+ // through bash here would hide exactly that.
627
+ line(BAD, "the entry has no 'commandWindows' — Codex runs hooks through cmd.exe on Windows, which cannot run the bash script, so orientation never fires (unless Git Bash is on PATH). Re-run 'vexp setup' to write the batch twin.");
628
+ }
581
629
  else {
582
- // Run it. The script bakes an absolute path to the vexp binary and
583
- // exits 0 when that path is not executable, so a stale or wrong-profile
584
- // path leaves NO trace anywhere: no orientation, no error, forever.
585
- // A Windows user found exactly that by reading the generated file.
586
- const r = spawnSync(process.platform === "win32" ? "bash" : "sh", ["-c", hook.command], {
630
+ // Run it, through the same shell Codex would use: cmd.exe on
631
+ // Windows (COMSPEC), sh elsewhere. The script bakes an absolute path
632
+ // to the vexp binary and exits 0 when that path is not executable, so
633
+ // a stale or wrong-profile path leaves NO trace anywhere: no
634
+ // orientation, no error, forever. A Windows user found exactly that
635
+ // by reading the generated file.
636
+ const isWin = process.platform === "win32";
637
+ const cmdLine = (isWin ? hook.commandWindows : hook.command);
638
+ const r = spawnSync(isWin ? (process.env.COMSPEC || "cmd.exe") : "sh", [isWin ? "/c" : "-c", cmdLine], {
587
639
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
588
640
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
589
641
  timeout: 10000,
@@ -591,15 +643,17 @@ export async function runDoctor() {
591
643
  });
592
644
  const baked = (() => {
593
645
  try {
594
- const m = fs.readFileSync(scriptPath, "utf-8").match(/VEXP_BIN="([^"]+)"/);
595
- return m?.[1];
646
+ // One pattern covers both twins: VEXP_BIN="path" (bash) and
647
+ // set "VEXP_BIN=path" (cmd).
648
+ const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
649
+ return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
596
650
  }
597
651
  catch {
598
652
  return undefined;
599
653
  }
600
654
  })();
601
655
  if (r.error) {
602
- line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert. On Windows this usually means bash (Git Bash) is not on PATH.`);
656
+ line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert.`);
603
657
  }
604
658
  else if (r.status !== 0) {
605
659
  line(BAD, `hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Codex continues without vexp.`);
@@ -397,6 +397,55 @@ exit 0
397
397
  export function vexpHintHookScript(binaryPath) {
398
398
  return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
399
399
  }
400
+ /**
401
+ * Windows twin of the hint hook. Codex runs a hook command through
402
+ * `cmd.exe /C` (COMSPEC) on Windows, and cmd has no `bash`: a Windows user
403
+ * with neither Git Bash nor WSL got "bash not found" on every prompt, so
404
+ * orientation was inert and nothing said so. The logic is four lines and
405
+ * none of it is shell-specific, so it translates exactly — and `exit /b 0`
406
+ * on the last line keeps the same fail-open contract as the bash twin.
407
+ *
408
+ * CRLF on purpose: a batch file with bare LF endings is parsed
409
+ * inconsistently by cmd, and this one has to run on the machines least
410
+ * likely to have a POSIX toolchain that would normalise it.
411
+ */
412
+ export const VEXP_HINT_HOOK_CMD = [
413
+ "@echo off",
414
+ "REM vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.",
415
+ 'set "VEXP_BIN=__VEXP_BIN__"',
416
+ 'if not exist "%VEXP_BIN%" exit /b 0',
417
+ '"%VEXP_BIN%" prompt-hint 2>nul',
418
+ "exit /b 0",
419
+ "",
420
+ ].join("\r\n");
421
+ /** Bake the binary path into the Windows hint hook. Backslashes stay. */
422
+ export function vexpHintHookCmdScript(binaryPath) {
423
+ return VEXP_HINT_HOOK_CMD.replace("__VEXP_BIN__", binaryPath.replace(/\//g, "\\"));
424
+ }
425
+ /**
426
+ * v4 M1: the search answer (PreToolUse on Bash).
427
+ *
428
+ * Of 227 measured agent sessions with vexp installed, 9 called it — 4%. Bash
429
+ * is called in all of them. So this stops asking the model to adopt anything:
430
+ * when it is about to grep the repository for a name the index knows, the
431
+ * command is rewritten to ask vexp first, with the original grep preserved
432
+ * after a `||` so the failure mode is exactly "vexp is not installed".
433
+ *
434
+ * All the logic is in the binary (`vexp-core search-hook`), like prompt-hint:
435
+ * the script is a cross-OS wrapper that exits silently when anything is
436
+ * missing.
437
+ */
438
+ export const VEXP_SEARCH_HOOK = `#!/bin/bash
439
+ # vexp-search: answer a grep with structure instead of lines. Fails open.
440
+ VEXP_BIN="__VEXP_BIN__"
441
+ [ -x "$VEXP_BIN" ] || exit 0
442
+ "$VEXP_BIN" search-hook 2>/dev/null
443
+ exit 0
444
+ `;
445
+ /** Bake the binary path into the search hook script. */
446
+ export function vexpSearchHookScript(binaryPath) {
447
+ return VEXP_SEARCH_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
448
+ }
400
449
  /**
401
450
  * Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
402
451
  * in the Rust binary (stop-gate): mechanical completion check via daemon,