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