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/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
- if (sessions.length > 0) {
1545
- const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
1546
- line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
1547
- }
1548
- else if (analyzed > 0) {
1549
- line(OK, `no tool calls in the last 4h — but the ledger above shows vexp is analyzing prompts (zero calls is normal on oriented tasks)`);
1550
- }
1551
- else if (Number(st.daemon_uptime_s) > 600) {
1552
- // Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
1553
- // the daemon was unreachable serves from its own in-process index and
1554
- // never attaches to the daemon later — its calls are real but
1555
- // invisible to these counters. Don't tell that user "the agent is not
1556
- // using vexp"; tell them how to converge on the daemon.
1557
- line(WARN, `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.`);
1558
- }
1559
- else {
1560
- line(OK, `no session calls yet (daemon just started)`);
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
- if (slow) {
2605
+ const cloud = cloudSyncHere(ws.root);
2606
+ if (slow || cloud) {
1831
2607
  console.log(chalk.bold("\nFilesystem"));
1832
- line(WARN, slow);
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