vexp-cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import * as net from "net";
5
+ import { spawnSync } from "child_process";
5
6
  import chalk from "chalk";
6
7
  import { socketPathFor } from "./socket-path.js";
7
8
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
@@ -150,15 +151,50 @@ export async function runDoctor() {
150
151
  if (st.llm_configured_but_inactive === true) {
151
152
  line(WARN, `local LLM is installed and enabled in config but this daemon runs the RULE compressor — results are not LLM-compressed. Run 'vexp daemon-cmd restart' to load the model.`);
152
153
  }
154
+ // 2.4.0 upgrade guard (CLI side): a daemon surviving an upgrade keeps
155
+ // serving the OLD feature set and its gaps read as product bugs
156
+ // (field case: pre-ledger daemon after the 2.4.0 install). The VS Code
157
+ // extension restarts automatically; CLI users get told explicitly.
158
+ try {
159
+ const { getBinaryPath } = await import("./binary.js");
160
+ const { execFileSync } = await import("node:child_process");
161
+ const out = execFileSync(getBinaryPath(), ["--version"], { timeout: 5000, encoding: "utf8" });
162
+ const bundled = out.trim().split(/\s+/).pop();
163
+ const running = st.daemon_version;
164
+ if (bundled && running && bundled !== running) {
165
+ line(WARN, `daemon is v${running} but the installed binary is v${bundled} — this workspace is still served by the OLD version. Run 'vexp daemon-cmd restart' to upgrade it now.`);
166
+ }
167
+ else if (st.binary_stale === true) {
168
+ line(WARN, `daemon is running a deleted executable (upgraded on disk) — run 'vexp daemon-cmd restart' to load the new build.`);
169
+ }
170
+ }
171
+ catch { /* best-effort */ }
153
172
  // 2.3 C1 — which agent sessions actually used vexp (a session with zero
154
173
  // calls never shows up here; that absence is the diagnostic).
155
174
  const sessions = Array.isArray(st.sessions) ? st.sessions : [];
175
+ // 2.3.3 Savings Ledger — activity counts even when no tool is ever
176
+ // called: silence on oriented prompts is vexp WORKING, and the old
177
+ // "never used" warning on quiet-but-active daemons generated real
178
+ // support tickets ("is it working? it never got called").
179
+ const ledger = (st.ledger ?? {});
180
+ const analyzed = Number(ledger.prompts_analyzed) || 0;
181
+ if (analyzed > 0) {
182
+ line(OK, `savings ledger (7d): ${analyzed} prompt(s) analyzed — ${Number(ledger.silences) || 0} silences (task already oriented), ${Number(ledger.hints_served) || 0} hints served. Details: vexp savings`);
183
+ }
156
184
  if (sessions.length > 0) {
157
185
  const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
158
186
  line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
159
187
  }
188
+ else if (analyzed > 0) {
189
+ 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)`);
190
+ }
160
191
  else if (Number(st.daemon_uptime_s) > 600) {
161
- line(WARN, `no agent session has called vexp since daemon start — if an agent is working here, it is not using vexp (check its MCP config)`);
192
+ // Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
193
+ // the daemon was unreachable serves from its own in-process index and
194
+ // never attaches to the daemon later — its calls are real but
195
+ // invisible to these counters. Don't tell that user "the agent is not
196
+ // using vexp"; tell them how to converge on the daemon.
197
+ 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.`);
162
198
  }
163
199
  else {
164
200
  line(OK, `no session calls yet (daemon just started)`);
@@ -201,6 +237,12 @@ export async function runDoctor() {
201
237
  // 3) License (fresh.jwt rolling token vs license.jwt).
202
238
  console.log(chalk.bold("\nLicense tokens (~/.vexp)"));
203
239
  const now = Math.floor(Date.now() / 1000);
240
+ // Either valid token keeps the plan ACTIVE. An expired license.jwt next to
241
+ // a valid fresh.jwt is normal steady-state (the long token re-rolls on the
242
+ // next online validation) — labeling it a bare WARN read as "my license
243
+ // expired" and generated tickets from perfectly licensed users.
244
+ const freshExp = jwtExp(path.join(home, ".vexp", "fresh.jwt"));
245
+ const freshValid = freshExp != null && freshExp > now;
204
246
  for (const name of ["fresh.jwt", "license.jwt"]) {
205
247
  const p = path.join(home, ".vexp", name);
206
248
  if (!fs.existsSync(p)) {
@@ -214,38 +256,58 @@ export async function runDoctor() {
214
256
  }
215
257
  const days = Math.round((exp - now) / 86400);
216
258
  if (exp < now) {
217
- line(name === "fresh.jwt" ? OK : WARN, `${name}: expired ${-days}d ago${name === "fresh.jwt" ? " (benign — rolling token, falls back to license.jwt)" : ""}`);
259
+ if (name === "fresh.jwt") {
260
+ line(OK, `${name}: expired ${-days}d ago (benign — rolling token, falls back to license.jwt)`);
261
+ }
262
+ else if (freshValid) {
263
+ line(OK, `${name}: long token expired ${-days}d ago — plan still ACTIVE via fresh.jwt; it renews automatically on the next online validation`);
264
+ }
265
+ else {
266
+ line(WARN, `${name}: expired ${-days}d ago and no valid fresh token — plan features may be limited; go online or re-activate the license`);
267
+ }
218
268
  }
219
269
  else {
220
270
  line(OK, `${name}: valid, ~${days}d remaining`);
221
271
  }
222
272
  }
223
- // 4) Codex MCP transport stanza.
224
- console.log(chalk.bold("\nCodex (~/.codex/config.toml)"));
225
- const codexPath = path.join(os.homedir(), ".codex", "config.toml");
226
- if (!fs.existsSync(codexPath)) {
227
- line(OK, "no ~/.codex/config.toml (Codex not configured)");
228
- }
229
- else {
230
- const toml = fs.readFileSync(codexPath, "utf-8");
273
+ // 4) Codex MCP transport stanza — global AND project-level. Codex supports
274
+ // per-project `.codex/config.toml`; checking only the global file made
275
+ // doctor report "[OK] no stanza" to users whose (working) config lives in
276
+ // the workspace.
277
+ console.log(chalk.bold("\nCodex (config.toml)"));
278
+ const codexConfigs = [
279
+ { label: "~/.codex/config.toml", file: path.join(os.homedir(), ".codex", "config.toml") },
280
+ { label: `${ws.root}/.codex/config.toml (project)`, file: path.join(ws.root, ".codex", "config.toml") },
281
+ ];
282
+ let codexStanzaSeen = false;
283
+ for (const { label, file } of codexConfigs) {
284
+ if (!fs.existsSync(file)) {
285
+ line(OK, `${label}: absent`);
286
+ continue;
287
+ }
288
+ const toml = fs.readFileSync(file, "utf-8");
231
289
  const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
232
290
  const section = m ? m[0] : "";
233
- if (!section)
234
- line(OK, "no [mcp_servers.vexp] stanza");
235
- else {
236
- const hasUrl = /^\s*url\s*=/m.test(section);
237
- const hasCmd = /^\s*command\s*=/m.test(section);
238
- if (hasUrl && hasCmd)
239
- line(BAD, "stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.");
240
- else if (hasUrl)
241
- line(OK, "transport: http (url)");
242
- else if (hasCmd) {
243
- const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
244
- line(OK, `transport: stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
245
- }
246
- else
247
- line(WARN, "stanza present but neither url nor command found");
291
+ if (!section) {
292
+ line(OK, `${label}: no [mcp_servers.vexp] stanza`);
293
+ continue;
294
+ }
295
+ codexStanzaSeen = true;
296
+ const hasUrl = /^\s*url\s*=/m.test(section);
297
+ const hasCmd = /^\s*command\s*=/m.test(section);
298
+ if (hasUrl && hasCmd)
299
+ line(BAD, `${label}: stanza has BOTH 'url' and 'command' → 'url is not supported for stdio'. Re-run setup to rewrite cleanly.`);
300
+ else if (hasUrl)
301
+ line(OK, `${label}: transport http (url)`);
302
+ else if (hasCmd) {
303
+ const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
304
+ line(OK, `${label}: transport stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
248
305
  }
306
+ else
307
+ line(WARN, `${label}: stanza present but neither url nor command found`);
308
+ }
309
+ if (!codexStanzaSeen) {
310
+ console.log(chalk.dim(" → no vexp stanza in either file (Codex not configured for vexp)"));
249
311
  }
250
312
  // 5) Claude Code entry (should be UNPINNED after the multi-session fix).
251
313
  console.log(chalk.bold("\nClaude Code (~/.claude.json)"));
@@ -263,6 +325,152 @@ export async function runDoctor() {
263
325
  catch {
264
326
  line(OK, "no ~/.claude.json");
265
327
  }
328
+ // 5b) Claude Code guard hook — EXECUTE it the way Claude Code would, don't
329
+ // just check presence. A shell-form command that word-splits on a project
330
+ // path containing a space fails non-blocking on every call: the guard never
331
+ // denies anything while the config "looks correct" and presence-only checks
332
+ // report healthy (Nathan, 2026-07).
333
+ console.log(chalk.bold("\nClaude Code guard hook (.claude/settings.json)"));
334
+ {
335
+ const sPath = path.join(ws.root, ".claude", "settings.json");
336
+ let guardHooks = [];
337
+ let settingsReadable = false;
338
+ try {
339
+ const settings = JSON.parse(fs.readFileSync(sPath, "utf-8"));
340
+ settingsReadable = true;
341
+ const pre = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
342
+ for (const m of pre) {
343
+ const hks = Array.isArray(m?.hooks) ? m.hooks : [];
344
+ for (const h of hks) {
345
+ if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
346
+ guardHooks.push(h);
347
+ }
348
+ }
349
+ }
350
+ catch { /* absent or unparseable */ }
351
+ if (!settingsReadable) {
352
+ line(OK, "no .claude/settings.json (guard not installed)");
353
+ }
354
+ else if (guardHooks.length === 0) {
355
+ line(OK, "no vexp guard configured (2.3 default — enable with 'vexp setup --guard-strict')");
356
+ }
357
+ else {
358
+ for (const h of guardHooks) {
359
+ const cmd = h.command;
360
+ const execForm = Array.isArray(h.args);
361
+ const timeoutS = typeof h.timeout === "number" ? h.timeout : 600;
362
+ if (!execForm && /\$\{?CLAUDE_PROJECT_DIR\}?\//.test(cmd) && !cmd.includes('"')) {
363
+ line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command with unquoted $CLAUDE_PROJECT_DIR — word-splits on paths with spaces${ws.root.includes(" ") ? ` and THIS project path has one: the guard never runs` : ""}. Re-run 'vexp setup --guard-strict' to rewrite with a quoted path.`);
364
+ }
365
+ if (execForm && process.platform === "win32") {
366
+ // Exec form spawns the .sh directly, which Windows cannot do at
367
+ // all — the entry LOOKS installed and enforces nothing. This was
368
+ // invisible for days because doctor used to skip the live check
369
+ // on Windows entirely.
370
+ line(BAD, `exec-form hook entry ('args' present) cannot run a .sh on Windows — the guard fails open. Re-run 'vexp setup --guard-strict' to rewrite it (bash-prefixed shell form).`);
371
+ }
372
+ if (timeoutS > 600) {
373
+ line(WARN, `hook timeout ${timeoutS} is in SECONDS (${Math.round(timeoutS / 60)} minutes) — likely meant milliseconds. Re-run 'vexp setup --guard-strict' to fix.`);
374
+ }
375
+ // Run it exactly as Claude Code would: exec form = direct spawn with
376
+ // the placeholder substituted by the host; shell form = a shell with
377
+ // CLAUDE_PROJECT_DIR in the environment. On Windows Claude Code runs
378
+ // shell-form hooks through Git Bash, so probe via bash there too —
379
+ // if bash is missing, that IS the finding (Claude Code itself
380
+ // requires Git Bash on Windows).
381
+ const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
382
+ const shell = process.platform === "win32" ? "bash" : "sh";
383
+ const r = execForm
384
+ ? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
385
+ env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
386
+ timeout: 5000,
387
+ encoding: "utf-8",
388
+ })
389
+ : spawnSync(shell, ["-c", cmd], {
390
+ env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
391
+ timeout: 5000,
392
+ encoding: "utf-8",
393
+ });
394
+ if (r.error) {
395
+ line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${substituted}' — the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
396
+ }
397
+ else if (r.status !== 0) {
398
+ line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Claude Code treats this as a non-blocking failure, so searches proceed unguarded.`);
399
+ }
400
+ else {
401
+ const decision = /"permissionDecision"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
402
+ if (decision)
403
+ line(OK, `guard hook runs (live decision here: ${decision})`);
404
+ else
405
+ line(WARN, `guard hook ran (exit 0) but produced no permissionDecision output — check ${path.join(".claude", "hooks", "vexp-guard.sh")}`);
406
+ }
407
+ }
408
+ }
409
+ }
410
+ // 5c) Cursor guard hook — same live-execution philosophy as 5b. Cursor's
411
+ // hooks fail OPEN too (`failClosed` defaults to false), so a guard that
412
+ // cannot spawn silently enforces nothing there as well. The guard's stdin
413
+ // protocol: JSON {tool_name, tool_input, workspace_roots, cwd}; a healthy
414
+ // run answers {"permission":"allow"} with exit 0 OR a deny verdict with
415
+ // exit 2 — BOTH mean "the hook works", anything else is a failure.
416
+ console.log(chalk.bold("\nCursor guard hook (.cursor/hooks.json)"));
417
+ {
418
+ const cursorCfgPath = path.join(ws.root, ".cursor", "hooks.json");
419
+ let cursorCmds = [];
420
+ let cursorReadable = false;
421
+ try {
422
+ const cfg = JSON.parse(fs.readFileSync(cursorCfgPath, "utf-8"));
423
+ cursorReadable = true;
424
+ const pre = Array.isArray(cfg?.hooks?.preToolUse) ? cfg.hooks.preToolUse : [];
425
+ for (const h of pre) {
426
+ if (typeof h?.command === "string" && h.command.includes("vexp-guard"))
427
+ cursorCmds.push(h.command);
428
+ }
429
+ }
430
+ catch { /* absent or unparseable */ }
431
+ if (!cursorReadable) {
432
+ line(OK, "no .cursor/hooks.json (guard not installed)");
433
+ }
434
+ else if (cursorCmds.length === 0) {
435
+ line(OK, "no vexp guard configured (enable with 'vexp setup --guard-strict')");
436
+ }
437
+ else if (process.platform === "win32") {
438
+ line(OK, `guard configured (${cursorCmds.length} entry) — live execution check skipped on Windows`);
439
+ }
440
+ else {
441
+ // A benign Grep probe: with a healthy daemon the guard denies (exit 2),
442
+ // without one it allows (exit 0) — either proves the hook executes.
443
+ const probe = JSON.stringify({
444
+ tool_name: "Grep",
445
+ tool_input: {},
446
+ workspace_roots: [ws.root],
447
+ cwd: ws.root,
448
+ });
449
+ for (const cmd of cursorCmds) {
450
+ // Cursor runs project hooks from the project root; emulate that.
451
+ const r = spawnSync("sh", ["-c", cmd], {
452
+ cwd: ws.root,
453
+ input: probe,
454
+ timeout: 5000,
455
+ encoding: "utf-8",
456
+ });
457
+ if (r.error) {
458
+ line(BAD, `guard hook DID NOT RUN: ${r.error.code ?? r.error.message} spawning '${cmd}' — Cursor hooks fail open, so the guard is enforcing nothing. Re-run 'vexp setup --guard-strict'.`);
459
+ continue;
460
+ }
461
+ const decision = /"permission"\s*:\s*"(\w+)"/.exec(String(r.stdout ?? ""))?.[1];
462
+ if ((r.status === 0 || r.status === 2) && decision) {
463
+ line(OK, `guard hook runs (live decision here: ${decision})`);
464
+ }
465
+ else if (r.status !== 0 && r.status !== 2) {
466
+ line(BAD, `guard hook exited ${r.status}${r.stderr ? ` — ${String(r.stderr).trim().slice(0, 200)}` : ""} — Cursor treats hook failures as allow, so searches proceed unguarded.`);
467
+ }
468
+ else {
469
+ line(WARN, `guard hook ran (exit ${r.status}) but produced no permission verdict — check ${path.join(".cursor", "hooks", "vexp-guard.js")}`);
470
+ }
471
+ }
472
+ }
473
+ }
266
474
  // 6) HTTP MCP supervisor.
267
475
  console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
268
476
  try {
@@ -374,3 +374,66 @@ process.stdin.on("end", () => {
374
374
  try { main(input); } catch (e) { allow(); } // any surprise -> fail open
375
375
  });
376
376
  `;
377
+ /**
378
+ * UserPromptSubmit hint hook (2.3.3 event-driven mandate). The resident
379
+ * CLAUDE.md strategy text is gone; instead, when a prompt names no known
380
+ * symbol or file of the indexed workspace, the daemon returns a one-line
381
+ * hint suggesting run_pipeline. All logic (stdin parsing, classification,
382
+ * hook-JSON output) lives in the Rust binary so the script is OS-neutral;
383
+ * Claude Code runs shell-form hooks through Git Bash on Windows.
384
+ * FAIL-OPEN CONTRACT: every failure path (binary missing, daemon down,
385
+ * timeout) exits 0 with no output = vanilla behavior, never a broken
386
+ * prompt. Keep in lockstep with the VS Code extension copy.
387
+ * The __VEXP_BIN__ placeholder is baked at install time.
388
+ */
389
+ export const VEXP_HINT_HOOK = `#!/bin/bash
390
+ # vexp-hint: event-driven orientation hint (UserPromptSubmit). Fails open.
391
+ VEXP_BIN="__VEXP_BIN__"
392
+ [ -x "$VEXP_BIN" ] || exit 0
393
+ "$VEXP_BIN" prompt-hint 2>/dev/null
394
+ exit 0
395
+ `;
396
+ /** Bake the binary path into the hint hook script. */
397
+ export function vexpHintHookScript(binaryPath) {
398
+ return VEXP_HINT_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
399
+ }
400
+ /**
401
+ * opencode/Kilo per-prompt hint plugin (2.4.0). The plugin API's
402
+ * `chat.message` hook sees the user message before the LLM call and can
403
+ * append parts — the opencode-family equivalent of UserPromptSubmit +
404
+ * additionalContext. All classification lives in the Rust binary
405
+ * (prompt-hint, Claude-shaped JSON envelope; we extract additionalContext
406
+ * here). FAIL-OPEN: any error/timeout/missing binary => no parts appended.
407
+ * __VEXP_BIN__ is baked at install time. Keep in lockstep with the VS Code
408
+ * extension copy.
409
+ */
410
+ export const VEXP_OPENCODE_HINT = `// vexp-hint: per-prompt orientation (fail-open). Managed by vexp.
411
+ const VEXP_BIN = "__VEXP_BIN__";
412
+ export const VexpHint = async ({ directory }) => {
413
+ return {
414
+ "chat.message": async (_input, output) => {
415
+ try {
416
+ const { execFileSync } = await import("node:child_process");
417
+ const text = (output.parts || [])
418
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
419
+ .map((p) => p.text)
420
+ .join("\\n");
421
+ if (!text || text.length < 40) return;
422
+ const out = execFileSync(VEXP_BIN, ["prompt-hint"], {
423
+ input: JSON.stringify({ prompt: text }),
424
+ timeout: 4000,
425
+ env: { ...process.env, CLAUDE_PROJECT_DIR: directory },
426
+ encoding: "utf8",
427
+ });
428
+ if (!out || !out.trim()) return;
429
+ const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
430
+ if (hint) output.parts.push({ type: "text", text: hint });
431
+ } catch (e) { /* fail open */ }
432
+ },
433
+ };
434
+ };
435
+ `;
436
+ /** Bake the binary path into the opencode hint plugin. */
437
+ export function vexpOpencodeHintPlugin(binaryPath) {
438
+ return VEXP_OPENCODE_HINT.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
439
+ }
package/dist/serve.js CHANGED
@@ -140,7 +140,17 @@ async function resurrectAll() {
140
140
  // Prune rows whose workspace vanished, but keep routing rows alive in the
141
141
  // registry — they are data, not spawn instructions.
142
142
  const pruned = {};
143
+ // Windows: dedupe case-variant keys for the same workspace (C:\ vs c:\)
144
+ // left behind by pre-2.3.3 daemons — they map to the same pipe and every
145
+ // rewrite here would otherwise immortalize the phantom twin.
146
+ const seenKeys = new Set();
143
147
  for (const [ws, sock] of Object.entries(reg)) {
148
+ const canonical = process.platform === "win32" ? ws.toLowerCase() : ws;
149
+ if (seenKeys.has(canonical)) {
150
+ appendLog(`registry prune: ${ws} (case-variant duplicate)`);
151
+ continue;
152
+ }
153
+ seenKeys.add(canonical);
144
154
  const manifest = path.join(ws, ".vexp", "manifest.json");
145
155
  if (!fs.existsSync(manifest)) {
146
156
  appendLog(`registry prune: ${ws} (no manifest)`);