@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,796 @@
1
+ // conflict-queue.mjs — Task 25 (T-022). Public boundary for the
2
+ // conflict-detection + queue-write + interactive-resolve flow.
3
+ //
4
+ // Per design §6.8:
5
+ // - The review queue (§6.2) handles medium-trust *new* writes
6
+ // awaiting blessing.
7
+ // - The conflict queue (this module) handles writes that
8
+ // CONTRADICT an existing high-trust fact on the same
9
+ // heading_path. Different concern, different queue, different UX.
10
+ //
11
+ // Public surface:
12
+ // - detectConflicts({...}) — pre-write check: does the new bullet
13
+ // conflict with an existing one on the same heading_path?
14
+ // - writeConflictEntry({...}) — appends a pending entry to
15
+ // <tierRoot>/queues/conflicts.md when the new write has LOWER
16
+ // trust than the existing fact (the user must resolve manually).
17
+ // - resolveConflictQueue({...}) — interactive walker that processes
18
+ // pending entries one-at-a-time (keep-old / keep-new / merge-both /
19
+ // skip).
20
+ //
21
+ // Trust ordering: high (3) > medium (2) > low (1). Higher wins.
22
+ // - new.trust >= existing.trust → supersede silently (the new
23
+ // write becomes canonical; old marked superseded — handled by
24
+ // memory-write's existing replace flow, NOT this module)
25
+ // - new.trust < existing.trust → queue (write to conflicts.md;
26
+ // surfaces in `cmk queue conflicts`)
27
+ //
28
+ // Similarity backends (per design §6.8):
29
+ // - Layer 5 FTS5 + optional vector: not in v0.1 scope. Injectable
30
+ // similarityFn hook for v0.1.x.
31
+ // - Substring-match fallback (the v0.1 default): token-Jaccard
32
+ // similarity on lowercased word tokens. Conservative,
33
+ // deterministic, no external deps. The audit-log entry records
34
+ // `similarity_backend: 'substring'` so v0.1.x can swap in FTS5
35
+ // and analytics can compare backends.
36
+ //
37
+ // Uses shared modules per CLAUDE.md "Shared modules" rule:
38
+ // tier-paths.mjs — resolveTierRoot
39
+ // audit-log.mjs — nowIso, appendAuditEntry
40
+ // result-shapes.mjs — ERROR_CATEGORIES, errorResult
41
+ // frontmatter.mjs — parse (for bullet provenance comment)
42
+
43
+ import {
44
+ existsSync,
45
+ mkdirSync,
46
+ readFileSync,
47
+ appendFileSync,
48
+ writeFileSync,
49
+ } from 'node:fs';
50
+ import { join } from 'node:path';
51
+ import { resolveTierRoot, VALID_TIERS } from './tier-paths.mjs';
52
+ import { nowIso, appendAuditEntry, REASON_CODES } from './audit-log.mjs';
53
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
54
+ import { generateId } from '@lh8ppl/cmk-canonicalize';
55
+
56
+ // Trust ordering. Higher number = higher trust.
57
+ const TRUST_LEVELS = Object.freeze({
58
+ high: 3,
59
+ medium: 2,
60
+ low: 1,
61
+ });
62
+
63
+ // Similarity thresholds vary by backend:
64
+ // - 'substring' (token-Jaccard fallback): 0.5 — lexical similarity
65
+ // under-reports semantic similarity ("Python 3.13" vs "Python 3.14"
66
+ // scores ~0.71 by Jaccard despite being a clear conflict).
67
+ // Calibrated empirically against real conflict cases.
68
+ // - 'custom' (FTS5 + vector, v0.1.x): 0.85 per design §6.8 — semantic
69
+ // similarity is more reliable. The caller passes
70
+ // `similarityThreshold: 0.85` when wiring in the FTS5 backend.
71
+ //
72
+ // Callers can always override via the `similarityThreshold` option.
73
+ const DEFAULT_SUBSTRING_THRESHOLD = 0.5;
74
+ const DEFAULT_SEMANTIC_THRESHOLD = 0.85;
75
+ const QUEUE_RELATIVE = ['queues', 'conflicts.md'];
76
+ const QUEUE_HEADER = '# Conflicts queue\n\n';
77
+
78
+ // --- Similarity backends -------------------------------------------
79
+
80
+ /**
81
+ * Token-Jaccard similarity. Lowercase, split on whitespace + common
82
+ * punctuation, dedupe. Returns 0..1.
83
+ *
84
+ * This is the substring-match fallback — "substring" is the
85
+ * audit-log label per design §6.8 even though we use Jaccard internally,
86
+ * because the SEMANTIC layer (FTS5 / vector embeddings) is what gets
87
+ * swapped in for v0.1.x. The substring/Jaccard fallback is "no semantic
88
+ * backend; lexical only".
89
+ */
90
+ export function tokenJaccardSimilarity(a, b) {
91
+ if (typeof a !== 'string' || typeof b !== 'string') return 0;
92
+ if (a === b) return 1;
93
+ const tokenize = (s) =>
94
+ new Set(
95
+ s
96
+ .toLowerCase()
97
+ .split(/[\s.,!?;:()[\]{}'"`/\\-]+/u)
98
+ .filter((t) => t.length > 0),
99
+ );
100
+ const ta = tokenize(a);
101
+ const tb = tokenize(b);
102
+ if (ta.size === 0 && tb.size === 0) return 1;
103
+ if (ta.size === 0 || tb.size === 0) return 0;
104
+ let intersect = 0;
105
+ for (const t of ta) if (tb.has(t)) intersect++;
106
+ const union = ta.size + tb.size - intersect;
107
+ return intersect / union;
108
+ }
109
+
110
+ // --- Existing-bullet scan -------------------------------------------
111
+
112
+ const BULLET_LINE_RE = /^- \(([PUL])-([A-Za-z0-9]{8})\)\s+(.+)$/;
113
+ const HEADING_LINE_RE = /^##\s+(.+?)\s*$/;
114
+ const PROVENANCE_RE = /^<!--\s*(.*?)\s*-->\s*$/;
115
+
116
+ function findSectionRange(lines, sectionTitle) {
117
+ let startIdx = -1;
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const m = lines[i].match(HEADING_LINE_RE);
120
+ if (m && m[1].trim() === sectionTitle.trim()) {
121
+ startIdx = i + 1;
122
+ break;
123
+ }
124
+ }
125
+ if (startIdx === -1) return null;
126
+ let endIdx = lines.length;
127
+ for (let i = startIdx; i < lines.length; i++) {
128
+ if (HEADING_LINE_RE.test(lines[i])) {
129
+ endIdx = i;
130
+ break;
131
+ }
132
+ }
133
+ return { startIdx, endIdx };
134
+ }
135
+
136
+ // Walks all bullets in the given section (or the whole file if no
137
+ // section). Returns the array of {id, text, trust, lineIdx} entries.
138
+ function collectExistingBullets({ scratchpadText, sectionTitle }) {
139
+ const lines = scratchpadText.split(/\r?\n/);
140
+ const range = sectionTitle
141
+ ? findSectionRange(lines, sectionTitle)
142
+ : { startIdx: 0, endIdx: lines.length };
143
+ if (!range) return [];
144
+ const out = [];
145
+ for (let i = range.startIdx; i < range.endIdx; i++) {
146
+ const bm = lines[i].match(BULLET_LINE_RE);
147
+ if (!bm) continue;
148
+ const [, tier, idShort, bulletText] = bm;
149
+ const id = `${tier}-${idShort}`;
150
+ // Trust lives in the provenance comment on the next line.
151
+ let trust = 'medium';
152
+ const nextLine = lines[i + 1] ?? '';
153
+ const pm = nextLine.match(PROVENANCE_RE);
154
+ if (pm) {
155
+ const tm = pm[1].match(/trust:\s*(high|medium|low)/);
156
+ if (tm) trust = tm[1];
157
+ }
158
+ out.push({ id, text: bulletText, trust, lineIdx: i });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ // --- Public: detectConflicts ---------------------------------------
164
+
165
+ /**
166
+ * Compare a new write against existing bullets in the same
167
+ * scratchpad + section. Three outcomes:
168
+ * - { conflict: false, similarityBackend, scanned: N }
169
+ * - { conflict: true, action: 'supersede', existingId, similarity,
170
+ * similarityBackend }
171
+ * — new.trust >= existing.trust; caller should treat as a
172
+ * replace (kit's existing replace flow handles this; conflict-
173
+ * queue is NOT involved beyond returning the signal).
174
+ * - { conflict: true, action: 'queue', existingId, existingText,
175
+ * existingTrust, similarity, similarityBackend }
176
+ * — new.trust < existing.trust; caller routes via
177
+ * writeConflictEntry.
178
+ */
179
+ export function detectConflicts({
180
+ newText,
181
+ newTrust,
182
+ scratchpadPath,
183
+ sectionTitle,
184
+ similarityFn,
185
+ similarityThreshold,
186
+ } = {}) {
187
+ if (typeof newText !== 'string' || newText.length === 0) {
188
+ return errorResult({
189
+ category: ERROR_CATEGORIES.SCHEMA,
190
+ errors: ['detectConflicts: newText required (non-empty string)'],
191
+ });
192
+ }
193
+ if (!TRUST_LEVELS[newTrust]) {
194
+ return errorResult({
195
+ category: ERROR_CATEGORIES.SCHEMA,
196
+ errors: [`detectConflicts: newTrust required (one of: ${Object.keys(TRUST_LEVELS).join(', ')})`],
197
+ });
198
+ }
199
+ if (typeof scratchpadPath !== 'string') {
200
+ return errorResult({
201
+ category: ERROR_CATEGORIES.SCHEMA,
202
+ errors: ['detectConflicts: scratchpadPath required (string)'],
203
+ });
204
+ }
205
+
206
+ const fn = typeof similarityFn === 'function' ? similarityFn : tokenJaccardSimilarity;
207
+ const similarityBackend = fn === tokenJaccardSimilarity ? 'substring' : 'custom';
208
+ const threshold =
209
+ typeof similarityThreshold === 'number'
210
+ ? similarityThreshold
211
+ : similarityBackend === 'substring'
212
+ ? DEFAULT_SUBSTRING_THRESHOLD
213
+ : DEFAULT_SEMANTIC_THRESHOLD;
214
+
215
+ // Empty scratchpad / missing file → no conflicts possible.
216
+ let scratchpadText = '';
217
+ if (existsSync(scratchpadPath)) {
218
+ scratchpadText = readFileSync(scratchpadPath, 'utf8');
219
+ }
220
+ const bullets = collectExistingBullets({ scratchpadText, sectionTitle });
221
+ if (bullets.length === 0) {
222
+ return { conflict: false, similarityBackend, scanned: 0 };
223
+ }
224
+
225
+ // Find the highest-similarity existing bullet.
226
+ let best = null;
227
+ for (const b of bullets) {
228
+ if (b.text === newText) continue; // exact-match isn't a conflict; the replace flow handles dedup
229
+ const sim = fn(newText, b.text);
230
+ if (!best || sim > best.similarity) {
231
+ best = { ...b, similarity: sim };
232
+ }
233
+ }
234
+
235
+ if (!best || best.similarity < threshold) {
236
+ return { conflict: false, similarityBackend, scanned: bullets.length };
237
+ }
238
+
239
+ // We have a conflict candidate. Decide route based on trust.
240
+ const newRank = TRUST_LEVELS[newTrust];
241
+ const existingRank = TRUST_LEVELS[best.trust] ?? TRUST_LEVELS.medium;
242
+ if (newRank >= existingRank) {
243
+ return {
244
+ conflict: true,
245
+ action: 'supersede',
246
+ existingId: best.id,
247
+ similarity: best.similarity,
248
+ similarityBackend,
249
+ };
250
+ }
251
+ return {
252
+ conflict: true,
253
+ action: 'queue',
254
+ existingId: best.id,
255
+ existingText: best.text,
256
+ existingTrust: best.trust,
257
+ similarity: best.similarity,
258
+ similarityBackend,
259
+ };
260
+ }
261
+
262
+ // --- Public: writeConflictEntry ------------------------------------
263
+
264
+ /**
265
+ * Append a pending entry to <tierRoot>/queues/conflicts.md.
266
+ *
267
+ * Entry shape:
268
+ * - (proposed: P-NEW) "<new bullet text>"
269
+ * conflicts_with: P-EXISTING
270
+ * existing_text: "<existing bullet text>"
271
+ * existing_trust: high
272
+ * new_trust: medium
273
+ * similarity: 0.91
274
+ * similarity_backend: substring
275
+ * detected_at: 2026-05-27T10:00:00Z
276
+ * resolution: pending
277
+ *
278
+ * Returns { action: 'queued', id, conflictsWith, path }.
279
+ * Audit-log entry written via appendAuditEntry.
280
+ */
281
+ export function writeConflictEntry({
282
+ tier,
283
+ projectRoot,
284
+ userDir,
285
+ newId,
286
+ newText,
287
+ newTrust,
288
+ existingId,
289
+ existingText,
290
+ existingTrust,
291
+ similarity,
292
+ similarityBackend,
293
+ detectedAt,
294
+ } = {}) {
295
+ if (!VALID_TIERS.has(tier)) {
296
+ return errorResult({
297
+ category: ERROR_CATEGORIES.SCHEMA,
298
+ errors: [`writeConflictEntry: tier must be one of P/U/L (got ${tier})`],
299
+ });
300
+ }
301
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
302
+ if (!tierRoot) {
303
+ return errorResult({
304
+ category: ERROR_CATEGORIES.SCHEMA,
305
+ errors: ['writeConflictEntry: could not resolve tier root (projectRoot/userDir missing?)'],
306
+ });
307
+ }
308
+ const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
309
+ mkdirSync(join(tierRoot, QUEUE_RELATIVE[0]), { recursive: true });
310
+ const ts = detectedAt ?? nowIso();
311
+
312
+ const entry = [
313
+ `- (proposed: ${newId}) ${JSON.stringify(newText)}`,
314
+ ` conflicts_with: ${existingId}`,
315
+ ` existing_text: ${JSON.stringify(existingText)}`,
316
+ ` existing_trust: ${existingTrust}`,
317
+ ` new_trust: ${newTrust}`,
318
+ ` similarity: ${similarity.toFixed(4)}`,
319
+ ` similarity_backend: ${similarityBackend}`,
320
+ ` detected_at: ${ts}`,
321
+ ` resolution: pending`,
322
+ '',
323
+ ].join('\n');
324
+
325
+ // Initialize the queue file with a header on first write.
326
+ if (!existsSync(queuePath)) {
327
+ writeFileSync(queuePath, QUEUE_HEADER + entry, 'utf8');
328
+ } else {
329
+ appendFileSync(queuePath, entry, 'utf8');
330
+ }
331
+
332
+ appendAuditEntry(tierRoot, {
333
+ ts,
334
+ action: 'queued',
335
+ tier,
336
+ id: newId,
337
+ reasonCode: REASON_CODES.CONFLICT_QUEUED,
338
+ reasonText: `conflict-queue: new write contradicts ${existingId} (similarity=${similarity.toFixed(4)}, backend=${similarityBackend}, new_trust=${newTrust} < existing_trust=${existingTrust})`,
339
+ extra: {
340
+ conflicts_with: existingId,
341
+ similarity,
342
+ similarity_backend: similarityBackend,
343
+ new_trust: newTrust,
344
+ existing_trust: existingTrust,
345
+ },
346
+ });
347
+
348
+ return {
349
+ action: 'queued',
350
+ id: newId,
351
+ conflictsWith: existingId,
352
+ path: queuePath,
353
+ similarity,
354
+ similarityBackend,
355
+ };
356
+ }
357
+
358
+ // --- Public: resolveConflictQueue ----------------------------------
359
+
360
+ const ENTRY_HEADER_RE = /^- \(proposed:\s*([PUL]-[A-Za-z0-9]{8})\)\s+(.+)$/;
361
+ const FIELD_LINE_RE = /^\s+(\w+):\s*(.+)$/;
362
+
363
+ function parseQueue(queueText) {
364
+ const lines = queueText.split(/\r?\n/);
365
+ const entries = [];
366
+ let current = null;
367
+ for (let i = 0; i < lines.length; i++) {
368
+ const line = lines[i];
369
+ const headerMatch = line.match(ENTRY_HEADER_RE);
370
+ if (headerMatch) {
371
+ if (current) entries.push(current);
372
+ let proposedText = headerMatch[2];
373
+ // Strip quotes if JSON.stringify'd
374
+ try {
375
+ proposedText = JSON.parse(proposedText);
376
+ } catch {
377
+ /* leave as-is */
378
+ }
379
+ current = {
380
+ startLineIdx: i,
381
+ endLineIdx: i,
382
+ proposedId: headerMatch[1],
383
+ proposedText,
384
+ fields: {},
385
+ };
386
+ continue;
387
+ }
388
+ if (current) {
389
+ const fieldMatch = line.match(FIELD_LINE_RE);
390
+ if (fieldMatch) {
391
+ let value = fieldMatch[2];
392
+ try {
393
+ value = JSON.parse(value);
394
+ } catch {
395
+ /* leave as-is */
396
+ }
397
+ current.fields[fieldMatch[1]] = value;
398
+ current.endLineIdx = i;
399
+ } else if (line.trim() === '') {
400
+ current.endLineIdx = i;
401
+ } else {
402
+ // Non-empty, non-field line breaks the current entry.
403
+ entries.push(current);
404
+ current = null;
405
+ }
406
+ }
407
+ }
408
+ if (current) entries.push(current);
409
+ return { entries, lines };
410
+ }
411
+
412
+ /**
413
+ * Walk pending conflict entries one-at-a-time. The `prompter` is a
414
+ * caller-supplied function: ({proposedEntry, existingEntry}) → 'keep-old' | 'keep-new' | 'merge-both' | 'skip'.
415
+ * Lets the CLI / tests inject behavior; production wires through the
416
+ * interactive `cmk queue conflicts` verb.
417
+ *
418
+ * `mergeFn` is invoked on 'merge-both' actions. Tests can inject a
419
+ * stub; production wires to mergeFacts() from merge-facts.mjs.
420
+ *
421
+ * Returns { resolved: N, kept_old: N, kept_new: N, merged: N, skipped: N }.
422
+ */
423
+ export async function resolveConflictQueue({
424
+ tier,
425
+ projectRoot,
426
+ userDir,
427
+ prompter,
428
+ mergeFn,
429
+ } = {}) {
430
+ if (!VALID_TIERS.has(tier)) {
431
+ return errorResult({
432
+ category: ERROR_CATEGORIES.SCHEMA,
433
+ errors: [`resolveConflictQueue: tier must be one of P/U/L (got ${tier})`],
434
+ });
435
+ }
436
+ if (typeof prompter !== 'function') {
437
+ return errorResult({
438
+ category: ERROR_CATEGORIES.SCHEMA,
439
+ errors: ['resolveConflictQueue: prompter required (function)'],
440
+ });
441
+ }
442
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
443
+ const queuePath = join(tierRoot, ...QUEUE_RELATIVE);
444
+ if (!existsSync(queuePath)) {
445
+ return { resolved: 0, kept_old: 0, kept_new: 0, merged: 0, skipped: 0 };
446
+ }
447
+
448
+ const queueText = readFileSync(queuePath, 'utf8');
449
+ const { entries } = parseQueue(queueText);
450
+ const pending = entries.filter((e) => e.fields.resolution === 'pending');
451
+
452
+ let kept_old = 0;
453
+ let kept_new = 0;
454
+ let merged = 0;
455
+ let skipped = 0;
456
+
457
+ // Rewriting strategy: build a new queue file from scratch with
458
+ // resolved entries marked + skipped entries kept as pending.
459
+ const newEntryLines = [];
460
+ for (const entry of entries) {
461
+ if (entry.fields.resolution !== 'pending') {
462
+ // Already resolved — preserve as-is.
463
+ newEntryLines.push(serializeEntry(entry));
464
+ continue;
465
+ }
466
+ const decision = await prompter({
467
+ proposedId: entry.proposedId,
468
+ proposedText: entry.proposedText,
469
+ proposedTrust: entry.fields.new_trust,
470
+ existingId: entry.fields.conflicts_with,
471
+ existingText: entry.fields.existing_text,
472
+ existingTrust: entry.fields.existing_trust,
473
+ similarity: entry.fields.similarity,
474
+ detectedAt: entry.fields.detected_at,
475
+ });
476
+
477
+ if (decision === 'skip') {
478
+ skipped++;
479
+ newEntryLines.push(serializeEntry(entry));
480
+ continue;
481
+ }
482
+
483
+ // Resolved: rewrite the entry's resolution field.
484
+ const resolvedAt = nowIso();
485
+ if (decision === 'keep-old') {
486
+ kept_old++;
487
+ } else if (decision === 'keep-new') {
488
+ kept_new++;
489
+ } else if (decision === 'merge-both') {
490
+ merged++;
491
+ if (typeof mergeFn === 'function') {
492
+ await mergeFn({
493
+ tier,
494
+ projectRoot,
495
+ userDir,
496
+ proposedId: entry.proposedId,
497
+ proposedText: entry.proposedText,
498
+ existingId: entry.fields.conflicts_with,
499
+ existingText: entry.fields.existing_text,
500
+ });
501
+ }
502
+ } else {
503
+ // Unknown decision — preserve as pending; let the next pass try.
504
+ skipped++;
505
+ newEntryLines.push(serializeEntry(entry));
506
+ continue;
507
+ }
508
+
509
+ appendAuditEntry(tierRoot, {
510
+ ts: resolvedAt,
511
+ action: 'resolved',
512
+ tier,
513
+ id: entry.proposedId,
514
+ reasonCode: REASON_CODES.CONFLICT_RESOLVED,
515
+ reasonText: `conflict-queue: ${decision} on ${entry.proposedId} (conflicts_with=${entry.fields.conflicts_with})`,
516
+ extra: {
517
+ decision,
518
+ conflicts_with: entry.fields.conflicts_with,
519
+ },
520
+ });
521
+
522
+ const resolved = {
523
+ ...entry,
524
+ fields: {
525
+ ...entry.fields,
526
+ resolution: decision,
527
+ resolved_at: resolvedAt,
528
+ },
529
+ };
530
+ newEntryLines.push(serializeEntry(resolved));
531
+ }
532
+
533
+ // Rewrite the queue file.
534
+ const body = QUEUE_HEADER + newEntryLines.join('');
535
+ writeFileSync(queuePath, body, 'utf8');
536
+
537
+ return {
538
+ resolved: kept_old + kept_new + merged,
539
+ kept_old,
540
+ kept_new,
541
+ merged,
542
+ skipped,
543
+ };
544
+ }
545
+
546
+ function serializeEntry(entry) {
547
+ const fieldOrder = [
548
+ 'conflicts_with',
549
+ 'existing_text',
550
+ 'existing_trust',
551
+ 'new_trust',
552
+ 'similarity',
553
+ 'similarity_backend',
554
+ 'detected_at',
555
+ 'resolution',
556
+ 'resolved_at',
557
+ ];
558
+ const lines = [`- (proposed: ${entry.proposedId}) ${JSON.stringify(entry.proposedText)}`];
559
+ for (const key of fieldOrder) {
560
+ if (!(key in entry.fields)) continue;
561
+ const value = entry.fields[key];
562
+ const formatted =
563
+ typeof value === 'string' && /["\s]|^\d/.test(value) && !/^-?\d+(\.\d+)?$/.test(value)
564
+ ? JSON.stringify(value)
565
+ : value;
566
+ lines.push(` ${key}: ${formatted}`);
567
+ }
568
+ lines.push('');
569
+ return lines.join('\n');
570
+ }
571
+
572
+ // --- Public: mergeScratchpadBullets (Task 25b) ---------------------
573
+ //
574
+ // Layer-3 scratchpad-bullet merger. Closes Task 25's cross-layer
575
+ // composition gap: `mergeFacts` (Layer-2 per-fact files) cannot
576
+ // operate on scratchpad bullets routed to `queues/conflicts.md`
577
+ // without first materializing them as fact files. This function
578
+ // stays at Layer 3 — it reads two bullets from a scratchpad, writes
579
+ // a combined third bullet, and marks both originals with
580
+ // `superseded_by: <newId>` in their provenance comments.
581
+ //
582
+ // Semantics per design §6.8 (updated by Task 25b):
583
+ // - Combined text: " | "-joined (`textA | textB`). Identical
584
+ // texts collapse to a single bullet (skipping the merge would
585
+ // be reasonable but doesn't fit the `merge-both` UX — the user
586
+ // asked to merge, so we still produce a unified bullet).
587
+ // - New canonical ID: `generateId(tier, combinedText)` per the
588
+ // kit's content-addressed convention.
589
+ // - New provenance: `source: merge-both, merged_from: [idA, idB],
590
+ // merged_at: <ISO>, trust: max(trustA, trustB)`.
591
+ // - Originals: existing provenance comments get `superseded_by:
592
+ // <newId>` appended (lighter than `forget`/tombstone; the
593
+ // bullets remain in the scratchpad but read as derived-from).
594
+ //
595
+ // Returns { action: 'merged', id, supersededIds: [idA, idB], path }
596
+ // OR an errorResult if a bullet can't be located.
597
+
598
+ const TRUST_MAX = ['low', 'medium', 'high']; // index = rank
599
+
600
+ function pickHigherTrust(a, b) {
601
+ const ra = TRUST_MAX.indexOf(a);
602
+ const rb = TRUST_MAX.indexOf(b);
603
+ if (ra === -1 && rb === -1) return 'medium';
604
+ if (ra >= rb) return a ?? 'medium';
605
+ return b ?? 'medium';
606
+ }
607
+
608
+ function findBulletById(lines, id) {
609
+ for (let i = 0; i < lines.length; i++) {
610
+ const m = lines[i].match(BULLET_LINE_RE);
611
+ if (!m) continue;
612
+ if (`${m[1]}-${m[2]}` === id) {
613
+ return { bulletIdx: i, commentIdx: i + 1, tier: m[1], text: m[3] };
614
+ }
615
+ }
616
+ return null;
617
+ }
618
+
619
+ // Discover the section heading immediately above the bullet at
620
+ // `bulletIdx`. Returns the heading title (e.g., "Decisions") or null
621
+ // if the bullet is above any heading. Used by `mergeScratchpadBullets`
622
+ // when the caller doesn't pass an explicit `section` (the conflict-
623
+ // queue resolver doesn't store section in queue entries — see the
624
+ // CLI mergeFn caller pattern).
625
+ function discoverSectionAt(lines, bulletIdx) {
626
+ for (let i = bulletIdx - 1; i >= 0; i--) {
627
+ const m = lines[i].match(HEADING_LINE_RE);
628
+ if (m) return m[1].trim();
629
+ }
630
+ return null;
631
+ }
632
+
633
+ function injectSupersededBy(commentLine, newId) {
634
+ // Append `superseded_by: <newId>` to the existing provenance comment.
635
+ // If the comment already has a `superseded_by:`, replace it (last
636
+ // write wins). This is CORRECT for chained supersede graphs (A→C
637
+ // then C→E preserves traversability because A's marker still
638
+ // points at C, and C's marker points at E). The chain is
639
+ // discoverable by following supersede pointers forward without
640
+ // history loss.
641
+ const m = commentLine.match(PROVENANCE_RE);
642
+ if (!m) return commentLine; // not a provenance comment; leave untouched
643
+ const body = m[1];
644
+ if (/\bsuperseded_by\s*:/.test(body)) {
645
+ return commentLine.replace(/superseded_by\s*:\s*[\w-]+/, `superseded_by: ${newId}`);
646
+ }
647
+ return `<!-- ${body}, superseded_by: ${newId} -->`;
648
+ }
649
+
650
+ function parseProvenanceTrust(commentLine) {
651
+ const m = commentLine?.match(PROVENANCE_RE);
652
+ if (!m) return 'medium';
653
+ const tm = m[1].match(/trust\s*:\s*(high|medium|low)/);
654
+ return tm ? tm[1] : 'medium';
655
+ }
656
+
657
+ /**
658
+ * Merge two scratchpad bullets into a third combined bullet.
659
+ *
660
+ * Side effects:
661
+ * - Mutates `scratchpadPath` (rewrites file in place)
662
+ * - Appends an audit-log entry with reasonCode `CONFLICT_RESOLVED`
663
+ * (extra: { decision: 'merge-both', merged_from: [idA, idB] })
664
+ *
665
+ * Pre-conditions:
666
+ * - Both bullets exist in the scratchpad with matching IDs
667
+ * - `tier` matches the bullets' tier prefix
668
+ * - `section` is the heading the merged bullet belongs in (caller
669
+ * supplies the same section used by detectConflicts)
670
+ *
671
+ * Returns:
672
+ * - { action: 'merged', id, supersededIds, path } on success
673
+ * - errorResult on missing bullet / invalid input
674
+ */
675
+ export function mergeScratchpadBullets({
676
+ tier,
677
+ projectRoot,
678
+ userDir,
679
+ scratchpadPath,
680
+ section,
681
+ idA,
682
+ idB,
683
+ separator = ' | ',
684
+ now,
685
+ } = {}) {
686
+ if (!VALID_TIERS.has(tier)) {
687
+ return errorResult({
688
+ category: ERROR_CATEGORIES.SCHEMA,
689
+ errors: [`mergeScratchpadBullets: tier must be one of P/U/L (got ${tier})`],
690
+ });
691
+ }
692
+ if (!idA || !idB) {
693
+ return errorResult({
694
+ category: ERROR_CATEGORIES.SCHEMA,
695
+ errors: ['mergeScratchpadBullets: idA and idB required'],
696
+ });
697
+ }
698
+ // Parallel to mergeFacts's same-id check — merging a bullet with
699
+ // itself would inject superseded_by twice on the same line and
700
+ // emit nonsense audit data (`merged_from: [idA, idA]`).
701
+ if (idA === idB) {
702
+ return errorResult({
703
+ category: ERROR_CATEGORIES.SCHEMA,
704
+ errors: [`mergeScratchpadBullets: idA and idB are the same (${idA}); cannot merge a bullet with itself`],
705
+ });
706
+ }
707
+ if (typeof scratchpadPath !== 'string') {
708
+ return errorResult({
709
+ category: ERROR_CATEGORIES.SCHEMA,
710
+ errors: ['mergeScratchpadBullets: scratchpadPath required (string)'],
711
+ });
712
+ }
713
+ if (!existsSync(scratchpadPath)) {
714
+ return errorResult({
715
+ category: ERROR_CATEGORIES.NOT_FOUND,
716
+ errors: [`mergeScratchpadBullets: scratchpad does not exist: ${scratchpadPath}`],
717
+ });
718
+ }
719
+
720
+ const text = readFileSync(scratchpadPath, 'utf8');
721
+ const lines = text.split(/\r?\n/);
722
+ const matchA = findBulletById(lines, idA);
723
+ const matchB = findBulletById(lines, idB);
724
+ if (!matchA || !matchB) {
725
+ return errorResult({
726
+ category: ERROR_CATEGORIES.NOT_FOUND,
727
+ errors: [
728
+ !matchA ? `idA not found in scratchpad: ${idA}` : null,
729
+ !matchB ? `idB not found in scratchpad: ${idB}` : null,
730
+ ].filter(Boolean),
731
+ });
732
+ }
733
+
734
+ // Compute combined text + new canonical ID.
735
+ const combinedText =
736
+ matchA.text === matchB.text ? matchA.text : `${matchA.text}${separator}${matchB.text}`;
737
+ let newId = generateId(tier, combinedText);
738
+ // Self-supersede prevention (code-review IMP-2): when both bullets
739
+ // have identical text (rare — manual edit, or two auto-extract turns
740
+ // producing the same canonicalized form), `combinedText` equals each
741
+ // original text, so `generateId(tier, combinedText) === idA === idB`.
742
+ // Injecting `superseded_by: <newId>` into idA's provenance would
743
+ // make the bullet supersede itself. Append a merge-discriminator
744
+ // so the canonical id differs from both inputs.
745
+ if (newId === idA || newId === idB) {
746
+ newId = generateId(tier, `${combinedText} [merge-of: ${idA},${idB}]`);
747
+ }
748
+ const ts = now ?? nowIso();
749
+
750
+ // Mutate the two originals' provenance comments to inject `superseded_by`.
751
+ const updatedLines = lines.slice();
752
+ updatedLines[matchA.commentIdx] = injectSupersededBy(updatedLines[matchA.commentIdx], newId);
753
+ updatedLines[matchB.commentIdx] = injectSupersededBy(updatedLines[matchB.commentIdx], newId);
754
+
755
+ // Pick the higher of the two originals' trust as the merged bullet's
756
+ // trust (cautious — caller can override via a second pass if needed).
757
+ const trustA = parseProvenanceTrust(lines[matchA.commentIdx]);
758
+ const trustB = parseProvenanceTrust(lines[matchB.commentIdx]);
759
+ const mergedTrust = pickHigherTrust(trustA, trustB);
760
+
761
+ // Append the new bullet + provenance to the section. If the caller
762
+ // didn't pass an explicit `section` (the CLI resolver path), discover
763
+ // it from idA's position in the scratchpad — the new bullet lands in
764
+ // the same heading as the originals it supersedes.
765
+ const effectiveSection = section ?? discoverSectionAt(lines, matchA.bulletIdx);
766
+ const range = effectiveSection ? findSectionRange(updatedLines, effectiveSection) : null;
767
+ const insertAt = range ? range.endIdx : updatedLines.length;
768
+ const newBullet = `- (${newId}) ${combinedText}`;
769
+ const newProvenance = `<!-- source: merge-both, merged_from: [${idA}, ${idB}], merged_at: ${ts}, trust: ${mergedTrust} -->`;
770
+ updatedLines.splice(insertAt, 0, newBullet, newProvenance, '');
771
+
772
+ writeFileSync(scratchpadPath, updatedLines.join('\n'), 'utf8');
773
+
774
+ // Audit-log entry.
775
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
776
+ appendAuditEntry(tierRoot, {
777
+ ts,
778
+ action: 'merged',
779
+ tier,
780
+ id: newId,
781
+ reasonCode: REASON_CODES.CURATED_MERGE,
782
+ reasonText: `mergeScratchpadBullets: ${idA} + ${idB} → ${newId} (merge-both via conflict-queue)`,
783
+ extra: {
784
+ decision: 'merge-both',
785
+ merged_from: [idA, idB],
786
+ merged_trust: mergedTrust,
787
+ },
788
+ });
789
+
790
+ return {
791
+ action: 'merged',
792
+ id: newId,
793
+ supersededIds: [idA, idB],
794
+ path: scratchpadPath,
795
+ };
796
+ }