@lh8ppl/claude-memory-kit 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -0
- package/bin/cmk-auto-extract.mjs +62 -0
- package/bin/cmk-capture-prompt.mjs +65 -0
- package/bin/cmk-capture-turn.mjs +76 -0
- package/bin/cmk-compress-lazy.mjs +0 -0
- package/bin/cmk-compress-session.mjs +64 -0
- package/bin/cmk-daily-distill.mjs +0 -0
- package/bin/cmk-inject-context.mjs +69 -0
- package/bin/cmk-observe-edit.mjs +57 -0
- package/bin/cmk-weekly-curate.mjs +0 -0
- package/bin/cmk.mjs +11 -11
- package/package.json +10 -2
- package/src/audit-log.mjs +1 -0
- package/src/claude-md.mjs +212 -212
- package/src/doctor.mjs +16 -5
- package/src/frontmatter.mjs +73 -73
- package/src/install.mjs +49 -1
- package/src/merge-facts.mjs +213 -213
- package/src/provenance.mjs +217 -217
- package/src/reindex.mjs +134 -134
- package/src/repair.mjs +26 -96
- package/src/settings-hooks.mjs +186 -0
- package/src/subcommands.mjs +13 -2
- package/template/.gitignore.fragment +12 -12
- package/template/CLAUDE.md.template +49 -49
- package/template/docs/journey/journey-log.md.template +292 -292
- package/template/project/memory/INDEX.md.template +47 -47
- package/template/support/cron-jobs/daily-memory-distill.md +15 -15
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -17
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -15
- package/template/support/milvus-deploy/README.md +57 -57
- package/template/support/milvus-deploy/docker-compose.yml +66 -66
- package/template/support/scripts/auto-extract-memory.sh +102 -102
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -59
- package/template/support/scripts/refresh-distill-timestamp.py +35 -35
- package/template/support/scripts/register-crons.py +242 -242
- package/template/support/scripts/run-daily-distill.sh +67 -67
- package/template/support/scripts/run-weekly-curate.sh +58 -58
package/src/claude-md.mjs
CHANGED
|
@@ -1,212 +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.
|
|
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.
|
package/src/doctor.mjs
CHANGED
|
@@ -142,13 +142,24 @@ function hc2Hooks({ projectRoot }) {
|
|
|
142
142
|
const missing = [];
|
|
143
143
|
for (const { event, command } of required) {
|
|
144
144
|
const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
145
|
+
// An entry may be (a) a bare string command, (b) a flat object
|
|
146
|
+
// {command: '...'}, or (c) the canonical Anthropic / kit nested shape
|
|
147
|
+
// {hooks: [{type, command}, ...]}. The kit's own writers (cmk install
|
|
148
|
+
// + cmk repair --hooks via settings-hooks.mjs) emit form (c), so HC-2
|
|
149
|
+
// MUST traverse the nested hooks[] array — otherwise `cmk install`
|
|
150
|
+
// followed by `cmk doctor` reports HC-2 fail on hooks the kit itself
|
|
151
|
+
// just wrote (a separately-correct-jointly-broken composition bug
|
|
152
|
+
// caught while shipping Task 49; pre-Task-49 the doctor test only ever
|
|
153
|
+
// fed form (b), so the gap stayed latent).
|
|
148
154
|
const found = entries.some((e) => {
|
|
149
155
|
if (typeof e === 'string') return e.includes(command);
|
|
150
|
-
if (e && typeof e === 'object'
|
|
151
|
-
|
|
156
|
+
if (e && typeof e === 'object') {
|
|
157
|
+
if (typeof e.command === 'string' && e.command.includes(command)) return true;
|
|
158
|
+
if (Array.isArray(e.hooks)) {
|
|
159
|
+
return e.hooks.some(
|
|
160
|
+
(h) => h && typeof h.command === 'string' && h.command.includes(command),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
152
163
|
}
|
|
153
164
|
return false;
|
|
154
165
|
});
|
package/src/frontmatter.mjs
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
// Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
|
|
2
|
-
// every kit module uses to read and write per-fact frontmatter + scratchpad
|
|
3
|
-
// HTML-comment provenance (Layer 3+ will join).
|
|
4
|
-
//
|
|
5
|
-
// Per the Layer-2 review's I2 finding, the previous code had THREE different
|
|
6
|
-
// naive parsers across four modules (split-on-first-colon read; verbatim
|
|
7
|
-
// stringify write). Output and input weren't symmetric: booleans round-tripped
|
|
8
|
-
// as strings, arrays didn't round-trip at all, strings with `:` truncated on
|
|
9
|
-
// read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
|
|
10
|
-
// that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
|
|
11
|
-
//
|
|
12
|
-
// Public surface:
|
|
13
|
-
// parse(text) → {frontmatter, body, parseError?}
|
|
14
|
-
// - text: full file contents (with or without `---` markers)
|
|
15
|
-
// - returns frontmatter as a typed object (string/number/bool/array/etc.)
|
|
16
|
-
// - returns body as the markdown after the closing `---\n` (or empty)
|
|
17
|
-
// - if no frontmatter block: frontmatter is null, body is the full text
|
|
18
|
-
// - if YAML parse fails: frontmatter is null, parseError carries the message
|
|
19
|
-
//
|
|
20
|
-
// format({frontmatter, body}) → text
|
|
21
|
-
// - frontmatter: typed object; key order preserved per insertion
|
|
22
|
-
// - body: markdown; written verbatim after the closing `---\n`
|
|
23
|
-
// - if frontmatter is null/empty: just returns body
|
|
24
|
-
//
|
|
25
|
-
// js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
|
|
26
|
-
// ISO strings stay as strings). Output uses flowLevel: 1 — top-level
|
|
27
|
-
// mapping is block style; nested arrays render as `[a, b]` (matches the
|
|
28
|
-
// pre-refactor visual format).
|
|
29
|
-
|
|
30
|
-
import yaml from 'js-yaml';
|
|
31
|
-
|
|
32
|
-
const DUMP_OPTIONS = Object.freeze({
|
|
33
|
-
schema: yaml.CORE_SCHEMA,
|
|
34
|
-
flowLevel: 1,
|
|
35
|
-
lineWidth: -1, // no line wrapping
|
|
36
|
-
noRefs: true, // never emit YAML anchors / refs
|
|
37
|
-
sortKeys: false, // preserve insertion order
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const LOAD_OPTIONS = Object.freeze({
|
|
41
|
-
schema: yaml.CORE_SCHEMA,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
export function parse(text) {
|
|
45
|
-
if (typeof text !== 'string') return { frontmatter: null, body: '' };
|
|
46
|
-
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
47
|
-
if (!m) return { frontmatter: null, body: text };
|
|
48
|
-
let frontmatter;
|
|
49
|
-
try {
|
|
50
|
-
frontmatter = yaml.load(m[1], LOAD_OPTIONS);
|
|
51
|
-
} catch (e) {
|
|
52
|
-
return { frontmatter: null, body: text, parseError: e.message };
|
|
53
|
-
}
|
|
54
|
-
if (frontmatter === undefined || frontmatter === null) {
|
|
55
|
-
return { frontmatter: null, body: m[2] ?? '' };
|
|
56
|
-
}
|
|
57
|
-
if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
58
|
-
return {
|
|
59
|
-
frontmatter: null,
|
|
60
|
-
body: text,
|
|
61
|
-
parseError: 'frontmatter is not a mapping',
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
return { frontmatter, body: m[2] ?? '' };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function format({ frontmatter, body }) {
|
|
68
|
-
if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
|
|
69
|
-
return body ?? '';
|
|
70
|
-
}
|
|
71
|
-
const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
|
|
72
|
-
return `---\n${yamlBody}---\n${body ?? ''}`;
|
|
73
|
-
}
|
|
1
|
+
// Canonical frontmatter serializer/parser. Single js-yaml-backed pair that
|
|
2
|
+
// every kit module uses to read and write per-fact frontmatter + scratchpad
|
|
3
|
+
// HTML-comment provenance (Layer 3+ will join).
|
|
4
|
+
//
|
|
5
|
+
// Per the Layer-2 review's I2 finding, the previous code had THREE different
|
|
6
|
+
// naive parsers across four modules (split-on-first-colon read; verbatim
|
|
7
|
+
// stringify write). Output and input weren't symmetric: booleans round-tripped
|
|
8
|
+
// as strings, arrays didn't round-trip at all, strings with `:` truncated on
|
|
9
|
+
// read. js-yaml fixes all of these AND lifts the B2 minimum-fix restriction
|
|
10
|
+
// that PR-1 added — values with `\n` / `\r` / `:` are now quoted properly.
|
|
11
|
+
//
|
|
12
|
+
// Public surface:
|
|
13
|
+
// parse(text) → {frontmatter, body, parseError?}
|
|
14
|
+
// - text: full file contents (with or without `---` markers)
|
|
15
|
+
// - returns frontmatter as a typed object (string/number/bool/array/etc.)
|
|
16
|
+
// - returns body as the markdown after the closing `---\n` (or empty)
|
|
17
|
+
// - if no frontmatter block: frontmatter is null, body is the full text
|
|
18
|
+
// - if YAML parse fails: frontmatter is null, parseError carries the message
|
|
19
|
+
//
|
|
20
|
+
// format({frontmatter, body}) → text
|
|
21
|
+
// - frontmatter: typed object; key order preserved per insertion
|
|
22
|
+
// - body: markdown; written verbatim after the closing `---\n`
|
|
23
|
+
// - if frontmatter is null/empty: just returns body
|
|
24
|
+
//
|
|
25
|
+
// js-yaml schema: CORE_SCHEMA (no implicit timestamp/Date conversion;
|
|
26
|
+
// ISO strings stay as strings). Output uses flowLevel: 1 — top-level
|
|
27
|
+
// mapping is block style; nested arrays render as `[a, b]` (matches the
|
|
28
|
+
// pre-refactor visual format).
|
|
29
|
+
|
|
30
|
+
import yaml from 'js-yaml';
|
|
31
|
+
|
|
32
|
+
const DUMP_OPTIONS = Object.freeze({
|
|
33
|
+
schema: yaml.CORE_SCHEMA,
|
|
34
|
+
flowLevel: 1,
|
|
35
|
+
lineWidth: -1, // no line wrapping
|
|
36
|
+
noRefs: true, // never emit YAML anchors / refs
|
|
37
|
+
sortKeys: false, // preserve insertion order
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const LOAD_OPTIONS = Object.freeze({
|
|
41
|
+
schema: yaml.CORE_SCHEMA,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function parse(text) {
|
|
45
|
+
if (typeof text !== 'string') return { frontmatter: null, body: '' };
|
|
46
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
47
|
+
if (!m) return { frontmatter: null, body: text };
|
|
48
|
+
let frontmatter;
|
|
49
|
+
try {
|
|
50
|
+
frontmatter = yaml.load(m[1], LOAD_OPTIONS);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { frontmatter: null, body: text, parseError: e.message };
|
|
53
|
+
}
|
|
54
|
+
if (frontmatter === undefined || frontmatter === null) {
|
|
55
|
+
return { frontmatter: null, body: m[2] ?? '' };
|
|
56
|
+
}
|
|
57
|
+
if (typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
58
|
+
return {
|
|
59
|
+
frontmatter: null,
|
|
60
|
+
body: text,
|
|
61
|
+
parseError: 'frontmatter is not a mapping',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { frontmatter, body: m[2] ?? '' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function format({ frontmatter, body }) {
|
|
68
|
+
if (!frontmatter || (typeof frontmatter === 'object' && Object.keys(frontmatter).length === 0)) {
|
|
69
|
+
return body ?? '';
|
|
70
|
+
}
|
|
71
|
+
const yamlBody = yaml.dump(frontmatter, DUMP_OPTIONS);
|
|
72
|
+
return `---\n${yamlBody}---\n${body ?? ''}`;
|
|
73
|
+
}
|