vexp-cli 3.2.4 → 3.3.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/doctor.js CHANGED
@@ -2,15 +2,19 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import * as net from "net";
5
- import { spawnSync } from "child_process";
5
+ import { execFileSync, spawnSync } from "child_process";
6
6
  import chalk from "chalk";
7
- import { socketPathFor, socketPathMargin } from "./socket-path.js";
8
- import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath } from "./agent-config.js";
7
+ import { fnvHash, socketPathFor, socketPathMargin } from "./socket-path.js";
8
+ import { agentConsentPath, decisionFor, loadAgentConsentFile, readAgentConsent } from "./agent-consent.js";
9
+ import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath, getAgentList } from "./agent-config.js";
9
10
  import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
10
11
  import { CLI_VERSION } from "./version.js";
12
+ import { formatCheckIn, registrationState } from "./license.js";
11
13
  import { parentPid } from "./mcp-supervisor.js";
12
14
  import { slowMountNotice } from "./slow-mount.js";
13
15
  import { vexpHome, isStateHome } from "./state-home.js";
16
+ import { canonicalWorkspaceRoot } from "./socket-path.js";
17
+ import { codexHome, codexHookTrust, codexOtherConfigLayers, codexTrustCandidates, codexTrustFindings } from "./codex-trust.js";
14
18
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
15
19
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
16
20
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -18,6 +22,8 @@ import { vexpHome, isStateHome } from "./state-home.js";
18
22
  const OK = chalk.green("OK");
19
23
  const WARN = chalk.yellow("WARN");
20
24
  const BAD = chalk.red("FAIL");
25
+ // Said, not counted: a check doctor cannot make here, with nothing wrong found.
26
+ const INFO = chalk.cyan("INFO");
21
27
  /** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
22
28
  * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
23
29
  function discoverWorkspaceRoot(start) {
@@ -187,8 +193,18 @@ export function staleDaemonRemedy(exePath) {
187
193
  }
188
194
  return `run 'vexp daemon-cmd restart' to upgrade it now.`;
189
195
  }
190
- export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
196
+ export function gitHooksVerdict(hooksPath, repoRoot, installedCount,
197
+ /** The first line of .vexp/git-hooks.declined, when this clone said no to the hooks. */
198
+ declined) {
191
199
  const ourHooksDir = path.join(repoRoot, ".git", "hooks");
200
+ if (declined !== undefined && installedCount === 0) {
201
+ // "Skip" in VS Code, `vexp setup --personal` or `vexp hooks remove`: the
202
+ // daemon installs none while .vexp/git-hooks.declined is there.
203
+ return {
204
+ level: OK,
205
+ message: `no vexp git hooks: declined for this clone (${declined.trim() || "git-hooks.declined"}); the index refreshes on demand, and 'vexp hooks install' installs them`,
206
+ };
207
+ }
192
208
  if (!hooksPath) {
193
209
  if (installedCount === 3) {
194
210
  return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
@@ -257,6 +273,35 @@ export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
257
273
  `\n raise max_file_size_kb in .vexp/vexp.toml (0 = no cap) to include them, or exclude them on purpose with exclude_patterns — excluded files stop being counted here at the next reindex ('vexp index').`,
258
274
  };
259
275
  }
276
+ /**
277
+ * The partial-parse verdict, from `.vexp/coverage.json`, as data.
278
+ *
279
+ * A file the parser could only partly read is indexed, but the declarations
280
+ * in the lines it could not read are missing or misplaced. Dart classes
281
+ * written with a primary constructor (Dart 3.13) were lost this way, their
282
+ * methods indexed as top-level functions, with every status surface green
283
+ * (2026-09). Null when the list is empty or absent (an older index).
284
+ */
285
+ export function partialParseVerdict(cov, coveragePath = ".vexp/coverage.json") {
286
+ if (!cov || typeof cov !== "object")
287
+ return null;
288
+ const list = cov.partially_parsed_files;
289
+ if (!Array.isArray(list) || list.length === 0)
290
+ return null;
291
+ const files = list;
292
+ const examples = files
293
+ .slice(0, 5)
294
+ .map((f) => `${f.path} (${Number(f.lines) || 0} line(s) from line ${Number(f.first_line) || 0})`);
295
+ const more = files.length - examples.length;
296
+ return {
297
+ level: WARN,
298
+ message: `${files.length} file(s) were only partly parsed - the declarations in the lines the parser could not read are missing from impact, search and run_pipeline:\n` +
299
+ examples.map((e) => ` - ${e}`).join("\n") +
300
+ (more > 0 ? `\n ... +${more} more` : "") +
301
+ `\n full list: ${coveragePath}` +
302
+ `\n a newer vexp may read them: update, then run 'vexp index'.`,
303
+ };
304
+ }
260
305
  /**
261
306
  * The `.vscode/mcp.json` verdict for GitHub Copilot, as data.
262
307
  *
@@ -292,20 +337,22 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
292
337
  if (script && !exists(script))
293
338
  return { level: WARN, message: `.vscode/mcp.json vexp server bundle is missing: ${script} (an editor upgrade removed the old extension folder?) — re-run 'vexp setup'` };
294
339
  const pinned = typeof vexp.env?.VEXP_WORKSPACE === "string" ? vexp.env.VEXP_WORKSPACE : undefined;
295
- if (pinned && path.resolve(pinned).toLowerCase() !== path.resolve(wsRoot).toLowerCase()) {
340
+ if (pinned && !samePath(pinned, wsRoot)) {
296
341
  return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
297
342
  }
298
343
  return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
299
344
  }
300
345
  /**
301
- * How doctor runs a Codex `UserPromptSubmit` hook line: the way Codex does.
302
- * On Windows Codex hands `cmd.exe /C` the WHOLE line wrapped in one more pair
303
- * of quotes, verbatim (codex-rs/hooks engine/command_runner: `raw_arg`), and
304
- * cmd strips that outer pair — which is why the installer may quote the
305
- * script path. Passing the line as a normal argument instead lets Node escape
346
+ * How doctor runs a Codex `UserPromptSubmit` hook line through cmd.exe: the
347
+ * way Codex does when it has no session shell to use. Codex then hands
348
+ * `cmd.exe /C` the WHOLE line wrapped in one more pair of quotes, verbatim
349
+ * (codex-rs/hooks engine/command_runner: `raw_arg`), and cmd strips that
350
+ * outer pair. Passing the line as a normal argument instead lets Node escape
306
351
  * the inner quotes, and cmd then looks for a program literally named
307
352
  * `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
308
353
  * failed doctor's probe and passed once the user removed the quotes).
354
+ *
355
+ * This is the fallback, not the usual path: see codexPowerShellSpawnSpec.
309
356
  */
310
357
  export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
311
358
  if (platform === "win32") {
@@ -313,9 +360,565 @@ export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec
313
360
  }
314
361
  return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
315
362
  }
363
+ /**
364
+ * How Codex runs a hook line on Windows by default: through the session
365
+ * shell, PowerShell, as `<pwsh|powershell> -NoProfile -Command <line>` with
366
+ * ordinary argument quoting (codex-rs core/src/shell.rs derive_exec_args;
367
+ * shell_detect prefers pwsh, then Windows PowerShell). doctor used to probe
368
+ * only through cmd.exe, and passed a hook line that PowerShell merely printed.
369
+ */
370
+ export function codexPowerShellSpawnSpec(cmdLine, powershell) {
371
+ return { file: powershell, args: ["-NoProfile", "-Command", cmdLine], windowsVerbatimArguments: false };
372
+ }
373
+ /**
374
+ * The PowerShell Codex picks on Windows: pwsh (7+) when installed, else
375
+ * Windows PowerShell (shell_detect get_powershell_shell). By name first,
376
+ * then at the paths Codex falls back to.
377
+ */
378
+ function windowsPowerShell() {
379
+ const onPath = (name) => {
380
+ try {
381
+ return spawnSync("where.exe", [name], { encoding: "utf-8", windowsHide: true, timeout: 5000 }).status === 0;
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ };
387
+ if (onPath("pwsh.exe"))
388
+ return "pwsh.exe";
389
+ if (fs.existsSync("C:\\Program Files\\PowerShell\\7\\pwsh.exe"))
390
+ return "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
391
+ if (onPath("powershell.exe"))
392
+ return "powershell.exe";
393
+ return "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
394
+ }
395
+ /**
396
+ * Did the hook print its own command instead of running it? A quoted path is
397
+ * a string expression to PowerShell: it echoes the path and exits 0, and
398
+ * Codex injects that text as the context of every prompt while the script
399
+ * never runs (the 3.2.5 Windows form, `"C:\...\vexp-hint.cmd"`).
400
+ */
401
+ export function hookEchoedItself(stdout, cmdLine) {
402
+ const out = stdout.trim();
403
+ if (!out)
404
+ return false;
405
+ const bare = cmdLine.trim().replace(/^"(.*)"$/, "$1");
406
+ return out === bare || out === cmdLine.trim();
407
+ }
408
+ const GIT_BASH_KEY = "CLAUDE_CODE_GIT_BASH_PATH";
409
+ /** The folder Claude Code reads managed-settings.json from, per OS. */
410
+ function claudeManagedSettingsDir(platform = process.platform) {
411
+ if (platform === "win32")
412
+ return "C:\\Program Files\\ClaudeCode";
413
+ if (platform === "darwin")
414
+ return "/Library/Application Support/ClaudeCode";
415
+ return "/etc/claude-code";
416
+ }
417
+ /** The key in a settings file's "env" block: a string, or undefined when the
418
+ * file is absent, unparseable or does not set it. */
419
+ function settingsEnvValue(file, read) {
420
+ try {
421
+ const value = parseJsonc(read(file))?.env?.[GIT_BASH_KEY];
422
+ return typeof value === "string" ? value : undefined;
423
+ }
424
+ catch {
425
+ return undefined; // absent or unparseable: Claude Code skips it too
426
+ }
427
+ }
428
+ /**
429
+ * CLAUDE_CODE_GIT_BASH_PATH as Claude Code's Git Bash lookup sees it (see
430
+ * above): managed settings over the user settings over ~/.claude.json over
431
+ * the environment. Not read: the --settings file (doctor cannot know it),
432
+ * the registry policies and managed-settings.d drop-ins, and the project's
433
+ * own settings files, which Claude Code ignores for this key.
434
+ */
435
+ export function claudeGitBashSetting(deps = {}) {
436
+ const env = deps.env ?? process.env;
437
+ const home = deps.home ?? os.homedir();
438
+ const read = deps.read ?? ((p) => fs.readFileSync(p, "utf-8"));
439
+ const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
440
+ // Highest first: the first file that sets the key is the one that won.
441
+ for (const file of [
442
+ path.join(deps.managedDir ?? claudeManagedSettingsDir(), "managed-settings.json"),
443
+ path.join(configDir, "settings.json"),
444
+ path.join(env.CLAUDE_CONFIG_DIR || home, ".claude.json"),
445
+ ]) {
446
+ const value = settingsEnvValue(file, read);
447
+ // An empty string still overwrites the environment, and unsets the pin.
448
+ if (value !== undefined)
449
+ return value ? { value, from: `the "env" block of ${file}` } : undefined;
450
+ }
451
+ const value = env[GIT_BASH_KEY];
452
+ return value ? { value, from: `the ${GIT_BASH_KEY} environment variable` } : undefined;
453
+ }
454
+ /** The project's settings files that pin a Git Bash Claude Code never uses. */
455
+ export function projectGitBashPins(root, read = (p) => fs.readFileSync(p, "utf-8")) {
456
+ return [path.join(root, ".claude", "settings.local.json"), path.join(root, ".claude", "settings.json")].filter((file) => !!settingsEnvValue(file, read));
457
+ }
458
+ /** The one INFO line for such files, never a FAIL. */
459
+ export function projectGitBashPinNote(files, env = process.env, home = os.homedir()) {
460
+ const user = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "settings.json");
461
+ return (`${GIT_BASH_KEY} in ${files.join(" and ")} is not used: Claude Code finds its Git Bash before it reads that key from a project's settings, ` +
462
+ `so doctor ignores it too. To pin a Git Bash, set it in the "env" block of ${user}.`);
463
+ }
464
+ const STANDARD_GIT_BASH = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"];
465
+ function whereGitDefault(cwd) {
466
+ try {
467
+ const where = path.win32.join(process.env.SYSTEMROOT || "C:\\Windows", "System32", "where.exe");
468
+ return execFileSync(where, ["git"], {
469
+ cwd,
470
+ encoding: "utf8",
471
+ timeout: 5000,
472
+ windowsHide: true,
473
+ stdio: ["ignore", "pipe", "ignore"],
474
+ })
475
+ .trim()
476
+ .split(/\r?\n/)
477
+ .filter(Boolean);
478
+ }
479
+ catch {
480
+ return [];
481
+ }
482
+ }
483
+ /** Claude Code's "is this hit inside the session folder" test, path first,
484
+ * then through the real paths; an unresolvable hit counts as inside. */
485
+ function insideFolder(file, dir, realpath) {
486
+ const d = path.win32.resolve(dir).toLowerCase();
487
+ const f = path.win32.resolve(file).toLowerCase();
488
+ if (path.win32.dirname(f) === d || f.startsWith(d + "\\"))
489
+ return true;
490
+ let rd;
491
+ try {
492
+ rd = realpath(dir).toLowerCase();
493
+ }
494
+ catch {
495
+ return false;
496
+ }
497
+ let rf;
498
+ try {
499
+ rf = realpath(path.win32.dirname(path.win32.resolve(file))).toLowerCase();
500
+ }
501
+ catch {
502
+ return true;
503
+ }
504
+ return rf === rd || rf.startsWith(rd + "\\");
505
+ }
506
+ /** The Git Bash Claude Code would run hooks with on Windows (see above). */
507
+ export function resolveClaudeGitBash(deps) {
508
+ const exists = deps.exists ?? fs.existsSync;
509
+ const realpath = deps.realpath ?? ((p) => fs.realpathSync.native(p));
510
+ if (deps.setting) {
511
+ return exists(deps.setting.value)
512
+ ? { path: deps.setting.value, via: deps.setting.from }
513
+ : { path: null, via: deps.setting.from, pinnedMissing: deps.setting.value };
514
+ }
515
+ for (const p of STANDARD_GIT_BASH) {
516
+ if (exists(p))
517
+ return { path: p, via: "the standard Git for Windows folder" };
518
+ }
519
+ // Only the FIRST usable hit counts: Claude Code does not try the next one
520
+ // when that git has no bin\bash.exe beside it.
521
+ const git = (deps.whereGit ?? (() => whereGitDefault(deps.cwd)))().find((g) => exists(g) && !insideFolder(g, deps.cwd, realpath) && /\.(com|exe|bat|cmd)$/i.test(g.replace(/[. ]+$/, "")));
522
+ if (git) {
523
+ const bash = path.win32.join(git, "..", "..", "bin", "bash.exe");
524
+ if (exists(bash))
525
+ return { path: bash, via: `next to ${git}` };
526
+ }
527
+ return { path: null, via: "not found" };
528
+ }
529
+ /**
530
+ * How Claude Code runs a shell-form hook command: Git Bash on Windows, /bin/sh
531
+ * elsewhere (Node's `shell: true`), cwd = the project. Null on Windows without
532
+ * a Git Bash: doctor cannot reproduce Claude Code's shell, so it runs nothing.
533
+ */
534
+ export function claudeHookSpawnSpec(command, root, platform, gitBash, baseEnv = process.env) {
535
+ if (platform !== "win32") {
536
+ return { file: "/bin/sh", args: ["-c", command], env: { ...baseEnv, CLAUDE_PROJECT_DIR: root }, cwd: root };
537
+ }
538
+ if (!gitBash)
539
+ return null;
540
+ const env = { ...baseEnv, CLAUDE_PROJECT_DIR: root.replace(/\\/g, "/") };
541
+ // The `bash` inside the command must resolve to Git Bash too, not WSL's.
542
+ if (path.win32.isAbsolute(gitBash)) {
543
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
544
+ const dir = path.win32.dirname(gitBash);
545
+ env[key] = env[key] ? `${dir};${env[key]}` : dir;
546
+ }
547
+ return { file: gitBash, args: ["-c", command], env, cwd: root };
548
+ }
549
+ /** An entry Claude Code runs with PowerShell, which doctor does not reproduce. */
550
+ export function isPowerShellHook(h) {
551
+ const shell = h?.shell;
552
+ return typeof shell === "string" && shell.toLowerCase() === "powershell";
553
+ }
554
+ /** The doctor line for Claude Code hooks that cannot run on this PC at all. */
555
+ export function claudeShellMissing(what, lookup) {
556
+ if (lookup.pinnedMissing) {
557
+ return (`${what}: CLAUDE_CODE_GIT_BASH_PATH is set to ${lookup.pinnedMissing} (in ${lookup.via}), which does not exist — ` +
558
+ `Claude Code itself refuses to start with this setting.\n` +
559
+ ` point it at the bash.exe of Git for Windows, or remove it.`);
560
+ }
561
+ return (`${what} cannot run on this PC: on Windows Claude Code runs hooks with Git Bash, and there is none where it looks ` +
562
+ `(CLAUDE_CODE_GIT_BASH_PATH, C:\\Program Files\\Git\\bin\\bash.exe, bin\\bash.exe beside the git.exe on PATH). ` +
563
+ `It then runs them with PowerShell, which cannot run vexp's bash hooks. A 'bash' on PATH does not count: with WSL installed it is WSL's, which cannot see this project.\n` +
564
+ ` install Git for Windows (https://git-scm.com/downloads/win) or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe ` +
565
+ `(in the environment or the "env" block of ~/.claude/settings.json; a project's settings do not count), then restart Claude Code.`);
566
+ }
567
+ /**
568
+ * What the ledger adds to a failed hook probe. Its prompts arrive only
569
+ * through an agent's hook — doctor's own probe is tagged manual and never
570
+ * counted — so a count above zero means an agent's hooks reach vexp here,
571
+ * whatever the probe met. Not "on this machine": the index of a project on
572
+ * a share may also be written from another one. Only on the orientation
573
+ * probes' own failures: the prompts it counts come from UserPromptSubmit
574
+ * hooks, and next to a failure that is real (no Git Bash for Claude Code, an
575
+ * exec-form guard Windows cannot spawn) "may be the probe's alone" talked the
576
+ * reader out of the one line he had to act on (review, 2026-09-24).
577
+ */
578
+ export function hookLedgerNote(hookPrompts7d) {
579
+ if (!(hookPrompts7d > 0))
580
+ return "";
581
+ return (`\n note: the savings ledger counted ${hookPrompts7d} prompt(s) in the last 7 days that reached vexp through an agent's hook ` +
582
+ `(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.`);
583
+ }
584
+ /** The answers file when it exists but does not read as answers. */
585
+ function unreadableAnswers(file) {
586
+ // The same loader the CLI and the extension read the answers with: it
587
+ // decodes a UTF-8 BOM and UTF-16 (Windows PowerShell 5.1 writes UTF-16),
588
+ // so a file they read is never reported here as unreadable (integration
589
+ // of the 3.2.6 consent work, 2026-09-25: a UTF-16 file holding answers
590
+ // made doctor say 'could not be read' on every run).
591
+ const loaded = loadAgentConsentFile(file);
592
+ return loaded.state === "unreadable" || loaded.state === "corrupt" ? file : undefined;
593
+ }
594
+ /**
595
+ * Whether Codex is used in this project. Without `consent`, the answers are
596
+ * read from agent-consent.json, and one that is there but unreadable is
597
+ * reported rather than taken for "no answer" as readAgentConsent takes it.
598
+ */
599
+ export function codexUseHere(root, consent, exists = fs.existsSync) {
600
+ let answersUnreadable;
601
+ if (!consent) {
602
+ answersUnreadable = unreadableAnswers(agentConsentPath());
603
+ consent = readAgentConsent();
604
+ }
605
+ const decision = decisionFor(consent, root, "Codex");
606
+ // The detector's own list (codexHomeArtefacts, CODEX_HOME honoured), so
607
+ // doctor and the extension's question agree on what "Codex is here" means.
608
+ const codex = getAgentList().find((d) => d.agent === "Codex");
609
+ const onMachine = (codex?.detectAbsPaths?.() ?? []).some((p) => exists(p));
610
+ const used = exists(path.join(root, ".codex", "config.toml")) || decision === "yes";
611
+ return { used, decision, onMachine, ...(answersUnreadable && !used ? { answersUnreadable } : {}) };
612
+ }
613
+ /** No answer on record, and either Codex has run on this machine or the
614
+ * answers could not be read: vexp's Codex files here may be a working setup. */
615
+ function codexUseUnknown(use) {
616
+ return !use.used && use.decision === undefined && (use.onMachine === true || use.answersUnreadable !== undefined);
617
+ }
618
+ /** Inverse of the two TOML strings vexp writes: '...' literal, "..." basic. */
619
+ function parseTomlString(raw) {
620
+ const literal = /^'([^']*)'$/.exec(raw);
621
+ if (literal)
622
+ return literal[1];
623
+ const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(raw);
624
+ return basic ? basic[1].replace(/\\(["\\])/g, "$1") : undefined;
625
+ }
626
+ /** The project a [mcp_servers.vexp] section names, if any. Same reading as
627
+ * the extension's codexStanzaPin: the env pin, --workspace, cwd, then the
628
+ * /ws/<hash>/ route of the http transport. */
629
+ function codexSectionPin(section) {
630
+ const env = /^\s*VEXP_WORKSPACE\s*=\s*(.+?)\s*$/m.exec(section);
631
+ const fromEnv = env ? parseTomlString(env[1]) : undefined;
632
+ if (fromEnv)
633
+ return { path: fromEnv };
634
+ const args = /^\s*args\s*=\s*(\[.*\])\s*$/m.exec(section);
635
+ if (args) {
636
+ try {
637
+ const list = JSON.parse(args[1]);
638
+ const i = list.indexOf("--workspace");
639
+ if (i >= 0 && typeof list[i + 1] === "string")
640
+ return { path: list[i + 1] };
641
+ }
642
+ catch {
643
+ /* not the array vexp writes */
644
+ }
645
+ }
646
+ const cwd = /^\s*cwd\s*=\s*(.+?)\s*$/m.exec(section);
647
+ const fromCwd = cwd ? parseTomlString(cwd[1]) : undefined;
648
+ if (fromCwd)
649
+ return { path: fromCwd };
650
+ const url = /^\s*url\s*=\s*"[^"]*\/ws\/([0-9a-f]+)\/mcp"/m.exec(section);
651
+ return url ? { hash: url[1] } : {};
652
+ }
653
+ /** Does a vexp-written [mcp_servers.vexp] section name `root`? */
654
+ function codexSectionPinnedTo(section, root) {
655
+ const pin = codexSectionPin(section);
656
+ if (pin.path !== undefined)
657
+ return samePath(pin.path, root);
658
+ return pin.hash !== undefined && pin.hash === fnvHash(canonicalWorkspaceRoot(root).toLowerCase()).slice(0, 8);
659
+ }
660
+ /**
661
+ * The Codex remedies as commands that name the project. `vexp use .` and a
662
+ * bare `vexp setup` both take the folder they run in as the project, and
663
+ * doctor also runs from a subfolder (it resolves the root upwards): "run
664
+ * 'vexp use .' in this folder" from src\deep would have set Codex up for
665
+ * src\deep (review, 2026-09-24, on a share started from a subfolder).
666
+ */
667
+ export function codexUseCommand(root) {
668
+ return `vexp use ${quotedRoot(root)}`;
669
+ }
670
+ export function codexSetupCommand(root) {
671
+ return `vexp setup ${quotedRoot(root)} --agents Codex`;
672
+ }
673
+ /**
674
+ * The root as one shell argument, for the commands above. A Windows root that
675
+ * ends in a backslash — X:\ for a share mapped straight to the project, or a
676
+ * \\server\share\ root — would print as "X:\", and the Windows argument
677
+ * parser reads \" as a literal quote: vexp received `X:"` (review of the fix,
678
+ * 2026-09-24). "X:\." names the same folder. Off Windows, sh expands $ and `
679
+ * inside double quotes and reads \ and " there as escapes: those four get a
680
+ * backslash in front, so a POSIX root arrives as written.
681
+ */
682
+ function quotedRoot(root) {
683
+ if (/^(?:[A-Za-z]:\\|\\\\)/.test(root) && root.endsWith("\\"))
684
+ return `"${root}."`;
685
+ if (root.startsWith("/"))
686
+ return `"${root.replace(/[$`"\\]/g, "\\$&")}"`;
687
+ return `"${root}"`;
688
+ }
689
+ /**
690
+ * The remedy for a [mcp_servers.vexp] section with both `url` and `command`
691
+ * (Codex refuses it: "url is not supported for stdio"). The machine-wide
692
+ * section may be another project's, which did choose Codex: deleting it from
693
+ * here would break that one, so an entry pinned elsewhere is left to it.
694
+ */
695
+ export function codexBrokenStanzaRemedy(use, section, root) {
696
+ if (use.used)
697
+ return `Re-run '${codexSetupCommand(root)}' to rewrite it cleanly.`;
698
+ const pin = codexSectionPin(section);
699
+ if ((pin.path !== undefined || pin.hash !== undefined) && !codexSectionPinnedTo(section, root)) {
700
+ return `It points Codex at ${pin.path ?? "another project"}, not at this one: run 'vexp doctor' in that project to see what to do there.`;
701
+ }
702
+ const remove = "delete the [mcp_servers.vexp] section and the [mcp_servers.vexp.*] sections under it from that file";
703
+ if (codexUseUnknown(use)) {
704
+ return `No choice of Codex for this project is on record: if you use Codex here, run '${codexUseCommand(root)}' to rewrite it; if not, ${remove}.`;
705
+ }
706
+ return `Codex is not set up for this project: ${remove}.`;
707
+ }
708
+ /**
709
+ * A vexp stdio entry that names another project. Codex starts it in each
710
+ * session's folder, and a pin overrides that: sessions here query the other
711
+ * project's index. 3.2.5 pinned the project set up last; the next setup
712
+ * rewrites that without a pin. A pin made with `vexp use --pin` is the
713
+ * user's, and only `vexp use` moves it.
714
+ */
715
+ export function codexStdioPinNote(section, pin, root) {
716
+ const explicit = section.includes("pinned by 'vexp use'");
717
+ return (`the vexp entry for Codex is pinned to ${pin}, so Codex sessions in this project query that project's index, not this one's. ` +
718
+ (explicit
719
+ ? `The pin is from 'vexp use --pin': run 'vexp use --unpin' to let each session use its own folder, or '${codexUseCommand(root)} --pin' to pin it here.`
720
+ : "vexp 3.2.5 and earlier pinned the project set up last; the next 'vexp setup' or VS Code start with Codex set up rewrites it without a pin."));
721
+ }
722
+ /** The http entry routes every Codex session to one project's daemon. */
723
+ export function codexHttpPinNote(pinnedHere) {
724
+ return (`the http transport routes every Codex session to ${pinnedHere ? "this project" : "one other project"}, whatever folder it starts in: with Codex in several projects, the others query ${pinnedHere ? "this one's" : "that one's"} index. ` +
725
+ "The direct transport (the default: unset VEXP_CODEX_TRANSPORT, or set vexp.codexMcpTransport to direct in VS Code, then run setup again) starts vexp in each session's folder.");
726
+ }
727
+ /** What vexp wrote for Codex in and for this project. */
728
+ export function codexLeftovers(root, globalConfig = path.join(os.homedir(), ".codex", "config.toml")) {
729
+ const dir = path.join(root, ".codex");
730
+ const ours = ["hooks.json", "vexp-hint.sh", "vexp-hint.cmd"];
731
+ const files = [];
732
+ let foreignHooks = false;
733
+ let hooksJsonOurs = false;
734
+ try {
735
+ const obj = parseJsonc(fs.readFileSync(path.join(dir, "hooks.json"), "utf-8"));
736
+ const scan = (holder, skipHooksKey) => {
737
+ for (const [event, list] of Object.entries(holder)) {
738
+ if (skipHooksKey && event === "hooks")
739
+ continue;
740
+ if (!Array.isArray(list)) {
741
+ foreignHooks = true;
742
+ continue;
743
+ }
744
+ for (const group of list) {
745
+ for (const h of Array.isArray(group?.hooks) ? group.hooks : [group]) {
746
+ if (typeof h?.command === "string" && h.command.includes("vexp-hint"))
747
+ hooksJsonOurs = true;
748
+ else
749
+ foreignHooks = true;
750
+ }
751
+ }
752
+ }
753
+ };
754
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
755
+ const o = obj;
756
+ scan(o, true); // the flat shape of older files
757
+ if (o.hooks && typeof o.hooks === "object" && !Array.isArray(o.hooks))
758
+ scan(o.hooks, false);
759
+ else if (o.hooks !== undefined)
760
+ foreignHooks = true;
761
+ }
762
+ else
763
+ foreignHooks = true;
764
+ }
765
+ catch {
766
+ // Absent: nothing to say. Unparseable: it may be the user's; not ours to name.
767
+ if (fs.existsSync(path.join(dir, "hooks.json")))
768
+ foreignHooks = true;
769
+ }
770
+ if (hooksJsonOurs)
771
+ files.push(".codex/hooks.json");
772
+ for (const f of ["vexp-hint.sh", "vexp-hint.cmd"]) {
773
+ if (fs.existsSync(path.join(dir, f)))
774
+ files.push(`.codex/${f}`);
775
+ }
776
+ let onlyOurs = false;
777
+ try {
778
+ onlyOurs = files.length > 0 && !foreignHooks && fs.readdirSync(dir).every((e) => ours.includes(e));
779
+ }
780
+ catch {
781
+ /* no folder */
782
+ }
783
+ let stanza;
784
+ try {
785
+ const toml = fs.readFileSync(globalConfig, "utf-8");
786
+ const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[(?!mcp_servers\.vexp\.)|$)/);
787
+ // A section without the marker is hand-written: the user's, not a leftover.
788
+ if (m && m[0].includes("# vexp-managed") && codexSectionPinnedTo(m[0], root)) {
789
+ stanza = { file: globalConfig, sections: m[0].match(/^\[mcp_servers\.vexp(?:\.[A-Za-z_]+)?\]/gm) ?? ["[mcp_servers.vexp]"] };
790
+ }
791
+ }
792
+ catch {
793
+ /* no machine-wide config */
794
+ }
795
+ return { files, onlyOurs, stanza };
796
+ }
797
+ /** The line for a .codex/hooks.json that starts with a byte-order mark. */
798
+ export const CODEX_HOOKS_BOM_NOTE = ".codex/hooks.json starts with a byte-order mark (Windows PowerShell 5.1 writes one): doctor reads past it, Codex may not, and a hooks.json Codex cannot read runs none of its hooks.\n" +
799
+ " save it as UTF-8 without a BOM (in VS Code: click the encoding in the status bar, then Save with Encoding, UTF-8).";
800
+ /**
801
+ * The ways out through VS Code's question, for someone who does not use
802
+ * Codex here. 'Not here' is the answer for this project; 'Never' answers
803
+ * for Codex in every project on the machine, and in another project 3.2.5
804
+ * set up for Codex it then takes vexp's Codex files out too — doctor used to
805
+ * offer only 'Never', for a cleanup of this project (review, 2026-09-24).
806
+ * VS Code asks one question per tool (offerMachineAgents), so 'Never' here
807
+ * says nothing about any other tool; an earlier wording claimed it did,
808
+ * from the time one question named them all (review of the fix, 2026-09-24).
809
+ * 'Not here' leaves the machine-wide entry alone (releaseDeclinedAgents);
810
+ * only 'Never' removes it.
811
+ */
812
+ function vsCodeDeclines(projectFiles, stanzaFile) {
813
+ const notHere = projectFiles
814
+ ? `, or answer 'Not here' when VS Code asks: vexp then removes its files in this project itself` +
815
+ (stanzaFile ? ` (the entry in ${stanzaFile} stays: delete it as above).` : ".")
816
+ : ".";
817
+ return (notHere +
818
+ `\n 'Not here' is for this project only; 'Never' says no to Codex for every project on this machine` +
819
+ (stanzaFile ? `, and also removes the entry in ${stanzaFile}.` : "."));
820
+ }
821
+ /**
822
+ * The Codex orientation-hook verdict, as data. `run` means: probe the hook.
823
+ * Never names `vexp setup` to someone who did not choose Codex here.
824
+ */
825
+ export function codexHookVerdict(s) {
826
+ if (!s.use.used) {
827
+ const { files, onlyOurs, stanza } = s.leftovers;
828
+ if (files.length === 0 && !stanza) {
829
+ return { level: OK, message: "no vexp Codex hook here (Codex is not set up for this project)", run: false };
830
+ }
831
+ const what = [...files];
832
+ if (stanza)
833
+ what.push(`the vexp entry in ${stanza.file}, which points every Codex session on this machine at this project`);
834
+ const steps = [];
835
+ if (files.length > 0) {
836
+ const scripts = files.filter((f) => f !== ".codex/hooks.json");
837
+ steps.push(onlyOurs
838
+ ? "delete the .codex folder in this project (it holds only vexp's files)"
839
+ : `in this project's .codex folder delete ${[
840
+ ...scripts,
841
+ ...(files.includes(".codex/hooks.json") ? ["vexp's entry (the one naming vexp-hint) in hooks.json"] : []),
842
+ ].join(" and ")}; the rest of that folder is not vexp's`);
843
+ }
844
+ if (stanza) {
845
+ steps.push(`in ${stanza.file} delete the ${stanza.sections.join(" and ")} sections, each from its line in square brackets down to the next such line`);
846
+ }
847
+ if (codexUseUnknown(s.use)) {
848
+ // Possibly a Codex user of 3.2.5 or earlier: no answer existed then.
849
+ // VS Code asks only while Codex's own files are on the machine; without
850
+ // them it drops the project files silently and never asks.
851
+ const asks = s.use.onMachine === true;
852
+ return {
853
+ level: WARN,
854
+ message: `vexp set Codex up here, but no choice of Codex for this project is on record (vexp 3.2.5 and earlier did not ask), so doctor cannot tell whether you use Codex here: ${what.join("; ")}.` +
855
+ (s.use.answersUnreadable ? `\n vexp's saved answers in ${s.use.answersUnreadable} could not be read; run doctor again.` : "") +
856
+ `\n if you use Codex in this project: run '${codexUseCommand(s.root)}', which points Codex at this project and records that you use it here` +
857
+ (asks ? ", or answer 'Set up' when VS Code asks whether to set up Codex." : ".") +
858
+ `\n if you do not: ${steps.join(", then ")}` +
859
+ (asks ? vsCodeDeclines(files.length > 0, stanza?.file) : "."),
860
+ run: false,
861
+ };
862
+ }
863
+ return {
864
+ level: WARN,
865
+ message: `vexp left Codex files here although Codex is not set up for this project: ${what.join("; ")}.\n` +
866
+ ` to remove them: ${steps.join(", then ")}.`,
867
+ run: false,
868
+ };
869
+ }
870
+ if (s.hooksJson === "absent") {
871
+ return {
872
+ level: WARN,
873
+ message:
874
+ // "The tools work" only with a vexp entry seen above: Codex counts as
875
+ // used here also through its own project config or a saved "yes",
876
+ // with no vexp entry anywhere (review, 2026-09-24).
877
+ // An entry pinned to another project does not count: Codex here would
878
+ // answer from that project's index (review of the fix, 2026-09-24).
879
+ (s.mcpEntrySeen
880
+ ? "no .codex/hooks.json, but Codex is set up for this project — the tools work and the per-prompt orientation was never installed."
881
+ : s.mcpEntryElsewhere
882
+ ? `no .codex/hooks.json, but Codex is set up for this project — the per-prompt orientation was never installed, and the vexp entry for Codex above points at ${s.mcpEntryElsewhere}, not at this project.`
883
+ : "no .codex/hooks.json, but Codex is set up for this project — the per-prompt orientation was never installed, and no usable vexp entry for Codex was found above either.") +
884
+ ` Run '${codexSetupCommand(s.root)}' (needs Codex >= 0.129).`,
885
+ run: false,
886
+ };
887
+ }
888
+ if (s.hooksJson === "no-entry") {
889
+ return { level: WARN, message: `hooks.json has no vexp UserPromptSubmit entry — run '${codexSetupCommand(s.root)}'.`, run: false };
890
+ }
891
+ if (!s.scriptPresent) {
892
+ // On Windows the entry's Windows command runs, and it names the .cmd.
893
+ const script = s.platform === "win32" && s.hasCommandWindows ? "vexp-hint.cmd" : "vexp-hint.sh";
894
+ return { level: BAD, message: `hooks.json points at .codex/${script} but the script is missing — the hook fails on every prompt.`, run: false };
895
+ }
896
+ if (s.platform === "win32" && !s.hasCommandWindows) {
897
+ // Probing it through a bash here would hide exactly this.
898
+ return {
899
+ level: WARN,
900
+ message: "the vexp entry in .codex/hooks.json has no Windows command: on Windows Codex would need a bash to run the .sh script, and without one it gets no per-prompt orientation on this PC.\n" +
901
+ ` to fix it, run '${codexSetupCommand(s.root)}': it adds the Windows version of the hook (.codex/vexp-hint.cmd) to the entry.`,
902
+ run: false,
903
+ };
904
+ }
905
+ if (s.platform === "win32" && s.commandWindows !== undefined && /^"?[^"]*vexp-hint\.cmd"?$/i.test(s.commandWindows.trim())) {
906
+ // The 3.2.5 form. Codex runs hook lines in PowerShell by default, which
907
+ // reads a quoted path as a string: it prints it, exits 0, and Codex
908
+ // hands the path to the model as the context of every prompt.
909
+ return {
910
+ level: WARN,
911
+ message: "the Windows command of vexp's entry in .codex/hooks.json is the bare script path: Codex runs hooks through PowerShell on Windows, which prints that path instead of running the script, so Codex gets the path as context on every prompt and no orientation.\n" +
912
+ ` to fix it, run '${codexSetupCommand(s.root)}': it rewrites the command as cmd /d /c call "...". Then approve the changed hook once in Codex (Settings > Hooks in the VS Code extension, /hooks in a terminal).`,
913
+ run: false,
914
+ };
915
+ }
916
+ return { level: OK, message: "", run: true };
917
+ }
316
918
  function samePath(a, b) {
317
- // The daemon registry lower-cases Windows roots; compare accordingly.
318
- return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
919
+ // The daemon registry lower-cases Windows roots and spells a mapped drive
920
+ // as its share; compare accordingly.
921
+ return canonicalWorkspaceRoot(path.resolve(a)).toLowerCase() === canonicalWorkspaceRoot(path.resolve(b)).toLowerCase();
319
922
  }
320
923
  /**
321
924
  * A connected repo of a multi-repo workspace has no daemon of its own: the
@@ -362,9 +965,132 @@ export function workspaceCoverageFindings(root) {
362
965
  catch {
363
966
  continue; /* never indexed, or an index older than 2.7 */
364
967
  }
365
- const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
366
- if (v)
367
- out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
968
+ const covPath = path.join(t.dir, ".vexp", "coverage.json");
969
+ for (const v of [coverageVerdict(cov, covPath), partialParseVerdict(cov, covPath)]) {
970
+ if (v)
971
+ out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
972
+ }
973
+ }
974
+ return out;
975
+ }
976
+ /**
977
+ * The ignore-rules verdict, from the `Rules:` line of `vexp-core index
978
+ * --status`, as data.
979
+ *
980
+ * The index records which root ignore rules (.vexpignore, .gitignore,
981
+ * .ignore, .git/info/exclude, the exclude settings of vexp.toml) it last
982
+ * applied. A CLI query applies a change before it answers, except the files
983
+ * on disk the index lacks (let back in by a removed line, or created since
984
+ * the last index) when there are more than a query indexes: they wait for
985
+ * `vexp index` or the daemon, "pending". A tester who runs only the CLI had
986
+ * no way to see either state outside `index --status`.
987
+ * Null when the engine prints no such line (before 3.2.6) or did not run.
988
+ */
989
+ export function rulesVerdict(statusOut, utf16 = []) {
990
+ if (/another vexp indexer is running/.test(statusOut)) {
991
+ return { level: INFO, message: "ignore rules: not checked - an indexer is running on this workspace; run 'vexp index --status' once it is done" };
992
+ }
993
+ const found = stripAnsi(statusOut).split("\n").map((l) => l.trim()).find((l) => l.startsWith("Rules:"));
994
+ if (!found)
995
+ return null;
996
+ const text = found.replace(/^Rules:\s*/, "");
997
+ // A UTF-16 root rule file applies none of its lines, whatever an engine
998
+ // that does not check for it (before 3.3.0) reports.
999
+ if (utf16.length > 0 && !/UTF-16/.test(text)) {
1000
+ return { level: WARN, message: `ignore rules: ${utf16Notice(utf16)}` };
1001
+ }
1002
+ // Applied as they are on disk, with nothing left over; or recorded by no
1003
+ // pass yet on an index the rules still describe.
1004
+ const fine = /^applied \(/.test(text) || (/^not recorded/.test(text) && !/excluded by the current ignore rules/.test(text));
1005
+ return { level: fine ? OK : WARN, message: `ignore rules: ${text}` };
1006
+ }
1007
+ /** The root rule files, as the engine's walk reads them (rules_stamp.rs). */
1008
+ const ROOT_RULE_FILES = [".gitignore", ".ignore", ".vexpignore", ".vexp_ignore", ".git/info/exclude"];
1009
+ /**
1010
+ * The root rule files of `dir` written in UTF-16 (a byte-order mark or a
1011
+ * NUL byte): what Windows PowerShell 5.1 writes for `echo x > .vexpignore`
1012
+ * and `Out-File`. The engine reads rule files as UTF-8, so none of their
1013
+ * lines applies.
1014
+ */
1015
+ export function utf16RuleFiles(dir) {
1016
+ return ROOT_RULE_FILES.filter((name) => {
1017
+ try {
1018
+ const b = fs.readFileSync(path.join(dir, name));
1019
+ return (b.length >= 2 && ((b[0] === 0xff && b[1] === 0xfe) || (b[0] === 0xfe && b[1] === 0xff))) || b.includes(0);
1020
+ }
1021
+ catch {
1022
+ return false;
1023
+ }
1024
+ });
1025
+ }
1026
+ /** The engine's wording for {@link utf16RuleFiles} (rules_stamp.rs `utf16_notice`). */
1027
+ export function utf16Notice(files) {
1028
+ const [is, its, it] = files.length === 1 ? ["is", "its", "it"] : ["are", "their", "them"];
1029
+ return `${files.join(", ")} ${is} UTF-16; vexp reads rule files as UTF-8, so ${its} lines do not apply: save ${it} as UTF-8 (in PowerShell: Set-Content -Encoding utf8, or Out-File -Encoding utf8)`;
1030
+ }
1031
+ /**
1032
+ * The `Calls:` line of `vexp-core index --status`: files a query added
1033
+ * before it answered, whose call edges wait for the daemon's next pass or
1034
+ * `vexp index` ("who calls X" is empty for them until then). Null when the
1035
+ * line is absent (nothing waits, or an engine before 3.2.6).
1036
+ */
1037
+ export function callsVerdict(statusOut) {
1038
+ const found = stripAnsi(statusOut).split("\n").map((l) => l.trim()).find((l) => l.startsWith("Calls:"));
1039
+ if (!found)
1040
+ return null;
1041
+ return { level: WARN, message: `call edges: ${found.replace(/^Calls:\s*/, "")}` };
1042
+ }
1043
+ /**
1044
+ * The `Parser:` line of `vexp-core index --status`: a full rebuild the next
1045
+ * `vexp index` or daemon start runs once (this engine reads more of each
1046
+ * file than the one that built the index, or an older vexp install, such as
1047
+ * an editor extension not yet updated, re-read files since), or an index a
1048
+ * newer engine built, which this one answers from and leaves alone. Null
1049
+ * when the index is current or the line is absent.
1050
+ */
1051
+ export function parserVerdict(statusOut) {
1052
+ const found = stripAnsi(statusOut).split("\n").map((l) => l.trim()).find((l) => l.startsWith("Parser:"));
1053
+ if (!found)
1054
+ return null;
1055
+ const text = found.replace(/^Parser:\s*/, "");
1056
+ if (/rebuild pending/.test(text))
1057
+ return { level: WARN, message: `index parser: ${text}` };
1058
+ if (/newer vexp engine/.test(text))
1059
+ return { level: INFO, message: `index parser: ${text}` };
1060
+ return null;
1061
+ }
1062
+ /**
1063
+ * `vexp-core index --status <dir>`, run by `bin`: its stdout, or null when
1064
+ * it could not run or failed. The engine reports the index in `dir` itself
1065
+ * when `dir` holds one (not the git root above it) and creates none.
1066
+ */
1067
+ export function coreIndexStatus(bin, dir) {
1068
+ const r = spawnSync(bin, ["index", "--status", dir], { cwd: dir, timeout: 15000, encoding: "utf-8", windowsHide: true });
1069
+ return r.error || r.status !== 0 ? null : String(r.stdout ?? "");
1070
+ }
1071
+ /**
1072
+ * Ignore-rules verdicts for the workspace at `root` and every repo connected
1073
+ * to it (each has its own index), tagged with the alias when there are
1074
+ * several. `status` runs `vexp-core index --status <dir>` and returns its
1075
+ * stdout, or null when it could not run.
1076
+ */
1077
+ export function workspaceRulesFindings(root, status) {
1078
+ const repos = listWorkspaceRepos(root);
1079
+ const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
1080
+ if (!targets.some((t) => t.primary))
1081
+ targets.unshift({ alias: path.basename(root), dir: root, primary: true });
1082
+ const out = [];
1083
+ for (const t of targets) {
1084
+ // Never indexed: nothing to compare, and --status would create the database.
1085
+ if (!fs.existsSync(path.join(t.dir, ".vexp", "index.db")))
1086
+ continue;
1087
+ const text = status(t.dir);
1088
+ if (text === null)
1089
+ continue;
1090
+ for (const v of [rulesVerdict(text, utf16RuleFiles(t.dir)), callsVerdict(text), parserVerdict(text)]) {
1091
+ if (v)
1092
+ out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
1093
+ }
368
1094
  }
369
1095
  return out;
370
1096
  }
@@ -379,13 +1105,17 @@ export function workspaceCoverageFindings(root) {
379
1105
  export function ledgerFindings(ledger) {
380
1106
  const out = [];
381
1107
  const analyzed = Number(ledger.prompts_analyzed) || 0;
1108
+ const late = Number(ledger.late) || 0;
1109
+ // The parts add up to the total (silences + served + held out + late);
1110
+ // held out and late are named only when there are some.
1111
+ const held = Number(ledger.held_out) || 0;
382
1112
  if (analyzed > 0) {
383
1113
  out.push({
384
1114
  level: OK,
385
- message: `savings ledger (7d): ${analyzed} prompt(s) analyzed — ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served. Details: vexp savings`,
1115
+ message: `savings ledger (7d): ${analyzed} prompt(s) analyzed: ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served` +
1116
+ `${held > 0 ? `, ${held} held out (randomized measurement)` : ""}${late > 0 ? `, ${late} late` : ""}. Details: vexp savings`,
386
1117
  });
387
1118
  }
388
- const late = Number(ledger.late) || 0;
389
1119
  if (late > 0) {
390
1120
  out.push({
391
1121
  level: WARN,
@@ -425,6 +1155,69 @@ export function maskSecretsForReport(text) {
425
1155
  out = out.replace(ASSIGNED_SECRET, (whole, key, val) => /[$<{]/.test(val) ? whole : key + maskValue(val));
426
1156
  return out;
427
1157
  }
1158
+ /** Where the first invalid UTF-8 sequence starts (Rust's `valid_up_to`). */
1159
+ function utf8ValidUpTo(b) {
1160
+ let i = 0;
1161
+ while (i < b.length) {
1162
+ const c = b[i];
1163
+ if (c < 0x80) {
1164
+ i++;
1165
+ continue;
1166
+ }
1167
+ const len = c >= 0xc2 && c <= 0xdf ? 2 : c >= 0xe0 && c <= 0xef ? 3 : c >= 0xf0 && c <= 0xf4 ? 4 : 0;
1168
+ if (len === 0 || i + len > b.length)
1169
+ return i;
1170
+ // The second byte's range excludes overlong forms, surrogates and > U+10FFFF.
1171
+ const lo = c === 0xe0 ? 0xa0 : c === 0xf0 ? 0x90 : 0x80;
1172
+ const hi = c === 0xed ? 0x9f : c === 0xf4 ? 0x8f : 0xbf;
1173
+ if (b[i + 1] < lo || b[i + 1] > hi)
1174
+ return i;
1175
+ for (let k = 2; k < len; k++)
1176
+ if ((b[i + k] & 0xc0) !== 0x80)
1177
+ return i;
1178
+ i += len;
1179
+ }
1180
+ return i;
1181
+ }
1182
+ /**
1183
+ * vexp.toml's bytes as text, the way the engine decodes them
1184
+ * (vexp-core config.rs, decode_config_bytes): a byte-order mark is honoured
1185
+ * (UTF-8, UTF-16LE, UTF-16BE), without one the text must be UTF-8. Windows
1186
+ * PowerShell 5.1's `>` writes UTF-16LE (field report, 2026-09-24: the
1187
+ * daemon refused to start on one), which the engine now reads; the report
1188
+ * still read it as UTF-8 and would have shown support the text with a NUL
1189
+ * between every letter instead of the settings the engine runs with.
1190
+ */
1191
+ export function decodeConfigBytes(bytes) {
1192
+ const utf16 = (rest, littleEndian, encoding) => {
1193
+ if (rest.length % 2 !== 0)
1194
+ return { problem: "is UTF-16 with a truncated last character" };
1195
+ const le = littleEndian ? rest : Uint8Array.from(rest, (_, i) => rest[i ^ 1]);
1196
+ try {
1197
+ return { text: new TextDecoder("utf-16le", { fatal: true, ignoreBOM: true }).decode(le), encoding };
1198
+ }
1199
+ catch {
1200
+ return { problem: "is not valid UTF-16 text" };
1201
+ }
1202
+ };
1203
+ const utf8 = (rest, bom, encoding) => {
1204
+ try {
1205
+ return { text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(rest), encoding };
1206
+ }
1207
+ catch {
1208
+ // Worded, and the offset counted, as the engine's warning does.
1209
+ const at = utf8ValidUpTo(rest) + (bom ? 3 : 0);
1210
+ return { problem: bom ? `is not valid UTF-8 (invalid byte at offset ${at})` : `is not UTF-8 text (invalid byte at offset ${at})` };
1211
+ }
1212
+ };
1213
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
1214
+ return utf8(bytes.subarray(3), true, "UTF-8 with a byte-order mark");
1215
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
1216
+ return utf16(bytes.subarray(2), true, "UTF-16LE");
1217
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
1218
+ return utf16(bytes.subarray(2), false, "UTF-16BE");
1219
+ return utf8(bytes, false, "UTF-8");
1220
+ }
428
1221
  /**
429
1222
  * The report a tester can hand over without editing it first.
430
1223
  *
@@ -464,7 +1257,14 @@ export function buildDoctorReport(i) {
464
1257
  else
465
1258
  parts.push("```", ...i.logWarnings, "```");
466
1259
  parts.push("", "## Config (.vexp/vexp.toml)", "");
467
- parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
1260
+ if (i.configProblem) {
1261
+ parts.push(`present, but it ${i.configProblem}: the engine ignores it and runs with the defaults (save it as UTF-8)`);
1262
+ }
1263
+ else {
1264
+ if (i.configEncoding && i.configText !== null)
1265
+ parts.push(`saved as ${i.configEncoding} (the engine reads it)`, "");
1266
+ parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
1267
+ }
468
1268
  parts.push("");
469
1269
  return maskSecretsForReport(parts.join("\n"));
470
1270
  }
@@ -536,8 +1336,17 @@ export async function writeDoctorReport(o) {
536
1336
  }
537
1337
  }
538
1338
  let configText = null;
1339
+ let configProblem;
1340
+ let configEncoding;
539
1341
  try {
540
- configText = fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml"), "utf-8");
1342
+ const decoded = decodeConfigBytes(fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml")));
1343
+ if ("problem" in decoded)
1344
+ configProblem = decoded.problem;
1345
+ else {
1346
+ configText = decoded.text;
1347
+ if (decoded.encoding !== "UTF-8")
1348
+ configEncoding = decoded.encoding;
1349
+ }
541
1350
  }
542
1351
  catch {
543
1352
  /* absent: defaults */
@@ -556,6 +1365,8 @@ export async function writeDoctorReport(o) {
556
1365
  logName,
557
1366
  logWarnings,
558
1367
  configText,
1368
+ configProblem,
1369
+ configEncoding,
559
1370
  mountNotice: slowMountNotice(o.root),
560
1371
  });
561
1372
  const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
@@ -633,7 +1444,7 @@ async function doctorChecks(onWorkspace) {
633
1444
  // disagrees with this directory's natural target.
634
1445
  if (ws.source === "VEXP_WORKSPACE") {
635
1446
  const discovered = discoverWorkspaceRoot(process.cwd());
636
- if (discovered.toLowerCase() !== ws.root.toLowerCase()) {
1447
+ if (!samePath(discovered, ws.root)) {
637
1448
  line(WARN, `global VEXP_WORKSPACE env var pins this session to ${ws.root}, but this directory's natural target is ${discovered} → agents here get [] / wrong-repo results. Unset the VEXP_WORKSPACE OS/shell env var for per-session targeting.`);
638
1449
  }
639
1450
  }
@@ -657,6 +1468,9 @@ async function doctorChecks(onWorkspace) {
657
1468
  // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
658
1469
  // say "LLM enabled" while the daemon serves rule-compressed output (config
659
1470
  // race / stale daemon / non-LLM build); only the daemon knows what's active.
1471
+ // Prompts that reached vexp through an agent's hook (7 days): the hook
1472
+ // probes below weigh their own failures against it.
1473
+ let hookPrompts7d = 0;
660
1474
  if (live) {
661
1475
  const st = await queryDaemon(sock, "index_status");
662
1476
  if (st) {
@@ -724,6 +1538,7 @@ async function doctorChecks(onWorkspace) {
724
1538
  // support tickets ("is it working? it never got called").
725
1539
  const ledger = (st.ledger ?? {});
726
1540
  const analyzed = Number(ledger.prompts_analyzed) || 0;
1541
+ hookPrompts7d = analyzed;
727
1542
  for (const f of ledgerFindings(ledger))
728
1543
  line(f.level, f.message);
729
1544
  if (sessions.length > 0) {
@@ -753,6 +1568,21 @@ async function doctorChecks(onWorkspace) {
753
1568
  // repo alias so a run at the primary sees the whole workspace.
754
1569
  for (const f of workspaceCoverageFindings(ws.root))
755
1570
  line(f.level, f.message);
1571
+ // Whether each index applies the ignore rules on disk, read from the index
1572
+ // itself: the CLI-only case has no daemon to ask.
1573
+ {
1574
+ let bin = null;
1575
+ try {
1576
+ const { getBinaryPath } = await import("./binary.js");
1577
+ bin = getBinaryPath();
1578
+ }
1579
+ catch { /* no binary: no rules line */ }
1580
+ if (bin) {
1581
+ const exe = bin;
1582
+ for (const f of workspaceRulesFindings(ws.root, (dir) => coreIndexStatus(exe, dir)))
1583
+ line(f.level, f.message);
1584
+ }
1585
+ }
756
1586
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
757
1587
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
758
1588
  const entries = Object.entries(registry);
@@ -776,7 +1606,7 @@ async function doctorChecks(onWorkspace) {
776
1606
  line(BAD, `${root} is your home directory — never a workspace (its .vexp is vexp's state dir). A daemon here indexes everything under home. Run: vexp forget "${root}" (stops it, drops this row, deletes only its index files — licence, config and registry stay)`);
777
1607
  }
778
1608
  }
779
- const inReg = entries.some(([r]) => r.toLowerCase() === ws.root.toLowerCase());
1609
+ const inReg = entries.some(([r]) => samePath(r, ws.root));
780
1610
  if (!inReg)
781
1611
  line(WARN, `current workspace (${ws.root}) is NOT registered — a child here could mis-route`);
782
1612
  // Multi-daemon + global pin: catches the case the section-1 check misses
@@ -823,16 +1653,61 @@ async function doctorChecks(onWorkspace) {
823
1653
  line(OK, `${name}: valid, ~${days}d remaining`);
824
1654
  }
825
1655
  }
1656
+ // Registration: only a check-in with vexp.dev puts this machine on the
1657
+ // account's device list, and nothing long-running used to make one — a
1658
+ // license could work for a month with the dashboard reading 0 devices.
1659
+ // The stamp alone is not proof: a revocation or a device-cap refusal writes
1660
+ // it too. Registered = the stamp AND a fresh token that still verifies.
1661
+ if (fs.existsSync(path.join(home, ".vexp", "license.jwt"))) {
1662
+ const reg = registrationState();
1663
+ const retry = "run `vexp license` online to check in and see why";
1664
+ switch (reg.state) {
1665
+ case "blocked":
1666
+ line(WARN, `device: NOT registered - the license's ${reg.blocked.maxDevices} device slots are full; free one at ${reg.blocked.manageUrl}`);
1667
+ break;
1668
+ case "registered":
1669
+ line(OK, `device: registered (last check-in ${formatCheckIn(reg.at)})`);
1670
+ break;
1671
+ case "not_registered":
1672
+ line(WARN, `device: last check-in ${formatCheckIn(reg.at)} did not register this device; ${retry}`);
1673
+ break;
1674
+ case "unconfirmed":
1675
+ line(WARN, `device: vexp.dev renewed the license but has not confirmed this device on your account yet; ${retry}`);
1676
+ break;
1677
+ case "never":
1678
+ line(WARN, "device: never checked in with vexp.dev, so it is not on your account's device list yet; run `vexp license` online to register it");
1679
+ break;
1680
+ }
1681
+ }
826
1682
  // 4) Codex MCP transport stanza — global AND project-level. Codex supports
827
1683
  // per-project `.codex/config.toml`; checking only the global file made
828
1684
  // doctor report "[OK] no stanza" to users whose (working) config lives in
829
1685
  // the workspace.
830
1686
  console.log(chalk.bold("\nCodex (config.toml)"));
1687
+ // Whether this project chose Codex decides every Codex remedy below: a
1688
+ // user who did not is never sent to `vexp setup`, which on 3.2.5 set Codex
1689
+ // up again from its own leftovers.
1690
+ const codexUse = codexUseHere(ws.root);
831
1691
  const codexConfigs = [
832
1692
  { label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
833
1693
  { label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
834
1694
  ];
835
1695
  let codexStanzaSeen = false;
1696
+ // An entry Codex can start: the hook verdict below says "the tools work"
1697
+ // only after one (not after a url + command one, which Codex refuses), and
1698
+ // only when it names no project or this one — the machine-wide entry is
1699
+ // pinned to whichever project was set up last, and from any other one
1700
+ // Codex would answer from that project's index (review of the fix,
1701
+ // 2026-09-24).
1702
+ let codexMcpUsable = false;
1703
+ let codexMcpElsewhere;
1704
+ const codexEntryHere = (section) => {
1705
+ const pin = codexSectionPin(section);
1706
+ if ((pin.path === undefined && pin.hash === undefined) || codexSectionPinnedTo(section, ws.root))
1707
+ return true;
1708
+ codexMcpElsewhere ??= pin.path ?? "another project";
1709
+ return false;
1710
+ };
836
1711
  for (const { label, file } of codexConfigs) {
837
1712
  if (!fs.existsSync(file)) {
838
1713
  line(OK, `${label}: absent`);
@@ -852,15 +1727,30 @@ async function doctorChecks(onWorkspace) {
852
1727
  codexStanzaSeen = true;
853
1728
  const hasUrl = /^\s*url\s*=/m.test(section);
854
1729
  const hasCmd = /^\s*command\s*=/m.test(section);
855
- if (hasUrl && hasCmd)
856
- line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.`);
857
- else if (hasUrl)
1730
+ if (hasUrl && hasCmd) {
1731
+ line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. ` + codexBrokenStanzaRemedy(codexUse, section, ws.root));
1732
+ }
1733
+ else if (hasUrl) {
1734
+ if (codexEntryHere(section))
1735
+ codexMcpUsable = true;
858
1736
  line(OK, `${label}: transport http (url)`);
1737
+ // The URL routes to one workspace's daemon, so this form cannot follow
1738
+ // the session's folder the way the direct one does.
1739
+ if (section.includes("# vexp-managed") && codexSectionPin(section).hash !== undefined) {
1740
+ line(WARN, codexHttpPinNote(codexSectionPinnedTo(section, ws.root)));
1741
+ }
1742
+ }
859
1743
  else if (hasCmd) {
1744
+ const here = codexEntryHere(section);
1745
+ if (here)
1746
+ codexMcpUsable = true;
860
1747
  const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
861
1748
  const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
862
1749
  const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
863
- line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin — the server resolves from Codex's cwd"}`);
1750
+ line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin: the server starts in each Codex session's folder"}`);
1751
+ const pin = codexSectionPin(section).path;
1752
+ if (!here && pin !== undefined && section.includes("# vexp-managed"))
1753
+ line(WARN, codexStdioPinNote(section, pin, ws.root));
864
1754
  }
865
1755
  else
866
1756
  line(WARN, `${label}: stanza present but neither url nor command found`);
@@ -947,6 +1837,19 @@ async function doctorChecks(onWorkspace) {
947
1837
  // path containing a space fails non-blocking on every call: the guard never
948
1838
  // denies anything while the config "looks correct" and presence-only checks
949
1839
  // report healthy (Nathan, 2026-07).
1840
+ // Git Bash is looked up once, and only on Windows when a hook needs it.
1841
+ let gitBashLookup;
1842
+ const claudeGitBash = () => {
1843
+ if (!gitBashLookup) {
1844
+ gitBashLookup = resolveClaudeGitBash({ cwd: ws.root, setting: claudeGitBashSetting() });
1845
+ // A pin in the project's own settings is what someone tried; say it
1846
+ // does not count, once, where the lookup is first needed.
1847
+ const pins = projectGitBashPins(ws.root);
1848
+ if (pins.length > 0)
1849
+ line(INFO, projectGitBashPinNote(pins));
1850
+ }
1851
+ return gitBashLookup;
1852
+ };
950
1853
  console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
951
1854
  {
952
1855
  const sPath = path.join(ws.root, ".claude", "settings.json");
@@ -989,27 +1892,37 @@ async function doctorChecks(onWorkspace) {
989
1892
  if (timeoutS > 600) {
990
1893
  line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
991
1894
  }
1895
+ if (isPowerShellHook(h)) {
1896
+ line(INFO, `guard hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
1897
+ continue;
1898
+ }
992
1899
  // Run it exactly as Claude Code would: exec form = direct spawn with
993
- // the placeholder substituted by the host; shell form = a shell with
994
- // CLAUDE_PROJECT_DIR in the environment. On Windows Claude Code runs
995
- // shell-form hooks through Git Bash, so probe via bash there too —
996
- // if bash is missing, that IS the finding (Claude Code itself
997
- // requires Git Bash on Windows).
1900
+ // the placeholder substituted by the host; shell form = the shell
1901
+ // Claude Code uses (claudeHookSpawnSpec: Git Bash on Windows, never
1902
+ // a `bash` from PATH), both from the project folder.
998
1903
  const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
999
- const shell = process.platform === "win32" ? "bash" : "sh";
1000
- const r = execForm
1001
- ? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
1002
- env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
1003
- timeout: 5000,
1004
- encoding: "utf-8",
1005
- })
1006
- : spawnSync(shell, ["-c", cmd], {
1904
+ let spawned = substituted;
1905
+ let r;
1906
+ if (execForm) {
1907
+ r = spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
1007
1908
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
1909
+ cwd: ws.root,
1008
1910
  timeout: 5000,
1009
1911
  encoding: "utf-8",
1010
1912
  });
1913
+ }
1914
+ else {
1915
+ const bash = process.platform === "win32" ? claudeGitBash() : null;
1916
+ const spec = claudeHookSpawnSpec(cmd, ws.root, process.platform, bash?.path ?? null);
1917
+ if (!spec) {
1918
+ line(BAD, claudeShellMissing("the guard hook", bash));
1919
+ continue;
1920
+ }
1921
+ spawned = spec.file;
1922
+ r = spawnSync(spec.file, spec.args, { env: spec.env, cwd: spec.cwd, timeout: 5000, encoding: "utf-8", windowsHide: true });
1923
+ }
1011
1924
  if (r.error) {
1012
- line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${substituted}' — the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
1925
+ line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${spawned}' — the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
1013
1926
  }
1014
1927
  else if (r.status !== 0) {
1015
1928
  line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Claude Code treats this as a non-blocking failure, so searches proceed unguarded.`);
@@ -1052,7 +1965,12 @@ async function doctorChecks(onWorkspace) {
1052
1965
  return false;
1053
1966
  }
1054
1967
  });
1055
- const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length);
1968
+ let declined;
1969
+ try {
1970
+ declined = fs.readFileSync(path.join(ws.root, ".vexp", "git-hooks.declined"), "utf-8").split(/\r?\n/)[0];
1971
+ }
1972
+ catch { /* not declined */ }
1973
+ const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length, declined);
1056
1974
  line(verdict.level, verdict.message);
1057
1975
  }
1058
1976
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
@@ -1089,6 +2007,9 @@ async function doctorChecks(onWorkspace) {
1089
2007
  : "no .claude/settings.json (Claude Code not configured here)");
1090
2008
  }
1091
2009
  else {
2010
+ // Hooks Claude Code cannot run on this PC for want of Git Bash: one line
2011
+ // for all of them, after the loop.
2012
+ const noShell = [];
1092
2013
  for (const w of wanted) {
1093
2014
  const entries = Array.isArray(settings?.hooks?.[w.event]) ? settings.hooks[w.event] : [];
1094
2015
  const hook = entries
@@ -1103,6 +2024,13 @@ async function doctorChecks(onWorkspace) {
1103
2024
  }
1104
2025
  continue;
1105
2026
  }
2027
+ // Before the .sh check: a PowerShell entry names its own script (a
2028
+ // .ps1), and "vexp-verify.sh is missing — fails on every prompt" was
2029
+ // a FAIL for an entry that never runs that file (review, 2026-09-24).
2030
+ if (isPowerShellHook(hook)) {
2031
+ line(INFO, `${w.event} hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
2032
+ continue;
2033
+ }
1106
2034
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
1107
2035
  if (!fs.existsSync(scriptPath)) {
1108
2036
  line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — the hook fails on every prompt.`);
@@ -1114,17 +2042,26 @@ async function doctorChecks(onWorkspace) {
1114
2042
  : w.event === "Stop"
1115
2043
  ? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
1116
2044
  : { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
1117
- const r = spawnSync(process.platform === "win32" ? "bash" : "sh", ["-c", hook.command], {
1118
- env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
2045
+ // In Claude Code's own shell (claudeHookSpawnSpec), from the project.
2046
+ const bash = process.platform === "win32" ? claudeGitBash() : null;
2047
+ const spec = claudeHookSpawnSpec(hook.command, ws.root, process.platform, bash?.path ?? null);
2048
+ if (!spec) {
2049
+ noShell.push(w.event);
2050
+ continue;
2051
+ }
2052
+ const r = spawnSync(spec.file, spec.args, {
2053
+ env: spec.env,
2054
+ cwd: spec.cwd,
1119
2055
  input: payload,
1120
2056
  timeout: 10000,
1121
2057
  encoding: "utf-8",
2058
+ windowsHide: true,
1122
2059
  });
1123
2060
  if (r.error) {
1124
- line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.`);
2061
+ line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d));
1125
2062
  }
1126
2063
  else if (r.status !== 0) {
1127
- 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.`);
2064
+ 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.` + hookLedgerNote(hookPrompts7d));
1128
2065
  }
1129
2066
  else {
1130
2067
  // Silence is a legitimate outcome for the hint hook (the classifier
@@ -1133,6 +2070,9 @@ async function doctorChecks(onWorkspace) {
1133
2070
  line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
1134
2071
  }
1135
2072
  }
2073
+ if (noShell.length > 0) {
2074
+ line(BAD, claudeShellMissing(`the ${noShell.join(", ")} hook${noShell.length > 1 ? "s" : ""}`, claudeGitBash()));
2075
+ }
1136
2076
  }
1137
2077
  }
1138
2078
  // 5b-bis) Codex orientation hook. The section above it reports Codex's MCP
@@ -1146,97 +2086,155 @@ async function doctorChecks(onWorkspace) {
1146
2086
  {
1147
2087
  const hooksJson = path.join(ws.root, ".codex", "hooks.json");
1148
2088
  const scriptPath = path.join(ws.root, ".codex", "vexp-hint.sh");
1149
- const codexMcp = (() => {
1150
- try {
1151
- return fs
1152
- .readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf-8")
1153
- .includes("vexp");
1154
- }
1155
- catch {
1156
- return false;
1157
- }
1158
- })();
1159
2089
  let cfg = null;
2090
+ let hooksRaw = "";
2091
+ // The installer's own parser: a BOM (Windows PowerShell 5.1 writes one)
2092
+ // made a valid file read as "no .codex/hooks.json" here.
1160
2093
  try {
1161
- cfg = JSON.parse(fs.readFileSync(hooksJson, "utf-8"));
2094
+ hooksRaw = fs.readFileSync(hooksJson, "utf-8");
2095
+ cfg = parseJsonc(hooksRaw);
1162
2096
  }
1163
2097
  catch { /* absent */ }
1164
- if (!cfg) {
1165
- // Only a finding for someone who actually uses Codex here.
1166
- line(codexMcp ? WARN : OK, codexMcp
1167
- ? "no .codex/hooks.json, but vexp MCP is configured for Codex — the tools work and the per-prompt orientation was never installed. Run 'vexp setup' here (needs Codex >= 0.129)."
1168
- : "no .codex/hooks.json (Codex not configured here)");
2098
+ // Two shapes exist in the wild: the installer nests under "hooks", and
2099
+ // files from earlier versions (or edited by hand) carry the event at the
2100
+ // top level. Reading only one of them would leave the other undiagnosable
2101
+ // — and the flat one is what the user who reported this had on disk.
2102
+ const events = (Array.isArray(cfg?.hooks?.UserPromptSubmit)
2103
+ ? cfg.hooks.UserPromptSubmit
2104
+ : Array.isArray(cfg?.UserPromptSubmit)
2105
+ ? cfg.UserPromptSubmit
2106
+ : []);
2107
+ const hook = events
2108
+ .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
2109
+ .find((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
2110
+ const verdict = codexHookVerdict({
2111
+ platform: process.platform,
2112
+ root: ws.root,
2113
+ use: codexUse,
2114
+ mcpEntrySeen: codexMcpUsable,
2115
+ mcpEntryElsewhere: codexMcpElsewhere,
2116
+ hooksJson: !cfg ? "absent" : hook ? "entry" : "no-entry",
2117
+ hasCommandWindows: typeof hook?.commandWindows === "string",
2118
+ commandWindows: typeof hook?.commandWindows === "string" ? hook.commandWindows : undefined,
2119
+ // The script the platform's command runs: on Windows the entry's
2120
+ // Windows command, vexp-hint.cmd.
2121
+ scriptPresent: fs.existsSync(process.platform === "win32" && typeof hook?.commandWindows === "string" ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath),
2122
+ leftovers: codexUse.used ? { files: [], onlyOurs: false } : codexLeftovers(ws.root),
2123
+ });
2124
+ // Reading past the BOM must not turn into an [OK] for a file Codex may
2125
+ // refuse: serde_json, Rust's usual JSON reader, rejects one ("expected
2126
+ // value at line 1 column 1", checked on 1.0.150), and a hooks.json Codex
2127
+ // cannot parse runs none of its hooks. Before the tolerant read above,
2128
+ // the wrong "no .codex/hooks.json" at least led to a rewrite (review of
2129
+ // the fix, 2026-09-24). The installer leaves a file that already holds
2130
+ // vexp's entry as it is, so the remedy is a re-save, not 'vexp setup'.
2131
+ if (codexUse.used && cfg && hooksRaw.charCodeAt(0) === 0xfeff)
2132
+ line(WARN, CODEX_HOOKS_BOM_NOTE);
2133
+ if (!verdict.run) {
2134
+ line(verdict.level, verdict.message);
1169
2135
  }
1170
2136
  else {
1171
- // Two shapes exist in the wild: the installer nests under "hooks", and
1172
- // files from earlier versions (or edited by hand) carry the event at the
1173
- // top level. Reading only one of them would leave the other undiagnosable
1174
- // — and the flat one is what the user who reported this had on disk.
1175
- const events = (Array.isArray(cfg?.hooks?.UserPromptSubmit)
1176
- ? cfg.hooks.UserPromptSubmit
1177
- : Array.isArray(cfg?.UserPromptSubmit)
1178
- ? cfg.UserPromptSubmit
1179
- : []);
1180
- const hook = events
1181
- .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
1182
- .find((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
1183
- if (!hook) {
1184
- line(WARN, "hooks.json has no vexp UserPromptSubmit entry — re-run 'vexp setup'.");
1185
- }
1186
- else if (!fs.existsSync(scriptPath)) {
1187
- line(BAD, "hooks.json points at .codex/vexp-hint.sh but the script is missing — the hook fails on every prompt.");
1188
- }
1189
- else if (process.platform === "win32" && typeof hook.commandWindows !== "string") {
1190
- // On Windows Codex runs the command through cmd.exe, which has no
1191
- // bash: without the commandWindows override the entry is inert on
1192
- // every prompt unless Git Bash happens to be on PATH. Probing it
1193
- // through bash here would hide exactly that.
1194
- 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.");
1195
- }
1196
- else {
1197
- // Run it, through the same shell Codex would use — cmd.exe on
1198
- // Windows (COMSPEC) with the line wrapped the way Codex wraps it, sh
1199
- // elsewhere (codexHookSpawnSpec). The script bakes an absolute path
1200
- // to the vexp binary and exits 0 when that path is not executable, so
1201
- // a stale or wrong-profile path leaves NO trace anywhere: no
1202
- // orientation, no error, forever. A Windows user found exactly that
1203
- // by reading the generated file.
1204
- const isWin = process.platform === "win32";
1205
- const cmdLine = (isWin ? hook.commandWindows : hook.command);
1206
- const spec = codexHookSpawnSpec(cmdLine);
2137
+ // Run it, through the shells Codex would use: on Windows PowerShell
2138
+ // (its default) and cmd.exe with the line wrapped the way Codex wraps
2139
+ // it (its fallback), sh elsewhere. The script bakes an absolute path
2140
+ // to the vexp binary and exits 0 when that path is not executable, so
2141
+ // a stale or wrong-profile path leaves NO trace anywhere: no
2142
+ // orientation, no error, forever. A Windows user found exactly that
2143
+ // by reading the generated file.
2144
+ const isWin = process.platform === "win32";
2145
+ const cmdLine = (isWin ? hook.commandWindows : hook.command);
2146
+ const probes = isWin
2147
+ ? [
2148
+ { shell: "PowerShell", spec: codexPowerShellSpawnSpec(cmdLine, windowsPowerShell()) },
2149
+ { shell: "cmd.exe", spec: codexHookSpawnSpec(cmdLine) },
2150
+ ]
2151
+ : [{ shell: "sh", spec: codexHookSpawnSpec(cmdLine) }];
2152
+ const baked = (() => {
2153
+ try {
2154
+ // One pattern covers both twins: VEXP_BIN="path" (bash) and
2155
+ // set "VEXP_BIN=path" (cmd).
2156
+ const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
2157
+ return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
2158
+ }
2159
+ catch {
2160
+ return undefined;
2161
+ }
2162
+ })();
2163
+ for (const { shell, spec } of probes) {
2164
+ const via = isWin ? ` (through ${shell})` : "";
1207
2165
  const r = spawnSync(spec.file, spec.args, {
1208
2166
  windowsVerbatimArguments: spec.windowsVerbatimArguments,
1209
2167
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
2168
+ // From the project, as Codex runs it — except a \\server\share
2169
+ // root on Windows: cmd.exe cannot stand in one, prints "UNC paths are
2170
+ // not supported. Defaulting to Windows directory." on stderr and runs
2171
+ // from there anyway. Start it there directly; the hook line names its
2172
+ // script by absolute path.
2173
+ cwd: isWin && /^[\\/]{2}/.test(ws.root) ? process.env.SystemRoot || "C:\\Windows" : ws.root,
1210
2174
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
1211
2175
  timeout: 10000,
1212
2176
  encoding: "utf-8",
2177
+ windowsHide: true,
1213
2178
  });
1214
- const baked = (() => {
1215
- try {
1216
- // One pattern covers both twins: VEXP_BIN="path" (bash) and
1217
- // set "VEXP_BIN=path" (cmd).
1218
- const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
1219
- return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
1220
- }
1221
- catch {
1222
- return undefined;
1223
- }
1224
- })();
1225
2179
  if (r.error) {
1226
- line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert.`);
2180
+ line(BAD, `hook DID NOT RUN${via}: ${r.error.code ?? r.error.message}; orientation is inert.`);
1227
2181
  }
1228
2182
  else if (r.status !== 0) {
1229
- line(BAD, `hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 160)}` : ""} — Codex continues without vexp.`);
2183
+ line(BAD, `hook exited ${r.status}${via}${r.stderr ? `: ${String(r.stderr).trim().slice(0, 160)}` : ""}; Codex continues without vexp.`);
2184
+ }
2185
+ else if (hookEchoedItself(String(r.stdout ?? ""), cmdLine)) {
2186
+ line(BAD, `hook printed its own command instead of running it${via}: Codex would hand that text to the model on every prompt, and the script never runs. Re-run '${codexSetupCommand(ws.root)}'.`);
1230
2187
  }
1231
2188
  else if (baked && !fs.existsSync(baked)) {
1232
- line(BAD, `hook runs but the binary it points at does not exist: ${baked} — it exits silently on every prompt. Re-run 'vexp setup' as the user that owns this install.`);
2189
+ // The root named, as in every other Codex remedy: a bare 'vexp setup'
2190
+ // takes the folder it runs in as the project, and from src/deep it
2191
+ // set that subfolder up as a new one (review of the fix, 2026-09-24).
2192
+ line(BAD, `hook runs but the binary it points at does not exist: ${baked} — it exits silently on every prompt. Re-run '${codexSetupCommand(ws.root)}' as the user that owns this install.`);
2193
+ break;
1233
2194
  }
1234
2195
  else {
1235
2196
  const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
1236
- line(OK, `UserPromptSubmit hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
2197
+ line(OK, `UserPromptSubmit hook runs${via}${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
1237
2198
  }
1238
2199
  }
1239
2200
  }
2201
+ // Running is not enough: Codex runs a project hook only in a project it
2202
+ // trusts, and only once the user approved that exact hook (/hooks in the
2203
+ // terminal, Settings > Hooks in the VS Code extension). Both records live
2204
+ // in Codex's own config.toml; vexp reads them and never writes them.
2205
+ const nested = Array.isArray(cfg?.hooks?.UserPromptSubmit) ? cfg.hooks.UserPromptSubmit : [];
2206
+ const groupIndex = nested.findIndex((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === "string" && h.command.includes("vexp-hint")));
2207
+ if (codexUse.used && groupIndex >= 0) {
2208
+ const handlerIndex = nested[groupIndex].hooks.findIndex((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
2209
+ const home = codexHome();
2210
+ const configPath = path.join(home, "config.toml");
2211
+ let userConfig;
2212
+ try {
2213
+ userConfig = fs.readFileSync(configPath, "utf-8");
2214
+ }
2215
+ catch { /* none yet */ }
2216
+ const trust = codexHookTrust({
2217
+ platform: process.platform,
2218
+ userConfig,
2219
+ userConfigPath: configPath,
2220
+ otherLayers: codexOtherConfigLayers(),
2221
+ trustCandidates: codexTrustCandidates(ws.root, (args) => {
2222
+ try {
2223
+ const r = spawnSync("git", args, { cwd: ws.root, encoding: "utf-8", timeout: 5000, windowsHide: true });
2224
+ return r.status === 0 ? String(r.stdout).trim() || undefined : undefined;
2225
+ }
2226
+ catch {
2227
+ return undefined;
2228
+ }
2229
+ }),
2230
+ hooksJsonPath: hooksJson,
2231
+ groupIndex,
2232
+ handlerIndex,
2233
+ handler: nested[groupIndex].hooks[handlerIndex],
2234
+ });
2235
+ for (const f of codexTrustFindings(trust, ws.root, configPath, { OK, WARN }))
2236
+ line(f.level, f.message);
2237
+ }
1240
2238
  }
1241
2239
  // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
1242
2240
  // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
@@ -1380,7 +2378,9 @@ async function doctorChecks(onWorkspace) {
1380
2378
  else
1381
2379
  console.log(` ${fails > 0 ? BAD : WARN} ${fails} failure(s), ${warns} warning(s)`);
1382
2380
  if (warns > 0 || fails > 0) {
1383
- console.log(chalk.dim(" Tips: 'vexp daemon-cmd restart' restarts the workspace daemon; 'vexp setup' rewrites agent configs; restart Codex to drop a cached config."));
2381
+ // The Codex half only where Codex was chosen: to anyone else it read as
2382
+ // one more reason to set Codex up (field report, 2026-09-24).
2383
+ console.log(chalk.dim(` Tips: 'vexp daemon-cmd restart' restarts the workspace daemon; 'vexp setup' rewrites agent configs${codexUse.used ? "; restart Codex to drop a cached config" : ""}.`));
1384
2384
  }
1385
2385
  console.log("");
1386
2386
  // Scripts and CI read the exit code, not the colors: any FAIL exits 1.