@lh8ppl/claude-memory-kit 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +12 -5
  2. package/bin/cmk-auto-extract.mjs +13 -0
  3. package/bin/cmk-compress-session.mjs +31 -17
  4. package/bin/cmk-inject-context.mjs +12 -2
  5. package/bin/cmk-weekly-curate.mjs +14 -2
  6. package/package.json +3 -2
  7. package/src/audit-log.mjs +6 -0
  8. package/src/auto-drain.mjs +59 -0
  9. package/src/auto-extract.mjs +117 -6
  10. package/src/auto-persona.mjs +544 -0
  11. package/src/bullet-lookup.mjs +59 -0
  12. package/src/capture-turn.mjs +54 -0
  13. package/src/compress-session.mjs +6 -8
  14. package/src/compressor.mjs +19 -4
  15. package/src/conflict-queue.mjs +8 -1
  16. package/src/daily-distill.mjs +19 -11
  17. package/src/doctor.mjs +74 -23
  18. package/src/forget.mjs +14 -0
  19. package/src/graduate-session.mjs +65 -0
  20. package/src/graduation.mjs +179 -0
  21. package/src/inject-context.mjs +206 -59
  22. package/src/install.mjs +52 -7
  23. package/src/lessons-promote.mjs +137 -0
  24. package/src/memory-write.mjs +2 -2
  25. package/src/native-memory.mjs +98 -0
  26. package/src/persona-portability.mjs +253 -0
  27. package/src/provenance.mjs +23 -5
  28. package/src/read-hook-stdin.mjs +47 -0
  29. package/src/register-crons.mjs +17 -8
  30. package/src/scratchpad.mjs +247 -19
  31. package/src/session-end-tasks.mjs +127 -0
  32. package/src/settings-hooks.mjs +33 -3
  33. package/src/subcommands.mjs +339 -16
  34. package/src/weekly-curate.mjs +53 -6
  35. package/src/write-fact.mjs +14 -0
  36. package/template/.claude/skills/memory-write/SKILL.md +47 -88
  37. package/template/.gitignore.fragment +6 -0
  38. package/template/CLAUDE.md.template +15 -9
  39. package/template/local/machine-paths.md.template +1 -12
  40. package/template/local/overrides.md.template +1 -11
  41. package/template/project/MEMORY.md.template +5 -26
  42. package/template/project/SOUL.md.template +1 -10
  43. package/template/user/fragments/INDEX.md.template +1 -1
  44. package/template/.claude/hooks/pre-tool-memory.js +0 -78
  45. package/template/.claude/hooks/transcript-capture.js +0 -69
  46. package/template/.claude/settings.json +0 -27
  47. package/template/support/scripts/auto-extract-memory.sh +0 -102
  48. package/template/support/scripts/refresh-distill-timestamp.py +0 -35
  49. package/template/support/scripts/register-crons.py +0 -242
  50. package/template/support/scripts/run-daily-distill.sh +0 -67
  51. package/template/support/scripts/run-weekly-curate.sh +0 -58
@@ -0,0 +1,179 @@
1
+ // graduation.mjs — Task 91 (D-54 / D-57). The 3rd MEMORY.md shrink mechanism.
2
+ //
3
+ // When appendScratchpadBullet() hits cap pressure and consolidate() (stale-drop)
4
+ // can't free enough — because high-trust bullets are NEVER dropped — graduation
5
+ // moves the OLDEST high-trust bullets OUT of the byte-capped hot index into the
6
+ // permanent, indexed fact store (context/memory/<type>_<slug>.md via writeFact),
7
+ // so the new write lands instead of returning CAP_EXCEEDED.
8
+ //
9
+ // Decision A (D-57): SEARCH-ONLY (graduated facts are not injected; reliable
10
+ // recall of them is Task 75, v0.3) and PROJECT MEMORY.md ONLY — the caller gates
11
+ // the tier/scratchpad so this never fires on user-tier persona scratchpads.
12
+ //
13
+ // Reuses writeFact(), which gives four lifecycle-edge guarantees for free:
14
+ // - cross-store dedup (content-id keyed → re-graduating the same fact is a
15
+ // no-op `skipped`, not a duplicate file),
16
+ // - home-path sanitization + Poison_Guard (the safe write path),
17
+ // - reindex-on-write (the FTS5/INDEX.md view stays consistent — map edge #8).
18
+
19
+ import { unlinkSync } from 'node:fs';
20
+ import { writeFact } from './write-fact.mjs';
21
+ import { reindex } from './reindex.mjs';
22
+ import { parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
23
+
24
+ // Loose enough to match whatever id a bullet carries (graduation moves the
25
+ // bullet regardless of id-alphabet validity; that's the writer's concern).
26
+ const BULLET_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)\s*(.*)$/;
27
+
28
+ const VALID_WRITE_SOURCES = new Set([
29
+ 'user-explicit',
30
+ 'auto-extract',
31
+ 'compressor',
32
+ 'manual-edit',
33
+ 'imported',
34
+ ]);
35
+
36
+ function slugify(s) {
37
+ // Collapse non-alphanumerics to single dashes, cap, trim edges (string ops,
38
+ // no trailing-dash quantifier — matches subcommands.slugifyFact's ReDoS-safe
39
+ // shape).
40
+ let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
41
+ if (base.startsWith('-')) base = base.slice(1);
42
+ if (base.endsWith('-')) base = base.slice(0, -1);
43
+ return base || 'fact';
44
+ }
45
+
46
+ function deriveTitle(text) {
47
+ const t = String(text).trim().slice(0, 80).trim();
48
+ return t || 'graduated fact';
49
+ }
50
+
51
+ // One bullet → one project-tier fact file. writeFact action 'error' means it
52
+ // could NOT be stored (collision / poison / schema) → the caller must keep the
53
+ // bullet; anything else ('created' or a dedup 'skipped') means it's safely in
54
+ // the permanent store → the caller removes the bullet from the hot index.
55
+ function graduateOne({ id, text, prov, tier, projectRoot, userDir, now }) {
56
+ // Slug carries a content-derived tail so two distinct facts never collide on
57
+ // `project_<slug>.md`, while the SAME fact re-graduates to the same filename
58
+ // (→ writeFact dedups by id instead of creating a duplicate).
59
+ const slugTail = id.replace(/^[PUL]-/, '').toLowerCase();
60
+ return writeFact({
61
+ tier,
62
+ // User-tier graduated content is cross-project doctrine → 'user'; project
63
+ // (and any other) tier → 'project'. Both are valid fact-file types; this
64
+ // just keeps the on-disk filename + frontmatter semantically honest.
65
+ type: tier === 'U' ? 'user' : 'project',
66
+ slug: `${slugify(text)}-${slugTail}`,
67
+ title: deriveTitle(text),
68
+ body: text,
69
+ writeSource: VALID_WRITE_SOURCES.has(prov.write) ? prov.write : 'manual-edit',
70
+ trust: 'high',
71
+ sourceFile: prov.source || 'MEMORY.md',
72
+ sourceLine: prov.source_line || 1,
73
+ sourceSha1: prov.sha1,
74
+ id, // preserve the citation id across graduation; also the dedup key
75
+ createdAt: prov.at, // keep the original capture time
76
+ projectRoot,
77
+ userDir,
78
+ now,
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Graduate oldest high-trust bullets out of `text` until it fits `capBytes`.
84
+ *
85
+ * @returns {{ text: string, graduated: string[] }} the new scratchpad content
86
+ * (graduated bullets removed) and the ids that were graduated.
87
+ */
88
+ export function graduateForCapRelief({
89
+ text,
90
+ capBytes,
91
+ tier,
92
+ projectRoot,
93
+ userDir,
94
+ now,
95
+ }) {
96
+ const lines = text.split('\n');
97
+ const entries = [];
98
+ for (let i = 0; i < lines.length - 1; i++) {
99
+ const m = lines[i].match(BULLET_RE);
100
+ if (!m) continue;
101
+ if (!isProvenanceCommentLine(lines[i + 1])) continue;
102
+ const prov = parseBulletProvenance(lines[i + 1]);
103
+ if (!prov || prov.trust !== 'high' || !prov.at) continue;
104
+ entries.push({ bulletIdx: i, commentIdx: i + 1, id: m[1], text: m[2], prov });
105
+ }
106
+ // Feasibility gate (composition safety): if graduating EVERY eligible bullet
107
+ // still wouldn't get under cap, graduate NOTHING. Otherwise writeFact would
108
+ // persist fact files that the failed-append error path then strands — the
109
+ // bullets stay in the unchanged on-disk MEMORY.md AND now also exist as fact
110
+ // files: the exact double-capture this task exists to kill. Returning early
111
+ // lets CAP_EXCEEDED fire cleanly with zero side effects.
112
+ const bulletBytes = (e) =>
113
+ Buffer.byteLength(`${lines[e.bulletIdx]}\n${lines[e.commentIdx]}\n`, 'utf8');
114
+ const totalGraduatable = entries.reduce((sum, e) => sum + bulletBytes(e), 0);
115
+ const startBytes = Buffer.byteLength(text, 'utf8');
116
+ if (startBytes - totalGraduatable > capBytes) {
117
+ return { text, graduated: [] };
118
+ }
119
+
120
+ // Oldest first: graduate aged durable facts before recent ones. The just-
121
+ // appended bullet (newest `at`) sorts last and only graduates if still needed.
122
+ entries.sort(
123
+ (a, b) => new Date(a.prov.at).getTime() - new Date(b.prov.at).getTime(),
124
+ );
125
+
126
+ const removeIdx = new Set();
127
+ const graduated = [];
128
+ const createdPaths = []; // files writeFact NEWLY created (for transactional rollback)
129
+ let curBytes = Buffer.byteLength(text, 'utf8');
130
+ for (const e of entries) {
131
+ if (curBytes <= capBytes) break;
132
+ const res = graduateOne({
133
+ id: e.id,
134
+ text: e.text,
135
+ prov: e.prov,
136
+ tier,
137
+ projectRoot,
138
+ userDir,
139
+ now,
140
+ });
141
+ if (res.action === 'error') continue; // couldn't store → keep the bullet
142
+ // 'created' made a NEW file (track for rollback); 'skipped' deduped against
143
+ // a pre-existing fact file (don't track — deleting it would lose a real fact).
144
+ if (res.action === 'created' && res.path) createdPaths.push(res.path);
145
+ removeIdx.add(e.bulletIdx);
146
+ removeIdx.add(e.commentIdx);
147
+ graduated.push(e.id);
148
+ curBytes -= bulletBytes(e);
149
+ }
150
+
151
+ // Transactional guard (composition safety): the feasibility gate assumed every
152
+ // eligible bullet would graduate. If a graduateOne unexpectedly errored
153
+ // (poison/schema on a resident bullet) we may still be over cap — the append
154
+ // will then CAP_EXCEEDED and leave MEMORY.md unchanged, which would STRAND the
155
+ // fact files we created (the bullets stay live AND now exist as facts = the
156
+ // double-capture this task kills). Roll the created files back so the failure
157
+ // path has zero side effects, and let CAP_EXCEEDED fire cleanly.
158
+ if (curBytes > capBytes) {
159
+ for (const p of createdPaths) {
160
+ try {
161
+ unlinkSync(p);
162
+ } catch {
163
+ // best-effort; a leaked fact file is recoverable, a lost bullet is not
164
+ }
165
+ }
166
+ if (createdPaths.length > 0) {
167
+ try {
168
+ reindex({ tier, projectRoot, userDir, warn: () => {} });
169
+ } catch {
170
+ // index rebuild is best-effort; the next reindex/search self-heals
171
+ }
172
+ }
173
+ return { text, graduated: [] };
174
+ }
175
+
176
+ if (removeIdx.size === 0) return { text, graduated: [] };
177
+ const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
178
+ return { text: out, graduated };
179
+ }
@@ -33,6 +33,24 @@ import { homedir } from 'node:os';
33
33
  import { SCRATCHPADS_BY_TIER, resolveTierRoot } from './tier-paths.mjs';
34
34
  import { nowIso } from './audit-log.mjs';
35
35
  import { detectStaleness } from './lazy-compress.mjs';
36
+ import { isProvenanceCommentLine, parseBulletProvenance } from './provenance.mjs';
37
+
38
+ // Importance ranking for value-ordered inject eviction (Task 93 / design §19.3).
39
+ // When a tier exceeds its budget we drop the LOWEST-value sections first, not the
40
+ // tail. Trust dominates; recency (newest `at`) breaks ties; a section with no
41
+ // resolvable provenance ranks as UNKNOWN (between low and medium) so genuinely
42
+ // scored content outranks it.
43
+ const TRUST_RANK = Object.freeze({ low: 0, medium: 1, high: 2 });
44
+ const UNKNOWN_TRUST_RANK = 0.5; // a bullet whose provenance we can't read
45
+ function trustRank(trust) {
46
+ return TRUST_RANK[trust] ?? UNKNOWN_TRUST_RANK;
47
+ }
48
+ function trustLabel(rank) {
49
+ if (rank >= TRUST_RANK.high) return 'high';
50
+ if (rank >= TRUST_RANK.medium) return 'medium';
51
+ if (rank >= UNKNOWN_TRUST_RANK) return 'unknown';
52
+ return 'low';
53
+ }
36
54
 
37
55
  // 13,000 bytes = sum of all per-file caps (12,275 from Task 12/14) + 725
38
56
  // bytes of headroom for inter-tier markers + future modest growth.
@@ -275,13 +293,38 @@ function hasRealContent(cleaned) {
275
293
  .some((l) => l.trim() !== '' && !/^#{1,6}\s/.test(l));
276
294
  }
277
295
 
278
- // Read the snapshot-eligible content for one tier as a single string. If
279
- // no tier files exist (or the tier dir itself is absent), returns ''. Each
280
- // file body is cleaned for injection (see cleanScratchpadBody); files that
281
- // reduce to scaffolding-only contribute nothing, and a tier whose every
282
- // file is scaffolding-only is excluded entirely (no header, no skeleton).
296
+ // Scan a RAW scratchpad body for bullet+provenance pairs, recording each
297
+ // cited id's trust + capture time into `valueById`. Run on the raw body
298
+ // BEFORE cleanScratchpadBody strips the provenance comments that's the only
299
+ // place the trust/recency signal exists, and the importance-aware truncator
300
+ // (truncateTierToBudget) needs it to rank sections by value, not file order.
301
+ // Note: this records EVERY bullet+provenance pair, including seed bullets (later
302
+ // stripped by cleanScratchpadBody) and ids later removed by cross-tier shadowing.
303
+ // Those stale entries are inert — truncateTierToBudget only resolves ids on block
304
+ // lines that are actually PRESENT, so an orphaned valueById entry is never used.
305
+ function collectBulletValues(body, valueById) {
306
+ const lines = body.replace(/\r\n/g, '\n').split('\n');
307
+ for (let i = 0; i < lines.length - 1; i++) {
308
+ if (!/^\s*-\s/.test(lines[i])) continue;
309
+ const m = lines[i].match(ID_TOKEN_RE);
310
+ if (!m) continue;
311
+ if (!isProvenanceCommentLine(lines[i + 1])) continue;
312
+ const prov = parseBulletProvenance(lines[i + 1]);
313
+ if (!prov) continue;
314
+ valueById.set(`${m[1]}-${m[2]}`, { trust: prov.trust, at: prov.at });
315
+ }
316
+ }
317
+
318
+ // Read the snapshot-eligible content for one tier. Returns { text, valueById }.
319
+ // `text` is the cleaned, injection-ready block (or '' if the tier contributes
320
+ // nothing); `valueById` maps each cited id → {trust, at} parsed from the RAW
321
+ // bodies (used by the importance-aware budget truncator). Each file body is
322
+ // cleaned for injection (see cleanScratchpadBody); files that reduce to
323
+ // scaffolding-only contribute nothing, and a tier whose every file is
324
+ // scaffolding-only is excluded entirely (no header, no skeleton).
283
325
  function readTierBlock(tier, tierRoot) {
284
- if (!tierDirExists(tier, tierRoot)) return '';
326
+ const valueById = new Map();
327
+ if (!tierDirExists(tier, tierRoot)) return { text: '', valueById };
285
328
  const sections = [];
286
329
  for (const path of plannedFilesForTier(tier, tierRoot)) {
287
330
  if (!existsSync(path)) continue;
@@ -292,13 +335,21 @@ function readTierBlock(tier, tierRoot) {
292
335
  continue;
293
336
  }
294
337
  if (body.trim() === '') continue;
338
+ collectBulletValues(body, valueById); // raw body — provenance still present
295
339
  const cleaned = cleanScratchpadBody(body);
296
340
  if (!hasRealContent(cleaned)) continue;
297
341
  sections.push(cleaned);
298
342
  }
299
- if (sections.length === 0) return '';
343
+ if (sections.length === 0) return { text: '', valueById };
300
344
  const header = `<!-- cmk: ${TIER_LABELS[tier]} tier (${tier}) -->`;
301
- return [header, ...sections].join('\n\n').replace(/\n+$/, '') + '\n';
345
+ // Trailing-newline strip via string scan (NOT a `/\n+$/` regex the `+$`
346
+ // shape trips the ReDoS heuristic, per CLAUDE.md; string-scan is linear and
347
+ // strips only newlines, faithful to the original intent).
348
+ const joined = [header, ...sections].join('\n\n');
349
+ let end = joined.length;
350
+ while (end > 0 && joined[end - 1] === '\n') end--;
351
+ const text = joined.slice(0, end) + '\n';
352
+ return { text, valueById };
302
353
  }
303
354
 
304
355
  // Strip duplicate-ID lines from a tier block. Mutates by returning a new
@@ -318,8 +369,7 @@ function stripShadowedIds(tier, block, seenIds, shadowedEvents, ts) {
318
369
  if (prior && prior !== tier) {
319
370
  // Drop this line + (if next is the indented provenance) the next.
320
371
  const next = lines[i + 1];
321
- const isComment =
322
- typeof next === 'string' && /^\s*<!--.*-->\s*$/.test(next);
372
+ const isComment = isProvenanceCommentLine(next);
323
373
  // Record the shadowing once per (id, shadowed-tier).
324
374
  let event = shadowedEvents.find((e) => e.id === id);
325
375
  if (!event) {
@@ -351,27 +401,58 @@ function writeNdjsonLine(logPath, entry) {
351
401
  appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
352
402
  }
353
403
 
354
- // Truncate one tier block to fit its budget by dropping whole `## `
355
- // sections from the END. Section-granular (not bullet- or byte-
356
- // granular) per design §7.1.1: structural shape preservation matters
357
- // more than maximum byte utilization. Returns { text, sectionsDropped,
358
- // preBytes, postBytes }.
404
+ // Compute one section's aggregate value from its bullets' provenance.
405
+ // aggregate trust = the MAX bullet trust in the section (so a section holding
406
+ // ANY high-trust bullet is protected before a section that holds none — the
407
+ // §19.3 "never evict a high-trust bullet before a lower one" invariant, at
408
+ // section granularity). aggregate recency = the NEWEST `at`. A section with no
409
+ // resolvable bullets ranks lowest (value -1) so it drops first.
410
+ //
411
+ // Known limitation (section-granularity, accepted per §7.1.1 + the Task 93
412
+ // "whole sections by aggregate value" sanction): MAX-aggregate protects high-
413
+ // trust content, but a LOW-trust bullet bundled in the same section as a high-
414
+ // trust one survives, while a standalone MEDIUM-trust section can be dropped
415
+ // first — a bullet-level inversion. Note the asymmetry with 94.3 graduation,
416
+ // which evicts per-BULLET (oldest-first). Bullet-granular inject eviction is the
417
+ // stricter v-next option if this matters; for now it keeps §7.1.1 structural
418
+ // shape + costs less re-rendering.
419
+ function sectionValue(lines, startIdx, endIdx, valueById) {
420
+ let maxTrust = -1;
421
+ let maxAtMs = -1;
422
+ const ids = [];
423
+ for (let i = startIdx; i < endIdx; i++) {
424
+ if (!/^\s*-\s/.test(lines[i])) continue;
425
+ const m = lines[i].match(ID_TOKEN_RE);
426
+ if (!m) continue;
427
+ const id = `${m[1]}-${m[2]}`;
428
+ ids.push(id);
429
+ const v = valueById.get(id);
430
+ const t = v ? trustRank(v.trust) : UNKNOWN_TRUST_RANK;
431
+ if (t > maxTrust) maxTrust = t;
432
+ const atMs = v && v.at ? Date.parse(v.at) : NaN;
433
+ if (!Number.isNaN(atMs) && atMs > maxAtMs) maxAtMs = atMs;
434
+ }
435
+ return { maxTrust, maxAtMs, ids };
436
+ }
437
+
438
+ // Truncate one tier block to fit its budget by dropping whole `## ` sections,
439
+ // LOWEST-VALUE first (Task 93 / design §19.3) — superseding the old tail-order
440
+ // drop. Section-granular per design §7.1.1 (structural-shape preservation), but
441
+ // the eviction ORDER is now importance-aware: lowest aggregate trust first, then
442
+ // oldest, then — as a tiebreak among equal-value sections — later-in-file first.
443
+ // That tiebreak makes this a strict generalization of the legacy tail-drop: when
444
+ // no provenance is present (every section ranks equal) it drops from the end,
445
+ // exactly as before. Returns { text, sectionsDropped, droppedSections, preBytes,
446
+ // postBytes }.
359
447
  //
360
- // Algorithm: split into sections delimited by `## ` (level-2 markdown
361
- // heading) anywhere in the tier block. Anything BEFORE the first `## `
362
- // (file headers, comments, top-level title) is the "preamble" and is
363
- // always kept. Sections are popped from the END until the kept text
364
- // fits the budget OR no sections remain (preamble-only). If the
365
- // preamble alone exceeds budget, we return it unchanged — that's a
366
- // configuration problem (preamble shouldn't be that big) but
367
- // preferable to dropping the file header.
368
- function truncateTierToBudget(blockText, budget) {
448
+ // Anything BEFORE the first `## ` (file headers, top-level title) is the
449
+ // "preamble" and always kept; if the preamble alone exceeds budget it's returned
450
+ // unchanged (a config problem, but preferable to dropping the header).
451
+ function truncateTierToBudget(blockText, budget, valueById = new Map()) {
369
452
  const preBytes = Buffer.byteLength(blockText, 'utf8');
370
453
  if (preBytes <= budget) {
371
- return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
454
+ return { text: blockText, sectionsDropped: 0, droppedSections: [], preBytes, postBytes: preBytes };
372
455
  }
373
- // Find every `## ` heading position. Each section runs from one
374
- // heading line to the next (or EOF).
375
456
  const lines = blockText.split('\n');
376
457
  const headingIdxs = [];
377
458
  for (let i = 0; i < lines.length; i++) {
@@ -379,27 +460,51 @@ function truncateTierToBudget(blockText, budget) {
379
460
  }
380
461
  if (headingIdxs.length === 0) {
381
462
  // No sections — nothing to drop. Return as-is.
382
- return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
463
+ return { text: blockText, sectionsDropped: 0, droppedSections: [], preBytes, postBytes: preBytes };
383
464
  }
384
- // Build section boundaries: [start..end) for each section.
385
- const sections = headingIdxs.map((startIdx, i) => ({
386
- startIdx,
387
- endIdx: i + 1 < headingIdxs.length ? headingIdxs[i + 1] : lines.length,
388
- }));
389
- // Pop from the end while over budget.
390
- let droppedCount = 0;
391
- let keptEndLine = lines.length;
392
- while (sections.length > 0) {
393
- const candidateText = lines.slice(0, keptEndLine).join('\n');
394
- if (Buffer.byteLength(candidateText, 'utf8') <= budget) break;
395
- const last = sections.pop();
396
- keptEndLine = last.startIdx;
397
- droppedCount++;
465
+ const firstHeading = headingIdxs[0];
466
+ const sections = headingIdxs.map((startIdx, i) => {
467
+ const endIdx = i + 1 < headingIdxs.length ? headingIdxs[i + 1] : lines.length;
468
+ return {
469
+ origIndex: i,
470
+ startIdx,
471
+ endIdx,
472
+ heading: lines[startIdx].replace(/^##\s+/, '').trim(),
473
+ ...sectionValue(lines, startIdx, endIdx, valueById),
474
+ };
475
+ });
476
+ // Drop order: lowest aggregate trust first → oldest first → later-in-file
477
+ // first (the legacy tail tiebreak, so equal-value blocks still drop from the
478
+ // end). High-value sections are evicted only after everything cheaper is gone.
479
+ const dropOrder = [...sections].sort(
480
+ (a, b) =>
481
+ a.maxTrust - b.maxTrust ||
482
+ a.maxAtMs - b.maxAtMs ||
483
+ b.origIndex - a.origIndex,
484
+ );
485
+ const dropped = new Set();
486
+ const render = () => {
487
+ const keep = [];
488
+ for (let i = 0; i < firstHeading; i++) keep.push(lines[i]); // preamble
489
+ for (const s of sections) {
490
+ if (dropped.has(s.origIndex)) continue;
491
+ for (let i = s.startIdx; i < s.endIdx; i++) keep.push(lines[i]);
492
+ }
493
+ return keep.join('\n');
494
+ };
495
+ let finalText = render();
496
+ for (const s of dropOrder) {
497
+ if (Buffer.byteLength(finalText, 'utf8') <= budget) break;
498
+ dropped.add(s.origIndex);
499
+ finalText = render();
398
500
  }
399
- const finalText = lines.slice(0, keptEndLine).join('\n');
501
+ const droppedSections = sections
502
+ .filter((s) => dropped.has(s.origIndex))
503
+ .map((s) => ({ heading: s.heading, max_trust: trustLabel(s.maxTrust), ids: s.ids }));
400
504
  return {
401
505
  text: finalText,
402
- sectionsDropped: droppedCount,
506
+ sectionsDropped: dropped.size,
507
+ droppedSections,
403
508
  preBytes,
404
509
  postBytes: Buffer.byteLength(finalText, 'utf8'),
405
510
  };
@@ -421,7 +526,7 @@ function enforceCap(orderedBlocks, capBytes, ts) {
421
526
  for (const block of orderedBlocks) {
422
527
  const budget = TIER_BUDGETS[block.tier];
423
528
  if (typeof budget !== 'number') continue; // unknown tier; pass through
424
- const r = truncateTierToBudget(block.text, budget);
529
+ const r = truncateTierToBudget(block.text, budget, block.valueById);
425
530
  if (r.sectionsDropped > 0) {
426
531
  tierEvents.push({
427
532
  ts,
@@ -431,6 +536,11 @@ function enforceCap(orderedBlocks, capBytes, ts) {
431
536
  pre_bytes: r.preBytes,
432
537
  post_bytes: r.postBytes,
433
538
  sections_dropped: r.sectionsDropped,
539
+ // Door 4 (Task 93): WHICH sections were evicted + WHY (lowest-value
540
+ // first). dropped_sections carries each evicted section's heading, its
541
+ // aggregate trust, and the cited ids it contained.
542
+ strategy: 'importance-ordered',
543
+ dropped_sections: r.droppedSections,
434
544
  });
435
545
  block.text = r.text;
436
546
  }
@@ -470,23 +580,52 @@ function enforceCap(orderedBlocks, capBytes, ts) {
470
580
  * Exposed so injectContext can override via dependency injection in tests
471
581
  * (testSpawnLazy parameter) — production callers pass nothing.
472
582
  */
473
- function spawnLazyCompress(projectRoot) {
583
+ /**
584
+ * Pure spawn descriptor for the lazy-compress child (Task 81). Separated so the
585
+ * Door-3 contract (node-direct + windowsHide, no shell, when the path is known)
586
+ * is unit-assertable without a real spawn. Path known + present → `node <path>`
587
+ * directly; otherwise the PATH-resolved `.cmd` bin via shell:true (the corrupt-
588
+ * install fallback that may still flash a console on Windows).
589
+ */
590
+ export function lazyCompressSpawnDescriptor(projectRoot, compressLazyPath) {
591
+ const baseEnv = { ...process.env, CMK_PROJECT_DIR: projectRoot };
592
+ if (compressLazyPath && existsSync(compressLazyPath)) {
593
+ return {
594
+ command: process.execPath,
595
+ args: [compressLazyPath],
596
+ options: { detached: true, stdio: 'ignore', cwd: projectRoot, windowsHide: true, env: baseEnv },
597
+ };
598
+ }
599
+ return {
600
+ command: 'cmk-compress-lazy',
601
+ args: [],
602
+ options: { detached: true, stdio: 'ignore', shell: true, cwd: projectRoot, windowsHide: true, env: baseEnv },
603
+ };
604
+ }
605
+
606
+ function spawnLazyCompress(projectRoot, compressLazyPath) {
474
607
  try {
475
608
  // The lazy-compress child intentionally outlives this hook process;
476
609
  // parent-side timeout is incorrect by design — the child carries its
477
610
  // own internal timeout via runLazyCompress → daily-distill /
478
611
  // weekly-curate → HaikuViaAnthropicApi.compress({timeoutMs: 50_000}).
479
- // shell:true so the Windows .cmd shim is found via PATH (same pattern
480
- // register-crons.mjs uses for cmk-daily-distill).
481
612
  // spawn-discipline: ignore detached-fire-and-forget per design §8.5 — same posture as capture-turn.mjs's auto-extract spawn (Task 23).
482
- const child = spawn('cmk-compress-lazy', [], {
483
- detached: true,
484
- stdio: 'ignore',
485
- shell: true,
486
- cwd: projectRoot,
487
- windowsHide: true,
488
- env: { ...process.env, CMK_PROJECT_DIR: projectRoot },
489
- });
613
+ //
614
+ // Task 81 (Windows console-popup fix): spawn `node` DIRECTLY on the
615
+ // resolved .mjs. The legacy `shell:true` path resolved the npm `.cmd`
616
+ // shim via cmd.exe (cmd.exe → cmk-compress-lazy.cmd → node), and on
617
+ // Windows `windowsHide:true` hid only the cmd.exe window — NOT the
618
+ // detached `node` grandchild the shim launched, which flashed a visible
619
+ // console at every SessionStart. `process.execPath` + `windowsHide`
620
+ // suppresses it. The shell:true bin-name spawn survives only as a
621
+ // fallback when the path is unknown (corrupt install) — better the
622
+ // legacy popup than losing compression entirely.
623
+ const { command, args, options } = lazyCompressSpawnDescriptor(
624
+ projectRoot,
625
+ compressLazyPath,
626
+ );
627
+ // spawn-discipline: ignore detached fire-and-forget per design §8.5 — the child carries its own internal timeout (runLazyCompress → compress({timeoutMs})); parent-side timeout is incorrect by design.
628
+ const child = spawn(command, args, options);
490
629
  child.unref();
491
630
  return { spawned: true, pid: child.pid };
492
631
  } catch (err) {
@@ -527,6 +666,11 @@ export function injectContext({
527
666
  // uses spawnLazyCompress directly). Tests pass a fake to assert
528
667
  // "lazy-compress was/was-not triggered" without touching the host.
529
668
  testSpawnLazy,
669
+ // Resolved path to cmk-compress-lazy.mjs (passed by the bin wrapper, which
670
+ // knows the install layout). Lets spawnLazyCompress run `node <path>`
671
+ // directly instead of the shell:true `.cmd` shim — the Windows
672
+ // console-popup fix (Task 81). Absent → graceful shell:true fallback.
673
+ compressLazyPath,
530
674
  } = {}) {
531
675
  const ts = now ?? nowIso();
532
676
  const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
@@ -537,13 +681,16 @@ export function injectContext({
537
681
  process.env.MEMORY_KIT_USER_DIR ??
538
682
  join(homedir(), '.claude-memory-kit');
539
683
 
540
- // 1. Read each tier's block in priority order.
684
+ // 1. Read each tier's block in priority order. readTierBlock also returns a
685
+ // per-tier value map (id → trust/recency) parsed from the raw bodies, which
686
+ // the importance-aware budget truncator uses to evict lowest-value first.
541
687
  const rawBlocks = TIER_ORDER.map((tier) => {
542
688
  const tierRoot =
543
689
  tier === 'U'
544
690
  ? resolvedUserDir
545
691
  : resolveTierRoot({ tier, projectRoot, userDir: resolvedUserDir });
546
- return { tier, tierRoot, text: readTierBlock(tier, tierRoot) };
692
+ const { text, valueById } = readTierBlock(tier, tierRoot);
693
+ return { tier, tierRoot, text, valueById };
547
694
  }).filter((b) => b.text !== '');
548
695
 
549
696
  // 2. Dedup IDs across tiers (highest-priority first).
@@ -595,7 +742,7 @@ export function injectContext({
595
742
  lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
596
743
  if (verdict.action === 'stale-daily' || verdict.action === 'stale-weekly') {
597
744
  const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
598
- const spawnResult = spawner(projectRoot);
745
+ const spawnResult = spawner(projectRoot, compressLazyPath);
599
746
  lazyTrigger = { ...lazyTrigger, ...spawnResult };
600
747
  }
601
748
  } catch (err) {
package/src/install.mjs CHANGED
@@ -37,10 +37,9 @@ import {
37
37
  readdirSync,
38
38
  statSync,
39
39
  writeFileSync,
40
- copyFileSync,
41
40
  } from 'node:fs';
42
41
  import { homedir } from 'node:os';
43
- import { dirname, join, relative, resolve } from 'node:path';
42
+ import { basename, dirname, join, relative, resolve } from 'node:path';
44
43
  import { fileURLToPath } from 'node:url';
45
44
  import { injectClaudeMdBlock } from './claude-md.mjs';
46
45
  import { writeKitHooks } from './settings-hooks.mjs';
@@ -155,7 +154,27 @@ function targetName(srcName) {
155
154
  * - Writes new files
156
155
  * - Mutates the supplied `created` / `skipped` / `errors` arrays
157
156
  */
158
- function installTier(srcDir, destDir, { created, skipped, errors }) {
157
+ // Substitute the kit's template placeholders. Templates ship with
158
+ // `{{TODAY}}` / `{{PROJECT_NAME}}` / `{{VERSION}}`; without this, the
159
+ // scaffolded scratchpads leaked a literal `{{TODAY}}` into MEMORY.md et al.
160
+ // (live-test finding #4). Only the three known tokens are replaced.
161
+ function renderTemplate(content, vars) {
162
+ return content
163
+ .replaceAll('{{TODAY}}', vars.today)
164
+ .replaceAll('{{PROJECT_NAME}}', vars.projectName)
165
+ .replaceAll('{{VERSION}}', vars.version);
166
+ }
167
+
168
+ function installTier(srcDir, destDir, { created, skipped, errors, vars }) {
169
+ // Self-sufficient default so no caller can crash renderTemplate by omitting
170
+ // vars (e.g. initUserTier). install() passes an explicit vars with the real
171
+ // projectName; standalone callers fall back to a sensible default (the user
172
+ // tier's scratchpads only carry {{TODAY}} anyway).
173
+ const v = vars ?? {
174
+ today: new Date().toISOString().slice(0, 10),
175
+ projectName: basename(destDir),
176
+ version: getKitVersion(),
177
+ };
159
178
  if (!existsSync(srcDir)) {
160
179
  errors.push({ path: srcDir, error: 'template tier missing from kit' });
161
180
  return;
@@ -182,7 +201,10 @@ function installTier(srcDir, destDir, { created, skipped, errors }) {
182
201
 
183
202
  try {
184
203
  mkdirSync(dirname(targetAbs), { recursive: true });
185
- copyFileSync(file.absSrc, targetAbs);
204
+ // Read → render placeholders → write (was a raw copyFileSync, which left
205
+ // `{{TODAY}}` literal in the scaffolded scratchpads). All template files
206
+ // are text (.gitkeep is handled above), so utf8 round-trip is safe.
207
+ writeFileSync(targetAbs, renderTemplate(readFileSync(file.absSrc, 'utf8'), v), 'utf8');
186
208
  created.push(targetAbs);
187
209
  } catch (err) {
188
210
  errors.push({ path: targetAbs, error: err && err.message ? err.message : String(err) });
@@ -269,9 +291,32 @@ export async function install(options = {}) {
269
291
  const skipped = [];
270
292
  const errors = [];
271
293
 
272
- installTier(join(templateDir, 'project'), join(projectRoot, 'context'), { created, skipped, errors });
273
- installTier(join(templateDir, 'local'), join(projectRoot, 'context.local'), { created, skipped, errors });
274
- installTier(join(templateDir, 'user'), userTier, { created, skipped, errors });
294
+ const vars = {
295
+ today: new Date().toISOString().slice(0, 10), // YYYY-MM-DD
296
+ projectName: basename(projectRoot),
297
+ version,
298
+ };
299
+
300
+ installTier(join(templateDir, 'project'), join(projectRoot, 'context'), { created, skipped, errors, vars });
301
+ installTier(join(templateDir, 'local'), join(projectRoot, 'context.local'), { created, skipped, errors, vars });
302
+ installTier(join(templateDir, 'user'), userTier, { created, skipped, errors, vars });
303
+
304
+ // Skills — Task 69. Scaffold the kit's Claude Code skills from
305
+ // template/.claude/skills/ into <projectRoot>/.claude/skills/. This is what
306
+ // makes model-invoked capture (the memory-write skill) ship with the npm
307
+ // `cmk install` route, not only the plugin route — route-equivalence per
308
+ // design §1.3. Same boundary as the tiers: idempotent skip-existing +
309
+ // over-mutation-safe (a hand-edited skill survives a re-install). The skill
310
+ // files carry no {{placeholders}}, so renderTemplate is a byte-passthrough.
311
+ const skillsSrc = join(templateDir, '.claude', 'skills');
312
+ if (existsSync(skillsSrc)) {
313
+ installTier(skillsSrc, join(projectRoot, '.claude', 'skills'), {
314
+ created,
315
+ skipped,
316
+ errors,
317
+ vars,
318
+ });
319
+ }
275
320
 
276
321
  const gitignore = injectGitignore(projectRoot, buildGitignoreBlock(templateDir));
277
322