@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.
- package/README.md +8 -5
- package/bin/cmk-auto-extract.mjs +13 -0
- package/bin/cmk-compress-session.mjs +31 -17
- package/bin/cmk-inject-context.mjs +12 -2
- package/bin/cmk-weekly-curate.mjs +14 -2
- package/package.json +3 -2
- package/src/audit-log.mjs +6 -0
- package/src/auto-drain.mjs +59 -0
- package/src/auto-extract.mjs +117 -6
- package/src/auto-persona.mjs +544 -0
- package/src/bullet-lookup.mjs +59 -0
- package/src/capture-turn.mjs +54 -0
- package/src/compress-session.mjs +6 -8
- package/src/compressor.mjs +19 -4
- package/src/conflict-queue.mjs +8 -1
- package/src/daily-distill.mjs +19 -11
- package/src/doctor.mjs +74 -23
- package/src/forget.mjs +14 -0
- package/src/graduate-session.mjs +65 -0
- package/src/graduation.mjs +179 -0
- package/src/inject-context.mjs +206 -59
- package/src/install.mjs +52 -7
- package/src/lessons-promote.mjs +137 -0
- package/src/memory-write.mjs +2 -2
- package/src/native-memory.mjs +98 -0
- package/src/persona-portability.mjs +253 -0
- package/src/provenance.mjs +23 -5
- package/src/read-hook-stdin.mjs +47 -0
- package/src/register-crons.mjs +17 -8
- package/src/scratchpad.mjs +247 -19
- package/src/session-end-tasks.mjs +127 -0
- package/src/settings-hooks.mjs +33 -3
- package/src/subcommands.mjs +339 -16
- package/src/weekly-curate.mjs +53 -6
- package/src/write-fact.mjs +14 -0
- package/template/.claude/skills/memory-write/SKILL.md +47 -88
- package/template/.gitignore.fragment +6 -0
- package/template/CLAUDE.md.template +15 -9
- package/template/local/machine-paths.md.template +1 -12
- package/template/local/overrides.md.template +1 -11
- package/template/project/MEMORY.md.template +5 -26
- package/template/project/SOUL.md.template +1 -10
- package/template/user/fragments/INDEX.md.template +1 -1
- package/template/.claude/hooks/pre-tool-memory.js +0 -78
- package/template/.claude/hooks/transcript-capture.js +0 -69
- package/template/.claude/settings.json +0 -27
- package/template/support/scripts/auto-extract-memory.sh +0 -102
- package/template/support/scripts/refresh-distill-timestamp.py +0 -35
- package/template/support/scripts/register-crons.py +0 -242
- package/template/support/scripts/run-daily-distill.sh +0 -67
- package/template/support/scripts/run-weekly-curate.sh +0 -58
package/src/capture-turn.mjs
CHANGED
|
@@ -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(
|
package/src/compress-session.mjs
CHANGED
|
@@ -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.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
' 4.
|
|
103
|
-
' 5.
|
|
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');
|
package/src/compressor.mjs
CHANGED
|
@@ -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
|
-
//
|
|
185
|
-
//
|
|
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',
|
package/src/conflict-queue.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/daily-distill.mjs
CHANGED
|
@@ -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.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
' 4.
|
|
70
|
-
' 5.
|
|
71
|
-
' 6.
|
|
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: '
|
|
195
|
-
message: '
|
|
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: '
|
|
238
|
-
message: '
|
|
239
|
-
recoveryCommand: 'check that this project is the primary cwd in Claude Code, then reopen the project',
|
|
243
|
+
status: 'skip',
|
|
244
|
+
message: 'no transcripts yet — they 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: '
|
|
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
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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 = /\
|
|
357
|
+
const re = /\]\(([^)]+\.md)\)/g;
|
|
328
358
|
let m;
|
|
329
359
|
while ((m = re.exec(indexText)) !== null) {
|
|
330
|
-
|
|
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: '
|
|
369
|
-
message: '
|
|
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
|
+
}
|