vexp-cli 3.2.5 → 3.3.1

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,20 @@ 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, isVexpZcodeHookEntry, setupAgentsCommand, zcodeUserConfigPath } 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";
15
+ import { cloudSyncNotice } from "./cloud-sync.js";
13
16
  import { vexpHome, isStateHome } from "./state-home.js";
14
17
  import { canonicalWorkspaceRoot } from "./socket-path.js";
18
+ import { codexHome, codexHookTrust, codexOtherConfigLayers, codexTrustCandidates, codexTrustFindings } from "./codex-trust.js";
15
19
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
16
20
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
17
21
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -19,6 +23,8 @@ import { canonicalWorkspaceRoot } from "./socket-path.js";
19
23
  const OK = chalk.green("OK");
20
24
  const WARN = chalk.yellow("WARN");
21
25
  const BAD = chalk.red("FAIL");
26
+ // Said, not counted: a check doctor cannot make here, with nothing wrong found.
27
+ const INFO = chalk.cyan("INFO");
22
28
  /** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
23
29
  * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
24
30
  function discoverWorkspaceRoot(start) {
@@ -188,8 +194,18 @@ export function staleDaemonRemedy(exePath) {
188
194
  }
189
195
  return `run 'vexp daemon-cmd restart' to upgrade it now.`;
190
196
  }
191
- export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
197
+ export function gitHooksVerdict(hooksPath, repoRoot, installedCount,
198
+ /** The first line of .vexp/git-hooks.declined, when this clone said no to the hooks. */
199
+ declined) {
192
200
  const ourHooksDir = path.join(repoRoot, ".git", "hooks");
201
+ if (declined !== undefined && installedCount === 0) {
202
+ // "Skip" in VS Code, `vexp setup --personal` or `vexp hooks remove`: the
203
+ // daemon installs none while .vexp/git-hooks.declined is there.
204
+ return {
205
+ level: OK,
206
+ 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`,
207
+ };
208
+ }
193
209
  if (!hooksPath) {
194
210
  if (installedCount === 3) {
195
211
  return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
@@ -258,6 +274,35 @@ export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
258
274
  `\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
275
  };
260
276
  }
277
+ /**
278
+ * The partial-parse verdict, from `.vexp/coverage.json`, as data.
279
+ *
280
+ * A file the parser could only partly read is indexed, but the declarations
281
+ * in the lines it could not read are missing or misplaced. Dart classes
282
+ * written with a primary constructor (Dart 3.13) were lost this way, their
283
+ * methods indexed as top-level functions, with every status surface green
284
+ * (2026-09). Null when the list is empty or absent (an older index).
285
+ */
286
+ export function partialParseVerdict(cov, coveragePath = ".vexp/coverage.json") {
287
+ if (!cov || typeof cov !== "object")
288
+ return null;
289
+ const list = cov.partially_parsed_files;
290
+ if (!Array.isArray(list) || list.length === 0)
291
+ return null;
292
+ const files = list;
293
+ const examples = files
294
+ .slice(0, 5)
295
+ .map((f) => `${f.path} (${Number(f.lines) || 0} line(s) from line ${Number(f.first_line) || 0})`);
296
+ const more = files.length - examples.length;
297
+ return {
298
+ level: WARN,
299
+ 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` +
300
+ examples.map((e) => ` - ${e}`).join("\n") +
301
+ (more > 0 ? `\n ... +${more} more` : "") +
302
+ `\n full list: ${coveragePath}` +
303
+ `\n a newer vexp may read them: update, then run 'vexp index'.`,
304
+ };
305
+ }
261
306
  /**
262
307
  * The `.vscode/mcp.json` verdict for GitHub Copilot, as data.
263
308
  *
@@ -296,106 +341,1504 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
296
341
  if (pinned && !samePath(pinned, wsRoot)) {
297
342
  return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
298
343
  }
299
- return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
300
- }
301
- /**
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
307
- * the inner quotes, and cmd then looks for a program literally named
308
- * `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
309
- * failed doctor's probe and passed once the user removed the quotes).
310
- */
311
- export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
312
- if (platform === "win32") {
313
- return { file: comspec || "cmd.exe", args: ["/C", `"${cmdLine}"`], windowsVerbatimArguments: true };
344
+ return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
345
+ }
346
+ /**
347
+ * How doctor runs a Codex `UserPromptSubmit` hook line through cmd.exe: the
348
+ * way Codex does when it has no session shell to use. Codex then hands
349
+ * `cmd.exe /C` the WHOLE line wrapped in one more pair of quotes, verbatim
350
+ * (codex-rs/hooks engine/command_runner: `raw_arg`), and cmd strips that
351
+ * outer pair. Passing the line as a normal argument instead lets Node escape
352
+ * the inner quotes, and cmd then looks for a program literally named
353
+ * `\"D:\…\vexp-hint.cmd\"` (field report, 2026-09-01: a correct hooks.json
354
+ * failed doctor's probe and passed once the user removed the quotes).
355
+ *
356
+ * This is the fallback, not the usual path: see codexPowerShellSpawnSpec.
357
+ */
358
+ export function codexHookSpawnSpec(cmdLine, platform = process.platform, comspec = process.env.COMSPEC) {
359
+ if (platform === "win32") {
360
+ return { file: comspec || "cmd.exe", args: ["/C", `"${cmdLine}"`], windowsVerbatimArguments: true };
361
+ }
362
+ return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
363
+ }
364
+ /**
365
+ * How Codex runs a hook line on Windows by default: through the session
366
+ * shell, PowerShell, as `<pwsh|powershell> -NoProfile -Command <line>` with
367
+ * ordinary argument quoting (codex-rs core/src/shell.rs derive_exec_args;
368
+ * shell_detect prefers pwsh, then Windows PowerShell). doctor used to probe
369
+ * only through cmd.exe, and passed a hook line that PowerShell merely printed.
370
+ */
371
+ export function codexPowerShellSpawnSpec(cmdLine, powershell) {
372
+ return { file: powershell, args: ["-NoProfile", "-Command", cmdLine], windowsVerbatimArguments: false };
373
+ }
374
+ /**
375
+ * The PowerShell Codex picks on Windows: pwsh (7+) when installed, else
376
+ * Windows PowerShell (shell_detect get_powershell_shell). By name first,
377
+ * then at the paths Codex falls back to.
378
+ */
379
+ function windowsPowerShell() {
380
+ const onPath = (name) => {
381
+ try {
382
+ return spawnSync("where.exe", [name], { encoding: "utf-8", windowsHide: true, timeout: 5000 }).status === 0;
383
+ }
384
+ catch {
385
+ return false;
386
+ }
387
+ };
388
+ if (onPath("pwsh.exe"))
389
+ return "pwsh.exe";
390
+ if (fs.existsSync("C:\\Program Files\\PowerShell\\7\\pwsh.exe"))
391
+ return "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
392
+ if (onPath("powershell.exe"))
393
+ return "powershell.exe";
394
+ return "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
395
+ }
396
+ /**
397
+ * Did the hook print its own command instead of running it? A quoted path is
398
+ * a string expression to PowerShell: it echoes the path and exits 0, and
399
+ * Codex injects that text as the context of every prompt while the script
400
+ * never runs (the 3.2.5 Windows form, `"C:\...\vexp-hint.cmd"`).
401
+ */
402
+ export function hookEchoedItself(stdout, cmdLine) {
403
+ const out = stdout.trim();
404
+ if (!out)
405
+ return false;
406
+ const bare = cmdLine.trim().replace(/^"(.*)"$/, "$1");
407
+ return out === bare || out === cmdLine.trim();
408
+ }
409
+ const GIT_BASH_KEY = "CLAUDE_CODE_GIT_BASH_PATH";
410
+ /** The folder Claude Code reads managed-settings.json from, per OS. */
411
+ function claudeManagedSettingsDir(platform = process.platform) {
412
+ if (platform === "win32")
413
+ return "C:\\Program Files\\ClaudeCode";
414
+ if (platform === "darwin")
415
+ return "/Library/Application Support/ClaudeCode";
416
+ return "/etc/claude-code";
417
+ }
418
+ /** The key in a settings file's "env" block: a string, or undefined when the
419
+ * file is absent, unparseable or does not set it. */
420
+ function settingsEnvValue(file, read) {
421
+ try {
422
+ const value = parseJsonc(read(file))?.env?.[GIT_BASH_KEY];
423
+ return typeof value === "string" ? value : undefined;
424
+ }
425
+ catch {
426
+ return undefined; // absent or unparseable: Claude Code skips it too
427
+ }
428
+ }
429
+ /**
430
+ * CLAUDE_CODE_GIT_BASH_PATH as Claude Code's Git Bash lookup sees it (see
431
+ * above): managed settings over the user settings over ~/.claude.json over
432
+ * the environment. Not read: the --settings file (doctor cannot know it),
433
+ * the registry policies and managed-settings.d drop-ins, and the project's
434
+ * own settings files, which Claude Code ignores for this key.
435
+ */
436
+ export function claudeGitBashSetting(deps = {}) {
437
+ const env = deps.env ?? process.env;
438
+ const home = deps.home ?? os.homedir();
439
+ const read = deps.read ?? ((p) => fs.readFileSync(p, "utf-8"));
440
+ const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
441
+ // Highest first: the first file that sets the key is the one that won.
442
+ for (const file of [
443
+ path.join(deps.managedDir ?? claudeManagedSettingsDir(), "managed-settings.json"),
444
+ path.join(configDir, "settings.json"),
445
+ path.join(env.CLAUDE_CONFIG_DIR || home, ".claude.json"),
446
+ ]) {
447
+ const value = settingsEnvValue(file, read);
448
+ // An empty string still overwrites the environment, and unsets the pin.
449
+ if (value !== undefined)
450
+ return value ? { value, from: `the "env" block of ${file}` } : undefined;
451
+ }
452
+ const value = env[GIT_BASH_KEY];
453
+ return value ? { value, from: `the ${GIT_BASH_KEY} environment variable` } : undefined;
454
+ }
455
+ /** The project's settings files that pin a Git Bash Claude Code never uses. */
456
+ export function projectGitBashPins(root, read = (p) => fs.readFileSync(p, "utf-8")) {
457
+ return [path.join(root, ".claude", "settings.local.json"), path.join(root, ".claude", "settings.json")].filter((file) => !!settingsEnvValue(file, read));
458
+ }
459
+ /** The one INFO line for such files, never a FAIL. */
460
+ export function projectGitBashPinNote(files, env = process.env, home = os.homedir()) {
461
+ const user = path.join(env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"), "settings.json");
462
+ 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, ` +
463
+ `so doctor ignores it too. To pin a Git Bash, set it in the "env" block of ${user}.`);
464
+ }
465
+ const STANDARD_GIT_BASH = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"];
466
+ function whereGitDefault(cwd) {
467
+ try {
468
+ const where = path.win32.join(process.env.SYSTEMROOT || "C:\\Windows", "System32", "where.exe");
469
+ return execFileSync(where, ["git"], {
470
+ cwd,
471
+ encoding: "utf8",
472
+ timeout: 5000,
473
+ windowsHide: true,
474
+ stdio: ["ignore", "pipe", "ignore"],
475
+ })
476
+ .trim()
477
+ .split(/\r?\n/)
478
+ .filter(Boolean);
479
+ }
480
+ catch {
481
+ return [];
482
+ }
483
+ }
484
+ /** Claude Code's "is this hit inside the session folder" test, path first,
485
+ * then through the real paths; an unresolvable hit counts as inside. */
486
+ function insideFolder(file, dir, realpath) {
487
+ const d = path.win32.resolve(dir).toLowerCase();
488
+ const f = path.win32.resolve(file).toLowerCase();
489
+ if (path.win32.dirname(f) === d || f.startsWith(d + "\\"))
490
+ return true;
491
+ let rd;
492
+ try {
493
+ rd = realpath(dir).toLowerCase();
494
+ }
495
+ catch {
496
+ return false;
497
+ }
498
+ let rf;
499
+ try {
500
+ rf = realpath(path.win32.dirname(path.win32.resolve(file))).toLowerCase();
501
+ }
502
+ catch {
503
+ return true;
504
+ }
505
+ return rf === rd || rf.startsWith(rd + "\\");
506
+ }
507
+ /** The Git Bash Claude Code would run hooks with on Windows (see above). */
508
+ export function resolveClaudeGitBash(deps) {
509
+ const exists = deps.exists ?? fs.existsSync;
510
+ const realpath = deps.realpath ?? ((p) => fs.realpathSync.native(p));
511
+ if (deps.setting) {
512
+ return exists(deps.setting.value)
513
+ ? { path: deps.setting.value, via: deps.setting.from }
514
+ : { path: null, via: deps.setting.from, pinnedMissing: deps.setting.value };
515
+ }
516
+ for (const p of STANDARD_GIT_BASH) {
517
+ if (exists(p))
518
+ return { path: p, via: "the standard Git for Windows folder" };
519
+ }
520
+ // Only the FIRST usable hit counts: Claude Code does not try the next one
521
+ // when that git has no bin\bash.exe beside it.
522
+ const git = (deps.whereGit ?? (() => whereGitDefault(deps.cwd)))().find((g) => exists(g) && !insideFolder(g, deps.cwd, realpath) && /\.(com|exe|bat|cmd)$/i.test(g.replace(/[. ]+$/, "")));
523
+ if (git) {
524
+ const bash = path.win32.join(git, "..", "..", "bin", "bash.exe");
525
+ if (exists(bash))
526
+ return { path: bash, via: `next to ${git}` };
527
+ }
528
+ return { path: null, via: "not found" };
529
+ }
530
+ /**
531
+ * How Claude Code runs a shell-form hook command: Git Bash on Windows, /bin/sh
532
+ * elsewhere (Node's `shell: true`), cwd = the project. Null on Windows without
533
+ * a Git Bash: doctor cannot reproduce Claude Code's shell, so it runs nothing.
534
+ */
535
+ export function claudeHookSpawnSpec(command, root, platform, gitBash, baseEnv = process.env) {
536
+ if (platform !== "win32") {
537
+ return { file: "/bin/sh", args: ["-c", command], env: { ...baseEnv, CLAUDE_PROJECT_DIR: root }, cwd: root };
538
+ }
539
+ if (!gitBash)
540
+ return null;
541
+ const env = { ...baseEnv, CLAUDE_PROJECT_DIR: root.replace(/\\/g, "/") };
542
+ // The `bash` inside the command must resolve to Git Bash too, not WSL's.
543
+ if (path.win32.isAbsolute(gitBash)) {
544
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
545
+ const dir = path.win32.dirname(gitBash);
546
+ env[key] = env[key] ? `${dir};${env[key]}` : dir;
547
+ }
548
+ return { file: gitBash, args: ["-c", command], env, cwd: root };
549
+ }
550
+ /** An entry Claude Code runs with PowerShell, which doctor does not reproduce. */
551
+ export function isPowerShellHook(h) {
552
+ const shell = h?.shell;
553
+ return typeof shell === "string" && shell.toLowerCase() === "powershell";
554
+ }
555
+ /** The doctor line for Claude Code hooks that cannot run on this PC at all. */
556
+ export function claudeShellMissing(what, lookup) {
557
+ if (lookup.pinnedMissing) {
558
+ return (`${what}: CLAUDE_CODE_GIT_BASH_PATH is set to ${lookup.pinnedMissing} (in ${lookup.via}), which does not exist — ` +
559
+ `Claude Code itself refuses to start with this setting.\n` +
560
+ ` point it at the bash.exe of Git for Windows, or remove it.`);
561
+ }
562
+ return (`${what} cannot run on this PC: on Windows Claude Code runs hooks with Git Bash, and there is none where it looks ` +
563
+ `(CLAUDE_CODE_GIT_BASH_PATH, C:\\Program Files\\Git\\bin\\bash.exe, bin\\bash.exe beside the git.exe on PATH). ` +
564
+ `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` +
565
+ ` install Git for Windows (https://git-scm.com/downloads/win) or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe ` +
566
+ `(in the environment or the "env" block of ~/.claude/settings.json; a project's settings do not count), then restart Claude Code.`);
567
+ }
568
+ /**
569
+ * What the ledger adds to a failed hook probe. Its prompts arrive only
570
+ * through an agent's hook — doctor's own probe is tagged manual and never
571
+ * counted — so a count above zero means an agent's hooks reach vexp here,
572
+ * whatever the probe met. Not "on this machine": the index of a project on
573
+ * a share may also be written from another one. Only on the orientation
574
+ * probes' own failures: the prompts it counts come from UserPromptSubmit
575
+ * hooks, and next to a failure that is real (no Git Bash for Claude Code, an
576
+ * exec-form guard Windows cannot spawn) "may be the probe's alone" talked the
577
+ * reader out of the one line he had to act on (review, 2026-09-24).
578
+ */
579
+ export function hookLedgerNote(hookPrompts7d) {
580
+ if (!(hookPrompts7d > 0))
581
+ return "";
582
+ return (`\n note: the savings ledger counted ${hookPrompts7d} prompt(s) in the last 7 days that reached vexp through an agent's hook ` +
583
+ `(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.`);
584
+ }
585
+ /** The answers file when it exists but does not read as answers. */
586
+ function unreadableAnswers(file) {
587
+ // The same loader the CLI and the extension read the answers with: it
588
+ // decodes a UTF-8 BOM and UTF-16 (Windows PowerShell 5.1 writes UTF-16),
589
+ // so a file they read is never reported here as unreadable (integration
590
+ // of the 3.2.6 consent work, 2026-09-25: a UTF-16 file holding answers
591
+ // made doctor say 'could not be read' on every run).
592
+ const loaded = loadAgentConsentFile(file);
593
+ return loaded.state === "unreadable" || loaded.state === "corrupt" ? file : undefined;
594
+ }
595
+ /**
596
+ * Whether Codex is used in this project. Without `consent`, the answers are
597
+ * read from agent-consent.json, and one that is there but unreadable is
598
+ * reported rather than taken for "no answer" as readAgentConsent takes it.
599
+ */
600
+ export function codexUseHere(root, consent, exists = fs.existsSync) {
601
+ let answersUnreadable;
602
+ if (!consent) {
603
+ answersUnreadable = unreadableAnswers(agentConsentPath());
604
+ consent = readAgentConsent();
605
+ }
606
+ const decision = decisionFor(consent, root, "Codex");
607
+ // The detector's own list (codexHomeArtefacts, CODEX_HOME honoured), so
608
+ // doctor and the extension's question agree on what "Codex is here" means.
609
+ const codex = getAgentList().find((d) => d.agent === "Codex");
610
+ const onMachine = (codex?.detectAbsPaths?.() ?? []).some((p) => exists(p));
611
+ const used = exists(path.join(root, ".codex", "config.toml")) || decision === "yes";
612
+ return { used, decision, onMachine, ...(answersUnreadable && !used ? { answersUnreadable } : {}) };
613
+ }
614
+ /** No answer on record, and either Codex has run on this machine or the
615
+ * answers could not be read: vexp's Codex files here may be a working setup. */
616
+ function codexUseUnknown(use) {
617
+ return !use.used && use.decision === undefined && (use.onMachine === true || use.answersUnreadable !== undefined);
618
+ }
619
+ /** Inverse of the two TOML strings vexp writes: '...' literal, "..." basic. */
620
+ function parseTomlString(raw) {
621
+ const literal = /^'([^']*)'$/.exec(raw);
622
+ if (literal)
623
+ return literal[1];
624
+ const basic = /^"((?:[^"\\]|\\.)*)"$/.exec(raw);
625
+ return basic ? basic[1].replace(/\\(["\\])/g, "$1") : undefined;
626
+ }
627
+ /** The project a [mcp_servers.vexp] section names, if any. Same reading as
628
+ * the extension's codexStanzaPin: the env pin, --workspace, cwd, then the
629
+ * /ws/<hash>/ route of the http transport. */
630
+ function codexSectionPin(section) {
631
+ const env = /^\s*VEXP_WORKSPACE\s*=\s*(.+?)\s*$/m.exec(section);
632
+ const fromEnv = env ? parseTomlString(env[1]) : undefined;
633
+ if (fromEnv)
634
+ return { path: fromEnv };
635
+ const args = /^\s*args\s*=\s*(\[.*\])\s*$/m.exec(section);
636
+ if (args) {
637
+ try {
638
+ const list = JSON.parse(args[1]);
639
+ const i = list.indexOf("--workspace");
640
+ if (i >= 0 && typeof list[i + 1] === "string")
641
+ return { path: list[i + 1] };
642
+ }
643
+ catch {
644
+ /* not the array vexp writes */
645
+ }
646
+ }
647
+ const cwd = /^\s*cwd\s*=\s*(.+?)\s*$/m.exec(section);
648
+ const fromCwd = cwd ? parseTomlString(cwd[1]) : undefined;
649
+ if (fromCwd)
650
+ return { path: fromCwd };
651
+ const url = /^\s*url\s*=\s*"[^"]*\/ws\/([0-9a-f]+)\/mcp"/m.exec(section);
652
+ return url ? { hash: url[1] } : {};
653
+ }
654
+ /** Does a vexp-written [mcp_servers.vexp] section name `root`? */
655
+ function codexSectionPinnedTo(section, root) {
656
+ const pin = codexSectionPin(section);
657
+ if (pin.path !== undefined)
658
+ return samePath(pin.path, root);
659
+ return pin.hash !== undefined && pin.hash === fnvHash(canonicalWorkspaceRoot(root).toLowerCase()).slice(0, 8);
660
+ }
661
+ /**
662
+ * The Codex remedies as commands that name the project. `vexp use .` and a
663
+ * bare `vexp setup` both take the folder they run in as the project, and
664
+ * doctor also runs from a subfolder (it resolves the root upwards): "run
665
+ * 'vexp use .' in this folder" from src\deep would have set Codex up for
666
+ * src\deep (review, 2026-09-24, on a share started from a subfolder).
667
+ */
668
+ export function codexUseCommand(root) {
669
+ return `vexp use ${quotedRoot(root)}`;
670
+ }
671
+ export function codexSetupCommand(root) {
672
+ return `vexp setup ${quotedRoot(root)} --agents Codex`;
673
+ }
674
+ /**
675
+ * The root as one shell argument, for the commands above. A Windows root that
676
+ * ends in a backslash — X:\ for a share mapped straight to the project, or a
677
+ * \\server\share\ root — would print as "X:\", and the Windows argument
678
+ * parser reads \" as a literal quote: vexp received `X:"` (review of the fix,
679
+ * 2026-09-24). "X:\." names the same folder. Off Windows, sh expands $ and `
680
+ * inside double quotes and reads \ and " there as escapes: those four get a
681
+ * backslash in front, so a POSIX root arrives as written.
682
+ */
683
+ function quotedRoot(root) {
684
+ if (/^(?:[A-Za-z]:\\|\\\\)/.test(root) && root.endsWith("\\"))
685
+ return `"${root}."`;
686
+ if (root.startsWith("/"))
687
+ return `"${root.replace(/[$`"\\]/g, "\\$&")}"`;
688
+ return `"${root}"`;
689
+ }
690
+ /**
691
+ * The remedy for a [mcp_servers.vexp] section with both `url` and `command`
692
+ * (Codex refuses it: "url is not supported for stdio"). The machine-wide
693
+ * section may be another project's, which did choose Codex: deleting it from
694
+ * here would break that one, so an entry pinned elsewhere is left to it.
695
+ */
696
+ export function codexBrokenStanzaRemedy(use, section, root) {
697
+ if (use.used)
698
+ return `Re-run '${codexSetupCommand(root)}' to rewrite it cleanly.`;
699
+ const pin = codexSectionPin(section);
700
+ if ((pin.path !== undefined || pin.hash !== undefined) && !codexSectionPinnedTo(section, root)) {
701
+ 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.`;
702
+ }
703
+ const remove = "delete the [mcp_servers.vexp] section and the [mcp_servers.vexp.*] sections under it from that file";
704
+ if (codexUseUnknown(use)) {
705
+ 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}.`;
706
+ }
707
+ return `Codex is not set up for this project: ${remove}.`;
708
+ }
709
+ /**
710
+ * A vexp stdio entry that names another project. Codex starts it in each
711
+ * session's folder, and a pin overrides that: sessions here query the other
712
+ * project's index. 3.2.5 pinned the project set up last; the next setup
713
+ * rewrites that without a pin. A pin made with `vexp use --pin` is the
714
+ * user's, and only `vexp use` moves it.
715
+ */
716
+ export function codexStdioPinNote(section, pin, root) {
717
+ const explicit = section.includes("pinned by 'vexp use'");
718
+ 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. ` +
719
+ (explicit
720
+ ? `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.`
721
+ : "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."));
722
+ }
723
+ /** The http entry routes every Codex session to one project's daemon. */
724
+ export function codexHttpPinNote(pinnedHere) {
725
+ 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. ` +
726
+ "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.");
727
+ }
728
+ /** What vexp wrote for Codex in and for this project. */
729
+ export function codexLeftovers(root, globalConfig = path.join(os.homedir(), ".codex", "config.toml")) {
730
+ const dir = path.join(root, ".codex");
731
+ const ours = ["hooks.json", "vexp-hint.sh", "vexp-hint.cmd"];
732
+ const files = [];
733
+ let foreignHooks = false;
734
+ let hooksJsonOurs = false;
735
+ try {
736
+ const obj = parseJsonc(fs.readFileSync(path.join(dir, "hooks.json"), "utf-8"));
737
+ const scan = (holder, skipHooksKey) => {
738
+ for (const [event, list] of Object.entries(holder)) {
739
+ if (skipHooksKey && event === "hooks")
740
+ continue;
741
+ if (!Array.isArray(list)) {
742
+ foreignHooks = true;
743
+ continue;
744
+ }
745
+ for (const group of list) {
746
+ for (const h of Array.isArray(group?.hooks) ? group.hooks : [group]) {
747
+ if (typeof h?.command === "string" && h.command.includes("vexp-hint"))
748
+ hooksJsonOurs = true;
749
+ else
750
+ foreignHooks = true;
751
+ }
752
+ }
753
+ }
754
+ };
755
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
756
+ const o = obj;
757
+ scan(o, true); // the flat shape of older files
758
+ if (o.hooks && typeof o.hooks === "object" && !Array.isArray(o.hooks))
759
+ scan(o.hooks, false);
760
+ else if (o.hooks !== undefined)
761
+ foreignHooks = true;
762
+ }
763
+ else
764
+ foreignHooks = true;
765
+ }
766
+ catch {
767
+ // Absent: nothing to say. Unparseable: it may be the user's; not ours to name.
768
+ if (fs.existsSync(path.join(dir, "hooks.json")))
769
+ foreignHooks = true;
770
+ }
771
+ if (hooksJsonOurs)
772
+ files.push(".codex/hooks.json");
773
+ for (const f of ["vexp-hint.sh", "vexp-hint.cmd"]) {
774
+ if (fs.existsSync(path.join(dir, f)))
775
+ files.push(`.codex/${f}`);
776
+ }
777
+ let onlyOurs = false;
778
+ try {
779
+ onlyOurs = files.length > 0 && !foreignHooks && fs.readdirSync(dir).every((e) => ours.includes(e));
780
+ }
781
+ catch {
782
+ /* no folder */
783
+ }
784
+ let stanza;
785
+ try {
786
+ const toml = fs.readFileSync(globalConfig, "utf-8");
787
+ const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[(?!mcp_servers\.vexp\.)|$)/);
788
+ // A section without the marker is hand-written: the user's, not a leftover.
789
+ if (m && m[0].includes("# vexp-managed") && codexSectionPinnedTo(m[0], root)) {
790
+ stanza = { file: globalConfig, sections: m[0].match(/^\[mcp_servers\.vexp(?:\.[A-Za-z_]+)?\]/gm) ?? ["[mcp_servers.vexp]"] };
791
+ }
792
+ }
793
+ catch {
794
+ /* no machine-wide config */
795
+ }
796
+ return { files, onlyOurs, stanza };
797
+ }
798
+ /** The line for a .codex/hooks.json that starts with a byte-order mark. */
799
+ 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" +
800
+ " save it as UTF-8 without a BOM (in VS Code: click the encoding in the status bar, then Save with Encoding, UTF-8).";
801
+ /**
802
+ * The ways out through VS Code's question, for someone who does not use
803
+ * Codex here. 'Not here' is the answer for this project; 'Never' answers
804
+ * for Codex in every project on the machine, and in another project 3.2.5
805
+ * set up for Codex it then takes vexp's Codex files out too — doctor used to
806
+ * offer only 'Never', for a cleanup of this project (review, 2026-09-24).
807
+ * VS Code asks one question per tool (offerMachineAgents), so 'Never' here
808
+ * says nothing about any other tool; an earlier wording claimed it did,
809
+ * from the time one question named them all (review of the fix, 2026-09-24).
810
+ * 'Not here' leaves the machine-wide entry alone (releaseDeclinedAgents);
811
+ * only 'Never' removes it.
812
+ */
813
+ function vsCodeDeclines(projectFiles, stanzaFile) {
814
+ const notHere = projectFiles
815
+ ? `, or answer 'Not here' when VS Code asks: vexp then removes its files in this project itself` +
816
+ (stanzaFile ? ` (the entry in ${stanzaFile} stays: delete it as above).` : ".")
817
+ : ".";
818
+ return (notHere +
819
+ `\n 'Not here' is for this project only; 'Never' says no to Codex for every project on this machine` +
820
+ (stanzaFile ? `, and also removes the entry in ${stanzaFile}.` : "."));
821
+ }
822
+ /**
823
+ * The Codex orientation-hook verdict, as data. `run` means: probe the hook.
824
+ * Never names `vexp setup` to someone who did not choose Codex here.
825
+ */
826
+ export function codexHookVerdict(s) {
827
+ if (!s.use.used) {
828
+ const { files, onlyOurs, stanza } = s.leftovers;
829
+ if (files.length === 0 && !stanza) {
830
+ return { level: OK, message: "no vexp Codex hook here (Codex is not set up for this project)", run: false };
831
+ }
832
+ const what = [...files];
833
+ if (stanza)
834
+ what.push(`the vexp entry in ${stanza.file}, which points every Codex session on this machine at this project`);
835
+ const steps = [];
836
+ if (files.length > 0) {
837
+ const scripts = files.filter((f) => f !== ".codex/hooks.json");
838
+ steps.push(onlyOurs
839
+ ? "delete the .codex folder in this project (it holds only vexp's files)"
840
+ : `in this project's .codex folder delete ${[
841
+ ...scripts,
842
+ ...(files.includes(".codex/hooks.json") ? ["vexp's entry (the one naming vexp-hint) in hooks.json"] : []),
843
+ ].join(" and ")}; the rest of that folder is not vexp's`);
844
+ }
845
+ if (stanza) {
846
+ 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`);
847
+ }
848
+ if (codexUseUnknown(s.use)) {
849
+ // Possibly a Codex user of 3.2.5 or earlier: no answer existed then.
850
+ // VS Code asks only while Codex's own files are on the machine; without
851
+ // them it drops the project files silently and never asks.
852
+ const asks = s.use.onMachine === true;
853
+ return {
854
+ level: WARN,
855
+ 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("; ")}.` +
856
+ (s.use.answersUnreadable ? `\n vexp's saved answers in ${s.use.answersUnreadable} could not be read; run doctor again.` : "") +
857
+ `\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` +
858
+ (asks ? ", or answer 'Set up' when VS Code asks whether to set up Codex." : ".") +
859
+ `\n if you do not: ${steps.join(", then ")}` +
860
+ (asks ? vsCodeDeclines(files.length > 0, stanza?.file) : "."),
861
+ run: false,
862
+ };
863
+ }
864
+ return {
865
+ level: WARN,
866
+ message: `vexp left Codex files here although Codex is not set up for this project: ${what.join("; ")}.\n` +
867
+ ` to remove them: ${steps.join(", then ")}.`,
868
+ run: false,
869
+ };
870
+ }
871
+ if (s.hooksJson === "absent") {
872
+ return {
873
+ level: WARN,
874
+ message:
875
+ // "The tools work" only with a vexp entry seen above: Codex counts as
876
+ // used here also through its own project config or a saved "yes",
877
+ // with no vexp entry anywhere (review, 2026-09-24).
878
+ // An entry pinned to another project does not count: Codex here would
879
+ // answer from that project's index (review of the fix, 2026-09-24).
880
+ (s.mcpEntrySeen
881
+ ? "no .codex/hooks.json, but Codex is set up for this project — the tools work and the per-prompt orientation was never installed."
882
+ : s.mcpEntryElsewhere
883
+ ? `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.`
884
+ : "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.") +
885
+ ` Run '${codexSetupCommand(s.root)}' (needs Codex >= 0.129).`,
886
+ run: false,
887
+ };
888
+ }
889
+ if (s.hooksJson === "no-entry") {
890
+ return { level: WARN, message: `hooks.json has no vexp UserPromptSubmit entry — run '${codexSetupCommand(s.root)}'.`, run: false };
891
+ }
892
+ if (!s.scriptPresent) {
893
+ // On Windows the entry's Windows command runs, and it names the .cmd.
894
+ const script = s.platform === "win32" && s.hasCommandWindows ? "vexp-hint.cmd" : "vexp-hint.sh";
895
+ return { level: BAD, message: `hooks.json points at .codex/${script} but the script is missing — the hook fails on every prompt.`, run: false };
896
+ }
897
+ if (s.platform === "win32" && !s.hasCommandWindows) {
898
+ // Probing it through a bash here would hide exactly this.
899
+ return {
900
+ level: WARN,
901
+ 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" +
902
+ ` to fix it, run '${codexSetupCommand(s.root)}': it adds the Windows version of the hook (.codex/vexp-hint.cmd) to the entry.`,
903
+ run: false,
904
+ };
905
+ }
906
+ if (s.platform === "win32" && s.commandWindows !== undefined && /^"?[^"]*vexp-hint\.cmd"?$/i.test(s.commandWindows.trim())) {
907
+ // The 3.2.5 form. Codex runs hook lines in PowerShell by default, which
908
+ // reads a quoted path as a string: it prints it, exits 0, and Codex
909
+ // hands the path to the model as the context of every prompt.
910
+ return {
911
+ level: WARN,
912
+ 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" +
913
+ ` 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).`,
914
+ run: false,
915
+ };
916
+ }
917
+ return { level: OK, message: "", run: true };
918
+ }
919
+ function samePath(a, b) {
920
+ // The daemon registry lower-cases Windows roots and spells a mapped drive
921
+ // as its share; compare accordingly.
922
+ return canonicalWorkspaceRoot(path.resolve(a)).toLowerCase() === canonicalWorkspaceRoot(path.resolve(b)).toLowerCase();
923
+ }
924
+ /**
925
+ * A connected repo of a multi-repo workspace has no daemon of its own: the
926
+ * parent's daemon serves it. Doctor run inside such a repo used to treat it as
927
+ * a standalone workspace — "daemon NOT reachable", "NOT registered" — while
928
+ * `vexp search` from the same directory answered fine (field report,
929
+ * 2026-09-01, nine repos). The link is `.vexp/parent_workspace.json` in the
930
+ * member; when that file is missing, any registered workspace whose
931
+ * workspace.json lists this directory is the parent.
932
+ */
933
+ export function resolveMemberParent(root, registryRoots) {
934
+ const link = resolveParentWorkspace(root);
935
+ if (link) {
936
+ const alias = listWorkspaceRepos(link.parentRoot).find((r) => samePath(r.resolvedPath, root))?.alias
937
+ ?? path.basename(root);
938
+ return { parentRoot: link.parentRoot, alias, via: ".vexp/parent_workspace.json" };
939
+ }
940
+ for (const reg of registryRoots) {
941
+ if (samePath(reg, root))
942
+ continue;
943
+ const hit = listWorkspaceRepos(reg).find((r) => !r.isPrimary && samePath(r.resolvedPath, root));
944
+ if (hit)
945
+ return { parentRoot: reg, alias: hit.alias, via: `${path.join(reg, ".vexp", "workspace.json")} (daemon registry)` };
946
+ }
947
+ return null;
948
+ }
949
+ /**
950
+ * Coverage verdicts for the workspace at `root` AND every repo connected to
951
+ * it, each tagged with its alias. Each repo's indexer writes its own
952
+ * `.vexp/coverage.json`; reading only the primary's hid three oversized files
953
+ * in a connected repo from a doctor run at the primary (same report).
954
+ */
955
+ export function workspaceCoverageFindings(root) {
956
+ const repos = listWorkspaceRepos(root);
957
+ const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
958
+ if (!targets.some((t) => t.primary))
959
+ targets.unshift({ alias: path.basename(root), dir: root, primary: true });
960
+ const out = [];
961
+ for (const t of targets) {
962
+ let cov;
963
+ try {
964
+ cov = JSON.parse(fs.readFileSync(path.join(t.dir, ".vexp", "coverage.json"), "utf-8"));
965
+ }
966
+ catch {
967
+ continue; /* never indexed, or an index older than 2.7 */
968
+ }
969
+ const covPath = path.join(t.dir, ".vexp", "coverage.json");
970
+ for (const v of [coverageVerdict(cov, covPath), partialParseVerdict(cov, covPath)]) {
971
+ if (v)
972
+ out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
973
+ }
974
+ }
975
+ return out;
976
+ }
977
+ /**
978
+ * The ignore-rules verdict, from the `Rules:` line of `vexp-core index
979
+ * --status`, as data.
980
+ *
981
+ * The index records which root ignore rules (.vexpignore, .gitignore,
982
+ * .ignore, .git/info/exclude, the exclude settings of vexp.toml) it last
983
+ * applied. A CLI query applies a change before it answers, except the files
984
+ * on disk the index lacks (let back in by a removed line, or created since
985
+ * the last index) when there are more than a query indexes: they wait for
986
+ * `vexp index` or the daemon, "pending". A tester who runs only the CLI had
987
+ * no way to see either state outside `index --status`.
988
+ * Null when the engine prints no such line (before 3.2.6) or did not run.
989
+ */
990
+ export function rulesVerdict(statusOut, utf16 = []) {
991
+ if (/another vexp indexer is running/.test(statusOut)) {
992
+ return { level: INFO, message: "ignore rules: not checked - an indexer is running on this workspace; run 'vexp index --status' once it is done" };
993
+ }
994
+ const found = stripAnsi(statusOut).split("\n").map((l) => l.trim()).find((l) => l.startsWith("Rules:"));
995
+ if (!found)
996
+ return null;
997
+ const text = found.replace(/^Rules:\s*/, "");
998
+ // A UTF-16 root rule file applies none of its lines, whatever an engine
999
+ // that does not check for it (before 3.3.0) reports.
1000
+ if (utf16.length > 0 && !/UTF-16/.test(text)) {
1001
+ return { level: WARN, message: `ignore rules: ${utf16Notice(utf16)}` };
1002
+ }
1003
+ // Applied as they are on disk, with nothing left over; or recorded by no
1004
+ // pass yet on an index the rules still describe.
1005
+ const fine = /^applied \(/.test(text) || (/^not recorded/.test(text) && !/excluded by the current ignore rules/.test(text));
1006
+ return { level: fine ? OK : WARN, message: `ignore rules: ${text}` };
1007
+ }
1008
+ /** The root rule files, as the engine's walk reads them (rules_stamp.rs). */
1009
+ const ROOT_RULE_FILES = [".gitignore", ".ignore", ".vexpignore", ".vexp_ignore", ".git/info/exclude"];
1010
+ /**
1011
+ * The root rule files of `dir` written in UTF-16 (a byte-order mark or a
1012
+ * NUL byte): what Windows PowerShell 5.1 writes for `echo x > .vexpignore`
1013
+ * and `Out-File`. The engine reads rule files as UTF-8, so none of their
1014
+ * lines applies.
1015
+ */
1016
+ export function utf16RuleFiles(dir) {
1017
+ return ROOT_RULE_FILES.filter((name) => {
1018
+ try {
1019
+ const b = fs.readFileSync(path.join(dir, name));
1020
+ return (b.length >= 2 && ((b[0] === 0xff && b[1] === 0xfe) || (b[0] === 0xfe && b[1] === 0xff))) || b.includes(0);
1021
+ }
1022
+ catch {
1023
+ return false;
1024
+ }
1025
+ });
1026
+ }
1027
+ /** The engine's wording for {@link utf16RuleFiles} (rules_stamp.rs `utf16_notice`). */
1028
+ export function utf16Notice(files) {
1029
+ const [is, its, it] = files.length === 1 ? ["is", "its", "it"] : ["are", "their", "them"];
1030
+ 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)`;
1031
+ }
1032
+ /**
1033
+ * The `Calls:` line of `vexp-core index --status`: files a query added
1034
+ * before it answered, whose call edges wait for the daemon's next pass or
1035
+ * `vexp index` ("who calls X" is empty for them until then). Null when the
1036
+ * line is absent (nothing waits, or an engine before 3.2.6).
1037
+ */
1038
+ export function callsVerdict(statusOut) {
1039
+ const found = stripAnsi(statusOut).split("\n").map((l) => l.trim()).find((l) => l.startsWith("Calls:"));
1040
+ if (!found)
1041
+ return null;
1042
+ return { level: WARN, message: `call edges: ${found.replace(/^Calls:\s*/, "")}` };
1043
+ }
1044
+ /**
1045
+ * The `Parser:` line of `vexp-core index --status`: a full rebuild the next
1046
+ * `vexp index` or daemon start runs once (this engine reads more of each
1047
+ * file than the one that built the index, or an older vexp install, such as
1048
+ * an editor extension not yet updated, re-read files since), or an index a
1049
+ * newer engine built, which this one answers from and leaves alone. Null
1050
+ * when the index is current or the line is absent.
1051
+ */
1052
+ export function parserVerdict(statusOut) {
1053
+ const found = stripAnsi(statusOut).split("\n").map((l) => l.trim()).find((l) => l.startsWith("Parser:"));
1054
+ if (!found)
1055
+ return null;
1056
+ const text = found.replace(/^Parser:\s*/, "");
1057
+ if (/rebuild pending/.test(text))
1058
+ return { level: WARN, message: `index parser: ${text}` };
1059
+ if (/newer vexp engine/.test(text))
1060
+ return { level: INFO, message: `index parser: ${text}` };
1061
+ return null;
1062
+ }
1063
+ /**
1064
+ * `vexp-core index --status <dir>`, run by `bin`: its stdout, or null when
1065
+ * it could not run or failed. The engine reports the index in `dir` itself
1066
+ * when `dir` holds one (not the git root above it) and creates none.
1067
+ */
1068
+ export function coreIndexStatus(bin, dir) {
1069
+ const r = spawnSync(bin, ["index", "--status", dir], { cwd: dir, timeout: 15000, encoding: "utf-8", windowsHide: true });
1070
+ return r.error || r.status !== 0 ? null : String(r.stdout ?? "");
1071
+ }
1072
+ /**
1073
+ * Ignore-rules verdicts for the workspace at `root` and every repo connected
1074
+ * to it (each has its own index), tagged with the alias when there are
1075
+ * several. `status` runs `vexp-core index --status <dir>` and returns its
1076
+ * stdout, or null when it could not run.
1077
+ */
1078
+ export function workspaceRulesFindings(root, status) {
1079
+ const repos = listWorkspaceRepos(root);
1080
+ const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
1081
+ if (!targets.some((t) => t.primary))
1082
+ targets.unshift({ alias: path.basename(root), dir: root, primary: true });
1083
+ const out = [];
1084
+ for (const t of targets) {
1085
+ // Never indexed: nothing to compare, and --status would create the database.
1086
+ if (!fs.existsSync(path.join(t.dir, ".vexp", "index.db")))
1087
+ continue;
1088
+ const text = status(t.dir);
1089
+ if (text === null)
1090
+ continue;
1091
+ for (const v of [rulesVerdict(text, utf16RuleFiles(t.dir)), callsVerdict(text), parserVerdict(text)]) {
1092
+ if (v)
1093
+ out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
1094
+ }
1095
+ }
1096
+ return out;
1097
+ }
1098
+ /**
1099
+ * The 7-day activity ledger as doctor lines. Zero calls is not "not
1100
+ * working": silence on oriented prompts is vexp doing its job, so the OK
1101
+ * line exists whenever prompts were analyzed. The WARN is the other half of
1102
+ * the same ledger: orientations the daemon finished after the hook client
1103
+ * (3 s) had stopped waiting were computed, counted by 3.1.x as served, and
1104
+ * never read by the agent (field ledger, 3.1.3: up to 8 of 33).
1105
+ */
1106
+ export function ledgerFindings(ledger) {
1107
+ const out = [];
1108
+ const analyzed = Number(ledger.prompts_analyzed) || 0;
1109
+ const late = Number(ledger.late) || 0;
1110
+ // The parts add up to the total (silences + served + held out + late);
1111
+ // held out and late are named only when there are some.
1112
+ const held = Number(ledger.held_out) || 0;
1113
+ if (analyzed > 0) {
1114
+ out.push({
1115
+ level: OK,
1116
+ message: `savings ledger (7d): ${analyzed} prompt(s) analyzed: ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served` +
1117
+ `${held > 0 ? `, ${held} held out (randomized measurement)` : ""}${late > 0 ? `, ${late} late` : ""}. Details: vexp savings`,
1118
+ });
1119
+ }
1120
+ if (late > 0) {
1121
+ out.push({
1122
+ level: WARN,
1123
+ message: `${late} orientation(s) in the last 7 days finished after the hook client's budget (3 s by default) and never reached the agent - long prompts are slow on this index; vexp savings shows the count`,
1124
+ });
1125
+ }
1126
+ return out;
1127
+ }
1128
+ // ---------------------------------------------------------------------------
1129
+ // Who reached the daemon, and from where.
1130
+ //
1131
+ // Field report (2026-09-28, macOS, ZCode, a project in iCloud Drive): "vexp
1132
+ // setup done for ZCode but vexp is not used". His doctor was green except
1133
+ // the license and read "sessions (4h): 2 active, 0 pipeline calls total",
1134
+ // with no ledger line. Neither "session" was an agent: the orientation
1135
+ // hook's own pseudo-session ("internal-hook") and a one-shot CLI call were
1136
+ // counted as active sessions next to the agents'. The hook's abstentions
1137
+ // (a prompt under 8 words, an empty index) were recorded nowhere, so "the
1138
+ // hook fires and skips" read exactly like "the hook never fires". And a hook
1139
+ // run from a folder no daemon serves (ZCode had the PARENT of the nested,
1140
+ // same-name project open) left no trace at all. Each function below answers
1141
+ // one of those, from what the daemon (index_status) and the hook client
1142
+ // (~/.vexp/hook-calls.json) now record.
1143
+ // ---------------------------------------------------------------------------
1144
+ /** A finding doctor prints dim and indented under the lines before it; never counted. */
1145
+ export const NOTE = "NOTE";
1146
+ /**
1147
+ * What an index_status `sessions` row is: an agent's MCP session, the
1148
+ * pseudo-session of vexp's hooks, or a one-shot CLI call. Daemons from this
1149
+ * release on say so in "kind"; older ones sent only the id cut to 8
1150
+ * characters, where the hooks' "internal-hook" reads "internal" and a CLI
1151
+ * call of this release's client still shows its "cli-" prefix. Only an older
1152
+ * client's CLI call (a bare uuid) cannot be told from an agent.
1153
+ */
1154
+ export function sessionKind(row) {
1155
+ const k = row.kind;
1156
+ if (k === "agent" || k === "hook" || k === "cli")
1157
+ return k;
1158
+ const s = typeof row.session === "string" ? row.session : "";
1159
+ if (s === "internal")
1160
+ return "hook";
1161
+ // A daemon from before "kind" that outlived an upgrade (the CLI's bootstrap
1162
+ // only probes the socket, it never replaces it) still shows the new client's
1163
+ // "cli-" prefix in its 8 characters; a uuid never starts with it.
1164
+ return s.startsWith("cli-") ? "cli" : "agent";
1165
+ }
1166
+ /** "5 min ago", "3 h ago", "2 days ago": age of a unix-seconds stamp. */
1167
+ export function agoText(seconds) {
1168
+ const s = Math.max(0, Math.floor(Number(seconds) || 0));
1169
+ if (s < 60)
1170
+ return "less than a minute ago";
1171
+ if (s < 3600)
1172
+ return `${Math.floor(s / 60)} min ago`;
1173
+ if (s < 48 * 3600)
1174
+ return `${Math.floor(s / 3600)} h ago`;
1175
+ return `${Math.floor(s / 86400)} days ago`;
1176
+ }
1177
+ function count(v) {
1178
+ const n = Number(v);
1179
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
1180
+ }
1181
+ /**
1182
+ * The live sessions of the last 4 hours as doctor lines. Only agent
1183
+ * sessions are "agent sessions": the hooks' row counts hook calls, not agent
1184
+ * tool calls, and a CLI row is a person at a terminal. The no-session
1185
+ * branches are the ones doctor always had, now decided on agent sessions
1186
+ * alone.
1187
+ */
1188
+ export function sessionsFindings(sessions, o) {
1189
+ const of = (kind) => sessions.filter((s) => sessionKind(s) === kind);
1190
+ const agents = of("agent");
1191
+ const hooks = of("hook");
1192
+ const clis = of("cli");
1193
+ const sum = (rows, key) => rows.reduce((t, s) => t + count(s[key]), 0);
1194
+ // The hooks' shared pseudo-session ("internal-hook") records each call as
1195
+ // an "other" one: the prompt hook's prompt_hint, and also Claude Code's
1196
+ // SessionStart context restore (index_status on compact/resume, installed
1197
+ // by default) and its opt-in stop gate (verify_done). Not a prompt count;
1198
+ // hook_activity is. Doctor's own probes go in as CLI calls.
1199
+ const hookCalls = sum(hooks, "other_calls");
1200
+ const agentHookAgo = typeof o.hookLastAt === "number" && Number.isFinite(o.hookLastAt) && o.nowS !== undefined && o.nowS - o.hookLastAt <= 4 * 3600
1201
+ ? agoText(o.nowS - o.hookLastAt)
1202
+ : null;
1203
+ const out = [];
1204
+ if (agents.length > 0) {
1205
+ out.push({
1206
+ level: OK,
1207
+ message: `agent sessions (4h): ${agents.length} - ${sum(agents, "pipeline_calls")} pipeline, ${sum(agents, "skeleton_calls")} skeleton, ${sum(agents, "other_calls")} other tool call(s)`,
1208
+ });
1209
+ }
1210
+ else if (o.prompts7d > 0) {
1211
+ out.push({ level: OK, message: `no agent tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)` });
1212
+ }
1213
+ else if (o.uptimeS > 600) {
1214
+ // Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
1215
+ // the daemon was unreachable serves from its own in-process index and
1216
+ // never attaches to the daemon later — its calls are real but
1217
+ // invisible to these counters. Don't tell that user "the agent is not
1218
+ // using vexp"; tell them how to converge on the daemon.
1219
+ out.push({
1220
+ level: WARN,
1221
+ message: `no agent session has called vexp through this daemon since it started — either no agent is using vexp here (check its MCP config), or the agent's vexp MCP server started BEFORE the daemon and is running embedded (in-process). If calls do succeed in the agent, restart the agent (with the daemon already up) so its MCP server attaches to the daemon.` +
1222
+ // The field report's exact state: an agent's hook reached the
1223
+ // daemon, so an agent was at work here, and no tool call came in.
1224
+ (agentHookAgo
1225
+ ? `\n an agent's hook brought a prompt to this daemon ${agentHookAgo}, so an agent is at work in this project: its vexp MCP tools are either not connected or never called.`
1226
+ : ""),
1227
+ });
1228
+ }
1229
+ else {
1230
+ out.push({ level: OK, message: `no session calls yet (daemon just started)` });
1231
+ }
1232
+ if (hooks.length > 0) {
1233
+ out.push({
1234
+ level: OK,
1235
+ message: `vexp hooks (4h): ${hookCalls} call(s) reached this daemon - prompts, plus Claude Code's context restore after compact/resume and its stop gate`,
1236
+ });
1237
+ }
1238
+ if (clis.length > 0) {
1239
+ const calls = sum(clis, "pipeline_calls") + sum(clis, "skeleton_calls") + sum(clis, "other_calls");
1240
+ out.push({ level: NOTE, message: `${calls} one-shot CLI call(s) in the last 4h (vexp search, skeleton, doctor's own hook probes, ... from a terminal or a script): not agent sessions` });
1241
+ }
1242
+ return out;
1243
+ }
1244
+ /**
1245
+ * What the orientation hook did when it did not orient: index_status's
1246
+ * `hook_activity` (7 days) as doctor lines, apart from the savings ledger,
1247
+ * whose numbers these abstentions never change. Nothing for a daemon that
1248
+ * predates the key.
1249
+ */
1250
+ export function hookActivityFindings(ha, ledger, nowS) {
1251
+ if (!ha || typeof ha !== "object" || Array.isArray(ha))
1252
+ return [];
1253
+ const h = ha;
1254
+ const days = count(h.period_days) || 7;
1255
+ const short = count(h.abstained_short);
1256
+ const empty = count(h.abstained_empty_index);
1257
+ const skipped = short + empty;
1258
+ const analyzed = count(ledger.prompts_analyzed);
1259
+ const parts = [
1260
+ short > 0 ? `${short} under 8 words (follow-ups are never oriented)` : "",
1261
+ empty > 0 ? `${empty} while the index was empty` : "",
1262
+ ]
1263
+ .filter(Boolean)
1264
+ .join(", ");
1265
+ const out = [];
1266
+ if (skipped > 0 && analyzed === 0) {
1267
+ out.push({ level: OK, message: `orientation hook reached vexp ${skipped} time(s) in the last ${days} days and every prompt was skipped: ${parts}` });
1268
+ }
1269
+ else if (skipped > 0) {
1270
+ out.push({ level: NOTE, message: `the hook also skipped ${skipped} prompt(s) in the last ${days} days: ${parts}` });
1271
+ }
1272
+ const last = typeof h.last_at === "number" && Number.isFinite(h.last_at) ? h.last_at : null;
1273
+ if (last !== null) {
1274
+ out.push({ level: OK, message: `last prompt through an agent hook: ${agoText(nowS - last)}` });
1275
+ }
1276
+ else {
1277
+ // "Fires and skips" and "never fires" are now two different lines.
1278
+ out.push({ level: NOTE, message: `no prompt has reached this daemon through an agent hook in the last ${days} days` });
1279
+ }
1280
+ return out;
1281
+ }
1282
+ /** Where the hook client leaves its breadcrumbs: <vexp home>/.vexp/hook-calls.json. */
1283
+ export function hookCallsPath(home = vexpHome()) {
1284
+ return path.join(home, ".vexp", "hook-calls.json");
1285
+ }
1286
+ /** The breadcrumbs, newest first; anything unreadable is skipped, never thrown. */
1287
+ export function parseHookCalls(text) {
1288
+ let obj;
1289
+ try {
1290
+ obj = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
1291
+ }
1292
+ catch {
1293
+ return [];
1294
+ }
1295
+ const list = obj?.entries;
1296
+ if (!Array.isArray(list))
1297
+ return [];
1298
+ const out = [];
1299
+ for (const e of list) {
1300
+ if (!e || typeof e !== "object")
1301
+ continue;
1302
+ const r = e;
1303
+ const at = Number(r.at);
1304
+ if (typeof r.root !== "string" || !r.root || !Number.isFinite(at))
1305
+ continue;
1306
+ out.push({
1307
+ root: r.root,
1308
+ at,
1309
+ outcome: typeof r.outcome === "string" && r.outcome ? r.outcome : "unknown",
1310
+ reason: typeof r.reason === "string" && r.reason ? r.reason : null,
1311
+ agent: typeof r.agent === "string" && r.agent ? r.agent : null,
1312
+ });
1313
+ }
1314
+ return out.sort((a, b) => b.at - a.at);
1315
+ }
1316
+ /**
1317
+ * Where `a` stands relative to `b`: the same folder, a strict ancestor of it,
1318
+ * a strict descendant, or neither. Case-folded where the platform's default
1319
+ * filesystem is (Windows, macOS).
1320
+ */
1321
+ export function pathRelation(a, b, platform) {
1322
+ const p = platform === "win32" ? path.win32 : path.posix;
1323
+ const fold = platform === "win32" || platform === "darwin";
1324
+ const norm = (s) => (fold ? p.resolve(s).toLowerCase() : p.resolve(s));
1325
+ const x = norm(a);
1326
+ const y = norm(b);
1327
+ const inside = (rel) => rel !== "" && rel !== ".." && !rel.startsWith(".." + p.sep) && !p.isAbsolute(rel);
1328
+ if (p.relative(x, y) === "")
1329
+ return "same";
1330
+ if (inside(p.relative(x, y)))
1331
+ return "ancestor";
1332
+ if (inside(p.relative(y, x)))
1333
+ return "descendant";
1334
+ return "unrelated";
1335
+ }
1336
+ const HOOK_OUTCOME_TROUBLE = {
1337
+ "no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation ('vexp daemon-cmd start' starts one)",
1338
+ timeout: "the daemon did not answer within the hook client's budget (3 s by default), so that prompt got no orientation",
1339
+ error: "the daemon answered with an error, so that prompt got no orientation; .vexp/daemon.log has the details",
1340
+ };
1341
+ /**
1342
+ * The hook client's breadcrumbs as doctor lines: the last call from this
1343
+ * workspace, and the calls from a folder above or below it that found no
1344
+ * daemon, which is how an agent with the wrong folder open looks from here
1345
+ * (the field report's ZCode had the parent open: it read neither the
1346
+ * project's AGENTS.md nor its MCP config, and its hook met no daemon).
1347
+ * `aliases` are other spellings of `wsRoot` (its real path, its share).
1348
+ * `neverServed` are folders no daemon can ever serve (the home directory,
1349
+ * the vexp home): a user-level hook (ZCode's, Codex's) meets no daemon there
1350
+ * every time a session opens in them, which says nothing about this project.
1351
+ */
1352
+ export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [], neverServed = []) {
1353
+ const roots = [wsRoot, ...aliases.filter((a) => !!a)];
1354
+ const relation = (e) => {
1355
+ const rels = roots.map((r) => pathRelation(e.root, r, platform));
1356
+ return rels.includes("same") ? "same" : rels.includes("ancestor") ? "ancestor" : rels.includes("descendant") ? "descendant" : "unrelated";
1357
+ };
1358
+ const by = (e) => (e.agent ? ` (${e.agent})` : "");
1359
+ const out = [];
1360
+ const here = entries.filter((e) => relation(e) === "same").sort((a, b) => b.at - a.at)[0];
1361
+ if (here) {
1362
+ const trouble = HOOK_OUTCOME_TROUBLE[here.outcome];
1363
+ const reason = here.reason ? `: ${here.reason.slice(0, 200)}` : "";
1364
+ out.push({
1365
+ level: trouble ? WARN : OK,
1366
+ message: `last agent-hook call from this workspace: ${agoText(nowS - here.at)}${by(here)} - ${here.outcome}${reason}${trouble ? `\n ${trouble}` : ""}`,
1367
+ });
1368
+ }
1369
+ const week = 7 * 86400;
1370
+ // The hook has since worked from the project itself: whatever the agent
1371
+ // had open before, it has opened the right folder since.
1372
+ const worksHere = here && (here.outcome === "served" || here.outcome === "no-injection") ? here : undefined;
1373
+ const p = platform === "win32" ? path.win32 : path.posix;
1374
+ const unservable = (root) => p.parse(p.resolve(root)).root === p.resolve(root) || neverServed.some((h) => !!h && pathRelation(root, h, platform) === "same");
1375
+ let elsewhere = 0;
1376
+ for (const e of entries) {
1377
+ if (e.outcome !== "no-daemon" || nowS - e.at > week || elsewhere >= 3)
1378
+ continue;
1379
+ if (worksHere && e.at <= worksHere.at)
1380
+ continue;
1381
+ const rel = relation(e);
1382
+ if (rel === "ancestor" && unservable(e.root))
1383
+ continue;
1384
+ if (rel === "ancestor") {
1385
+ elsewhere++;
1386
+ out.push({
1387
+ level: WARN,
1388
+ message: `an agent's hook ran from ${e.root}${by(e)} ${agoText(nowS - e.at)} and found no vexp daemon there: that is a parent folder of this project. ` +
1389
+ `The agent has the parent folder open, so it reads neither this project's AGENTS.md nor its MCP config, and its hook finds no daemon - open ${wsRoot} itself.`,
1390
+ });
1391
+ }
1392
+ else if (rel === "descendant") {
1393
+ elsewhere++;
1394
+ out.push({
1395
+ level: WARN,
1396
+ message: `an agent's hook ran from ${e.root}${by(e)} ${agoText(nowS - e.at)} and found no vexp daemon there: that is a subfolder of this project. ` +
1397
+ `The agent has the subfolder open, not the project, so its hook gets no orientation (and an agent that reads AGENTS.md and its MCP config in the folder it opened, as ZCode does, finds neither) - open ${wsRoot} itself.`,
1398
+ });
1399
+ }
1400
+ }
1401
+ return out;
1402
+ }
1403
+ /** Read a JSON(C) config the way setup does (BOM tolerant, comments and trailing commas allowed). */
1404
+ export function readJsonFileState(file) {
1405
+ let raw;
1406
+ try {
1407
+ raw = fs.readFileSync(file, "utf-8");
1408
+ }
1409
+ catch (e) {
1410
+ const code = e.code;
1411
+ return code === "ENOENT" || code === "ENOTDIR" ? { file, state: "absent" } : { file, state: "unparseable" };
1412
+ }
1413
+ if (raw.replace(/^\uFEFF/, "").trim() === "")
1414
+ return { file, state: "ok", data: {} };
1415
+ try {
1416
+ const v = parseJsonc(raw);
1417
+ if (v && typeof v === "object" && !Array.isArray(v))
1418
+ return { file, state: "ok", data: v };
1419
+ }
1420
+ catch {
1421
+ /* reported below */
1422
+ }
1423
+ return { file, state: "unparseable" };
1424
+ }
1425
+ function asObject(v) {
1426
+ return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
1427
+ }
1428
+ /** A file's MCP server map: `mcp.servers` in ZCode's own files, `mcpServers` in .agents/mcp.json. */
1429
+ function zcodeServers(f, shape) {
1430
+ if (f.state !== "ok")
1431
+ return undefined;
1432
+ return shape === "native" ? asObject(asObject(f.data.mcp)?.servers) : asObject(f.data.mcpServers);
1433
+ }
1434
+ /** vexp's UserPromptSubmit entry in ZCode's user config, and the hook in it that runs vexp. */
1435
+ export function zcodeVexpHook(cfg) {
1436
+ const events = asObject(asObject(asObject(cfg)?.hooks)?.events);
1437
+ const list = Array.isArray(events?.UserPromptSubmit) ? events.UserPromptSubmit : [];
1438
+ const group = list.find((g) => isVexpZcodeHookEntry(g));
1439
+ if (!group)
1440
+ return null;
1441
+ const hooks = group.hooks.filter((h) => !!asObject(h));
1442
+ const hook = hooks.find((h) => isVexpZcodeHookEntry({ hooks: [h] })) ?? hooks[0];
1443
+ return hook ? { group, hook } : null;
1444
+ }
1445
+ /** Hooks of the user's own in ZCode's user config: the same test installZcodeHintHook makes
1446
+ * before it honours hooks.enabled: false as the user's decision. */
1447
+ function zcodeForeignHooks(cfg) {
1448
+ const events = asObject(asObject(asObject(cfg)?.hooks)?.events);
1449
+ if (!events)
1450
+ return false;
1451
+ return Object.entries(events).some(([event, list]) => Array.isArray(list) && list.some((h) => event !== "UserPromptSubmit" || !isVexpZcodeHookEntry(h)));
1452
+ }
1453
+ /** Is ZCode set up for this project: its folder here, vexp in the project's
1454
+ * .agents/mcp.json, or the user said yes to ZCode for it. Project evidence
1455
+ * only: vexp's hook in ~/.zcode/cli/config.json is machine-wide, and taking
1456
+ * it for "ZCode here" failed doctor (exit 1) in every other project once
1457
+ * one project had been set up for ZCode. */
1458
+ export function zcodeInUse(i) {
1459
+ const p = i.platform === "win32" ? path.win32 : path.posix;
1460
+ if (i.exists(p.join(i.root, ".zcode")))
1461
+ return true;
1462
+ if (zcodeServers(i.workspaceCompat, "compat")?.vexp)
1463
+ return true;
1464
+ return i.consent === "yes";
1465
+ }
1466
+ /** The command doctor runs to probe vexp's hook, exactly as ZCode would
1467
+ * (a `process` hook: argv, no shell); null when there is nothing runnable. */
1468
+ export function zcodeProbeSpec(i) {
1469
+ if (i.userNative.state !== "ok")
1470
+ return null;
1471
+ const found = zcodeVexpHook(i.userNative.data);
1472
+ if (!found || found.hook.type !== "process")
1473
+ return null;
1474
+ const command = typeof found.hook.command === "string" ? found.hook.command : "";
1475
+ const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
1476
+ if (!command || !abs || !i.exists(command))
1477
+ return null;
1478
+ const args = Array.isArray(found.hook.args) ? found.hook.args.filter((a) => typeof a === "string") : [];
1479
+ return { command, args };
1480
+ }
1481
+ /** A path as the doctor line names it: ~/... under home, relative under the project. */
1482
+ function zcodeLabel(file, i) {
1483
+ const p = i.platform === "win32" ? path.win32 : path.posix;
1484
+ if (pathRelation(i.root, file, i.platform) === "ancestor")
1485
+ return p.relative(i.root, file).replace(/\\/g, "/");
1486
+ if (pathRelation(i.home, file, i.platform) === "ancestor")
1487
+ return `~/${p.relative(i.home, file).replace(/\\/g, "/")}`;
1488
+ return file;
1489
+ }
1490
+ /** One vexp MCP entry of ZCode's, checked. */
1491
+ function zcodeEntryFinding(entry, label, userScope, i, setup) {
1492
+ const e = asObject(entry);
1493
+ if (!e)
1494
+ return { level: BAD, message: `${label}: the vexp server entry is not an object - ZCode cannot start it; re-run '${setup}'` };
1495
+ const command = typeof e.command === "string" ? e.command.trim() : "";
1496
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
1497
+ const env = asObject(e.env);
1498
+ const pin = typeof env?.VEXP_WORKSPACE === "string" && env.VEXP_WORKSPACE ? env.VEXP_WORKSPACE : undefined;
1499
+ if (!command) {
1500
+ if (typeof e.url === "string")
1501
+ return { level: INFO, message: `${label}: vexp server over http (${e.url}) - not checked here` };
1502
+ return { level: BAD, message: `${label}: the vexp server entry has no command - ZCode cannot start it; re-run '${setup}'` };
1503
+ }
1504
+ const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
1505
+ if (abs && !i.exists(command)) {
1506
+ return { level: BAD, message: `${label}: the vexp server command does not exist: ${command} - ZCode cannot start it, so there are no vexp tools (an upgrade moved the binary?); re-run '${setup}'` };
1507
+ }
1508
+ // The Kiro lesson: a GUI app started from the Dock or the Start menu does
1509
+ // not get the shell's PATH, and a server it cannot spawn fails silently.
1510
+ // Bare names only: an absolute node needs no PATH lookup, and setup keeps
1511
+ // an entry that resolves, so its re-run would not change it anyway.
1512
+ const base = path.win32.basename(command).toLowerCase();
1513
+ if (!/[\\/]/.test(command) && ["node", "node.exe", "npx", "npx.cmd", "npx.exe"].includes(base)) {
1514
+ return {
1515
+ level: WARN,
1516
+ message: `${label}: the vexp server starts through '${command}' - ZCode is a GUI app, and launched from the Dock or Start menu it may not have node on its PATH (the server then never starts and no vexp tools appear); re-run '${setup}', which writes the vexp binary itself`,
1517
+ };
1518
+ }
1519
+ // An absolute npx outside Windows is a `#!/usr/bin/env node` script: it
1520
+ // still looks node up on that PATH. Setup adopts an entry that resolves,
1521
+ // so the entry has to go before its re-run can write the binary.
1522
+ if (i.platform !== "win32" && base === "npx") {
1523
+ return {
1524
+ level: WARN,
1525
+ message: `${label}: the vexp server starts through ${command}, a script that runs node from ZCode's PATH, which a GUI app launched from the Dock may lack (then no vexp tools appear) - delete the vexp entry there, then run '${setup}', which writes the vexp binary itself`,
1526
+ };
1527
+ }
1528
+ if (!/[\\/]/.test(command)) {
1529
+ return {
1530
+ level: WARN,
1531
+ message: `${label}: the vexp server command is a bare '${command}', looked up on ZCode's PATH, which a GUI app launched from the Dock or Start menu usually lacks; re-run '${setup}' to write the absolute path`,
1532
+ };
1533
+ }
1534
+ if (pin && !samePath(pin, i.root)) {
1535
+ return {
1536
+ level: WARN,
1537
+ message: `${label}: the vexp server is pinned to ${pin} (VEXP_WORKSPACE), but this project is ${i.root} - ZCode sessions here query that project's index. ` +
1538
+ (userScope
1539
+ ? `That entry is in your user config and serves every project: run '${setup}' here to give this project its own entry.`
1540
+ : `Re-run '${setup}' to repin it.`),
1541
+ };
1542
+ }
1543
+ return { level: OK, message: `${label}: vexp server ${command}${args.length ? ` ${args.join(" ")}` : ""}${pin ? `, pinned to ${pin}` : ", no workspace pin"}` };
1544
+ }
1545
+ /** The ZCode section of doctor, as data (the log has its own function). */
1546
+ export function zcodeFindings(i) {
1547
+ const setup = setupAgentsCommand(["ZCode"], i.root);
1548
+ if (!zcodeInUse(i)) {
1549
+ // vexp's hook runs in every folder ZCode opens, this one included: say
1550
+ // what opening it in ZCode would lack, never as a fault of a project
1551
+ // that may not use ZCode at all.
1552
+ if (i.zcodeHomeExists && i.userNative.state === "ok" && zcodeVexpHook(i.userNative.data) !== null) {
1553
+ return [
1554
+ {
1555
+ level: INFO,
1556
+ message: `vexp's ZCode hook (~/.zcode/cli/config.json) runs in every folder ZCode opens, but this project has no vexp MCP entry for ZCode (.zcode/config.json, .agents/mcp.json)` +
1557
+ (i.consent === "no" || i.consent === "never" ? " - you said no to ZCode here" : `: if you open it in ZCode, run '${setup}'`),
1558
+ },
1559
+ ];
1560
+ }
1561
+ return [{ level: OK, message: "no .zcode here (ZCode not configured for this project)" }];
1562
+ }
1563
+ const p = i.platform === "win32" ? path.win32 : path.posix;
1564
+ const fold = i.platform === "win32" || i.platform === "darwin";
1565
+ const out = [];
1566
+ // The field report's layout: .../Repos/<Project>/<Project>,
1567
+ // a clone inside a folder of its own name. Opening the outer one is easy
1568
+ // and reads as the project in ZCode's title bar.
1569
+ const name = p.basename(i.root);
1570
+ const parent = p.dirname(i.root);
1571
+ if (name && parent !== i.root && (fold ? name.toLowerCase() === p.basename(parent).toLowerCase() : name === p.basename(parent))) {
1572
+ out.push({
1573
+ level: INFO,
1574
+ message: `this project sits in a folder of the same name (${parent}): open ${i.root} itself in ZCode, not its parent - from the parent, ZCode reads neither this AGENTS.md nor .zcode/config.json`,
1575
+ });
1576
+ }
1577
+ if (i.agentsMd !== null && /<!--\s*vexp v|^#{1,6}\s*vexp\b/im.test(i.agentsMd)) {
1578
+ out.push({ level: OK, message: "AGENTS.md at the project root has the vexp section (ZCode reads it from the root of the folder it opened)" });
1579
+ }
1580
+ else {
1581
+ out.push({
1582
+ level: WARN,
1583
+ message: (i.agentsMd === null ? `no AGENTS.md in ${i.root}` : `AGENTS.md in ${i.root} has no vexp section`) +
1584
+ `: ZCode reads AGENTS.md at the root of the folder it opened, and without it the agent is never told vexp exists - run '${setup}'`,
1585
+ });
1586
+ }
1587
+ // A file ZCode cannot parse contributes nothing, as if it were absent;
1588
+ // setup never overwrites one, so only the user can fix it.
1589
+ for (const f of [i.workspaceNative, i.workspaceCompat, i.userNative, i.userCompat]) {
1590
+ if (f.state === "unparseable") {
1591
+ out.push({ level: BAD, message: `${zcodeLabel(f.file, i)} does not parse as JSON: nothing in it takes effect in ZCode, and 'vexp setup' will not overwrite it - fix its syntax` });
1592
+ }
1593
+ }
1594
+ // MCP, in ZCode's documented order: in each scope the .zcode file wins
1595
+ // outright as soon as it defines any server.
1596
+ const wsNative = zcodeServers(i.workspaceNative, "native");
1597
+ const wsCompat = zcodeServers(i.workspaceCompat, "compat");
1598
+ const usNative = zcodeServers(i.userNative, "native");
1599
+ const usCompat = zcodeServers(i.userCompat, "compat");
1600
+ const names = (s) => Object.keys(s ?? {});
1601
+ let served = false;
1602
+ if (wsNative?.vexp) {
1603
+ out.push(zcodeEntryFinding(wsNative.vexp, zcodeLabel(i.workspaceNative.file, i), false, i, setup));
1604
+ served = true;
1605
+ }
1606
+ else if (names(wsNative).length > 0 && wsCompat?.vexp) {
1607
+ out.push({
1608
+ level: BAD,
1609
+ message: `${zcodeLabel(i.workspaceCompat.file, i)} holds the vexp server, but ${zcodeLabel(i.workspaceNative.file, i)} defines servers of its own (${names(wsNative).join(", ")}): ` +
1610
+ `ZCode skips .agents/mcp.json whenever .zcode/config.json defines servers, so no vexp tools here. Run '${setup}': it writes the vexp entry into .zcode/config.json.`,
1611
+ });
1612
+ }
1613
+ else if (wsCompat?.vexp) {
1614
+ out.push(zcodeEntryFinding(wsCompat.vexp, zcodeLabel(i.workspaceCompat.file, i), false, i, setup));
1615
+ served = true;
1616
+ }
1617
+ if (!served) {
1618
+ if (usNative?.vexp) {
1619
+ out.push(zcodeEntryFinding(usNative.vexp, zcodeLabel(i.userNative.file, i), true, i, setup));
1620
+ }
1621
+ else if (names(usNative).length > 0 && usCompat?.vexp) {
1622
+ out.push({
1623
+ level: BAD,
1624
+ message: `${zcodeLabel(i.userCompat.file, i)} holds the vexp server, but ${zcodeLabel(i.userNative.file, i)} defines servers of its own (${names(usNative).join(", ")}): ` +
1625
+ `ZCode skips ~/.agents/mcp.json whenever ~/.zcode/cli/config.json defines servers. Run '${setup}': it writes the vexp entry into this project's .zcode/config.json.`,
1626
+ });
1627
+ }
1628
+ else if (usCompat?.vexp) {
1629
+ out.push(zcodeEntryFinding(usCompat.vexp, zcodeLabel(i.userCompat.file, i), true, i, setup));
1630
+ }
1631
+ else if (!(names(wsNative).length > 0 && wsCompat?.vexp)) {
1632
+ out.push({
1633
+ level: BAD,
1634
+ message: `no vexp MCP server for ZCode here: none in .zcode/config.json, .agents/mcp.json, ~/.zcode/cli/config.json or ~/.agents/mcp.json, so ZCode sessions get no vexp tools. Run '${setup}'.`,
1635
+ });
1636
+ }
1637
+ }
1638
+ out.push({ level: NOTE, message: "ZCode connects MCP servers at session start: start a NEW ZCode session after 'vexp setup'." });
1639
+ // The orientation hook: user scope only.
1640
+ const hookFile = zcodeLabel(i.userNative.file, i);
1641
+ if (i.userNative.state === "absent") {
1642
+ out.push({ level: WARN, message: `${hookFile} does not exist: vexp's per-prompt orientation hook was never installed (ZCode runs hooks only from that file) - run '${setup}'` });
314
1643
  }
315
- return { file: "sh", args: ["-c", cmdLine], windowsVerbatimArguments: false };
316
- }
317
- function samePath(a, b) {
318
- // The daemon registry lower-cases Windows roots and spells a mapped drive
319
- // as its share; compare accordingly.
320
- return canonicalWorkspaceRoot(path.resolve(a)).toLowerCase() === canonicalWorkspaceRoot(path.resolve(b)).toLowerCase();
1644
+ else if (i.userNative.state === "ok") {
1645
+ const cfg = i.userNative.data;
1646
+ const enabled = asObject(cfg.hooks)?.enabled;
1647
+ const found = zcodeVexpHook(cfg);
1648
+ // WARN, as every other "orientation inert" state is (the entry missing
1649
+ // or disabled, Codex's hooks = false): the MCP tools still work, only the
1650
+ // per-prompt orientation is off. Said only when vexp's hook is there to
1651
+ // be switched off; without one, the missing-hook line below covers both,
1652
+ // since setup writes the entry and hooks.enabled together.
1653
+ if (enabled !== true && found) {
1654
+ out.push(enabled === false && zcodeForeignHooks(cfg)
1655
+ ? {
1656
+ level: WARN,
1657
+ message: `hooks are switched off in ${hookFile} (hooks.enabled: false) while hooks of your own are registered there - that looks deliberate, so vexp leaves it alone; vexp's orientation hook stays inert until hooks.enabled is true`,
1658
+ }
1659
+ : {
1660
+ level: WARN,
1661
+ message: `hooks.enabled is not true in ${hookFile}: ZCode runs no hooks at all (they are off by default), vexp's orientation hook included - set "enabled": true under "hooks", or re-run '${setup}'`,
1662
+ });
1663
+ }
1664
+ if (!found) {
1665
+ out.push({ level: WARN, message: `no vexp UserPromptSubmit hook in ${hookFile}: ZCode gets no per-prompt orientation - re-run '${setup}'` });
1666
+ }
1667
+ else {
1668
+ const { group, hook } = found;
1669
+ if (group.enabled === false || hook.enabled === false) {
1670
+ out.push({ level: WARN, message: `vexp's UserPromptSubmit hook in ${hookFile} is disabled ("enabled": false), so ZCode skips it - remove that key or set it to true` });
1671
+ }
1672
+ if (hook.type !== "process") {
1673
+ out.push({ level: INFO, message: `vexp's hook in ${hookFile} is of type '${String(hook.type ?? "(none)")}', not 'process' - not probed here` });
1674
+ }
1675
+ else {
1676
+ const command = typeof hook.command === "string" ? hook.command : "";
1677
+ const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
1678
+ if (!command) {
1679
+ out.push({ level: BAD, message: `vexp's hook in ${hookFile} has no command - it fails on every prompt; re-run '${setup}'` });
1680
+ }
1681
+ else if (abs && !i.exists(command)) {
1682
+ out.push({ level: BAD, message: `vexp's hook in ${hookFile} runs ${command}, which does not exist - it fails on every prompt; re-run '${setup}'` });
1683
+ }
1684
+ else if (!abs) {
1685
+ out.push({ level: WARN, message: `vexp's hook in ${hookFile} runs a bare '${command}', looked up on ZCode's PATH, which a GUI app launched from the Dock or Start menu usually lacks; re-run '${setup}' to write the absolute path` });
1686
+ }
1687
+ const t = hook.timeoutMs;
1688
+ if (typeof t === "number" && t < 4000) {
1689
+ out.push({ level: WARN, message: `vexp's hook in ${hookFile} has timeoutMs ${t}: the orientation alone may take 3 s, so ZCode can stop the hook before it answers - set it to 5000 (what setup writes)` });
1690
+ }
1691
+ if (i.probe) {
1692
+ if (i.probe.error) {
1693
+ out.push({ level: BAD, message: `UserPromptSubmit hook DID NOT RUN: ${i.probe.error} - orientation is inert.` });
1694
+ }
1695
+ else if (i.probe.status !== 0) {
1696
+ const err = i.probe.stderr.trim();
1697
+ out.push({ level: BAD, message: `UserPromptSubmit hook exited ${i.probe.status}${err ? `: ${err.slice(0, 160)}` : ""} - ZCode continues without vexp.` });
1698
+ }
1699
+ else {
1700
+ // Silence is a legitimate outcome for the hint hook (the probe's
1701
+ // prompt is short on purpose), so report the daemon's reason.
1702
+ const why = i.probe.stderr.trim().replace(/^vexp prompt-hint: /, "");
1703
+ out.push({ level: OK, message: `UserPromptSubmit hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}` });
1704
+ }
1705
+ }
1706
+ }
1707
+ }
1708
+ out.push({ level: NOTE, message: `ZCode applies hook changes to NEW sessions only: start a new ZCode session after 'vexp setup' or any edit of ${hookFile}.` });
1709
+ }
1710
+ return out;
321
1711
  }
322
1712
  /**
323
- * A connected repo of a multi-repo workspace has no daemon of its own: the
324
- * parent's daemon serves it. Doctor run inside such a repo used to treat it as
325
- * a standalone workspace — "daemon NOT reachable", "NOT registered" — while
326
- * `vexp search` from the same directory answered fine (field report,
327
- * 2026-09-01, nine repos). The link is `.vexp/parent_workspace.json` in the
328
- * member; when that file is missing, any registered workspace whose
329
- * workspace.json lists this directory is the parent.
1713
+ * A log line as text twice over: `match` holds the string values only, keys
1714
+ * left out (`"error": null` is not an error); `show` keeps each key next to
1715
+ * its value, so a secret under a key such as api_key is recognisable to
1716
+ * maskSecretsForReport. A line that is not JSON is both.
330
1717
  */
331
- export function resolveMemberParent(root, registryRoots) {
332
- const link = resolveParentWorkspace(root);
333
- if (link) {
334
- const alias = listWorkspaceRepos(link.parentRoot).find((r) => samePath(r.resolvedPath, root))?.alias
335
- ?? path.basename(root);
336
- return { parentRoot: link.parentRoot, alias, via: ".vexp/parent_workspace.json" };
1718
+ function logLineText(line) {
1719
+ let v;
1720
+ try {
1721
+ v = JSON.parse(line);
337
1722
  }
338
- for (const reg of registryRoots) {
339
- if (samePath(reg, root))
340
- continue;
341
- const hit = listWorkspaceRepos(reg).find((r) => !r.isPrimary && samePath(r.resolvedPath, root));
342
- if (hit)
343
- return { parentRoot: reg, alias: hit.alias, via: `${path.join(reg, ".vexp", "workspace.json")} (daemon registry)` };
1723
+ catch {
1724
+ return { match: line, show: line };
344
1725
  }
345
- return null;
1726
+ const values = [];
1727
+ const pairs = [];
1728
+ const walk = (x, key, depth) => {
1729
+ if (depth > 6)
1730
+ return;
1731
+ if (typeof x === "string") {
1732
+ values.push(x);
1733
+ pairs.push(key ? `${key}=${x}` : x);
1734
+ }
1735
+ else if (Array.isArray(x))
1736
+ x.forEach((y) => walk(y, key, depth + 1));
1737
+ else if (x && typeof x === "object")
1738
+ Object.entries(x).forEach(([k, y]) => walk(y, k, depth + 1));
1739
+ };
1740
+ walk(v, "", 0);
1741
+ return values.length > 0 ? { match: values.join(" "), show: pairs.join(" ") } : { match: line, show: line };
346
1742
  }
347
1743
  /**
348
- * Coverage verdicts for the workspace at `root` AND every repo connected to
349
- * it, each tagged with its alias. Each repo's indexer writes its own
350
- * `.vexp/coverage.json`; reading only the primary's hid three oversized files
351
- * in a connected repo from a doctor run at the primary (same report).
1744
+ * ZCode's own log, read as text (its line schema is not documented): the
1745
+ * hook runs that failed, vexp's apart, and the project-level hook configs
1746
+ * it ignored (expected: ZCode never runs project hooks). `lines` null: no
1747
+ * log folder. `files` names what was read.
352
1748
  */
353
- export function workspaceCoverageFindings(root) {
354
- const repos = listWorkspaceRepos(root);
355
- const targets = repos.map((r) => ({ alias: r.alias, dir: r.resolvedPath, primary: r.isPrimary }));
356
- if (!targets.some((t) => t.primary))
357
- targets.unshift({ alias: path.basename(root), dir: root, primary: true });
358
- const out = [];
359
- for (const t of targets) {
360
- let cov;
361
- try {
362
- cov = JSON.parse(fs.readFileSync(path.join(t.dir, ".vexp", "coverage.json"), "utf-8"));
363
- }
364
- catch {
365
- continue; /* never indexed, or an index older than 2.7 */
1749
+ export function zcodeLogFindings(lines, files = []) {
1750
+ if (lines === null) {
1751
+ return [{ level: NOTE, message: "no ZCode log folder (~/.zcode/cli/log): ZCode has not run a session on this machine, or keeps its log elsewhere" }];
1752
+ }
1753
+ const ignored = [];
1754
+ const failures = [];
1755
+ for (const raw of lines) {
1756
+ if (!raw.trim())
1757
+ continue;
1758
+ const t = logLineText(raw);
1759
+ if (/\bignor/i.test(t.match) && /(project|workspace)/i.test(t.match) && /(hook|config)/i.test(t.match)) {
1760
+ ignored.push(t.show);
1761
+ continue;
366
1762
  }
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 });
1763
+ if (/hook/i.test(t.match) && /(fail|error|timed? ?out|denied|ENOENT|EACCES|EPERM)/i.test(t.match))
1764
+ failures.push(t);
370
1765
  }
371
- return out;
372
- }
373
- /**
374
- * The 7-day activity ledger as doctor lines. Zero calls is not "not
375
- * working": silence on oriented prompts is vexp doing its job, so the OK
376
- * line exists whenever prompts were analyzed. The WARN is the other half of
377
- * the same ledger: orientations the daemon finished after the hook client
378
- * (3 s) had stopped waiting were computed, counted by 3.1.x as served, and
379
- * never read by the agent (field ledger, 3.1.3: up to 8 of 33).
380
- */
381
- export function ledgerFindings(ledger) {
1766
+ const ours = failures.filter((t) => /vexp|prompt-hint/i.test(t.match));
1767
+ const where = files.length > 0 ? ` (${files.join(", ")})` : "";
382
1768
  const out = [];
383
- const analyzed = Number(ledger.prompts_analyzed) || 0;
384
- if (analyzed > 0) {
385
- out.push({
386
- 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`,
388
- });
389
- }
390
- const late = Number(ledger.late) || 0;
391
- if (late > 0) {
1769
+ if (ours.length > 0) {
392
1770
  out.push({
393
1771
  level: WARN,
394
- message: `${late} orientation(s) in the last 7 days finished after the hook client's budget (3 s by default) and never reached the agent - long prompts are slow on this index; vexp savings shows the count`,
1772
+ message: `ZCode's log${where} records ${ours.length} failed run(s) of vexp's hook; the last: ${maskSecretsForReport(ours[ours.length - 1].show).slice(0, 200)}`,
395
1773
  });
396
1774
  }
1775
+ else if (failures.length > 0) {
1776
+ out.push({ level: INFO, message: `ZCode's log${where} records ${failures.length} failed run(s) of other hooks, none of vexp's` });
1777
+ }
1778
+ if (ignored.length > 0) {
1779
+ out.push({ level: INFO, message: `ZCode's log notes ${ignored.length} ignored project-level config(s): expected, ZCode never runs a project's hooks (vexp's hook is in the user config)` });
1780
+ }
1781
+ if (out.length === 0) {
1782
+ out.push(files.length > 0
1783
+ ? { level: OK, message: `ZCode's log${where}: no hook failures recorded` }
1784
+ : { level: NOTE, message: "the ZCode log folder holds no zcode-*.jsonl yet" });
1785
+ }
397
1786
  return out;
398
1787
  }
1788
+ /** The tail of the newest two ZCode logs, oldest first; null when there is no log folder. */
1789
+ export function readZcodeLogTail(dir, maxBytes = 2 * 1024 * 1024) {
1790
+ let names;
1791
+ try {
1792
+ names = fs.readdirSync(dir);
1793
+ }
1794
+ catch {
1795
+ return null;
1796
+ }
1797
+ const newest = names
1798
+ .filter((n) => /^zcode-.*\.jsonl$/.test(n))
1799
+ .map((n) => {
1800
+ try {
1801
+ return { n, mtime: fs.statSync(path.join(dir, n)).mtimeMs };
1802
+ }
1803
+ catch {
1804
+ return null;
1805
+ }
1806
+ })
1807
+ .filter((x) => x !== null)
1808
+ .sort((a, b) => b.mtime - a.mtime)
1809
+ .slice(0, 2)
1810
+ .reverse();
1811
+ const lines = [];
1812
+ const files = [];
1813
+ for (const { n } of newest) {
1814
+ let fd;
1815
+ try {
1816
+ fd = fs.openSync(path.join(dir, n), "r");
1817
+ const size = fs.fstatSync(fd).size;
1818
+ const start = Math.max(0, size - maxBytes);
1819
+ const buf = Buffer.alloc(size - start);
1820
+ fs.readSync(fd, buf, 0, buf.length, start);
1821
+ const text = buf.toString("utf-8").split(/\r?\n/);
1822
+ // A cut first line is half a record.
1823
+ lines.push(...(start > 0 ? text.slice(1) : text));
1824
+ files.push(n);
1825
+ }
1826
+ catch {
1827
+ /* unreadable: skip it */
1828
+ }
1829
+ finally {
1830
+ if (fd !== undefined) {
1831
+ try {
1832
+ fs.closeSync(fd);
1833
+ }
1834
+ catch {
1835
+ /* closed */
1836
+ }
1837
+ }
1838
+ }
1839
+ }
1840
+ return { files, lines };
1841
+ }
399
1842
  export const SUPPORT_EMAIL = "staff@vexp.dev";
400
1843
  export const DEFAULT_REPORT_FILE = path.join(".vexp", "vexp-report.md");
401
1844
  // eslint-disable-next-line no-control-regex
@@ -427,6 +1870,69 @@ export function maskSecretsForReport(text) {
427
1870
  out = out.replace(ASSIGNED_SECRET, (whole, key, val) => /[$<{]/.test(val) ? whole : key + maskValue(val));
428
1871
  return out;
429
1872
  }
1873
+ /** Where the first invalid UTF-8 sequence starts (Rust's `valid_up_to`). */
1874
+ function utf8ValidUpTo(b) {
1875
+ let i = 0;
1876
+ while (i < b.length) {
1877
+ const c = b[i];
1878
+ if (c < 0x80) {
1879
+ i++;
1880
+ continue;
1881
+ }
1882
+ const len = c >= 0xc2 && c <= 0xdf ? 2 : c >= 0xe0 && c <= 0xef ? 3 : c >= 0xf0 && c <= 0xf4 ? 4 : 0;
1883
+ if (len === 0 || i + len > b.length)
1884
+ return i;
1885
+ // The second byte's range excludes overlong forms, surrogates and > U+10FFFF.
1886
+ const lo = c === 0xe0 ? 0xa0 : c === 0xf0 ? 0x90 : 0x80;
1887
+ const hi = c === 0xed ? 0x9f : c === 0xf4 ? 0x8f : 0xbf;
1888
+ if (b[i + 1] < lo || b[i + 1] > hi)
1889
+ return i;
1890
+ for (let k = 2; k < len; k++)
1891
+ if ((b[i + k] & 0xc0) !== 0x80)
1892
+ return i;
1893
+ i += len;
1894
+ }
1895
+ return i;
1896
+ }
1897
+ /**
1898
+ * vexp.toml's bytes as text, the way the engine decodes them
1899
+ * (vexp-core config.rs, decode_config_bytes): a byte-order mark is honoured
1900
+ * (UTF-8, UTF-16LE, UTF-16BE), without one the text must be UTF-8. Windows
1901
+ * PowerShell 5.1's `>` writes UTF-16LE (field report, 2026-09-24: the
1902
+ * daemon refused to start on one), which the engine now reads; the report
1903
+ * still read it as UTF-8 and would have shown support the text with a NUL
1904
+ * between every letter instead of the settings the engine runs with.
1905
+ */
1906
+ export function decodeConfigBytes(bytes) {
1907
+ const utf16 = (rest, littleEndian, encoding) => {
1908
+ if (rest.length % 2 !== 0)
1909
+ return { problem: "is UTF-16 with a truncated last character" };
1910
+ const le = littleEndian ? rest : Uint8Array.from(rest, (_, i) => rest[i ^ 1]);
1911
+ try {
1912
+ return { text: new TextDecoder("utf-16le", { fatal: true, ignoreBOM: true }).decode(le), encoding };
1913
+ }
1914
+ catch {
1915
+ return { problem: "is not valid UTF-16 text" };
1916
+ }
1917
+ };
1918
+ const utf8 = (rest, bom, encoding) => {
1919
+ try {
1920
+ return { text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(rest), encoding };
1921
+ }
1922
+ catch {
1923
+ // Worded, and the offset counted, as the engine's warning does.
1924
+ const at = utf8ValidUpTo(rest) + (bom ? 3 : 0);
1925
+ return { problem: bom ? `is not valid UTF-8 (invalid byte at offset ${at})` : `is not UTF-8 text (invalid byte at offset ${at})` };
1926
+ }
1927
+ };
1928
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
1929
+ return utf8(bytes.subarray(3), true, "UTF-8 with a byte-order mark");
1930
+ if (bytes[0] === 0xff && bytes[1] === 0xfe)
1931
+ return utf16(bytes.subarray(2), true, "UTF-16LE");
1932
+ if (bytes[0] === 0xfe && bytes[1] === 0xff)
1933
+ return utf16(bytes.subarray(2), false, "UTF-16BE");
1934
+ return utf8(bytes, false, "UTF-8");
1935
+ }
430
1936
  /**
431
1937
  * The report a tester can hand over without editing it first.
432
1938
  *
@@ -466,7 +1972,14 @@ export function buildDoctorReport(i) {
466
1972
  else
467
1973
  parts.push("```", ...i.logWarnings, "```");
468
1974
  parts.push("", "## Config (.vexp/vexp.toml)", "");
469
- parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
1975
+ if (i.configProblem) {
1976
+ parts.push(`present, but it ${i.configProblem}: the engine ignores it and runs with the defaults (save it as UTF-8)`);
1977
+ }
1978
+ else {
1979
+ if (i.configEncoding && i.configText !== null)
1980
+ parts.push(`saved as ${i.configEncoding} (the engine reads it)`, "");
1981
+ parts.push(i.configText === null ? "absent — defaults in use" : "```toml\n" + i.configText.trim() + "\n```");
1982
+ }
470
1983
  parts.push("");
471
1984
  return maskSecretsForReport(parts.join("\n"));
472
1985
  }
@@ -496,6 +2009,41 @@ export function logWarningsFrom(text, keep = 80, scan = 3000) {
496
2009
  .map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
497
2010
  .slice(-keep);
498
2011
  }
2012
+ /** The home folders iCloud's "Desktop & Documents Folders" syncs on this
2013
+ * Mac: the ones com~apple~CloudDocs holds a symlink to. Sync off, no link. */
2014
+ function icloudLinkedFolders(home) {
2015
+ if (process.platform !== "darwin")
2016
+ return [];
2017
+ const out = [];
2018
+ for (const name of ["Desktop", "Documents"]) {
2019
+ try {
2020
+ const link = path.join(home, "Library", "Mobile Documents", "com~apple~CloudDocs", name);
2021
+ const local = path.join(home, name);
2022
+ if (!fs.lstatSync(link).isSymbolicLink())
2023
+ continue;
2024
+ const real = fs.realpathSync.native(local);
2025
+ if (samePath(fs.realpathSync.native(link), real))
2026
+ out.push(real);
2027
+ }
2028
+ catch { /* not there: not synced */ }
2029
+ }
2030
+ return out;
2031
+ }
2032
+ /** cloudSyncNotice for this machine: the root as given, then its real path
2033
+ * (a ~/Repos symlink into iCloud Drive is still in iCloud Drive). */
2034
+ function cloudSyncHere(root) {
2035
+ const home = os.homedir();
2036
+ const ctx = { platform: process.platform, home, env: process.env, icloudLinked: icloudLinkedFolders(home) };
2037
+ const direct = cloudSyncNotice(root, ctx);
2038
+ if (direct)
2039
+ return direct;
2040
+ try {
2041
+ return cloudSyncNotice(fs.realpathSync.native(root), ctx);
2042
+ }
2043
+ catch {
2044
+ return null;
2045
+ }
2046
+ }
499
2047
  /** Gather every input on this machine and write the report. Returns its path. */
500
2048
  export async function writeDoctorReport(o) {
501
2049
  let bin = null;
@@ -538,8 +2086,17 @@ export async function writeDoctorReport(o) {
538
2086
  }
539
2087
  }
540
2088
  let configText = null;
2089
+ let configProblem;
2090
+ let configEncoding;
541
2091
  try {
542
- configText = fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml"), "utf-8");
2092
+ const decoded = decodeConfigBytes(fs.readFileSync(path.join(o.root, ".vexp", "vexp.toml")));
2093
+ if ("problem" in decoded)
2094
+ configProblem = decoded.problem;
2095
+ else {
2096
+ configText = decoded.text;
2097
+ if (decoded.encoding !== "UTF-8")
2098
+ configEncoding = decoded.encoding;
2099
+ }
543
2100
  }
544
2101
  catch {
545
2102
  /* absent: defaults */
@@ -558,7 +2115,9 @@ export async function writeDoctorReport(o) {
558
2115
  logName,
559
2116
  logWarnings,
560
2117
  configText,
561
- mountNotice: slowMountNotice(o.root),
2118
+ configProblem,
2119
+ configEncoding,
2120
+ mountNotice: [slowMountNotice(o.root), cloudSyncHere(o.root)].filter(Boolean).join(" ") || null,
562
2121
  });
563
2122
  const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
564
2123
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -612,6 +2171,13 @@ async function doctorChecks(onWorkspace) {
612
2171
  fails++;
613
2172
  console.log(` [${status}] ${msg}`);
614
2173
  };
2174
+ // A pure verdict's findings: NOTE is a dim, indented aside, never counted.
2175
+ const emit = (f) => {
2176
+ if (f.level === NOTE)
2177
+ console.log(chalk.dim(` ${f.message}`));
2178
+ else
2179
+ line(f.level, f.message);
2180
+ };
615
2181
  console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
616
2182
  // 1) Workspace resolution for the current directory.
617
2183
  console.log(chalk.bold("Workspace targeting"));
@@ -659,6 +2225,9 @@ async function doctorChecks(onWorkspace) {
659
2225
  // 2.3 B1/B2 — live daemon truth: coverage + active compressor. The config can
660
2226
  // say "LLM enabled" while the daemon serves rule-compressed output (config
661
2227
  // race / stale daemon / non-LLM build); only the daemon knows what's active.
2228
+ // Prompts that reached vexp through an agent's hook (7 days): the hook
2229
+ // probes below weigh their own failures against it.
2230
+ let hookPrompts7d = 0;
662
2231
  if (live) {
663
2232
  const st = await queryDaemon(sock, "index_status");
664
2233
  if (st) {
@@ -726,26 +2295,42 @@ async function doctorChecks(onWorkspace) {
726
2295
  // support tickets ("is it working? it never got called").
727
2296
  const ledger = (st.ledger ?? {});
728
2297
  const analyzed = Number(ledger.prompts_analyzed) || 0;
2298
+ hookPrompts7d = analyzed;
729
2299
  for (const f of ledgerFindings(ledger))
730
2300
  line(f.level, f.message);
731
- if (sessions.length > 0) {
732
- const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
733
- line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
734
- }
735
- else if (analyzed > 0) {
736
- line(OK, `no tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)`);
737
- }
738
- else if (Number(st.daemon_uptime_s) > 600) {
739
- // Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
740
- // the daemon was unreachable serves from its own in-process index and
741
- // never attaches to the daemon later — its calls are real but
742
- // invisible to these counters. Don't tell that user "the agent is not
743
- // using vexp"; tell them how to converge on the daemon.
744
- line(WARN, `no agent session has called vexp through this daemon since it started — either no agent is using vexp here (check its MCP config), or the agent's vexp MCP server started BEFORE the daemon and is running embedded (in-process). If calls do succeed in the agent, restart the agent (with the daemon already up) so its MCP server attaches to the daemon.`);
745
- }
746
- else {
747
- line(OK, `no session calls yet (daemon just started)`);
2301
+ // The hook's abstentions, apart from the ledger they never change.
2302
+ for (const f of hookActivityFindings(st.hook_activity, ledger, Math.floor(Date.now() / 1000)))
2303
+ emit(f);
2304
+ // Agent sessions only: the hook's pseudo-session and one-shot CLI
2305
+ // calls were counted as "active" sessions (field report, 2026-09-28).
2306
+ const hookLastAt = st.hook_activity?.last_at;
2307
+ for (const f of sessionsFindings(sessions, {
2308
+ uptimeS: Number(st.daemon_uptime_s) || 0,
2309
+ prompts7d: analyzed,
2310
+ hookLastAt: typeof hookLastAt === "number" ? hookLastAt : null,
2311
+ nowS: Math.floor(Date.now() / 1000),
2312
+ }))
2313
+ emit(f);
2314
+ }
2315
+ }
2316
+ // Where the agent hooks ran from, as the hook client recorded it: read from
2317
+ // disk, so it speaks also when no daemon answers here, which is exactly
2318
+ // when an agent with the parent folder open leaves its only trace.
2319
+ {
2320
+ let text = null;
2321
+ try {
2322
+ text = fs.readFileSync(hookCallsPath(), "utf-8");
2323
+ }
2324
+ catch { /* no hook has run on this machine since the breadcrumbs exist */ }
2325
+ if (text !== null) {
2326
+ const aliases = [canonicalWorkspaceRoot(ws.root)];
2327
+ try {
2328
+ aliases.push(fs.realpathSync.native(ws.root));
748
2329
  }
2330
+ catch { /* the root as given */ }
2331
+ const neverServed = [os.homedir(), vexpHome()];
2332
+ for (const f of hookCallFindings(parseHookCalls(text), ws.root, Math.floor(Date.now() / 1000), process.platform, aliases, neverServed))
2333
+ emit(f);
749
2334
  }
750
2335
  }
751
2336
  // 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
@@ -755,6 +2340,21 @@ async function doctorChecks(onWorkspace) {
755
2340
  // repo alias so a run at the primary sees the whole workspace.
756
2341
  for (const f of workspaceCoverageFindings(ws.root))
757
2342
  line(f.level, f.message);
2343
+ // Whether each index applies the ignore rules on disk, read from the index
2344
+ // itself: the CLI-only case has no daemon to ask.
2345
+ {
2346
+ let bin = null;
2347
+ try {
2348
+ const { getBinaryPath } = await import("./binary.js");
2349
+ bin = getBinaryPath();
2350
+ }
2351
+ catch { /* no binary: no rules line */ }
2352
+ if (bin) {
2353
+ const exe = bin;
2354
+ for (const f of workspaceRulesFindings(ws.root, (dir) => coreIndexStatus(exe, dir)))
2355
+ line(f.level, f.message);
2356
+ }
2357
+ }
758
2358
  // 2) Daemon registry (~/.vexp/daemons.json) — stale entries are a drift source.
759
2359
  console.log(chalk.bold("\nDaemon registry (~/.vexp/daemons.json)"));
760
2360
  const entries = Object.entries(registry);
@@ -825,16 +2425,61 @@ async function doctorChecks(onWorkspace) {
825
2425
  line(OK, `${name}: valid, ~${days}d remaining`);
826
2426
  }
827
2427
  }
2428
+ // Registration: only a check-in with vexp.dev puts this machine on the
2429
+ // account's device list, and nothing long-running used to make one — a
2430
+ // license could work for a month with the dashboard reading 0 devices.
2431
+ // The stamp alone is not proof: a revocation or a device-cap refusal writes
2432
+ // it too. Registered = the stamp AND a fresh token that still verifies.
2433
+ if (fs.existsSync(path.join(home, ".vexp", "license.jwt"))) {
2434
+ const reg = registrationState();
2435
+ const retry = "run `vexp license` online to check in and see why";
2436
+ switch (reg.state) {
2437
+ case "blocked":
2438
+ line(WARN, `device: NOT registered - the license's ${reg.blocked.maxDevices} device slots are full; free one at ${reg.blocked.manageUrl}`);
2439
+ break;
2440
+ case "registered":
2441
+ line(OK, `device: registered (last check-in ${formatCheckIn(reg.at)})`);
2442
+ break;
2443
+ case "not_registered":
2444
+ line(WARN, `device: last check-in ${formatCheckIn(reg.at)} did not register this device; ${retry}`);
2445
+ break;
2446
+ case "unconfirmed":
2447
+ line(WARN, `device: vexp.dev renewed the license but has not confirmed this device on your account yet; ${retry}`);
2448
+ break;
2449
+ case "never":
2450
+ 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");
2451
+ break;
2452
+ }
2453
+ }
828
2454
  // 4) Codex MCP transport stanza — global AND project-level. Codex supports
829
2455
  // per-project `.codex/config.toml`; checking only the global file made
830
2456
  // doctor report "[OK] no stanza" to users whose (working) config lives in
831
2457
  // the workspace.
832
2458
  console.log(chalk.bold("\nCodex (config.toml)"));
2459
+ // Whether this project chose Codex decides every Codex remedy below: a
2460
+ // user who did not is never sent to `vexp setup`, which on 3.2.5 set Codex
2461
+ // up again from its own leftovers.
2462
+ const codexUse = codexUseHere(ws.root);
833
2463
  const codexConfigs = [
834
2464
  { label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
835
2465
  { label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
836
2466
  ];
837
2467
  let codexStanzaSeen = false;
2468
+ // An entry Codex can start: the hook verdict below says "the tools work"
2469
+ // only after one (not after a url + command one, which Codex refuses), and
2470
+ // only when it names no project or this one — the machine-wide entry is
2471
+ // pinned to whichever project was set up last, and from any other one
2472
+ // Codex would answer from that project's index (review of the fix,
2473
+ // 2026-09-24).
2474
+ let codexMcpUsable = false;
2475
+ let codexMcpElsewhere;
2476
+ const codexEntryHere = (section) => {
2477
+ const pin = codexSectionPin(section);
2478
+ if ((pin.path === undefined && pin.hash === undefined) || codexSectionPinnedTo(section, ws.root))
2479
+ return true;
2480
+ codexMcpElsewhere ??= pin.path ?? "another project";
2481
+ return false;
2482
+ };
838
2483
  for (const { label, file } of codexConfigs) {
839
2484
  if (!fs.existsSync(file)) {
840
2485
  line(OK, `${label}: absent`);
@@ -854,15 +2499,30 @@ async function doctorChecks(onWorkspace) {
854
2499
  codexStanzaSeen = true;
855
2500
  const hasUrl = /^\s*url\s*=/m.test(section);
856
2501
  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)
2502
+ if (hasUrl && hasCmd) {
2503
+ line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. ` + codexBrokenStanzaRemedy(codexUse, section, ws.root));
2504
+ }
2505
+ else if (hasUrl) {
2506
+ if (codexEntryHere(section))
2507
+ codexMcpUsable = true;
860
2508
  line(OK, `${label}: transport http (url)`);
2509
+ // The URL routes to one workspace's daemon, so this form cannot follow
2510
+ // the session's folder the way the direct one does.
2511
+ if (section.includes("# vexp-managed") && codexSectionPin(section).hash !== undefined) {
2512
+ line(WARN, codexHttpPinNote(codexSectionPinnedTo(section, ws.root)));
2513
+ }
2514
+ }
861
2515
  else if (hasCmd) {
2516
+ const here = codexEntryHere(section);
2517
+ if (here)
2518
+ codexMcpUsable = true;
862
2519
  const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
863
2520
  const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
864
2521
  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"}`);
2522
+ line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin: the server starts in each Codex session's folder"}`);
2523
+ const pin = codexSectionPin(section).path;
2524
+ if (!here && pin !== undefined && section.includes("# vexp-managed"))
2525
+ line(WARN, codexStdioPinNote(section, pin, ws.root));
866
2526
  }
867
2527
  else
868
2528
  line(WARN, `${label}: stanza present but neither url nor command found`);
@@ -937,11 +2597,18 @@ async function doctorChecks(onWorkspace) {
937
2597
  // network share) makes every vexp call 10-50x slower and reads as a vexp
938
2598
  // bug (field report, 2026-09-09: verify_done at 41-170 s on /app over 9p,
939
2599
  // every other line of doctor green).
2600
+ // And a workspace inside a cloud-synced folder uploads .vexp/index.db, a
2601
+ // live SQLite database, on every write (field report, 2026-09-28: iCloud
2602
+ // Drive on macOS).
940
2603
  {
941
2604
  const slow = slowMountNotice(ws.root);
942
- if (slow) {
2605
+ const cloud = cloudSyncHere(ws.root);
2606
+ if (slow || cloud) {
943
2607
  console.log(chalk.bold("\nFilesystem"));
944
- line(WARN, slow);
2608
+ if (slow)
2609
+ line(WARN, slow);
2610
+ if (cloud)
2611
+ line(WARN, cloud);
945
2612
  }
946
2613
  }
947
2614
  // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
@@ -949,6 +2616,19 @@ async function doctorChecks(onWorkspace) {
949
2616
  // path containing a space fails non-blocking on every call: the guard never
950
2617
  // denies anything while the config "looks correct" and presence-only checks
951
2618
  // report healthy (Nathan, 2026-07).
2619
+ // Git Bash is looked up once, and only on Windows when a hook needs it.
2620
+ let gitBashLookup;
2621
+ const claudeGitBash = () => {
2622
+ if (!gitBashLookup) {
2623
+ gitBashLookup = resolveClaudeGitBash({ cwd: ws.root, setting: claudeGitBashSetting() });
2624
+ // A pin in the project's own settings is what someone tried; say it
2625
+ // does not count, once, where the lookup is first needed.
2626
+ const pins = projectGitBashPins(ws.root);
2627
+ if (pins.length > 0)
2628
+ line(INFO, projectGitBashPinNote(pins));
2629
+ }
2630
+ return gitBashLookup;
2631
+ };
952
2632
  console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
953
2633
  {
954
2634
  const sPath = path.join(ws.root, ".claude", "settings.json");
@@ -991,27 +2671,37 @@ async function doctorChecks(onWorkspace) {
991
2671
  if (timeoutS > 600) {
992
2672
  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
2673
  }
2674
+ if (isPowerShellHook(h)) {
2675
+ line(INFO, `guard hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
2676
+ continue;
2677
+ }
994
2678
  // 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).
2679
+ // the placeholder substituted by the host; shell form = the shell
2680
+ // Claude Code uses (claudeHookSpawnSpec: Git Bash on Windows, never
2681
+ // a `bash` from PATH), both from the project folder.
1000
2682
  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], {
2683
+ let spawned = substituted;
2684
+ let r;
2685
+ if (execForm) {
2686
+ r = spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
1009
2687
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
2688
+ cwd: ws.root,
1010
2689
  timeout: 5000,
1011
2690
  encoding: "utf-8",
1012
2691
  });
2692
+ }
2693
+ else {
2694
+ const bash = process.platform === "win32" ? claudeGitBash() : null;
2695
+ const spec = claudeHookSpawnSpec(cmd, ws.root, process.platform, bash?.path ?? null);
2696
+ if (!spec) {
2697
+ line(BAD, claudeShellMissing("the guard hook", bash));
2698
+ continue;
2699
+ }
2700
+ spawned = spec.file;
2701
+ r = spawnSync(spec.file, spec.args, { env: spec.env, cwd: spec.cwd, timeout: 5000, encoding: "utf-8", windowsHide: true });
2702
+ }
1013
2703
  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'.`);
2704
+ 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
2705
  }
1016
2706
  else if (r.status !== 0) {
1017
2707
  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 +2744,12 @@ async function doctorChecks(onWorkspace) {
1054
2744
  return false;
1055
2745
  }
1056
2746
  });
1057
- const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length);
2747
+ let declined;
2748
+ try {
2749
+ declined = fs.readFileSync(path.join(ws.root, ".vexp", "git-hooks.declined"), "utf-8").split(/\r?\n/)[0];
2750
+ }
2751
+ catch { /* not declined */ }
2752
+ const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length, declined);
1058
2753
  line(verdict.level, verdict.message);
1059
2754
  }
1060
2755
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
@@ -1091,6 +2786,9 @@ async function doctorChecks(onWorkspace) {
1091
2786
  : "no .claude/settings.json (Claude Code not configured here)");
1092
2787
  }
1093
2788
  else {
2789
+ // Hooks Claude Code cannot run on this PC for want of Git Bash: one line
2790
+ // for all of them, after the loop.
2791
+ const noShell = [];
1094
2792
  for (const w of wanted) {
1095
2793
  const entries = Array.isArray(settings?.hooks?.[w.event]) ? settings.hooks[w.event] : [];
1096
2794
  const hook = entries
@@ -1105,6 +2803,13 @@ async function doctorChecks(onWorkspace) {
1105
2803
  }
1106
2804
  continue;
1107
2805
  }
2806
+ // Before the .sh check: a PowerShell entry names its own script (a
2807
+ // .ps1), and "vexp-verify.sh is missing — fails on every prompt" was
2808
+ // a FAIL for an entry that never runs that file (review, 2026-09-24).
2809
+ if (isPowerShellHook(hook)) {
2810
+ line(INFO, `${w.event} hook declares "shell": "powershell" — not tested here; Claude Code runs it with PowerShell`);
2811
+ continue;
2812
+ }
1108
2813
  const scriptPath = path.join(ws.root, ".claude", "hooks", `${w.marker}.sh`);
1109
2814
  if (!fs.existsSync(scriptPath)) {
1110
2815
  line(BAD, `${w.event} points at ${w.marker}.sh but the script is missing — the hook fails on every prompt.`);
@@ -1116,17 +2821,26 @@ async function doctorChecks(onWorkspace) {
1116
2821
  : w.event === "Stop"
1117
2822
  ? { session_id: "vexp-doctor", stop_hook_active: true, cwd: ws.root }
1118
2823
  : { 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 },
2824
+ // In Claude Code's own shell (claudeHookSpawnSpec), from the project.
2825
+ const bash = process.platform === "win32" ? claudeGitBash() : null;
2826
+ const spec = claudeHookSpawnSpec(hook.command, ws.root, process.platform, bash?.path ?? null);
2827
+ if (!spec) {
2828
+ noShell.push(w.event);
2829
+ continue;
2830
+ }
2831
+ const r = spawnSync(spec.file, spec.args, {
2832
+ env: spec.env,
2833
+ cwd: spec.cwd,
1121
2834
  input: payload,
1122
2835
  timeout: 10000,
1123
2836
  encoding: "utf-8",
2837
+ windowsHide: true,
1124
2838
  });
1125
2839
  if (r.error) {
1126
- line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.`);
2840
+ line(BAD, `${w.event} hook DID NOT RUN: ${r.error.code ?? r.error.message} — the ${w.label} is inert.` + hookLedgerNote(hookPrompts7d));
1127
2841
  }
1128
2842
  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.`);
2843
+ 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
2844
  }
1131
2845
  else {
1132
2846
  // Silence is a legitimate outcome for the hint hook (the classifier
@@ -1135,6 +2849,9 @@ async function doctorChecks(onWorkspace) {
1135
2849
  line(OK, `${w.event} hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
1136
2850
  }
1137
2851
  }
2852
+ if (noShell.length > 0) {
2853
+ line(BAD, claudeShellMissing(`the ${noShell.join(", ")} hook${noShell.length > 1 ? "s" : ""}`, claudeGitBash()));
2854
+ }
1138
2855
  }
1139
2856
  }
1140
2857
  // 5b-bis) Codex orientation hook. The section above it reports Codex's MCP
@@ -1148,97 +2865,211 @@ async function doctorChecks(onWorkspace) {
1148
2865
  {
1149
2866
  const hooksJson = path.join(ws.root, ".codex", "hooks.json");
1150
2867
  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
2868
  let cfg = null;
2869
+ let hooksRaw = "";
2870
+ // The installer's own parser: a BOM (Windows PowerShell 5.1 writes one)
2871
+ // made a valid file read as "no .codex/hooks.json" here.
1162
2872
  try {
1163
- cfg = JSON.parse(fs.readFileSync(hooksJson, "utf-8"));
2873
+ hooksRaw = fs.readFileSync(hooksJson, "utf-8");
2874
+ cfg = parseJsonc(hooksRaw);
1164
2875
  }
1165
2876
  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)");
2877
+ // Two shapes exist in the wild: the installer nests under "hooks", and
2878
+ // files from earlier versions (or edited by hand) carry the event at the
2879
+ // top level. Reading only one of them would leave the other undiagnosable
2880
+ // — and the flat one is what the user who reported this had on disk.
2881
+ const events = (Array.isArray(cfg?.hooks?.UserPromptSubmit)
2882
+ ? cfg.hooks.UserPromptSubmit
2883
+ : Array.isArray(cfg?.UserPromptSubmit)
2884
+ ? cfg.UserPromptSubmit
2885
+ : []);
2886
+ const hook = events
2887
+ .flatMap((m) => (Array.isArray(m?.hooks) ? m.hooks : []))
2888
+ .find((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
2889
+ const verdict = codexHookVerdict({
2890
+ platform: process.platform,
2891
+ root: ws.root,
2892
+ use: codexUse,
2893
+ mcpEntrySeen: codexMcpUsable,
2894
+ mcpEntryElsewhere: codexMcpElsewhere,
2895
+ hooksJson: !cfg ? "absent" : hook ? "entry" : "no-entry",
2896
+ hasCommandWindows: typeof hook?.commandWindows === "string",
2897
+ commandWindows: typeof hook?.commandWindows === "string" ? hook.commandWindows : undefined,
2898
+ // The script the platform's command runs: on Windows the entry's
2899
+ // Windows command, vexp-hint.cmd.
2900
+ scriptPresent: fs.existsSync(process.platform === "win32" && typeof hook?.commandWindows === "string" ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath),
2901
+ leftovers: codexUse.used ? { files: [], onlyOurs: false } : codexLeftovers(ws.root),
2902
+ });
2903
+ // Reading past the BOM must not turn into an [OK] for a file Codex may
2904
+ // refuse: serde_json, Rust's usual JSON reader, rejects one ("expected
2905
+ // value at line 1 column 1", checked on 1.0.150), and a hooks.json Codex
2906
+ // cannot parse runs none of its hooks. Before the tolerant read above,
2907
+ // the wrong "no .codex/hooks.json" at least led to a rewrite (review of
2908
+ // the fix, 2026-09-24). The installer leaves a file that already holds
2909
+ // vexp's entry as it is, so the remedy is a re-save, not 'vexp setup'.
2910
+ if (codexUse.used && cfg && hooksRaw.charCodeAt(0) === 0xfeff)
2911
+ line(WARN, CODEX_HOOKS_BOM_NOTE);
2912
+ if (!verdict.run) {
2913
+ line(verdict.level, verdict.message);
1171
2914
  }
1172
2915
  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);
2916
+ // Run it, through the shells Codex would use: on Windows PowerShell
2917
+ // (its default) and cmd.exe with the line wrapped the way Codex wraps
2918
+ // it (its fallback), sh elsewhere. The script bakes an absolute path
2919
+ // to the vexp binary and exits 0 when that path is not executable, so
2920
+ // a stale or wrong-profile path leaves NO trace anywhere: no
2921
+ // orientation, no error, forever. A Windows user found exactly that
2922
+ // by reading the generated file.
2923
+ const isWin = process.platform === "win32";
2924
+ const cmdLine = (isWin ? hook.commandWindows : hook.command);
2925
+ const probes = isWin
2926
+ ? [
2927
+ { shell: "PowerShell", spec: codexPowerShellSpawnSpec(cmdLine, windowsPowerShell()) },
2928
+ { shell: "cmd.exe", spec: codexHookSpawnSpec(cmdLine) },
2929
+ ]
2930
+ : [{ shell: "sh", spec: codexHookSpawnSpec(cmdLine) }];
2931
+ const baked = (() => {
2932
+ try {
2933
+ // One pattern covers both twins: VEXP_BIN="path" (bash) and
2934
+ // set "VEXP_BIN=path" (cmd).
2935
+ const probed = isWin ? path.join(ws.root, ".codex", "vexp-hint.cmd") : scriptPath;
2936
+ return fs.readFileSync(probed, "utf-8").match(/VEXP_BIN="?([^"\r\n]+)"?/)?.[1];
2937
+ }
2938
+ catch {
2939
+ return undefined;
2940
+ }
2941
+ })();
2942
+ for (const { shell, spec } of probes) {
2943
+ const via = isWin ? ` (through ${shell})` : "";
1209
2944
  const r = spawnSync(spec.file, spec.args, {
1210
2945
  windowsVerbatimArguments: spec.windowsVerbatimArguments,
1211
2946
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
2947
+ // From the project, as Codex runs it — except a \\server\share
2948
+ // root on Windows: cmd.exe cannot stand in one, prints "UNC paths are
2949
+ // not supported. Defaulting to Windows directory." on stderr and runs
2950
+ // from there anyway. Start it there directly; the hook line names its
2951
+ // script by absolute path.
2952
+ cwd: isWin && /^[\\/]{2}/.test(ws.root) ? process.env.SystemRoot || "C:\\Windows" : ws.root,
1212
2953
  input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
1213
2954
  timeout: 10000,
1214
2955
  encoding: "utf-8",
2956
+ windowsHide: true,
1215
2957
  });
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
2958
  if (r.error) {
1228
- line(BAD, `hook DID NOT RUN: ${r.error.code ?? r.error.message} — orientation is inert.`);
2959
+ line(BAD, `hook DID NOT RUN${via}: ${r.error.code ?? r.error.message}; orientation is inert.`);
1229
2960
  }
1230
2961
  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.`);
2962
+ line(BAD, `hook exited ${r.status}${via}${r.stderr ? `: ${String(r.stderr).trim().slice(0, 160)}` : ""}; Codex continues without vexp.`);
2963
+ }
2964
+ else if (hookEchoedItself(String(r.stdout ?? ""), cmdLine)) {
2965
+ 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
2966
  }
1233
2967
  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.`);
2968
+ // The root named, as in every other Codex remedy: a bare 'vexp setup'
2969
+ // takes the folder it runs in as the project, and from src/deep it
2970
+ // set that subfolder up as a new one (review of the fix, 2026-09-24).
2971
+ 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.`);
2972
+ break;
1235
2973
  }
1236
2974
  else {
1237
2975
  const why = String(r.stderr ?? "").trim().replace(/^vexp [\w-]+: /, "");
1238
- line(OK, `UserPromptSubmit hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
2976
+ line(OK, `UserPromptSubmit hook runs${via}${why ? ` (this probe: ${why.slice(0, 120)})` : ""}`);
1239
2977
  }
1240
2978
  }
1241
2979
  }
2980
+ // Running is not enough: Codex runs a project hook only in a project it
2981
+ // trusts, and only once the user approved that exact hook (/hooks in the
2982
+ // terminal, Settings > Hooks in the VS Code extension). Both records live
2983
+ // in Codex's own config.toml; vexp reads them and never writes them.
2984
+ const nested = Array.isArray(cfg?.hooks?.UserPromptSubmit) ? cfg.hooks.UserPromptSubmit : [];
2985
+ const groupIndex = nested.findIndex((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === "string" && h.command.includes("vexp-hint")));
2986
+ if (codexUse.used && groupIndex >= 0) {
2987
+ const handlerIndex = nested[groupIndex].hooks.findIndex((h) => typeof h?.command === "string" && h.command.includes("vexp-hint"));
2988
+ const home = codexHome();
2989
+ const configPath = path.join(home, "config.toml");
2990
+ let userConfig;
2991
+ try {
2992
+ userConfig = fs.readFileSync(configPath, "utf-8");
2993
+ }
2994
+ catch { /* none yet */ }
2995
+ const trust = codexHookTrust({
2996
+ platform: process.platform,
2997
+ userConfig,
2998
+ userConfigPath: configPath,
2999
+ otherLayers: codexOtherConfigLayers(),
3000
+ trustCandidates: codexTrustCandidates(ws.root, (args) => {
3001
+ try {
3002
+ const r = spawnSync("git", args, { cwd: ws.root, encoding: "utf-8", timeout: 5000, windowsHide: true });
3003
+ return r.status === 0 ? String(r.stdout).trim() || undefined : undefined;
3004
+ }
3005
+ catch {
3006
+ return undefined;
3007
+ }
3008
+ }),
3009
+ hooksJsonPath: hooksJson,
3010
+ groupIndex,
3011
+ handlerIndex,
3012
+ handler: nested[groupIndex].hooks[handlerIndex],
3013
+ });
3014
+ for (const f of codexTrustFindings(trust, ws.root, configPath, { OK, WARN }))
3015
+ line(f.level, f.message);
3016
+ }
3017
+ }
3018
+ // 5b-ter) ZCode. Setup has written its three pieces since 3.2.0 (AGENTS.md,
3019
+ // an MCP entry, a user-level hook) and doctor checked none of them: a user
3020
+ // whose ZCode had the project's parent folder open read an all-green doctor
3021
+ // and "vexp is not used" in the same afternoon (field report, 2026-09-28).
3022
+ // Every decision is in zcodeFindings; this block only reads and probes.
3023
+ console.log(chalk.bold("\nZCode (AGENTS.md, .zcode/config.json, ~/.zcode/cli/config.json)"));
3024
+ try {
3025
+ const home = os.homedir();
3026
+ let agentsMd = null;
3027
+ try {
3028
+ agentsMd = fs.readFileSync(path.join(ws.root, "AGENTS.md"), "utf-8");
3029
+ }
3030
+ catch { /* absent */ }
3031
+ const input = {
3032
+ root: ws.root,
3033
+ platform: process.platform,
3034
+ home,
3035
+ workspaceNative: readJsonFileState(path.join(ws.root, ".zcode", "config.json")),
3036
+ workspaceCompat: readJsonFileState(path.join(ws.root, ".agents", "mcp.json")),
3037
+ userNative: readJsonFileState(zcodeUserConfigPath()),
3038
+ userCompat: readJsonFileState(path.join(home, ".agents", "mcp.json")),
3039
+ agentsMd,
3040
+ exists: (p) => fs.existsSync(p),
3041
+ zcodeHomeExists: fs.existsSync(path.join(home, ".zcode")),
3042
+ consent: decisionFor(readAgentConsent(), ws.root, "ZCode"),
3043
+ };
3044
+ const inUse = zcodeInUse(input);
3045
+ const spec = inUse ? zcodeProbeSpec(input) : null;
3046
+ if (spec) {
3047
+ // Run it as ZCode does: a `process` hook, argv without a shell, from
3048
+ // the project. No hook_event_name or transcript_path in the payload:
3049
+ // the hook client takes it for a manual probe, so it counts in no
3050
+ // ledger and leaves no breadcrumb.
3051
+ const r = spawnSync(spec.command, spec.args, {
3052
+ input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
3053
+ cwd: ws.root,
3054
+ timeout: 10000,
3055
+ encoding: "utf-8",
3056
+ windowsHide: true,
3057
+ });
3058
+ input.probe = r.error
3059
+ ? { error: r.error.code ?? r.error.message, status: r.status, stderr: String(r.stderr ?? "") }
3060
+ : { status: r.status, stderr: String(r.stderr ?? "") };
3061
+ }
3062
+ for (const f of zcodeFindings(input))
3063
+ emit(f);
3064
+ if (inUse) {
3065
+ const log = readZcodeLogTail(path.join(home, ".zcode", "cli", "log"));
3066
+ for (const f of zcodeLogFindings(log ? log.lines : null, log ? log.files : []))
3067
+ emit(f);
3068
+ }
3069
+ }
3070
+ catch (e) {
3071
+ // Never the reason doctor stops: the sections below still run.
3072
+ line(INFO, `ZCode: not checked (${e?.message ?? String(e)})`);
1242
3073
  }
1243
3074
  // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
1244
3075
  // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
@@ -1382,7 +3213,9 @@ async function doctorChecks(onWorkspace) {
1382
3213
  else
1383
3214
  console.log(` ${fails > 0 ? BAD : WARN} ${fails} failure(s), ${warns} warning(s)`);
1384
3215
  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."));
3216
+ // The Codex half only where Codex was chosen: to anyone else it read as
3217
+ // one more reason to set Codex up (field report, 2026-09-24).
3218
+ 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
3219
  }
1387
3220
  console.log("");
1388
3221
  // Scripts and CI read the exit code, not the colors: any FAIL exits 1.