claude-mem-lite 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "5.3.0",
13
+ "version": "5.4.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -120,7 +120,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
120
120
  - **Schema auto-migration** -- Idempotent `ALTER TABLE` migrations run on every startup, safely adding new columns and indexes without data loss
121
121
  - **LLM concurrency control** -- File-based semaphore limits background workers to 2 concurrent LLM calls, preventing resource contention
122
122
  - **stdin overflow protection** -- Hook input truncated at 256KB with regex-based action salvage for oversized tool outputs
123
- - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm does not currently fire** (tracked as R10-P1-1). The measurement, first: on the maintainer's own install `session_handoffs` holds 4 `exit` rows and **0** `clear` rows the `clear` snapshot has never once been written. The mechanism, as a hypothesis: SessionStart treats a session file left on disk as the marker of a previous session that ended without `Stop` (`hook.mjs:2393-2405`, whose own comment reads "Normal `/exit` deletes the file, so this only triggers for `/clear`, `/compact`, or crash recovery"), and that file is exactly what `Stop` deletes (`hook.mjs:1542`) which would make the branch unreachable if `Stop` runs at the end of every assistant turn rather than once per session. **That last clause is NOT verified**, and it is the whole question: the fix differs depending on whether Claude Code rotates its session id across `/clear`, so a real `/clear` stdin capture comes before any code change
123
+ - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap. **The `/clear` and `/compact` arm fires since v5.4.0** (R10-P1-1); before that it had never once written a row — `session_handoffs` on the maintainer's install held 4 `exit` rows and **0** `clear` rows. Two host facts settled it, both measured rather than assumed. (1) `Stop` runs at the end of every assistant *turn*, not once per session, and it deleted the session file that SessionStart reads to learn which session just ended so the branch was unreachable, and mem sessions were minted per turn (58 prompts over 16 host sessions produced 56 mem sessions and 56 summary rows, 2026-09-07). (2) Claude Code **rotates its session id across `/clear`**: of 21 real transcripts, 12 carry a `/clear` command record, and in 12/12 that record's timestamp precedes its own file's first record by ~0.1s the command is issued in the old session and replayed into a new file under a new id. So `Stop` no longer deletes the file, SessionStart asks the host's `source` (`startup`/`clear`/`compact`/`resume`) instead of guessing from the file, and the handoff's prompt lookup falls back to the unscoped set when the new session's id matches none. Revert path: `CLAUDE_MEM_LEGACY_STOP_UNLINK=1`
124
124
  - **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
125
125
  - **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
126
126
  - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic. (v3.39: the `/lesson` and `/bug` slash commands were redirected from this events table to searchable **observations** — `mem_search` never read the events table, so explicit saves were unfindable; the events table remains the auto-capture activity log.)
@@ -431,9 +431,10 @@ FTS5 indexes: `observations_fts` (title, subtitle, narrative, text, facts, conce
431
431
 
432
432
  ```
433
433
  SessionStart
434
- -> Generate session ID
435
- (the /clear|/compact handoff branch here is currently unreachable R10-P1-1,
436
- see Cross-session handoff above)
434
+ -> Read the host's `source` (startup | clear | compact | resume) from stdin
435
+ -> On clear/compact: read the outgoing session from the session file, save its
436
+ 'clear' handoff, emit the Working State block (R10-P1-1, fixed v5.4.0)
437
+ -> Generate session ID (overwrites the session file)
437
438
  -> Mark stale sessions (>24h active) as abandoned
438
439
  -> Clean orphaned/stale lock files
439
440
  -> Query recent observations (24h)
@@ -462,8 +463,9 @@ Stop
462
463
  -> Flush final episode buffer
463
464
  -> Save handoff snapshot (type 'exit')
464
465
  -> Mark session completed
465
- -> Delete the session file <- what makes the SessionStart /clear branch unreachable
466
466
  -> Spawn LLM summary worker (poll-based wait)
467
+ -> Keep the session file <- Stop fires per TURN; deleting it here re-minted a mem
468
+ session every turn and left the SessionStart /clear branch unreachable (v5.4.0)
467
469
  ```
468
470
 
469
471
 
@@ -828,6 +830,7 @@ what is already stored — only whether new work runs.
828
830
  | Variable | Description | Default |
829
831
  |----------|-------------|---------|
830
832
  | `CLAUDE_MEM_SKIP_SUMMARY` | Skip the background LLM session summary at **both** of its spawn sites — `Stop`, and the SessionStart `/clear`-handoff path. Until v5.3.0 only the `Stop` one honoured it. | _(runs)_ |
833
+ | `CLAUDE_MEM_LEGACY_STOP_UNLINK` | Restore the pre-v5.4.0 behaviour where `Stop` deletes the session file. Documented revert path for the session-lifecycle change, not a supported configuration: it re-mints a mem session per turn and makes the `/clear` handoff unreachable again. Only reach for it on a host that fires `Stop` once per session rather than once per turn. | _(file kept)_ |
831
834
  | `CLAUDE_MEM_SKIP_EPISODE_LLM` | Skip LLM extraction on episode flush — observations are still batched, just not summarized. | _(runs)_ |
832
835
  | `CLAUDE_MEM_SKIP_SAVE_ENRICH` | Skip the background Haiku call that backfills `lesson_learned` / search aliases after a save. | _(runs)_ |
833
836
  | `CLAUDE_MEM_SKIP_COMPRESS` | Skip auto-compression of old observations. | _(runs)_ |
package/hook-handoff.mjs CHANGED
@@ -56,25 +56,35 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
56
56
  // scopeSessionId is absent or == sessionId (legacy/test/no-stdin), fall back to the
57
57
  // unfiltered query (identical to pre-D#26 behavior).
58
58
  const ccScope = scopeSessionId && scopeSessionId !== sessionId ? scopeSessionId : null;
59
- const prompts = ccScope
60
- ? db
61
- .prepare(
62
- `
59
+ const unscopedPrompts = () =>
60
+ db
61
+ .prepare(
62
+ `
63
63
  SELECT prompt_text FROM user_prompts
64
- WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
64
+ WHERE content_session_id = ?
65
65
  ORDER BY prompt_number ASC LIMIT 5
66
66
  `,
67
- )
68
- .all(sessionId, ccScope)
69
- : db
67
+ )
68
+ .all(sessionId);
69
+ let prompts = ccScope
70
+ ? db
70
71
  .prepare(
71
72
  `
72
73
  SELECT prompt_text FROM user_prompts
73
- WHERE content_session_id = ?
74
+ WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
74
75
  ORDER BY prompt_number ASC LIMIT 5
75
76
  `,
76
77
  )
77
- .all(sessionId);
78
+ .all(sessionId, ccScope)
79
+ : unscopedPrompts();
80
+ // R10-P1-1: on the /clear path the scope is the NEW session's CC id while the prompts
81
+ // being handed off belong to the OLD one, and the host rotates that id across /clear
82
+ // (measured 12/12 on real transcripts, 2026-09-07) — so the scoped query returns 0 and
83
+ // the whole handoff was silently skipped. Fall back to the unscoped set when, and only
84
+ // when, the scoped one is EMPTY: D#26 exists to stop two live sessions being MERGED into
85
+ // one working_on, and there is nothing to merge with when this session contributed no
86
+ // prompts. The alternative at that point is not a cleaner row, it is no row at all.
87
+ if (ccScope && prompts.length === 0) prompts = unscopedPrompts();
78
88
  if (prompts.length === 0) return; // Empty session — nothing to hand off
79
89
 
80
90
  // Filter prompts whose only content is workflow/control language ("继续",
package/hook.mjs CHANGED
@@ -1537,10 +1537,32 @@ async function handleStop() {
1537
1537
  // recreate at 432ms and watched a 300ms grace lose.
1538
1538
  if (!process.env.CLAUDE_MEM_SKIP_SUMMARY) spawnBackground('llm-summary', sessionId, project);
1539
1539
 
1540
- // Clean session file AFTER spawning background
1541
- try {
1542
- unlinkSync(sessionFile());
1543
- } catch {}
1540
+ // The session file deliberately SURVIVES Stop (R10-P1-1). It used to be unlinked here,
1541
+ // on the model "Stop = /exit = the session is over". The host does not work that way:
1542
+ // Stop fires at the end of EVERY assistant turn, so the unlink minted a fresh mem
1543
+ // session on the next event and cost two things at once —
1544
+ //
1545
+ // • `sdk_sessions` / `session_summaries` counted turns, not sessions. Measured on the
1546
+ // maintainer's live DB 2026-09-07: 58 prompts over 16 host sessions produced 56
1547
+ // distinct mem sessions and 56 summary rows, 0 of which carried the LLM-only fields.
1548
+ // • handleSessionStart's mid-restart probe reads this file to learn which session just
1549
+ // ended. With it deleted every turn the probe never fired, so the /clear handoff
1550
+ // branch was unreachable in production — 0 `clear` rows against 21 real sessions.
1551
+ //
1552
+ // Lifetime is bounded by SESSION_EXPIRY_MS (12h) in getSessionId(), and every
1553
+ // SessionStart overwrites it via createSessionId(), so "one mem session per host
1554
+ // session" holds without anything having to delete it.
1555
+ //
1556
+ // CLAUDE_MEM_LEGACY_STOP_UNLINK=1 restores the pre-v5.4.0 unlink. It exists because the
1557
+ // measurements above are from ONE host build; a host that fires Stop once per session
1558
+ // instead of once per turn would be better served by the old shape, and a user who hits
1559
+ // that has no other lever. It is not a supported configuration — it re-breaks the /clear
1560
+ // handoff by design.
1561
+ if (process.env.CLAUDE_MEM_LEGACY_STOP_UNLINK === '1') {
1562
+ try {
1563
+ unlinkSync(sessionFile());
1564
+ } catch {}
1565
+ }
1544
1566
  }
1545
1567
 
1546
1568
  // ─── SessionStart Handler + CLAUDE.md Persistence (Tier 1 A, E) ─────────────
@@ -2336,13 +2358,20 @@ async function handleSessionStart() {
2336
2358
 
2337
2359
  // Read CC real session_id from hook stdin — used to scope handoff rows so parallel
2338
2360
  // sessions for the same project don't clobber each other (see docs/bug.txt).
2361
+ // `source` (startup | clear | compact | resume) is read here too: since Stop stopped
2362
+ // deleting the session file, the file's survival no longer tells us WHY this session
2363
+ // started, and the host's own word is the only non-guess (R10-P1-1).
2339
2364
  let ccSessionId = null;
2365
+ let startSource = null;
2340
2366
  try {
2341
2367
  const raw = await readStdin();
2342
2368
  const hookData = JSON.parse(raw.text);
2343
2369
  if (typeof hookData?.session_id === 'string' && hookData.session_id.length > 0) {
2344
2370
  ccSessionId = hookData.session_id;
2345
2371
  }
2372
+ if (typeof hookData?.source === 'string' && hookData.source.length > 0) {
2373
+ startSource = hookData.source;
2374
+ }
2346
2375
  } catch {
2347
2376
  /* stdin unavailable — legacy behavior */
2348
2377
  }
@@ -2390,19 +2419,32 @@ async function handleSessionStart() {
2390
2419
  }
2391
2420
  }
2392
2421
 
2393
- // Detect mid-session restart (/clear or /compact): if a recent session file exists,
2394
- // the previous session ended without Stop hook firing. Read BEFORE createSessionId()
2395
- // overwrites the session file. Normal /exit deletes the file, so this only triggers
2396
- // for /clear, /compact, or crash recovery.
2422
+ // Detect mid-session restart (/clear or /compact) and carry the ending session forward.
2423
+ // Read BEFORE createSessionId() overwrites the session file.
2424
+ //
2425
+ // The discriminator is the host's `source`, NOT the session file's survival. The old
2426
+ // comment here read "normal /exit deletes the file, so this only triggers for /clear,
2427
+ // /compact, or crash recovery" — but the deleter was Stop, which fires every turn, so
2428
+ // the file was always gone and this branch never triggered (R10-P1-1). Now that Stop
2429
+ // keeps the file, the file is always THERE, and asking it "why did this session start"
2430
+ // would answer /clear for a plain launch too. Only the host knows.
2431
+ //
2432
+ // `startup` and `resume` mean the previous session ended on its own terms and already
2433
+ // wrote its per-turn `exit` handoff, which UserPromptSubmit reads back — no clear
2434
+ // snapshot is owed. A null source (no stdin: tests, legacy hosts) keeps the old
2435
+ // file-presence behavior so nothing that used to reach this branch stops reaching it.
2436
+ const isMidSessionRestart = startSource !== 'startup' && startSource !== 'resume';
2397
2437
  let prevSessionId = null;
2398
2438
  let prevProject = null;
2399
- try {
2400
- const data = JSON.parse(readFileSync(sessionFile(), 'utf8'));
2401
- if (Date.now() - data.startedAt < SESSION_EXPIRY_MS) {
2402
- prevSessionId = data.id;
2403
- prevProject = data.project;
2404
- }
2405
- } catch {} // No session file = fresh startup, nothing to recover
2439
+ if (isMidSessionRestart) {
2440
+ try {
2441
+ const data = JSON.parse(readFileSync(sessionFile(), 'utf8'));
2442
+ if (Date.now() - data.startedAt < SESSION_EXPIRY_MS) {
2443
+ prevSessionId = data.id;
2444
+ prevProject = data.project;
2445
+ }
2446
+ } catch {} // No session file = fresh startup, nothing to recover
2447
+ }
2406
2448
 
2407
2449
  // Tier 1 A: Create unique session ID
2408
2450
  const sessionId = createSessionId();
package/install.mjs CHANGED
@@ -509,7 +509,7 @@ function registerMcpServer() {
509
509
  }
510
510
  }
511
511
 
512
- function dedupePluginCacheAndHooks({ managedHooks } = {}) {
512
+ export function dedupePluginCacheAndHooks({ managedHooks, isDev = false } = {}) {
513
513
  // 3b. Deduplicate: if marketplace plugin also registers MCP + hooks,
514
514
  // clear them to prevent double execution. install.mjs hooks (in settings.json)
515
515
  // point to ~/.claude-mem-lite/ (latest code in dev mode via symlinks),
@@ -593,17 +593,47 @@ function dedupePluginCacheAndHooks({ managedHooks } = {}) {
593
593
  const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
594
594
  if (existsSync(cacheBase)) {
595
595
  const launchSyncFiles = ['launch.mjs', 'launch-preflight.mjs'];
596
+ // Read, not remembered: the cache dir names ARE versions, so the comparison has to
597
+ // be against what this installer actually is. A stale constant here would re-open
598
+ // R10-P2-11 on the next release without changing a line of this block.
599
+ let selfVersion = null;
600
+ try {
601
+ selfVersion = JSON.parse(readFileSync(join(PROJECT_DIR, 'package.json'), 'utf8')).version;
602
+ } catch {
603
+ /* no readable package.json — treat every version as non-matching (sync nothing) */
604
+ }
596
605
  let clearedHooks = 0;
597
606
  for (const ver of readdirSync(cacheBase)) {
598
607
  const verDir = join(cacheBase, ver);
599
608
 
600
- // Sync launch.mjs + its preflight companion (issue #15)
601
- if (existsSync(join(verDir, 'scripts'))) {
609
+ // Sync launch.mjs + its preflight companion (issue #15).
610
+ //
611
+ // R10-P2-11: this used to run for EVERY cached version. Issue #15 is a dev-mode
612
+ // routing fix — the point is that a dev tree's launch.mjs reaches the cache the
613
+ // MCP server starts from — but nothing gated it, so a plain `install` (and the
614
+ // repair that SessionStart spawns in the background) pushed the installer's entry
615
+ // point into every OLD version dir, where it runs against that version's own
616
+ // `lib/`. Entry point and library are versioned together: HEAD's launch.mjs:72-73
617
+ // destructures `nativeBindingRepairHint` from ../lib/binding-probe.mjs, which
618
+ // v3.95.0 does not export, so :110 throws inside a catch and the user's repair
619
+ // hint disappears — a silent downgrade of the one message that tells them how to
620
+ // fix a dead binding. Reproduced in tests/sandbox/phaseB-npm.mjs §B9 (the old
621
+ // dir came back 9802B with `nativeBindingRepairHint` in it), which is the
622
+ // reproduction R10 §8 required before touching install().
623
+ //
624
+ // Dev mode still syncs everything: that is the fix's whole purpose, and a dev
625
+ // tree has no old versions to protect. Otherwise only the version dir that
626
+ // matches this installer — same release, so same expectations of `lib/`.
627
+ const versionMatches = isDev || ver === selfVersion;
628
+ if (versionMatches && existsSync(join(verDir, 'scripts'))) {
602
629
  for (const f of launchSyncFiles) {
603
630
  const src = join(PROJECT_DIR, 'scripts', f);
604
631
  if (existsSync(src)) {
605
632
  try {
606
- copyFileSync(src, join(verDir, 'scripts', f));
633
+ // Atomic for the same reason the two hooks.json writes above are: a
634
+ // torn launch.mjs is the MCP server's entry point, and the reader is
635
+ // Claude Code starting it, not us.
636
+ atomicWriteFileSync(join(verDir, 'scripts', f), readFileSync(src));
607
637
  } catch {
608
638
  /* keep going */
609
639
  }
@@ -959,7 +989,7 @@ async function install() {
959
989
  // re-read settings.json) is what keeps a future reorder from silently turning the
960
990
  // dedup off — the dependency is data, not sequence.
961
991
  const managedHooks = configureHooks();
962
- dedupePluginCacheAndHooks({ managedHooks });
992
+ dedupePluginCacheAndHooks({ managedHooks, isDev: IS_DEV });
963
993
  backupLegacyClaudeMemData();
964
994
  verifyDatabase();
965
995
  await dogfoodAutoAdopt();
@@ -394,6 +394,21 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
394
394
  -- lesson). Parity with the "lessons never auto-GC" guards in
395
395
  -- decayAndMarkIdle / selectCompressionCandidates / findSmartCompressCandidates.
396
396
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
397
+ -- D#4. The one HARD DELETE in this family, and until now the only site whose
398
+ -- exemption from liveObsFilterSql was a LIKELIHOOD argument rather than an
399
+ -- inertness proof: these rows have no title, narrative or lesson, so they are
400
+ -- absent from every injection surface, so an id that was never injected is not one
401
+ -- a #NN cites. Narrow, but reachable -- a hand-typed #NN, or a numeric
402
+ -- "save --supersedes" chain later blanked by a degenerate cluster-merge -- and what
403
+ -- the delete takes with it is the superseded_by that
404
+ -- citation-tracker.redirectSupersededIds (:1363) follows to credit a corrected
405
+ -- memory's #NN to its successor.
406
+ --
407
+ -- Deliberately NOT the full liveObsFilterSql. A retired row whose superseded_by is
408
+ -- null hands that redirect nothing: it falls through to out.add(id) (:1379-1381),
409
+ -- the same answer a missing row produces. Filtering on superseded_at instead would
410
+ -- strand every empty retired row here forever for no gain.
411
+ AND superseded_by IS NULL
397
412
  ${projectFilter} LIMIT ${opCap}
398
413
  `,
399
414
  )
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "5.3.0",
9
+ "version": "5.4.0",
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": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -124,12 +124,23 @@ if (!entryArg) {
124
124
 
125
125
  const entryAbs = entryArg.startsWith('/') ? entryArg : join(INSTALL_DIR, entryArg);
126
126
 
127
- // Swap barrier. An auto-update / repair renames files into the install dir one at
128
- // a time — atomic per file, not per file SET — so a hook process that starts
129
- // mid-swap can resolve its entry from the old version and an import from the new
130
- // one. hook-update.mjs marks that window; skip the fire instead of importing a
131
- // mixed module graph. Hooks are best-effort and the swap lasts ~a second, so the
132
- // next fire runs against a settled install.
127
+ // Swap barrier. An auto-update renames files into the install dir one at a time —
128
+ // atomic per file, not per file SET — so a hook process that starts mid-swap can
129
+ // resolve its entry from the old version and an import from the new one.
130
+ // hook-update.mjs marks that window; skip the fire instead of importing a mixed
131
+ // module graph. Hooks are best-effort and the swap lasts ~a second, so the next
132
+ // fire runs against a settled install.
133
+ //
134
+ // This covers the AUTO-UPDATE path only, and the distinction is not pedantic:
135
+ // `hook-update.mjs:719` is the sole writer of this marker in the whole tree (name set,
136
+ // 2026-09-07), so the barrier is never armed for `install.mjs install` — which copies the
137
+ // same file set in place with copyFileSync (`install.mjs:341-350`) and is what
138
+ // `install.mjs repair` ends up executing (`:2388`, after verifying the release). A repair
139
+ // spawned in the background at SessionStart therefore overwrites this tree while hooks
140
+ // keep firing into it. That is R10 P2-12, still open: the mechanism is settled, the runtime
141
+ // symptom is not reproduced, and R10 §8 asks for a repro in tests/sandbox/phaseB-npm.mjs
142
+ // before install()'s main path is touched. An earlier draft of this comment said "auto-update
143
+ // / repair", which reads as though the repair path were already covered — it is not.
133
144
  //
134
145
  // Stale-guarded on BOTH pid and ts: an updater killed mid-swap leaves the marker
135
146
  // behind, and a marker that outlives its writer must never mute hooks permanently.