@lh8ppl/claude-memory-kit 0.2.1 → 0.2.3

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.
@@ -0,0 +1,87 @@
1
+ // Shared read cores (Task 108b / ADR-0014).
2
+ //
3
+ // The query logic behind the MCP read tools (mk_get / mk_timeline / mk_cite /
4
+ // mk_recent_activity), extracted so the CLI read verbs (cmk get / timeline /
5
+ // cite / recent-activity) call the SAME logic — identical results from both
6
+ // surfaces, one implementation. Pure (db + args in, plain data out); the MCP
7
+ // adapter wraps the result in a content envelope, the CLI adapter prints it.
8
+
9
+ import { ID_PATTERN } from './tier-paths.mjs';
10
+
11
+ const GET_COLUMNS =
12
+ 'id, body, heading_path, source_file, source_line, tier, trust, ' +
13
+ 'write_source, created_at, superseded_by, deleted_at';
14
+
15
+ /**
16
+ * Fetch full observation rows by id. An invalid-format or missing id becomes
17
+ * a `{ id, error }` entry (the array stays positionally aligned with `ids`).
18
+ */
19
+ export function getObservations(db, ids) {
20
+ const stmt = db.prepare(`SELECT ${GET_COLUMNS} FROM observations WHERE id = ?`);
21
+ return ids.map((id) => {
22
+ if (!ID_PATTERN.test(id)) return { id, error: 'invalid id format' };
23
+ const row = stmt.get(id);
24
+ if (!row) return { id, error: 'not found' };
25
+ return row;
26
+ });
27
+ }
28
+
29
+ /** The canonical Markdown citation link for an id. Pure (no DB). */
30
+ export function citeLink(id) {
31
+ if (!ID_PATTERN.test(id)) return { ok: false, error: 'id must match ID_PATTERN' };
32
+ return { ok: true, link: `[#${id}](memkit://obs/${id})` };
33
+ }
34
+
35
+ const TIMELINE_COLUMNS = 'id, body, source_file, source_line, tier, trust, created_at';
36
+
37
+ /**
38
+ * Sequential context: N observations before the anchor + the anchor + N after,
39
+ * by created_at (id as the tiebreaker so same-millisecond rows stay
40
+ * deterministic). Returns `{ ok:false, error }` for a bad / missing anchor.
41
+ */
42
+ export function buildTimeline(db, { anchor, depthBefore = 5, depthAfter = 5 } = {}) {
43
+ if (!ID_PATTERN.test(anchor)) return { ok: false, error: 'anchor must be a valid kit ID' };
44
+ const anchorRow = db
45
+ .prepare('SELECT created_at, tier FROM observations WHERE id = ?')
46
+ .get(anchor);
47
+ if (!anchorRow) return { ok: false, error: 'anchor not found' };
48
+ const beforeRows = db
49
+ .prepare(`
50
+ SELECT ${TIMELINE_COLUMNS} FROM observations
51
+ WHERE created_at < ? AND deleted_at IS NULL
52
+ ORDER BY created_at DESC, id DESC LIMIT ?
53
+ `)
54
+ .all(anchorRow.created_at, depthBefore);
55
+ const anchorFull = db
56
+ .prepare(`SELECT ${TIMELINE_COLUMNS} FROM observations WHERE id = ?`)
57
+ .get(anchor);
58
+ const afterRows = db
59
+ .prepare(`
60
+ SELECT ${TIMELINE_COLUMNS} FROM observations
61
+ WHERE created_at > ? AND deleted_at IS NULL
62
+ ORDER BY created_at ASC, id ASC LIMIT ?
63
+ `)
64
+ .all(anchorRow.created_at, depthAfter);
65
+ return { ok: true, timeline: [...beforeRows.reverse(), anchorFull, ...afterRows] };
66
+ }
67
+
68
+ export const RECENT_WINDOWS = Object.freeze({
69
+ '1h': 60 * 60 * 1000,
70
+ '24h': 24 * 60 * 60 * 1000,
71
+ '7d': 7 * 24 * 60 * 60 * 1000,
72
+ });
73
+
74
+ /** Observations changed within a time window, newest first. */
75
+ export function recentActivity(db, { window = '24h', limit = 20 } = {}) {
76
+ if (!RECENT_WINDOWS[window]) return { ok: false, error: 'window must be 1h|24h|7d' };
77
+ const cutoff = Date.now() - RECENT_WINDOWS[window];
78
+ const rows = db
79
+ .prepare(`
80
+ SELECT id, body, source_file, source_line, tier, trust, created_at
81
+ FROM observations
82
+ WHERE created_at >= ? AND deleted_at IS NULL
83
+ ORDER BY created_at DESC LIMIT ?
84
+ `)
85
+ .all(cutoff, limit);
86
+ return { ok: true, rows };
87
+ }
@@ -74,7 +74,18 @@ function macOsPlistPath(entryName) {
74
74
  function buildMacOsPlist({ command, entryName, hour, minute, dayOfWeek }) {
75
75
  // Split command on whitespace for the ProgramArguments array.
76
76
  // launchd doesn't honor shell quoting — each arg is its own element.
77
- const args = command.split(/\s+/).filter(Boolean);
77
+ // Strip the surrounding double-quotes the caller wraps each path in (the
78
+ // command is `"<node>" "<script>" "<projectRoot>"`): launchd execs the arg
79
+ // LITERALLY, so a `<string>"/path/node"</string>` with quotes baked in is a
80
+ // path that starts with `"` → ENOENT (Task 109: the macOS sibling of the
81
+ // Windows D-83 bug). Each split token is one quoted path; drop the wrapping
82
+ // quotes to get the clean path. (A path that itself contains a space is the
83
+ // remaining edge — rare for node/project paths — and needs the argv-array
84
+ // refactor noted in the Task 109 follow-up.)
85
+ const args = command
86
+ .split(/\s+/)
87
+ .filter(Boolean)
88
+ .map((a) => a.replace(/^"(.*)"$/, '$1'));
78
89
  const argXml = args
79
90
  .map((a) => ` <string>${escapeXml(a)}</string>`)
80
91
  .join('\n');
@@ -116,34 +127,35 @@ function escapeXml(s) {
116
127
  .replace(/'/g, '&apos;');
117
128
  }
118
129
 
119
- function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
120
- // schtasks accepts /ST as HH:mm. /F forces re-create if the task
121
- // already exists (idempotency primitive). /RL LIMITED (not HIGHEST)
122
- // because daily distill doesn't need admin.
130
+ export function buildWindowsSchtasks({ command, entryName, hour, minute, dayOfWeek }) {
131
+ // Returns the schtasks.exe ARGV ARRAY (not a shell string). The /TR value the
132
+ // command to run, `"<node>" "<script>" "<projectRoot>"` with its own quotes
133
+ // around each spaced path is ONE array element, delivered to schtasks.exe
134
+ // verbatim via CreateProcess (Node's Windows arg-quoting), with NO cmd.exe
135
+ // re-parse at registration time.
136
+ //
137
+ // This is the D-83 fix. The old `/TR "${command}"` shell-string form double-
138
+ // wrapped the inner quotes (schtasks AND cmd.exe both tried to parse them) and
139
+ // the registerCron guard then rejected the inner `"` outright — so cron could
140
+ // NEVER register on Windows. Array-exec sidesteps the nesting entirely: Task
141
+ // Scheduler stores the /TR value and cmd.exe parses the quoted paths only when
142
+ // the task FIRES. (No `\"`-escaping needed → no CodeQL js/incomplete-
143
+ // sanitization, no path-corrupting backslash-doubling.)
144
+ //
145
+ // /ST is HH:mm; /F forces re-create for idempotency; /RL LIMITED (not HIGHEST)
146
+ // because distill needs no admin. Task 34: /SC WEEKLY /D <SUN|...> for weekly.
123
147
  const time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
124
- // The command is wrapped in `/TR "${command}"`. cmd.exe/schtasks treats
125
- // backslash as a PATH separator, not an escape character, so the only
126
- // char that can break out of the quotes is a literal `"` — and that is
127
- // rejected at the registerCron boundary (see the validation below), the
128
- // same way an embedded `'` is rejected for the Linux cron line. So no
129
- // escaping is applied here. (The earlier `command.replace(/"/g, '\\"')`
130
- // was both unnecessary — controlled bin-name+path commands never contain
131
- // `"` — and CodeQL-flagged js/incomplete-sanitization, since it didn't
132
- // escape backslashes; but `\"`/`\\` is not how cmd.exe quotes anyway, and
133
- // doubling backslashes would corrupt Windows paths. Reject-at-boundary is
134
- // the correct, safe contract.)
135
- // Task 34: /SC WEEKLY /D <SUN|MON|...> for weekly cadence; /SC DAILY otherwise.
136
- let scheduleFlags;
148
+ let scheduleArgs;
137
149
  if (dayOfWeek !== undefined && dayOfWeek !== null) {
138
150
  const day = WIN_DAY_MAP[dayOfWeek];
139
151
  if (!day) {
140
152
  throw new Error(`buildWindowsSchtasks: invalid dayOfWeek ${dayOfWeek}`);
141
153
  }
142
- scheduleFlags = `/SC WEEKLY /D ${day}`;
154
+ scheduleArgs = ['/SC', 'WEEKLY', '/D', day];
143
155
  } else {
144
- scheduleFlags = '/SC DAILY';
156
+ scheduleArgs = ['/SC', 'DAILY'];
145
157
  }
146
- return `schtasks /Create /TN "${entryName}" ${scheduleFlags} /ST ${time} /TR "${command}" /RL LIMITED /F`;
158
+ return ['/Create', '/TN', entryName, ...scheduleArgs, '/ST', time, '/TR', command, '/RL', 'LIMITED', '/F'];
147
159
  }
148
160
 
149
161
  /**
@@ -169,12 +181,16 @@ export function registerCron(opts = {}) {
169
181
  // cron command needs to either escape POSIX-style ('\'') or
170
182
  // we extend this helper with a sanitizer (v0.1.x candidate).
171
183
  errors.push("command: must not contain single quotes (Linux cron-line shell-quoting contract)");
172
- } else if (opts.command.includes('"')) {
173
- // Windows schtasks /TR wraps the command in double-quotes; an embedded
174
- // `"` would break out of the quoting. Reject at the boundary (cmd.exe
175
- // has no usable in-quote escape for `"`), mirroring the `'` contract.
176
- errors.push('command: must not contain double quotes (Windows schtasks /TR quoting contract)');
177
184
  }
185
+ // NOTE (Task 109 / D-83): there is deliberately NO double-quote rejection. The
186
+ // Windows command legitimately CONTAINS double-quotes — it's the quoted path
187
+ // triple `"<node>" "<script>" "<projectRoot>"` (Task 36 B1/B2). The earlier
188
+ // guard rejected `"` because the old `/TR "${command}"` SHELL form double-
189
+ // wrapped them; that made cron un-registerable on Windows (the whole D-83 bug).
190
+ // The win32 branch now execs schtasks with an ARGS ARRAY (no shell), so the
191
+ // /TR value is delivered verbatim and the inner quotes never need escaping.
192
+ // macOS XML-escapes them in the plist; Linux nests them inside its single-quote
193
+ // `echo '...'` — so `"` is safe on every platform.
178
194
  const entryName = opts.entryName ?? CRON_ENTRY_NAME;
179
195
  if (!entryName || typeof entryName !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(entryName)) {
180
196
  errors.push("entryName: must match /^[a-zA-Z0-9_.-]+$/ (used in shell + plist + schtasks identifiers)");
@@ -199,7 +215,9 @@ export function registerCron(opts = {}) {
199
215
  return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
200
216
  }
201
217
 
202
- const platform = detectPlatform();
218
+ // opts.platform is a test seam (detectPlatform() reads process.platform, which
219
+ // can't vary on a single CI host) — production never passes it.
220
+ const platform = opts.platform ?? detectPlatform();
203
221
  const dryRun = opts.dryRun === true;
204
222
 
205
223
  if (platform === 'linux') {
@@ -259,24 +277,37 @@ export function registerCron(opts = {}) {
259
277
  }
260
278
 
261
279
  if (platform === 'win32') {
262
- const cmd = buildWindowsSchtasks({ command: opts.command, entryName, hour, minute, dayOfWeek });
280
+ const argv = buildWindowsSchtasks({ command: opts.command, entryName, hour, minute, dayOfWeek });
281
+ const displayCmd = `schtasks ${argv.join(' ')}`; // informational (dry-run + result.command)
263
282
  if (dryRun) {
264
283
  return {
265
284
  action: 'dry-run',
266
285
  platform,
267
286
  executed: false,
268
- command: cmd,
287
+ command: displayCmd,
269
288
  output: '',
270
289
  };
271
290
  }
272
- // schtasks is a .exe; spawnSync handles it directly via shell:true
273
- // (per the kit's Windows .cmd shim pattern in compressor.mjs).
274
- const r = spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true, timeout: 10_000 });
291
+ // Exec schtasks.exe with the ARGS ARRAY NOT shell:true. This delivers the
292
+ // /TR value's inner quotes to schtasks verbatim (CreateProcess arg-quoting),
293
+ // never re-parsed by cmd.exe at registration time (the D-83 fix). Task
294
+ // Scheduler stores the command; cmd.exe parses the quoted paths at fire time.
295
+ // Resolve the ABSOLUTE System32 path rather than relying on PATH: schtasks
296
+ // creates a scheduled task, so a PATH-hijacked `schtasks.exe` in a writable
297
+ // dir would be a privilege-escalation vector (Sonar S4036). %SystemRoot% is
298
+ // a fixed, unwriteable system directory.
299
+ const schtasksExe = join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'schtasks.exe');
300
+ // opts.spawn is a test seam (defaults to spawnSync): the real schtasks exec
301
+ // can't run on a non-Windows CI host, so a fake lets the exec branch be
302
+ // covered in-process AND asserts WHAT gets spawned (Door 3: the absolute
303
+ // schtasks path + the verbatim argv). Production never passes it.
304
+ const spawn = opts.spawn ?? spawnSync;
305
+ const r = spawn(schtasksExe, argv, { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
275
306
  return {
276
307
  action: r.status === 0 ? 'registered' : 'error',
277
308
  platform,
278
309
  executed: true,
279
- command: cmd,
310
+ command: displayCmd,
280
311
  output: (r.stdout || '') + (r.stderr || ''),
281
312
  ...(r.status === 0 ? {} : { error: `schtasks exit ${r.status}` }),
282
313
  };
@@ -0,0 +1,91 @@
1
+ // Shared rich-capture core (Task 108b / ADR-0014).
2
+ //
3
+ // `rememberRich` writes a granular Why/How fact file via writeFact(). BOTH
4
+ // surfaces call it so they produce byte-identical fact files with one
5
+ // implementation of the contract:
6
+ // - CLI — subcommands.runRememberRich (re-exports this) ← `cmk remember --why/--how` + `--from-file`
7
+ // - MCP — mcp-server.makeMkRemember rich path ← `mk_remember` with why/how/title/type
8
+ //
9
+ // It lives in its own module (not subcommands.mjs) because subcommands.mjs
10
+ // imports runMcpServer from mcp-server.mjs — so mcp-server importing the core
11
+ // back from subcommands would be a circular dependency. The core depends only
12
+ // on the write/format primitives, never on either front-end.
13
+ //
14
+ // Logging is the CALLER's concern: this returns the writeFact result and the
15
+ // CLI/MCP adapter formats its own message (the CLI's "saved rich fact" line vs
16
+ // the MCP's JSON envelope) — so stdout-purity on the MCP path is the adapter's
17
+ // to keep (design §10.1), not the core's.
18
+
19
+ import { resolve as resolvePath } from 'node:path';
20
+ import { createHash } from 'node:crypto';
21
+ import { writeFact as defaultWriteFact } from './write-fact.mjs';
22
+ import { buildRichFactBody, slugifyFact } from './rich-fact.mjs';
23
+
24
+ /**
25
+ * The note shown when a non-project tier (U/L) is requested on a capture. Both
26
+ * `cmk remember` and `mk_remember` write the PROJECT tier (P) regardless of the
27
+ * requested tier; a fact becomes cross-project via lessons-promote, not a direct
28
+ * tier write (direct U/L routing is the deferred feature in design §16.40). This is the ONE
29
+ * source of truth for that note across all three adapter paths (CLI terse, CLI
30
+ * rich, MCP) — Task 108 unified the write core but the tier message had drifted
31
+ * into three divergent, independently-stale copies (D-102). Centralizing it here
32
+ * means it can't drift again.
33
+ */
34
+ export function nonProjectTierNote(tier) {
35
+ return (
36
+ `tier '${tier}' is not a direct write target — captured to the project tier (P). ` +
37
+ 'To make it cross-project, promote it (`cmk lessons promote <id>` / the `mk_lessons_promote` tool).'
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Write a rich Why/How fact file. Pure (no console logging) — returns the
43
+ * writeFact result so the caller can format its own message / envelope.
44
+ *
45
+ * @param {string} text - the fact headline.
46
+ * @param {object} [options] - { why, how, type, title, links, trust }. (tier is
47
+ * not honored here — rich capture writes the project tier P; the CLI/MCP
48
+ * adapters surface the non-project-tier note before/around calling.)
49
+ * @param {object} [deps] - { projectRoot, writeFact } injection seams for tests.
50
+ * @returns the writeFact result: { action:'created'|'skipped'|'error', id?, path?, errorCategory?, errors?, skipReason? }
51
+ */
52
+ export function rememberRich(text, options = {}, deps = {}) {
53
+ const projectRoot = deps.projectRoot ?? resolvePath(process.cwd());
54
+ const write = deps.writeFact ?? defaultWriteFact;
55
+
56
+ const headline = String(text).trim();
57
+ const title = (options.title && String(options.title).trim()) || headline.split('\n')[0].slice(0, 80);
58
+ const body = buildRichFactBody({ text: headline, why: options.why, how: options.how });
59
+ // `links` arrives as an ARRAY from the MCP tool (z.array) and as a
60
+ // comma-STRING from the CLI flag — accept both. The old `String(links)` path
61
+ // coerced an array via toString (works only until a link contains a comma);
62
+ // handle the array explicitly (D-102 / 121.6).
63
+ const related = Array.isArray(options.links)
64
+ ? options.links.map((s) => String(s).trim()).filter(Boolean)
65
+ : options.links
66
+ ? String(options.links).split(',').map((s) => s.trim()).filter(Boolean)
67
+ : undefined;
68
+
69
+ return write({
70
+ tier: 'P',
71
+ type: options.type ?? 'feedback',
72
+ slug: slugifyFact(title),
73
+ title,
74
+ body,
75
+ writeSource: 'user-explicit',
76
+ trust: options.trust ?? 'high',
77
+ sourceFile: 'user-explicit',
78
+ sourceLine: 1,
79
+ // Content fingerprint for provenance/dedup — NOT a security context. Matches
80
+ // the kit's sha1-of-content convention (memory-write.mjs, index-rebuild.mjs);
81
+ // writeFact dedups by content-addressed id, this is the source_sha1 field. // NOSONAR
82
+ sourceSha1: createHash('sha1').update(body).digest('hex'), // NOSONAR
83
+ related,
84
+ projectRoot,
85
+ });
86
+ }
87
+
88
+ /** The title rememberRich() will derive for `text`/`options` (for caller messages). */
89
+ export function richFactTitle(text, options = {}) {
90
+ return (options.title && String(options.title).trim()) || String(text).trim().split('\n')[0].slice(0, 80);
91
+ }
@@ -209,6 +209,19 @@ function serializeReviewQueue({ preamble, entries }) {
209
209
  // works because Active Threads is where transient observations
210
210
  // belong by convention; the user can promote into a different
211
211
  // section by editing MEMORY.md after promotion.
212
+ /**
213
+ * Pure-read list of pending review-queue entries (no mutation). Used by the MCP
214
+ * `mk_queue_list` tool so a "list" never rewrites the queue file — unlike
215
+ * resolveReviewQueue, which reserializes on every call. Returns `[]` when the
216
+ * queue file doesn't exist.
217
+ */
218
+ export function listReviewQueue({ tier = 'P', projectRoot, userDir } = {}) {
219
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
220
+ const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
221
+ if (!existsSync(queuePath)) return [];
222
+ return parseReviewQueue(readFileSync(queuePath, 'utf8')).entries;
223
+ }
224
+
212
225
  export async function resolveReviewQueue({
213
226
  tier,
214
227
  projectRoot,
@@ -0,0 +1,46 @@
1
+ // Rich-fact body + slug shaping — the single source of truth for HOW a rich
2
+ // fact's file body and filename slug are built (Task 103).
3
+ //
4
+ // Extracted from subcommands.mjs so the TWO rich-capture paths build identical
5
+ // fact files (the shared-modules / no-drift rule, CLAUDE.md §1.3):
6
+ // 1. explicit — `cmk remember --why/--how` → runRememberRich (subcommands.mjs)
7
+ // 2. automatic — the Stop-hook auto-extract synthesizing rich facts on the
8
+ // native-immune path (auto-extract.mjs, Task 103)
9
+ // Both call writeFact() with a body produced here, so an auto-extracted fact
10
+ // reads the same as an explicitly-captured one.
11
+
12
+ /**
13
+ * Build a slug for a rich fact's filename from its title.
14
+ *
15
+ * Collapse every run of non-alphanumerics to a single '-' (so dashes are never
16
+ * doubled), cap at 60 chars, then trim a leading/trailing dash without a regex
17
+ * quantifier (static analysis flags trailing `-+$` as ReDoS-prone; a single
18
+ * dash is all that can remain after the collapse, so string ops suffice).
19
+ *
20
+ * @param {string} s - the source text (typically the fact title).
21
+ * @returns {string} a `[a-z0-9][a-z0-9_-]*`-safe slug, or 'fact' if empty.
22
+ */
23
+ export function slugifyFact(s) {
24
+ let base = String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 60);
25
+ if (base.startsWith('-')) base = base.slice(1);
26
+ if (base.endsWith('-')) base = base.slice(0, -1);
27
+ return base || 'fact';
28
+ }
29
+
30
+ /**
31
+ * Assemble the rich fact body in the v0.1.1 shape: headline + Why + How.
32
+ * The headline/body may itself be multi-line markdown (a structured breakdown);
33
+ * Why/How are appended as labelled blocks only when present.
34
+ *
35
+ * @param {object} opts
36
+ * @param {string} opts.text - the headline / body (may be multi-line markdown).
37
+ * @param {string} [opts.why] - the rationale → `**Why:**` block.
38
+ * @param {string} [opts.how] - how to apply → `**How to apply:**` block.
39
+ * @returns {string} the assembled markdown body for writeFact().
40
+ */
41
+ export function buildRichFactBody({ text, why, how }) {
42
+ const parts = [String(text).trim()];
43
+ if (why && String(why).trim()) parts.push(`**Why:** ${String(why).trim()}`);
44
+ if (how && String(how).trim()) parts.push(`**How to apply:** ${String(how).trim()}`);
45
+ return parts.join('\n\n');
46
+ }
@@ -56,7 +56,7 @@ import {
56
56
  readFileSync,
57
57
  writeFileSync,
58
58
  } from 'node:fs';
59
- import { dirname } from 'node:path';
59
+ import { dirname, join } from 'node:path';
60
60
 
61
61
  /**
62
62
  * Canonical npm-route hooks block. Shell form (no `args`), PATH-resolved
@@ -191,7 +191,17 @@ export function writeKitHooks(settingsPath) {
191
191
  // returned one layer up once capture moved into the skill.
192
192
  // Idempotent + over-mutation safe: preserve the user's existing allow entries;
193
193
  // only append ours if absent.
194
- const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)'];
194
+ // - `mcp__cmk__*` (Task 108b, R2 / D-80) — allow-lists the kit's MCP tools
195
+ // (mk_remember / mk_forget / mk_trust / …) so the model's memory ops run
196
+ // without a per-call approval prompt. This is the structural fix for the
197
+ // `cd`-compound bash-permission edge (D-80): the permissions doc confirms
198
+ // "Combining `cd` with `git` in one compound command always prompts" — and
199
+ // a `Bash(cmk:*)` rule can't cover a `cd … && cmk …` compound. Running the
200
+ // SAME memory op as an allow-listed MCP tool sidesteps the bash gate
201
+ // entirely. `mcp__cmk__*` is the documented server-wildcard form
202
+ // (code.claude.com/docs/en/permissions — MCP section). Pairs with the
203
+ // `.mcp.json` server registration written by writeKitMcpServer().
204
+ const KIT_ALLOW = ['Bash(cmk:*)', 'Skill(memory-write)', 'mcp__cmk__*'];
195
205
  if (!settings.permissions || typeof settings.permissions !== 'object') {
196
206
  settings.permissions = {};
197
207
  }
@@ -214,3 +224,47 @@ export function writeKitHooks(settingsPath) {
214
224
 
215
225
  return { changed, settingsPath, events };
216
226
  }
227
+
228
+ /**
229
+ * Read-merge-write the kit's MCP server registration into
230
+ * `<projectRoot>/.mcp.json` — the project-scoped, committed MCP config
231
+ * (code.claude.com/docs/en/mcp). This makes the kit's memory tools
232
+ * (mk_remember / mk_forget / mk_trust / mk_queue_* / …) available to the model
233
+ * in conversation, so the user never has to run `cmk` themselves (D-85). Pairs
234
+ * with the `mcp__cmk__*` allow rule writeKitHooks() adds (Task 108b, R2 / D-80).
235
+ *
236
+ * The server runs `cmk mcp serve` (PATH-resolved bare bin, matching the hooks);
237
+ * `cmk mcp serve` resolves the project root from CLAUDE_PROJECT_DIR (which Claude
238
+ * Code sets in the spawned server's environment) so it indexes the right project.
239
+ *
240
+ * Idempotent + non-destructive: preserves any OTHER `mcpServers` the user
241
+ * registered; only (re)writes the `cmk` entry. On a JSON parse error of an
242
+ * existing `.mcp.json`, returns `{changed:false, error}` (never clobbers).
243
+ */
244
+ export function writeKitMcpServer(projectRoot) {
245
+ const mcpPath = join(projectRoot, '.mcp.json');
246
+
247
+ let config = {};
248
+ if (existsSync(mcpPath)) {
249
+ try {
250
+ config = JSON.parse(readFileSync(mcpPath, 'utf8'));
251
+ } catch (err) {
252
+ return { changed: false, path: mcpPath, error: `${mcpPath} parse error: ${err?.message ?? err}` };
253
+ }
254
+ }
255
+
256
+ const before = JSON.stringify(config);
257
+ if (!config.mcpServers || typeof config.mcpServers !== 'object') {
258
+ config.mcpServers = {};
259
+ }
260
+ config.mcpServers.cmk = { type: 'stdio', command: 'cmk', args: ['mcp', 'serve'] };
261
+ const after = JSON.stringify(config);
262
+ const changed = before !== after;
263
+
264
+ if (changed) {
265
+ mkdirSync(dirname(mcpPath), { recursive: true });
266
+ writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
267
+ }
268
+
269
+ return { changed, path: mcpPath };
270
+ }