vexp-cli 3.2.5 → 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,16 +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";
14
16
  import { canonicalWorkspaceRoot } from "./socket-path.js";
17
+ import { codexHome, codexHookTrust, codexOtherConfigLayers, codexTrustCandidates, codexTrustFindings } from "./codex-trust.js";
15
18
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
16
19
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
17
20
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -19,6 +22,8 @@ import { canonicalWorkspaceRoot } from "./socket-path.js";
19
22
  const OK = chalk.green("OK");
20
23
  const WARN = chalk.yellow("WARN");
21
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");
22
27
  /** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
23
28
  * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
24
29
  function discoverWorkspaceRoot(start) {
@@ -188,8 +193,18 @@ export function staleDaemonRemedy(exePath) {
188
193
  }
189
194
  return `run 'vexp daemon-cmd restart' to upgrade it now.`;
190
195
  }
191
- 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) {
192
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
+ }
193
208
  if (!hooksPath) {
194
209
  if (installedCount === 3) {
195
210
  return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
@@ -258,6 +273,35 @@ export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
258
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').`,
259
274
  };
260
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
+ }
261
305
  /**
262
306
  * The `.vscode/mcp.json` verdict for GitHub Copilot, as data.
263
307
  *
@@ -299,14 +343,16 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
299
343
  return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
300
344
  }
301
345
  /**
302
- * How doctor runs a Codex `UserPromptSubmit` hook line: the way Codex does.
303
- * On Windows Codex hands `cmd.exe /C` the WHOLE line wrapped in one more pair
304
- * of quotes, verbatim (codex-rs/hooks engine/command_runner: `raw_arg`), and
305
- * cmd strips that outer pair — which is why the installer may quote the
306
- * 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
307
351
  * the inner quotes, and cmd then looks for a program literally named
308
352
  * `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
309
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.
310
356
  */
311
357
  export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
312
358
  if (platform === "win32") {
@@ -314,6 +360,561 @@ export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec
314
360
  }
315
361
  return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
316
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
+ }
317
918
  function samePath(a, b) {
318
919
  // The daemon registry lower-cases Windows roots and spells a mapped drive
319
920
  // as its share; compare accordingly.
@@ -364,9 +965,132 @@ export function workspaceCoverageFindings(root) {
364
965
  catch {
365
966
  continue; /* never indexed, or an index older than 2.7 */
366
967
  }
367
- const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
368
- if (v)
369
- 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
+ }
370
1094
  }
371
1095
  return out;
372
1096
  }
@@ -381,13 +1105,17 @@ export function workspaceCoverageFindings(root) {
381
1105
  export function ledgerFindings(ledger) {
382
1106
  const out = [];
383
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;
384
1112
  if (analyzed > 0) {
385
1113
  out.push({
386
1114
  level: OK,
387
- 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`,
388
1117
  });
389
1118
  }
390
- const late = Number(ledger.late) || 0;
391
1119
  if (late > 0) {
392
1120
  out.push({
393
1121
  level: WARN,
@@ -427,6 +1155,69 @@ export function maskSecretsForReport(text) {
427
1155
  out = out.replace(ASSIGNED_SECRET, (whole, key, val) => /[$<{]/.test(val) ? whole : key + maskValue(val));
428
1156
  return out;
429
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
+ }
430
1221
  /**
431
1222
  * The report a tester can hand over without editing it first.
432
1223
  *
@@ -466,7 +1257,14 @@ export function buildDoctorReport(i) {
466
1257
  else
467
1258
  parts.push("```", ...i.logWarnings, "```");
468
1259
  parts.push("", "## Config (.vexp/vexp.toml)", "");
469
- 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
+ }
470
1268
  parts.push("");
471
1269
  return maskSecretsForReport(parts.join("\n"));
472
1270
  }
@@ -538,8 +1336,17 @@ export async function writeDoctorReport(o) {
538
1336
  }
539
1337
  }
540
1338
  let configText = null;
1339
+ let configProblem;
1340
+ let configEncoding;
541
1341
  try {
542
- 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
+ }
543
1350
  }
544
1351
  catch {
545
1352
  /* absent: defaults */
@@ -558,6 +1365,8 @@ export async function writeDoctorReport(o) {
558
1365
  logName,
559
1366
  logWarnings,
560
1367
  configText,
1368
+ configProblem,
1369
+ configEncoding,
561
1370
  mountNotice: slowMountNotice(o.root),
562
1371
  });
563
1372
  const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
@@ -659,6 +1468,9 @@ async function doctorChecks(onWorkspace) {
659
1468
  // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
660
1469
  // say "LLM enabled" while the daemon serves rule-compressed output (config
661
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;
662
1474
  if (live) {
663
1475
  const st = await queryDaemon(sock, "index_status");
664
1476
  if (st) {
@@ -726,6 +1538,7 @@ async function doctorChecks(onWorkspace) {
726
1538
  // support tickets ("is it working? it never got called").
727
1539
  const ledger = (st.ledger ?? {});
728
1540
  const analyzed = Number(ledger.prompts_analyzed) || 0;
1541
+ hookPrompts7d = analyzed;
729
1542
  for (const f of ledgerFindings(ledger))
730
1543
  line(f.level, f.message);
731
1544
  if (sessions.length > 0) {
@@ -755,6 +1568,21 @@ async function doctorChecks(onWorkspace) {
755
1568
  // repo alias so a run at the primary sees the whole workspace.
756
1569
  for (const f of workspaceCoverageFindings(ws.root))
757
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
+ }
758
1586
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
759
1587
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
760
1588
  const entries = Object.entries(registry);
@@ -825,16 +1653,61 @@ async function doctorChecks(onWorkspace) {
825
1653
  line(OK, `${name}: valid, ~${days}d remaining`);
826
1654
  }
827
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
+ }
828
1682
  // 4) Codex MCP transport stanza — global AND project-level. Codex supports
829
1683
  // per-project `.codex/config.toml`; checking only the global file made
830
1684
  // doctor report "[OK] no stanza" to users whose (working) config lives in
831
1685
  // the workspace.
832
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);
833
1691
  const codexConfigs = [
834
1692
  { label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
835
1693
  { label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
836
1694
  ];
837
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
+ };
838
1711
  for (const { label, file } of codexConfigs) {
839
1712
  if (!fs.existsSync(file)) {
840
1713
  line(OK, `${label}: absent`);
@@ -854,15 +1727,30 @@ async function doctorChecks(onWorkspace) {
854
1727
  codexStanzaSeen = true;
855
1728
  const hasUrl = /^\s*url\s*=/m.test(section);
856
1729
  const hasCmd = /^\s*command\s*=/m.test(section);
857
- if (hasUrl && hasCmd)
858
- line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.`);
859
- 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;
860
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
+ }
861
1743
  else if (hasCmd) {
1744
+ const here = codexEntryHere(section);
1745
+ if (here)
1746
+ codexMcpUsable = true;
862
1747
  const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
863
1748
  const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
864
1749
  const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
865
- 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));
866
1754
  }
867
1755
  else
868
1756
  line(WARN, `${label}: stanza present but neither url nor command found`);
@@ -949,6 +1837,19 @@ async function doctorChecks(onWorkspace) {
949
1837
  // path containing a space fails non-blocking on every call: the guard never
950
1838
  // denies anything while the config "looks correct" and presence-only checks
951
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
+ };
952
1853
  console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
953
1854
  {
954
1855
  const sPath = path.join(ws.root, ".claude", "settings.json");
@@ -991,27 +1892,37 @@ async function doctorChecks(onWorkspace) {
991
1892
  if (timeoutS > 600) {
992
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.`);
993
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
+ }
994
1899
  // Run it exactly as Claude Code would: exec form = direct spawn with
995
- // the placeholder substituted by the host; shell form = a shell with
996
- // CLAUDE_PROJECT_DIR in the environment. On Windows Claude Code runs
997
- // shell-form hooks through Git Bash, so probe via bash there too —
998
- // if bash is missing, that IS the finding (Claude Code itself
999
- // 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.
1000
1903
  const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
1001
- const shell = process.platform === "win32" ? "bash" : "sh";
1002
- const r = execForm
1003
- ? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
1004
- env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
1005
- timeout: 5000,
1006
- encoding: "utf-8",
1007
- })
1008
- : 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)), {
1009
1908
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
1909
+ cwd: ws.root,
1010
1910
  timeout: 5000,
1011
1911
  encoding: "utf-8",
1012
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
+ }
1013
1924
  if (r.error) {
1014
- 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'.`);
1015
1926
  }
1016
1927
  else if (r.status !== 0) {
1017
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.`);
@@ -1054,7 +1965,12 @@ async function doctorChecks(onWorkspace) {
1054
1965
  return false;
1055
1966
  }
1056
1967
  });
1057
- 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);
1058
1974
  line(verdict.level, verdict.message);
1059
1975
  }
1060
1976
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
@@ -1091,6 +2007,9 @@ async function doctorChecks(onWorkspace) {
1091
2007
  : "no .claude/settings.json (Claude Code not configured here)");
1092
2008
  }
1093
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 = [];
1094
2013
  for (const w of wanted) {
1095
2014
  const entries = Array.isArray(settings?.hooks?.[w.event]) ? settings.hooks[w.event] : [];
1096
2015
  const hook = entries
@@ -1105,6 +2024,13 @@ async function doctorChecks(onWorkspace) {
1105
2024
  }
1106
2025
  continue;
1107
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
+ }
1108
2034
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
1109
2035
  if (!fs.existsSync(scriptPath)) {
1110
2036
  line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — the hook fails on every prompt.`);
@@ -1116,17 +2042,26 @@ async function doctorChecks(onWorkspace) {
1116
2042
  : w.event === "Stop"
1117
2043
  ? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
1118
2044
  : { session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root });
1119
- const r = spawnSync(process.platform === "win32" ? "bash" : "sh", ["-c", hook.command], {
1120
- 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,
1121
2055
  input: payload,
1122
2056
  timeout: 10000,
1123
2057
  encoding: "utf-8",
2058
+ windowsHide: true,
1124
2059
  });
1125
2060
  if (r.error) {
1126
- 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));
1127
2062
  }
1128
2063
  else if (r.status !== 0) {
1129
- 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));
1130
2065
  }
1131
2066
  else {
1132
2067
  // Silence is a legitimate outcome for the hint hook (the classifier
@@ -1135,6 +2070,9 @@ async function doctorChecks(onWorkspace) {
1135
2070
  line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
1136
2071
  }
1137
2072
  }
2073
+ if (noShell.length > 0) {
2074
+ line(BAD, claudeShellMissing(`the ${noShell.join(", ")} hook${noShell.length > 1 ? "s" : ""}`, claudeGitBash()));
2075
+ }
1138
2076
  }
1139
2077
  }
1140
2078
  // 5b-bis) Codex orientation hook. The section above it reports Codex's MCP
@@ -1148,97 +2086,155 @@ async function doctorChecks(onWorkspace) {
1148
2086
  {
1149
2087
  const hooksJson = path.join(ws.root, ".codex", "hooks.json");
1150
2088
  const scriptPath = path.join(ws.root, ".codex", "vexp-hint.sh");
1151
- const codexMcp = (() => {
1152
- try {
1153
- return fs
1154
- .readFileSync(path.join(os.homedir(), ".codex", "config.toml"), "utf-8")
1155
- .includes("vexp");
1156
- }
1157
- catch {
1158
- return false;
1159
- }
1160
- })();
1161
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.
1162
2093
  try {
1163
- cfg = JSON.parse(fs.readFileSync(hooksJson, "utf-8"));
2094
+ hooksRaw = fs.readFileSync(hooksJson, "utf-8");
2095
+ cfg = parseJsonc(hooksRaw);
1164
2096
  }
1165
2097
  catch { /* absent */ }
1166
- if (!cfg) {
1167
- // Only a finding for someone who actually uses Codex here.
1168
- line(codexMcp ? WARN : OK, codexMcp
1169
- ? "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)."
1170
- : "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);
1171
2135
  }
1172
2136
  else {
1173
- // Two shapes exist in the wild: the installer nests under "hooks", and
1174
- // files from earlier versions (or edited by hand) carry the event at the
1175
- // top level. Reading only one of them would leave the other undiagnosable
1176
- // — and the flat one is what the user who reported this had on disk.
1177
- const events = (Array.isArray(cfg?.hooks?.UserPromptSubmit)
1178
- ? cfg.hooks.UserPromptSubmit
1179
- : Array.isArray(cfg?.UserPromptSubmit)
1180
- ? cfg.UserPromptSubmit
1181
- : []);
1182
- const hook = events
1183
- .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
1184
- .find((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
1185
- if (!hook) {
1186
- line(WARN, "hooks.json has no vexp UserPromptSubmit entry — re-run 'vexp setup'.");
1187
- }
1188
- else if (!fs.existsSync(scriptPath)) {
1189
- line(BAD, "hooks.json points at .codex/vexp-hint.sh but the script is missing — the hook fails on every prompt.");
1190
- }
1191
- else if (process.platform === "win32" && typeof hook.commandWindows !== "string") {
1192
- // On Windows Codex runs the command through cmd.exe, which has no
1193
- // bash: without the commandWindows override the entry is inert on
1194
- // every prompt unless Git Bash happens to be on PATH. Probing it
1195
- // through bash here would hide exactly that.
1196
- 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.");
1197
- }
1198
- else {
1199
- // Run it, through the same shell Codex would use — cmd.exe on
1200
- // Windows (COMSPEC) with the line wrapped the way Codex wraps it, sh
1201
- // elsewhere (codexHookSpawnSpec). The script bakes an absolute path
1202
- // to the vexp binary and exits 0 when that path is not executable, so
1203
- // a stale or wrong-profile path leaves NO trace anywhere: no
1204
- // orientation, no error, forever. A Windows user found exactly that
1205
- // by reading the generated file.
1206
- const isWin = process.platform === "win32";
1207
- const cmdLine = (isWin ? hook.commandWindows : hook.command);
1208
- 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})` : "";
1209
2165
  const r = spawnSync(spec.file, spec.args, {
1210
2166
  windowsVerbatimArguments: spec.windowsVerbatimArguments,
1211
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,
1212
2174
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
1213
2175
  timeout: 10000,
1214
2176
  encoding: "utf-8",
2177
+ windowsHide: true,
1215
2178
  });
1216
- const baked = (() => {
1217
- try {
1218
- // One pattern covers both twins: VEXP_BIN="path" (bash) and
1219
- // set "VEXP_BIN=path" (cmd).
1220
- const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
1221
- return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
1222
- }
1223
- catch {
1224
- return undefined;
1225
- }
1226
- })();
1227
2179
  if (r.error) {
1228
- 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.`);
1229
2181
  }
1230
2182
  else if (r.status !== 0) {
1231
- 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)}'.`);
1232
2187
  }
1233
2188
  else if (baked && !fs.existsSync(baked)) {
1234
- 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;
1235
2194
  }
1236
2195
  else {
1237
2196
  const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
1238
- 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)})` : ""}`);
1239
2198
  }
1240
2199
  }
1241
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
+ }
1242
2238
  }
1243
2239
  // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
1244
2240
  // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
@@ -1382,7 +2378,9 @@ async function doctorChecks(onWorkspace) {
1382
2378
  else
1383
2379
  console.log(` ${fails > 0 ? BAD : WARN} ${fails} failure(s), ${warns} warning(s)`);
1384
2380
  if (warns > 0 || fails > 0) {
1385
- 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" : ""}.`));
1386
2384
  }
1387
2385
  console.log("");
1388
2386
  // Scripts and CI read the exit code, not the colors: any FAIL exits 1.