@lh8ppl/claude-memory-kit 0.3.5 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +137 -50
  2. package/bin/cmk-approve-permission.mjs +62 -0
  3. package/bin/cmk-daily-distill.mjs +14 -0
  4. package/bin/cmk-guard-memory.mjs +57 -0
  5. package/bin/cmk-inject-context.mjs +12 -0
  6. package/bin/cmk-weekly-curate.mjs +12 -0
  7. package/package.json +4 -2
  8. package/src/agent-profile.mjs +115 -0
  9. package/src/agent-profiles.mjs +118 -0
  10. package/src/approve-permission.mjs +92 -0
  11. package/src/auto-extract.mjs +17 -10
  12. package/src/auto-persona.mjs +11 -4
  13. package/src/compaction-state.mjs +204 -0
  14. package/src/compress-session.mjs +13 -1
  15. package/src/config-core.mjs +7 -9
  16. package/src/decisions-journal.mjs +71 -3
  17. package/src/doctor.mjs +128 -5
  18. package/src/guard-memory.mjs +151 -0
  19. package/src/import-anthropic-memory.mjs +15 -1
  20. package/src/inject-context.mjs +42 -18
  21. package/src/install-agent.mjs +220 -0
  22. package/src/install-kiro.mjs +287 -0
  23. package/src/install.mjs +53 -7
  24. package/src/kiro-cli-agent.mjs +270 -0
  25. package/src/kiro-constants.mjs +19 -0
  26. package/src/kiro-hook-bin.mjs +105 -0
  27. package/src/kiro-hook-command.mjs +67 -0
  28. package/src/kiro-hook-dispatch.mjs +115 -0
  29. package/src/kiro-ide-hooks.mjs +219 -0
  30. package/src/kiro-permissions.mjs +175 -0
  31. package/src/kiro-skills.mjs +96 -0
  32. package/src/kiro-transcript.mjs +366 -0
  33. package/src/kiro-trusted-commands.mjs +130 -0
  34. package/src/lazy-compress.mjs +43 -110
  35. package/src/managed-block.mjs +138 -0
  36. package/src/memory-write.mjs +23 -8
  37. package/src/mutate-agent-config.mjs +243 -0
  38. package/src/read-json.mjs +43 -0
  39. package/src/register-crons.mjs +31 -0
  40. package/src/reindex.mjs +15 -2
  41. package/src/repair.mjs +39 -3
  42. package/src/result-shapes.mjs +8 -0
  43. package/src/review-queue.mjs +3 -0
  44. package/src/scratchpad.mjs +12 -2
  45. package/src/search.mjs +12 -5
  46. package/src/semantic-backend.mjs +7 -9
  47. package/src/settings-hooks.mjs +70 -3
  48. package/src/subcommands.mjs +360 -27
  49. package/src/tier-paths.mjs +82 -1
  50. package/src/weekly-curate.mjs +6 -2
  51. package/template/.claude/skills/memory-search/SKILL.md +14 -1
  52. package/template/.claude/skills/memory-write/SKILL.md +37 -1
  53. package/template/project/memory/INDEX.md.template +1 -1
@@ -0,0 +1,43 @@
1
+ // read-json.mjs — BOM-tolerant JSON config reading.
2
+ //
3
+ // Windows editors (and PowerShell `Set-Content -Encoding utf8`) routinely write
4
+ // a UTF-8 BOM (U+FEFF / EF BB BF) at the start of a file. A bare
5
+ // `JSON.parse(readFileSync(path, 'utf8'))` throws on that leading BOM, so any kit
6
+ // reader of a USER-AUTHORED config file (Amazon Q `settings.json`, an agent's
7
+ // config) silently mis-reads a BOM'd file. The cut-gate-kiro live-test surfaced
8
+ // this: the Kiro default-agent guard read a BOM'd `settings.json`, the parse
9
+ // threw into its catch, and the guard concluded "no default agent set" — then
10
+ // CLOBBERED the user's existing default (D-187; the same silent-clobber class as
11
+ // D-184). These helpers make the kit's config reads BOM-tolerant; route every
12
+ // USER-AUTHORED config JSON read through them (kiro-cli-agent, mutate-agent-
13
+ // config, doctor HC-1, settings-hooks, config-core, semantic-backend).
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+
17
+ /** Strip a single leading UTF-8 BOM if present. Non-string input passes through. */
18
+ export function stripBom(text) {
19
+ if (typeof text !== 'string') return text;
20
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
21
+ }
22
+
23
+ /**
24
+ * Read + parse a JSON file, tolerating a leading BOM. Never throws: a missing
25
+ * file or malformed JSON returns `fallback` (default `undefined`) so callers can
26
+ * branch on the value instead of wrapping every read in try/catch.
27
+ *
28
+ * NOTE: missing-file and malformed-JSON both collapse to `fallback`. A caller
29
+ * that must DISTINGUISH the two (e.g. to surface a "parse error" message, or to
30
+ * refuse-to-clobber a corrupt file like mutate-agent-config / settings-hooks)
31
+ * should NOT use this — use `stripBom(readFileSync(...))` before its own parse.
32
+ *
33
+ * @param {string} path
34
+ * @param {{ fallback?: any }} [opts]
35
+ */
36
+ export function parseJsonFile(path, { fallback = undefined } = {}) {
37
+ if (!existsSync(path)) return fallback;
38
+ try {
39
+ return JSON.parse(stripBom(readFileSync(path, 'utf8')));
40
+ } catch {
41
+ return fallback;
42
+ }
43
+ }
@@ -303,6 +303,37 @@ export function registerCron(opts = {}) {
303
303
  // schtasks path + the verbatim argv). Production never passes it.
304
304
  const spawn = opts.spawn ?? spawnSync;
305
305
  const r = spawn(schtasksExe, argv, { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
306
+
307
+ // Task 167.E (D-207): set StartWhenAvailable so a missed nightly run (laptop
308
+ // asleep at 23:00) runs on wake instead of being silently dropped. schtasks
309
+ // /Create has NO CLI flag for this (verified — not in the help); it's settable
310
+ // only via XML or PowerShell. We use a follow-up PowerShell Set-ScheduledTask,
311
+ // BEST-EFFORT: a failure here never fails registration — the lazy roll
312
+ // (167.A/D) is the guarantee; this is a catch-up OPTIMIZATION. NB: this only
313
+ // covers a missed run while the machine is OFF/asleep; all OS catch-up
314
+ // mechanisms COALESCE multiple missed periods into one run (research note).
315
+ if (r.status === 0) {
316
+ const psExe = join(
317
+ process.env.SystemRoot || process.env.windir || 'C:\\Windows',
318
+ 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe',
319
+ );
320
+ const psScript =
321
+ `try { Set-ScheduledTask -TaskName '${entryName}' ` +
322
+ `-Settings (New-ScheduledTaskSettingsSet -StartWhenAvailable) ` +
323
+ `-ErrorAction Stop | Out-Null } catch { exit 1 }`;
324
+ try {
325
+ spawn(psExe, ['-NoProfile', '-NonInteractive', '-Command', psScript], {
326
+ encoding: 'utf8',
327
+ windowsHide: true,
328
+ timeout: 10_000,
329
+ });
330
+ // We intentionally ignore the PS exit status — registration already
331
+ // succeeded; catch-up is best-effort.
332
+ } catch {
333
+ // never let the catch-up call abort a successful registration
334
+ }
335
+ }
336
+
306
337
  return {
307
338
  action: r.status === 0 ? 'registered' : 'error',
308
339
  platform,
package/src/reindex.mjs CHANGED
@@ -38,9 +38,22 @@ function extractHook(body) {
38
38
  return '';
39
39
  }
40
40
 
41
+ // Wrap any bare http(s):// URL in angle brackets so it doesn't trip markdownlint
42
+ // MD034 (no-bare-urls) when the INDEX ships in a user's committed repo. A URL
43
+ // already inside `<…>` or `](…)` is left alone (the char before it isn't `<`/`(`).
44
+ function autolinkBareUrls(text) {
45
+ return text.replace(/(^|[^<(])\b(https?:\/\/[^\s<>)\]]+)/g, '$1<$2>');
46
+ }
47
+
41
48
  function formatIndexLine({ id, type, title, filename, hook }) {
42
- const head = `- (${id}) [${type}] [${title}](${filename})`;
43
- return hook ? `${head} ${hook}` : head;
49
+ // Lint-clean the rendered INDEX line:
50
+ // - the title goes inside `[title]` link text: trim + collapse internal
51
+ // whitespace so a trailing space before `]` doesn't trip MD039
52
+ // (no-space-in-links).
53
+ // - the hook is trailing prose: wrap bare URLs (MD034).
54
+ const linkTitle = String(title ?? '').replace(/\s+/g, ' ').trim();
55
+ const head = `- (${id}) [${type}] [${linkTitle}](${filename})`;
56
+ return hook ? `${head} — ${autolinkBareUrls(hook)}` : head;
44
57
  }
45
58
 
46
59
  function listFactFiles(factDir) {
package/src/repair.mjs CHANGED
@@ -16,8 +16,11 @@
16
16
  // Per design §14 + tasks.md 39 (39.1–39.3).
17
17
 
18
18
  import {
19
+ existsSync,
20
+ readFileSync,
19
21
  statSync,
20
22
  unlinkSync,
23
+ writeFileSync,
21
24
  } from 'node:fs';
22
25
  import { join } from 'node:path';
23
26
  import {
@@ -204,6 +207,34 @@ async function repairIndex({ projectRoot, userDir, reindexer }) {
204
207
  }
205
208
  }
206
209
 
210
+ /**
211
+ * Migrate committed memory markdown to the lint-clean shape (Task 164.9) — so a
212
+ * repo installed before the lint-clean generators (164.1–164.7) brings its
213
+ * existing memory up to the clean format a default markdownlint passes. Today
214
+ * this normalizes `context/DECISIONS.md` (old `### ` entries → `## ` +
215
+ * blank-surrounded). Idempotent + CRLF-tolerant: a no-op on already-clean
216
+ * content (changed:false), so it's safe to run repeatedly. ONLY rewrites a file
217
+ * whose normalized form actually differs — never a gratuitous write.
218
+ */
219
+ async function repairMemoryFormat({ projectRoot }) {
220
+ const { normalizeDecisionsJournal } = await import('./decisions-journal.mjs');
221
+ const decisionsPath = join(projectRoot, 'context', 'DECISIONS.md');
222
+ if (!existsSync(decisionsPath)) {
223
+ return { kind: 'format', changed: false, detail: 'no DECISIONS.md' };
224
+ }
225
+ try {
226
+ const before = readFileSync(decisionsPath, 'utf8');
227
+ const after = normalizeDecisionsJournal(before);
228
+ if (after === before) {
229
+ return { kind: 'format', changed: false, detail: 'DECISIONS.md already lint-clean' };
230
+ }
231
+ writeFileSync(decisionsPath, after, 'utf8');
232
+ return { kind: 'format', changed: true, detail: 'DECISIONS.md migrated to lint-clean headings' };
233
+ } catch (err) {
234
+ return { kind: 'format', changed: false, error: `format migration failed: ${err?.message ?? err}` };
235
+ }
236
+ }
237
+
207
238
  /**
208
239
  * Public boundary: run the repair pipeline.
209
240
  *
@@ -226,15 +257,15 @@ export async function runRepair({
226
257
  duration_ms: Date.now() - t0,
227
258
  });
228
259
  }
229
- if (!['hooks', 'locks', 'index', 'all'].includes(scope)) {
260
+ if (!['hooks', 'locks', 'index', 'format', 'all'].includes(scope)) {
230
261
  return errorResult({
231
262
  category: ERROR_CATEGORIES.SCHEMA,
232
- errors: [`invalid scope: ${scope}; expected 'hooks' | 'locks' | 'index' | 'all'`],
263
+ errors: [`invalid scope: ${scope}; expected 'hooks' | 'locks' | 'index' | 'format' | 'all'`],
233
264
  duration_ms: Date.now() - t0,
234
265
  });
235
266
  }
236
267
 
237
- const scopes = scope === 'all' ? ['hooks', 'locks', 'index'] : [scope];
268
+ const scopes = scope === 'all' ? ['hooks', 'locks', 'index', 'format'] : [scope];
238
269
  const repairs = [];
239
270
  let errors = 0;
240
271
  for (const s of scopes) {
@@ -251,6 +282,11 @@ export async function runRepair({
251
282
  const r = await repairIndex({ projectRoot, userDir, reindexer });
252
283
  if (r.error) errors += 1;
253
284
  repairs.push(r);
285
+ } else if (s === 'format') {
286
+ // eslint-disable-next-line no-await-in-loop
287
+ const r = await repairMemoryFormat({ projectRoot });
288
+ if (r.error) errors += 1;
289
+ repairs.push(r);
254
290
  }
255
291
  }
256
292
  return {
@@ -110,6 +110,14 @@ export const ERROR_CATEGORIES = Object.freeze({
110
110
  // NO silent fallback to keyword — the user asked for semantic,
111
111
  // and the surface should fail-loud so they know what's missing.
112
112
  SEMANTIC_UNAVAILABLE: 'semantic_unavailable',
113
+
114
+ // A per-agent config file (Task 50, cross-agent install) could not be
115
+ // PARSED — it exists but its bytes aren't valid for the declared format
116
+ // (corrupt/hand-edited JSON, etc.). `mutateAgentConfig` returns this
117
+ // and REFUSES to write, so a malformed third-party config is never
118
+ // clobbered (the claude-mem rigor-drift bug class, inverted into a
119
+ // guarantee — design: cross-agent adapter seam note 2026-06-20 / D-180).
120
+ CONFIG_PARSE: 'config_parse',
113
121
  });
114
122
 
115
123
  export const ACTION_TYPES = Object.freeze({
@@ -166,6 +166,9 @@ function serializeReviewQueue({ preamble, entries }) {
166
166
  const lines = [...preamble];
167
167
  for (const e of entries) {
168
168
  lines.push(`## ${e.ts} — auto-extract (medium-trust, pending review)`);
169
+ lines.push(''); // blank line below the heading (MD022). SAFE: parseReviewQueue
170
+ // scans i+1..i+5 for the bullet, so a blank heading→bullet gap is tolerated;
171
+ // the provenance stays at bulletLine+1 (the pair is never split).
169
172
  lines.push(`- (${e.id}) ${e.text}`);
170
173
  if (e.provenance) lines.push(e.provenance);
171
174
  lines.push('');
@@ -215,7 +215,13 @@ export function ensureSectionExists(scratchpadPath, sectionTitle) {
215
215
  // No leading blank lines for an empty/whitespace-only file (the scaffolded
216
216
  // scratchpads are never empty, but keep the output clean if one ever is).
217
217
  const prefix = body ? `${body}\n\n` : '';
218
- writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n`, 'utf8');
218
+ // Blank line AFTER the heading too (MD022 blanks-around-headings) the first
219
+ // bullet appended into this section then lands after the blank, so the
220
+ // committed scratchpad is lint-clean. SAFE for readers: findSectionRange uses
221
+ // a whole-line trim-compare + insertIntoSection skips trailing blanks, so a
222
+ // blank under the heading doesn't change where bullets insert. The blank is
223
+ // between the HEADING and the bullet — never inside the bullet↔comment pair.
224
+ writeFileSync(scratchpadPath, `${prefix}## ${sectionTitle}\n\n`, 'utf8');
219
225
  return { created: true };
220
226
  }
221
227
 
@@ -271,7 +277,11 @@ function archiveEvictedBullets({ tierRoot, tier, scratchpad, evicted, now }) {
271
277
  const archivePath = join(archiveDir, 'evicted-bullets.md');
272
278
  const ts = now ?? nowIso();
273
279
  const header = existsSync(archivePath) ? '' : EVICTED_ARCHIVE_HEADER;
274
- const block = `## Evicted ${ts} consolidate(${scratchpad})\n${evicted
280
+ // Blank line after the heading so the archive is lint-clean markdown (MD022
281
+ // blanks-around-headings) — generated memory passes a strict linter by
282
+ // construction. The preceding header (or the prior block's trailing `\n\n`)
283
+ // supplies the blank line above.
284
+ const block = `## Evicted ${ts} — consolidate(${scratchpad})\n\n${evicted
275
285
  .map((e) => e.block)
276
286
  .join('\n')}\n\n`;
277
287
  appendFileSync(archivePath, header + block, 'utf8');
package/src/search.mjs CHANGED
@@ -481,12 +481,19 @@ function runDecisionsKeywordSearch(_db, opts) {
481
481
  // The line offset of the marker = source_line drill-back into DECISIONS.md.
482
482
  const sourceLine = content.slice(0, start).split('\n').length;
483
483
  // Retracted-tag detection mirrors the WRITER's contract: the tag sits on its
484
- // own line DIRECTLY after the `### ` heading (decisions-journal.mjs §2), so
485
- // scope the check there NOT a raw-block substring, which would mislabel an
486
- // active entry whose Why merely MENTIONS "_(retracted" (skill-review I1).
487
- const headingIdx = block.indexOf('### ');
484
+ // own line DIRECTLY after the `## ` heading (decisions-journal.mjs §2
485
+ // buildDecisionEntry emits `## ` h2 entries; the retract inserter puts the
486
+ // tag at headingEnd+1), so scope the check there — NOT a raw-block substring,
487
+ // which would mislabel an active entry whose Why merely MENTIONS "_(retracted"
488
+ // (skill-review I1). Match the heading line-start (`\n## `) so body text
489
+ // containing `##` can't be mistaken for the heading. (Was `### ` — a
490
+ // pre-existing bug: the writer emits `## `, so this never matched and EVERY
491
+ // decision read `retracted:false` — Task 164.3.)
492
+ // The heading is a line-start `## ` (the block opens with the marker comment,
493
+ // so the heading is never at block offset 0 — match `\n## `).
494
+ const headingNl = block.indexOf('\n## ');
488
495
  const afterHeading =
489
- headingIdx === -1 ? '' : block.slice(block.indexOf('\n', headingIdx) + 1);
496
+ headingNl === -1 ? '' : block.slice(block.indexOf('\n', headingNl + 1) + 1);
490
497
  const retracted = afterHeading.startsWith('_(retracted');
491
498
  hits.push({
492
499
  id: markers[i].id,
@@ -28,7 +28,7 @@
28
28
  // ≤1500-char chunking rule is satisfied by construction).
29
29
 
30
30
  import { createHash } from 'node:crypto';
31
- import { existsSync, readFileSync } from 'node:fs';
31
+ import { parseJsonFile } from './read-json.mjs';
32
32
  import { join } from 'node:path';
33
33
 
34
34
  // The D-105 ladder's WINNER (bake-off 2026-06-10, bench:recall on the Task-99
@@ -383,14 +383,12 @@ const VALID_DEFAULT_MODES = new Set(['keyword', 'semantic', 'hybrid']);
383
383
  * default — no surprise model downloads on machines that never opted in).
384
384
  */
385
385
  export function resolveDefaultSearchMode({ projectRoot }) {
386
- try {
387
- const p = join(projectRoot, 'context', 'settings.json');
388
- if (!existsSync(p)) return 'keyword';
389
- const mode = JSON.parse(readFileSync(p, 'utf8'))?.search?.default_mode;
390
- return VALID_DEFAULT_MODES.has(mode) ? mode : 'keyword';
391
- } catch {
392
- return 'keyword';
393
- }
386
+ // BOM-tolerant (parseJsonFile): a Windows-editor BOM on context/settings.json
387
+ // must not silently downgrade a `hybrid` user to keyword (D-187). Missing or
388
+ // malformed keyword.
389
+ const p = join(projectRoot, 'context', 'settings.json');
390
+ const mode = parseJsonFile(p, { fallback: null })?.search?.default_mode;
391
+ return VALID_DEFAULT_MODES.has(mode) ? mode : 'keyword';
394
392
  }
395
393
 
396
394
  /**
@@ -57,6 +57,8 @@ import {
57
57
  writeFileSync,
58
58
  } from 'node:fs';
59
59
  import { dirname, join } from 'node:path';
60
+ import { stripBom } from './read-json.mjs';
61
+ import { MCP_AUTO_APPROVE } from './kiro-constants.mjs';
60
62
 
61
63
  /**
62
64
  * Canonical npm-route hooks block. Shell form (no `args`), PATH-resolved
@@ -64,6 +66,24 @@ import { dirname, join } from 'node:path';
64
66
  * (modulo command form) plugin/hooks/hooks.json.
65
67
  */
66
68
  export const KIT_HOOKS_BLOCK = Object.freeze({
69
+ // PermissionRequest — the prompt-free auto-approver (Task 172, the v0.4.1
70
+ // cut-gate fix). Fires when Claude Code is about to show a permission dialog
71
+ // for one of the kit's OWN surfaces and answers "allow" so capture/recall stay
72
+ // seamless. Two matchers: the kit's MCP tools (`mcp__cmk__.*`) and the Skill
73
+ // tool (the "Use skill?" prompt). The handler (cmk-approve-permission)
74
+ // self-checks the tool name and approves ONLY kit tools/skills — the matcher
75
+ // is the first narrowing, the handler's check is the second (defence in depth).
76
+ // Needed because CC 2.1.x stopped honouring `permissions.allow` MCP rules +
77
+ // skill `allowed-tools` for these prompts (anthropics/claude-code#17499,
78
+ // #18837→#14956); the PermissionRequest hook is the documented, working path.
79
+ PermissionRequest: [
80
+ { matcher: 'mcp__cmk__.*', hooks: [{ type: 'command', command: 'cmk-approve-permission', timeout: 5 }] },
81
+ { matcher: 'Skill', hooks: [{ type: 'command', command: 'cmk-approve-permission', timeout: 5 }] },
82
+ ],
83
+ // PreToolUse — the memory delete-guardrail (D-192). Blocks a destructive
84
+ // shell command (rm / Remove-Item / git clean …) aimed at a memory path
85
+ // BEFORE it runs. The only kit hook that can exit non-zero (2 = block).
86
+ PreToolUse: [{ matcher: 'Bash|PowerShell', hooks: [{ type: 'command', command: 'cmk-guard-memory', timeout: 5 }] }],
67
87
  SessionStart: [{ hooks: [{ type: 'command', command: 'cmk-inject-context', timeout: 30 }] }],
68
88
  UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'cmk-capture-prompt', timeout: 10 }] }],
69
89
  PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'cmk-observe-edit', async: true, timeout: 120 }] }],
@@ -81,6 +101,8 @@ export const KIT_HOOKS_BLOCK = Object.freeze({
81
101
  */
82
102
  export const KIT_COMMAND_TOKENS = Object.freeze([
83
103
  'cmk-version-check',
104
+ 'cmk-approve-permission',
105
+ 'cmk-guard-memory',
84
106
  'cmk-inject-context',
85
107
  'cmk-capture-prompt',
86
108
  'cmk-observe-edit',
@@ -122,7 +144,9 @@ export function writeKitHooks(settingsPath) {
122
144
  let settings = {};
123
145
  if (existsSync(settingsPath)) {
124
146
  try {
125
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
147
+ // stripBom: a Windows-editor BOM must not make a valid settings.json read
148
+ // as corrupt and block hook wiring (D-187). Real corruption still errors.
149
+ settings = JSON.parse(stripBom(readFileSync(settingsPath, 'utf8')));
126
150
  } catch (err) {
127
151
  return {
128
152
  changed: false,
@@ -204,7 +228,33 @@ export function writeKitHooks(settingsPath) {
204
228
  // Task 133: memory-search joins memory-write — every scaffolded skill needs
205
229
  // its own allow entry or the model's invocation trips a "Use skill?" prompt
206
230
  // (the Task-90 class; 75.1 scaffolded the skill but missed this).
207
- const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)', 'Skill(memory-search)', 'mcp__cmk__*'];
231
+ // Task 169 (the v0.4.1 cut-gate find, 2026-06-26): Claude Code 2.1.x changed
232
+ // skill-permission matching — the bare `Skill(memory-write)` rule alone no
233
+ // longer suppressed the "Use skill?" prompt; when the user clicked "allow for
234
+ // this project", Claude Code wrote BOTH `Skill(memory-write)` AND
235
+ // `Skill(memory-write:*)` into settings.local.json. So the kit emits BOTH forms
236
+ // (bare for older CC, `:*` for 2.1.x+) — pending a docs re-verification of the
237
+ // current skill-permission syntax.
238
+ // Task 171 (v0.4.1 cut-gate ground-truth, 2026-06-26): the `mcp__cmk__*` server
239
+ // WILDCARD no longer suppresses the per-tool approval prompt on Claude Code
240
+ // 2.1.x — a DIRECT `mk_remember` call (the model using the MCP tool outside the
241
+ // skill's own `allowed-tools`) prompts "Do you want to proceed with
242
+ // mcp__cmk__mk_remember?", and CC writes the SPECIFIC tool name when allowed.
243
+ // (CC 2.1.x churned permission matching heavily — multiple changelog entries
244
+ // closed wildcard auto-approve holes, e.g. 2.1.145 "permission-prompt bypass".)
245
+ // The wildcard worked on older CC; it doesn't now. So allow-list each SPECIFIC
246
+ // cmk MCP tool (the canonical 11 in MCP_AUTO_APPROVE — shared with the Kiro
247
+ // pre-trust) AND keep `mcp__cmk__*` (harmless + future-proof). This is the same
248
+ // upstream-format-tracking the kit does for the Skill rule (D-209) and Kiro.
249
+ const KIT_ALLOW = [
250
+ 'Bash(cmk:*)',
251
+ 'Skill(memory-write)',
252
+ 'Skill(memory-write:*)',
253
+ 'Skill(memory-search)',
254
+ 'Skill(memory-search:*)',
255
+ 'mcp__cmk__*',
256
+ ...MCP_AUTO_APPROVE.map((tool) => `mcp__cmk__${tool}`),
257
+ ];
208
258
  if (!settings.permissions || typeof settings.permissions !== 'object') {
209
259
  settings.permissions = {};
210
260
  }
@@ -217,6 +267,21 @@ export function writeKitHooks(settingsPath) {
217
267
  }
218
268
  }
219
269
 
270
+ // Task 172: pre-approve the kit's OWN project-scoped `.mcp.json` server so its
271
+ // tools connect without the per-project "approve this MCP server?" prompt.
272
+ // `enabledMcpjsonServers` names specific servers to approve (NOT
273
+ // `enableAllProjectMcpServers`, which would blanket-approve EVERY server in
274
+ // `.mcp.json` — too broad for a kit shipped to others; we vouch only for our
275
+ // own server). This clears the SERVER-approval gate; the PermissionRequest
276
+ // hook above clears the per-TOOL gate. Idempotent + preserves any servers the
277
+ // user already approved.
278
+ if (!Array.isArray(settings.enabledMcpjsonServers)) {
279
+ settings.enabledMcpjsonServers = [];
280
+ }
281
+ if (!settings.enabledMcpjsonServers.includes('cmk')) {
282
+ settings.enabledMcpjsonServers.push('cmk');
283
+ }
284
+
220
285
  const after = JSON.stringify(settings);
221
286
  const changed = before !== after;
222
287
 
@@ -250,7 +315,9 @@ export function writeKitMcpServer(projectRoot) {
250
315
  let config = {};
251
316
  if (existsSync(mcpPath)) {
252
317
  try {
253
- config = JSON.parse(readFileSync(mcpPath, 'utf8'));
318
+ // stripBom: a BOM'd .mcp.json must not read as corrupt and block MCP
319
+ // registration (D-187). Real corruption still errors.
320
+ config = JSON.parse(stripBom(readFileSync(mcpPath, 'utf8')));
254
321
  } catch (err) {
255
322
  return { changed: false, path: mcpPath, error: `${mcpPath} parse error: ${err?.message ?? err}` };
256
323
  }