@lh8ppl/claude-memory-kit 0.2.1 → 0.2.3

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.
@@ -30,7 +30,8 @@ import {
30
30
  readFileSync,
31
31
  writeFileSync,
32
32
  appendFileSync,
33
- truncateSync,
33
+ renameSync,
34
+ unlinkSync,
34
35
  } from 'node:fs';
35
36
  import { join, dirname } from 'node:path';
36
37
  import { nowIso } from './audit-log.mjs';
@@ -46,6 +47,10 @@ const DEFAULT_MAX_OUTPUT_BYTES = 4096;
46
47
 
47
48
  const NOW_MD_RELATIVE = ['context', 'sessions', 'now.md'];
48
49
  const SESSIONS_DIR_RELATIVE = ['context', 'sessions'];
50
+ // Task 106 (§16.27): the live buffer is CLAIMED by an atomic rename to this
51
+ // suffix before compression, so concurrent PostToolUse/capture-turn appends
52
+ // land on a fresh now.md without racing the truncate.
53
+ const ROLLING_SUFFIX = '.rolling-';
49
54
 
50
55
  // Compression prompt (design §8.4). Written from scratch per the
51
56
  // licensing posture in SOURCES.md (claude-remember's prompts are not
@@ -126,13 +131,74 @@ function dateFromIso(ts) {
126
131
  return ts.slice(0, 10);
127
132
  }
128
133
 
129
- function readNowBuffer(projectRoot) {
130
- const p = readNowMdPath(projectRoot);
131
- if (!existsSync(p)) return '';
134
+ // Task 106 (§16.27 file-rename pattern). ATOMICALLY claim the live buffer:
135
+ // rename now.md → now.md.rolling-{ts}, then read the claimed copy. The rename is
136
+ // atomic on POSIX (rename(2)) + NTFS (MoveFileEx), so a concurrent appender
137
+ // (PostToolUse/capture-turn) that fires DURING the ~5–10s Haiku call lands on a
138
+ // fresh now.md with zero contention — its content is never inside the
139
+ // read→clear window the old `read then truncate(0)` left open. Returns the
140
+ // claimed buffer + the rolling path (null when now.md is absent / the rename
141
+ // raced, which the caller treats as an empty buffer).
142
+ //
143
+ // Bonus property — the rename also SERIALIZES concurrent rolls. compressSession
144
+ // is gated by the 120s cooldown, but the marker is only touched on success, so
145
+ // two callers (a SessionEnd + the Task 105 SessionStart-lazy roll) can both pass
146
+ // the cooldown gate and reach here. Only ONE renameSync wins; the other gets
147
+ // ENOENT (now.md already claimed) → returns an empty buffer → skips. No lock
148
+ // needed; the atomic rename IS the mutex.
149
+ function claimNowBuffer(projectRoot, ts) {
150
+ const nowPath = readNowMdPath(projectRoot);
151
+ if (!existsSync(nowPath)) return { buffer: '', rollingPath: null };
152
+ const rollingPath = nowPath + ROLLING_SUFFIX + String(ts).replace(/[:.]/g, '-');
153
+ try {
154
+ renameSync(nowPath, rollingPath);
155
+ } catch {
156
+ // now.md vanished or the rename lost a race — nothing to roll.
157
+ return { buffer: '', rollingPath: null };
158
+ }
159
+ let buffer = '';
160
+ try {
161
+ buffer = readFileSync(rollingPath, 'utf8');
162
+ } catch {
163
+ buffer = '';
164
+ }
165
+ return { buffer, rollingPath };
166
+ }
167
+
168
+ // Success path: the claimed buffer is safely compressed into today-{date}.md.
169
+ // Drop the rolling file. now.md is owned by the (new) session's appenders now —
170
+ // we do NOT recreate or touch it, so a concurrent append is never clobbered.
171
+ function discardRolling(rollingPath) {
172
+ if (!rollingPath) return;
132
173
  try {
133
- return readFileSync(p, 'utf8');
174
+ unlinkSync(rollingPath);
134
175
  } catch {
135
- return '';
176
+ // best-effort; a leaked rolling file is inert (the next roll claims now.md,
177
+ // not now.md.rolling-*) and harmless beyond disk noise.
178
+ }
179
+ }
180
+
181
+ // Error/timeout path: the claimed buffer was NOT compressed — restore it so the
182
+ // next roll retries it (the old impl's "leave now.md intact" contract). Prepend
183
+ // it to anything a concurrent session appended to the fresh now.md (the claimed
184
+ // content is OLDER, so it leads), preserving both with no truncate. Best-effort:
185
+ // if the restore write fails, the rolling file stays as a recovery breadcrumb.
186
+ function restoreRolling(projectRoot, rollingPath) {
187
+ if (!rollingPath || !existsSync(rollingPath)) return;
188
+ const nowPath = readNowMdPath(projectRoot);
189
+ try {
190
+ const claimed = readFileSync(rollingPath, 'utf8');
191
+ const current = existsSync(nowPath) ? readFileSync(nowPath, 'utf8') : '';
192
+ // Guarantee a newline boundary between the claimed (older) buffer and any
193
+ // concurrent appends. String op, not a regex — a trailing-anchored `\n*$`
194
+ // trips static-analysis's ReDoS heuristic (same convention as slugify in
195
+ // rich-fact.mjs / graduation.mjs).
196
+ const sep = claimed.endsWith('\n') ? '' : '\n';
197
+ const merged = current ? claimed + sep + current : claimed;
198
+ writeFileSync(nowPath, merged, 'utf8');
199
+ unlinkSync(rollingPath);
200
+ } catch {
201
+ // best-effort — see above
136
202
  }
137
203
  }
138
204
 
@@ -146,18 +212,6 @@ function appendToTodayMd({ projectRoot, date, body }) {
146
212
  return path;
147
213
  }
148
214
 
149
- function truncateNowMd(projectRoot) {
150
- const p = readNowMdPath(projectRoot);
151
- if (!existsSync(p)) return;
152
- try {
153
- truncateSync(p, 0);
154
- } catch {
155
- // Best-effort. If truncate fails (perm error etc.), the next
156
- // session compresses a slightly-larger buffer — not a data-loss
157
- // event.
158
- }
159
- }
160
-
161
215
  function writeCompressLogEntry({ projectRoot, date, entry }) {
162
216
  const path = compressLogPath(projectRoot, date);
163
217
  mkdirSync(dirname(path), { recursive: true });
@@ -231,9 +285,13 @@ export async function compressSession({
231
285
  };
232
286
  }
233
287
 
234
- // 2. Read live buffer; no-op if empty (tasks.md 22.1).
235
- const buffer = readNowBuffer(projectRoot);
288
+ // 2. CLAIM the live buffer by atomic rename (Task 106 / §16.27), then read it;
289
+ // no-op if empty (tasks.md 22.1). Claiming before the Haiku call is what
290
+ // closes the race — a concurrent append during compression lands on a
291
+ // fresh now.md, never inside a read→truncate window.
292
+ const { buffer, rollingPath } = claimNowBuffer(projectRoot, ts);
236
293
  if (buffer.trim() === '') {
294
+ discardRolling(rollingPath); // drop the (empty) claimed file if one was renamed
237
295
  const duration_ms = Date.now() - t0;
238
296
  const entry = {
239
297
  ts,
@@ -257,13 +315,14 @@ export async function compressSession({
257
315
  const input_bytes = Buffer.byteLength(buffer, 'utf8');
258
316
  const instructions = buildCompressionInstructions(maxOutputBytes);
259
317
 
260
- // 3. Invoke backend. On throw: leave now.md intact (22.5).
318
+ // 3. Invoke backend. On throw: RESTORE the claimed buffer to now.md (22.5) so
319
+ // the next session-end retries it — the file-rename analogue of the old
320
+ // "leave now.md intact".
261
321
  //
262
322
  // Subprocess timeout: 50_000 ms. Sits under the 60s SessionEnd
263
323
  // hook ceiling (design §5.1) so on timeout the catch + log write
264
- // complete BEFORE Claude Code kills the parent. now.md is left
265
- // intact in the timeout case (the truncate step is reached only
266
- // on the success path), so the next session-end retries naturally.
324
+ // complete BEFORE Claude Code kills the parent including the
325
+ // restoreRolling call, so the buffer is never stranded in the rolling file.
267
326
  // See design §8.5 for the composition rationale.
268
327
  let result;
269
328
  try {
@@ -285,6 +344,8 @@ export async function compressSession({
285
344
  const errorCategory = err instanceof HaikuTimeoutError
286
345
  ? ERROR_CATEGORIES.HAIKU_TIMEOUT
287
346
  : ERROR_CATEGORIES.COMPRESS_FAILED;
347
+ // The claimed buffer wasn't compressed — put it back so it isn't lost.
348
+ restoreRolling(projectRoot, rollingPath);
288
349
  const duration_ms = Date.now() - t0;
289
350
  const entry = {
290
351
  ts,
@@ -316,8 +377,10 @@ export async function compressSession({
316
377
  body: output,
317
378
  });
318
379
 
319
- // 5. Truncate now.md (22.3).
320
- truncateNowMd(projectRoot);
380
+ // 5. The claimed buffer is safely in today-{date}.md — drop the rolling file
381
+ // (Task 106/§16.27). now.md is untouched: any turn the new session appended
382
+ // while we compressed stays put.
383
+ discardRolling(rollingPath);
321
384
 
322
385
  // 6. Touch cooldown marker so the next caller within 120s skips.
323
386
  touchCooldownMarker({ projectRoot, now: ts });
@@ -24,7 +24,7 @@
24
24
  // Note on the allowedTools split: design.md §6.1 documents
25
25
  // `--allowed-tools "Read"`; the code-dive note recommended tightening
26
26
  // to fully empty per claude-remember's actual pattern. This PR
27
- // implements empty per Lior's instruction (the auto-extract sub-Claude
27
+ // implements empty per the user's instruction (the auto-extract sub-Claude
28
28
  // never needs Read either — the turn content arrives in the prompt).
29
29
 
30
30
  import { spawn as defaultSpawn } from 'node:child_process';
@@ -427,6 +427,20 @@ function parseQueue(queueText) {
427
427
  *
428
428
  * Returns { resolved: N, kept_old: N, kept_new: N, merged: N, skipped: N }.
429
429
  */
430
+ /**
431
+ * Pure-read list of PENDING conflict-queue entries (no mutation). Used by the MCP
432
+ * `mk_queue_list` tool so a "list" never rewrites the queue file — unlike
433
+ * resolveConflictQueue, which reserializes on every call. Returns `[]` when the
434
+ * queue file doesn't exist.
435
+ */
436
+ export function listConflictQueue({ tier = 'P', projectRoot, userDir } = {}) {
437
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
438
+ const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
439
+ if (!existsSync(queuePath)) return [];
440
+ const { entries } = parseQueue(readFileSync(queuePath, 'utf8'));
441
+ return entries.filter((e) => e.fields.resolution === 'pending');
442
+ }
443
+
430
444
  export async function resolveConflictQueue({
431
445
  tier,
432
446
  projectRoot,
package/src/doctor.mjs CHANGED
@@ -87,9 +87,9 @@ async function hc1Memsearch() {
87
87
  } catch {
88
88
  // fall through to skip
89
89
  }
90
- // Lior 2026-05-28: make the feature impact explicit so users
90
+ // The user (2026-05-28): make the feature impact explicit so users
91
91
  // understand WHAT THEY LOSE by skipping the install, not just that
92
- // a check failed. Matches Lior's directive: "ask before we do
92
+ // a check failed. Matches the user's directive: "ask before we do
93
93
  // anything, explain if they dont install they dont get certain
94
94
  // features".
95
95
  return {
@@ -345,7 +345,7 @@ function hc5IndexConsistency({ projectRoot }) {
345
345
  // Two false-positives this must avoid (both real):
346
346
  // 1. id-shaped names — the pre-Task-85 regex matched `[PUL]-XXXXXXXX.md`,
347
347
  // which the kit NEVER generates, so HC-5 false-FAILED "missing" on every
348
- // real fact the moment one existed (lior-test-7 2026-06-03).
348
+ // real fact the moment one existed (live-test-7 2026-06-03).
349
349
  // 2. non-fact links — a broad `](...md)` match also catches the scaffold's
350
350
  // own example `- [type] [Title](filename.md)` (inside an HTML comment) and
351
351
  // any prose link like `(design.md)`, which would false-FAIL "stale" on a
package/src/forget.mjs CHANGED
@@ -27,6 +27,8 @@ import { parse, format } from './frontmatter.mjs';
27
27
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
28
28
  import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
29
29
  import { findBulletScratchpad } from './bullet-lookup.mjs';
30
+ import { openIndexDb } from './index-db.mjs';
31
+ import { reindexBoot } from './index-rebuild.mjs';
30
32
 
31
33
  // Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
32
34
  // minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
@@ -290,6 +292,32 @@ export function forget(opts = {}) {
290
292
  },
291
293
  });
292
294
 
295
+ // Task 110 (F-7 / D-84): reindex the project tier IN-BAND so the just-
296
+ // tombstoned fact stops surfacing in `cmk search` immediately — no manual
297
+ // `cmk reindex`, no forgotten fact resurfacing (D-85: the action completes
298
+ // automatically; the regular user runs no follow-up command). reindexBoot's
299
+ // orphan-prune drops the unlinked fact's index rows and re-reads the scrubbed
300
+ // scratchpads in one pass. Both `cmk forget` (CLI) and `mk_forget` (MCP) call
301
+ // this same forget(), so both surfaces get it. Best-effort: the fact is
302
+ // ALREADY tombstoned + scrubbed on disk, so an index error must not fail the
303
+ // forget — every index reader lazy-reindexes (also orphan-pruning) and self-
304
+ // heals on the next read. A pure user-tier forget (no projectRoot) has no
305
+ // project index to touch and skips this.
306
+ let reindexed = false;
307
+ if (projectRoot) {
308
+ try {
309
+ const db = openIndexDb({ projectRoot });
310
+ try {
311
+ reindexBoot({ projectRoot, userDir, db });
312
+ reindexed = true;
313
+ } finally {
314
+ db.close();
315
+ }
316
+ } catch {
317
+ // best-effort — the on-disk tombstone is authoritative; search self-heals.
318
+ }
319
+ }
320
+
293
321
  return {
294
322
  action: 'tombstoned',
295
323
  id: match.id,
@@ -297,6 +325,7 @@ export function forget(opts = {}) {
297
325
  originalPath: match.path,
298
326
  tombstonePath,
299
327
  scratchpadEdits,
328
+ reindexed,
300
329
  };
301
330
  }
302
331
 
@@ -35,7 +35,7 @@ const VALID_WRITE_SOURCES = new Set([
35
35
 
36
36
  function slugify(s) {
37
37
  // Collapse non-alphanumerics to single dashes, cap, trim edges (string ops,
38
- // no trailing-dash quantifier — matches subcommands.slugifyFact's ReDoS-safe
38
+ // no trailing-dash quantifier — matches rich-fact.slugifyFact's ReDoS-safe
39
39
  // shape).
40
40
  let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
41
41
  if (base.startsWith('-')) base = base.slice(1);
@@ -403,10 +403,52 @@ export function reindexBoot({ projectRoot, userDir, db, now }) {
403
403
  observationsAffected += n;
404
404
  }
405
405
 
406
+ // Prune orphans (Task 110 / F-7). The walk above only ADDS/UPDATES files that
407
+ // still exist; a file removed since the last index (e.g. a fact `cmk forget`
408
+ // moved to archive/tombstones/, or a queue-discard) leaves its observation
409
+ // rows behind, so the forgotten fact keeps surfacing in `cmk search` until a
410
+ // manual `reindex --full`. Drop any `files` checkpoint whose source is no
411
+ // longer on disk, plus its observations (the FTS5 delete trigger fires per
412
+ // row). This makes boot a full sync (add/update/DELETE), so every index
413
+ // reader — all of which lazy-call reindexBoot first — self-heals after any
414
+ // removal with no manual command (the D-85 "everything automatic" contract).
415
+ //
416
+ // SAFETY (composition guard): the prune deletes any known row NOT in the
417
+ // current live-set, so it is only sound when the live-set is COMPLETE across
418
+ // every tier the index covers. The U tier is walked only when `userDir` is
419
+ // provided; without it, U sources are absent from `liveRelPaths` and a real
420
+ // U-tier row would be mis-pruned as an orphan. So we prune ONLY when userDir
421
+ // is present (P + L + U all walked). When it's absent we skip — the next
422
+ // reader that passes userDir (every `cmk search`/`get`/… does) self-heals.
423
+ // (projectRoot is always present here — it's required to open the db.)
424
+ let filesPruned = 0;
425
+ let observationsPruned = 0;
426
+ if (userDir) {
427
+ const liveRelPaths = new Set(
428
+ sources.map((s) => relativeSource(s.path, { projectRoot, userDir })),
429
+ );
430
+ const pruneTxn = db.transaction((relPath, obsCount) => {
431
+ db.prepare(DELETE_OBSERVATIONS_FOR_PATH_SQL).run(relPath);
432
+ db.prepare('DELETE FROM files WHERE path = ?').run(relPath);
433
+ filesPruned++;
434
+ observationsPruned += obsCount;
435
+ });
436
+ const knownPaths = db.prepare('SELECT path FROM files').all();
437
+ for (const { path: relPath } of knownPaths) {
438
+ if (liveRelPaths.has(relPath)) continue;
439
+ const obsCount = db
440
+ .prepare('SELECT COUNT(*) AS n FROM observations WHERE source_file = ?')
441
+ .get(relPath).n;
442
+ pruneTxn(relPath, obsCount);
443
+ }
444
+ }
445
+
406
446
  return {
407
447
  filesScanned,
408
448
  filesReindexed,
409
449
  observationsAffected,
450
+ filesPruned,
451
+ observationsPruned,
410
452
  durationMs: Date.now() - t0,
411
453
  skipped,
412
454
  };
@@ -740,7 +740,11 @@ export function injectContext({
740
740
  try {
741
741
  const verdict = detectStaleness({ projectRoot, now: ts });
742
742
  lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
743
- if (verdict.action === 'stale-daily' || verdict.action === 'stale-weekly') {
743
+ if (
744
+ verdict.action === 'stale-now' ||
745
+ verdict.action === 'stale-daily' ||
746
+ verdict.action === 'stale-weekly'
747
+ ) {
744
748
  const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
745
749
  const spawnResult = spawner(projectRoot, compressLazyPath);
746
750
  lazyTrigger = { ...lazyTrigger, ...spawnResult };
package/src/install.mjs CHANGED
@@ -42,7 +42,7 @@ import { homedir } from 'node:os';
42
42
  import { basename, dirname, join, relative, resolve } from 'node:path';
43
43
  import { fileURLToPath } from 'node:url';
44
44
  import { injectClaudeMdBlock } from './claude-md.mjs';
45
- import { writeKitHooks } from './settings-hooks.mjs';
45
+ import { writeKitHooks, writeKitMcpServer } from './settings-hooks.mjs';
46
46
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
47
47
 
48
48
  const __filename = fileURLToPath(import.meta.url);
@@ -51,9 +51,17 @@ const CLI_SRC_DIR = dirname(__filename);
51
51
  const REPO_ROOT_DEV = resolve(CLI_SRC_DIR, '..', '..', '..');
52
52
  const CLI_PKG_DIR = resolve(CLI_SRC_DIR, '..');
53
53
 
54
- const GITIGNORE_START = '# claude-memory-kit:gitignore:start v0.1.0';
54
+ // The start marker carries the install version (matching the CLAUDE.md block,
55
+ // which is load-bearing for upgrade detection). The replace-regex in
56
+ // injectGitignore ignores the version, so it's cosmetic for idempotency — but
57
+ // it must not show a stale hardcode (was `v0.1.0` in every install). Built per
58
+ // install from the kit version; see gitignoreStartMarker().
55
59
  const GITIGNORE_END = '# claude-memory-kit:gitignore:end';
56
60
 
61
+ function gitignoreStartMarker(version) {
62
+ return `# claude-memory-kit:gitignore:start v${version}`;
63
+ }
64
+
57
65
  /**
58
66
  * Read the kit version from the cli package's package.json.
59
67
  * Used as the default version for the CLAUDE.md marker.
@@ -216,12 +224,12 @@ function installTier(srcDir, destDir, { created, skipped, errors, vars }) {
216
224
  * Build the canonical .gitignore managed block from template/.gitignore.fragment.
217
225
  * Adds start/end markers around the fragment so we can refresh in place.
218
226
  */
219
- function buildGitignoreBlock(templateDir) {
227
+ function buildGitignoreBlock(templateDir, version = getKitVersion()) {
220
228
  const fragmentPath = join(templateDir, '.gitignore.fragment');
221
229
  const fragment = existsSync(fragmentPath)
222
230
  ? readFileSync(fragmentPath, 'utf8').trim()
223
231
  : 'context.local/\ncontext/.index/\ncontext/.locks/';
224
- return `${GITIGNORE_START}\n${fragment}\n${GITIGNORE_END}\n`;
232
+ return `${gitignoreStartMarker(version)}\n${fragment}\n${GITIGNORE_END}\n`;
225
233
  }
226
234
 
227
235
  /**
@@ -318,7 +326,7 @@ export async function install(options = {}) {
318
326
  });
319
327
  }
320
328
 
321
- const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
329
+ const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir, version));
322
330
 
323
331
  // CLAUDE.md loader block — Task 4. Read the block content from the kit's
324
332
  // template/ and inject (or refresh) it inside marker delimiters. Never
@@ -352,6 +360,11 @@ export async function install(options = {}) {
352
360
  // hooks is a no-op. Opt out with {noHooks:true} (CLI: --no-hooks) for
353
361
  // scaffold-only installs.
354
362
  let hooks = { action: 'skipped', path: join(projectRoot, '.claude', 'settings.json') };
363
+ // Task 108b — register the kit's MCP server (.mcp.json) so the model can drive
364
+ // memory ops as allow-listed tools (the `mcp__cmk__*` rule writeKitHooks adds),
365
+ // not just `cmk` bash. Same {noHooks} opt-out as the hooks (it's Claude Code
366
+ // wiring). R2 / D-80 fix.
367
+ let mcpServer = { action: 'skipped', path: join(projectRoot, '.mcp.json') };
355
368
  if (!options.noHooks) {
356
369
  const settingsPath = join(projectRoot, '.claude', 'settings.json');
357
370
  const r = writeKitHooks(settingsPath);
@@ -388,7 +401,17 @@ export async function install(options = {}) {
388
401
  }
389
402
  }
390
403
 
391
- return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, errors };
404
+ if (!options.noHooks) {
405
+ const r = writeKitMcpServer(projectRoot);
406
+ if (r.error) {
407
+ errors.push({ path: r.path, error: r.error });
408
+ mcpServer = { action: 'error', path: r.path, error: r.error };
409
+ } else {
410
+ mcpServer = { action: r.changed ? 'registered' : 'unchanged', path: r.path };
411
+ }
412
+ }
413
+
414
+ return { projectRoot, userTier, created, skipped, gitignore, claudeMd, hooks, mcpServer, errors };
392
415
  }
393
416
 
394
417
  /**
@@ -29,6 +29,7 @@ import {
29
29
  existsSync,
30
30
  mkdirSync,
31
31
  readdirSync,
32
+ readFileSync,
32
33
  statSync,
33
34
  writeFileSync,
34
35
  unlinkSync,
@@ -42,11 +43,13 @@ import {
42
43
  } from './cooldown.mjs';
43
44
  import { dailyDistill } from './daily-distill.mjs';
44
45
  import { weeklyCurate } from './weekly-curate.mjs';
46
+ import { compressSession } from './compress-session.mjs';
45
47
 
46
48
  const DEFAULT_DAILY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
47
49
  const DEFAULT_WEEKLY_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
48
50
  const SESSIONS_REL = ['context', 'sessions'];
49
51
  const LOCKS_REL = ['context', '.locks'];
52
+ const NOW_MD_REL = ['context', 'sessions', 'now.md'];
50
53
  const RECENT_MD_REL = ['context', 'sessions', 'recent.md'];
51
54
  const CRON_SENTINEL_REL = ['context', '.locks', 'cron-registered'];
52
55
  const LAZY_LOG_REL = ['context', '.locks', 'lazy-compress.log'];
@@ -100,6 +103,25 @@ function listTodayFiles(projectRoot) {
100
103
  return matches;
101
104
  }
102
105
 
106
+ // Task 105 (D-75): does now.md carry prior-session content? The now→today
107
+ // roll (compressSession) fires only at SessionEnd, and Claude Code fires
108
+ // SessionEnd ONLY on a clean window-close — so a never-cleanly-closed session
109
+ // leaves now.md growing unbounded with no today-*.md/recent.md built. We detect
110
+ // a non-empty now.md at SessionStart and let the lazy worker roll it. At
111
+ // SessionStart now.md can only hold PRIOR-session turns (this session's
112
+ // capture-turn writes haven't fired yet), so non-empty ⇒ stale. Emptiness must
113
+ // match compressSession's own `buffer.trim() === ''` check so the spawn verdict
114
+ // and the actual roll agree (else we'd spawn for a roll that immediately skips).
115
+ function nowMdHasContent(projectRoot) {
116
+ const p = join(projectRoot, ...NOW_MD_REL);
117
+ if (!existsSync(p)) return false;
118
+ try {
119
+ return readFileSync(p, 'utf8').trim() !== '';
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
103
125
  function recentMdMtimeMs(projectRoot) {
104
126
  const p = join(projectRoot, ...RECENT_MD_REL);
105
127
  if (!existsSync(p)) return null;
@@ -113,9 +135,11 @@ function recentMdMtimeMs(projectRoot) {
113
135
  /**
114
136
  * Cheap inline staleness check. Runs in <5ms — one stat + a few existsSync.
115
137
  *
116
- * Verdict semantics:
138
+ * Verdict semantics (precedence: cron > no-context-dir > now > weekly > daily > fresh):
117
139
  * - 'cron-active' : sentinel exists; cron will handle staleness. No-op.
118
140
  * - 'no-context-dir': context/sessions/ doesn't exist. No-op (kit not installed).
141
+ * - 'stale-now' : now.md carries prior-session content (Task 105/D-75) — the
142
+ * now→today roll the SessionEnd hook would have done.
119
143
  * - 'stale-weekly' : ANY today-*.md older than 7d exists. Weekly curate needed.
120
144
  * - 'stale-daily' : no OLD today files, but recent.md is missing OR older than dailyTtlMs.
121
145
  * - 'fresh' : recent.md exists + younger than dailyTtlMs AND no OLD today files.
@@ -141,6 +165,16 @@ export function detectStaleness({
141
165
  return { action: 'no-context-dir', reason: 'sessions-dir-missing' };
142
166
  }
143
167
 
168
+ // Task 105 (D-75): a non-empty now.md is the now→today roll the SessionEnd
169
+ // hook would have done. It takes PRECEDENCE over daily/weekly because it's
170
+ // the FIRST pipeline level (now → today → recent → archive) — roll it this
171
+ // SessionStart; the today→recent + weekly levels cascade on subsequent
172
+ // SessionStarts once now.md is drained. (cron-active above still wins — a
173
+ // registered cron owns the whole pipeline.)
174
+ if (nowMdHasContent(projectRoot)) {
175
+ return { action: 'stale-now', reason: 'now-md-has-prior-session-content' };
176
+ }
177
+
144
178
  const ts = now ?? nowIso();
145
179
  const nowMs = new Date(ts).getTime();
146
180
  const files = listTodayFiles(projectRoot);
@@ -281,12 +315,24 @@ export async function runLazyCompress({
281
315
  return { action: 'skipped', reason: verdict.reason, duration_ms };
282
316
  }
283
317
 
284
- // verdict.action is 'stale-daily' or 'stale-weekly'.
285
- // Delegate to the appropriate cycle, passing cooldownMs=0 because we
286
- // already gated above; the inner call shouldn't gate a second time on
287
- // the same marker (which they would not touch yet).
318
+ // verdict.action is 'stale-now', 'stale-daily', or 'stale-weekly'.
319
+ // Delegate to the matching pipeline stage, passing cooldownMs=0 because we
320
+ // already gated above; the inner call shouldn't gate a second time on the
321
+ // same marker. Task 105: 'stale-now' rolls now.md → today-*.md via
322
+ // compressSession (the level the SessionEnd hook owns); the today→recent +
323
+ // weekly levels cascade on the next SessionStart once now.md is drained.
288
324
  let result;
289
- if (verdict.action === 'stale-weekly') {
325
+ let delegatedTo;
326
+ if (verdict.action === 'stale-now') {
327
+ delegatedTo = 'compress-session';
328
+ result = await compressSession({
329
+ projectRoot,
330
+ backend,
331
+ now: ts,
332
+ cooldownMs: 0,
333
+ });
334
+ } else if (verdict.action === 'stale-weekly') {
335
+ delegatedTo = 'weekly-curate';
290
336
  result = await weeklyCurate({
291
337
  projectRoot,
292
338
  backend,
@@ -294,6 +340,7 @@ export async function runLazyCompress({
294
340
  cooldownMs: 0,
295
341
  });
296
342
  } else {
343
+ delegatedTo = 'daily-distill';
297
344
  result = await dailyDistill({
298
345
  projectRoot,
299
346
  backend,
@@ -310,17 +357,19 @@ export async function runLazyCompress({
310
357
  scope: 'lazy-compress',
311
358
  action: result?.action ?? 'unknown',
312
359
  verdict: verdict.action,
313
- delegated_to: verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
360
+ delegated_to: delegatedTo,
314
361
  duration_ms,
315
362
  success: result?.action !== 'error',
316
363
  ...(result?.errorCategory ? { error_category: result.errorCategory } : {}),
364
+ // compress-session reports its error via error_category (snake) — pass it
365
+ // through too so the lazy log captures either shape.
366
+ ...(result?.error_category ? { error_category: result.error_category } : {}),
317
367
  },
318
368
  });
319
369
  return {
320
370
  ...result,
321
371
  verdict: verdict.action,
322
- delegatedTo:
323
- verdict.action === 'stale-weekly' ? 'weekly-curate' : 'daily-distill',
372
+ delegatedTo,
324
373
  duration_ms,
325
374
  };
326
375
  }