vexp-cli 2.3.1 → 2.5.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
@@ -151,13 +151,43 @@ export async function runDoctor() {
151
151
  if (st.llm_configured_but_inactive === true) {
152
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.`);
153
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 */ }
154
172
  // 2.3 C1 — which agent sessions actually used vexp (a session with zero
155
173
  // calls never shows up here; that absence is the diagnostic).
156
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
+ }
157
184
  if (sessions.length > 0) {
158
185
  const total = sessions.reduce((n, s) => n + (Number(s.pipeline_calls) || 0), 0);
159
186
  line(OK, `sessions (4h): ${sessions.length} active, ${total} pipeline calls total`);
160
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
+ }
161
191
  else if (Number(st.daemon_uptime_s) > 600) {
162
192
  // Embedded caveat: a stdio MCP server (`vexp-core mcp`) spawned while
163
193
  // the daemon was unreachable serves from its own in-process index and
@@ -207,6 +237,12 @@ export async function runDoctor() {
207
237
  // 3) License (fresh.jwt rolling token vs license.jwt).
208
238
  console.log(chalk.bold("\nLicense tokens (~/.vexp)"));
209
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;
210
246
  for (const name of ["fresh.jwt", "license.jwt"]) {
211
247
  const p = path.join(home, ".vexp", name);
212
248
  if (!fs.existsSync(p)) {
@@ -220,7 +256,15 @@ export async function runDoctor() {
220
256
  }
221
257
  const days = Math.round((exp - now) / 86400);
222
258
  if (exp < now) {
223
- 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
+ }
224
268
  }
225
269
  else {
226
270
  line(OK, `${name}: valid, ~${days}d remaining`);
@@ -310,31 +354,39 @@ export async function runDoctor() {
310
354
  else if (guardHooks.length === 0) {
311
355
  line(OK, "no vexp guard configured (2.3 default — enable with 'vexp setup --guard-strict')");
312
356
  }
313
- else if (process.platform === "win32") {
314
- line(OK, `guard configured (${guardHooks.length} entry) — live execution check skipped on Windows`);
315
- }
316
357
  else {
317
358
  for (const h of guardHooks) {
318
359
  const cmd = h.command;
319
360
  const execForm = Array.isArray(h.args);
320
361
  const timeoutS = typeof h.timeout === "number" ? h.timeout : 600;
321
362
  if (!execForm && /\$\{?CLAUDE_PROJECT_DIR\}?\//.test(cmd) && !cmd.includes('"')) {
322
- line(ws.root.includes(" ") ? BAD : WARN, `shell-form hook command ('args' missing) — 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 in exec form.`);
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).`);
323
371
  }
324
372
  if (timeoutS > 600) {
325
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.`);
326
374
  }
327
375
  // Run it exactly as Claude Code would: exec form = direct spawn with
328
- // the placeholder substituted by the host; shell form = sh -c with
329
- // CLAUDE_PROJECT_DIR in the environment.
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).
330
381
  const substituted = cmd.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root);
382
+ const shell = process.platform === "win32" ? "bash" : "sh";
331
383
  const r = execForm
332
384
  ? spawnSync(substituted, h.args.map((a) => String(a).replace(/\$\{CLAUDE_PROJECT_DIR\}/g, ws.root)), {
333
385
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
334
386
  timeout: 5000,
335
387
  encoding: "utf-8",
336
388
  })
337
- : spawnSync("sh", ["-c", cmd], {
389
+ : spawnSync(shell, ["-c", cmd], {
338
390
  env: { ...process.env, CLAUDE_PROJECT_DIR: ws.root },
339
391
  timeout: 5000,
340
392
  encoding: "utf-8",
@@ -374,3 +374,138 @@ 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
+ * Horizon F2a: Stop-hook verification gate (Claude Code). All logic lives
402
+ * in the Rust binary (stop-gate): mechanical completion check via daemon,
403
+ * blocks at most once per session and ONLY on parse errors / broken
404
+ * imports. FAIL-OPEN by construction: no binary / no daemon / timeout =
405
+ * silent exit 0 = normal stop.
406
+ */
407
+ export const VEXP_STOP_GATE_HOOK = `#!/bin/bash
408
+ # vexp-verify: mechanical completion gate on Stop (Horizon). Fails open.
409
+ VEXP_BIN="__VEXP_BIN__"
410
+ [ -x "$VEXP_BIN" ] || exit 0
411
+ "$VEXP_BIN" stop-gate 2>/dev/null
412
+ exit 0
413
+ `;
414
+ /** Bake the binary path into the stop-gate hook script. */
415
+ export function vexpStopGateHookScript(binaryPath) {
416
+ return VEXP_STOP_GATE_HOOK.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
417
+ }
418
+ /**
419
+ * opencode/Kilo per-prompt hint plugin (2.4.0). The plugin API's
420
+ * `chat.message` hook sees the user message before the LLM call and can
421
+ * append parts — the opencode-family equivalent of UserPromptSubmit +
422
+ * additionalContext. All classification lives in the Rust binary
423
+ * (prompt-hint, Claude-shaped JSON envelope; we extract additionalContext
424
+ * here). FAIL-OPEN: any error/timeout/missing binary => no parts appended.
425
+ * __VEXP_BIN__ is baked at install time. Keep in lockstep with the VS Code
426
+ * extension copy.
427
+ */
428
+ export const VEXP_OPENCODE_HINT = `// vexp-hint: per-prompt orientation + idle verification (fail-open). Managed by vexp.
429
+ const VEXP_BIN = "__VEXP_BIN__";
430
+ export const VexpHint = async ({ directory, client }) => {
431
+ const fs = await import("node:fs");
432
+ const path = await import("node:path");
433
+ const taskFileFor = (sid) =>
434
+ path.join(directory, ".vexp", "task-" + String(sid || "unknown") + ".txt");
435
+ const gateMarker = (sid) =>
436
+ path.join(directory, ".vexp", "idle-gate-" + String(sid || "unknown") + ".done");
437
+ return {
438
+ "chat.message": async (input, output) => {
439
+ try {
440
+ const { execFileSync } = await import("node:child_process");
441
+ const text = (output.parts || [])
442
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
443
+ .map((p) => p.text)
444
+ .join("\\n");
445
+ if (!text || text.length < 40) return;
446
+ // First prompt of the session = the task spec for the idle gate.
447
+ const sid = (input && input.sessionID) || null;
448
+ try {
449
+ const tf = taskFileFor(sid);
450
+ if (!fs.existsSync(tf)) {
451
+ fs.mkdirSync(path.dirname(tf), { recursive: true });
452
+ fs.writeFileSync(tf, text);
453
+ }
454
+ } catch (e) { /* fail open */ }
455
+ const out = execFileSync(VEXP_BIN, ["prompt-hint"], {
456
+ input: JSON.stringify({ prompt: text, session_id: sid }),
457
+ timeout: 4000,
458
+ env: { ...process.env, CLAUDE_PROJECT_DIR: directory },
459
+ encoding: "utf8",
460
+ });
461
+ if (!out || !out.trim()) return;
462
+ const hint = JSON.parse(out).hookSpecificOutput?.additionalContext;
463
+ if (hint) output.parts.push({ type: "text", text: hint });
464
+ } catch (e) { /* fail open */ }
465
+ },
466
+ event: async ({ event }) => {
467
+ // Idle gate: the opencode twin of the Claude Stop hook. Runs the
468
+ // mechanical completion check once per session; on gaps, sends ONE
469
+ // follow-up prompt with the exact list (best effort - any failure
470
+ // is silent and the session simply stays stopped).
471
+ try {
472
+ if (!event || event.type !== "session.idle") return;
473
+ const sid = event.properties && event.properties.sessionID;
474
+ if (!sid) return;
475
+ const marker = gateMarker(sid);
476
+ if (fs.existsSync(marker)) return;
477
+ const tf = taskFileFor(sid);
478
+ if (!fs.existsSync(tf)) return;
479
+ const { execFileSync } = await import("node:child_process");
480
+ const out = execFileSync(
481
+ VEXP_BIN,
482
+ ["verify", "--json", "--task-file", tf],
483
+ { timeout: 15000, cwd: directory, encoding: "utf8" }
484
+ );
485
+ const rep = JSON.parse(out);
486
+ const items = [];
487
+ for (const f of (rep.spec && rep.spec.forbidden_touched) || [])
488
+ items.push("- the task says NOT to modify \`" + f + "\` but it was changed - revert or justify");
489
+ for (const a of (rep.spec && rep.spec.artifacts_missing) || [])
490
+ items.push("- the task asks for \`" + a + "\` and it does not exist yet");
491
+ for (const b of (rep.broken_imports || []).slice(0, 8))
492
+ items.push("- " + b.file + ":" + b.line + " imports \`" + b.imports + "\` which no longer exists in " + b.from_changed_file);
493
+ if (!items.length) return;
494
+ fs.writeFileSync(marker, "1");
495
+ await client.session.prompt({
496
+ path: { id: sid },
497
+ body: {
498
+ parts: [{ type: "text", text:
499
+ "vexp verify found mechanically checkable gaps between the session's work and the task:\\n" +
500
+ items.join("\\n") + "\\nFix each item above. This check will not repeat." }],
501
+ },
502
+ });
503
+ } catch (e) { /* fail open */ }
504
+ },
505
+ };
506
+ };
507
+ `;
508
+ /** Bake the binary path into the opencode hint plugin. */
509
+ export function vexpOpencodeHintPlugin(binaryPath) {
510
+ return VEXP_OPENCODE_HINT.replace("__VEXP_BIN__", binaryPath.replace(/\\/g, "/"));
511
+ }
@@ -1,4 +1,5 @@
1
1
  import * as fs from "fs";
2
+ import { CLI_VERSION } from "./version.js";
2
3
  import * as net from "net";
3
4
  import * as os from "os";
4
5
  import * as path from "path";
@@ -95,11 +96,22 @@ export async function ensureMcpHttpServer(opts = {}) {
95
96
  const mcpPath = getMcpServerPath();
96
97
  if (!mcpPath)
97
98
  return null;
98
- // Fast path: already alive and tracked.
99
+ // Fast path: already alive and tracked - but only if it is OUR version.
100
+ // Field report (Nathan): a 2.3.0 http server stayed authoritative for
101
+ // 9 days across extension upgrades because reuse never compared
102
+ // versions. On mismatch (or a legacy record without one) we take over:
103
+ // SIGTERM the old listener and spawn the current build.
99
104
  {
100
105
  const existing = readPidRecord();
101
106
  if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
102
- return { pid: existing.pid, port, started: false };
107
+ if (existing.version === CLI_VERSION) {
108
+ return { pid: existing.pid, port, started: false };
109
+ }
110
+ try {
111
+ process.kill(existing.pid, "SIGTERM");
112
+ }
113
+ catch { /* already gone */ }
114
+ await new Promise((r) => setTimeout(r, 500));
103
115
  }
104
116
  }
105
117
  // Slow path: we may need to spawn. Serialize across concurrent callers
@@ -164,7 +176,7 @@ export async function ensureMcpHttpServer(opts = {}) {
164
176
  const pid = child.pid;
165
177
  if (!pid)
166
178
  return null;
167
- writePidRecord({ pid, port, startedAt: Date.now(), owner });
179
+ writePidRecord({ pid, port, startedAt: Date.now(), owner, version: CLI_VERSION });
168
180
  // Wait for the child to actually bind the port BEFORE releasing the
169
181
  // lock. Without this, a second caller arriving right after release
170
182
  // sees pidfile+live-pid but port-not-yet-bound → falsely concludes
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)`);
@@ -63,6 +63,19 @@ export async function checkForUpdate() {
63
63
  return;
64
64
  if (process.argv.includes("--skip-update-check"))
65
65
  return;
66
+ // Protocol and diagnostic commands are NEVER touched by update
67
+ // machinery. Field report (Nathan, 2.4.0): the old hard gate ran for
68
+ // every subcommand and exit(1)'d on any registry delta - publishing a
69
+ // release remotely disabled every installed CLI at the instant of
70
+ // publish. `vexp mcp` died at spawn (host saw zero tools, silently),
71
+ // and doctor/--help/--version were disabled by the exact condition
72
+ // they existed to diagnose.
73
+ const argv = process.argv.slice(2);
74
+ const cmd = argv.find((a) => !a.startsWith("-"));
75
+ if (cmd === "mcp" || cmd === "doctor" || cmd === "stop-gate" || cmd === "prompt-hint")
76
+ return;
77
+ if (argv.includes("--version") || argv.includes("-V") || argv.includes("--help") || argv.includes("-h"))
78
+ return;
66
79
  let latest = null;
67
80
  // Try cache first
68
81
  const cache = readCache();
@@ -76,14 +89,13 @@ export async function checkForUpdate() {
76
89
  writeCache(latest);
77
90
  }
78
91
  if (isOutdated(CLI_VERSION, latest)) {
92
+ // ADVISORY ONLY - never exit. A newer version on the registry is
93
+ // news, not an incompatibility: the daemon handshake is where real
94
+ // incompatibilities surface, with an actionable error.
79
95
  console.error("");
80
- console.error(chalk.red.bold(" vexp update required!"));
81
- console.error("");
82
- console.error(` Installed: ${chalk.dim(CLI_VERSION)}`);
83
- console.error(` Available: ${chalk.green(latest)}`);
84
- console.error("");
96
+ console.error(chalk.yellow.bold(" vexp update available"));
97
+ console.error(` Installed: ${chalk.dim(CLI_VERSION)} -> Available: ${chalk.green(latest)}`);
85
98
  console.error(` Run: ${chalk.cyan("npm install -g vexp-cli")}`);
86
99
  console.error("");
87
- process.exit(1);
88
100
  }
89
101
  }