@lh8ppl/claude-memory-kit 0.1.1 → 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 (58) hide show
  1. package/README.md +8 -5
  2. package/bin/cmk-auto-extract.mjs +13 -0
  3. package/bin/cmk-capture-prompt.mjs +0 -0
  4. package/bin/cmk-capture-turn.mjs +0 -0
  5. package/bin/cmk-compress-session.mjs +31 -17
  6. package/bin/cmk-inject-context.mjs +12 -2
  7. package/bin/cmk-observe-edit.mjs +0 -0
  8. package/bin/cmk-weekly-curate.mjs +14 -2
  9. package/package.json +3 -2
  10. package/src/audit-log.mjs +6 -0
  11. package/src/auto-drain.mjs +59 -0
  12. package/src/auto-extract.mjs +117 -6
  13. package/src/auto-persona.mjs +544 -0
  14. package/src/bullet-lookup.mjs +59 -0
  15. package/src/capture-turn.mjs +54 -0
  16. package/src/compress-session.mjs +6 -8
  17. package/src/compressor.mjs +37 -22
  18. package/src/conflict-queue.mjs +8 -1
  19. package/src/daily-distill.mjs +19 -11
  20. package/src/doctor.mjs +79 -26
  21. package/src/forget.mjs +14 -0
  22. package/src/graduate-session.mjs +65 -0
  23. package/src/graduation.mjs +179 -0
  24. package/src/index-rebuild.mjs +26 -4
  25. package/src/inject-context.mjs +352 -65
  26. package/src/install.mjs +52 -7
  27. package/src/lessons-promote.mjs +137 -0
  28. package/src/mcp-server.mjs +17 -0
  29. package/src/memory-write.mjs +20 -7
  30. package/src/native-memory.mjs +98 -0
  31. package/src/persona-portability.mjs +253 -0
  32. package/src/provenance.mjs +23 -5
  33. package/src/read-hook-stdin.mjs +47 -0
  34. package/src/register-crons.mjs +17 -8
  35. package/src/sanitize.mjs +39 -0
  36. package/src/scratchpad.mjs +247 -19
  37. package/src/session-end-tasks.mjs +127 -0
  38. package/src/settings-hooks.mjs +33 -3
  39. package/src/spawn-bin.mjs +83 -0
  40. package/src/subcommands.mjs +472 -26
  41. package/src/weekly-curate.mjs +53 -6
  42. package/src/write-fact.mjs +60 -3
  43. package/template/.claude/skills/memory-write/SKILL.md +47 -88
  44. package/template/.gitignore.fragment +6 -0
  45. package/template/CLAUDE.md.template +17 -7
  46. package/template/local/machine-paths.md.template +1 -12
  47. package/template/local/overrides.md.template +1 -11
  48. package/template/project/MEMORY.md.template +5 -26
  49. package/template/project/SOUL.md.template +1 -10
  50. package/template/user/fragments/INDEX.md.template +1 -1
  51. package/template/.claude/hooks/pre-tool-memory.js +0 -78
  52. package/template/.claude/hooks/transcript-capture.js +0 -69
  53. package/template/.claude/settings.json +0 -27
  54. package/template/support/scripts/auto-extract-memory.sh +0 -102
  55. package/template/support/scripts/refresh-distill-timestamp.py +0 -35
  56. package/template/support/scripts/register-crons.py +0 -242
  57. package/template/support/scripts/run-daily-distill.sh +0 -67
  58. 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
@@ -28,6 +28,7 @@
28
28
  // never needs Read either — the turn content arrives in the prompt).
29
29
 
30
30
  import { spawn as defaultSpawn } from 'node:child_process';
31
+ import { spawnBin } from './spawn-bin.mjs';
31
32
  import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
32
33
  import { tmpdir } from 'node:os';
33
34
  import { join } from 'node:path';
@@ -179,13 +180,28 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
179
180
  const mcpConfigPath = join(sandbox, 'empty-mcp.json');
180
181
  writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: {} }), 'utf8');
181
182
 
182
- // Build claude --print invocation with the documented sandbox flags.
183
- // Empty allowedTools + empty MCP config = tightest possible sandbox;
184
- // 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.)
185
199
  const args = [
186
200
  '--print',
187
201
  '--model',
188
202
  this._model,
203
+ '--tools',
204
+ '',
189
205
  '--allowed-tools',
190
206
  '',
191
207
  '--max-turns',
@@ -203,25 +219,24 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
203
219
  const env = { ...process.env };
204
220
  delete env.CLAUDECODE;
205
221
 
206
- // shell:true required on Windows so that .cmd shims (claude.cmd)
207
- // resolve through cmd.exe. Without it, node's spawn fails with
208
- // EINVAL/ENOENT because it won't auto-resolve .cmd extensions
209
- // (CVE-2024-27980 hardening). On Linux/macOS shell:true is a
210
- // no-op for argv-style invocation when the arguments don't contain
211
- // shell metacharacters ours don't (the prompt goes via stdin).
222
+ // spawnBin handles the Windows .cmd-shim problem WITHOUT the
223
+ // `shell:true` + args-array combo that broke paths with spaces (#4):
224
+ // POSIX spawns argv-style (shell:false); Windows builds a single
225
+ // pre-quoted command string so `--mcp-config C:\Users\First Last\…`
226
+ // survives cmd.exe tokenization. See spawn-bin.mjs. `windowsHide`
227
+ // still suppresses the transient cmd.exe console flash on Windows.
212
228
  // spawn-discipline: caller-managed terminateSubprocess (kit's kill-chain helper) + setTimeout (per design §8.5; PR-A composition-verification instance #4; substance pinned by tests/cli-compressor-timeout.test.js + tests/spawn-smoke-kill-chain.test.js). The function signature `timeoutMs` parameter (line 162) is the caller-supplied bound; the setTimeout below (search "Timeout timer") fires the kill chain.
213
- const child = this._spawn(this._bin, args, {
214
- cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
215
- env,
216
- stdio: ['pipe', 'pipe', 'pipe'],
217
- shell: true,
218
- // Suppress the transient cmd.exe console window on Windows —
219
- // every shell:true spawn flashes a window otherwise (visible
220
- // to the user when auto-extract / compress-session fires
221
- // dozens of times per session). stdio is piped so we still
222
- // capture the child's output through the regular handlers.
223
- windowsHide: true,
224
- });
229
+ const child = spawnBin(
230
+ this._bin,
231
+ args,
232
+ {
233
+ cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
234
+ env,
235
+ stdio: ['pipe', 'pipe', 'pipe'],
236
+ windowsHide: true,
237
+ },
238
+ { spawnImpl: this._spawn },
239
+ );
225
240
 
226
241
  const cleanupSandbox = () => {
227
242
  // Single-use sandbox: the directory and the empty-mcp.json file
@@ -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
@@ -38,12 +38,13 @@ import {
38
38
  statSync,
39
39
  writeFileSync,
40
40
  } from 'node:fs';
41
- import { spawnSync } from 'node:child_process';
41
+ import { spawnBinSync } from './spawn-bin.mjs';
42
42
  import { homedir } from 'node:os';
43
43
  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;
@@ -62,7 +63,10 @@ async function hc1Memsearch() {
62
63
  // semantic requires a separate `pip install memsearch[onnx]`.
63
64
  // `requiresInstall: true` so the CLI prompts before auto-installing.
64
65
  try {
65
- const r = spawnSync('memsearch', ['--version'], {
66
+ // spawnBinSync resolves the Windows .cmd shim without `shell:true`+args
67
+ // (no DEP0190; #4). memsearch's only arg is `--version` (no spaces), so
68
+ // the quoting is a no-op here — the win is dropping the deprecated combo.
69
+ const r = spawnBinSync('memsearch', ['--version'], {
66
70
  encoding: 'utf8',
67
71
  // M1 fix (skill-review 2026-05-28): 3.5s tolerates Windows
68
72
  // cold-Python startup (AV scan + .pyc generation on first hit
@@ -71,7 +75,6 @@ async function hc1Memsearch() {
71
75
  // still fits comfortably inside the 5s NFR budget. Timeout →
72
76
  // 'skip' so cmk doctor completes regardless.
73
77
  timeout: 3_500,
74
- shell: process.platform === 'win32',
75
78
  });
76
79
  if (r.status === 0) {
77
80
  return {
@@ -186,12 +189,15 @@ function hc2Hooks({ projectRoot }) {
186
189
  function hc3DistillFreshness({ projectRoot, now }) {
187
190
  const recentPath = join(projectRoot, ...RECENT_MD_REL);
188
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.
189
196
  return {
190
197
  id: 'HC-3',
191
198
  name: 'Daily distill is fresh (≤2 days)',
192
- status: 'fail',
193
- message: 'context/sessions/recent.md missing — distill never ran',
194
- 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.',
195
201
  };
196
202
  }
197
203
  let mtimeMs;
@@ -229,19 +235,22 @@ function hc3DistillFreshness({ projectRoot, now }) {
229
235
  function hc4Transcripts({ projectRoot, now }) {
230
236
  const transcriptsDir = join(projectRoot, ...TRANSCRIPTS_REL);
231
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).
232
240
  return {
233
241
  id: 'HC-4',
234
242
  name: 'Transcripts firing (≤3 days)',
235
- status: 'fail',
236
- message: 'context/transcripts/ missingkit not capturing turn transcripts',
237
- 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.',
238
245
  };
239
246
  }
240
247
  const nowMs = new Date(now ?? nowIso()).getTime();
241
248
  const cutoffMs = nowMs - THREE_DAYS_MS;
249
+ let totalCount = 0;
242
250
  let recentCount = 0;
243
251
  for (const name of readdirSync(transcriptsDir)) {
244
252
  if (!/\.md$/.test(name)) continue;
253
+ totalCount += 1;
245
254
  try {
246
255
  const mtimeMs = statSync(join(transcriptsDir, name)).mtimeMs;
247
256
  if (mtimeMs >= cutoffMs) recentCount += 1;
@@ -249,12 +258,24 @@ function hc4Transcripts({ projectRoot, now }) {
249
258
  // skip unreadable
250
259
  }
251
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
+ }
252
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).
253
274
  return {
254
275
  id: 'HC-4',
255
276
  name: 'Transcripts firing (≤3 days)',
256
277
  status: 'fail',
257
- 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?)',
258
279
  recoveryCommand: 'reopen this project as the primary cwd in Claude Code',
259
280
  };
260
281
  }
@@ -316,16 +337,28 @@ function hc5IndexConsistency({ projectRoot }) {
316
337
  recoveryCommand: 'cmk reindex',
317
338
  };
318
339
  }
319
- // M2 fix (skill-review 2026-05-28): constrain the regex to fact-file
320
- // id shapes (`[PUL]-XXXXXXXX.md`) so unrelated markdown links inside
321
- // INDEX.md (e.g., "see also design.md") don't false-positive as fact
322
- // file references. Mirrors the kit's ID_PATTERN base32 alphabet
323
- // (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;
324
356
  const indexEntries = new Set();
325
- const re = /\b([PUL]-[A-Za-z2-9]{8})\.md\b/g;
357
+ const re = /\]\(([^)]+\.md)\)/g;
326
358
  let m;
327
359
  while ((m = re.exec(indexText)) !== null) {
328
- 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);
329
362
  }
330
363
  const factSet = new Set(factFiles);
331
364
  const inFactsNotIndex = [...factSet].filter((f) => !indexEntries.has(f));
@@ -360,12 +393,15 @@ function hc6CronRegistered({ projectRoot }) {
360
393
  message: 'cron-registered sentinel present',
361
394
  };
362
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.
363
400
  return {
364
401
  id: 'HC-6',
365
402
  name: 'Cron jobs registered with host scheduler',
366
- status: 'fail',
367
- message: 'no cron-registered sentinelkit will use lazy-on-read fallback (still functional, slower)',
368
- 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.',
369
405
  };
370
406
  }
371
407
 
@@ -441,6 +477,13 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
441
477
  // NOW" checks, not trend analysis. Trend logging is a v0.1.x
442
478
  // candidate (would require append + rotation or a separate
443
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
+
444
487
  const logPath = join(projectRoot, ...NATIVE_MEMORY_LOG_REL);
445
488
  try {
446
489
  mkdirSync(join(projectRoot, ...LOCKS_REL), { recursive: true });
@@ -448,16 +491,26 @@ function hc8NativeAutoMemory({ projectRoot, now }) {
448
491
  } catch {
449
492
  // best-effort
450
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
+
451
509
  return {
452
510
  id: 'HC-8',
453
511
  name: 'Native Anthropic Auto Memory status detected',
454
512
  status: 'pass',
455
- message:
456
- entry.active === true
457
- ? `Anthropic auto-memory ACTIVE (${entry.file_count} files; last: ${entry.last_modified ?? 'unknown'})`
458
- : entry.active === false
459
- ? 'Anthropic auto-memory not active for this project (kit is the sole memory source)'
460
- : 'Anthropic auto-memory state unknown (directory unreadable)',
513
+ message,
461
514
  };
462
515
  }
463
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
+ }