@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
@@ -17,7 +17,14 @@
17
17
  // this module will call instead. The handoff is clean: format stays identical;
18
18
  // only the location of the formatter moves.
19
19
 
20
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import {
21
+ existsSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ appendFileSync,
25
+ mkdirSync,
26
+ } from 'node:fs';
27
+ import { join } from 'node:path';
21
28
  import { generateId } from '@lh8ppl/cmk-canonicalize';
22
29
  import {
23
30
  VALID_TIERS,
@@ -28,7 +35,8 @@ import {
28
35
  } from './tier-paths.mjs';
29
36
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
30
37
  import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
31
- import { writeBullet, parseBulletProvenance } from './provenance.mjs';
38
+ import { writeBullet, parseBulletProvenance, isProvenanceCommentLine } from './provenance.mjs';
39
+ import { graduateForCapRelief } from './graduation.mjs';
32
40
 
33
41
  const VALID_TRUST = new Set(['high', 'medium', 'low']);
34
42
  const VALID_WRITE_SOURCES = new Set([
@@ -188,9 +196,33 @@ function insertIntoSection(text, sectionTitle, bullet) {
188
196
  return lines.join('\n');
189
197
  }
190
198
 
199
+ /**
200
+ * Ensure a `## <sectionTitle>` heading exists in a scratchpad file, creating
201
+ * it (appended at EOF, with clean spacing) if absent. Used by the persona
202
+ * promoter (Task 64 / F2) so a cross-project candidate routed to a sane-but-
203
+ * new section lands instead of schema-failing to the review queue. The CALLER
204
+ * owns the name-safety guard — this only writes the heading.
205
+ *
206
+ * @returns {{created: boolean, error?: string}}
207
+ */
208
+ export function ensureSectionExists(scratchpadPath, sectionTitle) {
209
+ if (!existsSync(scratchpadPath)) return { created: false, error: 'no-file' };
210
+ const text = readFileSync(scratchpadPath, 'utf8');
211
+ if (findSectionRange(text.split('\n'), sectionTitle)) return { created: false };
212
+ const body = text.trimEnd(); // drop trailing whitespace/blank lines (no `\s+$` regex — trips ReDoS heuristics)
213
+ // No leading blank lines for an empty/whitespace-only file (the scaffolded
214
+ // scratchpads are never empty, but keep the output clean if one ever is).
215
+ const prefix = body ? `${body}\n\n` : '';
216
+ writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n`, 'utf8');
217
+ return { created: true };
218
+ }
219
+
220
+ const EVICTED_ID_RE = /^- \(([PUL]-[A-Za-z0-9]+)\)/;
221
+
191
222
  function consolidate(text, { nowDate }) {
192
223
  const lines = text.split('\n');
193
224
  const removeIdx = new Set();
225
+ const evicted = [];
194
226
  const staleCutoff = new Date(nowDate.getTime() - STALE_AFTER_DAYS * 24 * 60 * 60 * 1000);
195
227
  let bulletsRemoved = 0;
196
228
 
@@ -199,7 +231,7 @@ function consolidate(text, { nowDate }) {
199
231
  const bulletLine = lines[i];
200
232
  const commentLine = lines[i + 1];
201
233
  if (!bulletLine.startsWith('- (')) continue;
202
- if (!commentLine || !/^\s*<!--.*-->\s*$/.test(commentLine)) continue;
234
+ if (!isProvenanceCommentLine(commentLine)) continue;
203
235
 
204
236
  const prov = parseBulletProvenance(commentLine);
205
237
  if (!prov || !prov.at || !prov.trust) continue;
@@ -211,14 +243,47 @@ function consolidate(text, { nowDate }) {
211
243
 
212
244
  removeIdx.add(i);
213
245
  removeIdx.add(i + 1);
246
+ // Task 91.2: capture the dropped bullet so the caller can ARCHIVE it
247
+ // (recoverable, per the §6.5 tombstone principle) instead of hard-deleting.
248
+ const idMatch = bulletLine.match(EVICTED_ID_RE);
249
+ evicted.push({ id: idMatch ? idMatch[1] : 'unknown', block: `${bulletLine}\n${commentLine}` });
214
250
  bulletsRemoved++;
215
251
  }
216
252
 
217
253
  if (removeIdx.size === 0) {
218
- return { text, bulletsRemoved: 0 };
254
+ return { text, bulletsRemoved: 0, evicted: [] };
219
255
  }
220
256
  const out = lines.filter((_, i) => !removeIdx.has(i)).join('\n');
221
- return { text: out, bulletsRemoved };
257
+ return { text: out, bulletsRemoved, evicted };
258
+ }
259
+
260
+ // Task 91.2: archive bullets that consolidate() dropped, so eviction is
261
+ // recoverable rather than silent (mirrors `cmk forget`'s tombstone, §6.5).
262
+ // Append-only log under the tier's archive dir; one audit entry per bullet.
263
+ const EVICTED_ARCHIVE_HEADER =
264
+ '# Evicted scratchpad bullets\n\n<!-- Bullets dropped by cap-consolidation (low/medium trust, >14 days old). Kept here so eviction is recoverable, not silent. To restore one, re-capture it via `cmk remember`. -->\n\n';
265
+
266
+ function archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now }) {
267
+ const archiveDir = join(tierRoot, 'memory', 'archive');
268
+ mkdirSync(archiveDir, { recursive: true });
269
+ const archivePath = join(archiveDir, 'evicted-bullets.md');
270
+ const ts = now ?? nowIso();
271
+ const header = existsSync(archivePath) ? '' : EVICTED_ARCHIVE_HEADER;
272
+ const block = `## Evicted ${ts} — consolidate(${scratchpad})\n${evicted
273
+ .map((e) => e.block)
274
+ .join('\n')}\n\n`;
275
+ appendFileSync(archivePath, header + block, 'utf8');
276
+ for (const e of evicted) {
277
+ appendAuditEntry(tierRoot, {
278
+ ts,
279
+ action: 'evicted',
280
+ tier,
281
+ id: e.id,
282
+ reasonCode: REASON_CODES.SCRATCHPAD_EVICTED,
283
+ paths: { archive: archivePath },
284
+ extra: { scratchpad },
285
+ });
286
+ }
222
287
  }
223
288
 
224
289
  export function appendScratchpadBullet(opts = {}) {
@@ -275,6 +340,7 @@ export function appendScratchpadBullet(opts = {}) {
275
340
  // 2. Cap check: would the write push to >95%? If yes, consolidate.
276
341
  let consolidationRan = false;
277
342
  let bulletsConsolidated = 0;
343
+ let evictedBullets = [];
278
344
  let finalContent = candidate;
279
345
  const candidateBytes = Buffer.byteLength(candidate, 'utf8');
280
346
 
@@ -284,28 +350,72 @@ export function appendScratchpadBullet(opts = {}) {
284
350
  const consolidated = consolidate(candidate, { nowDate });
285
351
  bulletsConsolidated = consolidated.bulletsRemoved;
286
352
  finalContent = consolidated.text;
353
+ evictedBullets = consolidated.evicted ?? [];
287
354
  }
288
355
 
289
- // 3. Post-consolidation cap check
290
- const finalBytes = Buffer.byteLength(finalContent, 'utf8');
291
- if (finalBytes > cap) {
292
- // File untouched. The original on-disk content is preserved verbatim.
293
- return errorResult({
294
- category: ERROR_CATEGORIES.CAP_EXCEEDED,
295
- errors: [
296
- `scratchpad cap exceeded: ${finalBytes} bytes would exceed cap of ${cap} bytes for ${scratchpad} (consolidator dropped ${bulletsConsolidated} bullet(s), still over). No silent truncation; resolve by raising the cap in settings.json or manually distilling.`,
297
- ],
298
- path,
299
- cap,
300
- bytes: finalBytes,
301
- consolidationRan,
302
- bulletsConsolidated,
356
+ // 2b. Graduation (Task 91, generalized to all tiers by Task 94 / §19.2). If
357
+ // still over the LOAD-cap after stale-drop — which is what happens when the
358
+ // bullets are high-trust (consolidate() never drops those) — graduate the
359
+ // oldest high-trust bullets OUT of the hot index into the tier's permanent
360
+ // fact store, keeping the injected slice small (the write already succeeded
361
+ // via the load-cap; graduation is about injection budget, not write success).
362
+ // ALL FACT-BEARING TIERS (D-61): project (MEMORY.md + SOUL.md) AND the user-
363
+ // tier persona (USER/HABITS/LESSONS) graduating into the tier's existing
364
+ // fact store (project context/memory/, user <userDir>/fragments/; writeFact
365
+ // already routes tier-U facts there). Local tier (machine-paths/overrides) is
366
+ // excluded: it's machine-specific config, not durable facts.
367
+ let bulletsGraduated = 0;
368
+ let graduatedIds = [];
369
+ let finalBytes = Buffer.byteLength(finalContent, 'utf8');
370
+ if (finalBytes > cap && (tier === 'P' || tier === 'U')) {
371
+ const grad = graduateForCapRelief({
372
+ text: finalContent,
373
+ capBytes: cap,
374
+ tier,
375
+ projectRoot,
376
+ userDir,
377
+ now,
303
378
  });
379
+ finalContent = grad.text;
380
+ graduatedIds = grad.graduated;
381
+ bulletsGraduated = graduatedIds.length;
382
+ finalBytes = Buffer.byteLength(finalContent, 'utf8');
304
383
  }
305
384
 
385
+ // 3. Load-cap, NOT write-cap (Task 94 / D-61 / design §19). The write ALWAYS
386
+ // succeeds — the cap governs only how much is injected, never whether content
387
+ // can be saved (the never-lose-memory invariant). When consolidate + graduation
388
+ // can't bring the file under cap (e.g. an absurdly small cap, or a single large
389
+ // bullet), the file is allowed to GROW past the inject budget; inject-context
390
+ // load-caps the snapshot (§7.1.1) and the overflow stays searchable on disk.
391
+ // The old `cap_exceeded` reject path was removed here — see §19.5 for the
392
+ // superseded write-cap design and why it changed.
393
+
306
394
  // 4. Write + audit
307
395
  writeFileSync(path, finalContent, 'utf8');
308
396
  const ts = now ?? nowIso();
397
+
398
+ // 4a. Task 91.2 — archive evicted bullets now that the drop is durable on
399
+ // disk (only on the success path, so we never archive a bullet that's still
400
+ // live in the unchanged on-disk scratchpad).
401
+ if (evictedBullets.length > 0) {
402
+ archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted: evictedBullets, now: ts });
403
+ }
404
+ // 4b. Task 91.4 (Door 4) — one audit entry per graduated bullet, so the
405
+ // bullet→fact-file move is traceable in the audit log (writeFact also logs
406
+ // the fact create; this records the graduation that triggered it).
407
+ for (const gid of graduatedIds) {
408
+ appendAuditEntry(tierRoot, {
409
+ ts,
410
+ action: 'graduated',
411
+ tier,
412
+ id: gid,
413
+ reasonCode: REASON_CODES.SCRATCHPAD_GRADUATED,
414
+ paths: { after: path },
415
+ extra: { scratchpad },
416
+ });
417
+ }
418
+
309
419
  appendAuditEntry(tierRoot, {
310
420
  ts,
311
421
  action: 'appended',
@@ -320,6 +430,7 @@ export function appendScratchpadBullet(opts = {}) {
320
430
  bytes: finalBytes,
321
431
  consolidationRan,
322
432
  bulletsConsolidated,
433
+ bulletsGraduated,
323
434
  },
324
435
  });
325
436
 
@@ -331,5 +442,122 @@ export function appendScratchpadBullet(opts = {}) {
331
442
  bytes: finalBytes,
332
443
  consolidationRan,
333
444
  bulletsConsolidated,
445
+ bulletsGraduated,
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Proactive cap-relief for a SINGLE scratchpad, run OUTSIDE the append path
451
+ * (Task 94.3). The reactive relief inside appendScratchpadBullet only fires when
452
+ * a write triggers cap pressure; this lets a SessionEnd sweep keep the injected
453
+ * slice under its load-cap even in a read-only session (no new bullets) and catch
454
+ * low/medium bullets that AGED past the 14-day stale window between sessions.
455
+ *
456
+ * Runs the SAME relief sequence as the append path — consolidate (stale-drop +
457
+ * archive) then graduate (high-trust overflow → the tier's fact store) — but only
458
+ * when the scratchpad is already over its load-cap, and it writes back ONLY if
459
+ * the content actually changed (no churn / no audit noise on a comfortable
460
+ * scratchpad, satisfying the over-mutation guard). The WHOLE relief (consolidate
461
+ * AND graduate) is gated to fact-bearing tiers P + U; the L tier (machine config)
462
+ * is left untouched even when over cap — its bullets are not durable facts.
463
+ *
464
+ * Cost note (hook-ceiling composition): each graduated bullet flows through
465
+ * writeFact, which reindexes — so a sweep that graduates N bullets does N reindex
466
+ * passes. That mirrors the reactive append path's existing per-graduating-bullet
467
+ * cost; at SessionEnd it runs AFTER the ~50s concurrent Haiku block but is local
468
+ * file I/O (no spawn/network), and the SessionEnd hook is best-effort (exits 0 on
469
+ * overrun), so it does not threaten the 60s ceiling in practice.
470
+ *
471
+ * @returns {{action:'relieved'|'noop'|'skipped', reason?:string, tier:string,
472
+ * scratchpad:string, bulletsConsolidated:number, bulletsGraduated:number,
473
+ * graduatedIds:string[], bytes:number}}
474
+ */
475
+ export function sweepScratchpadForCapRelief({
476
+ tier,
477
+ scratchpad,
478
+ projectRoot,
479
+ userDir,
480
+ now,
481
+ settings,
482
+ }) {
483
+ const base = {
484
+ tier,
485
+ scratchpad,
486
+ bulletsConsolidated: 0,
487
+ bulletsGraduated: 0,
488
+ graduatedIds: [],
489
+ bytes: 0,
490
+ };
491
+ const path = resolveScratchpadPath({ tier, scratchpad, projectRoot, userDir });
492
+ if (!existsSync(path)) {
493
+ return { ...base, action: 'skipped', reason: 'no-file' };
494
+ }
495
+
496
+ const original = readFileSync(path, 'utf8');
497
+ const cap = resolveCap({ tier, scratchpad, projectRoot, userDir, settings });
498
+ const originalBytes = Buffer.byteLength(original, 'utf8');
499
+ if (originalBytes <= cap) {
500
+ // Load-cap respected already — leave it alone (no churn).
501
+ return { ...base, action: 'noop', bytes: originalBytes };
502
+ }
503
+ // Relief (both consolidate AND graduate) applies only to fact-bearing tiers.
504
+ // Gate here so the consolidate stale-drop can never touch L-tier machine config
505
+ // even if a future caller passes it (graduateAllScratchpads never does today).
506
+ if (tier !== 'P' && tier !== 'U') {
507
+ return { ...base, action: 'noop', reason: 'tier-excluded', bytes: originalBytes };
508
+ }
509
+
510
+ // Over cap — run the same relief sequence as appendScratchpadBullet.
511
+ const ts = now ?? nowIso();
512
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
513
+
514
+ const consolidated = consolidate(original, { nowDate: new Date(ts) });
515
+ let working = consolidated.text;
516
+ const evicted = consolidated.evicted ?? [];
517
+
518
+ let graduatedIds = [];
519
+ if (Buffer.byteLength(working, 'utf8') > cap) {
520
+ // tier is already guaranteed P||U by the gate above.
521
+ const grad = graduateForCapRelief({
522
+ text: working,
523
+ capBytes: cap,
524
+ tier,
525
+ projectRoot,
526
+ userDir,
527
+ now: ts,
528
+ });
529
+ working = grad.text;
530
+ graduatedIds = grad.graduated;
531
+ }
532
+
533
+ if (working === original) {
534
+ // Nothing relievable (no stale bullets to drop; graduation infeasible or no
535
+ // eligible high-trust bullets). Load-cap means over-cap is allowed — leave
536
+ // the file untouched rather than rewriting it identically.
537
+ return { ...base, action: 'noop', reason: 'irreducible', bytes: originalBytes };
538
+ }
539
+
540
+ writeFileSync(path, working, 'utf8');
541
+ if (evicted.length > 0) {
542
+ archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now: ts });
543
+ }
544
+ for (const gid of graduatedIds) {
545
+ appendAuditEntry(tierRoot, {
546
+ ts,
547
+ action: 'graduated',
548
+ tier,
549
+ id: gid,
550
+ reasonCode: REASON_CODES.SCRATCHPAD_GRADUATED,
551
+ paths: { after: path },
552
+ extra: { scratchpad, trigger: 'session-end' },
553
+ });
554
+ }
555
+ return {
556
+ ...base,
557
+ action: 'relieved',
558
+ bulletsConsolidated: evicted.length,
559
+ bulletsGraduated: graduatedIds.length,
560
+ graduatedIds,
561
+ bytes: Buffer.byteLength(working, 'utf8'),
334
562
  };
335
563
  }
@@ -0,0 +1,127 @@
1
+ // SessionEnd orchestrator (Task 86b + D-42). The shared brain behind both
2
+ // cmk-compress-session bins (npm: packages/cli/bin/; plugin: plugin/bin/) —
3
+ // extracted so the orchestration lives in ONE testable place instead of being
4
+ // duplicated across the twin bins (the twin-bin drift hazard CLAUDE.md warns
5
+ // about).
6
+ //
7
+ // What it does: at session end we run TWO independent Haiku passes —
8
+ // 1. compressSession — reads sessions/now.md (the session buffer) → writes
9
+ // sessions/today-{date}.md, truncates now.md, appends compress.log.
10
+ // 2. autoPersona — reads the context/memory/ fact corpus (written per-turn by
11
+ // auto-extract, NOT by compressSession) → promotes cross-project doctrine
12
+ // into the user-tier persona scratchpads.
13
+ //
14
+ // They have DISJOINT inputs and DISJOINT outputs (compress touches the project
15
+ // sessions/ tree; persona touches the user-tier scratchpads + audit.log; neither
16
+ // reads the other's writes; neither takes a lock the other needs). So we run them
17
+ // CONCURRENTLY via Promise.allSettled.
18
+ //
19
+ // Why concurrent, not sequential (the D-42 composition fix): each pass carries a
20
+ // 50s inner Haiku timeout, and the SessionEnd hook ceiling is 60s (design §8.5 /
21
+ // plugin/hooks/hooks.json). Run sequentially, the worst case is 50s + 50s = 100s
22
+ // — well over the ceiling, so the OS would kill the hook mid-persona-write,
23
+ // dropping {"continue": true} AND risking a half-written user-tier INDEX (HC-5
24
+ // corruption, shared across every project). Run concurrently, the wall-clock is
25
+ // max(50s, 50s) ≈ 50s — comfortably inside 60s. compressSession is correct alone
26
+ // (50<60); autoPersona is correct alone (50<60); only their SEQUENTIAL composition
27
+ // was broken. Concurrency is the composition fix.
28
+ //
29
+ // allSettled (not all): both passes are best-effort. A failure in one must never
30
+ // discard the other's result, and must never reject up into the hook (a thrown
31
+ // SessionEnd hook blocks the user from closing their terminal). Each pass gets its
32
+ // OWN backend instance (makeBackend factory) so there is zero shared mutable state
33
+ // across the two concurrent calls.
34
+
35
+ import { compressSession } from './compress-session.mjs';
36
+ import { autoPersona } from './auto-persona.mjs';
37
+ import { graduateAllScratchpads } from './graduate-session.mjs';
38
+
39
+ /**
40
+ * Run the two independent SessionEnd Haiku passes concurrently.
41
+ *
42
+ * @param {object} opts
43
+ * @param {string} opts.projectRoot - resolved project root (CMK_PROJECT_DIR or cwd).
44
+ * @param {string} opts.userDir - user-tier root (~/.claude-memory-kit or override).
45
+ * @param {() => object} opts.makeBackend - factory returning a fresh CompressorBackend
46
+ * per call (each concurrent pass gets its own instance — no shared state).
47
+ * @param {string} [opts.now] - ISO timestamp override (tests).
48
+ * @returns {Promise<{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult, graduationOutcome: PromiseSettledResult}>}
49
+ */
50
+ export async function runSessionEndTasks({ projectRoot, userDir, makeBackend, now }) {
51
+ const [compressOutcome, personaOutcome] = await Promise.allSettled([
52
+ compressSession({ projectRoot, backend: makeBackend(), now }),
53
+ // cooldownMs:0 — compressSession runs concurrently and would otherwise trip the
54
+ // shared 120s Haiku cooldown gate; at SessionEnd we explicitly want persona to run.
55
+ // source:'transcript' (Task 86c / D-44) — classify the RAW recent conversation,
56
+ // where standing-rule statements survive verbatim, NOT the distilled fact corpus
57
+ // (which strips the cross-project signal). This is what makes the cold-open work.
58
+ autoPersona({ projectRoot, userDir, backend: makeBackend(), cooldownMs: 0, now, source: 'transcript' }),
59
+ ]);
60
+
61
+ // Task 94.3: proactive graduation sweep. SEQUENTIAL, AFTER the concurrent block —
62
+ // autoPersona WRITES the user-tier persona scratchpads and graduation READS+
63
+ // rewrites them, so they share inputs and must NOT overlap (the §6.8/§7.1
64
+ // disjoint-input rule). Running it here means the sweep sees the freshly-promoted
65
+ // persona, then trims any overflow so the next session's injected slice stays
66
+ // under its load-cap. Pure local file I/O (no Haiku/network) → adds <<1s, no
67
+ // hook-ceiling risk. Wrapped so a synchronous throw can't reject up into the hook.
68
+ let graduationOutcome;
69
+ try {
70
+ graduationOutcome = {
71
+ status: 'fulfilled',
72
+ value: graduateAllScratchpads({ projectRoot, userDir, now }),
73
+ };
74
+ } catch (err) {
75
+ graduationOutcome = { status: 'rejected', reason: err };
76
+ }
77
+
78
+ return { compressOutcome, personaOutcome, graduationOutcome };
79
+ }
80
+
81
+ /**
82
+ * Render the two outcomes into stderr diagnostic lines (shared by both bins so
83
+ * the log shape can't drift between them). Pure — returns an array of lines, each
84
+ * already newline-terminated.
85
+ *
86
+ * @param {{compressOutcome: PromiseSettledResult, personaOutcome: PromiseSettledResult}} outcomes
87
+ * @returns {string[]}
88
+ */
89
+ export function summarizeSessionEnd({ compressOutcome, personaOutcome, graduationOutcome }) {
90
+ const lines = [];
91
+
92
+ if (compressOutcome.status === 'fulfilled') {
93
+ const r = compressOutcome.value ?? {};
94
+ const reason = r.reason ? ` (${r.reason})` : '';
95
+ const bytes = r.bytesIn ? ` (in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : '';
96
+ lines.push(`cmk-compress-session: ${r.action}${reason}${bytes} ms: ${r.duration_ms ?? 0}\n`);
97
+ } else {
98
+ const e = compressOutcome.reason;
99
+ lines.push(`cmk-compress-session: unexpected error: ${e?.message ?? e}\n`);
100
+ }
101
+
102
+ if (personaOutcome.status === 'fulfilled') {
103
+ const p = personaOutcome.value ?? {};
104
+ lines.push(
105
+ `cmk-compress-session: persona ${p.action} (promoted: ${p.promoted?.length ?? 0}, queued: ${p.queued?.length ?? 0})\n`,
106
+ );
107
+ } else {
108
+ const e = personaOutcome.reason;
109
+ lines.push(`cmk-compress-session: persona refresh failed: ${e?.message ?? e}\n`);
110
+ }
111
+
112
+ // graduationOutcome is optional so pre-94.3 callers (and the orchestrator tests
113
+ // that pass only the two Haiku outcomes) still render exactly two lines.
114
+ if (graduationOutcome) {
115
+ if (graduationOutcome.status === 'fulfilled') {
116
+ const g = graduationOutcome.value ?? {};
117
+ lines.push(
118
+ `cmk-compress-session: graduation (graduated: ${g.totalGraduated ?? 0}, consolidated: ${g.totalConsolidated ?? 0})\n`,
119
+ );
120
+ } else {
121
+ const e = graduationOutcome.reason;
122
+ lines.push(`cmk-compress-session: graduation failed: ${e?.message ?? e}\n`);
123
+ }
124
+ }
125
+
126
+ return lines;
127
+ }
@@ -34,9 +34,12 @@
34
34
  //
35
35
  // **Original block (pre-2026-05-29, repair.mjs)**: the PLUGIN form,
36
36
  // `bash "${CLAUDE_PLUGIN_ROOT}/bin/<name>"`, 6 events incl. Setup →
37
- // cmk-version-check. That form is correct ONLY when the plugin is
38
- // loaded (CLAUDE_PLUGIN_ROOT is set + bash is present). It still lives
39
- // in plugin/hooks/hooks.json for the PLUGIN route (Route B), unchanged.
37
+ // cmk-version-check. That form required bash to be present. It lives in
38
+ // plugin/hooks/hooks.json for the PLUGIN route (Route B) and as of
39
+ // Task 62 (2026-05-31) that route was converted to the node form
40
+ // `node "${CLAUDE_PLUGIN_ROOT}/bin/<name>.mjs"`, so BOTH routes are now
41
+ // bash-free (node-only, cross-OS). version-check was ported to a node
42
+ // .mjs stub at that point (see below).
40
43
  //
41
44
  // **Task 49 (2026-05-29)**: the npm route (Route A) needs hooks that
42
45
  // work with NO plugin loaded. This block is that form. It drops the
@@ -174,6 +177,33 @@ export function writeKitHooks(settingsPath) {
174
177
  }
175
178
  }
176
179
 
180
+ // Task 79 + 90: allow-list the kit's own surfaces so the agent's EXPLICIT
181
+ // captures stay seamless (the AUTO hook path already is). Two prompts to
182
+ // suppress, because Task 69 made the SKILL the capture delivery path:
183
+ // - `Bash(cmk:*)` (Task 79) — stops "Allow this bash command?" when the
184
+ // agent runs `cmk remember` / `cmk lessons promote` (prefix-wildcard;
185
+ // matches any `cmk <subcommand> …`).
186
+ // - `Skill(memory-write)` (Task 90) — stops "Use skill /memory-write?" when
187
+ // the model INVOKES the capture skill. The bash rule alone doesn't cover
188
+ // this: the skill-invocation gate is a separate Claude Code permission
189
+ // surface (`Skill(<name>)` rule, per code.claude.com/docs/en/permissions).
190
+ // Surfaced by the v0.2.0 cut-gate live run — the friction Task 79 killed
191
+ // returned one layer up once capture moved into the skill.
192
+ // Idempotent + over-mutation safe: preserve the user's existing allow entries;
193
+ // only append ours if absent.
194
+ const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)'];
195
+ if (!settings.permissions || typeof settings.permissions !== 'object') {
196
+ settings.permissions = {};
197
+ }
198
+ if (!Array.isArray(settings.permissions.allow)) {
199
+ settings.permissions.allow = [];
200
+ }
201
+ for (const rule of KIT_ALLOW) {
202
+ if (!settings.permissions.allow.includes(rule)) {
203
+ settings.permissions.allow.push(rule);
204
+ }
205
+ }
206
+
177
207
  const after = JSON.stringify(settings);
178
208
  const changed = before !== after;
179
209