vexp-cli 3.3.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-config.js +3 -2
- package/dist/cloud-sync.js +98 -0
- package/dist/doctor.js +856 -21
- package/mcp/mcp-server.cjs +40 -40
- package/package.json +6 -6
package/dist/doctor.js
CHANGED
|
@@ -6,12 +6,13 @@ import { execFileSync, spawnSync } from "child_process";
|
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { fnvHash, socketPathFor, socketPathMargin } from "./socket-path.js";
|
|
8
8
|
import { agentConsentPath, decisionFor, loadAgentConsentFile, readAgentConsent } from "./agent-consent.js";
|
|
9
|
-
import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath, getAgentList } from "./agent-config.js";
|
|
9
|
+
import { parseJsonc, isWsl, windsurfWslBridgeNote, windsurfGlobalMcpPath, getAgentList, isVexpZcodeHookEntry, setupAgentsCommand, zcodeUserConfigPath } from "./agent-config.js";
|
|
10
10
|
import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js";
|
|
11
11
|
import { CLI_VERSION } from "./version.js";
|
|
12
12
|
import { formatCheckIn, registrationState } from "./license.js";
|
|
13
13
|
import { parentPid } from "./mcp-supervisor.js";
|
|
14
14
|
import { slowMountNotice } from "./slow-mount.js";
|
|
15
|
+
import { cloudSyncNotice } from "./cloud-sync.js";
|
|
15
16
|
import { vexpHome, isStateHome } from "./state-home.js";
|
|
16
17
|
import { canonicalWorkspaceRoot } from "./socket-path.js";
|
|
17
18
|
import { codexHome, codexHookTrust, codexOtherConfigLayers, codexTrustCandidates, codexTrustFindings } from "./codex-trust.js";
|
|
@@ -1124,6 +1125,720 @@ export function ledgerFindings(ledger) {
|
|
|
1124
1125
|
}
|
|
1125
1126
|
return out;
|
|
1126
1127
|
}
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
// Who reached the daemon, and from where.
|
|
1130
|
+
//
|
|
1131
|
+
// Field report (2026-09-28, macOS, ZCode, a project in iCloud Drive): "vexp
|
|
1132
|
+
// setup done for ZCode but vexp is not used". His doctor was green except
|
|
1133
|
+
// the license and read "sessions (4h): 2 active, 0 pipeline calls total",
|
|
1134
|
+
// with no ledger line. Neither "session" was an agent: the orientation
|
|
1135
|
+
// hook's own pseudo-session ("internal-hook") and a one-shot CLI call were
|
|
1136
|
+
// counted as active sessions next to the agents'. The hook's abstentions
|
|
1137
|
+
// (a prompt under 8 words, an empty index) were recorded nowhere, so "the
|
|
1138
|
+
// hook fires and skips" read exactly like "the hook never fires". And a hook
|
|
1139
|
+
// run from a folder no daemon serves (ZCode had the PARENT of the nested,
|
|
1140
|
+
// same-name project open) left no trace at all. Each function below answers
|
|
1141
|
+
// one of those, from what the daemon (index_status) and the hook client
|
|
1142
|
+
// (~/.vexp/hook-calls.json) now record.
|
|
1143
|
+
// ---------------------------------------------------------------------------
|
|
1144
|
+
/** A finding doctor prints dim and indented under the lines before it; never counted. */
|
|
1145
|
+
export const NOTE = "NOTE";
|
|
1146
|
+
/**
|
|
1147
|
+
* What an index_status `sessions` row is: an agent's MCP session, the
|
|
1148
|
+
* pseudo-session of vexp's hooks, or a one-shot CLI call. Daemons from this
|
|
1149
|
+
* release on say so in "kind"; older ones sent only the id cut to 8
|
|
1150
|
+
* characters, where the hooks' "internal-hook" reads "internal" and a CLI
|
|
1151
|
+
* call of this release's client still shows its "cli-" prefix. Only an older
|
|
1152
|
+
* client's CLI call (a bare uuid) cannot be told from an agent.
|
|
1153
|
+
*/
|
|
1154
|
+
export function sessionKind(row) {
|
|
1155
|
+
const k = row.kind;
|
|
1156
|
+
if (k === "agent" || k === "hook" || k === "cli")
|
|
1157
|
+
return k;
|
|
1158
|
+
const s = typeof row.session === "string" ? row.session : "";
|
|
1159
|
+
if (s === "internal")
|
|
1160
|
+
return "hook";
|
|
1161
|
+
// A daemon from before "kind" that outlived an upgrade (the CLI's bootstrap
|
|
1162
|
+
// only probes the socket, it never replaces it) still shows the new client's
|
|
1163
|
+
// "cli-" prefix in its 8 characters; a uuid never starts with it.
|
|
1164
|
+
return s.startsWith("cli-") ? "cli" : "agent";
|
|
1165
|
+
}
|
|
1166
|
+
/** "5 min ago", "3 h ago", "2 days ago": age of a unix-seconds stamp. */
|
|
1167
|
+
export function agoText(seconds) {
|
|
1168
|
+
const s = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
1169
|
+
if (s < 60)
|
|
1170
|
+
return "less than a minute ago";
|
|
1171
|
+
if (s < 3600)
|
|
1172
|
+
return `${Math.floor(s / 60)} min ago`;
|
|
1173
|
+
if (s < 48 * 3600)
|
|
1174
|
+
return `${Math.floor(s / 3600)} h ago`;
|
|
1175
|
+
return `${Math.floor(s / 86400)} days ago`;
|
|
1176
|
+
}
|
|
1177
|
+
function count(v) {
|
|
1178
|
+
const n = Number(v);
|
|
1179
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* The live sessions of the last 4 hours as doctor lines. Only agent
|
|
1183
|
+
* sessions are "agent sessions": the hooks' row counts hook calls, not agent
|
|
1184
|
+
* tool calls, and a CLI row is a person at a terminal. The no-session
|
|
1185
|
+
* branches are the ones doctor always had, now decided on agent sessions
|
|
1186
|
+
* alone.
|
|
1187
|
+
*/
|
|
1188
|
+
export function sessionsFindings(sessions, o) {
|
|
1189
|
+
const of = (kind) => sessions.filter((s) => sessionKind(s) === kind);
|
|
1190
|
+
const agents = of("agent");
|
|
1191
|
+
const hooks = of("hook");
|
|
1192
|
+
const clis = of("cli");
|
|
1193
|
+
const sum = (rows, key) => rows.reduce((t, s) => t + count(s[key]), 0);
|
|
1194
|
+
// The hooks' shared pseudo-session ("internal-hook") records each call as
|
|
1195
|
+
// an "other" one: the prompt hook's prompt_hint, and also Claude Code's
|
|
1196
|
+
// SessionStart context restore (index_status on compact/resume, installed
|
|
1197
|
+
// by default) and its opt-in stop gate (verify_done). Not a prompt count;
|
|
1198
|
+
// hook_activity is. Doctor's own probes go in as CLI calls.
|
|
1199
|
+
const hookCalls = sum(hooks, "other_calls");
|
|
1200
|
+
const agentHookAgo = typeof o.hookLastAt === "number" && Number.isFinite(o.hookLastAt) && o.nowS !== undefined && o.nowS - o.hookLastAt <= 4 * 3600
|
|
1201
|
+
? agoText(o.nowS - o.hookLastAt)
|
|
1202
|
+
: null;
|
|
1203
|
+
const out = [];
|
|
1204
|
+
if (agents.length > 0) {
|
|
1205
|
+
out.push({
|
|
1206
|
+
level: OK,
|
|
1207
|
+
message: `agent sessions (4h): ${agents.length} - ${sum(agents, "pipeline_calls")} pipeline, ${sum(agents, "skeleton_calls")} skeleton, ${sum(agents, "other_calls")} other tool call(s)`,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
else if (o.prompts7d > 0) {
|
|
1211
|
+
out.push({ level: OK, message: `no agent tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)` });
|
|
1212
|
+
}
|
|
1213
|
+
else if (o.uptimeS > 600) {
|
|
1214
|
+
// Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
|
|
1215
|
+
// the daemon was unreachable serves from its own in-process index and
|
|
1216
|
+
// never attaches to the daemon later — its calls are real but
|
|
1217
|
+
// invisible to these counters. Don't tell that user "the agent is not
|
|
1218
|
+
// using vexp"; tell them how to converge on the daemon.
|
|
1219
|
+
out.push({
|
|
1220
|
+
level: WARN,
|
|
1221
|
+
message: `no agent session has called vexp through this daemon since it started — either no agent is using vexp here (check its MCP config), or the agent's vexp MCP server started BEFORE the daemon and is running embedded (in-process). If calls do succeed in the agent, restart the agent (with the daemon already up) so its MCP server attaches to the daemon.` +
|
|
1222
|
+
// The field report's exact state: an agent's hook reached the
|
|
1223
|
+
// daemon, so an agent was at work here, and no tool call came in.
|
|
1224
|
+
(agentHookAgo
|
|
1225
|
+
? `\n an agent's hook brought a prompt to this daemon ${agentHookAgo}, so an agent is at work in this project: its vexp MCP tools are either not connected or never called.`
|
|
1226
|
+
: ""),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
else {
|
|
1230
|
+
out.push({ level: OK, message: `no session calls yet (daemon just started)` });
|
|
1231
|
+
}
|
|
1232
|
+
if (hooks.length > 0) {
|
|
1233
|
+
out.push({
|
|
1234
|
+
level: OK,
|
|
1235
|
+
message: `vexp hooks (4h): ${hookCalls} call(s) reached this daemon - prompts, plus Claude Code's context restore after compact/resume and its stop gate`,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
if (clis.length > 0) {
|
|
1239
|
+
const calls = sum(clis, "pipeline_calls") + sum(clis, "skeleton_calls") + sum(clis, "other_calls");
|
|
1240
|
+
out.push({ level: NOTE, message: `${calls} one-shot CLI call(s) in the last 4h (vexp search, skeleton, doctor's own hook probes, ... from a terminal or a script): not agent sessions` });
|
|
1241
|
+
}
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* What the orientation hook did when it did not orient: index_status's
|
|
1246
|
+
* `hook_activity` (7 days) as doctor lines, apart from the savings ledger,
|
|
1247
|
+
* whose numbers these abstentions never change. Nothing for a daemon that
|
|
1248
|
+
* predates the key.
|
|
1249
|
+
*/
|
|
1250
|
+
export function hookActivityFindings(ha, ledger, nowS) {
|
|
1251
|
+
if (!ha || typeof ha !== "object" || Array.isArray(ha))
|
|
1252
|
+
return [];
|
|
1253
|
+
const h = ha;
|
|
1254
|
+
const days = count(h.period_days) || 7;
|
|
1255
|
+
const short = count(h.abstained_short);
|
|
1256
|
+
const empty = count(h.abstained_empty_index);
|
|
1257
|
+
const skipped = short + empty;
|
|
1258
|
+
const analyzed = count(ledger.prompts_analyzed);
|
|
1259
|
+
const parts = [
|
|
1260
|
+
short > 0 ? `${short} under 8 words (follow-ups are never oriented)` : "",
|
|
1261
|
+
empty > 0 ? `${empty} while the index was empty` : "",
|
|
1262
|
+
]
|
|
1263
|
+
.filter(Boolean)
|
|
1264
|
+
.join(", ");
|
|
1265
|
+
const out = [];
|
|
1266
|
+
if (skipped > 0 && analyzed === 0) {
|
|
1267
|
+
out.push({ level: OK, message: `orientation hook reached vexp ${skipped} time(s) in the last ${days} days and every prompt was skipped: ${parts}` });
|
|
1268
|
+
}
|
|
1269
|
+
else if (skipped > 0) {
|
|
1270
|
+
out.push({ level: NOTE, message: `the hook also skipped ${skipped} prompt(s) in the last ${days} days: ${parts}` });
|
|
1271
|
+
}
|
|
1272
|
+
const last = typeof h.last_at === "number" && Number.isFinite(h.last_at) ? h.last_at : null;
|
|
1273
|
+
if (last !== null) {
|
|
1274
|
+
out.push({ level: OK, message: `last prompt through an agent hook: ${agoText(nowS - last)}` });
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
// "Fires and skips" and "never fires" are now two different lines.
|
|
1278
|
+
out.push({ level: NOTE, message: `no prompt has reached this daemon through an agent hook in the last ${days} days` });
|
|
1279
|
+
}
|
|
1280
|
+
return out;
|
|
1281
|
+
}
|
|
1282
|
+
/** Where the hook client leaves its breadcrumbs: <vexp home>/.vexp/hook-calls.json. */
|
|
1283
|
+
export function hookCallsPath(home = vexpHome()) {
|
|
1284
|
+
return path.join(home, ".vexp", "hook-calls.json");
|
|
1285
|
+
}
|
|
1286
|
+
/** The breadcrumbs, newest first; anything unreadable is skipped, never thrown. */
|
|
1287
|
+
export function parseHookCalls(text) {
|
|
1288
|
+
let obj;
|
|
1289
|
+
try {
|
|
1290
|
+
obj = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
return [];
|
|
1294
|
+
}
|
|
1295
|
+
const list = obj?.entries;
|
|
1296
|
+
if (!Array.isArray(list))
|
|
1297
|
+
return [];
|
|
1298
|
+
const out = [];
|
|
1299
|
+
for (const e of list) {
|
|
1300
|
+
if (!e || typeof e !== "object")
|
|
1301
|
+
continue;
|
|
1302
|
+
const r = e;
|
|
1303
|
+
const at = Number(r.at);
|
|
1304
|
+
if (typeof r.root !== "string" || !r.root || !Number.isFinite(at))
|
|
1305
|
+
continue;
|
|
1306
|
+
out.push({
|
|
1307
|
+
root: r.root,
|
|
1308
|
+
at,
|
|
1309
|
+
outcome: typeof r.outcome === "string" && r.outcome ? r.outcome : "unknown",
|
|
1310
|
+
reason: typeof r.reason === "string" && r.reason ? r.reason : null,
|
|
1311
|
+
agent: typeof r.agent === "string" && r.agent ? r.agent : null,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
return out.sort((a, b) => b.at - a.at);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Where `a` stands relative to `b`: the same folder, a strict ancestor of it,
|
|
1318
|
+
* a strict descendant, or neither. Case-folded where the platform's default
|
|
1319
|
+
* filesystem is (Windows, macOS).
|
|
1320
|
+
*/
|
|
1321
|
+
export function pathRelation(a, b, platform) {
|
|
1322
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
1323
|
+
const fold = platform === "win32" || platform === "darwin";
|
|
1324
|
+
const norm = (s) => (fold ? p.resolve(s).toLowerCase() : p.resolve(s));
|
|
1325
|
+
const x = norm(a);
|
|
1326
|
+
const y = norm(b);
|
|
1327
|
+
const inside = (rel) => rel !== "" && rel !== ".." && !rel.startsWith(".." + p.sep) && !p.isAbsolute(rel);
|
|
1328
|
+
if (p.relative(x, y) === "")
|
|
1329
|
+
return "same";
|
|
1330
|
+
if (inside(p.relative(x, y)))
|
|
1331
|
+
return "ancestor";
|
|
1332
|
+
if (inside(p.relative(y, x)))
|
|
1333
|
+
return "descendant";
|
|
1334
|
+
return "unrelated";
|
|
1335
|
+
}
|
|
1336
|
+
const HOOK_OUTCOME_TROUBLE = {
|
|
1337
|
+
"no-daemon": "no vexp daemon was serving this folder then, so that prompt got no orientation ('vexp daemon-cmd start' starts one)",
|
|
1338
|
+
timeout: "the daemon did not answer within the hook client's budget (3 s by default), so that prompt got no orientation",
|
|
1339
|
+
error: "the daemon answered with an error, so that prompt got no orientation; .vexp/daemon.log has the details",
|
|
1340
|
+
};
|
|
1341
|
+
/**
|
|
1342
|
+
* The hook client's breadcrumbs as doctor lines: the last call from this
|
|
1343
|
+
* workspace, and the calls from a folder above or below it that found no
|
|
1344
|
+
* daemon, which is how an agent with the wrong folder open looks from here
|
|
1345
|
+
* (the field report's ZCode had the parent open: it read neither the
|
|
1346
|
+
* project's AGENTS.md nor its MCP config, and its hook met no daemon).
|
|
1347
|
+
* `aliases` are other spellings of `wsRoot` (its real path, its share).
|
|
1348
|
+
* `neverServed` are folders no daemon can ever serve (the home directory,
|
|
1349
|
+
* the vexp home): a user-level hook (ZCode's, Codex's) meets no daemon there
|
|
1350
|
+
* every time a session opens in them, which says nothing about this project.
|
|
1351
|
+
*/
|
|
1352
|
+
export function hookCallFindings(entries, wsRoot, nowS, platform, aliases = [], neverServed = []) {
|
|
1353
|
+
const roots = [wsRoot, ...aliases.filter((a) => !!a)];
|
|
1354
|
+
const relation = (e) => {
|
|
1355
|
+
const rels = roots.map((r) => pathRelation(e.root, r, platform));
|
|
1356
|
+
return rels.includes("same") ? "same" : rels.includes("ancestor") ? "ancestor" : rels.includes("descendant") ? "descendant" : "unrelated";
|
|
1357
|
+
};
|
|
1358
|
+
const by = (e) => (e.agent ? ` (${e.agent})` : "");
|
|
1359
|
+
const out = [];
|
|
1360
|
+
const here = entries.filter((e) => relation(e) === "same").sort((a, b) => b.at - a.at)[0];
|
|
1361
|
+
if (here) {
|
|
1362
|
+
const trouble = HOOK_OUTCOME_TROUBLE[here.outcome];
|
|
1363
|
+
const reason = here.reason ? `: ${here.reason.slice(0, 200)}` : "";
|
|
1364
|
+
out.push({
|
|
1365
|
+
level: trouble ? WARN : OK,
|
|
1366
|
+
message: `last agent-hook call from this workspace: ${agoText(nowS - here.at)}${by(here)} - ${here.outcome}${reason}${trouble ? `\n ${trouble}` : ""}`,
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
const week = 7 * 86400;
|
|
1370
|
+
// The hook has since worked from the project itself: whatever the agent
|
|
1371
|
+
// had open before, it has opened the right folder since.
|
|
1372
|
+
const worksHere = here && (here.outcome === "served" || here.outcome === "no-injection") ? here : undefined;
|
|
1373
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
1374
|
+
const unservable = (root) => p.parse(p.resolve(root)).root === p.resolve(root) || neverServed.some((h) => !!h && pathRelation(root, h, platform) === "same");
|
|
1375
|
+
let elsewhere = 0;
|
|
1376
|
+
for (const e of entries) {
|
|
1377
|
+
if (e.outcome !== "no-daemon" || nowS - e.at > week || elsewhere >= 3)
|
|
1378
|
+
continue;
|
|
1379
|
+
if (worksHere && e.at <= worksHere.at)
|
|
1380
|
+
continue;
|
|
1381
|
+
const rel = relation(e);
|
|
1382
|
+
if (rel === "ancestor" && unservable(e.root))
|
|
1383
|
+
continue;
|
|
1384
|
+
if (rel === "ancestor") {
|
|
1385
|
+
elsewhere++;
|
|
1386
|
+
out.push({
|
|
1387
|
+
level: WARN,
|
|
1388
|
+
message: `an agent's hook ran from ${e.root}${by(e)} ${agoText(nowS - e.at)} and found no vexp daemon there: that is a parent folder of this project. ` +
|
|
1389
|
+
`The agent has the parent folder open, so it reads neither this project's AGENTS.md nor its MCP config, and its hook finds no daemon - open ${wsRoot} itself.`,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
else if (rel === "descendant") {
|
|
1393
|
+
elsewhere++;
|
|
1394
|
+
out.push({
|
|
1395
|
+
level: WARN,
|
|
1396
|
+
message: `an agent's hook ran from ${e.root}${by(e)} ${agoText(nowS - e.at)} and found no vexp daemon there: that is a subfolder of this project. ` +
|
|
1397
|
+
`The agent has the subfolder open, not the project, so its hook gets no orientation (and an agent that reads AGENTS.md and its MCP config in the folder it opened, as ZCode does, finds neither) - open ${wsRoot} itself.`,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return out;
|
|
1402
|
+
}
|
|
1403
|
+
/** Read a JSON(C) config the way setup does (BOM tolerant, comments and trailing commas allowed). */
|
|
1404
|
+
export function readJsonFileState(file) {
|
|
1405
|
+
let raw;
|
|
1406
|
+
try {
|
|
1407
|
+
raw = fs.readFileSync(file, "utf-8");
|
|
1408
|
+
}
|
|
1409
|
+
catch (e) {
|
|
1410
|
+
const code = e.code;
|
|
1411
|
+
return code === "ENOENT" || code === "ENOTDIR" ? { file, state: "absent" } : { file, state: "unparseable" };
|
|
1412
|
+
}
|
|
1413
|
+
if (raw.replace(/^\uFEFF/, "").trim() === "")
|
|
1414
|
+
return { file, state: "ok", data: {} };
|
|
1415
|
+
try {
|
|
1416
|
+
const v = parseJsonc(raw);
|
|
1417
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
1418
|
+
return { file, state: "ok", data: v };
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
/* reported below */
|
|
1422
|
+
}
|
|
1423
|
+
return { file, state: "unparseable" };
|
|
1424
|
+
}
|
|
1425
|
+
function asObject(v) {
|
|
1426
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
1427
|
+
}
|
|
1428
|
+
/** A file's MCP server map: `mcp.servers` in ZCode's own files, `mcpServers` in .agents/mcp.json. */
|
|
1429
|
+
function zcodeServers(f, shape) {
|
|
1430
|
+
if (f.state !== "ok")
|
|
1431
|
+
return undefined;
|
|
1432
|
+
return shape === "native" ? asObject(asObject(f.data.mcp)?.servers) : asObject(f.data.mcpServers);
|
|
1433
|
+
}
|
|
1434
|
+
/** vexp's UserPromptSubmit entry in ZCode's user config, and the hook in it that runs vexp. */
|
|
1435
|
+
export function zcodeVexpHook(cfg) {
|
|
1436
|
+
const events = asObject(asObject(asObject(cfg)?.hooks)?.events);
|
|
1437
|
+
const list = Array.isArray(events?.UserPromptSubmit) ? events.UserPromptSubmit : [];
|
|
1438
|
+
const group = list.find((g) => isVexpZcodeHookEntry(g));
|
|
1439
|
+
if (!group)
|
|
1440
|
+
return null;
|
|
1441
|
+
const hooks = group.hooks.filter((h) => !!asObject(h));
|
|
1442
|
+
const hook = hooks.find((h) => isVexpZcodeHookEntry({ hooks: [h] })) ?? hooks[0];
|
|
1443
|
+
return hook ? { group, hook } : null;
|
|
1444
|
+
}
|
|
1445
|
+
/** Hooks of the user's own in ZCode's user config: the same test installZcodeHintHook makes
|
|
1446
|
+
* before it honours hooks.enabled: false as the user's decision. */
|
|
1447
|
+
function zcodeForeignHooks(cfg) {
|
|
1448
|
+
const events = asObject(asObject(asObject(cfg)?.hooks)?.events);
|
|
1449
|
+
if (!events)
|
|
1450
|
+
return false;
|
|
1451
|
+
return Object.entries(events).some(([event, list]) => Array.isArray(list) && list.some((h) => event !== "UserPromptSubmit" || !isVexpZcodeHookEntry(h)));
|
|
1452
|
+
}
|
|
1453
|
+
/** Is ZCode set up for this project: its folder here, vexp in the project's
|
|
1454
|
+
* .agents/mcp.json, or the user said yes to ZCode for it. Project evidence
|
|
1455
|
+
* only: vexp's hook in ~/.zcode/cli/config.json is machine-wide, and taking
|
|
1456
|
+
* it for "ZCode here" failed doctor (exit 1) in every other project once
|
|
1457
|
+
* one project had been set up for ZCode. */
|
|
1458
|
+
export function zcodeInUse(i) {
|
|
1459
|
+
const p = i.platform === "win32" ? path.win32 : path.posix;
|
|
1460
|
+
if (i.exists(p.join(i.root, ".zcode")))
|
|
1461
|
+
return true;
|
|
1462
|
+
if (zcodeServers(i.workspaceCompat, "compat")?.vexp)
|
|
1463
|
+
return true;
|
|
1464
|
+
return i.consent === "yes";
|
|
1465
|
+
}
|
|
1466
|
+
/** The command doctor runs to probe vexp's hook, exactly as ZCode would
|
|
1467
|
+
* (a `process` hook: argv, no shell); null when there is nothing runnable. */
|
|
1468
|
+
export function zcodeProbeSpec(i) {
|
|
1469
|
+
if (i.userNative.state !== "ok")
|
|
1470
|
+
return null;
|
|
1471
|
+
const found = zcodeVexpHook(i.userNative.data);
|
|
1472
|
+
if (!found || found.hook.type !== "process")
|
|
1473
|
+
return null;
|
|
1474
|
+
const command = typeof found.hook.command === "string" ? found.hook.command : "";
|
|
1475
|
+
const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
|
|
1476
|
+
if (!command || !abs || !i.exists(command))
|
|
1477
|
+
return null;
|
|
1478
|
+
const args = Array.isArray(found.hook.args) ? found.hook.args.filter((a) => typeof a === "string") : [];
|
|
1479
|
+
return { command, args };
|
|
1480
|
+
}
|
|
1481
|
+
/** A path as the doctor line names it: ~/... under home, relative under the project. */
|
|
1482
|
+
function zcodeLabel(file, i) {
|
|
1483
|
+
const p = i.platform === "win32" ? path.win32 : path.posix;
|
|
1484
|
+
if (pathRelation(i.root, file, i.platform) === "ancestor")
|
|
1485
|
+
return p.relative(i.root, file).replace(/\\/g, "/");
|
|
1486
|
+
if (pathRelation(i.home, file, i.platform) === "ancestor")
|
|
1487
|
+
return `~/${p.relative(i.home, file).replace(/\\/g, "/")}`;
|
|
1488
|
+
return file;
|
|
1489
|
+
}
|
|
1490
|
+
/** One vexp MCP entry of ZCode's, checked. */
|
|
1491
|
+
function zcodeEntryFinding(entry, label, userScope, i, setup) {
|
|
1492
|
+
const e = asObject(entry);
|
|
1493
|
+
if (!e)
|
|
1494
|
+
return { level: BAD, message: `${label}: the vexp server entry is not an object - ZCode cannot start it; re-run '${setup}'` };
|
|
1495
|
+
const command = typeof e.command === "string" ? e.command.trim() : "";
|
|
1496
|
+
const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : [];
|
|
1497
|
+
const env = asObject(e.env);
|
|
1498
|
+
const pin = typeof env?.VEXP_WORKSPACE === "string" && env.VEXP_WORKSPACE ? env.VEXP_WORKSPACE : undefined;
|
|
1499
|
+
if (!command) {
|
|
1500
|
+
if (typeof e.url === "string")
|
|
1501
|
+
return { level: INFO, message: `${label}: vexp server over http (${e.url}) - not checked here` };
|
|
1502
|
+
return { level: BAD, message: `${label}: the vexp server entry has no command - ZCode cannot start it; re-run '${setup}'` };
|
|
1503
|
+
}
|
|
1504
|
+
const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
|
|
1505
|
+
if (abs && !i.exists(command)) {
|
|
1506
|
+
return { level: BAD, message: `${label}: the vexp server command does not exist: ${command} - ZCode cannot start it, so there are no vexp tools (an upgrade moved the binary?); re-run '${setup}'` };
|
|
1507
|
+
}
|
|
1508
|
+
// The Kiro lesson: a GUI app started from the Dock or the Start menu does
|
|
1509
|
+
// not get the shell's PATH, and a server it cannot spawn fails silently.
|
|
1510
|
+
// Bare names only: an absolute node needs no PATH lookup, and setup keeps
|
|
1511
|
+
// an entry that resolves, so its re-run would not change it anyway.
|
|
1512
|
+
const base = path.win32.basename(command).toLowerCase();
|
|
1513
|
+
if (!/[\\/]/.test(command) && ["node", "node.exe", "npx", "npx.cmd", "npx.exe"].includes(base)) {
|
|
1514
|
+
return {
|
|
1515
|
+
level: WARN,
|
|
1516
|
+
message: `${label}: the vexp server starts through '${command}' - ZCode is a GUI app, and launched from the Dock or Start menu it may not have node on its PATH (the server then never starts and no vexp tools appear); re-run '${setup}', which writes the vexp binary itself`,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
// An absolute npx outside Windows is a `#!/usr/bin/env node` script: it
|
|
1520
|
+
// still looks node up on that PATH. Setup adopts an entry that resolves,
|
|
1521
|
+
// so the entry has to go before its re-run can write the binary.
|
|
1522
|
+
if (i.platform !== "win32" && base === "npx") {
|
|
1523
|
+
return {
|
|
1524
|
+
level: WARN,
|
|
1525
|
+
message: `${label}: the vexp server starts through ${command}, a script that runs node from ZCode's PATH, which a GUI app launched from the Dock may lack (then no vexp tools appear) - delete the vexp entry there, then run '${setup}', which writes the vexp binary itself`,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
if (!/[\\/]/.test(command)) {
|
|
1529
|
+
return {
|
|
1530
|
+
level: WARN,
|
|
1531
|
+
message: `${label}: the vexp server command is a bare '${command}', looked up on ZCode's PATH, which a GUI app launched from the Dock or Start menu usually lacks; re-run '${setup}' to write the absolute path`,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
if (pin && !samePath(pin, i.root)) {
|
|
1535
|
+
return {
|
|
1536
|
+
level: WARN,
|
|
1537
|
+
message: `${label}: the vexp server is pinned to ${pin} (VEXP_WORKSPACE), but this project is ${i.root} - ZCode sessions here query that project's index. ` +
|
|
1538
|
+
(userScope
|
|
1539
|
+
? `That entry is in your user config and serves every project: run '${setup}' here to give this project its own entry.`
|
|
1540
|
+
: `Re-run '${setup}' to repin it.`),
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
return { level: OK, message: `${label}: vexp server ${command}${args.length ? ` ${args.join(" ")}` : ""}${pin ? `, pinned to ${pin}` : ", no workspace pin"}` };
|
|
1544
|
+
}
|
|
1545
|
+
/** The ZCode section of doctor, as data (the log has its own function). */
|
|
1546
|
+
export function zcodeFindings(i) {
|
|
1547
|
+
const setup = setupAgentsCommand(["ZCode"], i.root);
|
|
1548
|
+
if (!zcodeInUse(i)) {
|
|
1549
|
+
// vexp's hook runs in every folder ZCode opens, this one included: say
|
|
1550
|
+
// what opening it in ZCode would lack, never as a fault of a project
|
|
1551
|
+
// that may not use ZCode at all.
|
|
1552
|
+
if (i.zcodeHomeExists && i.userNative.state === "ok" && zcodeVexpHook(i.userNative.data) !== null) {
|
|
1553
|
+
return [
|
|
1554
|
+
{
|
|
1555
|
+
level: INFO,
|
|
1556
|
+
message: `vexp's ZCode hook (~/.zcode/cli/config.json) runs in every folder ZCode opens, but this project has no vexp MCP entry for ZCode (.zcode/config.json, .agents/mcp.json)` +
|
|
1557
|
+
(i.consent === "no" || i.consent === "never" ? " - you said no to ZCode here" : `: if you open it in ZCode, run '${setup}'`),
|
|
1558
|
+
},
|
|
1559
|
+
];
|
|
1560
|
+
}
|
|
1561
|
+
return [{ level: OK, message: "no .zcode here (ZCode not configured for this project)" }];
|
|
1562
|
+
}
|
|
1563
|
+
const p = i.platform === "win32" ? path.win32 : path.posix;
|
|
1564
|
+
const fold = i.platform === "win32" || i.platform === "darwin";
|
|
1565
|
+
const out = [];
|
|
1566
|
+
// The field report's layout: .../Repos/<Project>/<Project>,
|
|
1567
|
+
// a clone inside a folder of its own name. Opening the outer one is easy
|
|
1568
|
+
// and reads as the project in ZCode's title bar.
|
|
1569
|
+
const name = p.basename(i.root);
|
|
1570
|
+
const parent = p.dirname(i.root);
|
|
1571
|
+
if (name && parent !== i.root && (fold ? name.toLowerCase() === p.basename(parent).toLowerCase() : name === p.basename(parent))) {
|
|
1572
|
+
out.push({
|
|
1573
|
+
level: INFO,
|
|
1574
|
+
message: `this project sits in a folder of the same name (${parent}): open ${i.root} itself in ZCode, not its parent - from the parent, ZCode reads neither this AGENTS.md nor .zcode/config.json`,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
if (i.agentsMd !== null && /<!--\s*vexp v|^#{1,6}\s*vexp\b/im.test(i.agentsMd)) {
|
|
1578
|
+
out.push({ level: OK, message: "AGENTS.md at the project root has the vexp section (ZCode reads it from the root of the folder it opened)" });
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
out.push({
|
|
1582
|
+
level: WARN,
|
|
1583
|
+
message: (i.agentsMd === null ? `no AGENTS.md in ${i.root}` : `AGENTS.md in ${i.root} has no vexp section`) +
|
|
1584
|
+
`: ZCode reads AGENTS.md at the root of the folder it opened, and without it the agent is never told vexp exists - run '${setup}'`,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
// A file ZCode cannot parse contributes nothing, as if it were absent;
|
|
1588
|
+
// setup never overwrites one, so only the user can fix it.
|
|
1589
|
+
for (const f of [i.workspaceNative, i.workspaceCompat, i.userNative, i.userCompat]) {
|
|
1590
|
+
if (f.state === "unparseable") {
|
|
1591
|
+
out.push({ level: BAD, message: `${zcodeLabel(f.file, i)} does not parse as JSON: nothing in it takes effect in ZCode, and 'vexp setup' will not overwrite it - fix its syntax` });
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
// MCP, in ZCode's documented order: in each scope the .zcode file wins
|
|
1595
|
+
// outright as soon as it defines any server.
|
|
1596
|
+
const wsNative = zcodeServers(i.workspaceNative, "native");
|
|
1597
|
+
const wsCompat = zcodeServers(i.workspaceCompat, "compat");
|
|
1598
|
+
const usNative = zcodeServers(i.userNative, "native");
|
|
1599
|
+
const usCompat = zcodeServers(i.userCompat, "compat");
|
|
1600
|
+
const names = (s) => Object.keys(s ?? {});
|
|
1601
|
+
let served = false;
|
|
1602
|
+
if (wsNative?.vexp) {
|
|
1603
|
+
out.push(zcodeEntryFinding(wsNative.vexp, zcodeLabel(i.workspaceNative.file, i), false, i, setup));
|
|
1604
|
+
served = true;
|
|
1605
|
+
}
|
|
1606
|
+
else if (names(wsNative).length > 0 && wsCompat?.vexp) {
|
|
1607
|
+
out.push({
|
|
1608
|
+
level: BAD,
|
|
1609
|
+
message: `${zcodeLabel(i.workspaceCompat.file, i)} holds the vexp server, but ${zcodeLabel(i.workspaceNative.file, i)} defines servers of its own (${names(wsNative).join(", ")}): ` +
|
|
1610
|
+
`ZCode skips .agents/mcp.json whenever .zcode/config.json defines servers, so no vexp tools here. Run '${setup}': it writes the vexp entry into .zcode/config.json.`,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
else if (wsCompat?.vexp) {
|
|
1614
|
+
out.push(zcodeEntryFinding(wsCompat.vexp, zcodeLabel(i.workspaceCompat.file, i), false, i, setup));
|
|
1615
|
+
served = true;
|
|
1616
|
+
}
|
|
1617
|
+
if (!served) {
|
|
1618
|
+
if (usNative?.vexp) {
|
|
1619
|
+
out.push(zcodeEntryFinding(usNative.vexp, zcodeLabel(i.userNative.file, i), true, i, setup));
|
|
1620
|
+
}
|
|
1621
|
+
else if (names(usNative).length > 0 && usCompat?.vexp) {
|
|
1622
|
+
out.push({
|
|
1623
|
+
level: BAD,
|
|
1624
|
+
message: `${zcodeLabel(i.userCompat.file, i)} holds the vexp server, but ${zcodeLabel(i.userNative.file, i)} defines servers of its own (${names(usNative).join(", ")}): ` +
|
|
1625
|
+
`ZCode skips ~/.agents/mcp.json whenever ~/.zcode/cli/config.json defines servers. Run '${setup}': it writes the vexp entry into this project's .zcode/config.json.`,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
else if (usCompat?.vexp) {
|
|
1629
|
+
out.push(zcodeEntryFinding(usCompat.vexp, zcodeLabel(i.userCompat.file, i), true, i, setup));
|
|
1630
|
+
}
|
|
1631
|
+
else if (!(names(wsNative).length > 0 && wsCompat?.vexp)) {
|
|
1632
|
+
out.push({
|
|
1633
|
+
level: BAD,
|
|
1634
|
+
message: `no vexp MCP server for ZCode here: none in .zcode/config.json, .agents/mcp.json, ~/.zcode/cli/config.json or ~/.agents/mcp.json, so ZCode sessions get no vexp tools. Run '${setup}'.`,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
out.push({ level: NOTE, message: "ZCode connects MCP servers at session start: start a NEW ZCode session after 'vexp setup'." });
|
|
1639
|
+
// The orientation hook: user scope only.
|
|
1640
|
+
const hookFile = zcodeLabel(i.userNative.file, i);
|
|
1641
|
+
if (i.userNative.state === "absent") {
|
|
1642
|
+
out.push({ level: WARN, message: `${hookFile} does not exist: vexp's per-prompt orientation hook was never installed (ZCode runs hooks only from that file) - run '${setup}'` });
|
|
1643
|
+
}
|
|
1644
|
+
else if (i.userNative.state === "ok") {
|
|
1645
|
+
const cfg = i.userNative.data;
|
|
1646
|
+
const enabled = asObject(cfg.hooks)?.enabled;
|
|
1647
|
+
const found = zcodeVexpHook(cfg);
|
|
1648
|
+
// WARN, as every other "orientation inert" state is (the entry missing
|
|
1649
|
+
// or disabled, Codex's hooks = false): the MCP tools still work, only the
|
|
1650
|
+
// per-prompt orientation is off. Said only when vexp's hook is there to
|
|
1651
|
+
// be switched off; without one, the missing-hook line below covers both,
|
|
1652
|
+
// since setup writes the entry and hooks.enabled together.
|
|
1653
|
+
if (enabled !== true && found) {
|
|
1654
|
+
out.push(enabled === false && zcodeForeignHooks(cfg)
|
|
1655
|
+
? {
|
|
1656
|
+
level: WARN,
|
|
1657
|
+
message: `hooks are switched off in ${hookFile} (hooks.enabled: false) while hooks of your own are registered there - that looks deliberate, so vexp leaves it alone; vexp's orientation hook stays inert until hooks.enabled is true`,
|
|
1658
|
+
}
|
|
1659
|
+
: {
|
|
1660
|
+
level: WARN,
|
|
1661
|
+
message: `hooks.enabled is not true in ${hookFile}: ZCode runs no hooks at all (they are off by default), vexp's orientation hook included - set "enabled": true under "hooks", or re-run '${setup}'`,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
if (!found) {
|
|
1665
|
+
out.push({ level: WARN, message: `no vexp UserPromptSubmit hook in ${hookFile}: ZCode gets no per-prompt orientation - re-run '${setup}'` });
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
const { group, hook } = found;
|
|
1669
|
+
if (group.enabled === false || hook.enabled === false) {
|
|
1670
|
+
out.push({ level: WARN, message: `vexp's UserPromptSubmit hook in ${hookFile} is disabled ("enabled": false), so ZCode skips it - remove that key or set it to true` });
|
|
1671
|
+
}
|
|
1672
|
+
if (hook.type !== "process") {
|
|
1673
|
+
out.push({ level: INFO, message: `vexp's hook in ${hookFile} is of type '${String(hook.type ?? "(none)")}', not 'process' - not probed here` });
|
|
1674
|
+
}
|
|
1675
|
+
else {
|
|
1676
|
+
const command = typeof hook.command === "string" ? hook.command : "";
|
|
1677
|
+
const abs = i.platform === "win32" ? path.win32.isAbsolute(command) : path.posix.isAbsolute(command);
|
|
1678
|
+
if (!command) {
|
|
1679
|
+
out.push({ level: BAD, message: `vexp's hook in ${hookFile} has no command - it fails on every prompt; re-run '${setup}'` });
|
|
1680
|
+
}
|
|
1681
|
+
else if (abs && !i.exists(command)) {
|
|
1682
|
+
out.push({ level: BAD, message: `vexp's hook in ${hookFile} runs ${command}, which does not exist - it fails on every prompt; re-run '${setup}'` });
|
|
1683
|
+
}
|
|
1684
|
+
else if (!abs) {
|
|
1685
|
+
out.push({ level: WARN, message: `vexp's hook in ${hookFile} runs a bare '${command}', looked up on ZCode's PATH, which a GUI app launched from the Dock or Start menu usually lacks; re-run '${setup}' to write the absolute path` });
|
|
1686
|
+
}
|
|
1687
|
+
const t = hook.timeoutMs;
|
|
1688
|
+
if (typeof t === "number" && t < 4000) {
|
|
1689
|
+
out.push({ level: WARN, message: `vexp's hook in ${hookFile} has timeoutMs ${t}: the orientation alone may take 3 s, so ZCode can stop the hook before it answers - set it to 5000 (what setup writes)` });
|
|
1690
|
+
}
|
|
1691
|
+
if (i.probe) {
|
|
1692
|
+
if (i.probe.error) {
|
|
1693
|
+
out.push({ level: BAD, message: `UserPromptSubmit hook DID NOT RUN: ${i.probe.error} - orientation is inert.` });
|
|
1694
|
+
}
|
|
1695
|
+
else if (i.probe.status !== 0) {
|
|
1696
|
+
const err = i.probe.stderr.trim();
|
|
1697
|
+
out.push({ level: BAD, message: `UserPromptSubmit hook exited ${i.probe.status}${err ? `: ${err.slice(0, 160)}` : ""} - ZCode continues without vexp.` });
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
// Silence is a legitimate outcome for the hint hook (the probe's
|
|
1701
|
+
// prompt is short on purpose), so report the daemon's reason.
|
|
1702
|
+
const why = i.probe.stderr.trim().replace(/^vexp prompt-hint: /, "");
|
|
1703
|
+
out.push({ level: OK, message: `UserPromptSubmit hook runs${why ? ` (this probe: ${why.slice(0, 120)})` : ""}` });
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
out.push({ level: NOTE, message: `ZCode applies hook changes to NEW sessions only: start a new ZCode session after 'vexp setup' or any edit of ${hookFile}.` });
|
|
1709
|
+
}
|
|
1710
|
+
return out;
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* A log line as text twice over: `match` holds the string values only, keys
|
|
1714
|
+
* left out (`"error": null` is not an error); `show` keeps each key next to
|
|
1715
|
+
* its value, so a secret under a key such as api_key is recognisable to
|
|
1716
|
+
* maskSecretsForReport. A line that is not JSON is both.
|
|
1717
|
+
*/
|
|
1718
|
+
function logLineText(line) {
|
|
1719
|
+
let v;
|
|
1720
|
+
try {
|
|
1721
|
+
v = JSON.parse(line);
|
|
1722
|
+
}
|
|
1723
|
+
catch {
|
|
1724
|
+
return { match: line, show: line };
|
|
1725
|
+
}
|
|
1726
|
+
const values = [];
|
|
1727
|
+
const pairs = [];
|
|
1728
|
+
const walk = (x, key, depth) => {
|
|
1729
|
+
if (depth > 6)
|
|
1730
|
+
return;
|
|
1731
|
+
if (typeof x === "string") {
|
|
1732
|
+
values.push(x);
|
|
1733
|
+
pairs.push(key ? `${key}=${x}` : x);
|
|
1734
|
+
}
|
|
1735
|
+
else if (Array.isArray(x))
|
|
1736
|
+
x.forEach((y) => walk(y, key, depth + 1));
|
|
1737
|
+
else if (x && typeof x === "object")
|
|
1738
|
+
Object.entries(x).forEach(([k, y]) => walk(y, k, depth + 1));
|
|
1739
|
+
};
|
|
1740
|
+
walk(v, "", 0);
|
|
1741
|
+
return values.length > 0 ? { match: values.join(" "), show: pairs.join(" ") } : { match: line, show: line };
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* ZCode's own log, read as text (its line schema is not documented): the
|
|
1745
|
+
* hook runs that failed, vexp's apart, and the project-level hook configs
|
|
1746
|
+
* it ignored (expected: ZCode never runs project hooks). `lines` null: no
|
|
1747
|
+
* log folder. `files` names what was read.
|
|
1748
|
+
*/
|
|
1749
|
+
export function zcodeLogFindings(lines, files = []) {
|
|
1750
|
+
if (lines === null) {
|
|
1751
|
+
return [{ level: NOTE, message: "no ZCode log folder (~/.zcode/cli/log): ZCode has not run a session on this machine, or keeps its log elsewhere" }];
|
|
1752
|
+
}
|
|
1753
|
+
const ignored = [];
|
|
1754
|
+
const failures = [];
|
|
1755
|
+
for (const raw of lines) {
|
|
1756
|
+
if (!raw.trim())
|
|
1757
|
+
continue;
|
|
1758
|
+
const t = logLineText(raw);
|
|
1759
|
+
if (/\bignor/i.test(t.match) && /(project|workspace)/i.test(t.match) && /(hook|config)/i.test(t.match)) {
|
|
1760
|
+
ignored.push(t.show);
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (/hook/i.test(t.match) && /(fail|error|timed? ?out|denied|ENOENT|EACCES|EPERM)/i.test(t.match))
|
|
1764
|
+
failures.push(t);
|
|
1765
|
+
}
|
|
1766
|
+
const ours = failures.filter((t) => /vexp|prompt-hint/i.test(t.match));
|
|
1767
|
+
const where = files.length > 0 ? ` (${files.join(", ")})` : "";
|
|
1768
|
+
const out = [];
|
|
1769
|
+
if (ours.length > 0) {
|
|
1770
|
+
out.push({
|
|
1771
|
+
level: WARN,
|
|
1772
|
+
message: `ZCode's log${where} records ${ours.length} failed run(s) of vexp's hook; the last: ${maskSecretsForReport(ours[ours.length - 1].show).slice(0, 200)}`,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
else if (failures.length > 0) {
|
|
1776
|
+
out.push({ level: INFO, message: `ZCode's log${where} records ${failures.length} failed run(s) of other hooks, none of vexp's` });
|
|
1777
|
+
}
|
|
1778
|
+
if (ignored.length > 0) {
|
|
1779
|
+
out.push({ level: INFO, message: `ZCode's log notes ${ignored.length} ignored project-level config(s): expected, ZCode never runs a project's hooks (vexp's hook is in the user config)` });
|
|
1780
|
+
}
|
|
1781
|
+
if (out.length === 0) {
|
|
1782
|
+
out.push(files.length > 0
|
|
1783
|
+
? { level: OK, message: `ZCode's log${where}: no hook failures recorded` }
|
|
1784
|
+
: { level: NOTE, message: "the ZCode log folder holds no zcode-*.jsonl yet" });
|
|
1785
|
+
}
|
|
1786
|
+
return out;
|
|
1787
|
+
}
|
|
1788
|
+
/** The tail of the newest two ZCode logs, oldest first; null when there is no log folder. */
|
|
1789
|
+
export function readZcodeLogTail(dir, maxBytes = 2 * 1024 * 1024) {
|
|
1790
|
+
let names;
|
|
1791
|
+
try {
|
|
1792
|
+
names = fs.readdirSync(dir);
|
|
1793
|
+
}
|
|
1794
|
+
catch {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
const newest = names
|
|
1798
|
+
.filter((n) => /^zcode-.*\.jsonl$/.test(n))
|
|
1799
|
+
.map((n) => {
|
|
1800
|
+
try {
|
|
1801
|
+
return { n, mtime: fs.statSync(path.join(dir, n)).mtimeMs };
|
|
1802
|
+
}
|
|
1803
|
+
catch {
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
})
|
|
1807
|
+
.filter((x) => x !== null)
|
|
1808
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
1809
|
+
.slice(0, 2)
|
|
1810
|
+
.reverse();
|
|
1811
|
+
const lines = [];
|
|
1812
|
+
const files = [];
|
|
1813
|
+
for (const { n } of newest) {
|
|
1814
|
+
let fd;
|
|
1815
|
+
try {
|
|
1816
|
+
fd = fs.openSync(path.join(dir, n), "r");
|
|
1817
|
+
const size = fs.fstatSync(fd).size;
|
|
1818
|
+
const start = Math.max(0, size - maxBytes);
|
|
1819
|
+
const buf = Buffer.alloc(size - start);
|
|
1820
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
1821
|
+
const text = buf.toString("utf-8").split(/\r?\n/);
|
|
1822
|
+
// A cut first line is half a record.
|
|
1823
|
+
lines.push(...(start > 0 ? text.slice(1) : text));
|
|
1824
|
+
files.push(n);
|
|
1825
|
+
}
|
|
1826
|
+
catch {
|
|
1827
|
+
/* unreadable: skip it */
|
|
1828
|
+
}
|
|
1829
|
+
finally {
|
|
1830
|
+
if (fd !== undefined) {
|
|
1831
|
+
try {
|
|
1832
|
+
fs.closeSync(fd);
|
|
1833
|
+
}
|
|
1834
|
+
catch {
|
|
1835
|
+
/* closed */
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return { files, lines };
|
|
1841
|
+
}
|
|
1127
1842
|
export const SUPPORT_EMAIL = "staff@vexp.dev";
|
|
1128
1843
|
export const DEFAULT_REPORT_FILE = path.join(".vexp", "vexp-report.md");
|
|
1129
1844
|
// eslint-disable-next-line no-control-regex
|
|
@@ -1294,6 +2009,41 @@ export function logWarningsFrom(text, keep = 80, scan = 3000) {
|
|
|
1294
2009
|
.map(({ last, count }) => (count > 1 ? `${last} (x${count})` : last))
|
|
1295
2010
|
.slice(-keep);
|
|
1296
2011
|
}
|
|
2012
|
+
/** The home folders iCloud's "Desktop & Documents Folders" syncs on this
|
|
2013
|
+
* Mac: the ones com~apple~CloudDocs holds a symlink to. Sync off, no link. */
|
|
2014
|
+
function icloudLinkedFolders(home) {
|
|
2015
|
+
if (process.platform !== "darwin")
|
|
2016
|
+
return [];
|
|
2017
|
+
const out = [];
|
|
2018
|
+
for (const name of ["Desktop", "Documents"]) {
|
|
2019
|
+
try {
|
|
2020
|
+
const link = path.join(home, "Library", "Mobile Documents", "com~apple~CloudDocs", name);
|
|
2021
|
+
const local = path.join(home, name);
|
|
2022
|
+
if (!fs.lstatSync(link).isSymbolicLink())
|
|
2023
|
+
continue;
|
|
2024
|
+
const real = fs.realpathSync.native(local);
|
|
2025
|
+
if (samePath(fs.realpathSync.native(link), real))
|
|
2026
|
+
out.push(real);
|
|
2027
|
+
}
|
|
2028
|
+
catch { /* not there: not synced */ }
|
|
2029
|
+
}
|
|
2030
|
+
return out;
|
|
2031
|
+
}
|
|
2032
|
+
/** cloudSyncNotice for this machine: the root as given, then its real path
|
|
2033
|
+
* (a ~/Repos symlink into iCloud Drive is still in iCloud Drive). */
|
|
2034
|
+
function cloudSyncHere(root) {
|
|
2035
|
+
const home = os.homedir();
|
|
2036
|
+
const ctx = { platform: process.platform, home, env: process.env, icloudLinked: icloudLinkedFolders(home) };
|
|
2037
|
+
const direct = cloudSyncNotice(root, ctx);
|
|
2038
|
+
if (direct)
|
|
2039
|
+
return direct;
|
|
2040
|
+
try {
|
|
2041
|
+
return cloudSyncNotice(fs.realpathSync.native(root), ctx);
|
|
2042
|
+
}
|
|
2043
|
+
catch {
|
|
2044
|
+
return null;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
1297
2047
|
/** Gather every input on this machine and write the report. Returns its path. */
|
|
1298
2048
|
export async function writeDoctorReport(o) {
|
|
1299
2049
|
let bin = null;
|
|
@@ -1367,7 +2117,7 @@ export async function writeDoctorReport(o) {
|
|
|
1367
2117
|
configText,
|
|
1368
2118
|
configProblem,
|
|
1369
2119
|
configEncoding,
|
|
1370
|
-
mountNotice: slowMountNotice(o.root),
|
|
2120
|
+
mountNotice: [slowMountNotice(o.root), cloudSyncHere(o.root)].filter(Boolean).join(" ") || null,
|
|
1371
2121
|
});
|
|
1372
2122
|
const dest = o.outFile ? path.resolve(o.outFile) : path.join(o.root, DEFAULT_REPORT_FILE);
|
|
1373
2123
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
@@ -1421,6 +2171,13 @@ async function doctorChecks(onWorkspace) {
|
|
|
1421
2171
|
fails++;
|
|
1422
2172
|
console.log(` [${status}] ${msg}`);
|
|
1423
2173
|
};
|
|
2174
|
+
// A pure verdict's findings: NOTE is a dim, indented aside, never counted.
|
|
2175
|
+
const emit = (f) => {
|
|
2176
|
+
if (f.level === NOTE)
|
|
2177
|
+
console.log(chalk.dim(` ${f.message}`));
|
|
2178
|
+
else
|
|
2179
|
+
line(f.level, f.message);
|
|
2180
|
+
};
|
|
1424
2181
|
console.log(chalk.bold("\nvexp doctor — MCP / daemon diagnostics\n"));
|
|
1425
2182
|
// 1) Workspace resolution for the current directory.
|
|
1426
2183
|
console.log(chalk.bold("Workspace targeting"));
|
|
@@ -1541,24 +2298,39 @@ async function doctorChecks(onWorkspace) {
|
|
|
1541
2298
|
hookPrompts7d = analyzed;
|
|
1542
2299
|
for (const f of ledgerFindings(ledger))
|
|
1543
2300
|
line(f.level, f.message);
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
2301
|
+
// The hook's abstentions, apart from the ledger they never change.
|
|
2302
|
+
for (const f of hookActivityFindings(st.hook_activity, ledger, Math.floor(Date.now() / 1000)))
|
|
2303
|
+
emit(f);
|
|
2304
|
+
// Agent sessions only: the hook's pseudo-session and one-shot CLI
|
|
2305
|
+
// calls were counted as "active" sessions (field report, 2026-09-28).
|
|
2306
|
+
const hookLastAt = st.hook_activity?.last_at;
|
|
2307
|
+
for (const f of sessionsFindings(sessions, {
|
|
2308
|
+
uptimeS: Number(st.daemon_uptime_s) || 0,
|
|
2309
|
+
prompts7d: analyzed,
|
|
2310
|
+
hookLastAt: typeof hookLastAt === "number" ? hookLastAt : null,
|
|
2311
|
+
nowS: Math.floor(Date.now() / 1000),
|
|
2312
|
+
}))
|
|
2313
|
+
emit(f);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
// Where the agent hooks ran from, as the hook client recorded it: read from
|
|
2317
|
+
// disk, so it speaks also when no daemon answers here, which is exactly
|
|
2318
|
+
// when an agent with the parent folder open leaves its only trace.
|
|
2319
|
+
{
|
|
2320
|
+
let text = null;
|
|
2321
|
+
try {
|
|
2322
|
+
text = fs.readFileSync(hookCallsPath(), "utf-8");
|
|
2323
|
+
}
|
|
2324
|
+
catch { /* no hook has run on this machine since the breadcrumbs exist */ }
|
|
2325
|
+
if (text !== null) {
|
|
2326
|
+
const aliases = [canonicalWorkspaceRoot(ws.root)];
|
|
2327
|
+
try {
|
|
2328
|
+
aliases.push(fs.realpathSync.native(ws.root));
|
|
1561
2329
|
}
|
|
2330
|
+
catch { /* the root as given */ }
|
|
2331
|
+
const neverServed = [os.homedir(), vexpHome()];
|
|
2332
|
+
for (const f of hookCallFindings(parseHookCalls(text), ws.root, Math.floor(Date.now() / 1000), process.platform, aliases, neverServed))
|
|
2333
|
+
emit(f);
|
|
1562
2334
|
}
|
|
1563
2335
|
}
|
|
1564
2336
|
// 3.1 — coverage gaps only the daemon log used to witness. Read from disk,
|
|
@@ -1825,11 +2597,18 @@ async function doctorChecks(onWorkspace) {
|
|
|
1825
2597
|
// network share) makes every vexp call 10-50x slower and reads as a vexp
|
|
1826
2598
|
// bug (field report, 2026-09-09: verify_done at 41-170 s on /app over 9p,
|
|
1827
2599
|
// every other line of doctor green).
|
|
2600
|
+
// And a workspace inside a cloud-synced folder uploads .vexp/index.db, a
|
|
2601
|
+
// live SQLite database, on every write (field report, 2026-09-28: iCloud
|
|
2602
|
+
// Drive on macOS).
|
|
1828
2603
|
{
|
|
1829
2604
|
const slow = slowMountNotice(ws.root);
|
|
1830
|
-
|
|
2605
|
+
const cloud = cloudSyncHere(ws.root);
|
|
2606
|
+
if (slow || cloud) {
|
|
1831
2607
|
console.log(chalk.bold("\nFilesystem"));
|
|
1832
|
-
|
|
2608
|
+
if (slow)
|
|
2609
|
+
line(WARN, slow);
|
|
2610
|
+
if (cloud)
|
|
2611
|
+
line(WARN, cloud);
|
|
1833
2612
|
}
|
|
1834
2613
|
}
|
|
1835
2614
|
// 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
|
|
@@ -2236,6 +3015,62 @@ async function doctorChecks(onWorkspace) {
|
|
|
2236
3015
|
line(f.level, f.message);
|
|
2237
3016
|
}
|
|
2238
3017
|
}
|
|
3018
|
+
// 5b-ter) ZCode. Setup has written its three pieces since 3.2.0 (AGENTS.md,
|
|
3019
|
+
// an MCP entry, a user-level hook) and doctor checked none of them: a user
|
|
3020
|
+
// whose ZCode had the project's parent folder open read an all-green doctor
|
|
3021
|
+
// and "vexp is not used" in the same afternoon (field report, 2026-09-28).
|
|
3022
|
+
// Every decision is in zcodeFindings; this block only reads and probes.
|
|
3023
|
+
console.log(chalk.bold("\nZCode (AGENTS.md, .zcode/config.json, ~/.zcode/cli/config.json)"));
|
|
3024
|
+
try {
|
|
3025
|
+
const home = os.homedir();
|
|
3026
|
+
let agentsMd = null;
|
|
3027
|
+
try {
|
|
3028
|
+
agentsMd = fs.readFileSync(path.join(ws.root, "AGENTS.md"), "utf-8");
|
|
3029
|
+
}
|
|
3030
|
+
catch { /* absent */ }
|
|
3031
|
+
const input = {
|
|
3032
|
+
root: ws.root,
|
|
3033
|
+
platform: process.platform,
|
|
3034
|
+
home,
|
|
3035
|
+
workspaceNative: readJsonFileState(path.join(ws.root, ".zcode", "config.json")),
|
|
3036
|
+
workspaceCompat: readJsonFileState(path.join(ws.root, ".agents", "mcp.json")),
|
|
3037
|
+
userNative: readJsonFileState(zcodeUserConfigPath()),
|
|
3038
|
+
userCompat: readJsonFileState(path.join(home, ".agents", "mcp.json")),
|
|
3039
|
+
agentsMd,
|
|
3040
|
+
exists: (p) => fs.existsSync(p),
|
|
3041
|
+
zcodeHomeExists: fs.existsSync(path.join(home, ".zcode")),
|
|
3042
|
+
consent: decisionFor(readAgentConsent(), ws.root, "ZCode"),
|
|
3043
|
+
};
|
|
3044
|
+
const inUse = zcodeInUse(input);
|
|
3045
|
+
const spec = inUse ? zcodeProbeSpec(input) : null;
|
|
3046
|
+
if (spec) {
|
|
3047
|
+
// Run it as ZCode does: a `process` hook, argv without a shell, from
|
|
3048
|
+
// the project. No hook_event_name or transcript_path in the payload:
|
|
3049
|
+
// the hook client takes it for a manual probe, so it counts in no
|
|
3050
|
+
// ledger and leaves no breadcrumb.
|
|
3051
|
+
const r = spawnSync(spec.command, spec.args, {
|
|
3052
|
+
input: JSON.stringify({ session_id: "vexp-doctor", prompt: "vexp doctor probe", cwd: ws.root }),
|
|
3053
|
+
cwd: ws.root,
|
|
3054
|
+
timeout: 10000,
|
|
3055
|
+
encoding: "utf-8",
|
|
3056
|
+
windowsHide: true,
|
|
3057
|
+
});
|
|
3058
|
+
input.probe = r.error
|
|
3059
|
+
? { error: r.error.code ?? r.error.message, status: r.status, stderr: String(r.stderr ?? "") }
|
|
3060
|
+
: { status: r.status, stderr: String(r.stderr ?? "") };
|
|
3061
|
+
}
|
|
3062
|
+
for (const f of zcodeFindings(input))
|
|
3063
|
+
emit(f);
|
|
3064
|
+
if (inUse) {
|
|
3065
|
+
const log = readZcodeLogTail(path.join(home, ".zcode", "cli", "log"));
|
|
3066
|
+
for (const f of zcodeLogFindings(log ? log.lines : null, log ? log.files : []))
|
|
3067
|
+
emit(f);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
catch (e) {
|
|
3071
|
+
// Never the reason doctor stops: the sections below still run.
|
|
3072
|
+
line(INFO, `ZCode: not checked (${e?.message ?? String(e)})`);
|
|
3073
|
+
}
|
|
2239
3074
|
// 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
|
|
2240
3075
|
// hooks fail OPEN too (`failClosed` defaults to false), so a guard that
|
|
2241
3076
|
// cannot spawn silently enforces nothing there as well. The guard's stdin
|