@lh8ppl/claude-memory-kit 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -6,10 +6,10 @@
6
6
 
7
7
  - **Cross-project persona — the wedge (v0.2)** — when you state how you work *everywhere* ("always use uv, never pip", "from now on run the linter before committing"), the per-turn auto-extract promotes it into your **user tier** (`~/.claude-memory-kit/`) **that turn**. So a brand-new project **cold-opens already knowing your style** — layered structure, your tooling, your testing discipline — with no hand-curation and no waiting. Carry it between your own machines with `cmk persona export`/`import`, or pin a single fact across projects with `cmk lessons promote`.
8
8
  - **Frozen snapshot at session start** — MEMORY.md + USER.md + SOUL.md + INDEX.md + today's session log inject once at the first tool call, so Claude sees your context every session without you re-telling it.
9
- - **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts (decisions, preferences, environment) to memory. No manual writes needed.
9
+ - **Auto-extract on every assistant turn** — a background `claude --print` subagent reads each turn and saves durable facts to memory. Durable project knowledge (setup/config, conventions, workflows, tool quirks) becomes a **rich Why/How fact file** (structured + searchable); lighter signals stay terse `MEMORY.md` bullets. Runs automatically, so the rich tier survives even when the model uses Claude Code's built-in memory instead. No manual writes needed.
10
10
  - **Explicit capture when you want it** — say "remember this" / "from now on" / "we decided" / "forget X" (the `memory-write` skill), or run `cmk remember "<fact>"`. Both dedup, screen for secrets, abstract machine paths to `~`, and write silently.
11
11
  - **Search + MCP** — `cmk search "<term>"` (keyword/hybrid over facts + scratchpads); `cmk mcp` exposes the same to Claude Code as tools.
12
- - **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows.
12
+ - **Bounded by compression** — session → daily → weekly Haiku rollups (cron or lazy-on-read) keep the snapshot small as history grows. The session-buffer rollup self-heals at session start too, so memory stays bounded even if you never cleanly close the window.
13
13
  - **Per-project, in-repo** — `context/` lives inside your project and travels with `git clone`. Each project keeps its own memory.
14
14
  - **9 health checks** — `cmk doctor` validates install, hook wiring, distill freshness, INDEX consistency, cron registration, and stale locks.
15
15
 
@@ -11,7 +11,6 @@
11
11
  // transcript, emit {"continue": true}. Always exit 0 — a hook that errors
12
12
  // would interrupt the user mid-prompt.
13
13
 
14
- import { readFileSync } from 'node:fs';
15
14
  import { dirname, join } from 'node:path';
16
15
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
16
 
@@ -19,35 +18,36 @@ function emitContinue() {
19
18
  process.stdout.write('{"continue": true}');
20
19
  }
21
20
 
22
- let rawInput = '';
23
- try {
24
- rawInput = readFileSync(0, 'utf8');
25
- } catch {
26
- emitContinue();
27
- process.exit(0);
28
- }
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
24
+ const modulePath = join(__dirname, '..', 'src', 'capture-prompt.mjs');
29
25
 
30
- let payload;
26
+ let readHookStdin;
27
+ let capturePrompt;
31
28
  try {
32
- payload = rawInput.trim() === '' ? {} : JSON.parse(rawInput);
29
+ ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
30
+ ({ capturePrompt } = await import(pathToFileURL(modulePath).href));
33
31
  } catch (err) {
34
32
  process.stderr.write(
35
- `cmk-capture-prompt: failed to parse stdin JSON: ${err?.message ?? err}\n`,
33
+ `cmk-capture-prompt: failed to load modules: ${err?.message ?? err}\n`,
36
34
  );
37
35
  emitContinue();
38
36
  process.exit(0);
39
37
  }
40
38
 
41
- const __filename = fileURLToPath(import.meta.url);
42
- const __dirname = dirname(__filename);
43
- const modulePath = join(__dirname, '..', 'src', 'capture-prompt.mjs');
39
+ // Drain the hook payload — but NOT on an interactive TTY (a manual run):
40
+ // a blocking stdin read would hang forever on a console that never sends EOF, before
41
+ // any body runs (Task 101; DECISION-LOG 2026-06-06). readHookStdin returns ''
42
+ // for a TTY so a manual invocation finishes instead of hanging.
43
+ const rawInput = readHookStdin({ isTTY: process.stdin.isTTY });
44
44
 
45
- let capturePrompt;
45
+ let payload;
46
46
  try {
47
- ({ capturePrompt } = await import(pathToFileURL(modulePath).href));
47
+ payload = rawInput.trim() === '' ? {} : JSON.parse(rawInput);
48
48
  } catch (err) {
49
49
  process.stderr.write(
50
- `cmk-capture-prompt: failed to load module: ${err?.message ?? err}\n`,
50
+ `cmk-capture-prompt: failed to parse stdin JSON: ${err?.message ?? err}\n`,
51
51
  );
52
52
  emitContinue();
53
53
  process.exit(0);
@@ -13,7 +13,7 @@
13
13
  // append to transcripts, spawn detached auto-extract, emit
14
14
  // {"continue": true}, exit 0 within ~50ms (NFR-1). Always exit 0.
15
15
 
16
- import { readFileSync, existsSync } from 'node:fs';
16
+ import { existsSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
 
@@ -21,27 +21,9 @@ function emitContinue() {
21
21
  process.stdout.write('{"continue": true}');
22
22
  }
23
23
 
24
- let raw = '';
25
- try {
26
- raw = readFileSync(0, 'utf8');
27
- } catch {
28
- emitContinue();
29
- process.exit(0);
30
- }
31
-
32
- let payload;
33
- try {
34
- payload = raw.trim() === '' ? {} : JSON.parse(raw);
35
- } catch (err) {
36
- process.stderr.write(
37
- `cmk-capture-turn: failed to parse stdin JSON: ${err?.message ?? err}\n`,
38
- );
39
- emitContinue();
40
- process.exit(0);
41
- }
42
-
43
24
  const __filename = fileURLToPath(import.meta.url);
44
25
  const __dirname = dirname(__filename);
26
+ const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
45
27
  const modulePath = join(__dirname, '..', 'src', 'capture-turn.mjs');
46
28
 
47
29
  // Auto-extract path: env override → sibling cmk-auto-extract.mjs (ships
@@ -53,12 +35,31 @@ const autoExtractPath =
53
35
  ? join(__dirname, 'cmk-auto-extract.mjs')
54
36
  : null);
55
37
 
38
+ let readHookStdin;
56
39
  let captureTurn;
57
40
  try {
41
+ ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
58
42
  ({ captureTurn } = await import(pathToFileURL(modulePath).href));
59
43
  } catch (err) {
60
44
  process.stderr.write(
61
- `cmk-capture-turn: failed to load module: ${err?.message ?? err}\n`,
45
+ `cmk-capture-turn: failed to load modules: ${err?.message ?? err}\n`,
46
+ );
47
+ emitContinue();
48
+ process.exit(0);
49
+ }
50
+
51
+ // Drain the hook payload — but NOT on an interactive TTY (a manual run):
52
+ // a blocking stdin read would hang forever on a console that never sends EOF, before
53
+ // any body runs (Task 101; DECISION-LOG 2026-06-06). readHookStdin returns ''
54
+ // for a TTY so a manual invocation finishes instead of hanging.
55
+ const raw = readHookStdin({ isTTY: process.stdin.isTTY });
56
+
57
+ let payload;
58
+ try {
59
+ payload = raw.trim() === '' ? {} : JSON.parse(raw);
60
+ } catch (err) {
61
+ process.stderr.write(
62
+ `cmk-capture-turn: failed to parse stdin JSON: ${err?.message ?? err}\n`,
62
63
  );
63
64
  emitContinue();
64
65
  process.exit(0);
@@ -43,8 +43,8 @@ try {
43
43
  }
44
44
 
45
45
  // Drain the hook payload so Claude Code's pipe closes cleanly — but NOT when
46
- // stdin is an interactive TTY (a manual run): readFileSync(0) would block
47
- // forever on a console that never sends EOF, hanging before any of the body
46
+ // stdin is an interactive TTY (a manual run): a blocking stdin read would hang
47
+ // forever on a console that never sends EOF, before any of the body
48
48
  // runs (DECISION-LOG 2026-06-06). The payload is discarded; we read state from
49
49
  // disk. readHookStdin returns '' for a TTY so a manual invocation finishes.
50
50
  readHookStdin({ isTTY: process.stdin.isTTY });
@@ -14,19 +14,13 @@
14
14
  // unconditionally — a throwing SessionStart hook would interrupt
15
15
  // session start, worse than an empty additionalContext.
16
16
 
17
- import { readFileSync, existsSync } from 'node:fs';
17
+ import { existsSync } from 'node:fs';
18
18
  import { dirname, join } from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
 
21
- // Drain stdin so callers blocking on EPIPE don't hang.
22
- try {
23
- readFileSync(0, 'utf8');
24
- } catch {
25
- // stdin not connected; fine.
26
- }
27
-
28
21
  const __filename = fileURLToPath(import.meta.url);
29
22
  const __dirname = dirname(__filename);
23
+ const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
30
24
  const modulePath = join(__dirname, '..', 'src', 'inject-context.mjs');
31
25
 
32
26
  // Resolve the sibling lazy-compress bin (ships in this same bin/ dir) so
@@ -39,14 +33,14 @@ const compressLazyPath =
39
33
  ? join(__dirname, 'cmk-compress-lazy.mjs')
40
34
  : null);
41
35
 
36
+ let readHookStdin;
42
37
  let injectContext;
43
38
  try {
39
+ ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
44
40
  ({ injectContext } = await import(pathToFileURL(modulePath).href));
45
41
  } catch (err) {
46
42
  process.stderr.write(
47
- `cmk-inject-context: failed to load module at ${modulePath}: ${
48
- err?.message ?? String(err)
49
- }\n`,
43
+ `cmk-inject-context: failed to load modules: ${err?.message ?? String(err)}\n`,
50
44
  );
51
45
  process.stdout.write(
52
46
  JSON.stringify({
@@ -59,6 +53,12 @@ try {
59
53
  process.exit(0);
60
54
  }
61
55
 
56
+ // Drain stdin so callers blocking on EPIPE don't hang — but NOT on an
57
+ // interactive TTY (a manual run): a blocking stdin read would hang forever on a
58
+ // console that never sends EOF (Task 101; DECISION-LOG 2026-06-06). The payload
59
+ // is discarded; readHookStdin returns '' for a TTY so a manual run finishes.
60
+ readHookStdin({ isTTY: process.stdin.isTTY });
61
+
62
62
  try {
63
63
  const r = injectContext({ cwd: process.cwd(), compressLazyPath });
64
64
  process.stdout.write(JSON.stringify(r.hookOutput));
@@ -11,37 +11,38 @@
11
11
  // must never surface in the user's session. The append is
12
12
  // fire-and-forget by design.
13
13
 
14
- import { readFileSync } from 'node:fs';
15
14
  import { dirname, join } from 'node:path';
16
15
  import { fileURLToPath, pathToFileURL } from 'node:url';
17
16
 
18
- let raw = '';
19
- try {
20
- raw = readFileSync(0, 'utf8');
21
- } catch {
22
- process.exit(0);
23
- }
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = dirname(__filename);
19
+ const readHookStdinPath = join(__dirname, '..', 'src', 'read-hook-stdin.mjs');
20
+ const modulePath = join(__dirname, '..', 'src', 'observe-edit.mjs');
24
21
 
25
- let payload;
22
+ let readHookStdin;
23
+ let observeEdit;
26
24
  try {
27
- payload = raw.trim() === '' ? {} : JSON.parse(raw);
25
+ ({ readHookStdin } = await import(pathToFileURL(readHookStdinPath).href));
26
+ ({ observeEdit } = await import(pathToFileURL(modulePath).href));
28
27
  } catch (err) {
29
28
  process.stderr.write(
30
- `cmk-observe-edit: failed to parse stdin JSON: ${err?.message ?? err}\n`,
29
+ `cmk-observe-edit: failed to load modules: ${err?.message ?? err}\n`,
31
30
  );
32
31
  process.exit(0);
33
32
  }
34
33
 
35
- const __filename = fileURLToPath(import.meta.url);
36
- const __dirname = dirname(__filename);
37
- const modulePath = join(__dirname, '..', 'src', 'observe-edit.mjs');
34
+ // Drain the hook payload — but NOT on an interactive TTY (a manual run):
35
+ // a blocking stdin read would hang forever on a console that never sends EOF, before
36
+ // any body runs (Task 101; DECISION-LOG 2026-06-06). readHookStdin returns ''
37
+ // for a TTY so a manual invocation finishes instead of hanging.
38
+ const raw = readHookStdin({ isTTY: process.stdin.isTTY });
38
39
 
39
- let observeEdit;
40
+ let payload;
40
41
  try {
41
- ({ observeEdit } = await import(pathToFileURL(modulePath).href));
42
+ payload = raw.trim() === '' ? {} : JSON.parse(raw);
42
43
  } catch (err) {
43
44
  process.stderr.write(
44
- `cmk-observe-edit: failed to load module: ${err?.message ?? err}\n`,
45
+ `cmk-observe-edit: failed to parse stdin JSON: ${err?.message ?? err}\n`,
45
46
  );
46
47
  process.exit(0);
47
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lh8ppl/claude-memory-kit",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "cmk — the CLI for claude-memory-kit. Per-project, in-repo memory system for Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,8 +48,11 @@ import {
48
48
  appendFileSync,
49
49
  } from 'node:fs';
50
50
  import { join, dirname } from 'node:path';
51
+ import { createHash } from 'node:crypto';
51
52
  import { generateId } from '@lh8ppl/cmk-canonicalize';
52
53
  import { memoryWrite } from './memory-write.mjs';
54
+ import { writeFact } from './write-fact.mjs';
55
+ import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
53
56
  import { HaikuTimeoutError } from './compressor.mjs';
54
57
  import { pidIsAlive } from './lock-discipline.mjs';
55
58
  import { nowIso } from './audit-log.mjs';
@@ -284,6 +287,21 @@ export function buildExtractionInstructions() {
284
287
  '',
285
288
  'Note: assistant-origin candidates are auto-demoted one trust level before routing (HIGH → MEDIUM → LOW → discarded). This is intentional — assistant inferences need user review. Emit your honest trust assessment; the routing layer handles demotion.',
286
289
  '',
290
+ 'ALSO — rich fact files (durable project KNOWLEDGE). This is a SEPARATE output from the terse TRUST_ lines. When a turn reveals a durable, substantive piece of project knowledge worth a FULL record — a setup/configuration fact (trigger 3), a project convention (trigger 4), a completed multi-step workflow worth recording (trigger 5), or a tool quirk/workaround (trigger 6) — emit a BEGIN_FACT block (below) INSTEAD OF a terse TRUST_ line for it. Keep terse TRUST_ lines for the LIGHTER signals: user corrections and discovered preferences (triggers 1–2) and active threads. Emit each fact EITHER as a rich BEGIN_FACT block OR as a terse TRUST_ line — NEVER both.',
291
+ 'Format (one block per durable fact):',
292
+ ' BEGIN_FACT',
293
+ ' type: project',
294
+ ' title: <short Title-Case headline, ≤ 80 chars>',
295
+ ' body: <what is true; if it has parts, give a short labelled markdown breakdown over multiple lines, NOT one vague sentence>',
296
+ ' why: <why it is true / why it matters — the rationale a future session needs>',
297
+ ' how: <how the next session should apply it>',
298
+ ' END_FACT',
299
+ 'Rules for BEGIN_FACT blocks:',
300
+ ' - body may span multiple lines (markdown bullets are encouraged when the knowledge has parts — make the saved fact genuinely useful to a future session, at least as detailed as a careful hand-written note). Write it as plain markdown on the lines after `body:` — do NOT use a YAML block scalar (`|` or `>`).',
301
+ ' - title AND body are required; why/how are strongly preferred but optional. type defaults to project.',
302
+ ' - Do NOT invent facts; synthesize only what the turn shows. Never put a secret, token, password, or key in a block.',
303
+ ' - These facts are saved automatically (no review step), so be selective: only genuinely durable knowledge, at most a few per turn.',
304
+ '',
287
305
  'ALSO — cross-project doctrine. This is a REQUIRED, PER-FACT pass, separate from the TRUST_ lines above. Re-scan the SAME turn for EVERY fact that expresses how this user works in ALL their projects (tooling habits, how they structure their work, communication / process style — NOT specifics that belong to this ONE project, like a particular value, name, or detail that would not carry to their other projects). **For EACH such cross-project fact, emit its OWN PERSONA CANDIDATE line — one line per fact. If the turn states THREE cross-project rules, emit THREE PERSONA CANDIDATE lines. Never collapse several rules into one line, and never skip a rule because the turn is busy or already has TRUST_ lines.** Format (one line per cross-project fact):',
288
306
  ' PERSONA CANDIDATE | target=<HABITS.md|LESSONS.md|USER.md> | section=<Section> | confidence=<high|medium|low> | <one-line restatement>',
289
307
  ' - HABITS.md → sections: Iteration Cadence | Destructive Operations | Communication Style',
@@ -310,7 +328,11 @@ function buildExtractionPrompt({ userTurn, assistantTurn, dedupContext }) {
310
328
  return sections.join('\n');
311
329
  }
312
330
 
313
- function parseCandidates(haikuOutput) {
331
+ // Exported for the live-Haiku smoke (spawn-smoke-auto-extract-rich.test.js),
332
+ // which asserts the enriched prompt still elicits parseable terse OR rich
333
+ // output from real Haiku. The terse format is the extraction prompt's contract,
334
+ // same as parseRichFacts above.
335
+ export function parseCandidates(haikuOutput) {
314
336
  if (!haikuOutput || typeof haikuOutput !== 'string') return [];
315
337
  const lines = haikuOutput.split('\n');
316
338
  const candidates = [];
@@ -328,6 +350,127 @@ function parseCandidates(haikuOutput) {
328
350
  return candidates;
329
351
  }
330
352
 
353
+ // --- Rich-fact parser (Task 103) ------------------------------------
354
+
355
+ // Durable project KNOWLEDGE (the six triggers' config / convention / workflow /
356
+ // quirk facts) is emitted by Haiku as a fenced block, parsed here into the
357
+ // fields writeFact() needs. Lives next to parseCandidates + buildExtraction-
358
+ // Instructions — the format and its parser stay together (same as the terse
359
+ // TRUST_ surface). See design §6.4.
360
+ //
361
+ // BEGIN_FACT
362
+ // type: project
363
+ // title: <short title>
364
+ // body: <summary; MAY continue as markdown bullets on following lines>
365
+ // why: <rationale>
366
+ // how: <how to apply>
367
+ // END_FACT
368
+ //
369
+ // A field's value continues across lines until the next recognized key or the
370
+ // block close — so `body` can hold a multi-line structured breakdown (the
371
+ // native-parity bar). type defaults to 'project' when absent/invalid; a block
372
+ // missing title OR body is skipped (writeFact requires both).
373
+ const RICH_FACT_VALID_TYPES = new Set(['user', 'feedback', 'project', 'reference']);
374
+ const RICH_FACT_KEYS = new Set(['type', 'title', 'body', 'why', 'how']);
375
+ // Defensive per-field cap so a runaway block can't write an unbounded fact body.
376
+ const RICH_FACT_FIELD_CAP = 4000;
377
+
378
+ // Match a `key: value` field line. String-based (not a regex) — deterministically
379
+ // linear, no backtracking surface. Semantics: the key must be at the START of
380
+ // the line (no leading whitespace, mirroring an `^key` anchor), with optional
381
+ // whitespace before the colon. Returns {key, value} or null (a continuation /
382
+ // non-key line, e.g. a `- bullet:` inside a body).
383
+ function matchRichFactKey(line) {
384
+ const idx = line.indexOf(':');
385
+ if (idx <= 0) return null;
386
+ const keyPart = line.slice(0, idx);
387
+ if (keyPart.trimStart().length !== keyPart.length) return null; // leading ws → not a key
388
+ const key = keyPart.trimEnd().toLowerCase();
389
+ if (!RICH_FACT_KEYS.has(key)) return null;
390
+ return { key, value: line.slice(idx + 1).trimStart() };
391
+ }
392
+
393
+ // A YAML block-scalar indicator as a field's entire first-line value (`|`,
394
+ // `|-`, `>`, `>+`, `|2`, …). Live Haiku formats a multi-line body as `body: |`
395
+ // then indents the content — we must not keep the literal `|` or the indent.
396
+ const BLOCK_SCALAR_RE = /^[|>][+-]?\d*$/;
397
+
398
+ // Normalize a parsed field value: drop a leading block-scalar indicator line,
399
+ // then dedent (strip the common leading whitespace the block scalar adds). A
400
+ // plain single-line value passes through untouched.
401
+ function cleanFieldValue(raw) {
402
+ const lines = (raw ?? '').split('\n');
403
+ if (lines.length && BLOCK_SCALAR_RE.test(lines[0].trim())) lines.shift();
404
+ const indents = lines
405
+ .filter((l) => l.trim() !== '')
406
+ .map((l) => (l.match(/^[ \t]*/)?.[0].length ?? 0));
407
+ const minIndent = indents.length ? Math.min(...indents) : 0;
408
+ return lines.map((l) => l.slice(minIndent)).join('\n').trim();
409
+ }
410
+
411
+ function parseRichFactBlock(blockLines) {
412
+ const fields = {};
413
+ let currentKey = null;
414
+ for (const line of blockLines) {
415
+ const m = matchRichFactKey(line);
416
+ if (m) {
417
+ currentKey = m.key;
418
+ fields[currentKey] = m.value; // first-line value (may be '' or a `|` scalar)
419
+ } else if (currentKey) {
420
+ // Continuation of the current field — multi-line body / why / how.
421
+ fields[currentKey] += '\n' + line;
422
+ }
423
+ // A non-key line before any key is ignored.
424
+ }
425
+ const title = cleanFieldValue(fields.title);
426
+ const body = cleanFieldValue(fields.body);
427
+ if (!title || !body) return null; // writeFact requires both
428
+ let type = cleanFieldValue(fields.type).toLowerCase();
429
+ if (!RICH_FACT_VALID_TYPES.has(type)) type = 'project';
430
+ const why = cleanFieldValue(fields.why);
431
+ const how = cleanFieldValue(fields.how);
432
+ return {
433
+ type,
434
+ title: title.slice(0, RICH_FACT_FIELD_CAP),
435
+ body: body.slice(0, RICH_FACT_FIELD_CAP),
436
+ why: why ? why.slice(0, RICH_FACT_FIELD_CAP) : '',
437
+ how: how ? how.slice(0, RICH_FACT_FIELD_CAP) : '',
438
+ };
439
+ }
440
+
441
+ // Exported for direct unit-testing (cli-rich-fact.test.js) — the BEGIN_FACT
442
+ // format is the extraction prompt's contract, pinned independently of a live
443
+ // Haiku call.
444
+ export function parseRichFacts(haikuOutput) {
445
+ if (!haikuOutput || typeof haikuOutput !== 'string') return [];
446
+ const lines = haikuOutput.split('\n');
447
+ const facts = [];
448
+ let i = 0;
449
+ while (i < lines.length) {
450
+ if (lines[i].trim().toUpperCase() !== 'BEGIN_FACT') {
451
+ i++;
452
+ continue;
453
+ }
454
+ // Collect block lines until END_FACT, the next BEGIN_FACT (missing close —
455
+ // don't let it swallow the following block), or end-of-output.
456
+ i++;
457
+ const blockLines = [];
458
+ while (i < lines.length) {
459
+ const marker = lines[i].trim().toUpperCase();
460
+ if (marker === 'END_FACT') {
461
+ i++;
462
+ break;
463
+ }
464
+ if (marker === 'BEGIN_FACT') break; // close here; leave i for the outer loop
465
+ blockLines.push(lines[i]);
466
+ i++;
467
+ }
468
+ const fact = parseRichFactBlock(blockLines);
469
+ if (fact) facts.push(fact);
470
+ }
471
+ return facts;
472
+ }
473
+
331
474
  // Demote assistant-origin candidates one trust level. User-origin
332
475
  // candidates pass through unchanged — they're authoritative.
333
476
  // Order: must run BEFORE applyRetainOverride so the override beats
@@ -456,6 +599,45 @@ function routeMedium({ candidate, projectRoot, ts }) {
456
599
  return { action: 'queued', id, path: reviewPath };
457
600
  }
458
601
 
602
+ // Route a rich fact to the project fact store via writeFact() (Task 103).
603
+ //
604
+ // Direct-to-fact-store (NOT the review queue the terse medium-trust path uses):
605
+ // the point of Task 103 is AUTOMATIC native-parity capture — native writes its
606
+ // fact files with no approval step, so parity requires the same. The fact store
607
+ // is searchable-but-not-full-trust-injected, writeFact already screens every
608
+ // write (home-path sanitize + Poison_Guard + schema + INDEX/reindex), and a
609
+ // later explicit `cmk remember` (trust:high) supersedes. See design §6.4.
610
+ //
611
+ // trust:medium / write_source:auto-extract marks it as a Haiku synthesis
612
+ // (proposal-grade), below the explicit-high tier. The body is built by the SAME
613
+ // rich-fact.mjs helper the explicit path uses, so an auto-extracted fact reads
614
+ // identically to a `cmk remember --why/--how` one.
615
+ function routeRichFact({ candidate, projectRoot, ts }) {
616
+ const body = buildRichFactBody({
617
+ text: candidate.body,
618
+ why: candidate.why,
619
+ how: candidate.how,
620
+ });
621
+ return writeFact({
622
+ tier: 'P',
623
+ type: candidate.type,
624
+ slug: slugifyFact(candidate.title),
625
+ title: candidate.title,
626
+ body,
627
+ writeSource: 'auto-extract',
628
+ trust: 'medium',
629
+ sourceFile: 'auto-extract',
630
+ sourceLine: 1,
631
+ // Content fingerprint for the provenance field — NOT a security context.
632
+ // Matches the kit's sha1-of-content convention (write-fact.mjs caller in
633
+ // subcommands.runRememberRich, memory-write.mjs); writeFact dedups by the
634
+ // content-addressed id, this is just source_sha1. // NOSONAR
635
+ sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
636
+ createdAt: ts,
637
+ projectRoot,
638
+ });
639
+ }
640
+
459
641
  // --- NDJSON extract.log ---------------------------------------------
460
642
 
461
643
  function writeExtractLogEntry({ projectRoot, ts, entry }) {
@@ -668,6 +850,22 @@ export async function runAutoExtract({
668
850
  candidates = applyRetainOverride(candidates, retainSegments);
669
851
  candidates = dedupByCanonicalId(candidates);
670
852
 
853
+ // Task 103 — rich fact synthesis on the native-immune Stop-hook path. The
854
+ // SAME Haiku output may carry BEGIN_FACT blocks (durable project KNOWLEDGE)
855
+ // alongside the terse TRUST_ lines; route them to the fact store via
856
+ // writeFact (richer + searchable). No second LLM call — same outputText.
857
+ const richFacts = parseRichFacts(haikuResult.outputText);
858
+ // XOR safety net: the prompt asks Haiku to emit a fact as EITHER a rich
859
+ // block OR a terse line, never both. If it does both for the same fact, the
860
+ // rich block wins — drop any terse candidate whose canonical id matches a
861
+ // rich fact's body, so it isn't ALSO written as a MEMORY.md bullet. (Keyed
862
+ // on the rich fact's raw `body` headline vs the terse `text` — the prompt
863
+ // enforces the semantic XOR; this catches the exact-restatement case.)
864
+ if (richFacts.length > 0) {
865
+ const richIds = new Set(richFacts.map((f) => generateId('P', f.body)));
866
+ candidates = candidates.filter((c) => !richIds.has(generateId('P', c.text)));
867
+ }
868
+
671
869
  // Task 61 — inline cross-project promotion. The SAME Haiku output may
672
870
  // carry PERSONA CANDIDATE lines (cross-project doctrine); promote them to
673
871
  // the user tier THIS run (vs the weekly auto-persona janitor). No second
@@ -719,10 +917,11 @@ export async function runAutoExtract({
719
917
  }
720
918
  : {};
721
919
 
722
- if (candidates.length === 0 && !personaLanded) {
920
+ if (candidates.length === 0 && richFacts.length === 0 && !personaLanded) {
723
921
  const entry = {
724
922
  ...baseEntry,
725
923
  ...personaLogFields,
924
+ rich_facts_written: 0,
726
925
  success: true,
727
926
  skipped_reason: 'nothing_durable',
728
927
  duration_ms: Date.now() - t0,
@@ -735,6 +934,7 @@ export async function runAutoExtract({
735
934
  duration_ms: entry.duration_ms,
736
935
  logPath,
737
936
  candidates: [],
937
+ richFacts: [],
738
938
  persona,
739
939
  };
740
940
  }
@@ -787,9 +987,57 @@ export async function runAutoExtract({
787
987
  }
788
988
  }
789
989
 
790
- const observation_count = writes.filter(
791
- (w) => w.written === 'memory' || w.written === 'review' || w.written === 'conflict',
792
- ).length;
990
+ // 6b. Route rich facts to the fact store (Task 103). Each writeFact is
991
+ // isolated in try/catch a Poison_Guard / schema / collision rejection
992
+ // (or an unexpected throw) must NOT take down terse routing or the
993
+ // persona pass, exactly like the inline persona isolation above. A
994
+ // 'created' counts toward observation_count; a 'skipped' (content
995
+ // duplicate) is a no-op success that doesn't re-count; anything else is
996
+ // 'rejected' with its category for analytics (Door 4).
997
+ const richWrites = [];
998
+ for (const fact of richFacts) {
999
+ try {
1000
+ const r = routeRichFact({ candidate: fact, projectRoot, ts });
1001
+ let written;
1002
+ if (r?.action === 'created') written = 'fact';
1003
+ else if (r?.action === 'skipped') written = 'fact-duplicate';
1004
+ else written = 'rejected';
1005
+ const rec = { ...fact, written, result: r };
1006
+ if (written === 'rejected') {
1007
+ rec.rejected_category = r?.errorCategory ?? 'unknown';
1008
+ // Trace the drop (§6.5 don't-lose-without-trace), mirroring the terse
1009
+ // low-discard trace — a rejected rich fact is otherwise invisible once
1010
+ // the detached process exits. TITLE ONLY, never the body: a
1011
+ // poison_guard rejection means the body may carry a secret (the
1012
+ // redacted excerpt is already in poison-guard.log). One NDJSON entry
1013
+ // per rejection (Door 4).
1014
+ writeExtractLogEntry({
1015
+ projectRoot,
1016
+ ts,
1017
+ entry: {
1018
+ event: 'rich_fact_rejected',
1019
+ reason: 'rich_fact_rejected',
1020
+ rejected_category: rec.rejected_category,
1021
+ title: fact.title.slice(0, LOW_DISCARD_EXCERPT_MAX),
1022
+ },
1023
+ });
1024
+ }
1025
+ richWrites.push(rec);
1026
+ } catch (err) {
1027
+ richWrites.push({
1028
+ ...fact,
1029
+ written: 'rejected',
1030
+ rejected_category: 'exception',
1031
+ error: err?.message ?? String(err),
1032
+ });
1033
+ }
1034
+ }
1035
+ const richFactsWritten = richWrites.filter((w) => w.written === 'fact').length;
1036
+
1037
+ const observation_count =
1038
+ writes.filter(
1039
+ (w) => w.written === 'memory' || w.written === 'review' || w.written === 'conflict',
1040
+ ).length + richFactsWritten;
793
1041
 
794
1042
  // Persona-only turn: no project candidate landed, but cross-project
795
1043
  // doctrine promoted to the user tier this run. That IS a durable
@@ -799,6 +1047,7 @@ export async function runAutoExtract({
799
1047
  const entry = {
800
1048
  ...baseEntry,
801
1049
  ...personaLogFields,
1050
+ rich_facts_written: richFactsWritten,
802
1051
  success: true,
803
1052
  skipped_reason: 'nothing_durable',
804
1053
  duration_ms: Date.now() - t0,
@@ -811,6 +1060,7 @@ export async function runAutoExtract({
811
1060
  duration_ms: entry.duration_ms,
812
1061
  logPath,
813
1062
  candidates: writes,
1063
+ richFacts: richWrites,
814
1064
  persona,
815
1065
  };
816
1066
  }
@@ -818,6 +1068,7 @@ export async function runAutoExtract({
818
1068
  const entry = {
819
1069
  ...baseEntry,
820
1070
  ...personaLogFields,
1071
+ rich_facts_written: richFactsWritten,
821
1072
  success: true,
822
1073
  observation_count,
823
1074
  duration_ms: Date.now() - t0,
@@ -829,6 +1080,7 @@ export async function runAutoExtract({
829
1080
  duration_ms: entry.duration_ms,
830
1081
  logPath,
831
1082
  candidates: writes,
1083
+ richFacts: richWrites,
832
1084
  persona,
833
1085
  };
834
1086
  } finally {
@@ -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 });
@@ -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);
@@ -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
@@ -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
@@ -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
  }
@@ -0,0 +1,46 @@
1
+ // Rich-fact body + slug shaping — the single source of truth for HOW a rich
2
+ // fact's file body and filename slug are built (Task 103).
3
+ //
4
+ // Extracted from subcommands.mjs so the TWO rich-capture paths build identical
5
+ // fact files (the shared-modules / no-drift rule, CLAUDE.md §1.3):
6
+ // 1. explicit — `cmk remember --why/--how` → runRememberRich (subcommands.mjs)
7
+ // 2. automatic — the Stop-hook auto-extract synthesizing rich facts on the
8
+ // native-immune path (auto-extract.mjs, Task 103)
9
+ // Both call writeFact() with a body produced here, so an auto-extracted fact
10
+ // reads the same as an explicitly-captured one.
11
+
12
+ /**
13
+ * Build a slug for a rich fact's filename from its title.
14
+ *
15
+ * Collapse every run of non-alphanumerics to a single '-' (so dashes are never
16
+ * doubled), cap at 60 chars, then trim a leading/trailing dash without a regex
17
+ * quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
18
+ * dash is all that can remain after the collapse, so string ops suffice).
19
+ *
20
+ * @param {string} s - the source text (typically the fact title).
21
+ * @returns {string} a `[a-z0-9][a-z0-9_-]*`-safe slug, or 'fact' if empty.
22
+ */
23
+ export function slugifyFact(s) {
24
+ let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
25
+ if (base.startsWith('-')) base = base.slice(1);
26
+ if (base.endsWith('-')) base = base.slice(0, -1);
27
+ return base || 'fact';
28
+ }
29
+
30
+ /**
31
+ * Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
32
+ * The headline/body may itself be multi-line markdown (a structured breakdown);
33
+ * Why/How are appended as labelled blocks only when present.
34
+ *
35
+ * @param {object} opts
36
+ * @param {string} opts.text - the headline / body (may be multi-line markdown).
37
+ * @param {string} [opts.why] - the rationale → `**Why:**` block.
38
+ * @param {string} [opts.how] - how to apply → `**How to apply:**` block.
39
+ * @returns {string} the assembled markdown body for writeFact().
40
+ */
41
+ export function buildRichFactBody({ text, why, how }) {
42
+ const parts = [String(text).trim()];
43
+ if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
44
+ if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
45
+ return parts.join('\n\n');
46
+ }
@@ -27,6 +27,7 @@ import { autoPersona } from './auto-persona.mjs';
27
27
  import { exportPersona, importPersona } from './persona-portability.mjs';
28
28
  import { setNativeAutoMemory, nativeMemoryInstallNote } from './native-memory.mjs';
29
29
  import { writeFact } from './write-fact.mjs';
30
+ import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
30
31
  import { createHash } from 'node:crypto';
31
32
  import { runLazyCompress } from './lazy-compress.mjs';
32
33
  import { runDoctor } from './doctor.mjs';
@@ -356,25 +357,6 @@ function runSearch(queryParts, options) {
356
357
  */
357
358
  // Task 63 (F1): a slug derived from the title — lowercased, non-alphanumerics
358
359
  // collapsed to '-', trimmed, capped. Always passes writeFact's SLUG_PATTERN.
359
- function slugifyFact(s) {
360
- // Collapse every run of non-alphanumerics to a single '-' (so dashes are
361
- // never doubled), cap, then trim a leading/trailing dash without a regex
362
- // quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
363
- // dash is all that can remain after the collapse, so string ops suffice).
364
- let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
365
- if (base.startsWith('-')) base = base.slice(1);
366
- if (base.endsWith('-')) base = base.slice(0, -1);
367
- return base || 'fact';
368
- }
369
-
370
- // Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
371
- function buildRichFactBody({ text, why, how }) {
372
- const parts = [String(text).trim()];
373
- if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
374
- if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
375
- return parts.join('\n\n');
376
- }
377
-
378
360
  /**
379
361
  * `cmk remember --why … --how … --type … --title …` (Task 63 / F1) — RICH
380
362
  * capture. Writes a real granular fact file (frontmatter + Why/How/links) via
@@ -12,7 +12,7 @@ context/.index/
12
12
  context/.locks/
13
13
 
14
14
  # Diagnostic NDJSON logs (observability only; carry raw turn excerpts —
15
- # e.g. Task 92's low_trust_discarded traces — that are NOT Poison_Guard-
16
- # screened, so they must never be committed). The durable memory lives in
17
- # the scratchpads + fact files, not these logs.
15
+ # e.g. discarded-low-trust traces — that are NOT secret-screened, so they
16
+ # must never be committed). The durable memory lives in the scratchpads +
17
+ # fact files, not these logs.
18
18
  context/sessions/*.extract.log
@@ -2,7 +2,7 @@
2
2
 
3
3
  This project uses **claude-memory-kit** for per-project, in-repo memory that survives session boundaries. Memory lives in `context/` (committed) and `context.local/` (gitignored). Cross-project memory lives at `~/.claude-memory-kit/` (or `$MEMORY_KIT_USER_DIR`).
4
4
 
5
- > v0.1.0 is under active development. This block is the runtime contract. Specific mechanisms (auto-extract, memory-write skill, MCP search) come online incrementally — `cmk doctor` will tell you which layers are active in the current install. Full architecture: <https://github.com/LH8PPL/claude-memory-kit/blob/main/docs/journey/v0.1.0-build-log.md>
5
+ > This block is the runtime contract for the kit. `cmk doctor` reports which layers are active in your install. Docs: <https://github.com/LH8PPL/claude-memory-kit>
6
6
 
7
7
  ### Where memory lives
8
8
 
@@ -22,7 +22,7 @@ Precedence at session start: local > project > user (most-specific wins, others
22
22
 
23
23
  ### Health checks (when `cmk doctor` is live)
24
24
 
25
- Health checks (HC-1..HC-8) verify each layer is wired correctly: install integrity, hook registration, transcript capture freshness, INDEX accuracy, cron registration, semantic search backend, native Auto Memory coexistence. See [`docs/adr/`](docs/adr/) and [`specs/v0.1.0/design.md`](specs/v0.1.0/design.md) for the full contract.
25
+ The `cmk doctor` health checks verify each layer is wired correctly: install integrity, hook registration, transcript capture freshness, INDEX accuracy, cron registration, semantic search backend, native Auto Memory coexistence, and stale locks. Full design + decision records: <https://github.com/LH8PPL/claude-memory-kit>.
26
26
 
27
27
  ### Recalling memory (for Claude)
28
28
 
@@ -23,7 +23,7 @@ How to update it:
23
23
 
24
24
  For inspiration:
25
25
  See claude-memory-kit's own journey log as a worked example:
26
- https://github.com/LH8PPL/claude-memory-kit/blob/main/docs/journey/v0.1.0-build-log.md
26
+ https://github.com/LH8PPL/claude-memory-kit/tree/main/docs/journey
27
27
 
28
28
  This template uses HTML comments (like this one) to coach the
29
29
  user through each section. The comments are stripped from