@lh8ppl/claude-memory-kit 0.2.2 → 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.
@@ -1,9 +1,10 @@
1
1
  // MCP server (Task 31, T-027). Layer 5's final task — closes Layer 5.
2
2
  //
3
- // Per design §10 + tasks.md 31:
3
+ // Per design §10 + tasks.md 31 + ADR-0014 (Task 108b parity):
4
4
  // - stdio JSON-RPC transport per MCP 2025-06-18 spec
5
- // - Six tools: mk_search, mk_get, mk_timeline, mk_cite, mk_remember,
6
- // mk_recent_activity
5
+ // - Eleven tools (full CLI parity): READ — mk_search, mk_get, mk_timeline,
6
+ // mk_cite, mk_recent_activity; WRITE/MUTATE — mk_remember, mk_trust,
7
+ // mk_lessons_promote, mk_forget, mk_queue_list, mk_queue_resolve
7
8
  // - Path-traversal validation on every read/write surface
8
9
  // - All logs to stderr (or sessions/{date}.mcp.log); stdout pure
9
10
  //
@@ -18,7 +19,7 @@
18
19
  // handshake + tool listing. We register tool handlers; the SDK handles
19
20
  // the protocol envelope.
20
21
  //
21
- // Lior 2026-05-23 decision: @modelcontextprotocol/sdk library naming
22
+ // The user's 2026-05-23 decision: @modelcontextprotocol/sdk library naming
22
23
  // goes in tasks.md Task 31 implementation, NOT in design.md. This
23
24
  // module is where the dep choice lands.
24
25
  //
@@ -32,12 +33,29 @@
32
33
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
33
34
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
34
35
  import { z } from 'zod';
35
- import { resolve as resolvePath, isAbsolute } from 'node:path';
36
+ import { readFileSync } from 'node:fs';
37
+ import { resolve as resolvePath, isAbsolute, dirname, join } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
36
39
  import { openIndexDb } from './index-db.mjs';
37
40
  import { reindexBoot } from './index-rebuild.mjs';
38
41
  import { search, SEARCH_MODES } from './search.mjs';
39
42
  import { memoryWrite } from './memory-write.mjs';
40
- import { ID_PATTERN, resolveTierRoot } from './tier-paths.mjs';
43
+ import { rememberRich, nonProjectTierNote } from './remember-core.mjs';
44
+ import { forget } from './forget.mjs';
45
+ import { overrideTrust } from './trust.mjs';
46
+ import { lessonsPromote } from './lessons-promote.mjs';
47
+ import { resolveReviewQueue, listReviewQueue } from './review-queue.mjs';
48
+ import { resolveConflictQueue, listConflictQueue } from './conflict-queue.mjs';
49
+ import { createHash } from 'node:crypto';
50
+ import { getObservations, citeLink, buildTimeline, recentActivity } from './read-core.mjs';
51
+ import { resolveTierRoot } from './tier-paths.mjs';
52
+
53
+ // The kit version, read from package.json — NOT hardcoded. A hardcoded '0.1.0'
54
+ // shipped through the v0.2.3 cut and advertised the WRONG version to the MCP
55
+ // client (D-102 / Task 121.1). PKG_ROOT is one level up from src/.
56
+ const PKG_VERSION = JSON.parse(
57
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
58
+ ).version;
41
59
 
42
60
  // --- Path-traversal validation (design §10.2; tasks.md 31.2) ----------
43
61
 
@@ -112,130 +130,106 @@ function makeMkSearch({ db, semanticBackend }) {
112
130
  }
113
131
 
114
132
  function makeMkGet({ db }) {
115
- return async ({ ids }) => {
116
- const stmt = db.prepare(`
117
- SELECT id, body, heading_path, source_file, source_line, tier, trust,
118
- write_source, created_at, superseded_by, deleted_at
119
- FROM observations WHERE id = ?
120
- `);
121
- const rows = ids.map((id) => {
122
- if (!ID_PATTERN.test(id)) {
123
- return { id, error: 'invalid id format' };
124
- }
125
- const row = stmt.get(id);
126
- if (!row) return { id, error: 'not found' };
127
- return row;
128
- });
129
- return {
130
- content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
131
- };
132
- };
133
+ // Thin adapter over the shared read core (read-core.getObservations) the
134
+ // SAME logic the CLI `cmk get` calls (ADR-0014 parity).
135
+ return async ({ ids }) => ({
136
+ content: [{ type: 'text', text: JSON.stringify(getObservations(db, ids), null, 2) }],
137
+ });
133
138
  }
134
139
 
135
140
  function makeMkTimeline({ db }) {
136
- // Sequential context around an anchor ID or timestamp. v0.1.0 keeps
137
- // the implementation deliberately narrow: anchor by ID; return the
138
- // N observations before + N after by created_at order.
141
+ // Thin adapter over read-core.buildTimeline (shared with CLI `cmk timeline`).
139
142
  return async ({ anchor, depth_before, depth_after }) => {
140
- const before = depth_before ?? 5;
141
- const after = depth_after ?? 5;
142
- if (!ID_PATTERN.test(anchor)) {
143
- return {
144
- content: [{ type: 'text', text: 'error: anchor must be a valid kit ID' }],
145
- isError: true,
146
- };
147
- }
148
- const anchorRow = db
149
- .prepare('SELECT created_at, tier FROM observations WHERE id = ?')
150
- .get(anchor);
151
- if (!anchorRow) {
152
- return {
153
- content: [{ type: 'text', text: 'error: anchor not found' }],
154
- isError: true,
155
- };
143
+ const r = buildTimeline(db, {
144
+ anchor,
145
+ depthBefore: depth_before ?? 5,
146
+ depthAfter: depth_after ?? 5,
147
+ });
148
+ if (!r.ok) {
149
+ return { content: [{ type: 'text', text: `error: ${r.error}` }], isError: true };
156
150
  }
157
- // M2: id tiebreaker on observations with identical created_at
158
- // without it, observations created the same millisecond fall out
159
- // of the timeline non-deterministically. Same fix in afterRows.
160
- const beforeRows = db
161
- .prepare(`
162
- SELECT id, body, source_file, source_line, tier, trust, created_at
163
- FROM observations
164
- WHERE created_at < ? AND deleted_at IS NULL
165
- ORDER BY created_at DESC, id DESC LIMIT ?
166
- `)
167
- .all(anchorRow.created_at, before);
168
- const anchorFull = db
169
- .prepare(`
170
- SELECT id, body, source_file, source_line, tier, trust, created_at
171
- FROM observations WHERE id = ?
172
- `)
173
- .get(anchor);
174
- const afterRows = db
175
- .prepare(`
176
- SELECT id, body, source_file, source_line, tier, trust, created_at
177
- FROM observations
178
- WHERE created_at > ? AND deleted_at IS NULL
179
- ORDER BY created_at ASC, id ASC LIMIT ?
180
- `)
181
- .all(anchorRow.created_at, after);
182
- const timeline = [...beforeRows.reverse(), anchorFull, ...afterRows];
183
- return {
184
- content: [{ type: 'text', text: JSON.stringify(timeline, null, 2) }],
185
- };
151
+ return { content: [{ type: 'text', text: JSON.stringify(r.timeline, null, 2) }] };
186
152
  };
187
153
  }
188
154
 
189
155
  function makeMkCite() {
190
- // Pure formatting no DB query needed. The canonical citation link
191
- // form is documented in design §10's tool table:
192
- // `[#P-S79MJHFN](memkit://obs/P-S79MJHFN)`
156
+ // Thin adapter over read-core.citeLink (shared with CLI `cmk cite`). The
157
+ // canonical link form is `[#P-S79MJHFN](memkit://obs/P-S79MJHFN)`.
193
158
  return async ({ id }) => {
194
- if (!ID_PATTERN.test(id)) {
195
- return {
196
- content: [{ type: 'text', text: 'error: id must match ID_PATTERN' }],
197
- isError: true,
198
- };
159
+ const r = citeLink(id);
160
+ if (!r.ok) {
161
+ return { content: [{ type: 'text', text: `error: ${r.error}` }], isError: true };
199
162
  }
200
- const link = `[#${id}](memkit://obs/${id})`;
201
- return {
202
- content: [{ type: 'text', text: link }],
203
- };
163
+ return { content: [{ type: 'text', text: r.link }] };
204
164
  };
205
165
  }
206
166
 
207
167
  function makeMkRemember({ projectRoot, userDir }) {
208
- return async ({ text, tier, cites }) => {
209
- // I1 + I2 boundary checks (Task 31 code-review):
210
- // - cites: memory-write doesn't currently wire cites provenance.
211
- // Silently dropping the array would tell the model "your citation
212
- // was recorded" — false. Reject with "not yet supported" until
213
- // memoryWrite gains a cites parameter (v0.1.x).
214
- // - tier 'U': the kit's user-tier templates (USER.md / HABITS.md /
215
- // LESSONS.md) don't have MEMORY.md + 'Active Threads' section,
216
- // so memoryWrite would fail with NOT_FOUND. v0.1.0 mk_remember
217
- // only writes to project-tier MEMORY.md. (v0.1.x: parameterize
218
- // scratchpad routing per tier.)
168
+ return async ({ text, tier, cites, why, how, type, title, links }) => {
169
+ // cites: memoryWrite doesn't wire cites provenance yet. Silently dropping
170
+ // the array would tell the model "your citation was recorded" — false — so
171
+ // reject it clearly (the fact's own text is still captured if resubmitted
172
+ // without cites). Tracked in design §16.39.
219
173
  if (Array.isArray(cites) && cites.length > 0) {
220
174
  return {
221
175
  content: [
222
176
  {
223
177
  type: 'text',
224
- text: 'error: cites parameter not yet supported by mk_remember (v0.1.x see design §16.x). Submit the text without cites for now.',
178
+ text: 'error: the `cites` parameter is not recorded yet resubmit the fact without it (reference related facts via `links` for [[cross-links]]).',
225
179
  },
226
180
  ],
227
181
  isError: true,
228
182
  };
229
183
  }
230
- if (tier === 'U' || tier === 'L') {
184
+ // tier U/L: mk_remember writes the PROJECT tier (P) regardless; a fact
185
+ // becomes cross-project via mk_lessons_promote, not a direct tier write
186
+ // (direct U/L routing is the deferred feature in design §16.40). We do NOT error — we
187
+ // capture at P and attach the note, CONSISTENTLY with `cmk remember`. The
188
+ // three adapter paths had diverged (MCP error / CLI-rich warn / CLI-terse
189
+ // error); the note now comes from ONE shared source (D-102 / design §16.40).
190
+ const tierNote = tier === 'U' || tier === 'L' ? nonProjectTierNote(tier) : null;
191
+ // Task 108b — MCP write parity: when rich fields (why/how/type/title/links)
192
+ // are present, route to the SAME shared core (remember-core.rememberRich)
193
+ // the CLI `cmk remember --why/--how` uses → a granular Why/How fact file, not
194
+ // a terse MEMORY.md bullet. Identical fact files from both surfaces (ADR-0014).
195
+ if (why || how || type || title || links) {
196
+ const rr = rememberRich(text, { why, how, type, title, links }, { projectRoot });
197
+ if (rr.action === 'error') {
198
+ return {
199
+ content: [
200
+ {
201
+ type: 'text',
202
+ text: `error (${rr.errorCategory ?? 'unknown'}): ${(rr.errors ?? [rr.errorCategory ?? 'error']).join('; ')}`,
203
+ },
204
+ ],
205
+ isError: true,
206
+ };
207
+ }
208
+ if (rr.action === 'skipped') {
209
+ return {
210
+ content: [
211
+ {
212
+ type: 'text',
213
+ text: JSON.stringify(
214
+ { accepted: true, status: 'skipped', skip_reason: rr.skipReason, id: rr.id, written_to: rr.path },
215
+ null,
216
+ 2,
217
+ ),
218
+ },
219
+ ],
220
+ };
221
+ }
231
222
  return {
232
223
  content: [
233
224
  {
234
225
  type: 'text',
235
- text: `error: mk_remember in v0.1.0 only writes to tier 'P' (project). tier '${tier}' will be supported in v0.1.x when scratchpad routing is parameterized.`,
226
+ text: JSON.stringify(
227
+ { id: rr.id, written_to: rr.path, accepted: true, action: rr.action, kind: 'rich', ...(tierNote && { tier_note: tierNote }) },
228
+ null,
229
+ 2,
230
+ ),
236
231
  },
237
232
  ],
238
- isError: true,
239
233
  };
240
234
  }
241
235
  const r = memoryWrite({
@@ -291,7 +285,7 @@ function makeMkRemember({ projectRoot, userDir }) {
291
285
  {
292
286
  type: 'text',
293
287
  text: JSON.stringify(
294
- { id: r.id, written_to: r.path, accepted: true, action: r.action },
288
+ { id: r.id, written_to: r.path, accepted: true, action: r.action, ...(tierNote && { tier_note: tierNote }) },
295
289
  null,
296
290
  2,
297
291
  ),
@@ -302,35 +296,195 @@ function makeMkRemember({ projectRoot, userDir }) {
302
296
  }
303
297
 
304
298
  function makeMkRecentActivity({ db }) {
305
- const WINDOWS = {
306
- '1h': 60 * 60 * 1000,
307
- '24h': 24 * 60 * 60 * 1000,
308
- '7d': 7 * 24 * 60 * 60 * 1000,
309
- };
299
+ // Thin adapter over read-core.recentActivity (shared with CLI
300
+ // `cmk recent-activity`).
310
301
  return async ({ window, limit }) => {
311
- const w = window ?? '24h';
312
- if (!WINDOWS[w]) {
302
+ const r = recentActivity(db, { window: window ?? '24h', limit: limit ?? 20 });
303
+ if (!r.ok) {
304
+ return { content: [{ type: 'text', text: `error: ${r.error}` }], isError: true };
305
+ }
306
+ return { content: [{ type: 'text', text: JSON.stringify(r.rows, null, 2) }] };
307
+ };
308
+ }
309
+
310
+ // --- Mutate tools (Task 108b — MCP parity with the CLI) ---------------
311
+ //
312
+ // These wrap the existing CLI cores (forget / overrideTrust / lessonsPromote)
313
+ // so the model can perform the same mutations the user could via `cmk`. Per
314
+ // D-85: the user never types `cmk`; the conversation is the interface, so every
315
+ // mutate the CLI offers needs an MCP path the model can drive on their behalf.
316
+
317
+ /** Map a core error / not-found result to an MCP isError envelope. */
318
+ function mcpToolError(r) {
319
+ const msg = r.action === 'error'
320
+ ? `error (${r.errorCategory ?? 'unknown'}): ${(r.errors ?? ['operation failed']).join('; ')}`
321
+ : `error: ${(r.errors ?? ['not found']).join('; ')}`;
322
+ return { content: [{ type: 'text', text: msg }], isError: true };
323
+ }
324
+
325
+ /**
326
+ * Deterministic confirm token for a destructive op. Derived from id + body so a
327
+ * caller CANNOT produce it without first seeing the preview (the digest is not
328
+ * knowable from the id alone) — that forces the two-step preview→confirm flow.
329
+ * Stable across re-calls (idempotent), so a retried confirm with the same token
330
+ * still works. sha256 (not sha1) — not because this is a crypto-sensitive
331
+ * context (it's a preview-fingerprint nonce, not auth/signing), but so static
332
+ * analysis isn't tripped by a "weak hash" smell on a brand-new code path.
333
+ */
334
+ function forgetConfirmToken(id, body) {
335
+ return createHash('sha256').update(`${id}:${body ?? ''}`).digest('hex').slice(0, 8);
336
+ }
337
+
338
+ function makeMkTrust({ projectRoot, userDir }) {
339
+ return async ({ id, level }) => {
340
+ const r = overrideTrust({ id, level, projectRoot, userDir, actor: 'mcp-user-explicit' });
341
+ if (r.action !== 'trust-updated') return mcpToolError(r);
342
+ return {
343
+ content: [{ type: 'text', text: JSON.stringify(
344
+ { accepted: true, action: r.action, id: r.id, tier: r.tier, level: r.level }, null, 2,
345
+ ) }],
346
+ };
347
+ };
348
+ }
349
+
350
+ function makeMkLessonsPromote({ projectRoot, userDir }) {
351
+ return async ({ id, to }) => {
352
+ const r = lessonsPromote({ id, projectRoot, userDir, to: to ?? 'LESSONS.md' });
353
+ if (r.action !== 'promoted' && r.action !== 'queued') return mcpToolError(r);
354
+ return {
355
+ content: [{ type: 'text', text: JSON.stringify(
356
+ {
357
+ accepted: true,
358
+ action: r.action,
359
+ id: r.id,
360
+ target: r.target,
361
+ section: r.section,
362
+ ...(r.action === 'queued'
363
+ ? { status: 'queued', hint: 'Promotion routed to the user-tier review/conflict queue — it lands once resolved.' }
364
+ : {}),
365
+ }, null, 2,
366
+ ) }],
367
+ };
368
+ };
369
+ }
370
+
371
+ function makeMkForget({ projectRoot, userDir }) {
372
+ return async ({ id, reason, confirm }) => {
373
+ // Dry pass: resolve + capture the preview via a confirm callback that
374
+ // refuses (returns false → action:'cancelled'), so nothing is deleted yet.
375
+ // A not-found / ambiguous / schema error short-circuits BEFORE the callback.
376
+ let preview = null;
377
+ const dry = forget({
378
+ idOrQuery: id,
379
+ projectRoot,
380
+ userDir,
381
+ reason,
382
+ confirm: (p) => { preview = p; return false; },
383
+ });
384
+ if (dry.action !== 'cancelled' || !preview) {
385
+ return mcpToolError(dry);
386
+ }
387
+
388
+ const token = forgetConfirmToken(preview.id, preview.body);
389
+ if (confirm !== token) {
390
+ // Step 1 — preview + issue the token; require a second deliberate call.
313
391
  return {
314
- content: [{ type: 'text', text: 'error: window must be 1h|24h|7d' }],
315
- isError: true,
392
+ content: [{ type: 'text', text: JSON.stringify(
393
+ {
394
+ status: 'confirm_required',
395
+ would_tombstone: {
396
+ id: preview.id,
397
+ tier: preview.tier,
398
+ title: preview.title ?? null,
399
+ path: preview.path,
400
+ body_preview: String(preview.body ?? '').slice(0, 280),
401
+ },
402
+ confirm_token: token,
403
+ hint: `Permanently tombstones this fact (audit trail preserved). To proceed, call mk_forget again with confirm: "${token}".`,
404
+ }, null, 2,
405
+ ) }],
316
406
  };
317
407
  }
318
- const lim = limit ?? 20;
319
- const cutoff = Date.now() - WINDOWS[w];
320
- const rows = db
321
- .prepare(`
322
- SELECT id, body, source_file, source_line, tier, trust, created_at
323
- FROM observations
324
- WHERE created_at >= ? AND deleted_at IS NULL
325
- ORDER BY created_at DESC LIMIT ?
326
- `)
327
- .all(cutoff, lim);
408
+
409
+ // Step 2 token matches → execute.
410
+ const r = forget({ idOrQuery: id, projectRoot, userDir, reason, yes: true });
411
+ if (r.action !== 'tombstoned') return mcpToolError(r);
328
412
  return {
329
- content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
413
+ content: [{ type: 'text', text: JSON.stringify(
414
+ { accepted: true, action: 'tombstoned', id: r.id, tier: r.tier, tombstoned_to: r.tombstonePath }, null, 2,
415
+ ) }],
330
416
  };
331
417
  };
332
418
  }
333
419
 
420
+ // The review/conflict queues resolve via interactive walkers that take a
421
+ // `prompter(entry) → decision` callback. Two callback shapes give a clean,
422
+ // non-interactive MCP surface over them WITHOUT duplicating the walk logic:
423
+ // - LIST: a prompter that records each entry + returns 'skip' (mutates
424
+ // nothing) — so the model can see what's pending and tell the user.
425
+ // - RESOLVE: a prompter that returns the requested action for the matching
426
+ // id and 'skip' for every other entry — resolves exactly one item.
427
+ // 'merge-both' is excluded from mk_queue_resolve: it composes the two facts'
428
+ // content (mergeFacts needs a merged body), which is an interactive decision —
429
+ // the model is pointed at `cmk queue conflicts` for that.
430
+
431
+ function makeMkQueueList({ projectRoot, userDir }) {
432
+ return async ({ queue }) => {
433
+ const q = queue ?? 'review';
434
+ if (q !== 'review' && q !== 'conflicts') {
435
+ return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`queue must be 'review' or 'conflicts' (got ${q})`] });
436
+ }
437
+ // PURE READ (code-review SR-1): list via the dedicated read helpers, NOT the
438
+ // resolve* walkers — those reserialize + rewrite the queue file on every call,
439
+ // so listing through them would mutate (mtime churn / reformat / concurrent-
440
+ // resolve race) on a read-only op. listReviewQueue / listConflictQueue parse
441
+ // the file without writing.
442
+ try {
443
+ const entries = q === 'review'
444
+ ? listReviewQueue({ tier: 'P', projectRoot, userDir })
445
+ : listConflictQueue({ tier: 'P', projectRoot, userDir });
446
+ return {
447
+ content: [{ type: 'text', text: JSON.stringify({ queue: q, pending: entries.length, entries }, null, 2) }],
448
+ };
449
+ } catch (err) {
450
+ return { content: [{ type: 'text', text: `error: ${err?.message ?? err}` }], isError: true };
451
+ }
452
+ };
453
+ }
454
+
455
+ function makeMkQueueResolve({ projectRoot, userDir }) {
456
+ return async ({ queue, id, action }) => {
457
+ const q = queue ?? 'review';
458
+ if (q === 'review') {
459
+ if (action !== 'promote' && action !== 'discard') {
460
+ return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`review action must be 'promote' or 'discard' (got ${action})`] });
461
+ }
462
+ const prompter = async (e) => (e.id === id ? action : 'skip');
463
+ const r = await resolveReviewQueue({ tier: 'P', projectRoot, userDir, prompter });
464
+ if (r.action === 'error') return mcpToolError(r);
465
+ const count = action === 'promote' ? r.promoted : r.discarded;
466
+ return {
467
+ content: [{ type: 'text', text: JSON.stringify({ accepted: count > 0, queue: 'review', id, action, result: r }, null, 2) }],
468
+ };
469
+ }
470
+ if (q === 'conflicts') {
471
+ if (action === 'merge-both') {
472
+ return mcpToolError({ action: 'error', errorCategory: 'schema', errors: ["merge-both composes the two facts' content — run `cmk queue conflicts` for an interactive merge. mk_queue_resolve supports 'keep-old' / 'keep-new'."] });
473
+ }
474
+ if (action !== 'keep-old' && action !== 'keep-new') {
475
+ return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`conflict action must be 'keep-old' or 'keep-new' (got ${action})`] });
476
+ }
477
+ const prompter = async (e) => (e.proposedId === id ? action : 'skip');
478
+ const r = await resolveConflictQueue({ tier: 'P', projectRoot, userDir, prompter });
479
+ if (r.action === 'error') return mcpToolError(r);
480
+ return {
481
+ content: [{ type: 'text', text: JSON.stringify({ accepted: r.resolved > 0, queue: 'conflicts', id, action, result: r }, null, 2) }],
482
+ };
483
+ }
484
+ return mcpToolError({ action: 'error', errorCategory: 'schema', errors: [`queue must be 'review' or 'conflicts' (got ${q})`] });
485
+ };
486
+ }
487
+
334
488
  // --- Server build + run ----------------------------------------------
335
489
 
336
490
  /**
@@ -344,7 +498,7 @@ function makeMkRecentActivity({ db }) {
344
498
  export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
345
499
  const server = new McpServer({
346
500
  name: 'cmk',
347
- version: '0.1.0',
501
+ version: PKG_VERSION,
348
502
  });
349
503
 
350
504
  // mk_search
@@ -414,8 +568,15 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
414
568
  description: 'Explicit user-driven save to kit memory with audit trail.',
415
569
  inputSchema: {
416
570
  text: z.string().min(1).max(5000).describe('the fact text (max 5000 chars)'),
417
- tier: z.enum(['U', 'P', 'L']).optional(),
418
- cites: z.array(z.string()).optional(),
571
+ tier: z.enum(['U', 'P', 'L']).optional().describe("target tier (default P). U/L are captured to the project tier (P) with a note — use mk_lessons_promote to make a fact cross-project"),
572
+ cites: z.array(z.string()).optional().describe('not recorded yet — omit it'),
573
+ // Task 108b — rich capture parity with the CLI `cmk remember --why/--how`.
574
+ // Any of these routes to a granular Why/How fact file (not a terse bullet).
575
+ why: z.string().max(5000).optional().describe('rich: the rationale (the **Why:** block)'),
576
+ how: z.string().max(5000).optional().describe('rich: how to apply it (the **How to apply:** block)'),
577
+ type: z.enum(['feedback', 'project', 'reference', 'user']).optional().describe('rich: fact type (default feedback)'),
578
+ title: z.string().max(200).optional().describe('rich: short title (also the fact-file slug)'),
579
+ links: z.array(z.string()).optional().describe('rich: related fact names for [[cross-links]]'),
419
580
  },
420
581
  },
421
582
  makeMkRemember({ projectRoot, userDir }),
@@ -425,7 +586,7 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
425
586
  server.registerTool(
426
587
  'mk_recent_activity',
427
588
  {
428
- description: 'List recent observation changes within a time window.',
589
+ description: 'List recently added observations within a time window (by creation time).',
429
590
  inputSchema: {
430
591
  window: z.enum(['1h', '24h', '7d']).optional(),
431
592
  limit: z.number().int().positive().max(1000).optional(),
@@ -434,6 +595,74 @@ export function buildMcpServer({ projectRoot, userDir, db, semanticBackend }) {
434
595
  makeMkRecentActivity({ db }),
435
596
  );
436
597
 
598
+ // mk_trust (Task 108b — mutate parity). Reversible; audited.
599
+ server.registerTool(
600
+ 'mk_trust',
601
+ {
602
+ description: 'Override the trust level (low|medium|high) of a fact or bullet by ID. Reversible + audited. Parity with `cmk trust`.',
603
+ inputSchema: {
604
+ id: z.string().describe('kit observation ID'),
605
+ level: z.enum(['low', 'medium', 'high']).describe('the new trust level'),
606
+ },
607
+ },
608
+ makeMkTrust({ projectRoot, userDir }),
609
+ );
610
+
611
+ // mk_lessons_promote (Task 108b — mutate parity). Sanitized + audited.
612
+ server.registerTool(
613
+ 'mk_lessons_promote',
614
+ {
615
+ description: 'Promote a project-tier (P-) fact to the cross-project user tier so it applies in every project. Sanitized + secret-screened + audited. Parity with `cmk lessons promote`.',
616
+ inputSchema: {
617
+ id: z.string().describe('kit observation ID (a project-tier P- fact)'),
618
+ to: z.enum(['USER.md', 'HABITS.md', 'LESSONS.md']).optional().describe('target user-tier file (default LESSONS.md)'),
619
+ },
620
+ },
621
+ makeMkLessonsPromote({ projectRoot, userDir }),
622
+ );
623
+
624
+ // mk_forget (Task 108b — DESTRUCTIVE mutate parity). Two-step confirm-token:
625
+ // the first call previews + returns a confirm_token; call again with that
626
+ // token to execute. Tombstones (audit trail preserved), never hard-deletes.
627
+ server.registerTool(
628
+ 'mk_forget',
629
+ {
630
+ description: 'Tombstone (forget) a fact by ID. DESTRUCTIVE + two-step: the first call previews what would be removed and returns a confirm_token; call again with confirm set to that token to execute. Audit trail preserved. Parity with `cmk forget`.',
631
+ inputSchema: {
632
+ id: z.string().describe('kit observation ID to tombstone'),
633
+ reason: z.string().max(500).optional().describe('why it is being forgotten (audited)'),
634
+ confirm: z.string().optional().describe('the confirm_token from the preview call — required to actually delete'),
635
+ },
636
+ },
637
+ makeMkForget({ projectRoot, userDir }),
638
+ );
639
+
640
+ // mk_queue_list (Task 108b — queue parity). Read-only: show pending entries.
641
+ server.registerTool(
642
+ 'mk_queue_list',
643
+ {
644
+ description: "List pending entries in the review queue (medium-trust auto-extracts awaiting promotion) or the conflict queue (writes that clashed with existing facts). Read-only. Parity with `cmk queue review` / `cmk queue conflicts`.",
645
+ inputSchema: {
646
+ queue: z.enum(['review', 'conflicts']).optional().describe("which queue (default 'review')"),
647
+ },
648
+ },
649
+ makeMkQueueList({ projectRoot, userDir }),
650
+ );
651
+
652
+ // mk_queue_resolve (Task 108b — queue parity). Resolve one entry by id.
653
+ server.registerTool(
654
+ 'mk_queue_resolve',
655
+ {
656
+ description: "Resolve one queued entry by ID. review: 'promote' (land it in MEMORY.md at high trust) | 'discard'. conflicts: 'keep-old' | 'keep-new' (merge-both composes content — use `cmk queue conflicts`). Audited.",
657
+ inputSchema: {
658
+ queue: z.enum(['review', 'conflicts']).describe('which queue the entry is in'),
659
+ id: z.string().describe('the queued entry ID to resolve'),
660
+ action: z.enum(['promote', 'discard', 'keep-old', 'keep-new', 'merge-both']).describe('review: promote|discard; conflicts: keep-old|keep-new (merge-both → use `cmk queue conflicts`)'),
661
+ },
662
+ },
663
+ makeMkQueueResolve({ projectRoot, userDir }),
664
+ );
665
+
437
666
  return server;
438
667
  }
439
668
 
@@ -168,6 +168,10 @@ export function mergeFacts(opts = {}) {
168
168
  tags: mergedTags,
169
169
  projectRoot,
170
170
  userDir,
171
+ // A merge emits its own richer `merged`/CURATED_MERGE audit below — suppress
172
+ // writeFact's default `created` entry so the merge logs exactly one event
173
+ // (Task 123.A opt-out).
174
+ audit: false,
171
175
  });
172
176
  if (writeResult.action === 'error') {
173
177
  return errorResult({
@@ -26,6 +26,7 @@ import {
26
26
  statSync,
27
27
  renameSync,
28
28
  unlinkSync,
29
+ copyFileSync,
29
30
  } from 'node:fs';
30
31
  import { join, dirname } from 'node:path';
31
32
  import { reindex } from './reindex.mjs';
@@ -142,7 +143,7 @@ function rollbackImport(created, renamed) {
142
143
  try {
143
144
  if (existsSync(bkp)) {
144
145
  mkdirSync(dirname(dest), { recursive: true });
145
- renameSync(bkp, dest);
146
+ restoreBackup(bkp, dest);
146
147
  }
147
148
  } catch {
148
149
  /* best-effort — the backup copy still exists for manual recovery */
@@ -150,6 +151,28 @@ function rollbackImport(created, renamed) {
150
151
  }
151
152
  }
152
153
 
154
+ // Restore a backed-up file over `dest` (Task 116.x). `dest` may already hold the
155
+ // half-applied NEW content (Phase-2 write before the failure), so this OVERWRITES
156
+ // it. The old code used `renameSync(bkp, dest)`, but renaming ONTO an existing
157
+ // file can intermittently throw EPERM/EBUSY on Windows under heavy parallel FS
158
+ // load — and rollbackImport's silent catch then left the un-rolled-back NEW file
159
+ // in place (the rare persona-portability rollback flake). `copyFileSync` overwrites
160
+ // the destination reliably on every platform; a short retry covers a transient
161
+ // lock. The backup is removed only after a confirmed copy.
162
+ export function restoreBackup(bkp, dest) {
163
+ let lastErr;
164
+ for (let attempt = 0; attempt < 5; attempt++) {
165
+ try {
166
+ copyFileSync(bkp, dest);
167
+ try { unlinkSync(bkp); } catch { /* backup cleanup best-effort */ }
168
+ return;
169
+ } catch (err) {
170
+ lastErr = err; // transient EPERM/EBUSY under load — retry
171
+ }
172
+ }
173
+ throw lastErr;
174
+ }
175
+
153
176
  // Apply the bundle's files TRANSACTIONALLY (the Task-91 rollback discipline):
154
177
  // back up every existing target first, then write all files, and if ANY write
155
178
  // fails partway, roll the whole thing back so a mid-import disk/permission error