@lh8ppl/claude-memory-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- package/template/user/fragments/INDEX.md.template +23 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// UserPromptSubmit hook real handler (Task 19, T-016). Second Layer 4
|
|
2
|
+
// module — fires on every user prompt, sanitizes the privacy tags
|
|
3
|
+
// before any disk write, and appends to the daily transcript file.
|
|
4
|
+
//
|
|
5
|
+
// Public boundary: capturePrompt({payload, projectRoot, now}) → result.
|
|
6
|
+
// The bin wrapper deals with stdin parsing + protocol JSON; this
|
|
7
|
+
// module is pure-function-ish: takes the parsed payload + project
|
|
8
|
+
// root, produces the transcript file as a side effect.
|
|
9
|
+
//
|
|
10
|
+
// Privacy contract (FR-15, design §6.6):
|
|
11
|
+
// - <private>...</private> blocks are REPLACED with the literal
|
|
12
|
+
// "[private content redacted]" placeholder. The original content
|
|
13
|
+
// never touches any disk path under the project.
|
|
14
|
+
// - <retain>...</retain> blocks are preserved VERBATIM (including the
|
|
15
|
+
// tags). The Stop hook + auto-extract subagent downstream uses
|
|
16
|
+
// these tags as force-save signals; stripping them here would
|
|
17
|
+
// break that contract.
|
|
18
|
+
//
|
|
19
|
+
// Transcript format:
|
|
20
|
+
// ## <ISO timestamp> — user
|
|
21
|
+
//
|
|
22
|
+
// <sanitized prompt body>
|
|
23
|
+
//
|
|
24
|
+
// One heading per turn so downstream tools can scan by ## markers
|
|
25
|
+
// (matches claude-remember's compaction strategy).
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { sanitizePrivacyTags } from './privacy.mjs';
|
|
30
|
+
|
|
31
|
+
function dateFromIso(iso) {
|
|
32
|
+
// Slice 'YYYY-MM-DD' from 'YYYY-MM-DDTHH:MM:SSZ'. Validating
|
|
33
|
+
// upstream would be over-engineering — callers pass nowIso() or a
|
|
34
|
+
// test fixture date.
|
|
35
|
+
return String(iso).slice(0, 10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function capturePrompt({ payload, projectRoot, now } = {}) {
|
|
39
|
+
if (!payload || typeof payload !== 'object') {
|
|
40
|
+
return { action: 'noop', reason: 'no-payload' };
|
|
41
|
+
}
|
|
42
|
+
const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
|
|
43
|
+
if (prompt === '') {
|
|
44
|
+
return { action: 'noop', reason: 'empty-prompt' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ts = now ?? new Date().toISOString();
|
|
48
|
+
const date = dateFromIso(ts);
|
|
49
|
+
const transcriptsDir = join(projectRoot, 'context', 'transcripts');
|
|
50
|
+
const transcriptPath = join(transcriptsDir, `${date}.md`);
|
|
51
|
+
|
|
52
|
+
const sanitized = sanitizePrivacyTags(prompt);
|
|
53
|
+
const entry = `## ${ts} — user\n\n${sanitized}\n\n`;
|
|
54
|
+
|
|
55
|
+
if (!existsSync(transcriptsDir)) {
|
|
56
|
+
mkdirSync(transcriptsDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
appendFileSync(transcriptPath, entry, 'utf8');
|
|
59
|
+
|
|
60
|
+
return { action: 'appended', transcriptPath };
|
|
61
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Stop hook real handler (Task 21, T-018). Sixth Layer 4 module.
|
|
2
|
+
//
|
|
3
|
+
// Public boundary: captureTurn({payload, projectRoot, now,
|
|
4
|
+
// autoExtractPath}) → result. Does two things:
|
|
5
|
+
// 1. Append the just-completed assistant turn to
|
|
6
|
+
// <projectRoot>/context/transcripts/{YYYY-MM-DD}.md (parallel to
|
|
7
|
+
// Task 19's capture-prompt, sharing the privacy sanitizer).
|
|
8
|
+
// 2. Spawn the auto-extract subagent (Task 23) as a DETACHED child
|
|
9
|
+
// so it survives the parent's exit. The kit's hook must return
|
|
10
|
+
// within ~50ms; the subagent does its work in the background.
|
|
11
|
+
//
|
|
12
|
+
// stop_hook_active guard (design §5.2.1):
|
|
13
|
+
// The Stop hook can re-fire as a result of a prior Stop hook's
|
|
14
|
+
// decision: "block" response, causing a loop. Anthropic's hook
|
|
15
|
+
// payload carries stop_hook_active: true in those cases. When set,
|
|
16
|
+
// we short-circuit: no transcript append, no spawn, no work — just
|
|
17
|
+
// {action: 'noop'} and let the session close.
|
|
18
|
+
//
|
|
19
|
+
// Auto-extract subprocess (21.3 + 21.4):
|
|
20
|
+
// Unix path: `node ${autoExtractPath} ${turnTempFile}` spawned with
|
|
21
|
+
// detached:true + stdio:'ignore' + unref() — node's equivalent of
|
|
22
|
+
// the bash `</dev/null >/dev/null 2>&1 & disown` pattern from
|
|
23
|
+
// claude-remember. Works identically on Windows when the parent
|
|
24
|
+
// is Git Bash because we use node's spawn, not shell &.
|
|
25
|
+
// The turn text is buffered to a temp file under
|
|
26
|
+
// <projectRoot>/context/transcripts/.extract-<ts>.tmp
|
|
27
|
+
// so the detached child can read it without sharing stdin.
|
|
28
|
+
//
|
|
29
|
+
// Both-turns temp-file shape (design §6.4 amendment, 2026-05-26):
|
|
30
|
+
// The temp file now contains BOTH the prior user prompt AND the
|
|
31
|
+
// just-captured assistant turn, separated by literal markers:
|
|
32
|
+
// USER_TURN:
|
|
33
|
+
// <user body>
|
|
34
|
+
//
|
|
35
|
+
// ASSISTANT_TURN:
|
|
36
|
+
// <assistant body>
|
|
37
|
+
// This lets auto-extract identify candidate-origin (user-stated vs
|
|
38
|
+
// assistant-inferred) and apply the demotion rule from design §6.4
|
|
39
|
+
// (assistant-origin facts demote one trust level so user review is
|
|
40
|
+
// required before they enter MEMORY.md). The user turn is sourced
|
|
41
|
+
// by reading the most recent `## <ts> — user` entry from today's
|
|
42
|
+
// transcript file — which Task 19's capture-prompt wrote just
|
|
43
|
+
// before this Stop hook fired.
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
existsSync,
|
|
47
|
+
mkdirSync,
|
|
48
|
+
appendFileSync,
|
|
49
|
+
readFileSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from 'node:fs';
|
|
52
|
+
import { join } from 'node:path';
|
|
53
|
+
import { spawn } from 'node:child_process';
|
|
54
|
+
import { sanitizePrivacyTags } from './privacy.mjs';
|
|
55
|
+
|
|
56
|
+
function dateFromIso(iso) {
|
|
57
|
+
return String(iso).slice(0, 10);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Write a `phase: 'spawn'` NDJSON entry to `<projectRoot>/context/sessions/{date}.extract.log`
|
|
61
|
+
// when the auto-extract spawn fails. This closes PR-A's class-1 audit
|
|
62
|
+
// deferral (capture-turn Door 5 observability gap). Auto-extract's own
|
|
63
|
+
// in-process log entries don't carry the `phase` field (default = extract);
|
|
64
|
+
// the `phase: 'spawn'` discriminator lets log readers route capture-turn
|
|
65
|
+
// failures distinct from extract-phase failures.
|
|
66
|
+
function writeSpawnLogEntry({ projectRoot, ts, reason, error }) {
|
|
67
|
+
const date = dateFromIso(ts);
|
|
68
|
+
const logDir = join(projectRoot, 'context', 'sessions');
|
|
69
|
+
const logPath = join(logDir, `${date}.extract.log`);
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
72
|
+
const entry = {
|
|
73
|
+
ts,
|
|
74
|
+
phase: 'spawn',
|
|
75
|
+
success: false,
|
|
76
|
+
error_category: 'spawn_failed',
|
|
77
|
+
reason,
|
|
78
|
+
...(error ? { error } : {}),
|
|
79
|
+
};
|
|
80
|
+
appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
81
|
+
return { logged: true, logPath };
|
|
82
|
+
} catch (logErr) {
|
|
83
|
+
// Logging failure must not crash the hook — emit to stderr so it
|
|
84
|
+
// surfaces in the Stop-hook stderr stream Claude Code captures.
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`cmk-capture-turn: failed to write spawn observability log: ${logErr?.message ?? logErr}\n`,
|
|
87
|
+
);
|
|
88
|
+
return { logged: false, error: logErr?.message ?? String(logErr) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractTurnText(payload) {
|
|
93
|
+
// Anthropic Stop hook payload spelling has shifted across Claude Code
|
|
94
|
+
// versions. Probe the documented fields in priority order.
|
|
95
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
96
|
+
if (typeof payload.assistant_message === 'string') return payload.assistant_message;
|
|
97
|
+
if (typeof payload.last_assistant_message === 'string') return payload.last_assistant_message;
|
|
98
|
+
if (typeof payload.response === 'string') return payload.response;
|
|
99
|
+
if (typeof payload.message === 'string') return payload.message;
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Scan today's transcript for the most recent `## <ts> — user` entry
|
|
104
|
+
// and return its body. capture-prompt (Task 19) writes these entries
|
|
105
|
+
// on every UserPromptSubmit; the most-recent one is by definition the
|
|
106
|
+
// user prompt that triggered the assistant turn we're now capturing.
|
|
107
|
+
// Returns '' if the transcript doesn't exist, no user entry is
|
|
108
|
+
// present, or any read error occurs.
|
|
109
|
+
function readLastUserTurnFromTranscript(transcriptPath) {
|
|
110
|
+
if (!existsSync(transcriptPath)) return '';
|
|
111
|
+
let text;
|
|
112
|
+
try {
|
|
113
|
+
text = readFileSync(transcriptPath, 'utf8');
|
|
114
|
+
} catch {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
const lines = text.split(/\r?\n/);
|
|
118
|
+
let lastUserHeadingIdx = -1;
|
|
119
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
120
|
+
// Match capture-prompt's format: "## <iso-ts> — user"
|
|
121
|
+
if (/^##\s+\S+\s+—\s+user\s*$/.test(lines[i])) {
|
|
122
|
+
lastUserHeadingIdx = i;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (lastUserHeadingIdx === -1) return '';
|
|
127
|
+
// The user body runs from the line after the heading until the next
|
|
128
|
+
// `## ` heading (the just-appended assistant entry) or EOF.
|
|
129
|
+
let endIdx = lines.length;
|
|
130
|
+
for (let i = lastUserHeadingIdx + 1; i < lines.length; i++) {
|
|
131
|
+
if (/^##\s/.test(lines[i])) {
|
|
132
|
+
endIdx = i;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const body = lines.slice(lastUserHeadingIdx + 1, endIdx);
|
|
137
|
+
while (body.length > 0 && body[0].trim() === '') body.shift();
|
|
138
|
+
while (body.length > 0 && body[body.length - 1].trim() === '') body.pop();
|
|
139
|
+
return body.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Assemble the both-turns temp-file body. Both turns are sanitized
|
|
143
|
+
// upstream — the user body comes from the transcript (which
|
|
144
|
+
// capture-prompt sanitized when writing it) and the assistant body
|
|
145
|
+
// is the now-sanitized argument. Markers are literal-prefix lines so
|
|
146
|
+
// auto-extract's parser can split cleanly.
|
|
147
|
+
function assembleBothTurnsBody({ userTurn, assistantTurn }) {
|
|
148
|
+
return [
|
|
149
|
+
'USER_TURN:',
|
|
150
|
+
userTurn,
|
|
151
|
+
'',
|
|
152
|
+
'ASSISTANT_TURN:',
|
|
153
|
+
assistantTurn,
|
|
154
|
+
].join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function defaultAutoExtractPath() {
|
|
158
|
+
// Documented sibling of this script's bin wrapper. Resolved by the
|
|
159
|
+
// bin wrapper (which knows CLAUDE_PLUGIN_ROOT); we use this fallback
|
|
160
|
+
// only when the caller passes nothing. The path is allowed to not
|
|
161
|
+
// exist — see spawn block below.
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function spawnAutoExtract(autoExtractPath, turnFile, projectRoot) {
|
|
166
|
+
if (!autoExtractPath) return { spawned: false, reason: 'no-auto-extract-path' };
|
|
167
|
+
if (!existsSync(autoExtractPath)) {
|
|
168
|
+
return { spawned: false, reason: 'auto-extract-missing' };
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
// spawn-discipline: ignore detached-fire-and-forget (the auto-extract child intentionally outlives this hook process — parent-side timeout is incorrect by design; the child carries its own internal timeout via auto-extract.mjs's runAutoExtract → HaikuViaAnthropicApi.compress({timeoutMs: 25_000}) chain. PR-A class-1 audit confirmed this is the correct posture; the structural gap is the spawn-failed observability surface deferred to PR-D2b / Task 23.14.3.)
|
|
172
|
+
const child = spawn(
|
|
173
|
+
'node',
|
|
174
|
+
[autoExtractPath, turnFile],
|
|
175
|
+
{
|
|
176
|
+
detached: true,
|
|
177
|
+
stdio: 'ignore',
|
|
178
|
+
cwd: projectRoot,
|
|
179
|
+
env: { ...process.env, CMK_PROJECT_DIR: projectRoot },
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
child.unref();
|
|
183
|
+
return { spawned: true, pid: child.pid };
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return { spawned: false, reason: 'spawn-failed', error: err?.message ?? String(err) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function captureTurn({
|
|
190
|
+
payload,
|
|
191
|
+
projectRoot,
|
|
192
|
+
now,
|
|
193
|
+
autoExtractPath,
|
|
194
|
+
} = {}) {
|
|
195
|
+
// 1. stop_hook_active guard. Short-circuit BEFORE any disk write or
|
|
196
|
+
// spawn so a recursive Stop firing can't poison the transcript.
|
|
197
|
+
if (payload?.stop_hook_active === true) {
|
|
198
|
+
return { action: 'noop', reason: 'stop-hook-active', spawned: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const turnText = extractTurnText(payload);
|
|
202
|
+
if (turnText.trim() === '') {
|
|
203
|
+
return { action: 'noop', reason: 'no-turn-text', spawned: false };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. Append to today's transcript (sanitized).
|
|
207
|
+
const ts = now ?? new Date().toISOString();
|
|
208
|
+
const date = dateFromIso(ts);
|
|
209
|
+
const transcriptsDir = join(projectRoot, 'context', 'transcripts');
|
|
210
|
+
const transcriptPath = join(transcriptsDir, `${date}.md`);
|
|
211
|
+
if (!existsSync(transcriptsDir)) {
|
|
212
|
+
mkdirSync(transcriptsDir, { recursive: true });
|
|
213
|
+
}
|
|
214
|
+
const sanitized = sanitizePrivacyTags(turnText);
|
|
215
|
+
appendFileSync(
|
|
216
|
+
transcriptPath,
|
|
217
|
+
`## ${ts} — assistant\n\n${sanitized}\n\n`,
|
|
218
|
+
'utf8',
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// 3. Buffer BOTH turns to a temp file so the detached child can read
|
|
222
|
+
// them without sharing our stdin (which has already been consumed
|
|
223
|
+
// by the parent bash wrapper). Per design §6.4, auto-extract
|
|
224
|
+
// reads the user prompt + assistant response together so it can
|
|
225
|
+
// distinguish user-stated facts from assistant-inferred ones.
|
|
226
|
+
// The user portion comes from today's transcript (capture-prompt
|
|
227
|
+
// wrote it before this Stop fired); the assistant portion is the
|
|
228
|
+
// `sanitized` text we just appended above.
|
|
229
|
+
const userTurn = readLastUserTurnFromTranscript(transcriptPath);
|
|
230
|
+
const turnFile = join(transcriptsDir, `.extract-${Date.now()}.tmp`);
|
|
231
|
+
try {
|
|
232
|
+
writeFileSync(
|
|
233
|
+
turnFile,
|
|
234
|
+
assembleBothTurnsBody({ userTurn, assistantTurn: sanitized }),
|
|
235
|
+
'utf8',
|
|
236
|
+
);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// Continue without spawning — partial success is better than abort.
|
|
239
|
+
return {
|
|
240
|
+
action: 'captured',
|
|
241
|
+
transcriptPath,
|
|
242
|
+
spawned: false,
|
|
243
|
+
reason: 'turn-file-write-failed',
|
|
244
|
+
error: err?.message ?? String(err),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 4. Spawn the auto-extract child (Task 23 fills in the script body).
|
|
249
|
+
const path = autoExtractPath ?? defaultAutoExtractPath();
|
|
250
|
+
const spawnResult = spawnAutoExtract(path, turnFile, projectRoot);
|
|
251
|
+
|
|
252
|
+
// 5. Door 5 (observability) — when the spawn doesn't succeed, write
|
|
253
|
+
// an NDJSON entry to extract.log with `phase: 'spawn'`. Closes PR-A
|
|
254
|
+
// class-1 audit deferral (Task 23.14.3). Successful spawns are NOT
|
|
255
|
+
// logged here because their observability is provided by auto-
|
|
256
|
+
// extract.mjs itself (the detached child writes its own entries
|
|
257
|
+
// with `phase` absent, default = 'extract').
|
|
258
|
+
if (!spawnResult.spawned) {
|
|
259
|
+
writeSpawnLogEntry({
|
|
260
|
+
projectRoot,
|
|
261
|
+
ts,
|
|
262
|
+
reason: spawnResult.reason,
|
|
263
|
+
error: spawnResult.error,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
action: 'captured',
|
|
269
|
+
transcriptPath,
|
|
270
|
+
turnFile,
|
|
271
|
+
...spawnResult,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// claude-md.mjs — managed-block injection into the target project's CLAUDE.md.
|
|
2
|
+
//
|
|
3
|
+
// Public contract (tests assert this; internals can change freely):
|
|
4
|
+
//
|
|
5
|
+
// injectClaudeMdBlock({
|
|
6
|
+
// projectRoot, // <repo> root
|
|
7
|
+
// content, // body of the block (without markers)
|
|
8
|
+
// version, // kit version string, e.g. "0.1.0"
|
|
9
|
+
// force, // allow downgrade (replace newer block with older)
|
|
10
|
+
// }) → {
|
|
11
|
+
// action: 'created' // no CLAUDE.md before; one was created
|
|
12
|
+
// | 'appended' // CLAUDE.md existed without our markers; block appended at EOF
|
|
13
|
+
// | 'replaced' // same-version block content updated in place
|
|
14
|
+
// | 'upgraded' // older-version block replaced (kit version is newer)
|
|
15
|
+
// | 'downgrade-blocked' // newer-version block present and force not set
|
|
16
|
+
// | 'forced-downgrade' // newer-version block replaced because force=true
|
|
17
|
+
// | 'unchanged', // existing block content + version match the inputs exactly
|
|
18
|
+
// path: string, // absolute path to the CLAUDE.md
|
|
19
|
+
// oldVersion?: string, // version of the block we replaced (when applicable)
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// removeClaudeMdBlock({ projectRoot }) → {
|
|
23
|
+
// action: 'removed' // managed block found + stripped
|
|
24
|
+
// | 'not-found' // file exists but no managed markers
|
|
25
|
+
// | 'no-file', // CLAUDE.md does not exist
|
|
26
|
+
// path: string,
|
|
27
|
+
// }
|
|
28
|
+
//
|
|
29
|
+
// Design notes:
|
|
30
|
+
// - Deep module: the two boundary functions above are the only public
|
|
31
|
+
// surface. Internal helpers parse markers, compare versions, and
|
|
32
|
+
// splice the block — all private.
|
|
33
|
+
// - Markers wrap the kit-managed content. Everything outside markers is
|
|
34
|
+
// byte-preserved across inject + remove. This is what makes the
|
|
35
|
+
// installer safe to re-run.
|
|
36
|
+
// - Version comparison is semver-style (MAJOR.MINOR.PATCH). Prerelease
|
|
37
|
+
// suffixes (-dev, -alpha.1) are ignored when comparing.
|
|
38
|
+
// - Marker pattern is intentionally the same shape as the .gitignore
|
|
39
|
+
// marker pattern in install.mjs — same idea, same conventions.
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
existsSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
writeFileSync,
|
|
45
|
+
} from 'node:fs';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
|
|
48
|
+
const MARKER_START_RE =
|
|
49
|
+
/<!--\s*claude-memory-kit:start\s+v([\d.]+(?:-[\w.]+)?)\s*-->/;
|
|
50
|
+
const MARKER_END_RE = /<!--\s*claude-memory-kit:end\s*-->/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wrap a content string with kit markers at the given version.
|
|
54
|
+
*/
|
|
55
|
+
function buildBlock(content, version) {
|
|
56
|
+
return `<!-- claude-memory-kit:start v${version} -->\n${content.trim()}\n<!-- claude-memory-kit:end -->`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find the start + end marker positions in the source text.
|
|
61
|
+
* - Returns null when no start marker is present (no managed block).
|
|
62
|
+
* - When a start marker is present but the end marker is missing or
|
|
63
|
+
* misplaced, treats the block as extending to EOF. This recovers
|
|
64
|
+
* gracefully from a corrupted block (e.g. the user accidentally
|
|
65
|
+
* deleted the end marker by hand).
|
|
66
|
+
*/
|
|
67
|
+
function findManagedBlock(text) {
|
|
68
|
+
const startMatch = text.match(MARKER_START_RE);
|
|
69
|
+
if (!startMatch) return null;
|
|
70
|
+
|
|
71
|
+
const endMatch = text.match(MARKER_END_RE);
|
|
72
|
+
if (endMatch && startMatch.index < endMatch.index) {
|
|
73
|
+
return {
|
|
74
|
+
startIdx: startMatch.index,
|
|
75
|
+
endIdx: endMatch.index + endMatch[0].length,
|
|
76
|
+
version: startMatch[1],
|
|
77
|
+
fullText: text.slice(startMatch.index, endMatch.index + endMatch[0].length),
|
|
78
|
+
corrupted: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Orphan start marker → treat the block as extending to EOF so we
|
|
83
|
+
// can replace it cleanly on the next install.
|
|
84
|
+
return {
|
|
85
|
+
startIdx: startMatch.index,
|
|
86
|
+
endIdx: text.length,
|
|
87
|
+
version: startMatch[1],
|
|
88
|
+
fullText: text.slice(startMatch.index),
|
|
89
|
+
corrupted: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Strip trailing -prerelease, parse MAJOR.MINOR.PATCH integers.
|
|
95
|
+
* Tolerates partial versions ("0.1" → [0,1,0]).
|
|
96
|
+
*/
|
|
97
|
+
function parseVersion(v) {
|
|
98
|
+
const base = String(v).replace(/^v/, '').split('-')[0];
|
|
99
|
+
const parts = base.split('.').map((n) => parseInt(n, 10) || 0);
|
|
100
|
+
while (parts.length < 3) parts.push(0);
|
|
101
|
+
return parts.slice(0, 3);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Semver-style comparator. Returns -1 / 0 / 1.
|
|
106
|
+
* compareVersions('0.1.0', '0.2.0') === -1
|
|
107
|
+
* compareVersions('1.0.0', '1.0.0') === 0
|
|
108
|
+
* compareVersions('2.0.0', '1.9.9') === 1
|
|
109
|
+
*/
|
|
110
|
+
function compareVersions(a, b) {
|
|
111
|
+
const av = parseVersion(a);
|
|
112
|
+
const bv = parseVersion(b);
|
|
113
|
+
for (let i = 0; i < 3; i++) {
|
|
114
|
+
if (av[i] < bv[i]) return -1;
|
|
115
|
+
if (av[i] > bv[i]) return 1;
|
|
116
|
+
}
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function injectClaudeMdBlock(opts = {}) {
|
|
121
|
+
const projectRoot = opts.projectRoot;
|
|
122
|
+
const content = String(opts.content || '');
|
|
123
|
+
const version = String(opts.version || '0.0.0');
|
|
124
|
+
const force = !!opts.force;
|
|
125
|
+
if (!projectRoot) throw new Error('injectClaudeMdBlock: projectRoot is required');
|
|
126
|
+
|
|
127
|
+
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
|
|
128
|
+
const newBlock = buildBlock(content, version);
|
|
129
|
+
|
|
130
|
+
// Case 1 — no CLAUDE.md
|
|
131
|
+
if (!existsSync(claudeMdPath)) {
|
|
132
|
+
writeFileSync(claudeMdPath, newBlock + '\n', 'utf8');
|
|
133
|
+
return { action: 'created', path: claudeMdPath };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const existing = readFileSync(claudeMdPath, 'utf8');
|
|
137
|
+
const found = findManagedBlock(existing);
|
|
138
|
+
|
|
139
|
+
// Case 2 — file exists but no (or corrupted) managed block → append
|
|
140
|
+
if (!found) {
|
|
141
|
+
// If the file ends without a newline, add one before the block for
|
|
142
|
+
// readability. Trim trailing whitespace so we don't accumulate blank
|
|
143
|
+
// lines on repeated installs.
|
|
144
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
145
|
+
writeFileSync(claudeMdPath, existing.replace(/\s+$/, '') + sep + newBlock + '\n', 'utf8');
|
|
146
|
+
return { action: 'appended', path: claudeMdPath };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Case 3 — managed block present. Compare versions to choose action.
|
|
150
|
+
const cmp = compareVersions(version, found.version);
|
|
151
|
+
const before = existing.slice(0, found.startIdx);
|
|
152
|
+
const after = existing.slice(found.endIdx);
|
|
153
|
+
|
|
154
|
+
let action;
|
|
155
|
+
if (cmp === 0) {
|
|
156
|
+
if (found.fullText === newBlock) {
|
|
157
|
+
return { action: 'unchanged', path: claudeMdPath, oldVersion: found.version };
|
|
158
|
+
}
|
|
159
|
+
action = 'replaced';
|
|
160
|
+
} else if (cmp > 0) {
|
|
161
|
+
action = 'upgraded';
|
|
162
|
+
} else {
|
|
163
|
+
// cmp < 0 → incoming version is older than installed
|
|
164
|
+
if (!force) {
|
|
165
|
+
return {
|
|
166
|
+
action: 'downgrade-blocked',
|
|
167
|
+
path: claudeMdPath,
|
|
168
|
+
oldVersion: found.version,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
action = 'forced-downgrade';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
writeFileSync(claudeMdPath, before + newBlock + after, 'utf8');
|
|
175
|
+
return { action, path: claudeMdPath, oldVersion: found.version };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function removeClaudeMdBlock(opts = {}) {
|
|
179
|
+
const projectRoot = opts.projectRoot;
|
|
180
|
+
if (!projectRoot) throw new Error('removeClaudeMdBlock: projectRoot is required');
|
|
181
|
+
|
|
182
|
+
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
|
|
183
|
+
|
|
184
|
+
if (!existsSync(claudeMdPath)) {
|
|
185
|
+
return { action: 'no-file', path: claudeMdPath };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const existing = readFileSync(claudeMdPath, 'utf8');
|
|
189
|
+
const found = findManagedBlock(existing);
|
|
190
|
+
|
|
191
|
+
if (!found) {
|
|
192
|
+
return { action: 'not-found', path: claudeMdPath };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Strip the block. If the block was followed by exactly one trailing
|
|
196
|
+
// newline (the one we wrote at injection time), strip it too so the
|
|
197
|
+
// surrounding content stays clean. We do NOT touch newlines that exist
|
|
198
|
+
// in the user's surrounding content.
|
|
199
|
+
let after = existing.slice(found.endIdx);
|
|
200
|
+
if (after.startsWith('\n') && (after.length === 1 || after[1] !== '\n')) {
|
|
201
|
+
after = after.slice(1);
|
|
202
|
+
}
|
|
203
|
+
const before = existing.slice(0, found.startIdx).replace(/\s+$/, '\n');
|
|
204
|
+
|
|
205
|
+
const next = (before + after).trimEnd() + (after.endsWith('\n') ? '\n' : '');
|
|
206
|
+
|
|
207
|
+
writeFileSync(claudeMdPath, next, 'utf8');
|
|
208
|
+
return { action: 'removed', path: claudeMdPath };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Internal helpers are intentionally NOT exported — they're implementation
|
|
212
|
+
// details. The boundary tests check the public actions + on-disk effects.
|