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