claude-mem-lite 6.24.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.24.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.24.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/README.md CHANGED
@@ -237,6 +237,22 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
237
237
  repos/ # Shallow-cloned source repos
238
238
  ```
239
239
 
240
+ ## Upgrading to 6.25.0
241
+
242
+ **Every session gets the same first line on a file-recall block again.** No schema-version
243
+ change: 6.24.0 still opens the database after this release has.
244
+
245
+ - **What changes.** Since 6.16.0 half of sessions got a plain statement of where the notes come
246
+ from instead of "system-injected context, continue your planned action", so the two could be
247
+ compared by how often the agent cited a lesson. On the maintainer's own sessions the comparison
248
+ found no difference it could detect, and a scan of the agent's replies found neither line
249
+ surfaced to the user, so the split is closed: unset now means the older line in every session.
250
+ `CLAUDE_MEM_RECALL_FRAMING=factual` keeps the plain statement; `=ab` restores the split.
251
+ - **Also in this release:** when the data directory cannot be read, `doctor` says "not checked"
252
+ for what it could not look at and `status` names the directory, instead of reporting a missing
253
+ or empty store. `maintain execute --ops dedup` names the merge pairs it skips. Details in
254
+ CHANGELOG.md.
255
+
240
256
  ## Upgrading to 6.24.0
241
257
 
242
258
  **If you set `CLAUDE_CONFIG_DIR`, the installer, updates and hooks now use that directory, as
@@ -253,7 +269,8 @@ release has. Without `CLAUDE_CONFIG_DIR` nothing in this item changes.
253
269
  entries an older version wrote into `~/.claude/settings.json` stay there; if no other profile
254
270
  uses `~/.claude`, `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` removes them.
255
271
  - **Also in this release:** `npm install -g claude-mem-lite` and `npx` no longer install the
256
- development toolchain (96 packages / 57 MB instead of 298 / 541 MB). A confirmed
272
+ development toolchain. As a dependency install it went from 298 packages / 541 MB to 96 / 57 MB;
273
+ after the release, `-g` and `npx` measured the same 96 packages. A confirmed
257
274
  `maintain execute --ops purge_stale` now deletes every row its preview counted in one run,
258
275
  behind one snapshot. `doctor` warns about a failing update check and about an
259
276
  `ANTHROPIC_BASE_URL` that ends in `/v1`. Details in CHANGELOG.md.
@@ -1234,7 +1251,7 @@ and names can change between releases.
1234
1251
  |----------|-------------|---------|
1235
1252
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1236
1253
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1237
- | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. `ab` gives each session one of two wordings, the older "system-injected context, continue your planned action" or a plain statement of source, so their cite-rates can be compared in one run (`benchmark/citation-live-replay.mjs --by-framing`); `legacy` / `factual` pin one. | `ab` |
1254
+ | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. Unset or `legacy` = "system-injected context, continue your planned action"; `factual` = a plain statement of where the notes come from; `ab` = each session gets one of the two, for `benchmark/citation-live-replay.mjs --by-framing`. Any other value is `legacy`. | `legacy` |
1238
1255
  | `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
1239
1256
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1240
1257
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
package/README.zh-CN.md CHANGED
@@ -199,6 +199,35 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.25.0
203
+
204
+ **文件召回块的第一行,所有会话重新用同一种写法。** 没有 schema 版本变更:本版本打开过的数据库,6.24.0 仍能打开。
205
+
206
+ - **变了什么。** 从 6.16.0 起,一半会话的第一行是说明笔记来源的事实陈述,另一半保留 "system-injected context,
207
+ continue your planned action",用来比较 agent 引用教训的频率。在维护者自己的会话上,两种写法看不出差别,
208
+ 按回复文本扫描,两组里 agent 也都没有把这一行当作可疑内容告诉用户,所以结束分组:
209
+ 不设变量时,所有会话都用旧的那一行。
210
+ `CLAUDE_MEM_RECALL_FRAMING=factual` 保留事实陈述的写法,`=ab` 恢复分组。
211
+ - **本版本还有:** 数据目录读不了时,`doctor` 对看不到的项目显示“not checked”,`status` 直接指出这个目录
212
+ 无法访问,不再报成数据库缺失或为空。`maintain execute --ops dedup` 会列出跳过的合并对。详见 CHANGELOG.md。
213
+
214
+ ## 升级到 6.24.0
215
+
216
+ **如果你设置了 `CLAUDE_CONFIG_DIR`,安装程序、更新和 hook 现在都使用这个目录,和 Claude Code 一致。**
217
+ 没有 schema 版本变更:本版本打开过的数据库,6.23.3 仍能打开。没设 `CLAUDE_CONFIG_DIR` 时,这一条不带来任何变化。
218
+
219
+ - **受影响的是谁。** 设了 `CLAUDE_CONFIG_DIR` 时,Claude Code 把 `settings.json`、`plugins/` 和 `.claude.json`
220
+ 都放在那个目录里,插件却一直用 `~/.claude` 和 `~/.claude.json`:`install` 把 hook 写到了 Claude Code 不读的
221
+ 地方;有两个配置目录时,会话启动的整理步骤和 `self-update` 会清掉另一个配置目录的插件缓存,并改动它的
222
+ `.claude.json`。
223
+ - **要做什么。** 用 npm 或 npx 安装的:在设好这个变量的情况下再运行一次 `claude-mem-lite install`,让 hook 写进
224
+ 你的配置目录。插件安装不用做什么。旧版本写进 `~/.claude/settings.json` 的 hook 条目会留在那里;如果没有别的
225
+ 配置目录在用 `~/.claude`,可以用 `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` 清掉。
226
+ - **本版本还有:** `npm install -g claude-mem-lite` 和 `npx` 不再安装开发工具链。按依赖方式安装,从 298 个包 /
227
+ 541 MB 降到 96 个包 / 57 MB;发布后实测 `-g` 和 `npx` 也是同样的 96 个包。确认执行的
228
+ `maintain execute --ops purge_stale` 现在一次运行就删掉预览里统计的全部行,只做一次快照。`doctor` 会对失败的
229
+ 更新检查、以及以 `/v1` 结尾的 `ANTHROPIC_BASE_URL` 发出警告。详见 CHANGELOG.md。
230
+
202
231
  ## 升级到 6.23.1
203
232
 
204
233
  **如果你设置了 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY`,后台 LLM 调用现在会发往那个网关。**
package/bash-utils.mjs CHANGED
@@ -632,6 +632,28 @@ function withoutDiffHunks(text) {
632
632
  // Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
633
633
  // uniformly to both command-parsed paths and direct file_path/path/filePath fields.
634
634
 
635
+ // A line a test runner prints for one test (`# Subtest:` heads a failing one too): it carries the
636
+ // test's name, not a summary.
637
+ const PASSED_TEST_LINE_RE = /^[ \t]*(?:✔|✓|ok[ \t]+\d+[ \t]+-|# Subtest:)/;
638
+
639
+ /**
640
+ * Whether test-runner output carries a red summary or failure banner (detectBashSignificance's
641
+ * veto on a green "0 fail" marker; D#179).
642
+ * @param {string} scan
643
+ * @returns {boolean}
644
+ */
645
+ function hasRedTestSummary(scan) {
646
+ const lines = scan
647
+ .split('\n')
648
+ .filter((l) => !PASSED_TEST_LINE_RE.test(l))
649
+ .join('\n');
650
+ return (
651
+ /\b[1-9]\d*[ \t]+(?:fail|failed|failures|failing|errors?)\b(?=[ \t]*(?:$|[,;|(]|\.(?:[ \t]|$)|in[ \t]+\S))|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
652
+ lines,
653
+ ) || /^[ \t]*(?:--- )?FAIL(?:ED)?(?::|[ \t]|$)/m.test(lines)
654
+ );
655
+ }
656
+
635
657
  /**
636
658
  * Detect significance signals in a Bash command and its response.
637
659
  * Checks for errors, test runs, builds, git operations, and deployments.
@@ -675,12 +697,23 @@ export function detectBashSignificance(input, response) {
675
697
  // suites prints one summary each, and a node:test run whose failures were all timeouts says
676
698
  // "fail 0" next to "cancelled 1" (pre-tag defect review, item 7 F1). Red = a nonzero fail /
677
699
  // cancelled count in either summary form.
700
+ // D#179: a count-first red must sit where a summary puts it — end of line, or before `,` `;`
701
+ // `|` `(` `. ` or `in …` — on ONE line, and never on a line a runner prints for a PASSED test
702
+ // (node:test `✔ name (1ms)`, vitest / bun `✓ name`, TAP `ok 1 - name` and its `# Subtest:`),
703
+ // which carries the test's NAME. Matched anywhere and across newlines, a passing run whose test
704
+ // name or log said "3 failed" / "2 failed attempts" read red, and "want 2\nFAIL" read as
705
+ // "2 FAIL". `errors?` is red too (pytest "1 passed, 1 error", tsc "Found 1 error in …",
706
+ // eslint "(2 errors, 0 warnings)"). go / jest / vitest / shell-suite failures carry a
707
+ // case-sensitive FAIL / FAILED banner at line start ("--- FAIL:", "FAILED: 3 case(s)", pytest
708
+ // "FAILED test_x.py::…"), read on its own. The green marker still
709
+ // spans lines on purpose: made one-line, 17 of this machine's 77,819 recorded results flipped
710
+ // to errors, and 15 of them were passing batteries, file reads and reports that only ever
711
+ // matched "0\nFAIL" by accident; 2 were real failures (measured 2026-10-07, recounted by the
712
+ // pre-tag delta review).
678
713
  const hasGreenTestSummary =
679
714
  looksLikeError &&
680
715
  /\b0\s+(fail|failed|failures)\b|^[ \t]*(?:ℹ|#)[ \t]*fail[ \t]+0[ \t]*$/im.test(scan) &&
681
- !/\b[1-9]\d*\s+(fail|failed|failures|failing)\b|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
682
- scan,
683
- );
716
+ !hasRedTestSummary(scan);
684
717
  // NOTE: do not add `\bFAIL\s` here — with /i flag it would re-match the
685
718
  // very `0 fail\n` token green-summary is trying to exempt. A real test
686
719
  // failure produces "N fail" (N≥1) which never triggers hasGreenTestSummary,
package/hook-optimize.mjs CHANGED
@@ -484,8 +484,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
484
484
  // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
485
485
  // A compression keeper is never hidden: hiding it hides every member compressed into it
486
486
  // (the same rule NOT_COMPRESSION_KEEPER_SQL enforces on the maintenance writers). It falls
487
- // through to the normal update instead: floored to importance 1 and stamped, so it is not
488
- // re-sent. That update keeps its stored title and narrative in both scopes (keepStoredText).
487
+ // through instead: in narrow scope to the stamp-only write below (floored to importance 1
488
+ // and stamped, so it is not re-sent), in wide scope to the normal update.
489
489
  const isKeeper = !!db
490
490
  .prepare('SELECT 1 FROM observations WHERE compressed_into = ? LIMIT 1')
491
491
  .get(cand.id);
@@ -518,6 +518,31 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
518
518
  processed++;
519
519
  continue;
520
520
  }
521
+ // A narrow 0 reply reaches this point only for a row that may not be hidden (a compression
522
+ // keeper, or an importance a person set). It judged the row worthless, so it is no basis for
523
+ // any of the row's text either: the stored title and narrative stay (D#207: 'Weekly summary:
524
+ // auth refactor' became 'Weekly summary', narrative 'x'), and so do type, lesson, concepts,
525
+ // facts, aliases and scope (D#268). Written instead: what hiding would have written bar the
526
+ // hide, i.e. the stamp, so the row is not re-sent, plus the importance floor below. Wide
527
+ // scope stays on the normal path: its rows are substantive by construction, so a 0 there
528
+ // reads as a misjudgment of importance, not of the row.
529
+ if (scoredZero && scope !== 'wide') {
530
+ const res = db
531
+ .prepare(
532
+ `UPDATE observations SET
533
+ importance = CASE WHEN importance_set_at IS NOT NULL THEN importance
534
+ ELSE MAX(?, COALESCE(NULLIF(importance, 0), 1)) END,
535
+ optimized_at = ?
536
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL`,
537
+ )
538
+ .run(clampImportance(parsed.importance), Date.now(), cand.id);
539
+ if (res.changes === 0) {
540
+ skipped++;
541
+ continue;
542
+ }
543
+ processed++;
544
+ continue;
545
+ }
521
546
 
522
547
  // Enrichment ("add a lesson") must not reclassify a specific type down to the generic
523
548
  // 'change' (lower TYPE_QUALITY + faster decay); keep the stored type on that downgrade.
@@ -558,13 +583,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
558
583
  // For `narrow`, the truncate stays on LLM output but NOT on the preserve-on-empty
559
584
  // fallback — truncating the row's own stored narrative to buy nothing was the same
560
585
  // bug in miniature.
561
- //
562
- // A reply that scored the row 0 reaches this point only for a row that may not be hidden
563
- // (a compression keeper, or an importance a person set). It judged the row worthless, so
564
- // its title and narrative do not replace the stored ones either (D#207: 'Weekly summary:
565
- // auth refactor' became 'Weekly summary', narrative 'x').
566
586
  const isWide = scope === 'wide';
567
- const keepStoredText = isWide || scoredZero;
587
+ const keepStoredText = isWide;
568
588
  const title = keepStoredText ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
569
589
  const narrative = keepStoredText
570
590
  ? cand.narrative
@@ -611,8 +631,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
611
631
  // Narrow replaces title and narrative with model text, so an explicit save it rewrites (one
612
632
  // whose save-time enrich failed) moves to the re-enrich writer's id, as a cluster-merge
613
633
  // keeper does (D#146, D#138). Wide keeps the stored title and narrative (it fills the lesson
614
- // and the side fields), so the row stays an explicit save, and so does a row whose stored
615
- // text a 0 reply left in place. The writer's session row is best-effort.
634
+ // and the side fields), so the row stays an explicit save; a narrow 0 reply never gets here.
635
+ // The writer's session row is best-effort.
616
636
  let rewriteSessionId = null;
617
637
  if (!keepStoredText) {
618
638
  const cur = db.prepare('SELECT memory_session_id FROM observations WHERE id = ?').get(cand.id);
package/hook-shared.mjs CHANGED
@@ -281,7 +281,8 @@ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
281
281
  // out. Both are version-keyed one-shot migration sentinels written by
282
282
  // scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
283
283
  // its migration. `.mcp-dedup-v2.78` gates a block that removes
284
- // mcpServers.mem / mcpServers["mem-lite"] from the user's ~/.claude.json with
284
+ // mcpServers.mem / mcpServers["mem-lite"] from the config home's .claude.json (one marker per
285
+ // .claude.json since D#270, the same prefix) with
285
286
  // a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
286
287
  // documents as intentionally one-shot: "If a user later runs `claude mcp add
287
288
  // mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
package/hook-update.mjs CHANGED
@@ -318,13 +318,28 @@ function shouldCheck(state) {
318
318
  // ── GitHub API ─────────────────────────────────────────────
319
319
  // Try releases/latest first, fallback to tags (some repos only use tags)
320
320
  export async function fetchLatestRelease() {
321
- return (await lookupLatestRelease()).release;
321
+ const { release } = await lookupLatestRelease();
322
+ if (release) clearRecordedLookupFailure();
323
+ return release;
324
+ }
325
+
326
+ // repair looks up through fetchLatestRelease, so a lookup that succeeds there is evidence against
327
+ // the failure doctor reports; without this the warning outlived the repair by a full check interval
328
+ // (D#266). Only the two fields that decide whether doctor warns. lastCheck, latestVersion and
329
+ // rateLimited belong to checkForUpdate (the background check and self-update): rateLimited picks
330
+ // its interval (6 h, else 24 h), so clearing it here pushed the next check back by up to 18 h
331
+ // (post-release review). No write when there is nothing to clear.
332
+ function clearRecordedLookupFailure() {
333
+ const state = readState();
334
+ if (!state.lookupError && !state.lookupFailingSince) return;
335
+ saveState({ ...state, lookupError: null, lookupFailingSince: null });
322
336
  }
323
337
 
324
338
  // One lookup's outcome, returned rather than kept in module state: a module flag let a second
325
339
  // lookup running at the same time read the first one's 403 (D#260). `rateLimited` is THIS
326
- // lookup's refusal; the persisted flag is not that, since only a successful lookup clears it
327
- // (pre-tag review of cd9f1ab7). `error` says why there is no release, for doctor (D#255).
340
+ // lookup's refusal; the persisted flag is not that, since only a successful checkForUpdate (the
341
+ // background check or self-update) clears it (pre-tag review of cd9f1ab7). `error` says why there
342
+ // is no release, for doctor (D#255).
328
343
  async function lookupLatestRelease() {
329
344
  const headers = {
330
345
  Accept: 'application/vnd.github+json',
@@ -813,11 +828,15 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
813
828
  // updater that was killed mid-swap (it applies the same staleness bound).
814
829
  const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
815
830
  // Intent journal, written INSIDE the backup dir before each rename. On a hard kill
816
- // 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
817
833
  // which paths were in flight, so the next entry can finish the rollback at the right
818
834
  // granularity — a bare directory walk cannot tell a nested relPath from a directory
819
835
  // relPath like `node_modules`.
820
- 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';
821
840
 
822
841
  function markSwapStart() {
823
842
  try {
@@ -841,19 +860,131 @@ function clearSwapMarker() {
841
860
  // backup dir but nothing records it — recovery would then delete the backup dir with
842
861
  // the only copy of that file inside it. rollbackInstall guards every entry with
843
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']);
844
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) {
845
959
  try {
846
- writeFileSync(join(backupDir, SWAP_JOURNAL), JSON.stringify({ backedUp, installed }));
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)));
847
964
  } catch (e) {
848
- debugCatch(e, 'journalSwap');
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);
974
+ try {
975
+ rmSync(stagingDir, { recursive: true, force: true });
976
+ } catch (e) {
977
+ debugCatch(e, 'discard-staging-dir');
849
978
  }
850
979
  }
851
980
 
852
981
  /**
853
982
  * Finish any swap a previous process was killed in the middle of, then clear its
854
983
  * residue. Called on every install entry, under the install lock, BEFORE a new
855
- * staging/backup pair is created.
856
- * @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
857
988
  */
858
989
  export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
859
990
  let entries;
@@ -880,21 +1011,43 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
880
1011
  }
881
1012
  if (!entry.name.startsWith('.update-backup-')) continue;
882
1013
 
883
- let journal;
884
- try {
885
- journal = JSON.parse(readFileSync(join(dir, SWAP_JOURNAL), 'utf8'));
886
- } catch {
887
- 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;
888
1019
  }
889
- const backedUp = Array.isArray(journal?.backedUp) ? journal.backedUp : [];
890
- const installed = Array.isArray(journal?.installed) ? journal.installed : [];
891
- // Copies: rollbackInstall reverses the arrays in place.
892
- 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;
893
1029
  try {
894
- rmSync(dir, { recursive: true, force: true });
1030
+ raw = readFileSync(join(dir, SWAP_JOURNAL), 'utf8');
895
1031
  } catch (e) {
896
- debugCatch(e, 'recover-backup');
1032
+ if (e.code !== 'ENOENT') {
1033
+ debugCatch(e, 'recover-read-journal');
1034
+ continue;
1035
+ }
897
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
+ }
1047
+ }
1048
+ const { backedUp, installed } = journal;
1049
+ rollbackInstall(installed, backedUp, dir, targetDir);
1050
+ discardBackupDir(dir);
898
1051
  recovered++;
899
1052
  debugLog(
900
1053
  'WARN',
@@ -905,25 +1058,43 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
905
1058
  return recovered;
906
1059
  }
907
1060
 
1061
+ // A rollback swaps a file SET back just as the install swapped it in, so it holds the same
1062
+ // marker for the same reason (D#239 g). After the MED-5 smoke gate the loop has already cleared
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.
908
1072
  function rollbackInstall(installed, backedUp, backupDir, targetDir) {
909
- for (const relPath of installed.reverse()) {
910
- try {
911
- rmSync(join(targetDir, relPath), { recursive: true, force: true });
912
- } catch {
913
- /* best-effort */
1073
+ markSwapStart();
1074
+ try {
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;
1078
+ try {
1079
+ rmSync(join(targetDir, relPath), { recursive: true, force: true });
1080
+ } catch {
1081
+ /* best-effort */
1082
+ }
914
1083
  }
915
- }
916
- for (const relPath of backedUp.reverse()) {
917
- const backupPath = join(backupDir, relPath);
918
- const targetPath = join(targetDir, relPath);
919
- try {
920
- if (existsSync(backupPath)) {
921
- mkdirSync(dirname(targetPath), { recursive: true });
922
- renameSync(backupPath, targetPath);
1084
+ for (const relPath of [...backedUp].reverse()) {
1085
+ const backupPath = join(backupDir, relPath);
1086
+ const targetPath = join(targetDir, relPath);
1087
+ try {
1088
+ if (existsSync(backupPath)) {
1089
+ mkdirSync(dirname(targetPath), { recursive: true });
1090
+ renameSync(backupPath, targetPath);
1091
+ }
1092
+ } catch (restoreErr) {
1093
+ debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
923
1094
  }
924
- } catch (restoreErr) {
925
- debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
926
1095
  }
1096
+ } finally {
1097
+ clearSwapMarker();
927
1098
  }
928
1099
  }
929
1100
 
@@ -1026,6 +1197,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1026
1197
  const backupDir = join(targetDir, `.update-backup-${ts}`);
1027
1198
  const backedUp = [];
1028
1199
  const installed = [];
1200
+ let committed = false;
1029
1201
 
1030
1202
  const manifest = await loadReleaseManifest(sourceDir);
1031
1203
  const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
@@ -1035,6 +1207,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1035
1207
  // one — otherwise this install stacks on top of a mixed-version tree and its
1036
1208
  // own backup can no longer restore a coherent state.
1037
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);
1038
1213
 
1039
1214
  mkdirSync(stagingDir, { recursive: true });
1040
1215
  mkdirSync(backupDir, { recursive: true });
@@ -1049,6 +1224,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1049
1224
  }
1050
1225
 
1051
1226
  markSwapStart();
1227
+ // Cleared here only when the loop finished. If a rename throws, the half-swapped tree keeps
1228
+ // the marker through the catch below until rollbackInstall has put the old files back.
1229
+ let swapped = false;
1052
1230
  try {
1053
1231
  for (const relPath of switchablePaths) {
1054
1232
  const stagedPath = join(stagingDir, relPath);
@@ -1070,8 +1248,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1070
1248
  journalSwap(backupDir, backedUp, installed);
1071
1249
  renameSync(stagedPath, targetPath);
1072
1250
  }
1251
+ swapped = true;
1073
1252
  } finally {
1074
- clearSwapMarker();
1253
+ if (swapped) clearSwapMarker();
1075
1254
  }
1076
1255
 
1077
1256
  // MED-5: before discarding the rollback backup, prove the switched code boots.
@@ -1079,13 +1258,14 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1079
1258
  // keeps working rather than leaving a broken install with no way back.
1080
1259
  if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
1081
1260
  rollbackInstall(installed, backedUp, backupDir, targetDir);
1082
- rmSync(stagingDir, { recursive: true, force: true });
1083
- rmSync(backupDir, { recursive: true, force: true });
1261
+ discardSwapDirs(stagingDir, backupDir);
1084
1262
  return false;
1085
1263
  }
1086
1264
 
1087
- rmSync(stagingDir, { recursive: true, force: true });
1088
- 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);
1089
1269
 
1090
1270
  // Post-update migration: reconcile settings.json against the release we just
1091
1271
  // swapped in. `configureHooks()` already strips stale mem entries — but its only
@@ -1171,17 +1351,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
1171
1351
  return true;
1172
1352
  } catch (err) {
1173
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;
1174
1357
  rollbackInstall(installed, backedUp, backupDir, targetDir);
1175
- try {
1176
- rmSync(stagingDir, { recursive: true, force: true });
1177
- } catch {
1178
- /* best-effort */
1179
- }
1180
- try {
1181
- rmSync(backupDir, { recursive: true, force: true });
1182
- } catch {
1183
- /* best-effort */
1184
- }
1358
+ discardSwapDirs(stagingDir, backupDir);
1185
1359
  return false;
1186
1360
  } finally {
1187
1361
  release();