@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.
- package/README.md +137 -50
- package/bin/cmk-approve-permission.mjs +62 -0
- package/bin/cmk-daily-distill.mjs +14 -0
- package/bin/cmk-guard-memory.mjs +57 -0
- package/bin/cmk-inject-context.mjs +12 -0
- package/bin/cmk-weekly-curate.mjs +12 -0
- package/package.json +4 -2
- package/src/agent-profile.mjs +115 -0
- package/src/agent-profiles.mjs +118 -0
- package/src/approve-permission.mjs +92 -0
- package/src/auto-extract.mjs +17 -10
- package/src/auto-persona.mjs +11 -4
- package/src/compaction-state.mjs +204 -0
- package/src/compress-session.mjs +13 -1
- package/src/config-core.mjs +7 -9
- package/src/decisions-journal.mjs +71 -3
- package/src/doctor.mjs +128 -5
- package/src/guard-memory.mjs +151 -0
- package/src/import-anthropic-memory.mjs +15 -1
- package/src/inject-context.mjs +42 -18
- package/src/install-agent.mjs +220 -0
- package/src/install-kiro.mjs +287 -0
- package/src/install.mjs +53 -7
- package/src/kiro-cli-agent.mjs +270 -0
- package/src/kiro-constants.mjs +19 -0
- package/src/kiro-hook-bin.mjs +105 -0
- package/src/kiro-hook-command.mjs +67 -0
- package/src/kiro-hook-dispatch.mjs +115 -0
- package/src/kiro-ide-hooks.mjs +219 -0
- package/src/kiro-permissions.mjs +175 -0
- package/src/kiro-skills.mjs +96 -0
- package/src/kiro-transcript.mjs +366 -0
- package/src/kiro-trusted-commands.mjs +130 -0
- package/src/lazy-compress.mjs +43 -110
- package/src/managed-block.mjs +138 -0
- package/src/memory-write.mjs +23 -8
- package/src/mutate-agent-config.mjs +243 -0
- package/src/read-json.mjs +43 -0
- package/src/register-crons.mjs +31 -0
- package/src/reindex.mjs +15 -2
- package/src/repair.mjs +39 -3
- package/src/result-shapes.mjs +8 -0
- package/src/review-queue.mjs +3 -0
- package/src/scratchpad.mjs +12 -2
- package/src/search.mjs +12 -5
- package/src/semantic-backend.mjs +7 -9
- package/src/settings-hooks.mjs +70 -3
- package/src/subcommands.mjs +360 -27
- package/src/tier-paths.mjs +82 -1
- package/src/weekly-curate.mjs +6 -2
- package/template/.claude/skills/memory-search/SKILL.md +14 -1
- package/template/.claude/skills/memory-write/SKILL.md +37 -1
- 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
|
+
}
|
package/src/register-crons.mjs
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
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 {
|
package/src/result-shapes.mjs
CHANGED
|
@@ -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({
|
package/src/review-queue.mjs
CHANGED
|
@@ -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('');
|
package/src/scratchpad.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
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
|
-
|
|
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,
|
package/src/semantic-backend.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
/**
|
package/src/settings-hooks.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|