@lh8ppl/claude-memory-kit 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +77 -0
  2. package/bin/cmk-auto-extract.mjs +62 -0
  3. package/bin/cmk-capture-prompt.mjs +65 -0
  4. package/bin/cmk-capture-turn.mjs +76 -0
  5. package/bin/cmk-compress-lazy.mjs +0 -0
  6. package/bin/cmk-compress-session.mjs +64 -0
  7. package/bin/cmk-daily-distill.mjs +0 -0
  8. package/bin/cmk-inject-context.mjs +69 -0
  9. package/bin/cmk-observe-edit.mjs +57 -0
  10. package/bin/cmk-weekly-curate.mjs +0 -0
  11. package/bin/cmk.mjs +11 -11
  12. package/package.json +10 -2
  13. package/src/audit-log.mjs +1 -0
  14. package/src/claude-md.mjs +212 -212
  15. package/src/compressor.mjs +18 -18
  16. package/src/doctor.mjs +21 -8
  17. package/src/frontmatter.mjs +73 -73
  18. package/src/index-rebuild.mjs +26 -4
  19. package/src/inject-context.mjs +150 -10
  20. package/src/install.mjs +49 -1
  21. package/src/mcp-server.mjs +17 -0
  22. package/src/memory-write.mjs +18 -5
  23. package/src/merge-facts.mjs +213 -213
  24. package/src/provenance.mjs +217 -217
  25. package/src/reindex.mjs +134 -134
  26. package/src/repair.mjs +26 -96
  27. package/src/sanitize.mjs +39 -0
  28. package/src/settings-hooks.mjs +186 -0
  29. package/src/spawn-bin.mjs +83 -0
  30. package/src/subcommands.mjs +144 -10
  31. package/src/write-fact.mjs +46 -3
  32. package/template/.gitignore.fragment +12 -12
  33. package/template/CLAUDE.md.template +53 -49
  34. package/template/docs/journey/journey-log.md.template +292 -292
  35. package/template/project/memory/INDEX.md.template +47 -47
  36. package/template/support/cron-jobs/daily-memory-distill.md +15 -15
  37. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -17
  38. package/template/support/cron-jobs/weekly-memory-curator.md +15 -15
  39. package/template/support/milvus-deploy/README.md +57 -57
  40. package/template/support/milvus-deploy/docker-compose.yml +66 -66
  41. package/template/support/scripts/auto-extract-memory.sh +102 -102
  42. package/template/support/scripts/memsearch-index-with-flush.sh +59 -59
  43. package/template/support/scripts/refresh-distill-timestamp.py +35 -35
  44. package/template/support/scripts/register-crons.py +242 -242
  45. package/template/support/scripts/run-daily-distill.sh +67 -67
  46. 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.
@@ -28,6 +28,7 @@
28
28
  // never needs Read either — the turn content arrives in the prompt).
29
29
 
30
30
  import { spawn as defaultSpawn } from 'node:child_process';
31
+ import { spawnBin } from './spawn-bin.mjs';
31
32
  import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
32
33
  import { tmpdir } from 'node:os';
33
34
  import { join } from 'node:path';
@@ -203,25 +204,24 @@ export class HaikuViaAnthropicApi extends CompressorBackend {
203
204
  const env = { ...process.env };
204
205
  delete env.CLAUDECODE;
205
206
 
206
- // shell:true required on Windows so that .cmd shims (claude.cmd)
207
- // resolve through cmd.exe. Without it, node's spawn fails with
208
- // EINVAL/ENOENT because it won't auto-resolve .cmd extensions
209
- // (CVE-2024-27980 hardening). On Linux/macOS shell:true is a
210
- // no-op for argv-style invocation when the arguments don't contain
211
- // shell metacharacters ours don't (the prompt goes via stdin).
207
+ // spawnBin handles the Windows .cmd-shim problem WITHOUT the
208
+ // `shell:true` + args-array combo that broke paths with spaces (#4):
209
+ // POSIX spawns argv-style (shell:false); Windows builds a single
210
+ // pre-quoted command string so `--mcp-config C:\Users\First Last\…`
211
+ // survives cmd.exe tokenization. See spawn-bin.mjs. `windowsHide`
212
+ // still suppresses the transient cmd.exe console flash on Windows.
212
213
  // spawn-discipline: caller-managed terminateSubprocess (kit's kill-chain helper) + setTimeout (per design §8.5; PR-A composition-verification instance #4; substance pinned by tests/cli-compressor-timeout.test.js + tests/spawn-smoke-kill-chain.test.js). The function signature `timeoutMs` parameter (line 162) is the caller-supplied bound; the setTimeout below (search "Timeout timer") fires the kill chain.
213
- const child = this._spawn(this._bin, args, {
214
- cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
215
- env,
216
- stdio: ['pipe', 'pipe', 'pipe'],
217
- shell: true,
218
- // Suppress the transient cmd.exe console window on Windows —
219
- // every shell:true spawn flashes a window otherwise (visible
220
- // to the user when auto-extract / compress-session fires
221
- // dozens of times per session). stdio is piped so we still
222
- // capture the child's output through the regular handlers.
223
- windowsHide: true,
224
- });
214
+ const child = spawnBin(
215
+ this._bin,
216
+ args,
217
+ {
218
+ cwd: tmpdir(), // OS-native temp dir; replaces `/tmp` which fails to resolve on Windows
219
+ env,
220
+ stdio: ['pipe', 'pipe', 'pipe'],
221
+ windowsHide: true,
222
+ },
223
+ { spawnImpl: this._spawn },
224
+ );
225
225
 
226
226
  const cleanupSandbox = () => {
227
227
  // Single-use sandbox: the directory and the empty-mcp.json file
package/src/doctor.mjs CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  statSync,
39
39
  writeFileSync,
40
40
  } from 'node:fs';
41
- import { spawnSync } from 'node:child_process';
41
+ import { spawnBinSync } from './spawn-bin.mjs';
42
42
  import { homedir } from 'node:os';
43
43
  import { basename, join } from 'node:path';
44
44
  import { nowIso } from './audit-log.mjs';
@@ -62,7 +62,10 @@ async function hc1Memsearch() {
62
62
  // semantic requires a separate `pip install memsearch[onnx]`.
63
63
  // `requiresInstall: true` so the CLI prompts before auto-installing.
64
64
  try {
65
- const r = spawnSync('memsearch', ['--version'], {
65
+ // spawnBinSync resolves the Windows .cmd shim without `shell:true`+args
66
+ // (no DEP0190; #4). memsearch's only arg is `--version` (no spaces), so
67
+ // the quoting is a no-op here — the win is dropping the deprecated combo.
68
+ const r = spawnBinSync('memsearch', ['--version'], {
66
69
  encoding: 'utf8',
67
70
  // M1 fix (skill-review 2026-05-28): 3.5s tolerates Windows
68
71
  // cold-Python startup (AV scan + .pyc generation on first hit
@@ -71,7 +74,6 @@ async function hc1Memsearch() {
71
74
  // still fits comfortably inside the 5s NFR budget. Timeout →
72
75
  // 'skip' so cmk doctor completes regardless.
73
76
  timeout: 3_500,
74
- shell: process.platform === 'win32',
75
77
  });
76
78
  if (r.status === 0) {
77
79
  return {
@@ -142,13 +144,24 @@ function hc2Hooks({ projectRoot }) {
142
144
  const missing = [];
143
145
  for (const { event, command } of required) {
144
146
  const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
145
- // Each entry may be either a string command or {command: '...'}.
146
- // Anthropic's hook format uses the object form; the kit's bin
147
- // wrapper docs use it too. Accept both for resilience.
147
+ // An entry may be (a) a bare string command, (b) a flat object
148
+ // {command: '...'}, or (c) the canonical Anthropic / kit nested shape
149
+ // {hooks: [{type, command}, ...]}. The kit's own writers (cmk install
150
+ // + cmk repair --hooks via settings-hooks.mjs) emit form (c), so HC-2
151
+ // MUST traverse the nested hooks[] array — otherwise `cmk install`
152
+ // followed by `cmk doctor` reports HC-2 fail on hooks the kit itself
153
+ // just wrote (a separately-correct-jointly-broken composition bug
154
+ // caught while shipping Task 49; pre-Task-49 the doctor test only ever
155
+ // fed form (b), so the gap stayed latent).
148
156
  const found = entries.some((e) => {
149
157
  if (typeof e === 'string') return e.includes(command);
150
- if (e && typeof e === 'object' && typeof e.command === 'string') {
151
- return e.command.includes(command);
158
+ if (e && typeof e === 'object') {
159
+ if (typeof e.command === 'string' && e.command.includes(command)) return true;
160
+ if (Array.isArray(e.hooks)) {
161
+ return e.hooks.some(
162
+ (h) => h && typeof h.command === 'string' && h.command.includes(command),
163
+ );
164
+ }
152
165
  }
153
166
  return false;
154
167
  });