vexp-cli 3.3.8 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -6,8 +6,12 @@ import { execFileSync, spawnSync } from "child_process";
6
6
  import { fileURLToPath } from "url";
7
7
  import chalk from "chalk";
8
8
  import { fnvHash, socketPathFor, socketPathMargin } from "./socket-path.js";
9
- import { agentConsentPath, decisionFor, loadAgentConsentFile, readAgentConsent } from "./agent-consent.js";
10
- import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath, getAgentList, isVexpZcodeHookEntry, setupAgentsCommand, zcodeUserConfigPath } from "./agent-config.js";
9
+ import { agentConsentPath, decisionFor, inheritedAnswers, loadAgentConsentFile, readAgentConsent } from "./agent-consent.js";
10
+ import { extensionStartsOnOpen, linkedWorktreeMain } from "./git-worktree.js";
11
+ import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath, getAgentList, isVexpZcodeHookEntry, setupAgentsCommand, zcodeUserConfigPath, isDeadAntigravityEntry, antigravityOnThisMachine, antigravityGlobalMcpPath, gitTrackedPath, vexpCleansAntigravityFile, holdsOnlyVexp, projectFileLink, zcodeMcpTarget, projectMcpJsonVexp, worktreeMcpJsonNote, gitIn, expandedPin, dotMcpJsonPin, vexpPinOf, sameFolderAsExtension, dotMcpJsonMiss, dotMcpJsonKeptLine, } from "./agent-config.js";
12
+ // The pin and folder rules live with the writers, which apply the same .mcp.json
13
+ // condition (M2); the doctor's tests and the extension's lockstep import them here.
14
+ export { vexpPinOf, sameFolderAsExtension };
11
15
  import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
12
16
  import { CLI_VERSION } from "./version.js";
13
17
  import { formatCheckIn, registrationState } from "./license.js";
@@ -16,6 +20,7 @@ import { slowMountNotice } from "./slow-mount.js";
16
20
  import { cloudSyncNotice } from "./cloud-sync.js";
17
21
  import { vexpHome, isStateHome } from "./state-home.js";
18
22
  import { canonicalWorkspaceRoot } from "./socket-path.js";
23
+ import { devmindOff, devmindOffNote, isEndpointUnreachable, llmStatusReportLines } from "./devmind.js";
19
24
  import { codexHome, codexHookTrust, codexOtherConfigLayers, codexTrustCandidates, codexTrustFindings } from "./codex-trust.js";
20
25
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
21
26
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
@@ -26,19 +31,40 @@ const WARN = chalk.yellow("WARN");
26
31
  const BAD = chalk.red("FAIL");
27
32
  // Said, not counted: a check doctor cannot make here, with nothing wrong found.
28
33
  const INFO = chalk.cyan("INFO");
34
+ /** The nearest folder at or above `start` that is the root of a linked git worktree, or null (vexp-mcp linkedWorktreeRootOf). */
35
+ function linkedWorktreeRootOf(start) {
36
+ let cur = path.resolve(start);
37
+ while (true) {
38
+ if (linkedWorktreeMain(cur))
39
+ return cur;
40
+ const parent = path.dirname(cur);
41
+ if (parent === cur)
42
+ return null;
43
+ cur = parent;
44
+ }
45
+ }
29
46
  /** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
30
- * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
31
- function discoverWorkspaceRoot(start) {
32
- for (const test of [
33
- (d) => fs.existsSync(path.join(d, ".vexp", "manifest.json")) || fs.existsSync(path.join(d, ".vexp", "index.db")),
47
+ * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot.
48
+ * The first two passes never climb past the root of a linked git worktree:
49
+ * inside one the worktree is the root, with no .vexp yet when it is fresh,
50
+ * and the main checkout's index above it never holds its files. Run there,
51
+ * doctor diagnosed the main checkout's daemon as this workspace's (the
52
+ * 3.4.0 worktree release; lockstep with both MCP servers). */
53
+ export function discoverWorkspaceRoot(start) {
54
+ const boundary = linkedWorktreeRootOf(start);
55
+ const passes = [
56
+ { test: (d) => fs.existsSync(path.join(d, ".vexp", "manifest.json")) || fs.existsSync(path.join(d, ".vexp", "index.db")), walled: true },
34
57
  // Bare .vexp — except home/VEXP_HOME, whose .vexp is vexp's state dir.
35
- (d) => fs.existsSync(path.join(d, ".vexp")) && path.resolve(d) !== path.resolve(vexpHome()),
36
- (d) => fs.existsSync(path.join(d, ".git")),
37
- ]) {
38
- let cur = start;
58
+ { test: (d) => fs.existsSync(path.join(d, ".vexp")) && path.resolve(d) !== path.resolve(vexpHome()), walled: true },
59
+ { test: (d) => fs.existsSync(path.join(d, ".git")), walled: false },
60
+ ];
61
+ for (const { test, walled } of passes) {
62
+ let cur = path.resolve(start);
39
63
  while (true) {
40
64
  if (test(cur))
41
65
  return cur;
66
+ if (walled && cur === boundary)
67
+ break;
42
68
  const parent = path.dirname(cur);
43
69
  if (parent === cur)
44
70
  break;
@@ -47,11 +73,18 @@ function discoverWorkspaceRoot(start) {
47
73
  }
48
74
  return start;
49
75
  }
76
+ /** A workspace variable, unset when empty or when it still holds an editor variable (`${workspaceFolder}`): both MCP servers read it so (workspace_env_value / workspaceEnv). */
77
+ function workspaceEnvValue(name) {
78
+ const v = process.env[name];
79
+ return v && v.trim() !== "" && !v.includes("${") ? v : undefined;
80
+ }
50
81
  function resolveWorkspace() {
51
- if (process.env.VEXP_WORKSPACE)
52
- return { root: process.env.VEXP_WORKSPACE, source: "VEXP_WORKSPACE" };
53
- if (process.env.CLAUDE_PROJECT_DIR)
54
- return { root: process.env.CLAUDE_PROJECT_DIR, source: "CLAUDE_PROJECT_DIR" };
82
+ const vw = workspaceEnvValue("VEXP_WORKSPACE");
83
+ if (vw)
84
+ return { root: vw, source: "VEXP_WORKSPACE" };
85
+ const cpd = workspaceEnvValue("CLAUDE_PROJECT_DIR");
86
+ if (cpd)
87
+ return { root: cpd, source: "CLAUDE_PROJECT_DIR" };
55
88
  return { root: discoverWorkspaceRoot(process.cwd()), source: "cwd-discovery" };
56
89
  }
57
90
  // Was a fifth hand-rolled copy of the hash, and one of the two that zero-padded
@@ -647,7 +680,8 @@ export function idleStopFinding(idle) {
647
680
  * It used to be one WARN and "run: vexp daemon-cmd start", whoever read it.
648
681
  * With the IDE extension that is the ordinary state of a project that is not
649
682
  * open: the extension starts a project's daemon when its window opens (if
650
- * the project has an index) and stops it when the window closes. The reader
683
+ * the project has an index, or is a git worktree of a checkout set up for
684
+ * vexp) and stops it when the window closes. The reader
651
685
  * has no `vexp` command, and through npx that remedy starts a daemon out of
652
686
  * the npx cache that no window owns or stops (field report, 2026-10-08).
653
687
  * Doctor cannot see whether a window is open, so it says what it knows and
@@ -702,7 +736,7 @@ export function daemonDownFindings(o) {
702
736
  },
703
737
  ];
704
738
  }
705
- if (ext && !o.hasIndex) {
739
+ if (ext && !o.startsOnOpen) {
706
740
  return [
707
741
  {
708
742
  level: INFO,
@@ -961,8 +995,14 @@ export function claudeNotSetUpFinding(o) {
961
995
  */
962
996
  export function gitHooksVerdict(hooksPath, repoRoot, installedCount,
963
997
  /** The first line of .vexp/git-hooks.declined, when this clone said no to the hooks. */
964
- declined) {
965
- const ourHooksDir = path.join(repoRoot, ".git", "hooks");
998
+ declined,
999
+ /** Where vexp's hooks are (repositoryHooksFolder); `<repoRoot>/.git/hooks` when not given. */
1000
+ hooksDir = path.join(repoRoot, ".git", "hooks")) {
1001
+ const ourHooksDir = hooksDir;
1002
+ // The folder the lines name: ".git/hooks" for a normal checkout, as they
1003
+ // always said; where .git is a file (a submodule, a separate git dir, a
1004
+ // linked worktree) the folder git resolves, which is not <root>/.git/hooks.
1005
+ const where = path.resolve(hooksDir) === path.resolve(repoRoot, ".git", "hooks") ? ".git/hooks" : hooksDir;
966
1006
  if (declined !== undefined && installedCount === 0) {
967
1007
  // "Skip" in VS Code, `vexp setup --personal` or `vexp hooks remove`: the
968
1008
  // daemon installs none while .vexp/git-hooks.declined is there.
@@ -973,7 +1013,7 @@ declined) {
973
1013
  }
974
1014
  if (!hooksPath) {
975
1015
  if (installedCount === 3) {
976
- return { level: OK, message: "vexp hooks present in .git/hooks and git will run them" };
1016
+ return { level: OK, message: `vexp hooks present in ${where} and git will run them` };
977
1017
  }
978
1018
  if (installedCount === 0) {
979
1019
  return { level: OK, message: "no vexp git hooks (index refreshes on demand)" };
@@ -987,7 +1027,7 @@ declined) {
987
1027
  if (path.resolve(resolved) === path.resolve(ourHooksDir)) {
988
1028
  return {
989
1029
  level: OK,
990
- message: `core.hooksPath points at .git/hooks (${installedCount}/3 vexp hooks present)`,
1030
+ message: `core.hooksPath points at ${where} (${installedCount}/3 vexp hooks present)`,
991
1031
  };
992
1032
  }
993
1033
  if (installedCount === 0) {
@@ -995,11 +1035,118 @@ declined) {
995
1035
  }
996
1036
  return {
997
1037
  level: WARN,
998
- message: `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installedCount} vexp hook(s) in .git/hooks never run.\n` +
1038
+ message: `core.hooksPath = ${resolved} — git runs hooks ONLY from there, so the ${installedCount} vexp hook(s) in ${where} never run.\n` +
999
1039
  ` the index still refreshes: the daemon watches the tree live and reconciles it against disk every 5 minutes (VEXP_RECONCILE_INTERVAL_SECS).\n` +
1000
1040
  ` the hooks only make that immediate — if you want them and a tool manages this directory (moon, husky, lefthook), add 'vexp index --finalize || true' through ITS config, not the generated file.`,
1001
1041
  };
1002
1042
  }
1043
+ /** The hooks vexp installs (vexp-core hook_installer.rs). */
1044
+ const VEXP_GIT_HOOKS = ["pre-commit", "post-merge", "post-checkout"];
1045
+ /**
1046
+ * The hooks folder vexp-core writes its hooks into for `root`, by its rule
1047
+ * (hook_installer.rs repository_hooks_dir): `<root>/.git/hooks` where .git
1048
+ * is a folder, with no git call; where .git is a file (a linked worktree, a
1049
+ * submodule), what `git rev-parse --git-path hooks --git-common-dir` says in
1050
+ * `root` (gitIn: no shell, the GIT_DIR family removed, 3 s): the folder git
1051
+ * runs, when it is the common dir's hooks folder, else the common dir's,
1052
+ * with `overridden` (core.hooksPath sends git elsewhere). git giving no
1053
+ * answer: a worktree's main checkout's .git/hooks. `<wt>/.git/hooks` is a
1054
+ * path inside a .git FILE, which this check read in a worktree and so said
1055
+ * "no vexp git hooks" while the main checkout's ran there. Lockstep with
1056
+ * the extension's doctor-lines.ts.
1057
+ */
1058
+ export function repositoryHooksFolder(root) {
1059
+ const dotGit = path.join(root, ".git");
1060
+ let st;
1061
+ try {
1062
+ st = fs.statSync(dotGit);
1063
+ }
1064
+ catch {
1065
+ st = undefined;
1066
+ }
1067
+ if (st?.isDirectory())
1068
+ return { dir: path.join(dotGit, "hooks"), overridden: false };
1069
+ if (st?.isFile()) {
1070
+ const r = gitIn(root, ["rev-parse", "--git-path", "hooks", "--git-common-dir"]);
1071
+ const lines = r && r.status === 0 ? r.stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== "") : [];
1072
+ if (lines.length >= 2) {
1073
+ // git prints forward slashes on Windows too: normalized, the folder
1074
+ // reads as the OS spells it, as in every other doctor line.
1075
+ const absolute = (l) => path.normalize(path.isAbsolute(l) ? l : path.join(root, l));
1076
+ const canon = (p) => {
1077
+ try {
1078
+ return fs.realpathSync(p);
1079
+ }
1080
+ catch {
1081
+ return path.resolve(p);
1082
+ }
1083
+ };
1084
+ const named = absolute(lines[0]);
1085
+ const common = path.join(absolute(lines[1]), "hooks");
1086
+ return canon(named) === canon(common) ? { dir: named, overridden: false } : { dir: common, overridden: true };
1087
+ }
1088
+ const main = linkedWorktreeMain(root);
1089
+ if (main)
1090
+ return { dir: path.join(main, ".git", "hooks"), overridden: false };
1091
+ }
1092
+ return { dir: path.join(dotGit, "hooks"), overridden: false };
1093
+ }
1094
+ /** How many of vexp's three git hooks are in `dir` (a hook that mentions vexp). */
1095
+ export function vexpGitHooksIn(dir) {
1096
+ return VEXP_GIT_HOOKS.filter((h) => {
1097
+ try {
1098
+ return fs.readFileSync(path.join(dir, h), "utf-8").includes("vexp");
1099
+ }
1100
+ catch {
1101
+ return false;
1102
+ }
1103
+ }).length;
1104
+ }
1105
+ /**
1106
+ * The git hooks line of a linked worktree `root` of `main`, whose hooks git
1107
+ * runs from `dir` with `vexpHooks` of vexp's in it. What is true there: git
1108
+ * runs one hooks folder for every checkout of a repository; vexp's hooks
1109
+ * return at once in a linked worktree unless VEXP_WORKTREE_INDEX=1 (the
1110
+ * guard in their body, hook_installer.rs); the worktree's own daemon
1111
+ * watches its tree and reconciles it every 5 minutes (VEXP_RECONCILE_INTERVAL_SECS).
1112
+ * Starts with the words the extension logs when it does not ask the hooks
1113
+ * question there (X2). ASCII, word for word the extension doctor's.
1114
+ */
1115
+ export function worktreeGitHooksText(root, main, dir, vexpHooks) {
1116
+ const head = `git hooks: ${root} is a git worktree of ${main}, whose hooks also run here (from ${dir}); `;
1117
+ const tail = "so this worktree's index refreshes on demand: its own daemon watches the tree and reconciles it against disk every 5 minutes while it runs";
1118
+ return vexpHooks > 0
1119
+ ? `${head}the vexp hooks among them (${vexpHooks}/3) skip linked worktrees unless VEXP_WORKTREE_INDEX=1 is set, ${tail}`
1120
+ : `${head}none of them is vexp's, ${tail}`;
1121
+ }
1122
+ /**
1123
+ * The "Git hooks (index refresh)" verdict for `root`. A normal checkout: as
1124
+ * it always was (gitHooksVerdict on `<root>/.git/hooks`). Any checkout whose
1125
+ * .git is a file (a linked worktree, a submodule, a separate git dir): the
1126
+ * folder git resolves there (repositoryHooksFolder), where vexp-core
1127
+ * installs its hooks; `<root>/.git/hooks` is a path inside that file, and
1128
+ * reading it said "no vexp git hooks" in a submodule right after a
1129
+ * successful `vexp hooks install` (review, 2026-11-07). A linked worktree
1130
+ * gets worktreeGitHooksText, or, where core.hooksPath sends git to a folder
1131
+ * vexp does not write, gitHooksVerdict on the folder vexp does write, with
1132
+ * the "no git hooks" answer read where the hooks' owner keeps it (the main
1133
+ * checkout's .vexp/ when it has one, as vexp-core's answer_root_of).
1134
+ */
1135
+ export function gitHooksFinding(root, hooksPath) {
1136
+ const main = linkedWorktreeMain(root);
1137
+ // No git call where .git is a folder or missing (repositoryHooksFolder).
1138
+ const folder = repositoryHooksFolder(root);
1139
+ const installed = vexpGitHooksIn(folder.dir);
1140
+ if (main && !folder.overridden)
1141
+ return { level: OK, message: worktreeGitHooksText(root, main, folder.dir, installed) };
1142
+ const answerRoot = main && fs.existsSync(path.join(main, ".vexp")) ? main : root;
1143
+ let declined;
1144
+ try {
1145
+ declined = fs.readFileSync(path.join(answerRoot, ".vexp", "git-hooks.declined"), "utf-8").split(/\r?\n/)[0];
1146
+ }
1147
+ catch { /* not declined */ }
1148
+ return gitHooksVerdict(hooksPath, root, installed, declined, folder.dir);
1149
+ }
1003
1150
  /**
1004
1151
  * The size-skip verdict, from `.vexp/coverage.json` (3.1 shape), as data.
1005
1152
  *
@@ -1083,7 +1230,33 @@ export function partialParseVerdict(cov, coveragePath = ".vexp/coverage.json") {
1083
1230
  * upgrade removed), or Chat is not in Agent mode — the only mode that offers
1084
1231
  * MCP tools. doctor can prove the first two; it can only say the third.
1085
1232
  */
1086
- export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p)) {
1233
+ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p), link) {
1234
+ const v = vsCodeMcpEntryVerdict(cfg, wsRoot, exists);
1235
+ // Setup leaves a .vscode/mcp.json that leads out of the project through a
1236
+ // link untouched (agent-config's projectFileLink), so "re-run 'vexp setup'"
1237
+ // alone sent the user round in a loop (review, 2026-11-07, round 4).
1238
+ if (!link || v.level === OK)
1239
+ return v;
1240
+ return { level: v.level, message: `${v.message}\n but ${vsCodeMcpLinkNote(link)}` };
1241
+ }
1242
+ /**
1243
+ * Why 'vexp setup' leaves .vscode/mcp.json untouched, and what to do
1244
+ * instead: it leads outside this project through a link, or through a link
1245
+ * to nothing (agent-config's projectFileLink).
1246
+ */
1247
+ export function vsCodeMcpLinkNote(link) {
1248
+ return "outside" in link
1249
+ ? `'vexp setup' leaves .vscode/mcp.json untouched, since it leads outside this project through a link (${link.outside}): replace the link with a real file or folder in this project, then re-run 'vexp setup'`
1250
+ : "'vexp setup' leaves .vscode/mcp.json untouched, since a link on its way leads to nothing: remove that link, then re-run 'vexp setup'";
1251
+ }
1252
+ /** The no-file line of the Copilot section: setup writes the file, unless a link stops it (vsCodeMcpLinkNote). */
1253
+ export function vsCodeMcpAbsentLine(link) {
1254
+ if (link)
1255
+ return `no .vscode/mcp.json (Copilot MCP not configured in this folder; ${vsCodeMcpLinkNote(link)})`;
1256
+ return "no .vscode/mcp.json (Copilot MCP not configured in this folder — 'vexp setup --agents \"GitHub Copilot\"' writes it)";
1257
+ }
1258
+ /** vsCodeMcpVerdict for the entry as read, with the remedies setup carries out. */
1259
+ function vsCodeMcpEntryVerdict(cfg, wsRoot, exists) {
1087
1260
  if (!cfg || typeof cfg !== "object")
1088
1261
  return { level: WARN, message: ".vscode/mcp.json is present but not valid JSON — VS Code will ignore every server in it; fix the syntax and re-run 'vexp setup'" };
1089
1262
  const servers = cfg.servers;
@@ -1106,10 +1279,11 @@ export function vsCodeMcpVerdict(cfg, wsRoot, exists = (p) => fs.existsSync(p))
1106
1279
  return { level: WARN, message: `.vscode/mcp.json vexp command does not exist: ${command} — re-run 'vexp setup' to repin it` };
1107
1280
  if (script && !exists(script))
1108
1281
  return { level: WARN, message: `.vscode/mcp.json vexp server bundle is missing: ${script} (an editor upgrade removed the old extension folder?) — re-run 'vexp setup'` };
1109
- const pinned = typeof vexp.env?.VEXP_WORKSPACE === "string" ? vexp.env.VEXP_WORKSPACE : undefined;
1110
- if (pinned && !samePath(pinned, wsRoot)) {
1111
- return { level: WARN, message: `.vscode/mcp.json vexp server is pinned to ${pinned}, but this workspace is ${wsRoot} — re-run 'vexp setup' here` };
1112
- }
1282
+ // The pin is pinnedElsewhereFindings' (D2), which every MCP file agents
1283
+ // read here gets: "re-run 'vexp setup' here" could not clear it, since
1284
+ // setup writes "${workspaceFolder}" (W1), which this check took for
1285
+ // another folder, and keeps the pin of a committed file another checkout
1286
+ // wrote (W3).
1113
1287
  return { level: OK, message: `.vscode/mcp.json vexp server: ${command} ${script ?? args.join(" ")}` };
1114
1288
  }
1115
1289
  /**
@@ -2524,10 +2698,10 @@ function zcodeLabel(file, i) {
2524
2698
  return file;
2525
2699
  }
2526
2700
  /** One vexp MCP entry of ZCode's, checked. */
2527
- function zcodeEntryFinding(entry, label, userScope, i, setup) {
2701
+ function zcodeEntryFinding(entry, label, userScope, i, setup, fix = (r) => r) {
2528
2702
  const e = asObject(entry);
2529
2703
  if (!e)
2530
- return { level: BAD, message: `${label}: the vexp server entry is not an object - ZCode cannot start it; re-run '${setup}'` };
2704
+ return { level: BAD, message: `${label}: the vexp server entry is not an object - ZCode cannot start it; ${fix(`re-run '${setup}'`)}` };
2531
2705
  const command = typeof e.command === "string" ? e.command.trim() : "";
2532
2706
  const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
2533
2707
  const env = asObject(e.env);
@@ -2535,11 +2709,11 @@ function zcodeEntryFinding(entry, label, userScope, i, setup) {
2535
2709
  if (!command) {
2536
2710
  if (typeof e.url === "string")
2537
2711
  return { level: INFO, message: `${label}: vexp server over http (${e.url}) - not checked here` };
2538
- return { level: BAD, message: `${label}: the vexp server entry has no command - ZCode cannot start it; re-run '${setup}'` };
2712
+ return { level: BAD, message: `${label}: the vexp server entry has no command - ZCode cannot start it; ${fix(`re-run '${setup}'`)}` };
2539
2713
  }
2540
2714
  const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
2541
2715
  if (abs && !i.exists(command)) {
2542
- 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}'` };
2716
+ 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?); ${fix(`re-run '${setup}'`)}` };
2543
2717
  }
2544
2718
  // The Kiro lesson: a GUI app started from the Dock or the Start menu does
2545
2719
  // not get the shell's PATH, and a server it cannot spawn fails silently.
@@ -2549,7 +2723,7 @@ function zcodeEntryFinding(entry, label, userScope, i, setup) {
2549
2723
  if (!/[\\/]/.test(command) && ["node", "node.exe", "npx", "npx.cmd", "npx.exe"].includes(base)) {
2550
2724
  return {
2551
2725
  level: WARN,
2552
- 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`,
2726
+ 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); ${fix(`re-run '${setup}'`)}, which writes the vexp binary itself`,
2553
2727
  };
2554
2728
  }
2555
2729
  // An absolute npx outside Windows is a `#!/usr/bin/env node` script: it
@@ -2558,13 +2732,13 @@ function zcodeEntryFinding(entry, label, userScope, i, setup) {
2558
2732
  if (i.platform !== "win32" && base === "npx") {
2559
2733
  return {
2560
2734
  level: WARN,
2561
- 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`,
2735
+ 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) - ${fix(`delete the vexp entry there, then run '${setup}'`)}, which writes the vexp binary itself`,
2562
2736
  };
2563
2737
  }
2564
2738
  if (!/[\\/]/.test(command)) {
2565
2739
  return {
2566
2740
  level: WARN,
2567
- 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`,
2741
+ 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; ${fix(`re-run '${setup}'`)} to write the absolute path`,
2568
2742
  };
2569
2743
  }
2570
2744
  if (pin && !samePath(pin, i.root)) {
@@ -2572,8 +2746,8 @@ function zcodeEntryFinding(entry, label, userScope, i, setup) {
2572
2746
  level: WARN,
2573
2747
  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. ` +
2574
2748
  (userScope
2575
- ? `That entry is in your user config and serves every project: run '${setup}' here to give this project its own entry.`
2576
- : `Re-run '${setup}' to repin it.`),
2749
+ ? `That entry is in your user config and serves every project: ${fix(`run '${setup}' here`)} to give this project its own entry.`
2750
+ : `${i.projectLink ? fix(`re-run '${setup}'`) : `Re-run '${setup}'`} to repin it.`),
2577
2751
  };
2578
2752
  }
2579
2753
  return { level: OK, message: `${label}: vexp server ${command}${args.length ? ` ${args.join(" ")}` : ""}${pin ? `, pinned to ${pin}` : ", no workspace pin"}` };
@@ -2581,6 +2755,14 @@ function zcodeEntryFinding(entry, label, userScope, i, setup) {
2581
2755
  /** The ZCode section of doctor, as data (the log has its own function). */
2582
2756
  export function zcodeFindings(i) {
2583
2757
  const setup = setupAgentsCommand(["ZCode"], i.root);
2758
+ // A remedy that runs setup to write the project's MCP file names the link
2759
+ // that stops setup instead, when one does (projectLink): setup leaves
2760
+ // that file untouched (R4), and the doctor printed the same line after
2761
+ // it, for ever (review, 2026-11-07, round 5). `run` is the same remedy
2762
+ // at the start of a sentence; without a link both are as before.
2763
+ const pl = i.projectLink;
2764
+ const fix = (remedy) => (pl ? projectLinkRemedy(zcodeLabel(pl.file, i), pl.link, remedy) : remedy);
2765
+ const run = pl ? fix(`run '${setup}'`) : `Run '${setup}'`;
2584
2766
  if (!zcodeInUse(i)) {
2585
2767
  // vexp's hook runs in every folder ZCode opens, this one included: say
2586
2768
  // what opening it in ZCode would lack, never as a fault of a project
@@ -2590,7 +2772,7 @@ export function zcodeFindings(i) {
2590
2772
  {
2591
2773
  level: INFO,
2592
2774
  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)` +
2593
- (i.consent === "no" || i.consent === "never" ? " - you said no to ZCode here" : `: if you open it in ZCode, run '${setup}'`),
2775
+ (i.consent === "no" || i.consent === "never" ? " - you said no to ZCode here" : `: if you open it in ZCode, ${fix(`run '${setup}'`)}`),
2594
2776
  },
2595
2777
  ];
2596
2778
  }
@@ -2636,38 +2818,38 @@ export function zcodeFindings(i) {
2636
2818
  const names = (s) => Object.keys(s ?? {});
2637
2819
  let served = false;
2638
2820
  if (wsNative?.vexp) {
2639
- out.push(zcodeEntryFinding(wsNative.vexp, zcodeLabel(i.workspaceNative.file, i), false, i, setup));
2821
+ out.push(zcodeEntryFinding(wsNative.vexp, zcodeLabel(i.workspaceNative.file, i), false, i, setup, fix));
2640
2822
  served = true;
2641
2823
  }
2642
2824
  else if (names(wsNative).length > 0 && wsCompat?.vexp) {
2643
2825
  out.push({
2644
2826
  level: BAD,
2645
2827
  message: `${zcodeLabel(i.workspaceCompat.file, i)} holds the vexp server, but ${zcodeLabel(i.workspaceNative.file, i)} defines servers of its own (${names(wsNative).join(", ")}): ` +
2646
- `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.`,
2828
+ `ZCode skips .agents/mcp.json whenever .zcode/config.json defines servers, so no vexp tools here. ${run}: it writes the vexp entry into .zcode/config.json.`,
2647
2829
  });
2648
2830
  }
2649
2831
  else if (wsCompat?.vexp) {
2650
- out.push(zcodeEntryFinding(wsCompat.vexp, zcodeLabel(i.workspaceCompat.file, i), false, i, setup));
2832
+ out.push(zcodeEntryFinding(wsCompat.vexp, zcodeLabel(i.workspaceCompat.file, i), false, i, setup, fix));
2651
2833
  served = true;
2652
2834
  }
2653
2835
  if (!served) {
2654
2836
  if (usNative?.vexp) {
2655
- out.push(zcodeEntryFinding(usNative.vexp, zcodeLabel(i.userNative.file, i), true, i, setup));
2837
+ out.push(zcodeEntryFinding(usNative.vexp, zcodeLabel(i.userNative.file, i), true, i, setup, fix));
2656
2838
  }
2657
2839
  else if (names(usNative).length > 0 && usCompat?.vexp) {
2658
2840
  out.push({
2659
2841
  level: BAD,
2660
2842
  message: `${zcodeLabel(i.userCompat.file, i)} holds the vexp server, but ${zcodeLabel(i.userNative.file, i)} defines servers of its own (${names(usNative).join(", ")}): ` +
2661
- `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.`,
2843
+ `ZCode skips ~/.agents/mcp.json whenever ~/.zcode/cli/config.json defines servers. ${run}: it writes the vexp entry into this project's .zcode/config.json.`,
2662
2844
  });
2663
2845
  }
2664
2846
  else if (usCompat?.vexp) {
2665
- out.push(zcodeEntryFinding(usCompat.vexp, zcodeLabel(i.userCompat.file, i), true, i, setup));
2847
+ out.push(zcodeEntryFinding(usCompat.vexp, zcodeLabel(i.userCompat.file, i), true, i, setup, fix));
2666
2848
  }
2667
2849
  else if (!(names(wsNative).length > 0 && wsCompat?.vexp)) {
2668
2850
  out.push({
2669
2851
  level: BAD,
2670
- 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}'.`,
2852
+ 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}.`,
2671
2853
  });
2672
2854
  }
2673
2855
  }
@@ -2745,6 +2927,437 @@ export function zcodeFindings(i) {
2745
2927
  }
2746
2928
  return out;
2747
2929
  }
2930
+ const AG_PROJECT = ".agents/mcp_config.json";
2931
+ const AG_GLOBAL = "~/.gemini/config/mcp_config.json";
2932
+ const AG_LEGACY = "~/.gemini/antigravity/mcp_config.json";
2933
+ /**
2934
+ * Where an entry with no VEXP_WORKSPACE serves: Antigravity starts an MCP
2935
+ * server in its own process's folder and passes no workspace variable
2936
+ * (agy 1.5.3, measured 2026-11-07). Lockstep with the extension.
2937
+ */
2938
+ const AG_UNPINNED = "serves whatever folder Antigravity starts it from: the window's first folder in VS Code, often the home folder in the Antigravity app, where it lists no tools";
2939
+ /**
2940
+ * Why a global `vexp` beside the project's is the one used: its servers
2941
+ * start with Antigravity, the project's load per conversation, and the
2942
+ * server already running keeps the name (agy 1.5.3, measured 2026-11-07).
2943
+ */
2944
+ const AG_SHADOW = `a vexp in ${AG_GLOBAL} starts with Antigravity, before any conversation, so it keeps the name and the project's .agents entry is not used`;
2945
+ /**
2946
+ * A project entry whose binary is not on this machine: another machine or a
2947
+ * container may share the folder. It follows the remedy, which runs a
2948
+ * setup, and the setup is what rewrites it: nothing in the CLI or vexp-core
2949
+ * rewrites the file at a start, so "vexp writes its own at its next start
2950
+ * here" was false for a CLI-only user. Lockstep with the extension.
2951
+ */
2952
+ const AG_SHARED_FOLDER = " (another machine or a container that shares this folder may have written it; the setup rewrites it with this machine's paths)";
2953
+ /** The same case when the project file is reached through a link out of the project: the setup leaves that file untouched, so it rewrites nothing there. Lockstep with the extension. */
2954
+ const AG_SHARED_LINK = " (another machine or a container that shares that folder may have written it)";
2955
+ /**
2956
+ * The remedy for the project file: the reader's setup, unless setup leaves
2957
+ * the file untouched because it leads outside the project through a link,
2958
+ * or through a link to nothing (agent-config's projectFileLink). A remedy
2959
+ * that runs setup could never work there: setup refused, and the doctor
2960
+ * printed the same line again (review, 2026-11-07, round 4). The way out
2961
+ * is a file of the project's own: a file shared through the link holds one
2962
+ * machine's (or one project's) paths, and an edit by hand for this one
2963
+ * breaks it for the other. The doctor still reads the file through the
2964
+ * link, since Antigravity does. Lockstep with the extension.
2965
+ */
2966
+ function agProjectRemedy(i) {
2967
+ return projectLinkRemedy(AG_PROJECT, i.projectLink, i.remedy);
2968
+ }
2969
+ /**
2970
+ * A remedy that runs setup to write the project file `file` (as the line
2971
+ * names it), unless setup leaves that file untouched because it leads
2972
+ * outside the project through a link, or through a link to nothing
2973
+ * (agent-config's projectFileLink): then the link is named first, and the
2974
+ * remedy follows it. Antigravity's and ZCode's lines. Lockstep with the
2975
+ * extension's projectLinkRemedy (doctor-lines.ts).
2976
+ */
2977
+ export function projectLinkRemedy(file, link, remedy) {
2978
+ if (!link)
2979
+ return remedy;
2980
+ if ("outside" in link) {
2981
+ return `vexp's setup leaves ${file} untouched, since it leads outside this project through a link (${link.outside}): replace the link with a real file or folder in this project, then ${remedy}`;
2982
+ }
2983
+ return `vexp's setup leaves ${file} untouched, since a link on its way leads to nothing: remove that link, then ${remedy}`;
2984
+ }
2985
+ /**
2986
+ * Does the project file hold nothing but vexp's server, as setup leaves it
2987
+ * (one with no mcpServers yet gets vexp's)? Only such a file is vexp's to
2988
+ * keep out of git (agent-config's holdsOnlyVexp), and only for it does
2989
+ * `git rm --cached` stick. Lockstep with the extension.
2990
+ */
2991
+ function agOnlyVexp(f) {
2992
+ if (f.state !== "ok")
2993
+ return false;
2994
+ return holdsOnlyVexp(f.data.mcpServers === undefined ? { ...f.data, mcpServers: {} } : f.data);
2995
+ }
2996
+ /**
2997
+ * What a vexp server still in the 1.x file does. No current build reads
2998
+ * that file, but until ~/.gemini/config/.migrated exists the first start of
2999
+ * the Antigravity IDE or of its VS Code extension MOVES it over the global
3000
+ * file, replacing the servers there, and the vexp in it then runs before
3001
+ * any conversation (agy 1.5.3, measured 2026-11-07): "it does nothing" is
3002
+ * true only after that move. Setup takes out vexp's own dead entry, and
3003
+ * the file with it when nothing else is left. Lockstep with the extension.
3004
+ */
3005
+ function agLegacyNote(lv, i, isDead) {
3006
+ const what = i.migrated
3007
+ ? `${AG_LEGACY} still has a vexp server, in a file current Antigravity builds no longer read, so it does nothing`
3008
+ : `${AG_LEGACY} still has a vexp server; that file is not read, but the first start of the Antigravity IDE or of its VS Code extension moves it over ${AG_GLOBAL}, replacing the servers there`;
3009
+ if (isDead(lv))
3010
+ return `${what} - ${i.remedy} to take it out`;
3011
+ return i.migrated ? what : `${what} - remove it from that file (vexp takes out only the entries it wrote there)`;
3012
+ }
3013
+ /**
3014
+ * The command that stops git tracking the project file, from wherever the
3015
+ * reader is in the repository: the plain path when the workspace is the
3016
+ * top level, else the path from the top level as a `:/` pathspec, which
3017
+ * git reads the same from any folder (`:(top,literal)` when the path holds
3018
+ * a wildcard, which a pathspec would match as one), quoted for the shell.
3019
+ * A bare top-level path failed when run from the workspace, and one with a
3020
+ * space split in two (review, 2026-11-07). Lockstep with the extension.
3021
+ */
3022
+ function agUntrack(tracked, platform) {
3023
+ if (tracked === AG_PROJECT)
3024
+ return `git rm --cached ${AG_PROJECT}`;
3025
+ const spec = /[*?[\\]/.test(tracked) ? `:(top,literal)${tracked}` : `:/${tracked}`;
3026
+ return `git rm --cached ${platform === "win32" ? `"${spec}"` : `'${spec.split("'").join("'\\''")}'`}`;
3027
+ }
3028
+ /**
3029
+ * An absolute path on either kind of system. An entry in a folder shared
3030
+ * between Windows and WSL or a container may have been written on the
3031
+ * other side: `C:\...\vexp-core.exe` read on Linux is a binary that is not
3032
+ * here, never "a relative path". Lockstep with the extension.
3033
+ */
3034
+ function agAbsolute(p) {
3035
+ return path.win32.isAbsolute(p) || path.posix.isAbsolute(p);
3036
+ }
3037
+ /** The three files Antigravity's section reads. agy's migration may leave the 1.x name a link to the global file: it is then that file, read once. */
3038
+ export function readAntigravityFiles(root, home) {
3039
+ const globalPath = path.join(home, ".gemini", "config", "mcp_config.json");
3040
+ const legacyPath = path.join(home, ".gemini", "antigravity", "mcp_config.json");
3041
+ let sameFile = false;
3042
+ try {
3043
+ sameFile = fs.realpathSync(legacyPath) === fs.realpathSync(globalPath);
3044
+ }
3045
+ catch {
3046
+ /* either one absent: two files */
3047
+ }
3048
+ return {
3049
+ project: readJsonFileState(path.join(root, ".agents", "mcp_config.json")),
3050
+ global: readJsonFileState(globalPath),
3051
+ legacy: sameFile ? { file: legacyPath, state: "absent" } : readJsonFileState(legacyPath),
3052
+ };
3053
+ }
3054
+ /** The `vexp` entry of a file's mcpServers, when there is one. */
3055
+ function agVexp(f) {
3056
+ if (f.state !== "ok")
3057
+ return undefined;
3058
+ const v = asObject(f.data.mcpServers)?.vexp;
3059
+ return v === null ? undefined : v;
3060
+ }
3061
+ /**
3062
+ * The folder an entry makes vexp serve, as vexp-core reads it: `--workspace`,
3063
+ * then env VEXP_WORKSPACE, then env CLAUDE_PROJECT_DIR, then the `cwd` the
3064
+ * server is started in. Lockstep with the extension.
3065
+ */
3066
+ function agPin(e) {
3067
+ const args = Array.isArray(e.args) ? e.args : [];
3068
+ const i = args.indexOf("--workspace");
3069
+ if (i >= 0 && typeof args[i + 1] === "string" && args[i + 1].trim())
3070
+ return args[i + 1];
3071
+ const eq = args.find((a) => typeof a === "string" && a.startsWith("--workspace=") && a.length > 12);
3072
+ if (eq)
3073
+ return eq.slice(12);
3074
+ const env = asObject(e.env);
3075
+ if (typeof env?.VEXP_WORKSPACE === "string" && env.VEXP_WORKSPACE.trim())
3076
+ return env.VEXP_WORKSPACE;
3077
+ if (typeof env?.CLAUDE_PROJECT_DIR === "string" && env.CLAUDE_PROJECT_DIR.trim())
3078
+ return env.CLAUDE_PROJECT_DIR;
3079
+ if (typeof e.cwd === "string" && e.cwd.trim())
3080
+ return e.cwd;
3081
+ return undefined;
3082
+ }
3083
+ /**
3084
+ * Is a pin this workspace, as vexp-core resolves it: an absolute path that
3085
+ * is the root (in letter case only on Windows: samePath folds case on every
3086
+ * platform, and read a pin to another folder on Linux as this one), or the
3087
+ * same folder behind a link. A relative pin resolves against the folder
3088
+ * Antigravity starts vexp in, never against the doctor's own cwd. Lockstep
3089
+ * with the extension.
3090
+ */
3091
+ function agPinnedHere(pin, i) {
3092
+ if (pin.includes("${") || !agAbsolute(pin))
3093
+ return false;
3094
+ if (i.platform === "win32" ? samePath(pin, i.root) : path.posix.resolve(pin) === path.posix.resolve(i.root))
3095
+ return true;
3096
+ try {
3097
+ return fs.realpathSync(pin) === fs.realpathSync(i.root);
3098
+ }
3099
+ catch {
3100
+ return false;
3101
+ }
3102
+ }
3103
+ /** Is Antigravity set up for this project: exactly what planAgentSetup
3104
+ * (both twins) configures, its marker here (on a machine that shows
3105
+ * Antigravity: `marker`) or a "yes" on record; the extension's doctor
3106
+ * checks the files only for an agent its plan configures. The marker sets
3107
+ * Antigravity up whatever was answered: a "Never" here read "no
3108
+ * Antigravity setup" over a start that set it up and failed, while the
3109
+ * extension's doctor warned (review, 2026-11-07). A vexp entry in the
3110
+ * project file is no such evidence: a teammate's committed file, on a
3111
+ * machine with no Antigravity, made `vexp doctor` FAIL where the
3112
+ * extension said nothing and no setup would write the entry (review,
3113
+ * 2026-11-07, round 3). Project evidence only, as for ZCode: the global
3114
+ * file serves every project. */
3115
+ export function antigravityInUse(i) {
3116
+ return i.marker || i.consent === "yes";
3117
+ }
3118
+ /** The project's vexp entry, checked as Antigravity would start it. */
3119
+ function agProjectEntryFinding(entry, i) {
3120
+ const R = agProjectRemedy(i);
3121
+ const e = asObject(entry);
3122
+ if (!e)
3123
+ return { level: BAD, message: `Antigravity: ${AG_PROJECT} has a vexp server entry that is not an object, so Antigravity cannot start it - ${R}` };
3124
+ const command = typeof e.command === "string" ? e.command.trim() : "";
3125
+ if (!command) {
3126
+ const url = typeof e.serverUrl === "string" ? e.serverUrl : typeof e.url === "string" ? e.url : undefined;
3127
+ if (url)
3128
+ return { level: INFO, message: `Antigravity: ${AG_PROJECT} reaches vexp over http (${url}) - not checked here` };
3129
+ return { level: BAD, message: `Antigravity: ${AG_PROJECT} has a vexp server entry with no command, so Antigravity cannot start it - ${R}` };
3130
+ }
3131
+ if (agAbsolute(command) && !i.exists(command)) {
3132
+ return {
3133
+ level: BAD,
3134
+ message: `Antigravity: ${AG_PROJECT} starts vexp from ${command}, which does not exist, so Antigravity has no vexp tools in this workspace - ${R}${i.projectLink ? AG_SHARED_LINK : AG_SHARED_FOLDER}`,
3135
+ };
3136
+ }
3137
+ // The Kiro lesson: an app started from the Dock or the Start menu does not
3138
+ // get the shell's PATH, and a server it cannot spawn fails in silence.
3139
+ if (!/[\\/]/.test(command)) {
3140
+ return {
3141
+ level: WARN,
3142
+ message: `Antigravity: ${AG_PROJECT} starts vexp with a bare '${command}', looked up on Antigravity's PATH, which an app launched from the Dock or Start menu usually lacks - ${R}`,
3143
+ };
3144
+ }
3145
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
3146
+ if (!args.includes("mcp")) {
3147
+ return {
3148
+ level: BAD,
3149
+ message: `Antigravity: ${AG_PROJECT} starts ${command} without the mcp subcommand, so it serves no MCP and Antigravity has no vexp tools in this workspace - ${R}`,
3150
+ };
3151
+ }
3152
+ const pin = agPin(e);
3153
+ if (pin === undefined) {
3154
+ return {
3155
+ level: WARN,
3156
+ message: `Antigravity: ${AG_PROJECT} starts vexp with no VEXP_WORKSPACE, so it ${AG_UNPINNED} - ${R}`,
3157
+ };
3158
+ }
3159
+ if (pin.includes("${")) {
3160
+ return {
3161
+ level: WARN,
3162
+ message: `Antigravity: ${AG_PROJECT} pins vexp to '${pin}', which vexp gets as written: Antigravity expands no \${...} in mcp_config.json, so vexp serves no project - ${R}`,
3163
+ };
3164
+ }
3165
+ if (!agAbsolute(pin)) {
3166
+ return {
3167
+ level: WARN,
3168
+ message: `Antigravity: ${AG_PROJECT} pins vexp to '${pin}', a relative path, which vexp resolves against the folder Antigravity starts it in, not against this workspace - ${R}`,
3169
+ };
3170
+ }
3171
+ if (!agPinnedHere(pin, i)) {
3172
+ const effect = i.exists(pin) ? "query that project's index" : "get no vexp tools (that folder does not exist)";
3173
+ return { level: WARN, message: `Antigravity: ${AG_PROJECT} pins vexp to ${pin}, not to this workspace (${i.root}), so Antigravity sessions here ${effect} - ${R}` };
3174
+ }
3175
+ // A switch the writers keep across a rewrite (ANTIGRAVITY_USER_KEYS):
3176
+ // Antigravity then starts no vexp server, whatever the rest says.
3177
+ if (e.disabled === true) {
3178
+ return {
3179
+ level: WARN,
3180
+ message: `Antigravity: ${AG_PROJECT} has vexp turned off ("disabled": true), so Antigravity starts no vexp server in this workspace - remove "disabled" from vexp's entry there to turn it on`,
3181
+ };
3182
+ }
3183
+ return { level: OK, message: `Antigravity: ${AG_PROJECT} starts vexp (${[command, ...args].join(" ")}), pinned to this workspace` };
3184
+ }
3185
+ /** The extension version an entry's command or args point into (vexp.vexp-vscode-<ver>). Lockstep with the extension and the writers. */
3186
+ function agInstallVersion(e) {
3187
+ const parts = [e.command, ...(Array.isArray(e.args) ? e.args : [])];
3188
+ for (const p of parts) {
3189
+ const m = typeof p === "string" ? p.match(/vexp\.vexp-vscode-(\d+\.\d+\.\d+)/) : null;
3190
+ if (m)
3191
+ return m[1];
3192
+ }
3193
+ return undefined;
3194
+ }
3195
+ /** Is version `a` strictly older than `b`? What does not parse is never older. Lockstep with the extension. */
3196
+ function agOlder(a, b) {
3197
+ const parts = (v) => {
3198
+ const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
3199
+ return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
3200
+ };
3201
+ const x = parts(a);
3202
+ const y = parts(b);
3203
+ if (!x || !y)
3204
+ return false;
3205
+ for (let k = 0; k < 3; k++)
3206
+ if (x[k] !== y[k])
3207
+ return x[k] < y[k];
3208
+ return false;
3209
+ }
3210
+ /**
3211
+ * A `vexp` server in the global file: it has the project entry's name. No
3212
+ * vexp setup writes or updates that file, so an entry pinned here is
3213
+ * checked as Antigravity would start it, and beside a project entry it is
3214
+ * a second server of the same name (review, 2026-11-07). `projectEntry`:
3215
+ * the project file holds a vexp server.
3216
+ */
3217
+ function agGlobalFinding(entry, i, isDead, projectEntry) {
3218
+ const pin = agPin(entry);
3219
+ // Setup takes out only what it would remove (agent-config's
3220
+ // antigravityFilesVexpCleans): vexp's own dead entry, and in this file
3221
+ // only behind agy's migration link. Any other vexp here is the user's to
3222
+ // remove, a dead-looking one too.
3223
+ const fix = isDead(entry) && i.globalCleaned
3224
+ ? `${i.remedy} to take it out`
3225
+ : isDead(entry)
3226
+ ? `remove it from that file (vexp edits that file only while ${AG_LEGACY} links to it)`
3227
+ : "remove it from that file (vexp takes out only the entries it wrote there)";
3228
+ // Beside no project entry it has nothing to shadow yet: the one setup
3229
+ // writes is what it will shadow (review, 2026-11-07).
3230
+ const shadows = projectEntry ? `it shadows the entry in ${AG_PROJECT}` : `it will shadow the entry vexp sets up in ${AG_PROJECT}`;
3231
+ if (pin === undefined) {
3232
+ return {
3233
+ level: WARN,
3234
+ message: `Antigravity: ${AG_GLOBAL} has a server named vexp with no VEXP_WORKSPACE, so it ${AG_UNPINNED}; and ${shadows} (${AG_SHADOW}) - ${fix}`,
3235
+ };
3236
+ }
3237
+ if (!agPinnedHere(pin, i)) {
3238
+ return {
3239
+ level: WARN,
3240
+ message: `Antigravity: ${AG_GLOBAL} has a server named vexp pinned to ${pin}, so ${shadows} with that project's index (${AG_SHADOW}) - ${fix}`,
3241
+ };
3242
+ }
3243
+ // Beside no project entry, the setup writes one first: unless it leaves
3244
+ // the project file untouched (agProjectRemedy).
3245
+ const remove = projectEntry
3246
+ ? `remove it from that file, so the entry vexp keeps current in ${AG_PROJECT} is used (${AG_SHADOW})`
3247
+ : i.projectLink
3248
+ ? `${agProjectRemedy(i)}, then remove it from ${AG_GLOBAL}`
3249
+ : `${i.remedy}, then remove it from that file`;
3250
+ const command = typeof entry.command === "string" ? entry.command.trim() : "";
3251
+ if (command && agAbsolute(command) && !i.exists(command)) {
3252
+ return {
3253
+ level: WARN,
3254
+ message: `Antigravity: ${AG_GLOBAL} has a server named vexp pinned to this workspace that starts vexp from ${command}, which does not exist, so Antigravity cannot start it, and vexp never updates that file - ${remove}`,
3255
+ };
3256
+ }
3257
+ const theirs = agInstallVersion(entry);
3258
+ if (theirs && i.version && agOlder(theirs, i.version)) {
3259
+ return {
3260
+ level: WARN,
3261
+ message: `Antigravity: ${AG_GLOBAL} has a server named vexp pinned to this workspace that starts vexp from the ${theirs} extension folder, older than this build (${i.version}), and vexp never updates that file - ${remove}`,
3262
+ };
3263
+ }
3264
+ if (projectEntry) {
3265
+ return {
3266
+ level: WARN,
3267
+ message: `Antigravity: ${AG_GLOBAL} has a server named vexp pinned to this workspace, beside the vexp entry in ${AG_PROJECT}, and vexp never updates the global one - ${remove}`,
3268
+ };
3269
+ }
3270
+ return {
3271
+ level: INFO,
3272
+ message: `Antigravity: ${AG_GLOBAL} has a server named vexp pinned to this workspace: it serves this project here, but that file is global, so in other workspaces it shadows their own vexp entry (${AG_SHADOW})`,
3273
+ };
3274
+ }
3275
+ /** The Antigravity section of doctor, as data. */
3276
+ export function antigravityFindings(i) {
3277
+ const R = i.remedy;
3278
+ const isDead = i.isDead ?? isDeadAntigravityEntry;
3279
+ const pv = agVexp(i.project);
3280
+ const gv = agVexp(i.global);
3281
+ const lv = agVexp(i.legacy);
3282
+ if (!antigravityInUse(i)) {
3283
+ // The global file serves every project: say what this one would get
3284
+ // from it, never as a fault of a project that may not use Antigravity.
3285
+ // "Not here" is this project's answer, "Never" the machine's.
3286
+ const declined = i.consent === "no" ? " - you said no to Antigravity here" : i.consent === "never" ? " - you said Never to Antigravity on this machine" : "";
3287
+ if (gv !== undefined) {
3288
+ return [
3289
+ {
3290
+ level: INFO,
3291
+ message: `no Antigravity setup for this project, but ${AG_GLOBAL} has a server named vexp, which Antigravity reads in every workspace` +
3292
+ (declined || `: if you open this project in Antigravity, ${R}`),
3293
+ },
3294
+ ];
3295
+ }
3296
+ if (lv !== undefined) {
3297
+ return [{ level: INFO, message: `no Antigravity setup for this project; ${agLegacyNote(lv, i, isDead)}` }];
3298
+ }
3299
+ return [{ level: OK, message: `no Antigravity setup for this project${declined}` }];
3300
+ }
3301
+ const out = [];
3302
+ let legacyNamed = false;
3303
+ // The project file's remedy (agProjectRemedy); R stays for the 1.x and
3304
+ // global entries, which setup takes out whatever the project file is.
3305
+ const PR = agProjectRemedy(i);
3306
+ if (i.project.state === "unparseable") {
3307
+ out.push({ level: WARN, message: `Antigravity: ${AG_PROJECT} is not valid JSON, and vexp does not rewrite a file it cannot read - ${i.projectLink ? PR : `fix the file, then ${R}`}` });
3308
+ }
3309
+ else if (pv !== undefined) {
3310
+ out.push(agProjectEntryFinding(pv, i));
3311
+ }
3312
+ else if (gv !== undefined) {
3313
+ const g = asObject(gv);
3314
+ const pin = g ? agPin(g) : undefined;
3315
+ const here = pin !== undefined && agPinnedHere(pin, i);
3316
+ out.push({ level: here ? INFO : WARN, message: `Antigravity: no vexp server in ${AG_PROJECT}, the entry vexp sets up for this workspace - ${PR}` });
3317
+ }
3318
+ else if (lv !== undefined) {
3319
+ legacyNamed = true;
3320
+ out.push({
3321
+ level: WARN,
3322
+ message: `Antigravity: vexp's server is only in ${AG_LEGACY}, a file current Antigravity builds no longer read (they read ${AG_PROJECT} and ${AG_GLOBAL}), so Antigravity has no vexp tools in this workspace - ${PR}`,
3323
+ });
3324
+ }
3325
+ else {
3326
+ out.push({ level: WARN, message: `Antigravity: no vexp server in ${AG_PROJECT} or ${AG_GLOBAL}, so Antigravity has no vexp tools in this workspace - ${PR}` });
3327
+ }
3328
+ // A file that holds only vexp's entry is vexp's: setup keeps it out of
3329
+ // git (the exclude file), also after the `git rm --cached` below, so the
3330
+ // advice sticks. One with the project's own servers or keys is the
3331
+ // project's business: setup never excludes it, so after that command the
3332
+ // next `git add -A` committed it again, and the untracking commit took
3333
+ // the project's servers out of every other checkout (review, 2026-11-07,
3334
+ // round 4). For it the doctor says what committing vexp's entry costs,
3335
+ // and never to untrack it. A file it cannot read gets neither.
3336
+ if (i.tracked && agOnlyVexp(i.project)) {
3337
+ out.push({
3338
+ level: INFO,
3339
+ message: `Antigravity: ${AG_PROJECT} is committed to git, but it holds this machine's paths (the vexp binary and this folder) and vexp rewrites it with each machine's own - run: ${agUntrack(i.tracked, i.platform)}`,
3340
+ });
3341
+ }
3342
+ else if (i.tracked && i.project.state === "ok") {
3343
+ out.push({
3344
+ level: INFO,
3345
+ message: `Antigravity: ${AG_PROJECT} is committed to git with the project's own servers or keys, and the vexp entry setup writes there holds this machine's paths (the vexp binary and this folder), which vexp rewrites with each machine's own - commit the file without vexp's entry (each machine's setup adds its own), and keep it tracked: untracking it takes the project's servers out of every other checkout`,
3346
+ });
3347
+ }
3348
+ if (i.global.state === "unparseable") {
3349
+ out.push({ level: INFO, message: `Antigravity: ${AG_GLOBAL} is not valid JSON, so whether it holds a server named vexp is not known - vexp leaves a file it cannot read as it is` });
3350
+ }
3351
+ else {
3352
+ const g = asObject(gv);
3353
+ if (g)
3354
+ out.push(agGlobalFinding(g, i, isDead, pv !== undefined));
3355
+ }
3356
+ if (lv !== undefined && !legacyNamed) {
3357
+ out.push({ level: INFO, message: `Antigravity: ${agLegacyNote(lv, i, isDead)}` });
3358
+ }
3359
+ return out;
3360
+ }
2748
3361
  /**
2749
3362
  * A log line as text twice over: `match` holds the string values only, keys
2750
3363
  * left out (`"error": null` is not an error); `show` keeps each key next to
@@ -2998,6 +3611,15 @@ export function buildDoctorReport(i) {
2998
3611
  if (i.mountNotice)
2999
3612
  parts.push("", `> **Filesystem:** ${i.mountNotice}`);
3000
3613
  parts.push("", "## Doctor", "", "```", stripAnsi(i.doctorText).trim(), "```");
3614
+ // Whether DevMind runs on this machine, and why not: the daemon's reason
3615
+ // alone left support guessing at the GPU, the driver and the config
3616
+ // (review, 2026-11-08). The engine's own lines, an allow-list of them, no
3617
+ // key among them.
3618
+ parts.push("", "## Local LLM (DevMind): setup-llm --status", "");
3619
+ if (i.llmStatus && i.llmStatus.length > 0)
3620
+ parts.push("```", ...i.llmStatus, "```");
3621
+ else
3622
+ parts.push(`setup-llm --status unavailable: ${i.llmStatusError ?? (i.llmStatus ? "it printed none of the LLM lines" : "not run")}`);
3001
3623
  parts.push("", "## Usage ledger", "");
3002
3624
  parts.push(i.ledgerMarkdown?.trim() || `ledger unavailable: ${i.ledgerError ?? "unknown error"}`);
3003
3625
  parts.push("", `## Daemon log${i.logName ? ` (.vexp/${i.logName})` : ""}: warnings and errors`, "");
@@ -3266,6 +3888,29 @@ export async function writeDoctorReport(o) {
3266
3888
  }
3267
3889
  else
3268
3890
  ledgerMarkdown = String(ledger.stdout);
3891
+ // Read-only: the config, the hardware probe and the workspace daemon's
3892
+ // answer. It loads the CUDA plugin to see whether it loads, hence the
3893
+ // longer wait. The workspace is pinned and the cwd is not in it: a
3894
+ // backend library a cloned repository carries was loaded from the cwd
3895
+ // (review, 2026-11-08). Lockstep with the extension's LLM Status.
3896
+ const llm = bin
3897
+ ? spawnSync(bin, ["setup-llm", "--status"], {
3898
+ cwd: os.tmpdir(),
3899
+ env: { ...process.env, VEXP_WORKSPACE: o.root },
3900
+ timeout: 20000,
3901
+ encoding: "utf-8",
3902
+ })
3903
+ : null;
3904
+ let llmStatus = null;
3905
+ let llmStatusError;
3906
+ if (!llm)
3907
+ llmStatusError = "vexp-core binary not found";
3908
+ else if (llm.error)
3909
+ llmStatusError = llm.error.message;
3910
+ else if (llm.status !== 0)
3911
+ llmStatusError = String(llm.stderr ?? "").trim().split("\n")[0] || `exit ${llm.status}`;
3912
+ else
3913
+ llmStatus = llmStatusReportLines(String(llm.stdout));
3269
3914
  let logName = null;
3270
3915
  let logWarnings = [];
3271
3916
  const logs = [];
@@ -3320,6 +3965,8 @@ export async function writeDoctorReport(o) {
3320
3965
  configProblem,
3321
3966
  configEncoding,
3322
3967
  mountNotice: [slowMountNotice(o.root), cloudSyncHere(o.root)].filter(Boolean).join(" ") || null,
3968
+ llmStatus,
3969
+ llmStatusError,
3323
3970
  });
3324
3971
  const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
3325
3972
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -3356,6 +4003,253 @@ export async function runDoctor(opts = {}) {
3356
4003
  console.log("");
3357
4004
  }
3358
4005
  }
4006
+ // ---------------------------------------------------------------------------
4007
+ // Git worktrees (3.4.0). A window on a linked worktree was served by the
4008
+ // main checkout's index, through an entry pinned to it in a file every
4009
+ // checkout reads, and the doctors said only that a daemon answered and two
4010
+ // counts that did not match (field report, 2026-11-07: "203 files" against
4011
+ // "335"). These findings say which folder the daemon serves, which MCP files
4012
+ // pin vexp to another checkout, where a worktree's answers come from, and
4013
+ // what each extra daemon of one repository costs with the local model.
4014
+ // Lockstep with the extension's doctor-lines.ts, word for word (its
4015
+ // doctor-worktree-lockstep.test.ts holds the two to it). ASCII only.
4016
+ // ---------------------------------------------------------------------------
4017
+ /**
4018
+ * D1: the folder the daemon that answered serves (index_status
4019
+ * `workspace_root`), against this workspace; in a linked worktree, which
4020
+ * checkout it is a worktree of. null from a daemon too old to say.
4021
+ */
4022
+ export function servedRootFinding(served, root, main = linkedWorktreeMain(root)) {
4023
+ if (typeof served !== "string" || served.trim() === "")
4024
+ return null;
4025
+ if (!sameFolderAsExtension(served, root))
4026
+ return { level: WARN, message: `daemon serves ${served}, not this workspace (${root})` };
4027
+ return { level: OK, message: main ? `serves ${root} (a git worktree of ${main})` : `serves ${root}` };
4028
+ }
4029
+ /**
4030
+ * D2's finding for one file: word for word the extension's
4031
+ * pinnedElsewhereLine, remedy by where the pin is (`from`), and for a pin to
4032
+ * a folder that is not on this machine, what agents get there (`exists`).
4033
+ */
4034
+ export function pinnedElsewhereFinding(file, pin, root, from = "env", exists = fs.existsSync) {
4035
+ const get = exists(pin) ? `get the index of ${pin}` : "get no vexp tools (that folder does not exist)";
4036
+ const remedy = from === "args" ? "remove --workspace from that entry's args" : "remove VEXP_WORKSPACE from that entry";
4037
+ return {
4038
+ level: WARN,
4039
+ message: `${file} has a vexp server pinned to ${pin}, not to this workspace (${root}): agents that read it here ${get} - ${remedy}, and each checkout then serves itself`,
4040
+ };
4041
+ }
4042
+ /**
4043
+ * D2: every MCP file agents read here (`files`: <root>/.mcp.json and
4044
+ * <root>/.vscode/mcp.json) that holds a server named vexp, in any letter
4045
+ * case, pinned to a folder other than this root, each file read by its own
4046
+ * rules. In .vscode/mcp.json (key "servers") a pin of "${workspaceFolder}"
4047
+ * names whichever checkout reads it, so this one; ${userHome} and
4048
+ * ${env:NAME} are expanded as VS Code does there (expandedPin); any other
4049
+ * variable, and a relative pin, is not compared. In .mcp.json (key
4050
+ * "mcpServers") the pin is read as VS Code expands it in that file
4051
+ * (dotMcpJsonPin, the reading of M2's dotMcpJsonMiss): ${NAME} from the
4052
+ * environment, a relative pin against this root; what still holds `${` names
4053
+ * no folder, and the servers then serve this one. Read with .vscode/mcp.json's
4054
+ * rules, one entry got this warning and D4's [OK] in one run (review,
4055
+ * 2026-11-08). A file that is absent or does not parse says nothing here
4056
+ * (its own section does). The Antigravity files have their own findings
4057
+ * (antigravityFindings).
4058
+ */
4059
+ export function pinnedElsewhereFindings(files, root, exists = fs.existsSync) {
4060
+ const out = [];
4061
+ const seen = new Set();
4062
+ for (const { file, key } of files) {
4063
+ if (seen.has(path.resolve(file)))
4064
+ continue;
4065
+ seen.add(path.resolve(file));
4066
+ const f = readJsonFileState(file);
4067
+ if (f.state !== "ok")
4068
+ continue;
4069
+ const servers = asObject(f.data[key]);
4070
+ if (!servers)
4071
+ continue;
4072
+ for (const [name, entry] of Object.entries(servers)) {
4073
+ if (name.toLowerCase() !== "vexp")
4074
+ continue;
4075
+ const p = vexpPinOf(entry);
4076
+ const folder = p === undefined ? undefined : key === "mcpServers" ? dotMcpJsonPin(p.pin, root) : expandedPin(p.pin, root);
4077
+ if (p === undefined || folder === undefined || !(path.win32.isAbsolute(folder) || path.posix.isAbsolute(folder)) || sameFolderAsExtension(folder, root))
4078
+ continue;
4079
+ out.push(pinnedElsewhereFinding(file, folder, root, p.from, exists));
4080
+ }
4081
+ }
4082
+ return out;
4083
+ }
4084
+ /** D3: a linked worktree whose answers come from its main checkout (agent-consent's inheritedAnswers). */
4085
+ export function inheritedAnswersFinding(main) {
4086
+ return { level: INFO, message: `agent answers for this git worktree come from ${main}` };
4087
+ }
4088
+ /**
4089
+ * D4's condition, lockstep with the extension doctor: .vscode/mcp.json is
4090
+ * absent, or it parses and holds no server named vexp in any letter case. A
4091
+ * file that does not parse proves nothing: vexp's writer never edits it, so
4092
+ * its own entry may still be there, and VS Code may still read it. The
4093
+ * extension read such a file as "no vexp" and said "[OK] vexp for VS Code
4094
+ * comes from .mcp.json" where the CLI warned that it is not valid JSON
4095
+ * (review, 2026-11-07).
4096
+ */
4097
+ export function vsCodeMcpLeavesVexpOut(file) {
4098
+ if (!fs.existsSync(file))
4099
+ return true;
4100
+ let data;
4101
+ try {
4102
+ data = parseJsonc(fs.readFileSync(file, "utf-8"));
4103
+ }
4104
+ catch {
4105
+ return false;
4106
+ }
4107
+ if (data === null)
4108
+ return false;
4109
+ const servers = typeof data === "object" ? asObject(data.servers) : undefined;
4110
+ return !(servers && Object.keys(servers).some((k) => k.toLowerCase() === "vexp"));
4111
+ }
4112
+ /**
4113
+ * D4's INFO condition (M2), lockstep with the extension doctor:
4114
+ * .vscode/mcp.json parses and holds a server named vexp in any letter case,
4115
+ * the one VS Code uses when the .mcp.json entry would not serve this root.
4116
+ * Absent or not parsing proves nothing either way.
4117
+ */
4118
+ export function vsCodeMcpHoldsVexp(file) {
4119
+ if (!fs.existsSync(file))
4120
+ return false;
4121
+ let data;
4122
+ try {
4123
+ data = parseJsonc(fs.readFileSync(file, "utf-8"));
4124
+ }
4125
+ catch {
4126
+ return false;
4127
+ }
4128
+ const servers = data !== null && typeof data === "object" ? asObject(data.servers) : undefined;
4129
+ return !!servers && Object.keys(servers).some((k) => k.toLowerCase() === "vexp");
4130
+ }
4131
+ /** D4: .mcp.json defines vexp, and vexp left itself out of .vscode/mcp.json (W4). Word for word the extension's. */
4132
+ export const DOT_MCP_JSON_SERVES = "vexp for VS Code comes from .mcp.json";
4133
+ /**
4134
+ * Is the local model switched on in vexp's config.toml (`[llm]` with
4135
+ * `enabled = true`, or a top-level `llm.enabled = true`)? What
4136
+ * LlmConfig::load_user_global reads; every daemon of this user starts with
4137
+ * it, and each loads the model into its own memory. Lockstep with the
4138
+ * extension's llmEnabledInConfig.
4139
+ */
4140
+ export function llmEnabledInConfig(toml) {
4141
+ let section = "";
4142
+ for (const raw of toml.split(/\r?\n/)) {
4143
+ const l = raw.replace(/#.*$/, "").trim();
4144
+ const header = /^\[\s*([^\]]+?)\s*\]$/.exec(l);
4145
+ if (header) {
4146
+ section = header[1];
4147
+ continue;
4148
+ }
4149
+ const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(\S+)$/.exec(l);
4150
+ if (!kv)
4151
+ continue;
4152
+ const key = section ? `${section}.${kv[1]}` : kv[1];
4153
+ if (key === "llm.enabled")
4154
+ return kv[2] === "true";
4155
+ }
4156
+ return false;
4157
+ }
4158
+ /**
4159
+ * The registered daemon roots of the repository `root` belongs to: its main
4160
+ * checkout and that checkout's linked worktrees (vexp-core's
4161
+ * git_project_root collapse). Liveness is the caller's: the registry keeps
4162
+ * rows of daemons that are gone. Lockstep with the extension's
4163
+ * repositoryDaemonRoots.
4164
+ */
4165
+ export function repositoryDaemonRoots(registryRoots, root, mainOf = linkedWorktreeMain) {
4166
+ const repo = (dir) => mainOf(dir) ?? dir;
4167
+ const ours = repo(root);
4168
+ return registryRoots.filter((r) => sameFolderAsExtension(repo(r), ours));
4169
+ }
4170
+ /**
4171
+ * D5: more than one live daemon of this repository while the local model is
4172
+ * on. Only when the daemon serving here runs the model (`servedCompressor`
4173
+ * "llm"): where DevMind stays off, or the daemon runs the rule compressor
4174
+ * for any reason, no daemon holds a copy, and the line told of a cost that
4175
+ * did not exist (review, 2026-11-08). Nor when the model is an [llm]
4176
+ * endpoint's (`servedModel` "openai-compat:..."): the daemons share that one
4177
+ * server and load nothing (final verify, 2026-11-08). Lockstep with the
4178
+ * extension's llmDaemonsLine (packages/vexp-vscode/src/doctor-lines.ts).
4179
+ */
4180
+ export function llmDaemonsFinding(live, servedCompressor, servedModel) {
4181
+ if (live < 2 || servedCompressor !== "llm")
4182
+ return null;
4183
+ if (typeof servedModel === "string" && servedModel.startsWith("openai-compat:"))
4184
+ return null;
4185
+ return {
4186
+ level: INFO,
4187
+ message: `LLM compression: ${live} vexp daemons of this repository are running (its main checkout and git worktrees), and each loads its own copy of the model`,
4188
+ };
4189
+ }
4190
+ /**
4191
+ * What doctor says when LLM compression is on in the config and the daemon
4192
+ * runs the rule compressor (`llm_configured_but_inactive`), from the
4193
+ * daemon's own cause.
4194
+ *
4195
+ * DevMind off by policy (no GPU it can use, an Intel Mac, prefer_gpu =
4196
+ * false, ...) is no fault: an INFO that counts as no warning and brings no
4197
+ * restart tip, with the way out. It was a WARN saying "restarting will NOT
4198
+ * change this - fix the cause above" to users with nothing to fix (field
4199
+ * report, 2026-11-08: an hour spent on it). An endpoint that did not answer
4200
+ * is a fault a restart does fix once the server is up. A build with no
4201
+ * local model needs only the leftover setting turned off. Everything else
4202
+ * keeps the advice it had. The INFO line is word for word the extension
4203
+ * Doctor's (packages/vexp-vscode doctor-lines.ts); `devmindOff` reads the
4204
+ * cause as packages/vexp-vscode/src/ui/llm-advice.ts does.
4205
+ */
4206
+ export function llmCompressorFinding(st) {
4207
+ if (!st || st.llm_configured_but_inactive !== true)
4208
+ return null;
4209
+ const off = devmindOff(st);
4210
+ if (off)
4211
+ return { level: INFO, message: devmindOffNote(off) };
4212
+ const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
4213
+ const daemon = `\n daemon: v${st.daemon_version ?? "?"} at ${st.workspace_root ?? "?"}`;
4214
+ if (isEndpointUnreachable(why)) {
4215
+ return {
4216
+ level: WARN,
4217
+ message: "the [llm] endpoint did not answer when this daemon started, so it runs the RULE compressor - results are not LLM-compressed." +
4218
+ `\n reason: ${why}` +
4219
+ "\n once that server answers, run 'vexp daemon-cmd restart'" +
4220
+ daemon,
4221
+ };
4222
+ }
4223
+ if (why !== undefined && /vanilla build/i.test(why)) {
4224
+ return {
4225
+ level: INFO,
4226
+ message: "this build of vexp has no local LLM, and your config still switches it on, left from a build that had one: " +
4227
+ "'vexp setup-llm --disable' turns that off. Everything else works the same, with the rule-based compressor.",
4228
+ };
4229
+ }
4230
+ // The daemon knows WHY, and doctor was throwing it away: it printed the
4231
+ // same "restart to load the model" the sidebar prints, to a user whose
4232
+ // model fails on every start. Restarting is advice only when the cause
4233
+ // can change between starts.
4234
+ const restartIsPointless = why !== undefined &&
4235
+ /vanilla build|model load failed|incomplete|did not survive|worker did not start|needs a GPU/i.test(why);
4236
+ // A daemon still running its startup sync has not REACHED the model
4237
+ // yet. Restarting there is worse than useless: it starts the sync over,
4238
+ // and the user never arrives.
4239
+ const stillStarting = why !== undefined && /still completing its startup/i.test(why);
4240
+ return {
4241
+ level: WARN,
4242
+ message: `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed.` +
4243
+ (why ? `\n reason: ${why}` : "") +
4244
+ (stillStarting
4245
+ ? `\n wait for the sync to finish — do NOT restart, that begins it again` +
4246
+ `\n the daemon logs 'LlmWorker active' when the model is up`
4247
+ : restartIsPointless
4248
+ ? `\n restarting will NOT change this — fix the cause above`
4249
+ : `\n run 'vexp daemon-cmd restart' to load the model`) +
4250
+ daemon,
4251
+ };
4252
+ }
3359
4253
  async function doctorChecks(onWorkspace) {
3360
4254
  const home = vexpHome();
3361
4255
  const regPath = path.join(home, ".vexp", "daemons.json");
@@ -3422,8 +4316,18 @@ async function doctorChecks(onWorkspace) {
3422
4316
  // No daemon, the extension and no `vexp` command: most often a project
3423
4317
  // that is simply not open in the IDE, which is no fault at all.
3424
4318
  let closedInIde = false;
3425
- if (live)
4319
+ // index_status, asked once: the folder it serves is said here, the rest
4320
+ // of its answer below.
4321
+ const status = live ? await queryDaemon(sock, "index_status") : null;
4322
+ if (live) {
3426
4323
  line(OK, `daemon socket reachable: ${sock}`);
4324
+ // Which folder that daemon serves (D1): in a worktree, two file counts
4325
+ // that did not match were the only clue that the main checkout's index
4326
+ // answered. Lockstep with the extension's doctor.
4327
+ const served = status ? servedRootFinding(status.workspace_root, ws.root) : null;
4328
+ if (served)
4329
+ emit(served);
4330
+ }
3427
4331
  else {
3428
4332
  let startBlocked;
3429
4333
  try {
@@ -3439,7 +4343,7 @@ async function doctorChecks(onWorkspace) {
3439
4343
  const down = daemonDownFindings({
3440
4344
  sock,
3441
4345
  install,
3442
- hasIndex: fs.existsSync(path.join(ws.root, ".vexp", "index.db")),
4346
+ startsOnOpen: extensionStartsOnOpen(ws.root),
3443
4347
  startBlocked,
3444
4348
  outside: what ? { root: ws.root, what } : undefined,
3445
4349
  idleExit,
@@ -3467,13 +4371,17 @@ async function doctorChecks(onWorkspace) {
3467
4371
  // probes below weigh their own failures against it.
3468
4372
  let hookPrompts7d = 0;
3469
4373
  if (live) {
3470
- const st = await queryDaemon(sock, "index_status");
4374
+ const st = status;
3471
4375
  if (st) {
3472
4376
  // A daemon that is still loading its model answers "rule", which reads
3473
4377
  // as a downgrade for the ~20s it takes to settle. The daemon already
3474
4378
  // says which of the two it is; doctor was printing the label and
3475
4379
  // dropping the reason (tier-4 field report, 2026-09-05).
4380
+ // Only where the config switches the LLM on: a daemon of 3.4.0
4381
+ // with nothing configured no longer says "still loading", and one
4382
+ // from before did, during every startup sync (review, 2026-11-08).
3476
4383
  const loading = st.compressor !== "llm"
4384
+ && st.llm_configured_but_inactive === true
3477
4385
  && typeof st.llm_inactive_reason === "string"
3478
4386
  && /still loading|finishing its startup/i.test(st.llm_inactive_reason);
3479
4387
  const comp = st.compressor === "llm"
@@ -3485,28 +4393,10 @@ async function doctorChecks(onWorkspace) {
3485
4393
  const idleStop = idleStopFinding(st.idle_stop);
3486
4394
  if (idleStop)
3487
4395
  emit(idleStop);
3488
- if (st.llm_configured_but_inactive === true) {
3489
- // The daemon knows WHY, and doctor was throwing it away: it printed
3490
- // the same "restart to load the model" the sidebar prints, to a user
3491
- // whose model fails on every start. Restarting is advice only when
3492
- // the cause can change between starts.
3493
- const why = typeof st.llm_inactive_reason === "string" ? st.llm_inactive_reason : undefined;
3494
- const restartIsPointless = why !== undefined &&
3495
- /vanilla build|model load failed|incomplete|did not survive|worker did not start/i.test(why);
3496
- // A daemon still running its startup sync has not REACHED the model
3497
- // yet. Restarting there is worse than useless: it starts the sync
3498
- // over, and the user never arrives.
3499
- const stillStarting = why !== undefined && /still completing its startup/i.test(why);
3500
- line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed.` +
3501
- (why ? `\n reason: ${why}` : "") +
3502
- (stillStarting
3503
- ? `\n wait for the sync to finish — do NOT restart, that begins it again` +
3504
- `\n the daemon logs 'LlmWorker active' when the model is up`
3505
- : restartIsPointless
3506
- ? `\n restarting will NOT change this — fix the cause above`
3507
- : `\n run 'vexp daemon-cmd restart' to load the model`) +
3508
- `\n daemon: v${st.daemon_version ?? "?"} at ${st.workspace_root ?? "?"}`);
3509
- }
4396
+ // Why the config's LLM is not what the daemon runs (see llmCompressorFinding).
4397
+ const llm = llmCompressorFinding(st);
4398
+ if (llm)
4399
+ emit(llm);
3510
4400
  // 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
3511
4401
  // serving the OLD feature set and its gaps read as product bugs
3512
4402
  // (field case: pre-ledger daemon after the 2.4.0 install). The VS Code
@@ -3569,6 +4459,35 @@ async function doctorChecks(onWorkspace) {
3569
4459
  emit(f);
3570
4460
  }
3571
4461
  }
4462
+ // A linked worktree with no answers of its own uses its main checkout's
4463
+ // (D3): said, so an agent set up in a worktree that was never asked is no
4464
+ // mystery. Lockstep with the extension's doctor.
4465
+ {
4466
+ let inherited;
4467
+ try {
4468
+ inherited = inheritedAnswers(readAgentConsent(), ws.root);
4469
+ }
4470
+ catch { /* unreadable answers: said in their own section */ }
4471
+ if (inherited)
4472
+ emit(inheritedAnswersFinding(inherited.main));
4473
+ }
4474
+ // The local model is per daemon (D5): the main checkout and each of its
4475
+ // worktrees with a daemon load one copy each, when the daemon here runs
4476
+ // it. Lockstep with the extension.
4477
+ {
4478
+ let toml = "";
4479
+ try {
4480
+ toml = fs.readFileSync(configTomlPath(), "utf-8");
4481
+ }
4482
+ catch { /* no config: the model is off */ }
4483
+ if (llmEnabledInConfig(toml) && status?.compressor === "llm") {
4484
+ const roots = repositoryDaemonRoots(Object.keys(registry), ws.root);
4485
+ const alive = (await Promise.all(roots.map((r) => (typeof registry[r] === "string" ? reachable(registry[r]) : Promise.resolve(false))))).filter(Boolean).length;
4486
+ const many = llmDaemonsFinding(alive, status.compressor, status.llm_model);
4487
+ if (many)
4488
+ emit(many);
4489
+ }
4490
+ }
3572
4491
  // Where the agent hooks ran from, as the hook client recorded it: read from
3573
4492
  // disk, so it speaks also when no daemon answers here, which is exactly
3574
4493
  // when an agent with the parent folder open leaves its only trace.
@@ -3857,22 +4776,57 @@ async function doctorChecks(onWorkspace) {
3857
4776
  console.log(chalk.bold("\nGitHub Copilot / VS Code (.vscode/mcp.json)"));
3858
4777
  {
3859
4778
  const mcpPath = path.join(ws.root, ".vscode", "mcp.json");
3860
- if (!fs.existsSync(mcpPath)) {
3861
- line(OK, "no .vscode/mcp.json (Copilot MCP not configured in this folder — 'vexp setup --agents \"GitHub Copilot\"' writes it)");
3862
- }
3863
- else {
3864
- let cfg = null;
4779
+ const link = projectFileLink(mcpPath, ws.root);
4780
+ // <root>/.mcp.json defines vexp and .vscode/mcp.json holds none: setup
4781
+ // left vexp out of it on purpose (W4), and VS Code takes vexp from
4782
+ // .mcp.json (D4). "no vexp server - run setup" sent the user to a
4783
+ // setup that leaves it out again.
4784
+ let cfg = null;
4785
+ const present = fs.existsSync(mcpPath);
4786
+ if (present) {
3865
4787
  try {
3866
4788
  cfg = parseJsonc(fs.readFileSync(mcpPath, "utf-8"));
3867
4789
  }
3868
4790
  catch {
3869
4791
  cfg = null;
3870
4792
  }
3871
- const v = vsCodeMcpVerdict(cfg, ws.root);
4793
+ }
4794
+ // Only a .mcp.json entry that would serve this root from VS Code is where
4795
+ // VS Code takes vexp from (M2, the writer's dotMcpJsonMiss); one pinned to
4796
+ // another folder, or whose command does not start here, gets vexp's own
4797
+ // entry in .vscode/mcp.json instead, said as INFO below.
4798
+ const dotMcp = projectMcpJsonVexp(ws.root);
4799
+ const dotMiss = dotMcp ? dotMcpJsonMiss(dotMcp.entry, ws.root) : undefined;
4800
+ const fromDotMcp = dotMcp !== undefined && dotMiss === undefined && vsCodeMcpLeavesVexpOut(mcpPath);
4801
+ if (fromDotMcp) {
4802
+ line(OK, DOT_MCP_JSON_SERVES);
4803
+ }
4804
+ else if (!present) {
4805
+ line(OK, vsCodeMcpAbsentLine(link));
4806
+ }
4807
+ else {
4808
+ const v = vsCodeMcpVerdict(cfg, ws.root, undefined, link);
3872
4809
  line(v.level, v.message);
3873
4810
  console.log(chalk.dim(" VS Code shows it under Extensions → MCP SERVERS - INSTALLED; vexp tools appear only in Copilot Chat AGENT mode (tools picker → 'MCP Server: vexp')."));
3874
4811
  console.log(chalk.dim(" server log: Command Palette → 'MCP: List Servers' → vexp → Show Output (channel 'MCP: vexp'); daemon log: .vexp/daemon.log"));
3875
4812
  }
4813
+ // A file agents read here that pins vexp to another checkout (D2), and a
4814
+ // worktree's vexp-only .vscode/mcp.json beside a main checkout whose
4815
+ // untracked copy holds other servers (D4, W5). Lockstep with the
4816
+ // extension's doctor.
4817
+ for (const f of pinnedElsewhereFindings([
4818
+ { file: path.join(ws.root, ".mcp.json"), key: "mcpServers" },
4819
+ { file: mcpPath, key: "servers" },
4820
+ ], ws.root))
4821
+ emit(f);
4822
+ // M2: .mcp.json defines vexp but would not serve this root from VS Code,
4823
+ // so the entry in .vscode/mcp.json is the one VS Code uses: said after
4824
+ // D2, in the extension doctor's order.
4825
+ if (dotMiss && vsCodeMcpHoldsVexp(mcpPath))
4826
+ emit({ level: INFO, message: dotMcpJsonKeptLine(ws.root, dotMiss) });
4827
+ const missing = worktreeMcpJsonNote(ws.root, mcpPath);
4828
+ if (missing)
4829
+ emit({ level: INFO, message: missing });
3876
4830
  }
3877
4831
  // 5a-bis) WSL: agents on the Windows side read Windows-side configs, which
3878
4832
  // nothing running in WSL can see or write. Say so, and for Windsurf hand
@@ -4053,21 +5007,10 @@ async function doctorChecks(onWorkspace) {
4053
5007
  const v = String(r.stdout ?? "").trim();
4054
5008
  return r.status === 0 && v ? v : null;
4055
5009
  })();
4056
- const ours = path.join(ws.root, ".git", "hooks");
4057
- const installed = ["pre-commit", "post-merge", "post-checkout"].filter((h) => {
4058
- try {
4059
- return fs.readFileSync(path.join(ours, h), "utf-8").includes("vexp");
4060
- }
4061
- catch {
4062
- return false;
4063
- }
4064
- });
4065
- let declined;
4066
- try {
4067
- declined = fs.readFileSync(path.join(ws.root, ".vexp", "git-hooks.declined"), "utf-8").split(/\r?\n/)[0];
4068
- }
4069
- catch { /* not declined */ }
4070
- const verdict = gitHooksVerdict(hooksPath, ws.root, installed.length, declined);
5010
+ // Where .git is a file (a linked worktree, a submodule, a separate git
5011
+ // dir) the hooks folder is the one git resolves (gitHooksFinding); a
5012
+ // normal checkout reads .git/hooks as before.
5013
+ const verdict = gitHooksFinding(ws.root, hooksPath);
4071
5014
  line(verdict.level, verdict.message);
4072
5015
  }
4073
5016
  console.log(chalk.bold("\nClaude Code orientation hooks (.claude/settings.json)"));
@@ -4386,6 +5329,12 @@ async function doctorChecks(onWorkspace) {
4386
5329
  zcodeHomeExists: fs.existsSync(path.join(home, ".zcode")),
4387
5330
  consent: decisionFor(readAgentConsent(), ws.root, "ZCode"),
4388
5331
  };
5332
+ // The file setup writes, and the link that stops it, if any: its
5333
+ // remedies then name the link (ZcodeInput.projectLink).
5334
+ const zcodeTarget = zcodeMcpTarget(ws.root);
5335
+ const zcodeLink = projectFileLink(zcodeTarget, ws.root);
5336
+ if (zcodeLink)
5337
+ input.projectLink = { file: zcodeTarget, link: zcodeLink };
4389
5338
  const inUse = zcodeInUse(input);
4390
5339
  const spec = inUse ? zcodeProbeSpec(input) : null;
4391
5340
  if (spec) {
@@ -4416,6 +5365,36 @@ async function doctorChecks(onWorkspace) {
4416
5365
  // Never the reason doctor stops: the sections below still run.
4417
5366
  line(INFO, `ZCode: not checked (${e?.message ?? String(e)})`);
4418
5367
  }
5368
+ // 5b-quater) Antigravity. Setup wrote an entry no current Antigravity
5369
+ // reads, and no doctor looked at it (field report, 2026-11-07). Every
5370
+ // decision is in antigravityFindings; this block only reads.
5371
+ console.log(chalk.bold("\nAntigravity (.agents/mcp_config.json, ~/.gemini/config/mcp_config.json)"));
5372
+ try {
5373
+ const exists = (p) => fs.existsSync(p);
5374
+ const home = os.homedir();
5375
+ const input = {
5376
+ root: ws.root,
5377
+ platform: process.platform,
5378
+ ...readAntigravityFiles(ws.root, home),
5379
+ // As planAgentSetup reads them: the project's files set Antigravity up
5380
+ // only where the machine shows it.
5381
+ marker: (exists(path.join(ws.root, "GEMINI.md")) || exists(path.join(ws.root, ".agent"))) && antigravityOnThisMachine(),
5382
+ consent: decisionFor(readAgentConsent(), ws.root, "Antigravity"),
5383
+ exists,
5384
+ remedy: setupAgentsRemedy(install, ["Antigravity"], ws.root),
5385
+ version: CLI_VERSION,
5386
+ tracked: gitTrackedPath(ws.root),
5387
+ globalCleaned: vexpCleansAntigravityFile(home, antigravityGlobalMcpPath(home)),
5388
+ migrated: exists(path.join(home, ".gemini", "config", ".migrated")),
5389
+ projectLink: projectFileLink(path.join(ws.root, ".agents", "mcp_config.json"), ws.root),
5390
+ };
5391
+ for (const f of antigravityFindings(input))
5392
+ emit(f);
5393
+ }
5394
+ catch (e) {
5395
+ // Never the reason doctor stops: the sections below still run.
5396
+ line(INFO, `Antigravity: not checked (${e?.message ?? String(e)})`);
5397
+ }
4419
5398
  // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
4420
5399
  // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
4421
5400
  // cannot spawn silently enforces nothing there as well. The guard's stdin