claude-mem-lite 6.25.0 → 6.25.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.25.0",
12
+ "version": "6.25.1",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.0",
3
+ "version": "6.25.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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/hook-update.mjs CHANGED
@@ -828,11 +828,15 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
828
828
  // updater that was killed mid-swap (it applies the same staleness bound).
829
829
  const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
830
830
  // Intent journal, written INSIDE the backup dir before each rename. On a hard kill
831
- // the backup dir survives (every normal exit deletes it) and this file says exactly
831
+ // the backup dir survives (a normal exit deletes it, unless its journal cannot be
832
+ // removed, D#283) and this file says exactly
832
833
  // which paths were in flight, so the next entry can finish the rollback at the right
833
834
  // granularity — a bare directory walk cannot tell a nested relPath from a directory
834
835
  // relPath like `node_modules`.
835
- const SWAP_JOURNAL = '.swap-journal.json';
836
+ const SWAP_JOURNAL = '.swap-journal.json'; // doctor and cleanup spell it SWAP_JOURNAL_NAME (lib/doctor-stale-temp.mjs)
837
+ // Written into the backup dir when its swap is over (committed, or rolled back): never replay it
838
+ // (D#292 C). Doctor and cleanup spell it SWAP_RESOLVED_NAME.
839
+ const SWAP_RESOLVED = '.swap-resolved';
836
840
 
837
841
  function markSwapStart() {
838
842
  try {
@@ -856,19 +860,131 @@ function clearSwapMarker() {
856
860
  // backup dir but nothing records it — recovery would then delete the backup dir with
857
861
  // the only copy of that file inside it. rollbackInstall guards every entry with
858
862
  // existsSync/force, so an intent that never happened is a harmless no-op.
863
+ //
864
+ // By temp file and rename, and it throws (D#289). writeFileSync truncates the journal before it
865
+ // writes it, so a kill or ENOSPC in between left an empty journal, which recovery read as "nothing
866
+ // moved" and then deleted the backups. A write error was swallowed, so the rename it was meant to
867
+ // record went ahead unrecorded. Now the previous journal stays whole until the new one replaces
868
+ // it, and a swap that cannot journal stops before the rename, through the rollback in the caller.
869
+ //
870
+ // Retried for a moment on EPERM/EBUSY/EACCES: the rename replaces a file just written, about twice
871
+ // per switched path, and on Windows a scanner holding that file makes a rename over it fail
872
+ // briefly. Each failure stopped the update and rolled it back, and a rollback is where a restore
873
+ // can fail (D#292 A). Bounded at about 0.7 s, then it throws as before.
874
+ const JOURNAL_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
859
875
  function journalSwap(backupDir, backedUp, installed) {
876
+ const data = JSON.stringify({ backedUp, installed });
877
+ for (let attempt = 1; ; attempt++) {
878
+ try {
879
+ atomicWriteFileSync(join(backupDir, SWAP_JOURNAL), data);
880
+ return;
881
+ } catch (e) {
882
+ if (attempt >= 8 || !JOURNAL_RETRY_CODES.has(e.code)) throw e;
883
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
884
+ }
885
+ }
886
+ }
887
+
888
+ // Clean up after a swap that is over: committed, or rolled back. Never throws, because a cleanup
889
+ // that throws reaches the caller's catch, which rolls the swap back again (D#283; EBUSY is
890
+ // ordinary on Windows and `force` does not suppress it).
891
+ //
892
+ // The dir is marked resolved first (D#292 C). A journal left behind (it could not be removed, or a
893
+ // kill came first) was replayed by the next recovery: over a committed swap that put the previous
894
+ // release back, and over a rolled-back one whose journal outlived a later swap it deleted a path
895
+ // that swap had installed. Recovery, install, cleanup and doctor leave a resolved dir's journal
896
+ // alone. The marker is a new empty file, so it needs no access to the journal a failed removal
897
+ // could not get. With the marker written, a journal that stays is inert and the backups go anyway,
898
+ // child by child around the journal and the marker: a recursive removal of the dir takes children
899
+ // in directory order and stops at the first failure, so it could take the marker and then stop at
900
+ // the journal (v6.25.1 delta review). If the marker cannot be written either, the journal still
901
+ // goes before the backups, and if it stays, so do the backups, complete, so that a replay restores
902
+ // one whole release.
903
+ function discardBackupDir(backupDir) {
904
+ let resolved = false;
905
+ try {
906
+ writeFileSync(join(backupDir, SWAP_RESOLVED), '');
907
+ resolved = true;
908
+ } catch (e) {
909
+ debugCatch(e, 'mark-swap-resolved');
910
+ }
911
+ try {
912
+ rmSync(join(backupDir, SWAP_JOURNAL), { force: true });
913
+ } catch (e) {
914
+ debugCatch(e, 'discard-swap-journal');
915
+ if (resolved) removeBackupsBeside(backupDir);
916
+ return;
917
+ }
918
+ try {
919
+ rmSync(backupDir, { recursive: true, force: true });
920
+ } catch (e) {
921
+ debugCatch(e, 'discard-backup-dir');
922
+ }
923
+ }
924
+
925
+ function removeBackupsBeside(backupDir) {
926
+ let names = [];
927
+ try {
928
+ names = readdirSync(backupDir);
929
+ } catch (e) {
930
+ debugCatch(e, 'discard-backups-list');
931
+ }
932
+ for (const name of names) {
933
+ if (name === SWAP_JOURNAL || name === SWAP_RESOLVED) continue;
934
+ try {
935
+ rmSync(join(backupDir, name), { recursive: true, force: true });
936
+ } catch (e) {
937
+ debugCatch(e, 'discard-backup');
938
+ }
939
+ }
940
+ }
941
+
942
+ // Mark resolved the backup dirs a swap found left over and unfinished (recovery leaves one whose
943
+ // journal it cannot read right now). Called once that swap has committed: it replaced every path
944
+ // it switches, so a later replay of an older journal, once it can be read, would only mix the
945
+ // releases back (v6.25.1 delta review). The same retirement install does after writing the tree.
946
+ function retireUnresolvedBackups(targetDir, names) {
947
+ for (const name of names) {
948
+ try {
949
+ writeFileSync(join(targetDir, name, SWAP_RESOLVED), '');
950
+ debugLog('WARN', 'hook-update', `Retired ${name}: the committed swap replaced what its journal names`);
951
+ } catch (e) {
952
+ debugCatch(e, 'retire-unresolved-backup');
953
+ }
954
+ }
955
+ }
956
+
957
+ // The `.update-backup-*` dirs in targetDir that carry no resolved marker.
958
+ function unresolvedBackups(targetDir) {
959
+ try {
960
+ return readdirSync(targetDir, { withFileTypes: true })
961
+ .filter((e) => e.isDirectory() && e.name.startsWith('.update-backup-'))
962
+ .map((e) => e.name)
963
+ .filter((name) => !existsSync(join(targetDir, name, SWAP_RESOLVED)));
964
+ } catch (e) {
965
+ debugCatch(e, 'list-unresolved-backups');
966
+ return [];
967
+ }
968
+ }
969
+
970
+ // The backup dir is resolved before staging goes: removing a whole staging tree takes long enough
971
+ // for a kill to land in, and a kill there left a committed swap's journal to be replayed.
972
+ function discardSwapDirs(stagingDir, backupDir) {
973
+ discardBackupDir(backupDir);
860
974
  try {
861
- writeFileSync(join(backupDir, SWAP_JOURNAL), JSON.stringify({ backedUp, installed }));
975
+ rmSync(stagingDir, { recursive: true, force: true });
862
976
  } catch (e) {
863
- debugCatch(e, 'journalSwap');
977
+ debugCatch(e, 'discard-staging-dir');
864
978
  }
865
979
  }
866
980
 
867
981
  /**
868
982
  * Finish any swap a previous process was killed in the middle of, then clear its
869
983
  * residue. Called on every install entry, under the install lock, BEFORE a new
870
- * staging/backup pair is created.
871
- * @returns {number} number of interrupted swaps rolled back
984
+ * staging/backup pair is created; and by install.mjs, under the same lock, before
985
+ * `install` (and so `repair`) writes the tree and from `cleanup` (D#289).
986
+ * @returns {number} number of backup dirs processed, with a journal or without; one left in place
987
+ * because its journal cannot be read is not counted
872
988
  */
873
989
  export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
874
990
  let entries;
@@ -895,21 +1011,43 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
895
1011
  }
896
1012
  if (!entry.name.startsWith('.update-backup-')) continue;
897
1013
 
898
- let journal;
899
- try {
900
- journal = JSON.parse(readFileSync(join(dir, SWAP_JOURNAL), 'utf8'));
901
- } catch {
902
- journal = null;
1014
+ // Its swap is over and only its cleanup failed: remove what is left, never replay it (D#292 C).
1015
+ if (existsSync(join(dir, SWAP_RESOLVED))) {
1016
+ discardBackupDir(dir);
1017
+ recovered++;
1018
+ continue;
903
1019
  }
904
- const backedUp = Array.isArray(journal?.backedUp) ? journal.backedUp : [];
905
- const installed = Array.isArray(journal?.installed) ? journal.installed : [];
906
- // Copies: rollbackInstall reverses the arrays in place.
907
- rollbackInstall([...installed], [...backedUp], dir, targetDir);
1020
+
1021
+ // No journal: the swap is over and its journal is gone (or it was killed before its first
1022
+ // rename), so there is nothing to replay and the dir is residue. A journal that is there but
1023
+ // cannot be read, or not as one (an in-place write of 6.25.0 or older torn by a kill), says
1024
+ // nothing about what moved, and the dir may hold the only copy of it: leave it. It used to be
1025
+ // read as "nothing moved" and deleted (v6.25.1 review). doctor and cleanup name it; the next
1026
+ // swap that commits, or an install, retires it.
1027
+ let journal = { backedUp: [], installed: [] };
1028
+ let raw = null;
908
1029
  try {
909
- rmSync(dir, { recursive: true, force: true });
1030
+ raw = readFileSync(join(dir, SWAP_JOURNAL), 'utf8');
910
1031
  } catch (e) {
911
- debugCatch(e, 'recover-backup');
1032
+ if (e.code !== 'ENOENT') {
1033
+ debugCatch(e, 'recover-read-journal');
1034
+ continue;
1035
+ }
1036
+ }
1037
+ if (raw !== null) {
1038
+ try {
1039
+ journal = JSON.parse(raw);
1040
+ } catch {
1041
+ journal = null;
1042
+ }
1043
+ if (!Array.isArray(journal?.backedUp) || !Array.isArray(journal?.installed)) {
1044
+ debugLog('WARN', 'hook-update', `Left ${entry.name} in place: its swap journal cannot be read`);
1045
+ continue;
1046
+ }
912
1047
  }
1048
+ const { backedUp, installed } = journal;
1049
+ rollbackInstall(installed, backedUp, dir, targetDir);
1050
+ discardBackupDir(dir);
913
1051
  recovered++;
914
1052
  debugLog(
915
1053
  'WARN',
@@ -923,17 +1061,27 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
923
1061
  // A rollback swaps a file SET back just as the install swapped it in, so it holds the same
924
1062
  // marker for the same reason (D#239 g). After the MED-5 smoke gate the loop has already cleared
925
1063
  // it; after a throw inside the loop it is still set, and this call takes it over.
1064
+ //
1065
+ // Safe to run twice over one journal (D#283). A backed-up path whose backup is gone holds the OLD
1066
+ // file already, restored by an earlier rollback or never moved out, so it is not deleted: a
1067
+ // second rollback used to delete every installed path, by then the only copy of the old install.
1068
+ // That holds for a journal this code wrote: its cleanup removes the journal before any backup, and
1069
+ // when the journal cannot be removed, the resolved marker keeps it from being replayed. A backup
1070
+ // dir 6.25.0 or older left half deleted beside its journal can still replay to a mix.
1071
+ // The arrays are copied, not reversed in place, so a caller's journal is left as it was.
926
1072
  function rollbackInstall(installed, backedUp, backupDir, targetDir) {
927
1073
  markSwapStart();
928
1074
  try {
929
- for (const relPath of installed.reverse()) {
1075
+ const restored = new Set(backedUp.filter((relPath) => !existsSync(join(backupDir, relPath))));
1076
+ for (const relPath of [...installed].reverse()) {
1077
+ if (restored.has(relPath)) continue;
930
1078
  try {
931
1079
  rmSync(join(targetDir, relPath), { recursive: true, force: true });
932
1080
  } catch {
933
1081
  /* best-effort */
934
1082
  }
935
1083
  }
936
- for (const relPath of backedUp.reverse()) {
1084
+ for (const relPath of [...backedUp].reverse()) {
937
1085
  const backupPath = join(backupDir, relPath);
938
1086
  const targetPath = join(targetDir, relPath);
939
1087
  try {
@@ -1049,6 +1197,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1049
1197
  const backupDir = join(targetDir, `.update-backup-${ts}`);
1050
1198
  const backedUp = [];
1051
1199
  const installed = [];
1200
+ let committed = false;
1052
1201
 
1053
1202
  const manifest = await loadReleaseManifest(sourceDir);
1054
1203
  const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
@@ -1058,6 +1207,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1058
1207
  // one — otherwise this install stacks on top of a mixed-version tree and its
1059
1208
  // own backup can no longer restore a coherent state.
1060
1209
  recoverInterruptedSwaps(targetDir);
1210
+ // What recovery left unfinished (a journal it cannot read right now) is retired if this swap
1211
+ // commits, and left for the next entry if it rolls back.
1212
+ const leftUnresolved = unresolvedBackups(targetDir);
1061
1213
 
1062
1214
  mkdirSync(stagingDir, { recursive: true });
1063
1215
  mkdirSync(backupDir, { recursive: true });
@@ -1106,13 +1258,14 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1106
1258
  // keeps working rather than leaving a broken install with no way back.
1107
1259
  if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
1108
1260
  rollbackInstall(installed, backedUp, backupDir, targetDir);
1109
- rmSync(stagingDir, { recursive: true, force: true });
1110
- rmSync(backupDir, { recursive: true, force: true });
1261
+ discardSwapDirs(stagingDir, backupDir);
1111
1262
  return false;
1112
1263
  }
1113
1264
 
1114
- rmSync(stagingDir, { recursive: true, force: true });
1115
- rmSync(backupDir, { recursive: true, force: true });
1265
+ // Committed: from here on the catch below must not roll back (D#283).
1266
+ committed = true;
1267
+ retireUnresolvedBackups(targetDir, leftUnresolved);
1268
+ discardSwapDirs(stagingDir, backupDir);
1116
1269
 
1117
1270
  // Post-update migration: reconcile settings.json against the release we just
1118
1271
  // swapped in. `configureHooks()` already strips stale mem entries — but its only
@@ -1198,17 +1351,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1198
1351
  return true;
1199
1352
  } catch (err) {
1200
1353
  debugCatch(err, 'installExtractedRelease');
1354
+ // Past the commit point the backups are gone and the new release IS the install: a rollback
1355
+ // would delete the paths it added and restore nothing (D#283).
1356
+ if (committed) return true;
1201
1357
  rollbackInstall(installed, backedUp, backupDir, targetDir);
1202
- try {
1203
- rmSync(stagingDir, { recursive: true, force: true });
1204
- } catch {
1205
- /* best-effort */
1206
- }
1207
- try {
1208
- rmSync(backupDir, { recursive: true, force: true });
1209
- } catch {
1210
- /* best-effort */
1211
- }
1358
+ discardSwapDirs(stagingDir, backupDir);
1212
1359
  return false;
1213
1360
  } finally {
1214
1361
  release();
package/install.mjs CHANGED
@@ -73,10 +73,12 @@ import { doctorDbModeHint } from './lib/doctor-modes.mjs';
73
73
  import { checkHookInterpreter } from './lib/doctor-hook-interpreter.mjs';
74
74
  import {
75
75
  classifyEpisodeFile,
76
+ classifyUpdateResidue,
76
77
  EPISODE_AGE_LABEL,
77
78
  isEpisodeResidue,
78
79
  isUpdateResidue,
79
80
  scanStaleTempFiles,
81
+ SWAP_RESOLVED_NAME,
80
82
  } from './lib/doctor-stale-temp.mjs';
81
83
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
82
84
 
@@ -1298,6 +1300,115 @@ function offerCleanOldVectorDb() {
1298
1300
  }
1299
1301
  }
1300
1302
 
1303
+ // A path, a dangling symlink included (existsSync follows the link).
1304
+ function pathPresent(p) {
1305
+ try {
1306
+ lstatSync(p);
1307
+ return true;
1308
+ } catch {
1309
+ return false;
1310
+ }
1311
+ }
1312
+
1313
+ // Update residue in the code dir, each entry with what it is (lib/doctor-stale-temp.mjs).
1314
+ function listUpdateResidue() {
1315
+ return readdirSync(INSTALL_DIR)
1316
+ .map((f) => ({ f, kind: classifyUpdateResidue(INSTALL_DIR, f) }))
1317
+ .filter((r) => r.kind);
1318
+ }
1319
+
1320
+ /**
1321
+ * Finish what interrupted updates left in the code dir, the way the next update entry would:
1322
+ * hook-update's recoverInterruptedSwaps replays each journal, then removes the residue. Reports
1323
+ * every entry by what became of it. The caller holds install.lock.
1324
+ *
1325
+ * Never throws: install calls it before writing the tree, and a recovery that threw would stop
1326
+ * the install it exists to protect. If hook-update cannot load, the residue of finished swaps is
1327
+ * still removed and an unfinished swap is left whole (for install, which retires it once the
1328
+ * tree is written, or for a later entry that can replay it).
1329
+ * @param {{f: string, kind: string}[]} found entries from listUpdateResidue
1330
+ * @returns {Promise<{finished: string[], removed: string[], kept: string[], deferred: string[],
1331
+ * left: string[]}>} kept: left on purpose because its journal cannot be read; deferred: an
1332
+ * unfinished swap left because hook-update could not load; left: anything else still there
1333
+ */
1334
+ async function recoverUpdateResidue(found) {
1335
+ let replayed = true;
1336
+ try {
1337
+ const { recoverInterruptedSwaps } = await import('./hook-update.mjs');
1338
+ recoverInterruptedSwaps(INSTALL_DIR);
1339
+ } catch (e) {
1340
+ replayed = false;
1341
+ warn(`Could not finish an interrupted update here (${e.message}) — it is left in place`);
1342
+ }
1343
+ // Leftovers recovery did not take: a file or a symlink with the name (it replays only real
1344
+ // directories), or everything when hook-update could not load. Only 'stale' ones go.
1345
+ for (const { f, kind } of found) {
1346
+ if (kind !== 'stale' || !pathPresent(join(INSTALL_DIR, f))) continue;
1347
+ try {
1348
+ rmSync(join(INSTALL_DIR, f), { recursive: true, force: true });
1349
+ } catch {
1350
+ /* reported below as left */
1351
+ }
1352
+ }
1353
+ const out = { finished: [], removed: [], kept: [], deferred: [], left: [] };
1354
+ for (const { f, kind } of found) {
1355
+ if (!pathPresent(join(INSTALL_DIR, f))) (kind === 'unfinished-swap' ? out.finished : out.removed).push(f);
1356
+ else if (kind === 'unreadable-journal') out.kept.push(f);
1357
+ else if (kind === 'unfinished-swap' && !replayed) out.deferred.push(f);
1358
+ else out.left.push(f);
1359
+ }
1360
+ return out;
1361
+ }
1362
+
1363
+ // A swap an updater was killed in the middle of is finished BEFORE this install writes the tree
1364
+ // (D#289). Its journal names an older release's paths; left for the next update entry, it was
1365
+ // replayed over the tree written here: it deleted the files the killed swap had added and put the
1366
+ // older ones back. Runs before the swap barrier, because the recovery arms and clears that marker.
1367
+ async function finishInterruptedSwaps() {
1368
+ let found;
1369
+ try {
1370
+ found = listUpdateResidue();
1371
+ } catch {
1372
+ return; // no code dir yet, so nothing to finish
1373
+ }
1374
+ if (!found.some((r) => r.kind === 'unfinished-swap')) return;
1375
+ const r = await recoverUpdateResidue(found);
1376
+ if (r.finished.length > 0)
1377
+ ok(`Finished ${r.finished.length} interrupted update(s) before installing: ${r.finished.join(', ')}`);
1378
+ // A swap left unfinished here (deferred, kept) is retired once the tree is written.
1379
+ for (const f of r.left) warn(`Could not remove ${f} from ${INSTALL_DIR}`);
1380
+ }
1381
+
1382
+ const isUnresolvedSwap = (f) => {
1383
+ const kind = classifyUpdateResidue(INSTALL_DIR, f);
1384
+ return kind === 'unfinished-swap' || kind === 'unreadable-journal';
1385
+ };
1386
+
1387
+ // A journal still unresolved once deployCodeTree has written the code tree names paths of a
1388
+ // release this install has just replaced, and a later update entry would replay it over this tree:
1389
+ // delete the files the install wrote and put that release's back (v6.25.1 review; reached when
1390
+ // hook-update cannot load, or a journal cannot be read or removed). Marked resolved, it is never
1391
+ // replayed, and cleanup removes it as leftover. node_modules is written after this, by
1392
+ // installDependencies: if that fails, a node_modules the retired dir held is not put back (D#293).
1393
+ function retireLeftoverJournals() {
1394
+ let names;
1395
+ try {
1396
+ names = readdirSync(INSTALL_DIR).filter(isUnresolvedSwap);
1397
+ } catch {
1398
+ return;
1399
+ }
1400
+ for (const f of names) {
1401
+ try {
1402
+ writeFileSync(join(INSTALL_DIR, f, SWAP_RESOLVED_NAME), '');
1403
+ ok(`Retired ${f}: this install replaced the release it belongs to (cleanup removes it)`);
1404
+ } catch (e) {
1405
+ warn(
1406
+ `Could not retire ${f} (${e.code || e.message}): delete it, or the next update puts back the files it holds`,
1407
+ );
1408
+ }
1409
+ }
1410
+ }
1411
+
1301
1412
  async function install() {
1302
1413
  console.log('\nclaude-mem-lite installer\n');
1303
1414
  // Refuse an unparseable settings.json BEFORE the first side effect (throws
@@ -1310,12 +1421,14 @@ async function install() {
1310
1421
  const IS_DEV = flags.has('--dev');
1311
1422
 
1312
1423
  prepareInstallDirs();
1424
+ await finishInterruptedSwaps();
1313
1425
  // Both steps rewrite the tree hooks import — the code, then node_modules — so one swap
1314
1426
  // barrier spans them (D#223), for its first two minutes: the launcher ignores an older marker,
1315
1427
  // and npm install has no timeout. installDependencies may process.exit(1): the marker it leaves
1316
1428
  // names a dead pid, which the launcher ignores.
1317
1429
  await withSwapBarrier(async () => {
1318
1430
  deployCodeTree(IS_DEV);
1431
+ retireLeftoverJournals();
1319
1432
  await installDependencies(IS_DEV);
1320
1433
  });
1321
1434
  createCliSymlink();
@@ -1742,14 +1855,22 @@ async function status() {
1742
1855
  }
1743
1856
 
1744
1857
  // Database. A data dir this process cannot enter reads as an empty one (D#199), and
1745
- // `exists: false` would be a guess — null says nobody could look.
1858
+ // `exists: false` would be a guess — null says nobody could look. One it can read but not write
1859
+ // (the 755 a `sudo` run leaves owned by root) is read where SQLite can open the store there: its
1860
+ // counts are real, under a ✗ (D#284). A closed WAL store there cannot be opened; see the catch.
1746
1861
  const dataDirDenied = dataDirAccessError();
1747
- if (dataDirDenied) {
1862
+ const dataDirUnreadable = dataDirDenied
1863
+ ? dataDirAccessError(MEM_DATA_DIR, fsConstants.R_OK | fsConstants.X_OK)
1864
+ : null;
1865
+ const unwritable = dataDirDenied
1866
+ ? ` — ${MEM_DATA_DIR} can be read but not written (${dataDirDenied}). Fix: ${dataDirAccessRemedy(dataDirDenied)}`
1867
+ : '';
1868
+ if (dataDirUnreadable) {
1748
1869
  push(
1749
1870
  'fail',
1750
1871
  'database',
1751
- `Database: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — Fix: ${dataDirAccessRemedy()}`,
1752
- { exists: null, error: dataDirDenied },
1872
+ `Database: ${MEM_DATA_DIR} is not accessible (${dataDirUnreadable}) — Fix: ${dataDirAccessRemedy(dataDirUnreadable)}`,
1873
+ { exists: null, error: dataDirUnreadable },
1753
1874
  );
1754
1875
  } else if (existsSync(DB_PATH)) {
1755
1876
  try {
@@ -1759,19 +1880,36 @@ async function status() {
1759
1880
  // DISTINCT, like stats: a session can own several summary rows (legacy duplicates).
1760
1881
  const sess = db.prepare('SELECT COUNT(DISTINCT memory_session_id) as c FROM session_summaries').get();
1761
1882
  db.close();
1762
- push('ok', 'database', `Database: ${obs.c} observations, ${sess.c} sessions`, {
1763
- exists: true,
1764
- observations: obs.c,
1765
- sessions: sess.c,
1766
- });
1883
+ push(
1884
+ dataDirDenied ? 'fail' : 'ok',
1885
+ 'database',
1886
+ `Database: ${obs.c} observations, ${sess.c} sessions${unwritable}`,
1887
+ {
1888
+ exists: true,
1889
+ observations: obs.c,
1890
+ sessions: sess.c,
1891
+ ...(dataDirDenied && { error: dataDirDenied }),
1892
+ },
1893
+ );
1767
1894
  } catch (e) {
1768
- push('warn', 'database', 'Database: exists but check failed — ' + e.message, {
1895
+ // The store is WAL (schema.mjs), and even a read-only open needs its -wal and -shm files, which
1896
+ // SQLite creates beside the DB: in a directory it cannot write, a closed store cannot be opened
1897
+ // in place (a copy elsewhere could be read) — 6.24.0 could not open it either. Say so rather than relay "attempt to write a readonly database".
1898
+ // The errno stands in for SQLite's message only where the directory is the cause.
1899
+ const walBlocked = dataDirDenied && e.code === 'SQLITE_READONLY_DIRECTORY';
1900
+ const why = walBlocked
1901
+ ? 'exists, but SQLite cannot open a WAL database without creating its -wal/-shm files beside it'
1902
+ : 'exists but check failed — ' + e.message;
1903
+ push(dataDirDenied ? 'fail' : 'warn', 'database', `Database: ${why}${unwritable}`, {
1769
1904
  exists: true,
1770
- error: e.message,
1905
+ error: walBlocked ? dataDirDenied : e.message,
1771
1906
  });
1772
1907
  }
1773
1908
  } else {
1774
- push('warn', 'database', 'Database: not found', { exists: false });
1909
+ push(dataDirDenied ? 'fail' : 'warn', 'database', 'Database: not found' + unwritable, {
1910
+ exists: false,
1911
+ ...(dataDirDenied && { error: dataDirDenied }),
1912
+ });
1775
1913
  }
1776
1914
 
1777
1915
  // CLI.
@@ -1990,12 +2128,21 @@ async function doctor() {
1990
2128
  fail(
1991
2129
  `Data directory: ${MEM_DATA_DIR} is not accessible (${dataDirDenied}) — ` +
1992
2130
  (dataDirUnreadable ? 'checks that read it say "not checked"' : 'it can be read but not written') +
1993
- `. Fix: ${dataDirAccessRemedy()}`,
2131
+ `. Fix: ${dataDirAccessRemedy(dataDirDenied)}`,
1994
2132
  );
1995
2133
  issues++;
1996
2134
  }
1997
2135
  const notCheckedDenied = (what, dir = MEM_DATA_DIR) =>
1998
2136
  dwarn(`${what}: not checked — ${dir} is not accessible`);
2137
+ // A closed WAL store in a data dir that can be read but not written: even a read-only open has to
2138
+ // create the -wal/-shm files beside it, so every check that opens the DB fails, for the fault the
2139
+ // Data directory line names and counts. Those checks say they did not look, in the words status
2140
+ // uses (D#287, the D#199 shape). Keyed on SQLite's code: its message is shared with other faults.
2141
+ const walBlocked = (code) => Boolean(dataDirDenied) && code === 'SQLITE_READONLY_DIRECTORY';
2142
+ const notCheckedWal = (what, scope = '') =>
2143
+ dwarn(
2144
+ `${what}: not checked${scope} — SQLite cannot open a WAL database without creating its -wal/-shm files beside it, and ${MEM_DATA_DIR} cannot be written (see Data directory above)`,
2145
+ );
1999
2146
  // The code dir is the data dir in the default shape; under CLAUDE_MEM_DIR it is checked apart.
2000
2147
  const codeIsDataDir = sameDir(INSTALL_DIR, MEM_DATA_DIR);
2001
2148
  const codeDirDenied = codeIsDataDir ? dataDirUnreadable : dataDirAccessError(INSTALL_DIR, readMode);
@@ -2016,7 +2163,10 @@ async function doctor() {
2016
2163
  // process.
2017
2164
  const rootProbes = probeRuntimeRoots(shape.runtimeRoots);
2018
2165
  const brokenRoots = rootProbes.filter((r) => !r.ok);
2019
- if (rootProbes.length === 0) {
2166
+ if (rootProbes.length === 0 && codeDirDenied) {
2167
+ // The code home is locked, so it may own a binding nobody could look at (D#284 review P3-4).
2168
+ notCheckedDenied('better-sqlite3', INSTALL_DIR);
2169
+ } else if (rootProbes.length === 0) {
2020
2170
  fail('better-sqlite3: no install on this machine owns a native binding — nothing here can open the DB');
2021
2171
  issues++;
2022
2172
  } else if (brokenRoots.length === 0) {
@@ -2064,6 +2214,8 @@ async function doctor() {
2064
2214
  notCheckedDenied('DB schema');
2065
2215
  } else if (!existsSync(DB_PATH)) {
2066
2216
  ok('DB schema: no database yet — nothing to compare');
2217
+ } else if (rootProbes.length === 0 && codeDirDenied) {
2218
+ notCheckedDenied('DB schema', INSTALL_DIR); // the locked code home may own one (D#284 review)
2067
2219
  } else if (rootProbes.length === 0) {
2068
2220
  // The fourth outcome the first cut had and did not print. The `fail` above already tells
2069
2221
  // the reader no install owns a binding, but a block whose stated design point is "three
@@ -2135,7 +2287,8 @@ async function doctor() {
2135
2287
  // printed as a green line is the defect the v6.2.0 round wrote and its pre-ship review
2136
2288
  // caught before the tag — a check that says "nothing to check" and "I could not look"
2137
2289
  // in the same voice ends the reader's search instead of directing it.
2138
- dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
2290
+ if (walBlocked(u.errorCode)) notCheckedWal('DB schema', ` for ${u.label}`);
2291
+ else dwarn(`DB schema: could not determine compatibility for ${u.label} (${u.error})`);
2139
2292
  }
2140
2293
  }
2141
2294
 
@@ -2220,7 +2373,11 @@ async function doctor() {
2220
2373
  // is 6h-rate-limited stderr nobody reads), the live probe says "is it broken
2221
2374
  // right now". A Node upgrade breaks every DB-touching path at once, so this is
2222
2375
  // the single highest-value line in doctor when it fires.
2223
- const breakage = readNativeBindingBreakage(MEM_RUNTIME_DIR);
2376
+ // The marker sits in the runtime dir: in a locked data dir it reads as absent (D#284).
2377
+ const breakageUnread = dataDirUnreadable && runtimeInDataDir;
2378
+ const breakage = breakageUnread ? null : readNativeBindingBreakage(MEM_RUNTIME_DIR);
2379
+ const breakageSeen = (b) =>
2380
+ `~${Math.round((Date.now() - (b.ts || 0)) / 3600000)}h ago (${b.reason || 'unknown'})`;
2224
2381
  // Reuses the per-root probes above — same trees, same question, and doctor
2225
2382
  // should not pay for another round of child spawns to ask it twice.
2226
2383
  if (brokenRoots.length > 0) {
@@ -2228,10 +2385,20 @@ async function doctor() {
2228
2385
  `Native DB binding: unusable in ${brokenRoots.map((b) => b.label).join(', ')} — run \`node ${shellWord(join(PROJECT_DIR, 'cli.mjs'))} rebuild-binding\` (repairs every broken install, not just this one)`,
2229
2386
  );
2230
2387
  issues++;
2388
+ } else if (rootProbes.length === 0) {
2389
+ // Nothing was probed: a ✓ here sat under the better-sqlite3 ✗ for the same machine. Said, not
2390
+ // left silent, like DB schema's fourth outcome above; a marker it could read still shows.
2391
+ const why = codeDirDenied
2392
+ ? `${INSTALL_DIR} is not accessible`
2393
+ : 'no install on this machine owns a native binding to probe';
2394
+ dwarn(
2395
+ `Native DB binding: not checked — ${why}${breakage ? `; a fire failed ${breakageSeen(breakage)}` : ''}`,
2396
+ );
2397
+ } else if (breakageUnread) {
2398
+ notCheckedDenied(`Native DB binding: loadable on Node ${process.version}, past failures`);
2231
2399
  } else if (breakage) {
2232
- const ageH = Math.round((Date.now() - (breakage.ts || 0)) / 3600000);
2233
2400
  dwarn(
2234
- `Native DB binding: healthy now, but a fire failed ~${ageH}h ago (${breakage.reason || 'unknown'}) — stale marker clears on the next successful rebuild-binding`,
2401
+ `Native DB binding: healthy now, but a fire failed ${breakageSeen(breakage)} — stale marker clears on the next successful rebuild-binding`,
2235
2402
  );
2236
2403
  } else {
2237
2404
  ok(`Native DB binding: loadable on Node ${process.version}`);
@@ -2474,24 +2641,37 @@ async function doctor() {
2474
2641
  dwarn('FTS5 index: missing (will be created on server start)');
2475
2642
  }
2476
2643
  } catch (e) {
2477
- fail('Database: ' + e.message);
2478
- // Every other ✗ on this screen carries a remedy; this one used to be the exception,
2479
- // and a corrupt store is the failure a user is least able to diagnose unaided.
2480
- // dbCheckRemedy returns null rather than invent one for an error it cannot classify.
2481
- // Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
2482
- // its failure mode from load time into THIS catch — and an unhandled rejection here
2483
- // aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
2484
- // on the compound shape "a file is missing AND the database will not open". A remedy
2485
- // is an extra sentence on a check that has already failed; never let it take the run.
2486
- let remedy = null;
2487
- try {
2488
- remedy = await dbCheckRemedy(DB_PATH, e);
2489
- } catch (remedyErr) {
2490
- log(` (could not build a repair hint: ${remedyErr.message})`);
2644
+ if (walBlocked(e.code)) {
2645
+ notCheckedWal('Database');
2646
+ } else {
2647
+ fail('Database: ' + e.message);
2648
+ // Every other ✗ on this screen carries a remedy; this one used to be the exception,
2649
+ // and a corrupt store is the failure a user is least able to diagnose unaided.
2650
+ // dbCheckRemedy returns null rather than invent one for an error it cannot classify.
2651
+ // Pre-ship review 2026-09-09: dbCheckRemedy became async and lazy (P1-1), which moved
2652
+ // its failure mode from load time into THIS catch — and an unhandled rejection here
2653
+ // aborts doctor exactly as the static import did, so `--json` still emitted zero bytes
2654
+ // on the compound shape "a file is missing AND the database will not open". A remedy
2655
+ // is an extra sentence on a check that has already failed; never let it take the run.
2656
+ let remedy = null;
2657
+ try {
2658
+ remedy = await dbCheckRemedy(DB_PATH, e);
2659
+ } catch (remedyErr) {
2660
+ log(` (could not build a repair hint: ${remedyErr.message})`);
2661
+ }
2662
+ if (remedy) log(` ${remedy}`);
2663
+ issues++;
2491
2664
  }
2492
- if (remedy) log(` ${remedy}`);
2493
- issues++;
2494
2665
  }
2666
+ } else if (rootProbes.length === 0) {
2667
+ // "will be created" beside the better-sqlite3 line's "nothing here can open the DB" was a
2668
+ // promise this screen had just contradicted (D#287). With the code home locked the binding
2669
+ // was not looked for, so neither half is claimed.
2670
+ dwarn(
2671
+ codeDirDenied
2672
+ ? 'Database: not found'
2673
+ : 'Database: not found — and nothing here can create it until an install has a native binding (see better-sqlite3 above)',
2674
+ );
2495
2675
  } else {
2496
2676
  dwarn('Database: not found (will be created)');
2497
2677
  }
@@ -2822,18 +3002,38 @@ async function doctor() {
2822
3002
  // Stale temp files. The rules live in lib/doctor-stale-temp.mjs because this scanner and
2823
3003
  // cleanup's deleter are the same question asked twice and had drifted twice — see that
2824
3004
  // file. Counting is all that differs here; the classification is shared, so "what doctor
2825
- // calls stale" and "what cleanup removes" agree on the age gate, which is the axis they
2826
- // last diverged on. Not on every axis: cleanup skips update residue entirely while
2827
- // install.lock is held and the scanner has no such gate, so mid-self-update doctor still
2828
- // counts a file cleanup will decline. That one is milder than D#53 — cleanup SAYS it is
2829
- // skipping rather than answering "No stale files found" — and it predates this change.
3005
+ // calls stale" and "what cleanup removes" agree on the age gate and on the directory, the
3006
+ // axes they diverged on (the third time, D#289: both looked for update residue in the data
3007
+ // dir, which CLAUDE_MEM_DIR moves away from the code dir it lives in). Not on every axis:
3008
+ // cleanup skips update residue entirely while install.lock is held and the scanner has no
3009
+ // such gate, so mid-self-update doctor still counts what cleanup will decline. That one is
3010
+ // milder than D#53 — cleanup SAYS it is skipping — and the unfinished-update line says a
3011
+ // running update looks the same.
2830
3012
  try {
2831
- const { stale, inFlight } = scanStaleTempFiles({
2832
- dataDir: MEM_DATA_DIR,
3013
+ const { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked } = scanStaleTempFiles({
3014
+ codeDir: INSTALL_DIR,
2833
3015
  runtimeDir: MEM_RUNTIME_DIR,
3016
+ legacyDir: codeIsDataDir ? undefined : MEM_DATA_DIR,
2834
3017
  });
2835
- if (stale > 0) {
2836
- dwarn(`Stale temp files: ${stale} found (run: node install.mjs cleanup)`);
3018
+ const installer = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))}`;
3019
+ if (unfinishedSwaps > 0) {
3020
+ dwarn(
3021
+ `Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or is running right now (run: ${installer} cleanup — it finishes an interrupted one, and skips while an update holds install.lock)`,
3022
+ );
3023
+ }
3024
+ if (unreadableJournals > 0) {
3025
+ dwarn(
3026
+ `Unfinished update: ${unreadableJournals} backup dir(s) in ${INSTALL_DIR} whose journal cannot be read, so nothing can finish that update; they hold the files it moved aside (run: ${installer} repair — it reinstalls over them; cleanup then removes them)`,
3027
+ );
3028
+ }
3029
+ if (notChecked.length > 0) {
3030
+ const where = notChecked.map((n) => `${n.dir} is not accessible (${n.code})`).join('; ');
3031
+ dwarn(
3032
+ `Stale temp files: not checked — ${where}` +
3033
+ (stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : ''),
3034
+ );
3035
+ } else if (stale > 0) {
3036
+ dwarn(`Stale temp files: ${stale} found (run: ${installer} cleanup)`);
2837
3037
  } else {
2838
3038
  ok('Stale temp files: none');
2839
3039
  }
@@ -2875,7 +3075,8 @@ async function doctor() {
2875
3075
  dwarn(`${stats} — but the install you are running cannot use this database (see DB schema above)`);
2876
3076
  else ok(stats);
2877
3077
  } catch (e) {
2878
- dwarn('DB stats: ' + e.message);
3078
+ if (walBlocked(e.code)) notCheckedWal('DB stats');
3079
+ else dwarn('DB stats: ' + e.message);
2879
3080
  }
2880
3081
  }
2881
3082
 
@@ -3229,7 +3430,7 @@ function writeSettings(settings) {
3229
3430
 
3230
3431
  // ─── Cleanup Stale Files ─────────────────────────────────────────────────────
3231
3432
 
3232
- function cleanup() {
3433
+ async function cleanup() {
3233
3434
  // Dogfood-7 addition: --dry-run lists which files would be removed without
3234
3435
  // touching disk. Useful before running cleanup on a remote/CI machine where
3235
3436
  // accidentally pruning the wrong file would be costly. Doctor reports stale
@@ -3238,39 +3439,80 @@ function cleanup() {
3238
3439
  console.log(`\nclaude-mem-lite cleanup${dryRun ? ' (--dry-run)' : ''}\n`);
3239
3440
  let removed = 0;
3240
3441
 
3241
- // Clean .update-staging-* / .update-backup-* — hook-update writes these under
3242
- // DB_DIR (= MEM_DATA_DIR, env-aware), so scan the data dir, not the homedir code dir.
3442
+ // Update residue: `.update-staging-*` / `.update-backup-*` in the CODE dir, where hook-update's
3443
+ // swap writes them. Under CLAUDE_MEM_DIR this scanned only the data dir, where nothing has written
3444
+ // them since v2.90.0; residue an older updater left there is still removed, below. A backup dir
3445
+ // that holds its journal and no resolved marker is a swap whose updater was killed, holding the
3446
+ // only copy of every file the swap had moved out: cleanup finishes it, by replaying the journal
3447
+ // the way the next update entry would, instead of deleting it (D#289).
3243
3448
  //
3244
- // R10 P2-10: take install.lock first. `.update-backup-*` is the ONLY rollback copy of an
3245
- // in-flight update and it holds the journal hook-update replays; `.update-staging-*` is
3246
- // the tree being swapped in. Deleting either mid-update leaves the install unrecoverable,
3247
- // and the window is long — it spans the source-compile fallback, up to five minutes —
3248
- // while doctor is actively telling the user to run cleanup. Non-blocking: if an installer
3249
- // holds the lock we skip only these two patterns, not the rest of cleanup.
3449
+ // R10 P2-10: take install.lock first. A backup dir of a swap running now holds a journal too,
3450
+ // and the staging dir is the tree being swapped in; touching either mid-update leaves the install
3451
+ // unrecoverable. The window is long (it spans the source-compile fallback, up to five minutes),
3452
+ // and doctor tells the user to run cleanup. Non-blocking: if an installer holds the lock we skip
3453
+ // only the update residue, not the rest of cleanup.
3454
+ let finished = 0;
3250
3455
  const updateLock = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
3251
3456
  if (!updateLock) {
3252
3457
  warn('Update residue skipped: install in progress (install.lock held)');
3253
- } else if (existsSync(MEM_DATA_DIR)) {
3254
- for (const f of readdirSync(MEM_DATA_DIR)) {
3255
- if (isUpdateResidue(f)) {
3256
- if (dryRun) {
3257
- ok(`Would remove: ${f}`);
3258
- removed++;
3259
- continue;
3458
+ } else {
3459
+ try {
3460
+ let found = [];
3461
+ try {
3462
+ found = existsSync(INSTALL_DIR) ? listUpdateResidue() : [];
3463
+ } catch (e) {
3464
+ warn(`Update residue: could not list ${INSTALL_DIR} (${e.code || e.message})`);
3465
+ }
3466
+ if (dryRun) {
3467
+ for (const { f, kind } of found) {
3468
+ if (kind === 'unfinished-swap') {
3469
+ ok(`Would finish an interrupted update: ${f} (puts back the files its journal lists)`);
3470
+ finished++;
3471
+ } else if (kind === 'unreadable-journal') {
3472
+ warn(`Would leave in place: ${f} (its journal cannot be read)`);
3473
+ } else {
3474
+ ok(`Would remove: ${f}`);
3475
+ removed++;
3476
+ }
3260
3477
  }
3261
- try {
3262
- rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
3263
- ok(`Removed: ${f}`);
3264
- removed++;
3265
- } catch (e) {
3266
- warn(`Failed to remove ${f}: ${e.message}`);
3478
+ } else if (found.length > 0) {
3479
+ const r = await recoverUpdateResidue(found);
3480
+ for (const f of r.finished)
3481
+ ok(`Finished an interrupted update: ${f} (put back the files it had moved out)`);
3482
+ for (const f of r.removed) ok(`Removed: ${f}`);
3483
+ for (const f of r.kept)
3484
+ warn(
3485
+ `Left in place: ${f} — its journal cannot be read, so nothing can finish that update, and it holds the files the update moved aside. repair reinstalls over it; cleanup removes it after that.`,
3486
+ );
3487
+ for (const f of r.left) warn(`Failed to remove ${f}`);
3488
+ finished += r.finished.length;
3489
+ removed += r.removed.length;
3490
+ }
3491
+ // A relocated data dir that an updater older than v2.90.0 swapped into: its residue is stale,
3492
+ // whatever it holds, and is never replayed (lib/doctor-stale-temp.mjs, legacyDir).
3493
+ if (!sameDir(INSTALL_DIR, MEM_DATA_DIR) && existsSync(MEM_DATA_DIR)) {
3494
+ for (const f of readdirSync(MEM_DATA_DIR)) {
3495
+ if (!isUpdateResidue(f)) continue;
3496
+ if (dryRun) {
3497
+ ok(`Would remove: ${join(MEM_DATA_DIR, f)}`);
3498
+ removed++;
3499
+ continue;
3500
+ }
3501
+ try {
3502
+ rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
3503
+ ok(`Removed: ${join(MEM_DATA_DIR, f)}`);
3504
+ removed++;
3505
+ } catch (e) {
3506
+ warn(`Failed to remove ${join(MEM_DATA_DIR, f)}: ${e.message}`);
3507
+ }
3267
3508
  }
3268
3509
  }
3510
+ } finally {
3511
+ // Released here: the rest of cleanup touches runtime scratch that no installer owns, and
3512
+ // holding install.lock across it would block a self-heal for no reason.
3513
+ updateLock();
3269
3514
  }
3270
3515
  }
3271
- // Release immediately: the rest of cleanup touches runtime scratch that no installer owns,
3272
- // and holding install.lock across it would block a self-heal for no reason.
3273
- if (updateLock) updateLock();
3274
3516
 
3275
3517
  // Clean pending-* / ep-flush-* in runtime/ (env-aware, and honouring the runtime override).
3276
3518
  //
@@ -3322,7 +3564,11 @@ function cleanup() {
3322
3564
  // start of every run (tests/global-setup.mjs → lib/tmp-fixture-sweep.mjs).
3323
3565
 
3324
3566
  const verb = dryRun ? 'would be removed' : 'removed';
3325
- console.log(`\n ${removed === 0 ? 'No stale files found.' : `${removed} stale file(s) ${verb}.`}\n`);
3567
+ const done = [];
3568
+ if (removed > 0) done.push(`${removed} stale file(s) ${verb}`);
3569
+ if (finished > 0)
3570
+ done.push(`${finished} interrupted update(s) ${dryRun ? 'would be finished' : 'finished'}`);
3571
+ console.log(`\n ${done.length === 0 ? 'No stale files found.' : `${done.join('; ')}.`}\n`);
3326
3572
  }
3327
3573
 
3328
3574
  // ─── Manual Update ───────────────────────────────────────────────────────────
@@ -3710,14 +3956,19 @@ function sameDir(a, b) {
3710
3956
  }
3711
3957
  }
3712
3958
 
3713
- const dataDirAccessRemedy = () =>
3714
- `chmod u+rwx ${shellWord(MEM_DATA_DIR)} (or chown it back to your user if a sudo run created it)`;
3959
+ // The fix for a data dir this user cannot use, chosen by the error code. EROFS is the file system,
3960
+ // not the mode bits: accessSync(W_OK) on a read-only mount fails with it, and chmod cannot help
3961
+ // there (D#287).
3962
+ export const dataDirAccessRemedy = (code) =>
3963
+ code === 'EROFS'
3964
+ ? `the file system holding ${shellWord(MEM_DATA_DIR)} is mounted read-only — remount it read-write`
3965
+ : `chmod u+rwx ${shellWord(MEM_DATA_DIR)} (or chown it back to your user if a sudo run created it)`;
3715
3966
 
3716
3967
  async function runLockedInstall() {
3717
3968
  const denied = dataDirAccessError();
3718
3969
  if (denied) {
3719
3970
  console.error(
3720
- `[install] ${MEM_DATA_DIR} is not accessible (${denied}) — nothing was done. Fix: ${dataDirAccessRemedy()}`,
3971
+ `[install] ${MEM_DATA_DIR} is not accessible (${denied}) — nothing was done. Fix: ${dataDirAccessRemedy(denied)}`,
3721
3972
  );
3722
3973
  process.exitCode = 1;
3723
3974
  return;
@@ -3824,7 +4075,7 @@ async function dispatch(cmd) {
3824
4075
  await cleanupHooks();
3825
4076
  break;
3826
4077
  case 'cleanup':
3827
- cleanup();
4078
+ await cleanup();
3828
4079
  break;
3829
4080
  case 'self-update':
3830
4081
  case 'update':
@@ -21,13 +21,31 @@
21
21
  * A leaf: node:fs, node:path, and one constants module that imports nothing.
22
22
  */
23
23
 
24
- import { existsSync, readdirSync, statSync } from 'node:fs';
24
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { ORPHAN_EPISODE_AGE_MS } from './time-constants.mjs';
27
27
 
28
- /** Residue from an interrupted self-update, written under the data dir by hook-update. */
28
+ /**
29
+ * Residue of a self-update. hook-update's installExtractedRelease stages and backs up beside the
30
+ * tree it swaps: the CODE dir (~/.claude-mem-lite), which CLAUDE_MEM_DIR does not move (D#289).
31
+ * Before v2.90.0 the code lived in the data dir, so an updater then swapped into a relocated data
32
+ * dir; residue it left there is stale whatever it holds (scanStaleTempFiles' legacyDir).
33
+ */
29
34
  const UPDATE_RESIDUE_PREFIXES = ['.update-staging-', '.update-backup-'];
30
35
 
36
+ /**
37
+ * The journal a swap keeps inside its backup dir. A literal, not an import: hook-update.mjs is on
38
+ * the repair path and stays off this leaf's load graph; tests/update-residue-faces.test.mjs holds
39
+ * the two spellings equal.
40
+ */
41
+ export const SWAP_JOURNAL_NAME = '.swap-journal.json';
42
+
43
+ /**
44
+ * Written into a backup dir when its swap is over (committed, or rolled back), before the journal
45
+ * is removed: a journal left beside it is never replayed (D#292 C). Same arrangement as above.
46
+ */
47
+ export const SWAP_RESOLVED_NAME = '.swap-resolved';
48
+
31
49
  /** Episode hand-off files under the runtime dir. Age-gated: see classifyEpisodeFile. */
32
50
  const EPISODE_RESIDUE_PREFIXES = ['pending-', 'ep-flush-'];
33
51
 
@@ -35,6 +53,67 @@ export const isUpdateResidue = (name) => UPDATE_RESIDUE_PREFIXES.some((p) => nam
35
53
 
36
54
  export const isEpisodeResidue = (name) => EPISODE_RESIDUE_PREFIXES.some((p) => name.startsWith(p));
37
55
 
56
+ /**
57
+ * Is this update residue the leftover of a swap that is over, or a swap that never finished?
58
+ *
59
+ * A backup dir that holds its journal and no resolved marker is a swap whose updater was killed:
60
+ * it holds the only copy of every file the swap had moved out, and the next update entry replays
61
+ * the journal to put them back. Deleting it as stale temp kept the half-swapped tree for good
62
+ * (D#289). A swap that is over (committed, or rolled back) writes the resolved marker and then
63
+ * removes its journal, so a backup dir with the marker or without a journal, and any staging dir,
64
+ * is residue. A dir from 6.25.0 or older carries no marker; one whose journal survived a finished
65
+ * swap there still reads as unfinished.
66
+ *
67
+ * A swap running right now looks the same as one that was killed; cleanup tells them apart by
68
+ * install.lock, doctor cannot, and says so.
69
+ *
70
+ * 'unreadable-journal' is an unresolved backup dir whose journal is there but cannot be read now,
71
+ * or not as one (a write of 6.25.0 or older torn by a kill). Recovery cannot replay it and it may
72
+ * hold the only copy of what moved, so recovery leaves it (the same rule as hook-update's
73
+ * recoverInterruptedSwaps); the next swap that commits, or an install, retires it. One whose read
74
+ * error clears before that would be replayed like any unfinished swap.
75
+ *
76
+ * Only a real directory is a swap: recovery skips a file or a symlink with the name (its Dirent is
77
+ * not a directory), so the classifier calls those leftover too, judged by lstat as recovery does.
78
+ *
79
+ * @returns {'unfinished-swap'|'unreadable-journal'|'stale'|null} null when not update residue
80
+ */
81
+ export function classifyUpdateResidue(codeDir, name) {
82
+ if (!isUpdateResidue(name)) return null;
83
+ const dir = join(codeDir, name);
84
+ let st;
85
+ try {
86
+ st = lstatSync(dir);
87
+ } catch {
88
+ return 'stale';
89
+ }
90
+ if (!st.isDirectory() || !name.startsWith('.update-backup-')) return 'stale';
91
+ if (existsSync(join(dir, SWAP_RESOLVED_NAME))) return 'stale';
92
+ let raw;
93
+ try {
94
+ raw = readFileSync(join(dir, SWAP_JOURNAL_NAME), 'utf8');
95
+ } catch (e) {
96
+ return e.code === 'ENOENT' ? 'stale' : 'unreadable-journal';
97
+ }
98
+ try {
99
+ const j = JSON.parse(raw);
100
+ if (Array.isArray(j?.backedUp) && Array.isArray(j?.installed)) return 'unfinished-swap';
101
+ } catch {
102
+ /* not a journal */
103
+ }
104
+ return 'unreadable-journal';
105
+ }
106
+
107
+ /** A directory's names; [] when it does not exist. Any other error throws: absent is not unreadable. */
108
+ function listDir(dir) {
109
+ try {
110
+ return readdirSync(dir);
111
+ } catch (e) {
112
+ if (e.code === 'ENOENT') return [];
113
+ throw e;
114
+ }
115
+ }
116
+
38
117
  /**
39
118
  * How the age gate is SPOKEN, derived from the gate itself rather than hand-written.
40
119
  *
@@ -87,25 +166,49 @@ export function classifyEpisodeFile(
87
166
  * detail rather than a warning, because a file that is supposed to exist right now is not
88
167
  * a fault. Note the asymmetry, which is deliberate and is NOT the age gate being applied
89
168
  * inconsistently: update residue is guarded by install.lock in cleanup, not by age, so
90
- * there is no age gate here for it to mirror.
169
+ * there is no age gate here for it to mirror. `unfinishedSwaps` are backup dirs with a readable
170
+ * journal and no resolved marker: cleanup finishes those swaps rather than removing them, and
171
+ * `unreadableJournals` it leaves (classifyUpdateResidue).
91
172
  *
92
- * @returns {{stale: number, inFlight: number}}
173
+ * @param {{codeDir: string, runtimeDir: string, legacyDir?: string, now?: number, episodeAgeMs?: number}} args
174
+ * codeDir is where update residue lives; runtimeDir is where episode files live. legacyDir is a
175
+ * data dir CLAUDE_MEM_DIR keeps apart from the code dir: update residue there was left by an
176
+ * updater older than v2.90.0, which swapped into it, so all of it is stale and none of it is
177
+ * replayed. (It holds no journal either: journals came in v3.57.0.)
178
+ * @returns {{stale: number, inFlight: number, unfinishedSwaps: number, unreadableJournals: number,
179
+ * notChecked: Array<{dir: string, code: string}>}} A directory that exists but cannot be listed
180
+ * goes in notChecked, never counted as empty, and the others are still counted.
93
181
  */
94
- export function scanStaleTempFiles({ dataDir, runtimeDir, now = Date.now(), episodeAgeMs }) {
182
+ export function scanStaleTempFiles({ codeDir, runtimeDir, legacyDir, now = Date.now(), episodeAgeMs }) {
95
183
  let stale = 0;
96
184
  let inFlight = 0;
185
+ let unfinishedSwaps = 0;
186
+ let unreadableJournals = 0;
187
+ const notChecked = [];
188
+ const list = (dir) => {
189
+ try {
190
+ return listDir(dir);
191
+ } catch (e) {
192
+ notChecked.push({ dir, code: e.code || 'error' });
193
+ return [];
194
+ }
195
+ };
97
196
 
98
- if (existsSync(dataDir)) {
99
- for (const f of readdirSync(dataDir)) if (isUpdateResidue(f)) stale++;
197
+ for (const f of list(codeDir)) {
198
+ const kind = classifyUpdateResidue(codeDir, f);
199
+ if (kind === 'unfinished-swap') unfinishedSwaps++;
200
+ else if (kind === 'unreadable-journal') unreadableJournals++;
201
+ else if (kind === 'stale') stale++;
202
+ }
203
+ if (legacyDir) {
204
+ for (const f of list(legacyDir)) if (isUpdateResidue(f)) stale++;
100
205
  }
101
206
 
102
- if (existsSync(runtimeDir)) {
103
- for (const f of readdirSync(runtimeDir)) {
104
- if (!isEpisodeResidue(f)) continue;
105
- if (classifyEpisodeFile(runtimeDir, f, { now, episodeAgeMs }) === 'in-flight') inFlight++;
106
- else stale++;
107
- }
207
+ for (const f of list(runtimeDir)) {
208
+ if (!isEpisodeResidue(f)) continue;
209
+ if (classifyEpisodeFile(runtimeDir, f, { now, episodeAgeMs }) === 'in-flight') inFlight++;
210
+ else stale++;
108
211
  }
109
212
 
110
- return { stale, inFlight };
213
+ return { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked };
111
214
  }
@@ -273,7 +273,7 @@ export function schemaCompatProbeSource(root, dbPath) {
273
273
  ' const r = d.prepare("SELECT version FROM schema_version LIMIT 1").get();' +
274
274
  ' d.close();' +
275
275
  ' out.dbVersion = r && typeof r.version === "number" ? r.version : null; }' +
276
- ' catch (e) { out.dbError = String((e && e.message) || e); }' +
276
+ ' catch (e) { out.dbError = String((e && e.message) || e); out.dbErrorCode = (e && e.code) || null; }' +
277
277
  `process.stdout.write(${open} + JSON.stringify(out) + ${close}); })()`
278
278
  );
279
279
  }
@@ -318,7 +318,7 @@ function extractProbePayload(stdout) {
318
318
  * (a native crash leaving both streams empty, a spawn that never starts).
319
319
  *
320
320
  * @param {{timeoutMs?: number, spawn?: (cmd: string, args: string[], opts: object) => object}} [opts]
321
- * @returns {{status: 'ok'|'skew'|'unknown', supported?: number|null, dbVersion?: number|null, error?: string}}
321
+ * @returns {{status: 'ok'|'skew'|'unknown', supported?: number|null, dbVersion?: number|null, error?: string, errorCode?: string}}
322
322
  */
323
323
  export function probeSchemaCompatInFreshProcess(
324
324
  root,
@@ -350,6 +350,9 @@ export function probeSchemaCompatInFreshProcess(
350
350
  supported: supported ?? null,
351
351
  dbVersion: dbVersion ?? null,
352
352
  error: out.supportedError || out.dbError || 'probe returned no version',
353
+ // The DB open's own code, only when that is the error reported: a caller classifies on the
354
+ // code, never on SQLite's message, which several codes share (D#287).
355
+ ...(!out.supportedError && out.dbErrorCode && { errorCode: out.dbErrorCode }),
353
356
  };
354
357
  }
355
358
  return { status: supported < dbVersion ? 'skew' : 'ok', supported, dbVersion };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.0",
3
+ "version": "6.25.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.25.0",
9
+ "version": "6.25.1",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.0",
3
+ "version": "6.25.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",