@lh8ppl/claude-memory-kit 0.1.0

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 (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,565 @@
1
+ // memory-write — the public boundary for durable memory writes (Task
2
+ // 24, T-021). Invoked by two callers, same code path (design §6.3):
3
+ //
4
+ // 1. The auto-extract subagent (programmatic) — passes
5
+ // {action: 'add', source: 'auto-extract', ...} after Haiku
6
+ // identifies a high-trust durable fact. Closes the Poison_Guard
7
+ // bypass gap Task 23 left documented.
8
+ //
9
+ // 2. The user-explicit Skill — plugin/skills/memory-write/SKILL.md.
10
+ // Claude Code's harness invokes the skill body when the user's
11
+ // prompt matches the description+when_to_use signals (e.g.
12
+ // "remember this"). The skill body delegates here.
13
+ //
14
+ // Three actions per design §6.3:
15
+ // - add → Poison_Guard → consolidate-if-over-cap → append bullet
16
+ // - replace → Poison_Guard → substring match → strip old, append new
17
+ // - remove → substring match → ALWAYS-confirm gate → tombstone
18
+ //
19
+ // Poison_Guard runs BEFORE any disk write. A rejection produces an
20
+ // NDJSON entry in .locks/poison-guard.log with the redacted excerpt
21
+ // (cleartext never leaves checkPoisonGuard()), and the caller gets
22
+ // action: 'error', errorCategory: 'poison_guard', pattern_id.
23
+ //
24
+ // Tombstone discipline (design §6.5): `remove` NEVER silently
25
+ // deletes. The matched bullet + its provenance comment are copied
26
+ // into a tombstone file at <tier-root>/archive/tombstones/<id>.md
27
+ // with `deleted_at` / `deleted_reason` / `deleted_by` frontmatter,
28
+ // then stripped from the scratchpad. `mk_get(<id>)` (Layer 6) can
29
+ // still resolve via the tombstone for audit purposes.
30
+ //
31
+ // Uses shared modules per CLAUDE.md "Shared modules" rule:
32
+ // tier-paths.mjs — tier/scratchpad path resolution
33
+ // audit-log.mjs — nowIso(), appendAuditEntry()
34
+ // result-shapes.mjs — ERROR_CATEGORIES, errorResult()
35
+ // scratchpad.mjs — appendScratchpadBullet() (add path)
36
+ // provenance.mjs — parseBulletProvenance() (replace/remove bullet match)
37
+ // poison-guard.mjs — checkPoisonGuard(), logPoisonGuardRejection()
38
+
39
+ import {
40
+ existsSync,
41
+ readFileSync,
42
+ writeFileSync,
43
+ mkdirSync,
44
+ } from 'node:fs';
45
+ import { join, dirname } from 'node:path';
46
+ import { createHash } from 'node:crypto';
47
+ import { generateId } from '@lh8ppl/cmk-canonicalize';
48
+ import {
49
+ resolveTierRoot,
50
+ resolveScratchpadPath,
51
+ VALID_TIERS,
52
+ ID_PATTERN,
53
+ } from './tier-paths.mjs';
54
+ import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
55
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
56
+ import { appendScratchpadBullet } from './scratchpad.mjs';
57
+ import { parseBulletProvenance } from './provenance.mjs';
58
+ import { checkPoisonGuard, logPoisonGuardRejection } from './poison-guard.mjs';
59
+ import { detectConflicts, writeConflictEntry } from './conflict-queue.mjs';
60
+
61
+ const VALID_ACTIONS = new Set(['add', 'replace', 'remove']);
62
+
63
+ // --- Validation ----------------------------------------------------
64
+
65
+ function validateCommon(opts) {
66
+ const errors = [];
67
+ if (!VALID_ACTIONS.has(opts.action)) {
68
+ errors.push(
69
+ `action: required, one of ${[...VALID_ACTIONS].join(' / ')} (got ${JSON.stringify(opts.action)})`,
70
+ );
71
+ }
72
+ if (!opts.tier || !VALID_TIERS.has(opts.tier)) {
73
+ errors.push(`tier: required, one of 'U', 'P', 'L'`);
74
+ }
75
+ if (!opts.scratchpad || typeof opts.scratchpad !== 'string') {
76
+ errors.push('scratchpad: required, non-empty string');
77
+ }
78
+ if (!opts.section || typeof opts.section !== 'string') {
79
+ errors.push('section: required, non-empty string');
80
+ }
81
+ if (!opts.source || typeof opts.source !== 'string') {
82
+ errors.push('source: required, non-empty string');
83
+ }
84
+ return errors;
85
+ }
86
+
87
+ function validateText(opts, errors) {
88
+ if (opts.text == null || typeof opts.text !== 'string' || !opts.text.trim()) {
89
+ errors.push('text: required, non-empty string');
90
+ }
91
+ }
92
+
93
+ // --- Poison_Guard integration --------------------------------------
94
+
95
+ function runPoisonGuard({ text, projectRoot, source, sessionId, now }) {
96
+ const result = checkPoisonGuard(text);
97
+ if (!result.rejected) return null;
98
+ // Schema-class rejections (non-string input) don't need a log entry
99
+ // — there's no input to redact, and the caller will surface schema
100
+ // through the normal error path.
101
+ if (result.pattern_id === 'schema') {
102
+ return errorResult({
103
+ category: ERROR_CATEGORIES.SCHEMA,
104
+ errors: ['text: must be a non-empty string'],
105
+ });
106
+ }
107
+ const sourceFile = sessionId
108
+ ? `${source}-${sessionId}`
109
+ : source;
110
+ logPoisonGuardRejection({
111
+ projectRoot,
112
+ ts: now ?? nowIso(),
113
+ pattern_id: result.pattern_id,
114
+ source_file: sourceFile,
115
+ source_line: 1,
116
+ redacted_excerpt: result.redacted_excerpt,
117
+ });
118
+ // Route through errorResult() (not a hand-rolled object) so the result
119
+ // shape carries the `errors: [...]` array that downstream callers
120
+ // (review-queue.resolveReviewQueue → subcommands.runQueueReview's
121
+ // `err.errors.join('; ')`) expect. Without this, `cmk queue review`
122
+ // crashes with `TypeError: Cannot read properties of undefined`
123
+ // when the user promotes a candidate that contains a Poison_Guard
124
+ // pattern. The `pattern_id` + `redacted_excerpt` stay reachable on
125
+ // the returned object for analytics use.
126
+ return errorResult({
127
+ category: ERROR_CATEGORIES.POISON_GUARD,
128
+ errors: [
129
+ `Poison_Guard rejected write: pattern_id=${result.pattern_id}`,
130
+ ],
131
+ pattern_id: result.pattern_id,
132
+ redacted_excerpt: result.redacted_excerpt,
133
+ });
134
+ }
135
+
136
+ // --- Bullet search helpers (for replace + remove) -------------------
137
+
138
+ // Walk the scratchpad lines and find the first bullet (in the
139
+ // caller's section) whose text contains the substring. Returns
140
+ // {bulletIdx, commentIdx, id, bulletText, commentLine} or null.
141
+ // The bullet shape per provenance.mjs writeBullet():
142
+ // - (P-XXXXXXXX) <text>
143
+ // <!-- source:..., source_line:..., sha1:..., write:..., trust:..., at:... -->
144
+ //
145
+ // ID class is derived from the canonical ID_PATTERN exported by
146
+ // tier-paths.mjs (the kit's base32 alphabet: 2345679ABCDEFGHJKLMNPQRSTUVWXYZa,
147
+ // note the lowercase `a`). An earlier draft hard-coded [A-Z0-9]{8}
148
+ // which silently failed on the ~22% of IDs that canonicalize emits
149
+ // with a lowercase `a` — surfaced by the holistic code-review pass
150
+ // for Task 24.
151
+ const ID_TIER_PREFIX = '(U|P|L)';
152
+ const ID_BODY_CLASS = '[2345679ABCDEFGHJKLMNPQRSTUVWXYZa]{8}';
153
+ const BULLET_LINE_RE = new RegExp(`^- \\(${ID_TIER_PREFIX}-(${ID_BODY_CLASS})\\)\\s+(.*)$`);
154
+
155
+ // Sanity check: the regex we just built must accept anything
156
+ // ID_PATTERN does. Caught at module load — if a future edit to
157
+ // tier-paths.mjs adds a character we forgot to mirror here, this
158
+ // fails fast instead of silently producing not-found errors.
159
+ {
160
+ const sample = 'P-a2RH5GMN';
161
+ if (!ID_PATTERN.test(sample)) {
162
+ throw new Error(`memory-write: ID_PATTERN regression — sample ${sample} rejected`);
163
+ }
164
+ if (!BULLET_LINE_RE.test(`- (${sample}) example`)) {
165
+ throw new Error(`memory-write: BULLET_LINE_RE does not cover ID_PATTERN alphabet`);
166
+ }
167
+ }
168
+
169
+ // Find the bullet by section scope: walk only the section identified
170
+ // by `sectionTitle`. Without this, a substring that appears in two
171
+ // sections would match the first occurrence file-wide (wrong section).
172
+ function findSectionRange(lines, sectionTitle) {
173
+ const startIdx = lines.findIndex((l) => l.trim() === `## ${sectionTitle}`);
174
+ if (startIdx === -1) return null;
175
+ let endIdx = lines.findIndex((l, i) => i > startIdx && /^##\s/.test(l));
176
+ if (endIdx === -1) endIdx = lines.length;
177
+ return { startIdx, endIdx };
178
+ }
179
+
180
+ function findMatchingBullet({ lines, substring, sectionTitle }) {
181
+ if (typeof substring !== 'string' || substring === '') return null;
182
+ const range = sectionTitle
183
+ ? findSectionRange(lines, sectionTitle)
184
+ : { startIdx: 0, endIdx: lines.length };
185
+ if (!range) return null;
186
+ for (let i = range.startIdx; i < range.endIdx - 1; i++) {
187
+ const m = lines[i].match(BULLET_LINE_RE);
188
+ if (!m) continue;
189
+ const [, tier, idShort, bulletText] = m;
190
+ if (!bulletText.includes(substring)) continue;
191
+ const commentLine = lines[i + 1];
192
+ if (!commentLine || !/^\s*<!--.*-->\s*$/.test(commentLine)) continue;
193
+ return {
194
+ bulletIdx: i,
195
+ commentIdx: i + 1,
196
+ id: `${tier}-${idShort}`,
197
+ bulletText,
198
+ commentLine,
199
+ };
200
+ }
201
+ return null;
202
+ }
203
+
204
+ // --- Tombstone writer (design §6.5) --------------------------------
205
+
206
+ function writeTombstone({
207
+ tierRoot,
208
+ id,
209
+ bulletText,
210
+ commentLine,
211
+ deletedAt,
212
+ deletedReason,
213
+ deletedBy,
214
+ }) {
215
+ const tombstoneDir = join(tierRoot, 'archive', 'tombstones');
216
+ mkdirSync(tombstoneDir, { recursive: true });
217
+ const tombstonePath = join(tombstoneDir, `${id}.md`);
218
+ const provenance = parseBulletProvenance(commentLine) ?? {};
219
+ const body = [
220
+ '---',
221
+ `id: ${id}`,
222
+ `deleted_at: ${deletedAt}`,
223
+ `deleted_reason: ${JSON.stringify(deletedReason)}`,
224
+ `deleted_by: ${deletedBy}`,
225
+ provenance.source ? `original_source: ${JSON.stringify(provenance.source)}` : null,
226
+ provenance.at ? `original_at: ${provenance.at}` : null,
227
+ provenance.trust ? `original_trust: ${provenance.trust}` : null,
228
+ '---',
229
+ '',
230
+ bulletText,
231
+ '',
232
+ ].filter((l) => l !== null).join('\n');
233
+ writeFileSync(tombstonePath, body, 'utf8');
234
+ return tombstonePath;
235
+ }
236
+
237
+ // --- Action: add ---------------------------------------------------
238
+ //
239
+ // Split into two functions on purpose: `doAdd` is the public path
240
+ // (validate → Poison_Guard → write); `appendBulletGuarded` is the
241
+ // inner write-only step that assumes the caller has ALREADY gated
242
+ // through Poison_Guard. `doReplace` calls the inner directly so the
243
+ // guard doesn't fire twice on the same text. This isn't just
244
+ // performance — the guard call has a side effect (NDJSON log write),
245
+ // and double-rejecting a still-fine text would produce log noise the
246
+ // moment Poison_Guard ever becomes non-deterministic (e.g. settings-
247
+ // driven extension patterns from design §6.7's tunability hook).
248
+
249
+ function doAdd(opts) {
250
+ const errors = [];
251
+ validateText(opts, errors);
252
+ if (errors.length > 0) {
253
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
254
+ }
255
+ const poisonResult = runPoisonGuard({
256
+ text: opts.text,
257
+ projectRoot: opts.projectRoot,
258
+ source: opts.source,
259
+ sessionId: opts.sessionId,
260
+ now: opts.now,
261
+ });
262
+ if (poisonResult) return poisonResult;
263
+
264
+ // Conflict-queue check (Task 25, design §6.8). Runs BEFORE the append:
265
+ // - new.trust < existing.trust → route to queues/conflicts.md
266
+ // - new.trust >= existing.trust ('supersede' action) → for v0.1.0
267
+ // this continues to normal append; auto-marking the existing
268
+ // bullet's provenance with `superseded_by:` is deferred to v0.1.x
269
+ // (callers can use the explicit `replace` action when they want
270
+ // that semantics today).
271
+ const newTrust = opts.trust ?? 'high';
272
+ const scratchpadPath = resolveScratchpadPath({
273
+ tier: opts.tier,
274
+ scratchpad: opts.scratchpad,
275
+ projectRoot: opts.projectRoot,
276
+ userDir: opts.userDir,
277
+ });
278
+ const conflict = detectConflicts({
279
+ newText: opts.text,
280
+ newTrust,
281
+ scratchpadPath,
282
+ sectionTitle: opts.section,
283
+ });
284
+ // Defensive guard against a future detectConflicts schema-error
285
+ // path. Today the upstream validator catches bad opts before this
286
+ // call, so action:'error' from detectConflicts is unreachable.
287
+ // The guard is brittleness insurance: a future change to
288
+ // detectConflicts that adds a new schema check (e.g., for malformed
289
+ // existing scratchpad bullets) would otherwise fall through to
290
+ // appendBulletGuarded and silently drop the error.
291
+ if (conflict.action === 'error') {
292
+ return conflict;
293
+ }
294
+ if (conflict.conflict === true && conflict.action === 'queue') {
295
+ // Compute the proposed ID using the same canonical-id derivation
296
+ // appendScratchpadBullet would have used, then route to the queue.
297
+ // (Task 25b fix: generateId is positional `(tier, text)`, not
298
+ // named-args — Task 25 originally called it as an object.)
299
+ const proposedId = generateId(opts.tier, opts.text);
300
+ const ts = opts.now ?? nowIso();
301
+ return writeConflictEntry({
302
+ tier: opts.tier,
303
+ projectRoot: opts.projectRoot,
304
+ userDir: opts.userDir,
305
+ newId: proposedId,
306
+ newText: opts.text,
307
+ newTrust,
308
+ existingId: conflict.existingId,
309
+ existingText: conflict.existingText,
310
+ existingTrust: conflict.existingTrust,
311
+ similarity: conflict.similarity,
312
+ similarityBackend: conflict.similarityBackend,
313
+ detectedAt: ts,
314
+ });
315
+ }
316
+ return appendBulletGuarded(opts);
317
+ }
318
+
319
+ function appendBulletGuarded(opts) {
320
+ // Caller MUST have run Poison_Guard already. This is the inner
321
+ // write step — delegates to the existing scratchpad writer which
322
+ // handles dedup + cap + consolidation + audit + ID derivation.
323
+ const sha1 = createHash('sha1').update(opts.text, 'utf8').digest('hex');
324
+ const ts = opts.now ?? nowIso();
325
+ return appendScratchpadBullet({
326
+ tier: opts.tier,
327
+ scratchpad: opts.scratchpad,
328
+ section: opts.section,
329
+ text: opts.text,
330
+ projectRoot: opts.projectRoot,
331
+ userDir: opts.userDir,
332
+ now: ts,
333
+ settings: opts.settings,
334
+ provenance: {
335
+ source: opts.sessionId
336
+ ? `${opts.source}-${opts.sessionId}`
337
+ : opts.source,
338
+ source_line: 1,
339
+ sha1,
340
+ write: opts.source === 'auto-extract' ? 'auto-extract' : 'user-explicit',
341
+ trust: opts.trust ?? 'high',
342
+ at: ts,
343
+ },
344
+ });
345
+ }
346
+
347
+ // --- Action: replace -----------------------------------------------
348
+
349
+ function doReplace(opts) {
350
+ const errors = [];
351
+ validateText(opts, errors);
352
+ if (!opts.oldText || typeof opts.oldText !== 'string') {
353
+ errors.push('oldText: required for action=replace, non-empty string');
354
+ }
355
+ if (errors.length > 0) {
356
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
357
+ }
358
+ // Poison_Guard against the NEW text — never accept a write whose
359
+ // replacement contains a secret.
360
+ const poisonResult = runPoisonGuard({
361
+ text: opts.text,
362
+ projectRoot: opts.projectRoot,
363
+ source: opts.source,
364
+ sessionId: opts.sessionId,
365
+ now: opts.now,
366
+ });
367
+ if (poisonResult) return poisonResult;
368
+
369
+ const path = resolveScratchpadPath({
370
+ tier: opts.tier,
371
+ scratchpad: opts.scratchpad,
372
+ projectRoot: opts.projectRoot,
373
+ userDir: opts.userDir,
374
+ });
375
+ if (!existsSync(path)) {
376
+ return errorResult({
377
+ category: ERROR_CATEGORIES.NOT_FOUND,
378
+ errors: [`scratchpad does not exist: ${path}`],
379
+ path,
380
+ });
381
+ }
382
+ const original = readFileSync(path, 'utf8');
383
+ const lines = original.split('\n');
384
+ const match = findMatchingBullet({
385
+ lines,
386
+ substring: opts.oldText,
387
+ sectionTitle: opts.section,
388
+ });
389
+ if (!match) {
390
+ // Canonical "operation failed because target wasn't found" shape:
391
+ // action='error' + errorCategory='not-found'. The bare
392
+ // notFoundResult() shape (action='not-found') is for read-side
393
+ // boundaries where "I couldn't find it" is the success path of
394
+ // a lookup. For a write that needed an existing target,
395
+ // matching the user's expectation that a failed replace is an
396
+ // error.
397
+ return errorResult({
398
+ category: ERROR_CATEGORIES.NOT_FOUND,
399
+ errors: [
400
+ `replace: no bullet in ${opts.scratchpad} § ${opts.section} contains substring "${opts.oldText}"`,
401
+ ],
402
+ path,
403
+ });
404
+ }
405
+ // Strip the matched bullet + provenance comment.
406
+ const stripped = [
407
+ ...lines.slice(0, match.bulletIdx),
408
+ ...lines.slice(match.commentIdx + 1),
409
+ ].join('\n');
410
+ writeFileSync(path, stripped, 'utf8');
411
+
412
+ // Append the new bullet via the GUARDED inner path. We already ran
413
+ // Poison_Guard at the top of doReplace — calling doAdd() here
414
+ // would re-run it on the same text, with side effects (NDJSON log
415
+ // writes) that double-count once Poison_Guard becomes settings-
416
+ // driven per design §6.7. The inner appendBulletGuarded() skips
417
+ // the guard since the caller already gated.
418
+ const addResult = appendBulletGuarded({
419
+ ...opts,
420
+ action: 'add',
421
+ text: opts.text,
422
+ });
423
+ if (addResult.action !== 'appended') {
424
+ // Append failed AFTER we stripped the old (e.g. cap_exceeded
425
+ // after consolidation). Roll back: restore the original file so
426
+ // the user isn't left with a partial state.
427
+ writeFileSync(path, original, 'utf8');
428
+ return addResult;
429
+ }
430
+
431
+ // Audit the replace as a curated-merge-style event so analytics
432
+ // can distinguish from raw appends.
433
+ const tierRoot = resolveTierRoot({
434
+ tier: opts.tier,
435
+ projectRoot: opts.projectRoot,
436
+ userDir: opts.userDir,
437
+ });
438
+ appendAuditEntry(tierRoot, {
439
+ ts: opts.now ?? nowIso(),
440
+ action: 'replaced',
441
+ tier: opts.tier,
442
+ id: addResult.id,
443
+ reasonCode: REASON_CODES.CURATED_MERGE,
444
+ reasonText: `replace via memory-write: old=${match.id}`,
445
+ paths: { before: path, after: path },
446
+ extra: { oldId: match.id, newId: addResult.id, scratchpad: opts.scratchpad },
447
+ });
448
+
449
+ return {
450
+ action: 'replaced',
451
+ oldId: match.id,
452
+ newId: addResult.id,
453
+ path: addResult.path,
454
+ };
455
+ }
456
+
457
+ // --- Action: remove ------------------------------------------------
458
+
459
+ function doRemove(opts) {
460
+ const errors = [];
461
+ validateText(opts, errors);
462
+ if (opts.confirmRemove !== true) {
463
+ errors.push(
464
+ 'confirmRemove: must be explicitly true for action=remove (safety gate — tombstoning is irreversible from user perspective)',
465
+ );
466
+ }
467
+ if (errors.length > 0) {
468
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
469
+ }
470
+
471
+ const path = resolveScratchpadPath({
472
+ tier: opts.tier,
473
+ scratchpad: opts.scratchpad,
474
+ projectRoot: opts.projectRoot,
475
+ userDir: opts.userDir,
476
+ });
477
+ if (!existsSync(path)) {
478
+ return errorResult({
479
+ category: ERROR_CATEGORIES.NOT_FOUND,
480
+ errors: [`scratchpad does not exist: ${path}`],
481
+ path,
482
+ });
483
+ }
484
+ const original = readFileSync(path, 'utf8');
485
+ const lines = original.split('\n');
486
+ const match = findMatchingBullet({
487
+ lines,
488
+ substring: opts.text,
489
+ sectionTitle: opts.section,
490
+ });
491
+ if (!match) {
492
+ return errorResult({
493
+ category: ERROR_CATEGORIES.NOT_FOUND,
494
+ errors: [
495
+ `remove: no bullet in ${opts.scratchpad} § ${opts.section} contains substring "${opts.text}"`,
496
+ ],
497
+ path,
498
+ });
499
+ }
500
+
501
+ const tierRoot = resolveTierRoot({
502
+ tier: opts.tier,
503
+ projectRoot: opts.projectRoot,
504
+ userDir: opts.userDir,
505
+ });
506
+ const ts = opts.now ?? nowIso();
507
+ const tombstonePath = writeTombstone({
508
+ tierRoot,
509
+ id: match.id,
510
+ bulletText: match.bulletText,
511
+ commentLine: match.commentLine,
512
+ deletedAt: ts,
513
+ deletedReason: `user said: forget about "${opts.text}"`,
514
+ deletedBy: opts.source === 'auto-extract' ? 'auto-extract' : 'user-explicit',
515
+ });
516
+
517
+ // Strip from scratchpad.
518
+ const stripped = [
519
+ ...lines.slice(0, match.bulletIdx),
520
+ ...lines.slice(match.commentIdx + 1),
521
+ ].join('\n');
522
+ writeFileSync(path, stripped, 'utf8');
523
+
524
+ appendAuditEntry(tierRoot, {
525
+ ts,
526
+ action: 'tombstoned',
527
+ tier: opts.tier,
528
+ id: match.id,
529
+ reasonCode: REASON_CODES.USER_REQUESTED,
530
+ reasonText: `tombstone via memory-write remove: substring="${opts.text}"`,
531
+ paths: { before: path, archive: tombstonePath },
532
+ extra: { scratchpad: opts.scratchpad },
533
+ });
534
+
535
+ return {
536
+ action: 'tombstoned',
537
+ id: match.id,
538
+ path,
539
+ tombstonePath,
540
+ };
541
+ }
542
+
543
+ // --- Public boundary -----------------------------------------------
544
+
545
+ export function memoryWrite(opts = {}) {
546
+ const errors = validateCommon(opts);
547
+ if (errors.length > 0) {
548
+ return errorResult({ category: ERROR_CATEGORIES.SCHEMA, errors });
549
+ }
550
+ switch (opts.action) {
551
+ case 'add':
552
+ return doAdd(opts);
553
+ case 'replace':
554
+ return doReplace(opts);
555
+ case 'remove':
556
+ return doRemove(opts);
557
+ default:
558
+ // Unreachable — validateCommon catches unknown actions — but
559
+ // be explicit so the switch is exhaustive.
560
+ return errorResult({
561
+ category: ERROR_CATEGORIES.SCHEMA,
562
+ errors: [`unknown action: ${JSON.stringify(opts.action)}`],
563
+ });
564
+ }
565
+ }