@lh8ppl/claude-memory-kit 0.1.2 → 0.2.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 (51) hide show
  1. package/README.md +8 -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
@@ -139,6 +139,50 @@ function readLastUserTurnFromTranscript(transcriptPath) {
139
139
  return body.join('\n');
140
140
  }
141
141
 
142
+ // Task 87: append the turn's CONVERSATION to the compression buffer
143
+ // (context/sessions/now.md). Before this, now.md was fed ONLY by observe-edit's
144
+ // file-write lines ("[ts] Write file=X lines=N"), so the SessionEnd compressor
145
+ // summarized a list of filenames and hallucinated content the dialogue never
146
+ // contained (lior-test-6: "Flask app: app.py" — inferred a framework from a
147
+ // filename). Buffering the actual user+assistant turns here means the summary
148
+ // reflects what was DISCUSSED. Same `## <ts> — speaker` shape as the transcript
149
+ // so the compressor reads it as dialogue; now.md is truncated after each compress
150
+ // (compress-session), so this is a transient session buffer, not a second store.
151
+ // Best-effort: a now.md write failure must not abort the capture (the transcript
152
+ // is already the durable record).
153
+ // Per-turn cap on the ASSISTANT contribution to now.md (skill-review I1). compress
154
+ // returns on cooldown BEFORE truncating now.md (compress-session §8.2), and
155
+ // auto-extract refreshes the shared cooldown every turn — so during an active
156
+ // session now.md is NOT truncated and accumulates. A verbose assistant response
157
+ // (code dumps, long explanations) is far larger than the old file-write line, so
158
+ // we bound each assistant turn's footprint. The USER turn is left FULL — it's
159
+ // short and carries the standing rules/decisions we must not truncate. The
160
+ // residual many-short-turns growth is bounded by the eventual truncate +
161
+ // compress's 50s-timeout/degradation backstop; offset-based compression is the
162
+ // v0.2.x escalation if mega-sessions ever bite.
163
+ const NOW_MD_ASSISTANT_CAP = 4000;
164
+
165
+ function appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn }) {
166
+ const sessionsDir = join(projectRoot, 'context', 'sessions');
167
+ const nowMdPath = join(sessionsDir, 'now.md');
168
+ const blocks = [];
169
+ if (userTurn && userTurn.trim() !== '') {
170
+ blocks.push(`## ${ts} — user\n\n${userTurn}\n`);
171
+ }
172
+ let asst = assistantTurn ?? '';
173
+ if (asst.length > NOW_MD_ASSISTANT_CAP) {
174
+ asst = asst.slice(0, NOW_MD_ASSISTANT_CAP) + '\n…[assistant turn truncated for the session buffer]';
175
+ }
176
+ blocks.push(`## ${ts} — assistant\n\n${asst}\n`);
177
+ try {
178
+ if (!existsSync(sessionsDir)) mkdirSync(sessionsDir, { recursive: true });
179
+ appendFileSync(nowMdPath, blocks.join('\n') + '\n', 'utf8');
180
+ } catch {
181
+ // Best-effort — the transcript is the durable record; a missing now.md
182
+ // entry only means this turn isn't in the next session summary.
183
+ }
184
+ }
185
+
142
186
  // Assemble the both-turns temp-file body. Both turns are sanitized
143
187
  // upstream — the user body comes from the transcript (which
144
188
  // capture-prompt sanitized when writing it) and the assistant body
@@ -176,6 +220,11 @@ function spawnAutoExtract(autoExtractPath, turnFile, projectRoot) {
176
220
  detached: true,
177
221
  stdio: 'ignore',
178
222
  cwd: projectRoot,
223
+ // Task 81: suppress the Windows console window the detached child
224
+ // would otherwise flash. (This site spawns `node` directly already,
225
+ // so windowsHide is effective here — unlike the legacy shell:true
226
+ // lazy-compress spawn, which also needed the node-direct fix.)
227
+ windowsHide: true,
179
228
  env: { ...process.env, CMK_PROJECT_DIR: projectRoot },
180
229
  },
181
230
  );
@@ -227,6 +276,11 @@ export function captureTurn({
227
276
  // wrote it before this Stop fired); the assistant portion is the
228
277
  // `sanitized` text we just appended above.
229
278
  const userTurn = readLastUserTurnFromTranscript(transcriptPath);
279
+
280
+ // Task 87: buffer the conversation into now.md so the SessionEnd compressor
281
+ // summarizes the DIALOGUE, not observe-edit's filename log. Best-effort.
282
+ appendConversationToNowMd({ projectRoot, ts, userTurn, assistantTurn: sanitized });
283
+
230
284
  const turnFile = join(transcriptsDir, `.extract-${Date.now()}.tmp`);
231
285
  try {
232
286
  writeFileSync(
@@ -89,18 +89,16 @@ function buildCompressionInstructions(maxOutputBytes) {
89
89
  '## Open Questions',
90
90
  '- <one bullet per unresolved question raised during the session, ≤80 chars>',
91
91
  '',
92
- '## Files Touched',
93
- '- path: <relative path> — <verb summary> (cites: [#P-XXXXXXXX])',
94
- '',
95
92
  '## Active Threads',
96
93
  '- <one bullet per work-in-progress thread the next session should resume, ≤80 chars>',
97
94
  '',
98
95
  'HARD RULES:',
99
- ' 1. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
100
- ` 2. Total output ${maxOutputBytes} bytes.`,
101
- ' 3. If a section has no entries, omit the heading entirely (do not emit an empty heading).',
102
- ' 4. No prose around the headings only the bulleted list per section.',
103
- ' 5. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself, do not narrate.',
96
+ ' 1. Every bullet must be grounded in the session buffer below. Do not infer or guess any fact not explicitly stated in the buffer. Do not carry forward content from earlier summaries. When the buffer corrects, replaces, or reverses something stated earlier, keep ONLY the latest version of that fact — never list the superseded one alongside it (this resolves contradictions, NOT coexisting facts on different points). If unsure, omit it.',
97
+ ' 2. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
98
+ ` 3. Total output ${maxOutputBytes} bytes.`,
99
+ ' 4. If a section has no entries, omit the heading entirely (do not emit an empty heading).',
100
+ ' 5. No prose around the headings only the bulleted list per section.',
101
+ ' 6. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself, do not narrate.',
104
102
  '',
105
103
  `The session buffer to compress appears below between the ${SESSION_BUFFER_DELIMITER} and ${SESSION_BUFFER_END_DELIMITER} markers.`,
106
104
  ].join('\n');
@@ -14,7 +14,7 @@
14
14
  // v0.2 candidates per ADR-0008: BedrockHaiku, LocalLlama; selected via
15
15
  // settings.json (`compressor.backend`).
16
16
  //
17
- // Sandbox flags (cd /tmp, env -u CLAUDECODE, --allowed-tools "",
17
+ // Sandbox flags (cd /tmp, env -u CLAUDECODE, --tools "" + --allowed-tools "",
18
18
  // --max-turns 1, --mcp-config '{"mcpServers":{}}' --strict-mcp-config,
19
19
  // stdin from temp file) are absorbed from claude-remember's verified
20
20
  // pattern (see docs/research/2026-05-25-claude-remember-code-dive.md
@@ -180,13 +180,28 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
180
180
  const mcpConfigPath = join(sandbox, 'empty-mcp.json');
181
181
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), 'utf8');
182
182
 
183
- // Build claude --print invocation with the documented sandbox flags.
184
- // Empty allowedTools + empty MCP config = tightest possible sandbox;
185
- // the sub-Claude can only respond, not act.
183
+ // Build claude --print invocation with the documented sandbox flags (D-43,
184
+ // verified against code.claude.com/docs/en/cli-reference, Task 88).
185
+ // --tools "" → disables ALL built-in tools. This is the flag that
186
+ // actually restricts what the sub-Claude can DO ("Use
187
+ // `""` to disable all" per the CLI reference). It is the
188
+ // real sandbox: the model can only emit text.
189
+ // --allowed-tools "" → an AUTO-APPROVE allowlist (tools that run WITHOUT a
190
+ // permission prompt), NOT an availability restriction.
191
+ // Empty = nothing auto-approves; kept as defense-in-depth
192
+ // (redundant once --tools "" removes all tools, harmless).
193
+ // --max-turns 1 + empty --mcp-config + --strict-mcp-config → one turn, no MCP
194
+ // tools, so even absent an approver the model can't act.
195
+ // (Earlier this relied on `--allowed-tools ""` alone with a comment claiming it
196
+ // was the sandbox — wrong per primary source: that flag is the allowlist, not
197
+ // the restriction. Live exposure was ~nil under --print+--max-turns 1, but the
198
+ // flag now matches the stated intent.)
186
199
  const args = [
187
200
  '--print',
188
201
  '--model',
189
202
  this._model,
203
+ '--tools',
204
+ '',
190
205
  '--allowed-tools',
191
206
  '',
192
207
  '--max-turns',
@@ -111,7 +111,13 @@ export function tokenJaccardSimilarity(a, b) {
111
111
 
112
112
  const BULLET_LINE_RE = /^- \(([PUL])-([A-Za-z0-9]{8})\)\s+(.+)$/;
113
113
  const HEADING_LINE_RE = /^##\s+(.+?)\s*$/;
114
- const PROVENANCE_RE = /^<!--\s*(.*?)\s*-->\s*$/;
114
+ // Leading `\s*` is load-bearing: scratchpad provenance comments are written
115
+ // INDENTED (` <!-- … trust: high … -->`) by writeBullet/appendScratchpadBullet.
116
+ // Without the leading-whitespace tolerance, collectExistingBullets failed to
117
+ // parse trust and defaulted EVERY existing bullet to 'medium' — so a new
118
+ // medium fact would wrongly 'supersede' an existing trust:high hand-curated
119
+ // bullet instead of routing to the conflict queue (B-1, surfaced by Task 45).
120
+ const PROVENANCE_RE = /^\s*<!--\s*(.*?)\s*-->\s*$/;
115
121
 
116
122
  function findSectionRange(lines, sectionTitle) {
117
123
  let startIdx = -1;
@@ -244,6 +250,7 @@ export function detectConflicts({
244
250
  conflict: true,
245
251
  action: 'supersede',
246
252
  existingId: best.id,
253
+ existingText: best.text,
247
254
  similarity: best.similarity,
248
255
  similarityBackend,
249
256
  };
@@ -33,6 +33,7 @@ import {
33
33
  isCooldownActive,
34
34
  touchCooldownMarker,
35
35
  } from './cooldown.mjs';
36
+ import { autoDrainQueues } from './auto-drain.mjs';
36
37
 
37
38
  const DEFAULT_MAX_OUTPUT_BYTES = 4096;
38
39
  const SESSIONS_REL = ['context', 'sessions'];
@@ -56,19 +57,17 @@ function buildDistillInstructions(maxOutputBytes) {
56
57
  '## Open Questions',
57
58
  '- <one bullet per unresolved question, ≤80 chars>',
58
59
  '',
59
- '## Files Touched',
60
- '- path: <relative path> — <verb summary across days>',
61
- '',
62
60
  '## Active Threads',
63
61
  '- <one bullet per active work-in-progress thread, ≤80 chars>',
64
62
  '',
65
63
  'HARD RULES:',
66
- ' 1. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
67
- ` 2. Total output ${maxOutputBytes} bytes.`,
68
- ' 3. If a section has no entries, omit the heading entirely.',
69
- ' 4. No prose around the headings only the bulleted list per section.',
70
- ' 5. Deduplicate aggressively: if the same decision appears across multiple days, list it ONCE.',
71
- ' 6. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself.',
64
+ ' 1. Every bullet must be grounded in the daily summaries below. Do not infer or add any fact not explicitly present in them. When the summaries show a fact was later corrected, replaced, or reversed, keep ONLY the latest version of that fact — never list the superseded one alongside it (this resolves contradictions, NOT coexisting facts on different points). If unsure, omit it.',
65
+ ' 2. Preserve every citation ID matching /#[ULP]-[A-Z0-9]{6,8}/ verbatim. Never invent new IDs.',
66
+ ` 3. Total output ${maxOutputBytes} bytes.`,
67
+ ' 4. If a section has no entries, omit the heading entirely.',
68
+ ' 5. No prose around the headings only the bulleted list per section.',
69
+ ' 6. Deduplicate aggressively: if the same decision appears across multiple days, list it ONCE.',
70
+ ' 7. Your output goes directly into the next session\'s memory. Do not address the user, do not refer to yourself.',
72
71
  '',
73
72
  '=== BEGIN DAILY SUMMARIES TO CONSOLIDATE ===',
74
73
  ].join('\n');
@@ -125,6 +124,7 @@ export async function dailyDistill({
125
124
  now,
126
125
  cooldownMs = DEFAULT_COOLDOWN_MS,
127
126
  maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
127
+ skipDrain = false,
128
128
  } = {}) {
129
129
  const ts = now ?? nowIso();
130
130
  const date = ts.slice(0, 10);
@@ -143,6 +143,13 @@ export async function dailyDistill({
143
143
  return { action: 'skipped', reason: 'no-context-dir', duration_ms: Date.now() - t0 };
144
144
  }
145
145
 
146
+ // Auto-drain the project-tier review + conflict queues (v0.2 Phase 2, D-6).
147
+ // Non-Haiku file IO — runs on EVERY pass regardless of the Haiku cooldown
148
+ // below, so queues drain even when the distill itself is cooled down.
149
+ // skipDrain: weeklyCurate calls dailyDistill internally AFTER it already
150
+ // drained, so it passes skipDrain to avoid a redundant (idempotent) drain.
151
+ const drained = skipDrain ? null : await autoDrainQueues({ tier: 'P', projectRoot });
152
+
146
153
  // Cooldown gate per design §8.2.
147
154
  if (isCooldownActive({ projectRoot, now: ts, cooldownMs })) {
148
155
  const duration_ms = Date.now() - t0;
@@ -153,7 +160,7 @@ export async function dailyDistill({
153
160
  cost_usd: 0, duration_ms, success: true, skipped_reason: 'cooldown',
154
161
  };
155
162
  writeDistillLogEntry({ projectRoot, date, entry });
156
- return { action: 'skipped', reason: 'cooldown', duration_ms };
163
+ return { action: 'skipped', reason: 'cooldown', drained, duration_ms };
157
164
  }
158
165
 
159
166
  // Read last 7 days of today-*.md.
@@ -180,7 +187,7 @@ export async function dailyDistill({
180
187
  skipped_reason: 'no-input',
181
188
  },
182
189
  });
183
- return { action: 'skipped', reason: 'no-input', duration_ms };
190
+ return { action: 'skipped', reason: 'no-input', drained, duration_ms };
184
191
  }
185
192
 
186
193
  const buffer = readBuffer(files);
@@ -247,6 +254,7 @@ export async function dailyDistill({
247
254
  bytesIn: input_bytes,
248
255
  bytesOut: output_bytes,
249
256
  sourceDays: files.length,
257
+ drained,
250
258
  duration_ms,
251
259
  };
252
260
  }
package/src/doctor.mjs CHANGED
@@ -44,6 +44,7 @@ import { basename, join } from 'node:path';
44
44
  import { nowIso } from './audit-log.mjs';
45
45
  import { detectStaleLocks } from './lock-discipline.mjs';
46
46
  import { cronSentinelPath } from './lazy-compress.mjs';
47
+ import { getNativeAutoMemoryState } from './native-memory.mjs';
47
48
 
48
49
  const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
49
50
  const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
@@ -188,12 +189,15 @@ function hc2Hooks({ projectRoot }) {
188
189
  function hc3DistillFreshness({ projectRoot, now }) {
189
190
  const recentPath = join(projectRoot, ...RECENT_MD_REL);
190
191
  if (!existsSync(recentPath)) {
192
+ // Not a failure: on a fresh project there's nothing distilled yet, and
193
+ // when there is, the lazy-on-read path runs the distill at SessionStart
194
+ // (or `cmk daily-distill`). "Stale recent.md" below IS a real fail; a
195
+ // never-built one is just "not yet" — mirror HC-5's skip-on-fresh.
191
196
  return {
192
197
  id: 'HC-3',
193
198
  name: 'Daily distill is fresh (≤2 days)',
194
- status: 'fail',
195
- message: 'context/sessions/recent.md missing — distill never ran',
196
- recoveryCommand: 'cmk daily-distill',
199
+ status: 'skip',
200
+ message: 'recent.md not built yet nothing to distill. Runs automatically (lazy-on-read at SessionStart, or `cmk daily-distill`) once there is session content.',
197
201
  };
198
202
  }
199
203
  let mtimeMs;
@@ -231,19 +235,22 @@ function hc3DistillFreshness({ projectRoot, now }) {
231
235
  function hc4Transcripts({ projectRoot, now }) {
232
236
  const transcriptsDir = join(projectRoot, ...TRANSCRIPTS_REL);
233
237
  if (!existsSync(transcriptsDir)) {
238
+ // Fresh project, never had a Claude Code session here → nothing to fire
239
+ // yet. Skip, don't fail (the dir + first transcript appear on the first turn).
234
240
  return {
235
241
  id: 'HC-4',
236
242
  name: 'Transcripts firing (≤3 days)',
237
- status: 'fail',
238
- message: 'context/transcripts/ missingkit not capturing turn transcripts',
239
- recoveryCommand: 'check that this project is the primary cwd in Claude Code, then reopen the project',
243
+ status: 'skip',
244
+ message: 'no transcripts yetthey appear after your first Claude Code turn in this project.',
240
245
  };
241
246
  }
242
247
  const nowMs = new Date(now ?? nowIso()).getTime();
243
248
  const cutoffMs = nowMs - THREE_DAYS_MS;
249
+ let totalCount = 0;
244
250
  let recentCount = 0;
245
251
  for (const name of readdirSync(transcriptsDir)) {
246
252
  if (!/\.md$/.test(name)) continue;
253
+ totalCount += 1;
247
254
  try {
248
255
  const mtimeMs = statSync(join(transcriptsDir, name)).mtimeMs;
249
256
  if (mtimeMs >= cutoffMs) recentCount += 1;
@@ -251,12 +258,24 @@ function hc4Transcripts({ projectRoot, now }) {
251
258
  // skip unreadable
252
259
  }
253
260
  }
261
+ if (totalCount === 0) {
262
+ // Dir exists (scaffolded) but no transcripts captured yet → still "not
263
+ // yet", not a failure.
264
+ return {
265
+ id: 'HC-4',
266
+ name: 'Transcripts firing (≤3 days)',
267
+ status: 'skip',
268
+ message: 'no transcripts yet — they appear after your first Claude Code turn in this project.',
269
+ };
270
+ }
254
271
  if (recentCount === 0) {
272
+ // Transcripts EXIST but none recent → the kit was capturing and stopped.
273
+ // That IS a real signal (the Stop hook may not be firing here).
255
274
  return {
256
275
  id: 'HC-4',
257
276
  name: 'Transcripts firing (≤3 days)',
258
277
  status: 'fail',
259
- message: 'no transcripts within 3 days — likely this project is not Claude Code\'s primary cwd',
278
+ message: 'transcripts exist but none within 3 days — the Stop hook may have stopped firing (is this project Claude Code\'s primary cwd?)',
260
279
  recoveryCommand: 'reopen this project as the primary cwd in Claude Code',
261
280
  };
262
281
  }
@@ -318,16 +337,28 @@ function hc5IndexConsistency({ projectRoot }) {
318
337
  recoveryCommand: 'cmk reindex',
319
338
  };
320
339
  }
321
- // M2 fix (skill-review 2026-05-28): constrain the regex to fact-file
322
- // id shapes (`[PUL]-XXXXXXXX.md`) so unrelated markdown links inside
323
- // INDEX.md (e.g., "see also design.md") don't false-positive as fact
324
- // file references. Mirrors the kit's ID_PATTERN base32 alphabet
325
- // (excluding 0/O/1/l/I/8).
340
+ // Fact-file references in INDEX.md are markdown LINK TARGETS whose filename
341
+ // follows the kit's `<type>_<slug>.md` convention (e.g. feedback_layered.md)
342
+ // that's what `cmk reindex`'s formatIndexLine writes: `[slug](type_slug.md)`.
343
+ // Match the link target's *.md basename, THEN keep only fact-file-shaped names.
344
+ //
345
+ // Two false-positives this must avoid (both real):
346
+ // 1. id-shaped names — the pre-Task-85 regex matched `[PUL]-XXXXXXXX.md`,
347
+ // which the kit NEVER generates, so HC-5 false-FAILED "missing" on every
348
+ // real fact the moment one existed (lior-test-7 2026-06-03).
349
+ // 2. non-fact links — a broad `](...md)` match also catches the scaffold's
350
+ // own example `- [type] [Title](filename.md)` (inside an HTML comment) and
351
+ // any prose link like `(design.md)`, which would false-FAIL "stale" on a
352
+ // FRESH install (skill-review 2026-06-03). The `<type>_<slug>` shape
353
+ // (a `type_` underscore prefix) excludes `filename.md` / `design.md` /
354
+ // `0001.md` while matching every real fact file.
355
+ const FACT_FILE_RE = /^[a-z]+_[a-z0-9][a-z0-9-]*\.md$/i;
326
356
  const indexEntries = new Set();
327
- const re = /\b([PUL]-[A-Za-z2-9]{8})\.md\b/g;
357
+ const re = /\]\(([^)]+\.md)\)/g;
328
358
  let m;
329
359
  while ((m = re.exec(indexText)) !== null) {
330
- indexEntries.add(m[1] + '.md');
360
+ const fname = m[1].split(/[\\/]/).pop(); // basename, tolerate ./ or path-prefixed links
361
+ if (fname && fname !== 'INDEX.md' && FACT_FILE_RE.test(fname)) indexEntries.add(fname);
331
362
  }
332
363
  const factSet = new Set(factFiles);
333
364
  const inFactsNotIndex = [...factSet].filter((f) => !indexEntries.has(f));
@@ -362,12 +393,15 @@ function hc6CronRegistered({ projectRoot }) {
362
393
  message: 'cron-registered sentinel present',
363
394
  };
364
395
  }
396
+ // Cron is OPTIONAL by design (README + design §… the lazy-on-read fallback
397
+ // compresses at SessionStart without any scheduler). Absence is therefore a
398
+ // SKIP, not a FAIL — flagging an optional, working-by-fallback feature as a
399
+ // failure made a healthy fresh install read as broken.
365
400
  return {
366
401
  id: 'HC-6',
367
402
  name: 'Cron jobs registered with host scheduler',
368
- status: 'fail',
369
- message: 'no cron-registered sentinelkit will use lazy-on-read fallback (still functional, slower)',
370
- recoveryCommand: 'cmk register-crons',
403
+ status: 'skip',
404
+ message: 'cron not registered (optional)using the lazy-on-read fallback (compresses at SessionStart). Run `cmk register-crons` for scheduled background compression.',
371
405
  };
372
406
  }
373
407
 
@@ -443,6 +477,13 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
443
477
  // NOW" checks, not trend analysis. Trend logging is a v0.1.x
444
478
  // candidate (would require append + rotation or a separate
445
479
  // history.ndjson file).
480
+ // ADR-0011 / Task 60: the project's committable `autoMemoryEnabled` setting
481
+ // is what actually governs native memory going forward. Fold it into both
482
+ // the snapshot log (Door 4 observability) and the message so the
483
+ // `cmk disable-native-memory` opt-in is DISCOVERABLE here, not just at install.
484
+ const { state: settingState } = getNativeAutoMemoryState({ projectRoot });
485
+ entry.setting_state = settingState;
486
+
446
487
  const logPath = join(projectRoot, ...NATIVE_MEMORY_LOG_REL);
447
488
  try {
448
489
  mkdirSync(join(projectRoot, ...LOCKS_REL), { recursive: true });
@@ -450,16 +491,26 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
450
491
  } catch {
451
492
  // best-effort
452
493
  }
494
+
495
+ let message;
496
+ if (settingState === 'disabled') {
497
+ // The user opted out — native won't write here regardless of any old files.
498
+ message = 'Anthropic auto-memory DISABLED for this project via .claude/settings.json — the kit is the sole memory layer.';
499
+ } else if (entry.active === true) {
500
+ // Native is writing AND not disabled → both layers run (context bloat).
501
+ // Surface the one-command opt-out (the coexistence choice, discoverable late).
502
+ message = `Anthropic auto-memory ACTIVE (${entry.file_count} files; last: ${entry.last_modified ?? 'unknown'}) — running ALONGSIDE the kit (both inject at session start). Run \`cmk disable-native-memory\` to use one lean layer.`;
503
+ } else if (entry.active === false) {
504
+ message = 'Anthropic auto-memory not active for this project (kit is the sole memory source).';
505
+ } else {
506
+ message = 'Anthropic auto-memory state unknown (directory unreadable).';
507
+ }
508
+
453
509
  return {
454
510
  id: 'HC-8',
455
511
  name: 'Native Anthropic Auto Memory status detected',
456
512
  status: 'pass',
457
- message:
458
- entry.active === true
459
- ? `Anthropic auto-memory ACTIVE (${entry.file_count} files; last: ${entry.last_modified ?? 'unknown'})`
460
- : entry.active === false
461
- ? 'Anthropic auto-memory not active for this project (kit is the sole memory source)'
462
- : 'Anthropic auto-memory state unknown (directory unreadable)',
513
+ message,
463
514
  };
464
515
  }
465
516
 
package/src/forget.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import { parse, format } from './frontmatter.mjs';
27
27
  import { appendAuditEntry, nowIso, REASON_CODES } from './audit-log.mjs';
28
28
  import { ERROR_CATEGORIES, errorResult, notFoundResult } from './result-shapes.mjs';
29
+ import { findBulletScratchpad } from './bullet-lookup.mjs';
29
30
 
30
31
  // Layer-2 review: PR-1 rejected \n / \r / : in the `reason` field as a
31
32
  // minimum fix for the naive serializer (finding B2). PR-2's frontmatter.mjs
@@ -208,6 +209,19 @@ export function forget(opts = {}) {
208
209
  : resolveByQuery(idOrQuery, { projectRoot, userDir });
209
210
 
210
211
  if (resolved.matches.length === 0) {
212
+ // If the id is actually a scratchpad BULLET (the `cmk search` id mix-up —
213
+ // search lists bullet ids too, but forget tombstones FACTS), say so instead
214
+ // of the flat "no matching fact".
215
+ const bulletIn = ID_PATTERN.test(idOrQuery)
216
+ ? findBulletScratchpad(idOrQuery, { projectRoot, userDir })
217
+ : null;
218
+ if (bulletIn) {
219
+ return notFoundResult({
220
+ errors: [
221
+ `'${idOrQuery}' is a scratchpad bullet in ${bulletIn}, not a fact — \`cmk forget\` tombstones facts in context/memory/. A high-trust bullet becomes forgettable by this same id once it graduates to a fact (under cap pressure / at session end); a low/medium one ages out via consolidation. To remove it now, edit ${bulletIn} directly.`,
222
+ ],
223
+ });
224
+ }
211
225
  return notFoundResult({
212
226
  errors: [`no matching fact for "${idOrQuery}"`],
213
227
  });
@@ -0,0 +1,65 @@
1
+ // Proactive SessionEnd graduation sweep (Task 94.3, D-61 / design §19).
2
+ //
3
+ // The reactive relief inside appendScratchpadBullet only fires when a write
4
+ // triggers cap pressure. This sweep runs the same relief OUTSIDE the append path,
5
+ // across every fact-bearing scratchpad, so each scratchpad's INJECTED slice stays
6
+ // under its load-cap even in a read-only session — and low/medium bullets that
7
+ // AGED past the 14-day stale window between sessions get caught.
8
+ //
9
+ // Composition (the §6.8/§7.1 disjoint-input rule): this is invoked by
10
+ // runSessionEndTasks SEQUENTIALLY, AFTER the concurrent compress+persona block —
11
+ // because autoPersona WRITES the user-tier persona scratchpads (USER/HABITS/
12
+ // LESSONS) and this READS+rewrites them, so the two must not overlap. Running
13
+ // after means the sweep sees the freshly-promoted persona, then trims overflow.
14
+ // It is pure local file I/O (no Haiku/network), so it adds <<1s on top of the
15
+ // ~50s concurrent block — no SessionEnd hook-ceiling risk.
16
+
17
+ import { sweepScratchpadForCapRelief } from './scratchpad.mjs';
18
+
19
+ // The fact-bearing scratchpads, per tier. The L tier (machine-paths/overrides) is
20
+ // machine-specific config, not durable facts — never graduated (matches the
21
+ // `tier === 'P' || tier === 'U'` gate in the reactive path).
22
+ const GRADUATION_TARGETS = Object.freeze([
23
+ { tier: 'P', scratchpad: 'MEMORY.md' },
24
+ { tier: 'P', scratchpad: 'SOUL.md' },
25
+ { tier: 'U', scratchpad: 'USER.md' },
26
+ { tier: 'U', scratchpad: 'HABITS.md' },
27
+ { tier: 'U', scratchpad: 'LESSONS.md' },
28
+ ]);
29
+
30
+ /**
31
+ * Sweep every fact-bearing scratchpad, graduating overflow so each stays under
32
+ * its load-cap. Best-effort: a failure on one scratchpad is captured as an
33
+ * `error` result and never aborts the rest (a SessionEnd hook must never throw).
34
+ *
35
+ * @param {object} opts
36
+ * @param {string} opts.projectRoot - resolved project root.
37
+ * @param {string} opts.userDir - user-tier root.
38
+ * @param {string} [opts.now] - ISO timestamp override (tests).
39
+ * @param {object} [opts.settings] - test-injected cap override.
40
+ * @returns {{results: object[], totalGraduated: number, totalConsolidated: number}}
41
+ */
42
+ export function graduateAllScratchpads({ projectRoot, userDir, now, settings } = {}) {
43
+ const results = [];
44
+ for (const t of GRADUATION_TARGETS) {
45
+ try {
46
+ results.push(
47
+ sweepScratchpadForCapRelief({ ...t, projectRoot, userDir, now, settings }),
48
+ );
49
+ } catch (err) {
50
+ results.push({
51
+ tier: t.tier,
52
+ scratchpad: t.scratchpad,
53
+ action: 'error',
54
+ error: err?.message ?? String(err),
55
+ bulletsConsolidated: 0,
56
+ bulletsGraduated: 0,
57
+ graduatedIds: [],
58
+ bytes: 0,
59
+ });
60
+ }
61
+ }
62
+ const totalGraduated = results.reduce((s, r) => s + (r.bulletsGraduated || 0), 0);
63
+ const totalConsolidated = results.reduce((s, r) => s + (r.bulletsConsolidated || 0), 0);
64
+ return { results, totalGraduated, totalConsolidated };
65
+ }