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