claude-mem-lite 6.1.0 → 6.3.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "6.1.0",
13
+ "version": "6.3.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.1.0",
3
+ "version": "6.3.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -134,16 +134,23 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
134
134
 
135
135
  | Platform | Status | Notes |
136
136
  |----------|--------|-------|
137
- | **Linux** | Supported | Primary development and testing platform |
137
+ | **Linux** | Supported | Primary development and testing platform; the whole CI matrix runs here |
138
138
  | **macOS** | Supported | Fully compatible (Intel and Apple Silicon) |
139
- | **Windows** | Not supported | Uses POSIX shell scripts (`post-tool-use.sh`, `setup.sh`) and Unix file locking; WSL2 may work but is untested |
139
+ | **Windows** | Installs, not CI-covered | The MCP server, the CLI and the `node` hooks work (`better-sqlite3` ships `win32-x64` and `win32-arm64` prebuilds, so nothing is compiled). **Three hook commands run under `bash`** — `setup.sh`, `post-tool-use.sh`, `pre-agent-inject.sh` and need Git for Windows or WSL on `PATH`; `claude-mem-lite doctor` reports it when `bash` cannot be found. No GitHub Actions runner exercises Windows, so this rests on user reports ([#28](https://github.com/sdsrss/claude-mem-lite/issues/28)), not on a green pipeline |
140
+ | **WSL2** | Untested | Linux under the hood, so it should behave as the Linux row; nobody has reported either way |
141
+
142
+ From v5.1.0 through v6.1.0, `package.json` declared `os: ["darwin", "linux"]`. That is an npm *install*
143
+ gate, not a runtime check: on Windows it made `npm install` exit `EBADPLATFORM`, which the
144
+ plugin launcher runs on the first MCP start after every plugin update — so the server never
145
+ came up and `/mcp` reported `CONNECTION_CLOSED`. `win32` is now in the list. A platform that
146
+ is still outside it gets a message naming both sides of the mismatch instead of a guess.
140
147
 
141
148
  ## Requirements
142
149
 
143
150
  - **Node.js** >= 22
144
151
  - **Claude Code** CLI installed and configured (`claude` command available)
145
- - **SQLite3** support (provided by `better-sqlite3`, compiled on install)
146
- - **Platform**: Linux or macOS (see [Platform Support](#platform-support))
152
+ - **SQLite3** support (provided by `better-sqlite3` 13, which ships prebuilt binaries for 8 platforms — no compiler needed on any of them; a platform it has no prebuild for falls back to building from source)
153
+ - **Platform**: Linux or macOS; Windows installs and runs but is not CI-covered and needs Git Bash or WSL for three hooks (see [Platform Support](#platform-support))
147
154
 
148
155
  ## Installation
149
156
 
package/README.zh-CN.md CHANGED
@@ -100,16 +100,22 @@
100
100
 
101
101
  | 平台 | 状态 | 说明 |
102
102
  |------|------|------|
103
- | **Linux** | 支持 | 主要开发和测试平台 |
103
+ | **Linux** | 支持 | 主要开发和测试平台;整个 CI 矩阵都跑在这里 |
104
104
  | **macOS** | 支持 | 完全兼容(Intel 和 Apple Silicon) |
105
- | **Windows** | 暂不支持 | 使用 POSIX shell 脚本(`post-tool-use.sh`、`setup.sh`)和 Unix 文件锁;WSL2 可能可用但未经测试 |
105
+ | **Windows** | 可安装,但无 CI 覆盖 | MCP server、CLI `node` 类 hook 均可用(`better-sqlite3` 自带 `win32-x64` / `win32-arm64` 预编译产物,无需编译)。**有三个 hook 命令走 `bash`** —— `setup.sh`、`post-tool-use.sh`、`pre-agent-inject.sh` —— 需要 PATH 上有 Git for Windows 或 WSL;`bash` 找不到时 `claude-mem-lite doctor` 会报出来。GitHub Actions 没有 Windows runner,所以这一行依据的是用户报告([#28](https://github.com/sdsrss/claude-mem-lite/issues/28))而不是绿色流水线 |
106
+ | **WSL2** | 未测试 | 底层就是 Linux,预期与 Linux 行一致;但无人报告过实际结果 |
107
+
108
+ v5.1.0 到 v6.1.0 之间,`package.json` 声明的是 `os: ["darwin", "linux"]`。那是 npm 的**安装**门禁,不是运行时检查:
109
+ 在 Windows 上它让 `npm install` 以 `EBADPLATFORM` 退出,而插件启动器每次插件更新后的首次 MCP 启动都要跑这条
110
+ 安装 —— 于是 server 起不来,`/mcp` 报 `CONNECTION_CLOSED`。现在 `win32` 已加入该列表。仍不在列表内的平台会拿到
111
+ 一条同时点明“声明了什么”和“当前是什么”的消息,而不是一句猜测。
106
112
 
107
113
  ## 环境要求
108
114
 
109
115
  - **Node.js** >= 22(v4.0.0 起:better-sqlite3 13 要求 >=22,Node 20 已于 2026-04 EOL;`package.json` 的 `engines` 是唯一事实来源)
110
116
  - **Claude Code** CLI 已安装并配置(`claude` 命令可用)
111
- - **SQLite3** 支持(由 `better-sqlite3` 提供,安装时编译)
112
- - **平台**:Linux 或 macOS(参见[平台支持](#平台支持))
117
+ - **SQLite3** 支持(由 `better-sqlite3` 13 提供,它自带 8 个平台的预编译产物,这些平台上都不需要编译器;没有对应预编译产物的平台才会回退到源码编译)
118
+ - **平台**:Linux 或 macOS;Windows 可安装运行,但无 CI 覆盖,且三个 hook 需要 Git Bash 或 WSL(参见[平台支持](#平台支持))
113
119
 
114
120
  ## 安装
115
121
 
package/hook-shared.mjs CHANGED
@@ -22,6 +22,12 @@ import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
22
22
  // Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
23
23
  // neither imports this file) — no cycle.
24
24
  import { recordHookError } from './lib/hook-telemetry.mjs';
25
+ import {
26
+ isSchemaSkewError,
27
+ schemaSkewFromError,
28
+ shouldRecordSkew,
29
+ SKEW_MARKER_PREFIX,
30
+ } from './lib/schema-skew.mjs';
25
31
  // Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
26
32
  // predicates and the handoff constants moved into `lib/` because two lib modules
27
33
  // imported them from here and dragged this file's whole import graph — haiku-client,
@@ -237,6 +243,7 @@ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
237
243
  // forever. `.skill-cooldown-` / `.skill-reco-cooldown-` left with the skill registry in
238
244
  // v5.0.0; a prefix for files nothing writes any more is dead weight in a hot-path loop.
239
245
  'last-mark-compressible-', // per-project auto-compress 24h gate
246
+ SKEW_MARKER_PREFIX, // per-project schema-skew log dedup; regenerated on the next skewed open
240
247
  ]);
241
248
 
242
249
  // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
@@ -382,12 +389,58 @@ export function createSessionId() {
382
389
 
383
390
  // ─── Database ────────────────────────────────────────────────────────────────
384
391
 
392
+ // Last forward-incompat ("the DB is newer than me") failure seen in THIS process, or null.
393
+ // SessionStart needs the two version numbers to render its notice and openDb has just been
394
+ // handed them, so this beats a second DB open — and on a skew there may be no working
395
+ // binding to open with anyway.
396
+ let lastSkew = null;
397
+
398
+ /**
399
+ * The schema skew that made the most recent openDb() return null, or null.
400
+ * Cleared by any successful open, so a heal mid-session stops the notice.
401
+ *
402
+ * @returns {{dbVersion: number|null, binaryVersion: number|null}|null}
403
+ */
404
+ export function lastSchemaSkew() {
405
+ return lastSkew;
406
+ }
407
+
385
408
  export function openDb() {
386
409
  try {
387
410
  // WAL-corruption self-heal (was server.mjs-only): without it, hooks stayed
388
411
  // silently dead (null DB) on a corrupt WAL until the next MCP server start.
389
- return ensureDbWithWalRecovery();
412
+ const db = ensureDbWithWalRecovery();
413
+ lastSkew = null;
414
+ return db;
390
415
  } catch (e) {
416
+ // Forward-incompat is its own family: it cannot be healed by anything this process can
417
+ // do, it repeats on every single open, and it is the one failure the USER has to act on.
418
+ // Record it once and hand the numbers to SessionStart, which is the surface that speaks.
419
+ // Forward-incompat is its own family: nothing this process can do heals it, it repeats on
420
+ // every single open, and it is the one failure the USER has to act on. Dedup lives in
421
+ // lib/schema-skew.mjs so the `ups` face — which opens the DB itself and logged its own 15
422
+ // of the day's 727 lines — shares one implementation instead of drifting from this one.
423
+ //
424
+ // shouldRecordSkew is TOTAL by contract. Nothing in this catch may throw: the first cut
425
+ // called getSessionId() here, which MINTS and writes a session id, so an unwritable
426
+ // runtime dir turned openDb() itself into a thrower. All 13 call sites are written to
427
+ // no-op on null and none of them expects an exception.
428
+ if (isSchemaSkewError(e)) {
429
+ lastSkew = schemaSkewFromError(e) || { dbVersion: null, binaryVersion: null };
430
+ // Guarded even though inferProject() reads env and cwd: "the only statement in this
431
+ // catch cannot throw" was true of the original one-line body and stopped being true
432
+ // the moment anything was added. An unscoped marker is a worse dedup, not a crash.
433
+ let project = '';
434
+ try {
435
+ project = inferProject();
436
+ } catch {
437
+ /* total: the marker degrades to one shared file */
438
+ }
439
+ if (shouldRecordSkew(RUNTIME_DIR, project, lastSkew)) {
440
+ recordHookError('hook-shared:db-open', e, RUNTIME_DIR);
441
+ }
442
+ return null;
443
+ }
391
444
  // Still null, still no throw — a hook must never crash the host session, and all
392
445
  // eight call sites in hook.mjs are written to no-op on null. But "returned null"
393
446
  // used to be the ONLY trace: nothing reached runtime/hook-errors/, so `stats`
package/hook-update.mjs CHANGED
@@ -228,7 +228,11 @@ function isPluginMode() {
228
228
  }
229
229
 
230
230
  // ── Dev Mode Detection ─────────────────────────────────────
231
- function isDevMode() {
231
+ // Exported since the schema-skew notice needs it: a dev checkout must be told `git pull`,
232
+ // never a command that would overwrite its working tree. Re-implementing the check at the
233
+ // call site would make it the second copy of a predicate this file has already had to get
234
+ // right twice (whole-dir symlink, then per-file drift) — the twin-drift class.
235
+ export function isDevMode() {
232
236
  try {
233
237
  // A dev checkout always carries a .git dir. This catches a whole-directory
234
238
  // symlink (~/.claude-mem-lite -> /repo): lstat on server.mjs there follows the
package/hook.mjs CHANGED
@@ -60,7 +60,9 @@ import {
60
60
  episodeHasSignificantContent,
61
61
  explainSignificance,
62
62
  } from './hook-episode.mjs';
63
- import { DB_DIR } from './schema.mjs';
63
+ // CODE_DIR, not DB_DIR: the schema-skew notice asks which CODE homes exist, and those are
64
+ // always homedir-rooted even when CLAUDE_MEM_DIR relocates the data.
65
+ import { DB_DIR, CODE_DIR } from './schema.mjs';
64
66
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
65
67
  import { entry as preCompactEntry } from './hook-precompact.mjs';
66
68
  import {
@@ -82,6 +84,7 @@ import {
82
84
  spawnBackground,
83
85
  sweepOrphanEpisodeFiles,
84
86
  sweepStaleProjectMarkers,
87
+ lastSchemaSkew,
85
88
  } from './hook-shared.mjs';
86
89
  import { handleLLMEpisode, handleLLMSummary, saveEpisodeImmediate } from './hook-llm.mjs';
87
90
  import { readFastSummarySource, insertFastSummary, FAST_SUMMARY_LIMITS } from './lib/fast-summary.mjs';
@@ -2290,6 +2293,67 @@ async function buildStartupDashboardText(db, project) {
2290
2293
  }
2291
2294
  }
2292
2295
 
2296
+ /**
2297
+ * Tell the user their memory is version-skewed, on the one surface they read.
2298
+ *
2299
+ * Only fires when openDb() failed for THIS reason — hook-shared records the two version
2300
+ * numbers as it catches, so nothing is re-derived and no second DB open is attempted (on a
2301
+ * skew there may be no usable binding to open with).
2302
+ *
2303
+ * Everything is dynamically imported: this is a cold path that must not cost the healthy
2304
+ * SessionStart an install-shape scan. And it goes through the queue helpers, never a bare
2305
+ * console.log — SessionStart merges three would-be stdout contributors into ONE envelope,
2306
+ * and writing raw prose alongside it once made the host deliver the whole JSON object to
2307
+ * the model as literal text (tests/session-start-stdout-envelope.test.mjs).
2308
+ */
2309
+ async function emitSchemaSkewNotice() {
2310
+ try {
2311
+ const skew = lastSchemaSkew();
2312
+ if (!skew) return;
2313
+ const [shapeMod, updateMod, skewMod] = await Promise.all([
2314
+ import('./lib/install-shape.mjs'),
2315
+ import('./hook-update.mjs'),
2316
+ import('./lib/schema-skew.mjs'),
2317
+ ]);
2318
+ const shape = shapeMod.detectInstallShape({ installDir: CODE_DIR });
2319
+ // WHICH tree is running this hook, not which trees exist. CLAUDE_PLUGIN_ROOT is set in
2320
+ // every hook process Claude Code spawns, so on a machine holding BOTH a managed install
2321
+ // and a plugin cache it is the only thing that knows which one is behind. Deciding from
2322
+ // the machine's global shape printed `claude-mem-lite self-update` beneath a line naming
2323
+ // the plugin cache — a repair that cannot advance the tree it had just named.
2324
+ const runningRoot = process.env.CLAUDE_PLUGIN_ROOT || CODE_DIR;
2325
+ const remedy = skewMod.schemaSkewRemedy({
2326
+ managed: shape.managed,
2327
+ activePluginVersion: shape.activePluginVersion,
2328
+ dev: updateMod.isDevMode(),
2329
+ root: runningRoot,
2330
+ });
2331
+ const notice = skewMod.formatSchemaSkewNotice({
2332
+ dbVersion: skew.dbVersion,
2333
+ binaryVersion: skew.binaryVersion,
2334
+ remedy,
2335
+ // Name the home only when the remedy is about that home, so the two can never disagree.
2336
+ codeHome:
2337
+ remedy.kind === 'plugin' && shape.activePluginVersion
2338
+ ? `plugin cache v${shape.activePluginVersion.version}`
2339
+ : undefined,
2340
+ });
2341
+ // BOTH channels, and the HUMAN one is the point. queueHookContext reaches the model;
2342
+ // lib/hook-stdout.mjs's queueHookSystemMessage is documented "for the HUMAN, not the
2343
+ // model" and names v3.70.0 for making exactly this mistake — folding a banner into
2344
+ // additionalContext "kept its content and lost its audience". A notice whose whole job is
2345
+ // to hand the user a command must not depend on the assistant volunteering it.
2346
+ // flushHookStdout merges both into one envelope, so this is additive: the model learns
2347
+ // memory is unavailable, the user gets the repair.
2348
+ queueHookSystemMessage(notice);
2349
+ queueHookContext('SessionStart', notice);
2350
+ } catch (e) {
2351
+ // A hook must never crash the host session, and a notice that cannot render is still
2352
+ // better handled by staying quiet than by taking SessionStart down with it.
2353
+ debugCatch(e, 'session-start-schema-skew');
2354
+ }
2355
+ }
2356
+
2293
2357
  async function handleSessionStart() {
2294
2358
  // GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
2295
2359
  // moved here from pre-tool-recall.js's hot path.
@@ -2471,7 +2535,16 @@ async function handleSessionStart() {
2471
2535
  const project = inferProject();
2472
2536
 
2473
2537
  const db = openDb();
2474
- if (!db) return;
2538
+ if (!db) {
2539
+ // A null DB used to end SessionStart in total silence. For most causes that is right —
2540
+ // they are transient, or a repair path is already running. Forward-incompat is neither:
2541
+ // it persists until the user installs newer code, it disables every write path, and the
2542
+ // only other signal it produces is a `-32000 Connection closed` from the MCP host, which
2543
+ // names nothing. Measured 2026-09-08: a whole day of it, >=648 log lines, zero words to
2544
+ // the user. This is the surface the user actually reads.
2545
+ await emitSchemaSkewNotice();
2546
+ return;
2547
+ }
2475
2548
 
2476
2549
  try {
2477
2550
  const now = new Date();
package/install.mjs CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  scanPluginCacheHookPollution,
62
62
  hasInstallManagedHooks,
63
63
  pluginCacheHookEvents,
64
+ settingsHookCommands,
64
65
  } from './plugin-cache-guard.mjs';
65
66
  import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
66
67
  import {
@@ -69,6 +70,7 @@ import {
69
70
  nativeBindingRepairHint,
70
71
  } from './lib/binding-probe.mjs';
71
72
  import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
73
+ import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
72
74
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
73
75
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
74
76
  import { ORPHAN_EPISODE_AGE_MS } from './lib/time-constants.mjs';
@@ -266,6 +268,59 @@ export function buildDoctorSummary(issues, warnings) {
266
268
  return `${issues} issue(s) found.${warnSuffix}`;
267
269
  }
268
270
 
271
+ /**
272
+ * How many LIVE hook commands invoke `bash`, and which scripts they are.
273
+ *
274
+ * There are two hook registrations and only one is live per install shape, which is what
275
+ * the first cut of doctor's interpreter check got wrong (pre-ship review P1-1). The plugin
276
+ * shape reads `hooks/hooks.json` out of the plugin cache. The npm / npx / `git clone` shape
277
+ * has no such file — `hooks/hooks.json` is in RELEASE_SIGNED_FILES but NOT in SOURCE_FILES,
278
+ * so nothing deploys it to ~/.claude-mem-lite/ — and registers its hooks in settings.json
279
+ * instead. Reading only the manifest therefore answered "zero bash hooks" on the one shape
280
+ * where two of them are live.
281
+ *
282
+ * Returns THREE outcomes, never two. `count: null` means no registration could be read, and
283
+ * that is deliberately distinct from a count of zero: zero is an answer, null is the absence
284
+ * of one, and a diagnostic that reports them identically tells the reader to stop looking.
285
+ *
286
+ * @param {{manifestPath: string, settingsCommands?: string[], installDir: string}} opts
287
+ * @returns {{count: number|null, source: 'manifest'|'settings'|null, scripts: string[]}}
288
+ */
289
+ export function resolveBashHookCount({ manifestPath, settingsCommands = [], installDir }) {
290
+ const basenames = (commands) =>
291
+ commands
292
+ .map((c) => {
293
+ const m = c.match(/([^/"\s]+\.sh)/);
294
+ return m ? m[1] : c;
295
+ })
296
+ .sort();
297
+
298
+ if (existsSync(manifestPath)) {
299
+ try {
300
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
301
+ const commands = [];
302
+ for (const matchers of Object.values(parsed?.hooks || {})) {
303
+ for (const m of matchers || []) {
304
+ for (const h of m?.hooks || []) commands.push(String(h?.command || ''));
305
+ }
306
+ }
307
+ const bash = commands.filter((c) => c.startsWith('bash '));
308
+ return { count: bash.length, source: 'manifest', scripts: basenames(bash) };
309
+ } catch {
310
+ // A torn manifest is not evidence of zero bash hooks. Fall through to settings.json,
311
+ // and if that says nothing about us either, the caller gets null.
312
+ }
313
+ }
314
+ // Only OUR entries: settings.json is shared with every other tool the user installs, so a
315
+ // foreign `bash "…"` line is not ours to report on, and — the discriminating half — a
316
+ // settings.json that names nothing of ours is not evidence that no hook needs bash. It is
317
+ // evidence we are reading the wrong registration.
318
+ const ours = settingsCommands.filter((c) => c.includes(installDir));
319
+ if (ours.length === 0) return { count: null, source: null, scripts: [] };
320
+ const bash = ours.filter((c) => c.startsWith('bash '));
321
+ return { count: bash.length, source: 'settings', scripts: basenames(bash) };
322
+ }
323
+
269
324
  // Dev installs symlink server.mjs → the project's source file. Used to suppress
270
325
  // misleading "first run" messages since hook-update.mjs skips state-writes in
271
326
  // this mode (see hook-update.mjs isDevMode).
@@ -1425,6 +1480,73 @@ async function doctor() {
1425
1480
  }
1426
1481
  }
1427
1482
 
1483
+ // Can each code home actually OPEN this database? A binding that loads is not the same
1484
+ // question: better-sqlite3 can be perfect and the store still unreadable, because
1485
+ // schema.mjs refuses a DB written by a newer claude-mem-lite (correctly — replaying old
1486
+ // migrations over a newer layout would corrupt it). That is a one-way ratchet, and on a
1487
+ // plugin install it is REACHED ROUTINELY: the cache only advances when Claude Code's
1488
+ // marketplace updater advances it, so anything else that opens the DB — an npm-global
1489
+ // CLI, a dev checkout — can leave the cache locked out. Measured 2026-09-08: DB v49 vs a
1490
+ // live 5.6.0 cache supporting v48, >=648 identical hook errors in one day, and the only
1491
+ // user-visible signal was `-32000 Connection closed` from the MCP host.
1492
+ //
1493
+ // Probed per root, out of process, exactly like the binding check above and for the same
1494
+ // reason: this is the check that has to survive answering the question, and importing
1495
+ // another tree's schema.mjs would poison the process that must report the answer. It is
1496
+ // also why this check is USEFUL TODAY rather than only after the next upgrade — doctor
1497
+ // runs from whichever tree the user invoked, so new code here can diagnose an old cache.
1498
+ if (!existsSync(DB_PATH)) {
1499
+ ok('DB schema: no database yet — nothing to compare');
1500
+ } else if (rootProbes.length === 0) {
1501
+ // The fourth outcome the first cut had and did not print. The `fail` above already tells
1502
+ // the reader no install owns a binding, but a block whose stated design point is "three
1503
+ // outcomes, never two" must not answer a fourth case with silence.
1504
+ dwarn('DB schema: not checked — no install on this machine owns a native binding to read it with');
1505
+ } else {
1506
+ const compat = probeSchemaCompat(shape.runtimeRoots, DB_PATH);
1507
+ const behind = compat.filter((c) => c.status === 'skew');
1508
+ const unknown = compat.filter((c) => c.status === 'unknown');
1509
+ if (behind.length === 0 && unknown.length === 0) {
1510
+ ok(`DB schema: v${compat[0]?.dbVersion} — readable by all ${compat.length} install(s)`);
1511
+ }
1512
+ if (behind.length > 0) {
1513
+ // Dynamic: only a skewed machine pays for it, and it reuses hook-update's isDevMode
1514
+ // rather than re-deriving "is this a checkout", which that file has already had to
1515
+ // correct twice (whole-dir symlink, then per-file drift).
1516
+ let dev = false;
1517
+ try {
1518
+ const { isDevMode } = await import('./hook-update.mjs');
1519
+ dev = isDevMode();
1520
+ } catch {
1521
+ /* unreadable → the initialiser stands: a non-dev install gets the common remedy */
1522
+ }
1523
+ for (const b of behind) {
1524
+ // PER ROOT, inside the loop. Computing one remedy for every skewed tree printed the
1525
+ // machine's global answer beneath a label naming a different tree — on a mixed
1526
+ // managed+plugin install that meant `self-update` under "plugin cache v5.6.0",
1527
+ // which advances nothing. b.root is the tree that is actually behind.
1528
+ const remedy = schemaSkewRemedy({
1529
+ managed: shape.managed,
1530
+ activePluginVersion: shape.activePluginVersion,
1531
+ dev,
1532
+ root: b.root,
1533
+ });
1534
+ // fail, not warn: every write path is dead in this state and only the user can fix it.
1535
+ fail(`DB schema v${b.dbVersion} is newer than ${b.label}, which supports up to v${b.supported}`);
1536
+ for (const c of remedy.commands) log(` ${c}`);
1537
+ if (remedy.note) log(` ${remedy.note}`);
1538
+ issues++;
1539
+ }
1540
+ }
1541
+ for (const u of unknown) {
1542
+ // Deliberately its own outcome. "I could not determine what this install supports"
1543
+ // printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
1544
+ // caught before the tag — a check that says "nothing to check" and "I could not look"
1545
+ // in the same voice ends the reader's search instead of directing it.
1546
+ dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
1547
+ }
1548
+ }
1549
+
1428
1550
  try {
1429
1551
  await import('@modelcontextprotocol/sdk/server/mcp.js');
1430
1552
  ok('@modelcontextprotocol/sdk: verified (import OK)');
@@ -1865,6 +1987,68 @@ async function doctor() {
1865
1987
  dwarn('Hook scripts: check failed — ' + e.message);
1866
1988
  }
1867
1989
 
1990
+ // Hook interpreter. Some hook commands are `bash "<script>"` (the PostToolUse and
1991
+ // Agent prefilters, plus setup.sh in the plugin manifest) — the rest are `node`. If bash
1992
+ // cannot run, those commands fail and nothing says so; the check above grades whether the
1993
+ // FILES are present, which they are.
1994
+ //
1995
+ // Keyed on whether bash runs, not on process.platform === 'win32'. A Windows user with
1996
+ // Git for Windows on PATH — the normal case, since Claude Code shells out to bash for its
1997
+ // own Bash tool — has a working configuration and must not be warned; a stripped
1998
+ // container with no bash has a broken one and must be, whatever its platform. This is
1999
+ // also what issue #28's P3-19 intent asked for: `os: [darwin, linux]` was added so a
2000
+ // Windows user "should be told rather than handed a string of silent catch blocks", and
2001
+ // blocking the install told them nothing. This is the telling.
2002
+ try {
2003
+ const {
2004
+ count: bashCommands,
2005
+ source: countSource,
2006
+ scripts: bashScripts,
2007
+ } = resolveBashHookCount({
2008
+ manifestPath: join(PROJECT_DIR, 'hooks', 'hooks.json'),
2009
+ settingsCommands: settingsHookCommands(homedir()),
2010
+ installDir: INSTALL_DIR,
2011
+ });
2012
+ if (bashCommands === null) {
2013
+ // NOT `ok`. Pre-ship review (P1-1) found the first cut printing "no hook command needs
2014
+ // bash" here, on a shape where two of them are registered — a green line that ends the
2015
+ // reader's search is worse than the silence this check exists to remove.
2016
+ dwarn(
2017
+ 'Hook interpreter: could not read either hook registration — neither ' +
2018
+ `${join(PROJECT_DIR, 'hooks', 'hooks.json')} nor a claude-mem-lite entry in ` +
2019
+ `${join(homedir(), '.claude', 'settings.json')} — so whether any hook needs bash is unknown.`,
2020
+ );
2021
+ } else if (bashCommands === 0) {
2022
+ ok(`Hook interpreter: no hook command needs bash (per the ${countSource})`);
2023
+ } else {
2024
+ let bashOk = false;
2025
+ try {
2026
+ execFileSync('bash', ['-c', 'exit 0'], { stdio: 'ignore', timeout: 5000 });
2027
+ bashOk = true;
2028
+ } catch {
2029
+ /* not resolvable, or not runnable — either way the hooks that need it cannot fire */
2030
+ }
2031
+ if (bashOk) {
2032
+ ok(`Hook interpreter: bash present (${bashCommands} hook command(s) need it)`);
2033
+ } else {
2034
+ // dwarn, not an issue: everything else works. Saying "broken" about an install
2035
+ // whose MCP server and node hooks are fine would be the mirror of the defect that
2036
+ // sent this round's reporter looking at their disk and their network.
2037
+ // The scripts are NAMED from the live registration rather than described from
2038
+ // memory — the first cut wrote "(episode Read-tracking and the subagent prefilter)",
2039
+ // a two-item gloss on a count of three (P3-1).
2040
+ dwarn(
2041
+ `Hook interpreter: bash not found on PATH — the ${bashCommands} hook command(s) that ` +
2042
+ `invoke it cannot fire (${bashScripts.join(', ')}). The MCP server and the node ` +
2043
+ 'hooks are unaffected. On Windows, install Git for Windows or use WSL; elsewhere ' +
2044
+ 'this means a stripped PATH.',
2045
+ );
2046
+ }
2047
+ }
2048
+ } catch (e) {
2049
+ dwarn('Hook interpreter: check failed — ' + e.message);
2050
+ }
2051
+
1868
2052
  // Stale temp files
1869
2053
  try {
1870
2054
  // hook-update + the episode workers write runtime/ + staging under DB_DIR
@@ -0,0 +1,87 @@
1
+ // lib/platform-gate.mjs — is THIS platform inside the `os` list package.json declares?
2
+ //
3
+ // Why this is a module and not four lines inside scripts/launch.mjs, which is its only
4
+ // consumer: launch.mjs cannot be imported. Its top level installs dependencies, probes the
5
+ // native binding and then imports the MCP server, so a test that imported it would start a
6
+ // server. The alternative to a real import is a source-text scan of launch.mjs, which this
7
+ // repo has repeatedly found to be walkable — a guard that greps for a string passes for a
8
+ // change that keeps the string and deletes the behaviour. So the predicate lives here where
9
+ // tests can drive it, and launch.mjs keeps the wording and the exit.
10
+ //
11
+ // `os` is an npm INSTALL GATE, evaluated against the ROOT package being installed. npm
12
+ // rejects with EBADPLATFORM before it resolves anything, which is why a platform left off
13
+ // the list does not degrade — it fails the install outright, and in plugin mode that install
14
+ // is on the path of every first MCP launch after an update (issue #28).
15
+ //
16
+ // Node built-ins only: this runs BEFORE `npm install`, so node_modules may not exist yet.
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ /**
22
+ * npm's own `checkList` from npm-install-checks, reproduced rather than approximated.
23
+ *
24
+ * The rule is not "is it in the list": an entry may be NEGATED with `!`, and a list of
25
+ * nothing but negations allows every platform it does not name. `list.includes(platform)`
26
+ * agrees with npm on the simple case and disagrees on `["!win32"]` — where it would tell a
27
+ * linux user their platform is unsupported while npm installs happily. A diagnostic that is
28
+ * wrong in that direction is worse than none.
29
+ *
30
+ * @param {string[]|string} list The `os` value as declared.
31
+ * @param {string} platform A `process.platform` value.
32
+ * @returns {boolean} True when npm would allow the install.
33
+ */
34
+ export function platformAllowed(list, platform) {
35
+ const entries = typeof list === 'string' ? [list] : list;
36
+ if (!Array.isArray(entries)) return true;
37
+ if (entries.length === 1 && entries[0] === 'any') return true;
38
+ let negated = 0;
39
+ let match = false;
40
+ for (const entry of entries) {
41
+ const negate = typeof entry === 'string' && entry.startsWith('!');
42
+ const test = negate ? entry.slice(1) : entry;
43
+ if (negate) {
44
+ negated++;
45
+ if (platform === test) return false;
46
+ } else if (platform === test) {
47
+ match = true;
48
+ }
49
+ }
50
+ // An all-negation list (and, by the same arithmetic, an empty one) allows anything it
51
+ // has not vetoed above.
52
+ return match || negated === entries.length;
53
+ }
54
+
55
+ /**
56
+ * The `os` list `<root>/package.json` declares, or null when there is nothing to enforce.
57
+ *
58
+ * Fails OPEN on every error. A missing or torn package.json is the incomplete-install case
59
+ * that launch-preflight.mjs already diagnoses with a repair command; turning it into a
60
+ * platform complaint here would replace a good message with a wrong one.
61
+ *
62
+ * @param {string} root Directory holding package.json.
63
+ * @returns {string[]|null}
64
+ */
65
+ export function readDeclaredPlatforms(root) {
66
+ const p = join(root, 'package.json');
67
+ if (!existsSync(p)) return null;
68
+ try {
69
+ const os = JSON.parse(readFileSync(p, 'utf8'))?.os;
70
+ if (typeof os === 'string') return [os];
71
+ return Array.isArray(os) ? os : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Would npm refuse to install this package on this platform?
79
+ *
80
+ * @param {{root: string, platform?: string}} opts
81
+ * @returns {{blocked: boolean, declared: string[]|null, platform: string}}
82
+ */
83
+ export function platformGate({ root, platform = process.platform }) {
84
+ const declared = readDeclaredPlatforms(root);
85
+ if (declared === null) return { blocked: false, declared: null, platform };
86
+ return { blocked: !platformAllowed(declared, platform), declared, platform };
87
+ }