vexp-cli 3.3.9 → 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/agent-config.js +3454 -602
- package/dist/agent-consent.js +111 -1
- package/dist/cli.js +99 -62
- package/dist/devmind.js +269 -0
- package/dist/doctor.js +1068 -89
- package/dist/git-worktree.js +136 -0
- package/dist/hook-scripts.js +271 -0
- package/dist/license-activate.js +33 -0
- package/dist/serve.js +7 -2
- package/mcp/mcp-server.cjs +52 -51
- package/package.json +7 -7
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 {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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|needs a GPU/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
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
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
|