@lh8ppl/claude-memory-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,484 @@
1
+ // SessionStart hook real handler (Task 18, T-015). First Layer 4 module
2
+ // with non-trivial behavior; the previous Layer 4 task (#17) only shipped
3
+ // hooks.json + stub scripts.
4
+ //
5
+ // Public boundary: injectContext({cwd, userDir, now, capBytes}) → result.
6
+ // Walks the 3 tiers (local/project/user), composes a Frozen snapshot
7
+ // ≤ capBytes (default 10 KB per NFR-1 / design §1.4), dedups bullet IDs
8
+ // across tiers with most-specific-tier-wins, drops lowest-priority tiers
9
+ // on cap overflow, and emits the Anthropic hook `additionalContext` JSON
10
+ // shape so Claude Code's plugin loader injects it at session start.
11
+ //
12
+ // Side-effect log files (written under <projectRoot>/context/.locks/):
13
+ // shadowed_by.log — NDJSON, one entry per cross-tier ID collision
14
+ // truncation.log — NDJSON, one entry per cap-overflow truncation pass
15
+ // These mirror what cross-tier debug commands (`cmk config --show-origin`,
16
+ // future) will read from. The .locks/ dir is created on demand.
17
+ //
18
+ // Uses shared modules per CLAUDE.md "Shared modules" rule:
19
+ // tier-paths.mjs — resolveTierRoot, SCRATCHPADS_BY_TIER, ID_PATTERN
20
+ // audit-log.mjs — nowIso (consistent ISO formatter)
21
+
22
+ import {
23
+ existsSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ readdirSync,
27
+ appendFileSync,
28
+ statSync,
29
+ } from 'node:fs';
30
+ import { spawn } from 'node:child_process';
31
+ import { join } from 'node:path';
32
+ import { homedir } from 'node:os';
33
+ import { SCRATCHPADS_BY_TIER, resolveTierRoot } from './tier-paths.mjs';
34
+ import { nowIso } from './audit-log.mjs';
35
+ import { detectStaleness } from './lazy-compress.mjs';
36
+
37
+ // 13,000 bytes = sum of all per-file caps (12,275 from Task 12/14) + 725
38
+ // bytes of headroom for inter-tier markers + future modest growth.
39
+ // Coordinated with TIER_BUDGETS below per design §7.1 "Snapshot cap
40
+ // coordination rule" (2026-05-26 amendment). Raising this requires
41
+ // raising one or more TIER_BUDGETS to consume the new headroom; see
42
+ // scripts/validate-template.mjs for the build-time invariant check.
43
+ const DEFAULT_CAP_BYTES = 13_000;
44
+ const HOOK_EVENT_NAME = 'SessionStart';
45
+
46
+ // Match any line containing a `(P-XXXXXXXX)`-shaped citation id. Looser
47
+ // than ID_PATTERN on purpose — alphabet-validation is the writer's job;
48
+ // here we just want to recognize "any line that LOOKS like it carries a
49
+ // cited bullet" so we can dedup across tiers.
50
+ const ID_TOKEN_RE = /\(([PUL])-([A-Za-z0-9]{8})\)/;
51
+
52
+ // Tier-discovery + which files contribute to the snapshot for each tier.
53
+ // Order matters: this is the iteration order, also the snapshot output
54
+ // order (highest-priority first per design §7.1).
55
+ const TIER_ORDER = ['L', 'P', 'U'];
56
+
57
+ const TIER_LABELS = {
58
+ L: 'local',
59
+ P: 'project',
60
+ U: 'user',
61
+ };
62
+
63
+ // Per-tier byte budgets (design §7.1, 2026-05-26 coordination amendment).
64
+ // Each tier truncates section-by-section to its own budget BEFORE the
65
+ // snapshot's total-cap drop step runs. Each budget = EXACT SUM of
66
+ // per-file caps in that tier (Task 12/14):
67
+ //
68
+ // L = 3000 (machine-paths.md 1500 + overrides.md 1500)
69
+ // P = 4300 (SOUL.md 1800 + MEMORY.md 2500)
70
+ // U = 4975 (USER.md 1375 + HABITS.md 1800 + LESSONS.md 1800)
71
+ // Σ = 12,275 (fits the 13,000 DEFAULT_CAP_BYTES with 725-byte slack)
72
+ //
73
+ // This is THE STRUCTURAL FIX from PR-25's user-tier truncation finding.
74
+ // Per-file caps were specified independently from snapshot cap and
75
+ // per-tier budgets in v0.1.0's initial spec; the sums didn't compose,
76
+ // so files at their legal caps blew the snapshot. Now per-tier budgets
77
+ // derive from per-file caps; snapshot cap derives from the sum.
78
+ // scripts/validate-template.mjs asserts this composition rule on every
79
+ // `npm test` run so future per-file-cap changes can't silently break it.
80
+ const TIER_BUDGETS = Object.freeze({
81
+ L: 3000,
82
+ P: 4300,
83
+ U: 4975,
84
+ });
85
+
86
+ // Per-tier reading plan. The hook reads the scratchpads allowed at that
87
+ // tier (per SCRATCHPADS_BY_TIER) plus the tier's INDEX file, plus — for
88
+ // the project tier — the most recent rolling-window day file.
89
+ function plannedFilesForTier(tier, tierRoot) {
90
+ const files = [];
91
+ for (const name of SCRATCHPADS_BY_TIER[tier]) {
92
+ files.push(join(tierRoot, name));
93
+ }
94
+ // INDEX: P/L use memory/INDEX.md; U uses fragments/INDEX.md (per
95
+ // resolveFactDir asymmetry in tier-paths.mjs).
96
+ const indexDir = tier === 'U' ? 'fragments' : 'memory';
97
+ files.push(join(tierRoot, indexDir, 'INDEX.md'));
98
+ if (tier === 'P') {
99
+ const sessionsDir = join(tierRoot, 'sessions');
100
+ const latest = latestDaySession(sessionsDir);
101
+ if (latest) files.push(latest);
102
+ }
103
+ return files;
104
+ }
105
+
106
+ function latestDaySession(sessionsDir) {
107
+ if (!existsSync(sessionsDir)) return null;
108
+ const candidates = readdirSync(sessionsDir).filter((n) =>
109
+ /^today-\d{4}-\d{2}-\d{2}\.md$/.test(n),
110
+ );
111
+ if (candidates.length === 0) return null;
112
+ candidates.sort();
113
+ return join(sessionsDir, candidates[candidates.length - 1]);
114
+ }
115
+
116
+ // Walk up from `cwd` looking for a directory with a `context/` child. The
117
+ // kit's project-tier root convention is `<repo>/context/`; the walk-up
118
+ // matches `git rev-parse --show-toplevel`'s semantics for nested invocations
119
+ // (a hook may fire while Claude Code's cwd is in a sub-package).
120
+ function discoverProjectRoot(cwd) {
121
+ let dir = cwd;
122
+ // Defensive bound: walk no more than 64 ancestors.
123
+ for (let i = 0; i < 64; i++) {
124
+ if (existsSync(join(dir, 'context'))) return dir;
125
+ const parent = join(dir, '..');
126
+ const norm = statSync(parent).isDirectory() ? parent : null;
127
+ if (!norm || norm === dir) break;
128
+ // Stop at the filesystem root.
129
+ if (/^[A-Za-z]:\\?$|^\/$/.test(dir)) break;
130
+ dir = parent;
131
+ }
132
+ // Fall back to `cwd` — the per-tier readers will return empty for
133
+ // absent dirs, so this stays safe.
134
+ return cwd;
135
+ }
136
+
137
+ function tierDirExists(tier, tierRoot) {
138
+ return existsSync(tierRoot) && statSync(tierRoot).isDirectory();
139
+ }
140
+
141
+ // Read the snapshot-eligible content for one tier as a single string. If
142
+ // no tier files exist (or the tier dir itself is absent), returns ''. The
143
+ // per-file content is wrapped in a fenced header so the snapshot is
144
+ // self-describing to whoever reads Claude's context window.
145
+ function readTierBlock(tier, tierRoot) {
146
+ if (!tierDirExists(tier, tierRoot)) return '';
147
+ const sections = [];
148
+ for (const path of plannedFilesForTier(tier, tierRoot)) {
149
+ if (!existsSync(path)) continue;
150
+ let body;
151
+ try {
152
+ body = readFileSync(path, 'utf8');
153
+ } catch {
154
+ continue;
155
+ }
156
+ if (body.trim() === '') continue;
157
+ sections.push(body);
158
+ }
159
+ if (sections.length === 0) return '';
160
+ const header = `<!-- cmk: ${TIER_LABELS[tier]} tier (${tier}) -->`;
161
+ return [header, ...sections].join('\n\n').replace(/\n+$/, '') + '\n';
162
+ }
163
+
164
+ // Strip duplicate-ID lines from a tier block. Mutates by returning a new
165
+ // string. For each id in `seenIds`, find the line containing the id and
166
+ // the immediately-following line (if it looks like an HTML-comment
167
+ // provenance) and drop both. Records a shadow event for each id stripped.
168
+ function stripShadowedIds(tier, block, seenIds, shadowedEvents, ts) {
169
+ if (!block) return block;
170
+ const lines = block.split('\n');
171
+ const kept = [];
172
+ let i = 0;
173
+ while (i < lines.length) {
174
+ const m = lines[i].match(ID_TOKEN_RE);
175
+ if (m) {
176
+ const id = `${m[1]}-${m[2]}`;
177
+ const prior = seenIds.get(id);
178
+ if (prior && prior !== tier) {
179
+ // Drop this line + (if next is the indented provenance) the next.
180
+ const next = lines[i + 1];
181
+ const isComment =
182
+ typeof next === 'string' && /^\s*<!--.*-->\s*$/.test(next);
183
+ // Record the shadowing once per (id, shadowed-tier).
184
+ let event = shadowedEvents.find((e) => e.id === id);
185
+ if (!event) {
186
+ event = {
187
+ ts,
188
+ id,
189
+ winner_tier: prior,
190
+ shadowed_tiers: [],
191
+ };
192
+ shadowedEvents.push(event);
193
+ }
194
+ if (!event.shadowed_tiers.includes(tier)) {
195
+ event.shadowed_tiers.push(tier);
196
+ }
197
+ i += isComment ? 2 : 1;
198
+ continue;
199
+ }
200
+ // First sighting — claim it for this tier.
201
+ if (!prior) seenIds.set(id, tier);
202
+ }
203
+ kept.push(lines[i]);
204
+ i++;
205
+ }
206
+ return kept.join('\n');
207
+ }
208
+
209
+ function writeNdjsonLine(logPath, entry) {
210
+ mkdirSync(join(logPath, '..'), { recursive: true });
211
+ appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
212
+ }
213
+
214
+ // Truncate one tier block to fit its budget by dropping whole `## `
215
+ // sections from the END. Section-granular (not bullet- or byte-
216
+ // granular) per design §7.1.1: structural shape preservation matters
217
+ // more than maximum byte utilization. Returns { text, sectionsDropped,
218
+ // preBytes, postBytes }.
219
+ //
220
+ // Algorithm: split into sections delimited by `## ` (level-2 markdown
221
+ // heading) anywhere in the tier block. Anything BEFORE the first `## `
222
+ // (file headers, comments, top-level title) is the "preamble" and is
223
+ // always kept. Sections are popped from the END until the kept text
224
+ // fits the budget OR no sections remain (preamble-only). If the
225
+ // preamble alone exceeds budget, we return it unchanged — that's a
226
+ // configuration problem (preamble shouldn't be that big) but
227
+ // preferable to dropping the file header.
228
+ function truncateTierToBudget(blockText, budget) {
229
+ const preBytes = Buffer.byteLength(blockText, 'utf8');
230
+ if (preBytes <= budget) {
231
+ return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
232
+ }
233
+ // Find every `## ` heading position. Each section runs from one
234
+ // heading line to the next (or EOF).
235
+ const lines = blockText.split('\n');
236
+ const headingIdxs = [];
237
+ for (let i = 0; i < lines.length; i++) {
238
+ if (/^##\s/.test(lines[i])) headingIdxs.push(i);
239
+ }
240
+ if (headingIdxs.length === 0) {
241
+ // No sections — nothing to drop. Return as-is.
242
+ return { text: blockText, sectionsDropped: 0, preBytes, postBytes: preBytes };
243
+ }
244
+ // Build section boundaries: [start..end) for each section.
245
+ const sections = headingIdxs.map((startIdx, i) => ({
246
+ startIdx,
247
+ endIdx: i + 1 < headingIdxs.length ? headingIdxs[i + 1] : lines.length,
248
+ }));
249
+ // Pop from the end while over budget.
250
+ let droppedCount = 0;
251
+ let keptEndLine = lines.length;
252
+ while (sections.length > 0) {
253
+ const candidateText = lines.slice(0, keptEndLine).join('\n');
254
+ if (Buffer.byteLength(candidateText, 'utf8') <= budget) break;
255
+ const last = sections.pop();
256
+ keptEndLine = last.startIdx;
257
+ droppedCount++;
258
+ }
259
+ const finalText = lines.slice(0, keptEndLine).join('\n');
260
+ return {
261
+ text: finalText,
262
+ sectionsDropped: droppedCount,
263
+ preBytes,
264
+ postBytes: Buffer.byteLength(finalText, 'utf8'),
265
+ };
266
+ }
267
+
268
+ // Enforce per-tier byte budgets (design §7.1.1) by dropping whole `## `
269
+ // sections from each tier block's tail. Each truncation emits a
270
+ // tier_truncated_to_budget NDJSON event.
271
+ //
272
+ // AFTER per-tier truncation, if the SUM of kept tier blocks still
273
+ // exceeds the snapshot cap (configuration error: Σ budgets > cap),
274
+ // fall back to the legacy whole-tier-drop behavior — drops the
275
+ // lowest-priority tier wholesale, logged as a dropped_tiers event.
276
+ // This shouldn't fire under the documented budget table (1500+4500+
277
+ // 4000 = 10000 ≤ 10240 default cap), but the safety net is cheap.
278
+ function enforceCap(orderedBlocks, capBytes, ts) {
279
+ const tierEvents = [];
280
+ // Step 1: per-tier budget enforcement (section-granular).
281
+ for (const block of orderedBlocks) {
282
+ const budget = TIER_BUDGETS[block.tier];
283
+ if (typeof budget !== 'number') continue; // unknown tier; pass through
284
+ const r = truncateTierToBudget(block.text, budget);
285
+ if (r.sectionsDropped > 0) {
286
+ tierEvents.push({
287
+ ts,
288
+ event: 'tier_truncated_to_budget',
289
+ tier: block.tier,
290
+ budget,
291
+ pre_bytes: r.preBytes,
292
+ post_bytes: r.postBytes,
293
+ sections_dropped: r.sectionsDropped,
294
+ });
295
+ block.text = r.text;
296
+ }
297
+ }
298
+
299
+ // Step 2: total-cap fallback. Drop whole tier blocks from the tail
300
+ // until under capBytes. Shouldn't fire in normal config; the
301
+ // dropped_tiers shape is preserved for back-compat.
302
+ const dropEvents = [];
303
+ let bytes = orderedBlocks.reduce(
304
+ (sum, b) => sum + Buffer.byteLength(b.text, 'utf8'),
305
+ 0,
306
+ );
307
+ while (bytes > capBytes && orderedBlocks.length > 0) {
308
+ const dropped = orderedBlocks.pop();
309
+ bytes -= Buffer.byteLength(dropped.text, 'utf8');
310
+ let event = dropEvents[dropEvents.length - 1];
311
+ if (!event) {
312
+ event = { ts, capBytes, dropped_tiers: [] };
313
+ dropEvents.push(event);
314
+ }
315
+ event.dropped_tiers.push(dropped.tier);
316
+ }
317
+
318
+ return {
319
+ blocks: orderedBlocks,
320
+ truncationEvents: [...tierEvents, ...dropEvents],
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Detached fire-and-forget spawn of the lazy-compress bin. Per design
326
+ * §8.2.2 — non-blocking, hook returns within its 500ms budget while the
327
+ * child runs ambiently. The bin is PATH-resolved when npm-installed
328
+ * globally (`cmk-compress-lazy` declared in package.json `bin:`).
329
+ *
330
+ * Exposed so injectContext can override via dependency injection in tests
331
+ * (testSpawnLazy parameter) — production callers pass nothing.
332
+ */
333
+ function spawnLazyCompress(projectRoot) {
334
+ try {
335
+ // The lazy-compress child intentionally outlives this hook process;
336
+ // parent-side timeout is incorrect by design — the child carries its
337
+ // own internal timeout via runLazyCompress → daily-distill /
338
+ // weekly-curate → HaikuViaAnthropicApi.compress({timeoutMs: 50_000}).
339
+ // shell:true so the Windows .cmd shim is found via PATH (same pattern
340
+ // register-crons.mjs uses for cmk-daily-distill).
341
+ // spawn-discipline: ignore detached-fire-and-forget per design §8.5 — same posture as capture-turn.mjs's auto-extract spawn (Task 23).
342
+ const child = spawn('cmk-compress-lazy', [], {
343
+ detached: true,
344
+ stdio: 'ignore',
345
+ shell: true,
346
+ cwd: projectRoot,
347
+ windowsHide: true,
348
+ env: { ...process.env, CMK_PROJECT_DIR: projectRoot },
349
+ });
350
+ child.unref();
351
+ return { spawned: true, pid: child.pid };
352
+ } catch (err) {
353
+ // M2 fix: emit a Door-4 NDJSON entry on spawn failure (PATH miss,
354
+ // EACCES) so users have observability when lazy-compress can't
355
+ // fire. Without this, the only signal is the lazyTrigger.spawned
356
+ // field on the return struct, which Claude Code's hook subsystem
357
+ // doesn't persist. Best-effort write — if the log directory
358
+ // doesn't exist or is unwritable, silently continue (we don't want
359
+ // the hook to fail because we couldn't log a spawn failure).
360
+ try {
361
+ const locksDir = join(projectRoot, 'context', '.locks');
362
+ mkdirSync(locksDir, { recursive: true });
363
+ appendFileSync(
364
+ join(locksDir, 'lazy-compress.log'),
365
+ JSON.stringify({
366
+ ts: nowIso(),
367
+ scope: 'lazy-compress',
368
+ action: 'spawn-failed',
369
+ reason: 'spawn-failed',
370
+ error: err?.message ?? String(err),
371
+ }) + '\n',
372
+ 'utf8',
373
+ );
374
+ } catch {
375
+ // best-effort
376
+ }
377
+ return { spawned: false, reason: 'spawn-failed', error: err?.message ?? String(err) };
378
+ }
379
+ }
380
+
381
+ export function injectContext({
382
+ cwd,
383
+ userDir,
384
+ now,
385
+ capBytes,
386
+ // Test-only injection point per spawn-discipline (the production path
387
+ // uses spawnLazyCompress directly). Tests pass a fake to assert
388
+ // "lazy-compress was/was-not triggered" without touching the host.
389
+ testSpawnLazy,
390
+ } = {}) {
391
+ const ts = now ?? nowIso();
392
+ const cap = typeof capBytes === 'number' ? capBytes : DEFAULT_CAP_BYTES;
393
+ const startCwd = cwd ?? process.cwd();
394
+ const projectRoot = discoverProjectRoot(startCwd);
395
+ const resolvedUserDir =
396
+ userDir ??
397
+ process.env.MEMORY_KIT_USER_DIR ??
398
+ join(homedir(), '.claude-memory-kit');
399
+
400
+ // 1. Read each tier's block in priority order.
401
+ const rawBlocks = TIER_ORDER.map((tier) => {
402
+ const tierRoot =
403
+ tier === 'U'
404
+ ? resolvedUserDir
405
+ : resolveTierRoot({ tier, projectRoot, userDir: resolvedUserDir });
406
+ return { tier, tierRoot, text: readTierBlock(tier, tierRoot) };
407
+ }).filter((b) => b.text !== '');
408
+
409
+ // 2. Dedup IDs across tiers (highest-priority first).
410
+ const seenIds = new Map();
411
+ const shadowedEvents = [];
412
+ for (const block of rawBlocks) {
413
+ block.text = stripShadowedIds(
414
+ block.tier,
415
+ block.text,
416
+ seenIds,
417
+ shadowedEvents,
418
+ ts,
419
+ );
420
+ }
421
+
422
+ // 3. Cap enforcement: drop whole tier blocks from the tail until within
423
+ // capBytes. Each drop emits one truncation event.
424
+ const { blocks: keptBlocks, truncationEvents } = enforceCap(
425
+ rawBlocks,
426
+ cap,
427
+ ts,
428
+ );
429
+
430
+ // 4. Concatenate.
431
+ const snapshot = keptBlocks.map((b) => b.text).join('\n');
432
+
433
+ // 5. Persist side-effect logs under <projectRoot>/context/.locks/. We
434
+ // only write the project-tier .locks file (which is the well-known
435
+ // location for cross-tier debug; mirrors audit.log placement).
436
+ const locksDir = join(projectRoot, 'context', '.locks');
437
+ if (shadowedEvents.length > 0) {
438
+ for (const event of shadowedEvents) {
439
+ writeNdjsonLine(join(locksDir, 'shadowed_by.log'), event);
440
+ }
441
+ }
442
+ if (truncationEvents.length > 0) {
443
+ for (const event of truncationEvents) {
444
+ writeNdjsonLine(join(locksDir, 'truncation.log'), event);
445
+ }
446
+ }
447
+
448
+ // 6. Task 35 lazy-compress trigger: cheap (<5ms) staleness check.
449
+ // When non-fresh + non-cron-active, detached-spawn `cmk-compress-lazy`
450
+ // so the hook can return within its 500ms NFR-1 budget while the
451
+ // child does the rollup work cron would have done.
452
+ let lazyTrigger = null;
453
+ try {
454
+ const verdict = detectStaleness({ projectRoot, now: ts });
455
+ lazyTrigger = { verdict: verdict.action, reason: verdict.reason };
456
+ if (verdict.action === 'stale-daily' || verdict.action === 'stale-weekly') {
457
+ const spawner = typeof testSpawnLazy === 'function' ? testSpawnLazy : spawnLazyCompress;
458
+ const spawnResult = spawner(projectRoot);
459
+ lazyTrigger = { ...lazyTrigger, ...spawnResult };
460
+ }
461
+ } catch (err) {
462
+ // detectStaleness should be defensive; if it throws, log + continue.
463
+ lazyTrigger = { verdict: 'error', error: err?.message ?? String(err) };
464
+ }
465
+
466
+ // 7. Emit the Anthropic SessionStart hook output shape (design §5.1 +
467
+ // Anthropic hook protocol). When the snapshot is empty, we still emit
468
+ // the shape so downstream tooling can rely on the field's presence.
469
+ const hookOutput = {
470
+ hookSpecificOutput: {
471
+ hookEventName: HOOK_EVENT_NAME,
472
+ additionalContext: snapshot,
473
+ },
474
+ };
475
+
476
+ return {
477
+ snapshot,
478
+ hookOutput,
479
+ shadowedEvents,
480
+ truncationEvents,
481
+ lazyTrigger,
482
+ bytes: Buffer.byteLength(snapshot, 'utf8'),
483
+ };
484
+ }