claude-mem-lite 3.82.0 → 3.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hook.mjs CHANGED
@@ -29,6 +29,13 @@ import {
29
29
  formatErrorRecallHints,
30
30
  MAX_HOOK_STDIN_BYTES,
31
31
  } from './utils.mjs';
32
+ // Direct import (not via the utils.mjs barrel): the barrel's re-exports are a v2.21
33
+ // backward-compat surface that knip already lists as unused; new shared symbols go to
34
+ // their canonical module.
35
+ import { inferProjectDir } from './project-utils.mjs';
36
+ // Aliased: `acquireLock` from hook-episode.mjs below is the episode buffer's own
37
+ // (argument-less) lock — a different mutex with a different staleness policy.
38
+ import { acquireLock as acquireProcLock } from './lib/proc-lock.mjs';
32
39
  import {
33
40
  readEpisodeRaw, episodeFile,
34
41
  acquireLock, releaseLock, readEpisode, writeEpisode,
@@ -39,7 +46,7 @@ import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-con
39
46
  import { entry as preCompactEntry } from './hook-precompact.mjs';
40
47
  import {
41
48
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
42
- SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
49
+ SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
43
50
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
44
51
  sessionFile, getSessionId, createSessionId, openDb,
45
52
  spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
@@ -56,6 +63,7 @@ import { snapshotDb } from './lib/db-backup.mjs';
56
63
  import {
57
64
  extractCitationsFromTranscript,
58
65
  extractInjectedBySurface,
66
+ buildCitationRelevanceSet,
59
67
  unionSurfaces,
60
68
  extractInjectedFromKeyContext,
61
69
  bumpCitationAccess,
@@ -70,6 +78,7 @@ import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attri
70
78
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
71
79
  import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
72
80
  import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
81
+ import { upsFtsQuery } from './lib/ups-query.mjs';
73
82
  import { formatTaskImperative } from './lib/task-imperative.mjs';
74
83
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
75
84
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
@@ -216,27 +225,124 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
216
225
  }
217
226
  }
218
227
 
219
- function flushEpisodeWithDb(db, episode, hookEventName) {
220
- // Collect Read file paths tracked by post-tool-use.sh
221
- // Use rename to atomically collect — prevents losing concurrent appends
222
- const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
223
- const readsCollect = readsFile + `.collect-${Date.now()}`;
228
+ // D#178 safety valve. With CLAUDE_MEM_READS_CARRY on, an insignificant flush leaves
229
+ // `reads-<project>.txt` in place, so a long insignificant streak keeps appending to it.
230
+ //
231
+ // THE CAP COUNTS LINES, NOT DISTINCT PATHS, and that is the whole point. The writer
232
+ // (`scripts/post-tool-use.sh`) appends one line per Read with no dedup, so the file grows
233
+ // by REPEATED lines — a session re-reading the same five files forever. The first draft
234
+ // compared the DISTINCT set against the cap, which is the quantity that stays tiny
235
+ // (measured on the live corpus: median 1, p95 6, max 21 carried paths), so the valve could
236
+ // not fire on the growth mode it exists to bound. That is this repo's recurring
237
+ // "predicate that cannot return true reports the defect as absent" shape, and the pre-tag
238
+ // review caught it here.
239
+ //
240
+ // Still a backstop and not a relevance bound — the same distinction
241
+ // IMPERATIVE_POOL_BACKSTOP documents for its pool.
242
+ const READS_CARRY_MAX_LINES = 20000;
243
+
244
+ /**
245
+ * Bound the reads file when an insignificant flush leaves it in place. Rewrites it only
246
+ * when it is over READS_CARRY_MAX_LINES raw lines, keeping the newest distinct paths.
247
+ *
248
+ * @param {string} readsFile
249
+ * @returns {number} distinct paths now held; 0 when there is no reads file (the COMMON
250
+ * case — no Read since the last collect); -1 only when the file exists but could not be
251
+ * read or the trim threw. The three-way split is the point: `episode_reads` is the ruler
252
+ * for this flag, and folding "nothing to hold" together with "could not look" would put
253
+ * the normal case and the broken case on the same value. A first draft returned -1 for
254
+ * both, which made -1 the overwhelmingly common reading and hid the failure inside it.
255
+ */
256
+ function trimReadsFile(readsFile) {
257
+ let raw;
224
258
  try {
225
- renameSync(readsFile, readsCollect);
226
- const raw = readFileSync(readsCollect, 'utf8');
227
- const paths = [...new Set(raw.split('\n').filter(Boolean))];
228
- episode.filesRead = paths;
229
- try { unlinkSync(readsCollect); } catch {}
259
+ raw = readFileSync(readsFile, 'utf8');
260
+ } catch (e) {
261
+ return e?.code === 'ENOENT' ? 0 : -1;
262
+ }
263
+ try {
264
+ const lines = raw.split('\n').filter(Boolean);
265
+ const paths = [...new Set(lines)];
266
+ if (lines.length <= READS_CARRY_MAX_LINES) return paths.length;
267
+ const keep = paths.slice(-READS_CARRY_MAX_LINES);
268
+ const tmp = readsFile + `.trim-${process.pid}`;
269
+ writeFileSync(tmp, keep.join('\n') + '\n', { mode: 0o600 });
270
+ renameSync(tmp, readsFile);
271
+ return keep.length;
230
272
  } catch {
231
- episode.filesRead = episode.filesRead || [];
273
+ return -1;
232
274
  }
275
+ }
233
276
 
277
+ function flushEpisodeWithDb(db, episode, hookEventName) {
234
278
  // Split by CC session so concurrent same-project sessions flush as separate
235
279
  // observations. planEpisodeFlush returns [episode] BY REFERENCE for the common
236
280
  // single-session (or all-legacy) case → flushEpisodeGroup(episode) is identical
237
281
  // to pre-grouping. Two+ interleaved sessions each get their own sub-episode.
238
282
  const subs = planEpisodeFlush(episode);
283
+
284
+ // D#178. The reads file used to be consumed right here, unconditionally, BEFORE
285
+ // anything knew whether this flush would persist an observation — and an
286
+ // insignificant flush then dropped every path it had just swept up, leaving the
287
+ // next observation that DID save with files_read = []. Measured over 1122 real
288
+ // transcripts (benchmark/episode-flush-replay.mjs): 42.2% of the reads a flush
289
+ // consumed died that way, and 72.7% of significant flushes carried none at all.
290
+ //
291
+ // The fix is an ORDERING, not a buffer: explainSignificance reads only `entries`
292
+ // and `files`, never `filesRead`, so the verdict is available before the file is
293
+ // touched. An insignificant flush now leaves the file alone and the next
294
+ // significant one collects the union. Ages measured on the same corpus: the reads
295
+ // that survive attach a median 1.7 minutes and p90 10.1 minutes later than they do
296
+ // today, which is the whole cost — a read carried across two insignificant flushes
297
+ // lands on the edit it preceded rather than on nothing.
298
+ //
299
+ // ON by default since v3.83.0. `CLAUDE_MEM_READS_CARRY=0` restores the pre-D#178
300
+ // behavior byte for byte — kept as an off switch because this changes what a released
301
+ // artifact stores, and a defect here is invisible from the outside (the symptom is an
302
+ // absent field, which reads exactly like "there was nothing to record").
303
+ const carryReads = !['0', 'off', 'false', 'no'].includes(
304
+ String(process.env.CLAUDE_MEM_READS_CARRY ?? '').toLowerCase());
305
+ const willPersist = !carryReads || subs.some((s) => episodeHasSignificantContent(s));
306
+
307
+ // Collect Read file paths tracked by post-tool-use.sh
308
+ // Use rename to atomically collect — prevents losing concurrent appends
309
+ const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
310
+ const readsCollect = readsFile + `.collect-${Date.now()}`;
311
+ let readsHeld = 0;
312
+ if (willPersist) {
313
+ try {
314
+ renameSync(readsFile, readsCollect);
315
+ const raw = readFileSync(readsCollect, 'utf8');
316
+ const paths = [...new Set(raw.split('\n').filter(Boolean))];
317
+ episode.filesRead = paths;
318
+ try { unlinkSync(readsCollect); } catch {}
319
+ } catch {
320
+ episode.filesRead = episode.filesRead || [];
321
+ }
322
+ } else {
323
+ episode.filesRead = [];
324
+ // Not collecting means the file keeps growing across an insignificant streak, and a
325
+ // project that never flushes significantly would grow it without bound. Trim it.
326
+ //
327
+ // The trim's race window is WIDER than the collect path's, and saying otherwise (the
328
+ // first draft did) is the kind of comfortable claim that stops anyone checking: the
329
+ // collect path is a single atomic `renameSync`, while this is read → dedup → write tmp
330
+ // → rename, and a `>>` append from the bash prefilter landing inside that span is lost
331
+ // to the final rename. Accepted rather than fixed: the trim only runs above
332
+ // READS_CARRY_MAX_LINES, which no observed session approaches, so the exposure is a
333
+ // path or two in a session that has already read 20000 times.
334
+ readsHeld = trimReadsFile(readsFile);
335
+ }
336
+ // planEpisodeFlush now runs BEFORE the collection, so the multi-session branch — the
337
+ // one that builds fresh objects rather than returning [episode] by reference — copied
338
+ // whatever filesRead the buffer happened to carry, not what was just collected. The
339
+ // single-group path is identity and unaffected; this line is what keeps the two paths
340
+ // saying the same thing, and without it concurrent same-project sessions would lose
341
+ // their reads while a solo session kept them.
342
+ for (const sub of subs) if (sub !== episode) sub.filesRead = episode.filesRead;
343
+
239
344
  let anySignificant = false;
345
+ let writefail = false;
240
346
  for (const sub of subs) {
241
347
  const r = flushEpisodeGroup(sub, db);
242
348
  if (r === 'writefail') {
@@ -245,12 +351,39 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
245
351
  // keep the rest. The asymmetry is safe: each group's immediate obs is persisted
246
352
  // BEFORE its flush-file write, so re-flushing the whole buffer would re-emit
247
353
  // already-saved groups as duplicate observations.
248
- if (subs.length === 1) return;
354
+ if (subs.length === 1) { writefail = true; break; }
249
355
  continue;
250
356
  }
251
357
  if (r === 'significant') anySignificant = true;
252
358
  }
253
359
 
360
+ // D#178 instrument, and the ruler for the flag above. With CLAUDE_MEM_READS_CARRY
361
+ // off, a row with `significant: false` and `readsConsumed > 0` is that many Read
362
+ // paths collected and dropped on the floor. With it on, those rows become
363
+ // `readsConsumed: 0, readsHeld: N` — the same event, now recording a deferral
364
+ // instead of a loss, so one query over this sink covers both arms.
365
+ // Emitted HERE and not in flushEpisodeGroup on purpose: planEpisodeFlush copies
366
+ // the SAME filesRead array into every sub, so a per-group counter double-counts
367
+ // the multi-session case, and the destroyed/kept decision is `anySignificant`,
368
+ // which only exists at this level. `writefail` is its own arm because on the SIGNIFICANT
369
+ // path it keeps the episode buffer for a retry the reads file can no longer serve — it
370
+ // was already unlinked, so the retry re-collects nothing. A writefail flush is NOT
371
+ // necessarily one whose significance said collect: `flushEpisodeGroup` writes its flush
372
+ // file outside the significance branch, so an insignificant flush can fail there too —
373
+ // and with the flag on that case is strictly better than before, because the reads file
374
+ // was never touched and the retry still finds it.
375
+ // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
376
+ recordMetric(join(RUNTIME_DIR, '..'), {
377
+ event: 'episode_reads',
378
+ readsConsumed: (episode.filesRead || []).length,
379
+ readsHeld,
380
+ carry: carryReads,
381
+ significant: anySignificant,
382
+ subs: subs.length,
383
+ writefail,
384
+ });
385
+ if (writefail) return;
386
+
254
387
  // Aggregate receipt over the whole episode, gated exactly as before
255
388
  // (isSignificant → anySignificant). v2.33.4: Stop rejects hookSpecificOutput.
256
389
  if (anySignificant && RECEIPT_EVENTS.has(hookEventName)) {
@@ -807,10 +940,34 @@ async function handleStop() {
807
940
  // applyCitationDecay checks separately.
808
941
  try {
809
942
  if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
943
+ // D#152/D#177: the `subagent` face, collected ONCE, up front, and used twice —
944
+ // by the decay block below (only under CLAUDE_MEM_SUBAGENT_DECAY) and by its own
945
+ // metering call at the tail. It used to be collected at the tail only, with a
946
+ // comment saying the position was load-bearing because lib/transcript-scan.mjs
947
+ // memoizes ONE file and reading the sidechains evicts the parent. That constraint
948
+ // is real but it is not "last" — it is "not BETWEEN two parent scans". Running it
949
+ // FIRST parses the sidechains before anything has memoized the parent, so the
950
+ // parent is then parsed once and stays memoized for every scanner after it:
951
+ // still one parent parse per Stop, the property the tail comment was protecting.
952
+ let sub = { injected: new Set(), cited: new Set(), files: 0 };
953
+ try { sub = collectSubagentSurface(transcriptPath); }
954
+ catch (e) { debugCatch(e, 'handleStop-subagent-collect'); }
955
+
810
956
  const ids = extractCitationsFromTranscript(transcriptPath);
811
957
  if (ids.size > 0) {
812
- const n = bumpCitationAccess(db, ids, project);
813
- debugLog('DEBUG', 'handleStop', `citations: ${ids.size} ids scanned, ${n} obs bumped`);
958
+ // Gate the access-count channel on relevance (audit FLOW-2 / D#179). The cited
959
+ // set is every `#NN` in this session's assistant text and cannot tell a
960
+ // citation from a mention; in this repository a CHANGELOG or audit-writing
961
+ // session names dozens of ids in prose, and access_count > 3 promotes a row a
962
+ // tier via boostAccessed. The population to credit — all seven faces, and why
963
+ // extractAllInjected alone is the wrong five — lives in the builder.
964
+ const relevant = buildCitationRelevanceSet({
965
+ transcriptPath, runtimeDir: RUNTIME_DIR, project,
966
+ sessionId: ccSessionId, subagentInjected: sub.injected,
967
+ });
968
+ const n = bumpCitationAccess(db, ids, project, relevant);
969
+ debugLog('DEBUG', 'handleStop',
970
+ `citations: ${ids.size} ids scanned, ${relevant.size} relevant, ${n} obs bumped`);
814
971
  }
815
972
 
816
973
  // v32 citation-decay: tighter feedback loop on top of P4. Re-scan
@@ -854,7 +1011,15 @@ async function handleStop() {
854
1011
  const keyCtxIds = extractInjectedFromKeyContext({
855
1012
  runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
856
1013
  });
857
- if (injected.size > 0 || keyCtxIds.size > 0) {
1014
+ // D#177: `sub.injected` counts toward the entry gate when the face is admitted.
1015
+ // Without this a session whose ONLY injection was a dispatched agent's prompt
1016
+ // would return here with injected.size === 0 and the face would be "in the
1017
+ // denominator" in name only — the failure mode where a face is wired at one
1018
+ // level and gated out at another, which is how UPS went unmetered for a whole
1019
+ // minor version.
1020
+ const subDecayOn = !['0', 'off', 'false', 'no'].includes(
1021
+ String(process.env.CLAUDE_MEM_SUBAGENT_DECAY ?? '').toLowerCase());
1022
+ if (injected.size > 0 || keyCtxIds.size > 0 || (subDecayOn && sub.injected.size > 0)) {
858
1023
  // Text-floor gate: skip decay on tool-only Stops. Without this,
859
1024
  // a turn that ends on tool_use locks every injected obs as
860
1025
  // uncited (last_decided_session_id set), so a later turn that
@@ -867,17 +1032,71 @@ async function handleStop() {
867
1032
  } else {
868
1033
  const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
869
1034
  for (const id of citeBackIds) citedMain.add(id);
1035
+ // D#177: admit the `subagent` face to the decay loop. It cannot ride the
1036
+ // normal path because its injection lands in a dispatched agent's PROMPT
1037
+ // and its citation lands in that agent's OWN transcript — so its ids enter
1038
+ // the denominator AND its receiver-attributed cites enter the numerator,
1039
+ // asymmetrically, together. Feeding only the first half would mark every
1040
+ // subagent-only injection uncited by construction (that is why the face was
1041
+ // metered-but-excluded since v3.77); feeding only the second half would
1042
+ // credit the main-thread faces for citations the main thread never made.
1043
+ //
1044
+ // `sub.cited` is already the per-FILE intersection with `sub.injected`
1045
+ // (collectSubagentSurface), so this cannot credit an id the subagent surface
1046
+ // did not itself inject. Measured on the live corpus (1122 transcripts, 34
1047
+ // subagent-bearing sessions): 33 marginal (session,id) pairs enter the
1048
+ // denominator, 21.2% of them cited; 21 distinct observations behind the
1049
+ // uncited ones, FIVE at uncited_streak = 2. Four are 3->2 down-ranks (#8597,
1050
+ // #8847 with cited_count 56, #8948, #10246). At 2026-08-25 18:00Z #10716 was
1051
+ // at importance 2 — one miss from a 2->1 eviction out of
1052
+ // rankImperativeCandidates' own `importance >= 2` pool, the case
1053
+ // IMPERATIVE_POOL_BACKSTOP does not cover, and the reason "down-ranks, not
1054
+ // evictions" is wrong as a blanket claim. That row has since been promoted by
1055
+ // the very session that documented it (D#179: this loop cannot tell writing
1056
+ // `#NN` from applying it), so re-check the CLASS, not the row.
1057
+ // Cross-crediting is 3 pairs of 1181 DISTINCT (session,id) across the five
1058
+ // decay faces inside subagent-bearing sessions (0.25%), or 3 of 2738 the same
1059
+ // way corpus-wide (0.11%) — ids the main thread never cited but a subagent did.
1060
+ //
1061
+ // ON by default since v3.83.0; `CLAUDE_MEM_SUBAGENT_DECAY=0` restores the
1062
+ // metered-but-never-decaying state the face sat in from v3.77 to v3.82.
1063
+ //
1064
+ // The denominator is a COPY, not a mutation of `injected`: the edge
1065
+ // attribution below takes `mainInjectedIds: injected` to keep sidechain-only
1066
+ // injections from accruing file-edge misses (review D#78), and folding the
1067
+ // subagent ids into that set would undo exactly that guard.
870
1068
  // The promotion-only half: a Key Context row the agent actually
871
1069
  // cited joins the decay set (and takes the promote branch); one
872
1070
  // it ignored is never entered, so it cannot streak or demote.
873
1071
  for (const id of keyCtxIds) if (citedMain.has(id)) injected.add(id);
1072
+ // BOTH halves of the merge are COPIES, built AFTER the keyctx promotion above
1073
+ // so they carry it too. When the flag is off each IS the original object, so
1074
+ // every consumer below is byte identical to the pre-D#177 path.
1075
+ //
1076
+ // The copies are the whole safety property. `injected` and `citedMain` have
1077
+ // four consumers between them and only `applyCitationDecay` should see the
1078
+ // subagent ids; the first draft of this change mutated `citedMain` in place
1079
+ // and the pre-tag review measured both leaks it caused:
1080
+ // • recordCitationSurfaces (below) scored a `pretool` row the main thread
1081
+ // never cited as a pretool HIT — `pretool.cited_n` 0 -> 1 on a
1082
+ // two-observation probe. That is the caliber CLAUDE.md publishes for the
1083
+ // funnel ("cited as #NN in the session's own MAIN-THREAD text"), so it
1084
+ // would have made citation_surface_log and citation-live-replay.mjs
1085
+ // permanently different rulers — the v3.81.0 cross-agent defect, mirrored.
1086
+ // • resolveEdgeAttribution gates sidechain edges on
1087
+ // `!mainInjected.has(id) && !cited.has(id)`, so a file edge flipped MISS
1088
+ // -> HIT (`miss_streak` 1 -> 0). The comment there defends the DENOMINATOR
1089
+ // half of that gate and says nothing about the numerator, which is exactly
1090
+ // how the leak got past a reading of it.
1091
+ const decayInjected = subDecayOn ? new Set([...injected, ...sub.injected]) : injected;
1092
+ const decayCited = subDecayOn ? new Set([...citedMain, ...sub.cited]) : citedMain;
874
1093
  // D#60: the idempotency key must be the CC session UUID, NOT the
875
1094
  // project-scoped memory sessionId — concurrent same-project CC
876
1095
  // sessions share the latter, so the second session's decay pass
877
1096
  // read "already decided" and silently undercounted decay_seen /
878
1097
  // streaks / adoption denominators. Fallback keeps legacy
879
1098
  // stdin-less invocations on the old key.
880
- const r = applyCitationDecay(db, project, injected, citedMain, ccSessionId || sessionId);
1099
+ const r = applyCitationDecay(db, project, decayInjected, decayCited, ccSessionId || sessionId);
881
1100
  debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
882
1101
  // R1: persist this session's invocation→cite funnel row. touched =
883
1102
  // obs resolved this run (denominator), promoted = obs cited this run
@@ -968,11 +1187,19 @@ async function handleStop() {
968
1187
  // construction; folding its cites INTO citedMain would credit the
969
1188
  // main-thread faces for citations the main thread never made. The
970
1189
  // upsert key is (project, session, surface), so two calls with
971
- // disjoint face sets do not collide. Metering only — `subagent` is in
972
- // NON_ATTACHMENT_SURFACES and never reaches applyCitationDecay.
1190
+ // disjoint face sets do not collide.
1191
+ //
1192
+ // SINCE v3.83.0 (D#177) this is no longer metering-only: the face DOES reach
1193
+ // applyCitationDecay, through the `decayInjected` / `decayCited` copies above.
1194
+ // The sentence above about folding cites into `citedMain` still holds and is the
1195
+ // reason those are copies — this call, `resolveEdgeAttribution` and the keyctx
1196
+ // promotion all keep the un-widened set. `CLAUDE_MEM_SUBAGENT_DECAY=0` returns
1197
+ // the face to metering-only.
973
1198
  //
974
- // Placed LAST on purpose: lib/transcript-scan.mjs memoizes ONE file,
975
- // so reading the sidechain files evicts the parent transcript. Run
1199
+ // The "placed LAST" note below is now historical: `collectSubagentSurface` runs
1200
+ // at the HEAD of this block (the decay loop needs its result), and `sub` here is
1201
+ // that same object rather than a second call. The parse-count property the note
1202
+ // defends is unchanged — see the comment at the collection site.
976
1203
  // earlier, this block costs ONE extra parse of the parent — the memo
977
1204
  // re-caches on the first re-read, so it is one, not one per later
978
1205
  // scanner — and breaks the "one parse per Stop" property the block
@@ -985,7 +1212,9 @@ async function handleStop() {
985
1212
  // must not enter the funnel's session denominator either.
986
1213
  try {
987
1214
  if (hasMainThreadAssistantText(transcriptPath)) {
988
- const sub = collectSubagentSurface(transcriptPath);
1215
+ // `sub` is the one collected at the top of this block — a second
1216
+ // collectSubagentSurface call here would re-parse every sidechain file and,
1217
+ // worse, could disagree with the set the decay loop above just scored.
989
1218
  if (sub.injected.size > 0) {
990
1219
  recordCitationSurfaces(db, project, ccSessionId || sessionId,
991
1220
  { subagent: sub.injected }, sub.cited);
@@ -1341,14 +1570,52 @@ function scheduleSessionStartAutoMaintain(project) {
1341
1570
  if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1342
1571
  }
1343
1572
 
1573
+ // The maintenance mutex deliberately does NOT end in `.lock`: cleanStaleLockFiles()
1574
+ // below unlinks every `*.lock` in RUNTIME_DIR whose age exceeds STALE_LOCK_MS (30s)
1575
+ // WITHOUT consulting the holder's pid — a policy written for the episode lock, whose
1576
+ // critical section is milliseconds. A maintenance pass is seconds to minutes (VACUUM INTO
1577
+ // snapshot, purge, decay, dedup over the whole DB), so that sweeper would strip this lock
1578
+ // mid-pass and hand the exclusion straight back to the race it exists to close.
1579
+ // proc-lock brings its own staleness policy (age OR provably-dead pid), which is the
1580
+ // correct one here.
1581
+ // Generous upper bound on one pass; a crashed holder is normally reclaimed sooner via the
1582
+ // dead-pid check, so this only matters for a holder killed on another host.
1583
+ const AUTO_MAINTAIN_LOCK_STALE_MS = 10 * 60 * 1000;
1584
+
1344
1585
  // Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
1345
1586
  // pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
1346
1587
  // gate + the compress/optimize spawns at its tail.
1588
+ //
1589
+ // Cross-process mutual exclusion (2026-08-29 audit FLOW-1). The pass is shaped
1590
+ // read-gate → long work → write-gate, so two Claude Code windows booting either side of
1591
+ // the 24h boundary both see "due" and both spawn a worker. That breaks a documented
1592
+ // in-process invariant: decayAndMarkIdle marks BEFORE it decays precisely so an imp-2 row
1593
+ // cannot be decayed 2→1 and marked COMPRESSED_PENDING_PURGE in the same pass (MED-1, see
1594
+ // its docblock — each importance tier is supposed to buy a grace cycle). Across two
1595
+ // processes the ordering is gone: worker A decays 2→1, worker B's mark-idle then sees a
1596
+ // qualifying imp-1 row and hides it, 37 days from a hard delete. The same overlap
1597
+ // double-runs the cascade below it (duplicate weekly summaries from compressGroup, whose
1598
+ // UPDATE has no compressed_into guard; doubled llm-optimize spend).
1599
+ //
1600
+ // Lock at the worker entry rather than around the individual ops: the cascade spawns sit
1601
+ // inside the pass, so one gate covers the whole family. Not acquiring is a plain no-op —
1602
+ // a peer is already doing exactly this work.
1347
1603
  function handleAutoMaintain(project) {
1348
- const db = openDb();
1349
- if (!db) return;
1350
- try { runSessionStartAutoMaintain(db, project); }
1351
- finally { try { db.close(); } catch { /* ignore */ } }
1604
+ const release = acquireProcLock(join(RUNTIME_DIR, AUTO_MAINTAIN_LOCK), {
1605
+ staleMs: AUTO_MAINTAIN_LOCK_STALE_MS,
1606
+ });
1607
+ if (!release) {
1608
+ debugLog('DEBUG', 'auto-maintain', 'skipped — a live peer holds the maintenance lock');
1609
+ return;
1610
+ }
1611
+ try {
1612
+ const db = openDb();
1613
+ if (!db) return;
1614
+ try { runSessionStartAutoMaintain(db, project); }
1615
+ finally { try { db.close(); } catch { /* ignore */ } }
1616
+ } finally {
1617
+ release();
1618
+ }
1352
1619
  }
1353
1620
 
1354
1621
  function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
@@ -1477,7 +1744,11 @@ async function buildStartupDashboardText(db, project) {
1477
1744
  // tests/session-start-stdout-envelope.test.mjs.
1478
1745
  try {
1479
1746
  const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
1480
- let dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
1747
+ // projectPath MUST come from the same place `project` does (inferProjectDir), not from
1748
+ // process.cwd(): otherwise the dashboard renders directory A's git state and task list
1749
+ // under directory B's project name whenever the hook process was not spawned at the
1750
+ // project root. See inferProjectDir()'s docblock for the case this closed.
1751
+ let dashboardText = buildDashboard({ db, project, projectPath: inferProjectDir() });
1481
1752
  const citeNudge = buildCiteRecallNudge(project);
1482
1753
  if (citeNudge) {
1483
1754
  dashboardText = dashboardText ? `${citeNudge}\n${dashboardText}` : citeNudge;
@@ -1949,7 +2220,14 @@ async function handleUserPrompt() {
1949
2220
  // ranking and citation extractors (bare-`#` anchored) never read an event id as
1950
2221
  // an obs id. Nested try so an events failure can't suppress the imperative pick.
1951
2222
  try {
1952
- const events = searchInjectableEvents(db, { prompt: promptText, project });
2223
+ // upsFtsQuery, not the raw prompt (audit ALGO-1). lib/ups-query.mjs declares
2224
+ // itself "the ONE query-cap definition for the UserPromptSubmit event", and both
2225
+ // OTHER legs of this same event go through it — but this leg, wired in v3.48
2226
+ // before that module existed, handed searchInjectableEvents the whole prompt and
2227
+ // let it call the uncapped sanitizeFtsQuery. Measured here: a 250KB CJK prompt
2228
+ // (path B's stdin cap is 256KB) costs 356ms uncapped against 5.5ms capped, all of
2229
+ // it synchronous, before the model sees the turn.
2230
+ const events = searchInjectableEvents(db, { ftsQuery: upsFtsQuery(promptText), project });
1953
2231
  if (events.length > 0) {
1954
2232
  const elines = ['<memory-context relevance="events">'];
1955
2233
  for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);