@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.
- package/bin/cmk-compress-lazy.mjs +59 -0
- package/bin/cmk-daily-distill.mjs +67 -0
- package/bin/cmk-weekly-curate.mjs +56 -0
- package/bin/cmk.mjs +12 -0
- package/package.json +50 -0
- package/src/audit-log.mjs +103 -0
- package/src/auto-extract.mjs +742 -0
- package/src/capture-prompt.mjs +61 -0
- package/src/capture-turn.mjs +273 -0
- package/src/claude-md.mjs +212 -0
- package/src/compress-session.mjs +349 -0
- package/src/compressor.mjs +376 -0
- package/src/conflict-queue.mjs +796 -0
- package/src/cooldown.mjs +61 -0
- package/src/daily-distill.mjs +252 -0
- package/src/doctor.mjs +528 -0
- package/src/forget.mjs +335 -0
- package/src/frontmatter.mjs +73 -0
- package/src/import-anthropic-memory.mjs +266 -0
- package/src/index-db.mjs +154 -0
- package/src/index-rebuild.mjs +597 -0
- package/src/index.mjs +90 -0
- package/src/inject-context.mjs +484 -0
- package/src/install.mjs +327 -0
- package/src/lazy-compress.mjs +326 -0
- package/src/lock-discipline.mjs +166 -0
- package/src/mcp-server.mjs +498 -0
- package/src/memory-write.mjs +565 -0
- package/src/merge-facts.mjs +213 -0
- package/src/observe-edit.mjs +87 -0
- package/src/platform-commands.mjs +138 -0
- package/src/poison-guard.mjs +245 -0
- package/src/privacy.mjs +21 -0
- package/src/provenance.mjs +217 -0
- package/src/register-crons.mjs +354 -0
- package/src/reindex.mjs +134 -0
- package/src/repair.mjs +316 -0
- package/src/result-shapes.mjs +155 -0
- package/src/review-queue.mjs +345 -0
- package/src/roll.mjs +115 -0
- package/src/scratchpad.mjs +335 -0
- package/src/search.mjs +311 -0
- package/src/subcommands.mjs +1252 -0
- package/src/tier-paths.mjs +74 -0
- package/src/transcripts.mjs +234 -0
- package/src/trust.mjs +226 -0
- package/src/weekly-curate.mjs +454 -0
- package/src/write-fact.mjs +205 -0
- package/template/.claude/hooks/pre-tool-memory.js +78 -0
- package/template/.claude/hooks/transcript-capture.js +69 -0
- package/template/.claude/settings.json +27 -0
- package/template/.claude/skills/memory-write/SKILL.md +117 -0
- package/template/.gitignore.fragment +12 -0
- package/template/CLAUDE.md.template +49 -0
- package/template/docs/journey/journey-log.md.template +292 -0
- package/template/local/machine-paths.md.template +37 -0
- package/template/local/overrides.md.template +36 -0
- package/template/project/.index/.gitkeep +0 -0
- package/template/project/MEMORY.md.template +47 -0
- package/template/project/SOUL.md.template +35 -0
- package/template/project/memory/INDEX.md.template +47 -0
- package/template/project/memory/archive/superseded/.gitkeep +0 -0
- package/template/project/memory/archive/tombstones/.gitkeep +0 -0
- package/template/project/queues/.gitkeep +0 -0
- package/template/project/sessions/.gitkeep +0 -0
- package/template/project/transcripts/.gitkeep +0 -0
- package/template/support/cron-jobs/daily-memory-distill.md +15 -0
- package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
- package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
- package/template/support/milvus-deploy/README.md +57 -0
- package/template/support/milvus-deploy/docker-compose.yml +66 -0
- package/template/support/scripts/auto-extract-memory.sh +102 -0
- package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
- package/template/support/scripts/refresh-distill-timestamp.py +35 -0
- package/template/support/scripts/register-crons.py +242 -0
- package/template/support/scripts/run-daily-distill.sh +67 -0
- package/template/support/scripts/run-weekly-curate.sh +58 -0
- package/template/user/HABITS.md.template +18 -0
- package/template/user/LESSONS.md.template +18 -0
- package/template/user/USER.md.template +18 -0
- 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
|
+
}
|