@smartmemory/compose 0.1.5-beta → 0.1.7-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,647 @@
1
+ /**
2
+ * changelog-writer.js — typed writer + reader for compose/CHANGELOG.md.
3
+ *
4
+ * Sub-ticket #3 of COMP-MCP-FEATURE-MGMT (COMP-MCP-CHANGELOG-WRITER).
5
+ *
6
+ * Two operations:
7
+ * addChangelogEntry(cwd, args) — render + insert/replace one entry, atomic write
8
+ * getChangelogEntries(cwd, opts) — read, filter by code/since, limit
9
+ *
10
+ * Plus exported helpers for tests / future COMP-MCP-VALIDATE:
11
+ * parseChangelog(text)
12
+ * renderEntry({ code, summary, body, sections })
13
+ *
14
+ * Reuses the writer framework: caller-supplied idempotency_key via
15
+ * lib/idempotency.js, audit log via lib/feature-events.js, code validation +
16
+ * safeAppendEvent pattern from lib/feature-writer.js. Atomic write mirrors
17
+ * lib/sections.js writeRollup.
18
+ *
19
+ * No HTTP. Pure file IO so the same writers are callable from MCP tools, the
20
+ * CLI, or future REST routes.
21
+ */
22
+
23
+ import {
24
+ readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync,
25
+ } from 'node:fs';
26
+ import { join, dirname } from 'node:path';
27
+
28
+ import { appendEvent, normalizeSince } from './feature-events.js';
29
+ import { checkOrInsert } from './idempotency.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Validation helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
36
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
37
+ const VERSION_RE = /^v\d+\.\d+\.\d+$/;
38
+ const KNOWN_SECTIONS = new Set(['added', 'changed', 'fixed', 'snapshot']);
39
+ const SUBSECTION_ORDER = ['added', 'changed', 'fixed', 'snapshot'];
40
+ const SUBSECTION_LABELS = {
41
+ added: 'Added',
42
+ changed: 'Changed',
43
+ fixed: 'Fixed',
44
+ snapshot: 'Snapshot',
45
+ };
46
+
47
+ function inputError(message) {
48
+ const err = new Error(message);
49
+ err.code = 'INVALID_INPUT';
50
+ return err;
51
+ }
52
+
53
+ function formatError(message) {
54
+ const err = new Error(message);
55
+ err.code = 'CHANGELOG_FORMAT';
56
+ return err;
57
+ }
58
+
59
+ function validateCode(code) {
60
+ if (typeof code !== 'string' || !FEATURE_CODE_RE.test(code)) {
61
+ throw inputError(`changelog-writer: invalid feature code "${code}" — must match ${FEATURE_CODE_RE}`);
62
+ }
63
+ }
64
+
65
+ function validateDateOrVersion(value) {
66
+ if (typeof value !== 'string' || (!DATE_RE.test(value) && !VERSION_RE.test(value))) {
67
+ throw inputError(`changelog-writer: invalid date_or_version "${value}" — must match YYYY-MM-DD or vX.Y.Z`);
68
+ }
69
+ }
70
+
71
+ function validateSummary(summary) {
72
+ if (typeof summary !== 'string' || summary.trim().length === 0) {
73
+ throw inputError('changelog-writer: summary is required');
74
+ }
75
+ }
76
+
77
+ function validateSections(sections) {
78
+ if (sections === undefined || sections === null) return;
79
+ if (typeof sections !== 'object' || Array.isArray(sections)) {
80
+ throw inputError('changelog-writer: sections must be an object');
81
+ }
82
+ for (const [k, v] of Object.entries(sections)) {
83
+ if (!KNOWN_SECTIONS.has(k)) {
84
+ throw inputError(`changelog-writer: invalid sections key "${k}" — must be one of ${[...KNOWN_SECTIONS].join(', ')}`);
85
+ }
86
+ if (!Array.isArray(v) || v.some(item => typeof item !== 'string')) {
87
+ throw inputError(`changelog-writer: sections.${k} must be a string[]`);
88
+ }
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Parser
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const H1_RE = /^# Changelog\s*$/;
97
+ const SURFACE_RE = /^## (.+?)\s*$/;
98
+ const ENTRY_HEADER_RE = /^### ([A-Z][A-Z0-9-]*[A-Z0-9])\s+—\s+(.+?)\s*$/;
99
+ // Permissive subsection label: starts with a letter, contains any non-`*`/`:`
100
+ // thereafter. Tolerates digits, spaces, hyphens (e.g. `**Phase 7 review-loop fixes:**`).
101
+ const SUBSECTION_RE = /^\*\*([A-Za-z][^*:]*):\*\*\s*$/;
102
+ const BULLET_RE = /^- (.+)$/;
103
+
104
+ /**
105
+ * parseChangelog(text) — single-pass tolerant parser.
106
+ *
107
+ * Returns:
108
+ * {
109
+ * h1: 'Changelog' | null,
110
+ * surfaces: [
111
+ * {
112
+ * kind: 'date' | 'version',
113
+ * label: string,
114
+ * startLine: number, // 1-based, points at the `## ...` heading
115
+ * endLine: number, // exclusive (1-based line number of next surface or EOF+1)
116
+ * entries: [
117
+ * {
118
+ * code, summary, body,
119
+ * sections: { added: [], changed: [], fixed: [], snapshot: [] },
120
+ * unknownLabels: { Hardened: [...], Knobs: [...] },
121
+ * startLine, // 1-based, points at the `### CODE — ...` header
122
+ * endLine, // exclusive
123
+ * }
124
+ * ]
125
+ * }
126
+ * ]
127
+ * }
128
+ *
129
+ * Permissive: any line not matching a known structural marker accretes into
130
+ * the current entry's body (entries inherit pre-subsection prose) or the
131
+ * current entry's most-recently-opened labeled subsection (bullets only).
132
+ */
133
+ export function parseChangelog(text) {
134
+ const lines = text.split('\n');
135
+ const result = { h1: null, surfaces: [] };
136
+
137
+ let curSurface = null;
138
+ let curEntry = null;
139
+ let curLabelKey = null; // 'added'/'changed'/.../null
140
+ let curUnknownLabel = null; // exact original label string
141
+ // Buffer for body lines — flushed into entry.body when subsection or end-of-entry.
142
+ let bodyBuf = [];
143
+
144
+ function closeEntry(endLineExclusive) {
145
+ if (curEntry) {
146
+ // Only flush body buffer if no subsection has opened yet — once a
147
+ // subsection opens, body was already captured at that point.
148
+ if (curLabelKey === null && curUnknownLabel === null) {
149
+ curEntry.body = trimEdges(bodyBuf.join('\n'));
150
+ }
151
+ curEntry.endLine = endLineExclusive;
152
+ curSurface.entries.push(curEntry);
153
+ curEntry = null;
154
+ curLabelKey = null;
155
+ curUnknownLabel = null;
156
+ bodyBuf = [];
157
+ }
158
+ }
159
+
160
+ function closeSurface(endLineExclusive) {
161
+ closeEntry(endLineExclusive);
162
+ if (curSurface) {
163
+ curSurface.endLine = endLineExclusive;
164
+ result.surfaces.push(curSurface);
165
+ curSurface = null;
166
+ }
167
+ }
168
+
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i];
171
+ const lineNum = i + 1; // 1-based
172
+
173
+ if (result.h1 === null && H1_RE.test(line)) {
174
+ result.h1 = 'Changelog';
175
+ continue;
176
+ }
177
+
178
+ const surfaceM = line.match(SURFACE_RE);
179
+ if (surfaceM) {
180
+ closeSurface(lineNum);
181
+ const label = surfaceM[1].trim();
182
+ const kind = VERSION_RE.test(label) ? 'version'
183
+ : DATE_RE.test(label) ? 'date'
184
+ : 'date'; // permissive: unrecognized → treat as date
185
+ curSurface = { kind, label, startLine: lineNum, endLine: -1, entries: [] };
186
+ continue;
187
+ }
188
+
189
+ if (!curSurface) continue; // pre-surface lines (e.g. blank after H1) — drop.
190
+
191
+ const entryM = line.match(ENTRY_HEADER_RE);
192
+ if (entryM) {
193
+ closeEntry(lineNum);
194
+ curEntry = {
195
+ code: entryM[1],
196
+ summary: entryM[2].trim(),
197
+ body: '',
198
+ sections: { added: [], changed: [], fixed: [], snapshot: [] },
199
+ unknownLabels: {},
200
+ startLine: lineNum,
201
+ endLine: -1,
202
+ };
203
+ bodyBuf = [];
204
+ curLabelKey = null;
205
+ curUnknownLabel = null;
206
+ continue;
207
+ }
208
+
209
+ if (!curEntry) continue; // between surface heading and first entry.
210
+
211
+ const subM = line.match(SUBSECTION_RE);
212
+ if (subM) {
213
+ // First subsection encountered → flush body buffer.
214
+ if (curLabelKey === null && curUnknownLabel === null) {
215
+ curEntry.body = trimEdges(bodyBuf.join('\n'));
216
+ bodyBuf = [];
217
+ }
218
+ const rawLabel = subM[1];
219
+ const key = rawLabel.toLowerCase();
220
+ if (KNOWN_SECTIONS.has(key)) {
221
+ curLabelKey = key;
222
+ curUnknownLabel = null;
223
+ } else {
224
+ curLabelKey = null;
225
+ curUnknownLabel = rawLabel;
226
+ if (!curEntry.unknownLabels[rawLabel]) curEntry.unknownLabels[rawLabel] = [];
227
+ }
228
+ continue;
229
+ }
230
+
231
+ const bulletM = line.match(BULLET_RE);
232
+ if (bulletM && (curLabelKey || curUnknownLabel)) {
233
+ const item = bulletM[1];
234
+ if (curLabelKey) curEntry.sections[curLabelKey].push(item);
235
+ else if (curUnknownLabel) curEntry.unknownLabels[curUnknownLabel].push(item);
236
+ continue;
237
+ }
238
+
239
+ // Otherwise, accrete to body buffer (only if no subsection has opened yet).
240
+ if (curLabelKey === null && curUnknownLabel === null) {
241
+ bodyBuf.push(line);
242
+ }
243
+ // Lines under a subsection that aren't bullets are silently dropped from
244
+ // structured output but preserved by file content (parser is read-only).
245
+ }
246
+
247
+ closeSurface(lines.length + 1);
248
+ return result;
249
+ }
250
+
251
+ function trimEdges(s) {
252
+ return s.replace(/^\n+/, '').replace(/\n+$/, '');
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Renderer
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * renderEntry({ code, summary, body, sections }) — strict canonical output.
261
+ *
262
+ * Layout:
263
+ * ### <CODE> — <summary>
264
+ *
265
+ * <body if present>
266
+ *
267
+ * **Added:**
268
+ * - …
269
+ * …
270
+ *
271
+ * Subsections emitted only when non-empty, in the fixed order
272
+ * Added → Changed → Fixed → Snapshot.
273
+ *
274
+ * Returns a string ending with a single trailing '\n'.
275
+ */
276
+ export function renderEntry({ code, summary, body, sections } = {}) {
277
+ const blocks = [`### ${code} — ${summary}`];
278
+ if (body && body.trim().length) {
279
+ blocks.push(body.trim());
280
+ }
281
+ for (const key of SUBSECTION_ORDER) {
282
+ const items = sections && Array.isArray(sections[key]) ? sections[key] : [];
283
+ if (items.length === 0) continue;
284
+ const label = SUBSECTION_LABELS[key];
285
+ const lines = [`**${label}:**`, ...items.map(it => `- ${it}`)];
286
+ blocks.push(lines.join('\n'));
287
+ }
288
+ return blocks.join('\n\n') + '\n';
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // addChangelogEntry
293
+ // ---------------------------------------------------------------------------
294
+
295
+ const CHANGELOG_FILE = 'CHANGELOG.md';
296
+
297
+ function changelogPath(cwd) {
298
+ return join(cwd, CHANGELOG_FILE);
299
+ }
300
+
301
+ function readChangelogText(cwd) {
302
+ const p = changelogPath(cwd);
303
+ if (!existsSync(p)) return '';
304
+ return readFileSync(p, 'utf-8');
305
+ }
306
+
307
+ function safeAppendEvent(cwd, event) {
308
+ try {
309
+ appendEvent(cwd, event);
310
+ } catch (err) {
311
+ // eslint-disable-next-line no-console
312
+ console.warn(`[changelog-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
313
+ }
314
+ }
315
+
316
+ function maybeIdempotent(args, fn) {
317
+ if (args.idempotency_key) {
318
+ return checkOrInsert(args.cwd, args.idempotency_key, fn).then(({ result }) => result);
319
+ }
320
+ return Promise.resolve().then(fn);
321
+ }
322
+
323
+ function atomicWrite(cwd, content) {
324
+ const p = changelogPath(cwd);
325
+ const tmp = p + '.tmp';
326
+ mkdirSync(dirname(p), { recursive: true });
327
+ try {
328
+ writeFileSync(tmp, content);
329
+ renameSync(tmp, p);
330
+ } catch (err) {
331
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* ignore */ }
332
+ throw err;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Build the next file content given current text + the parsed surfaces and
338
+ * a chosen action.
339
+ *
340
+ * action.kind = 'replace' | 'append-to-surface' | 'new-surface' | 'new-file'
341
+ */
342
+ function buildNextContent(text, parsed, action, rendered, dateOrVersion) {
343
+ const lines = text.length ? text.split('\n') : [];
344
+ // Note: split('\n') of '...\n' yields a trailing '' — we treat lines as
345
+ // 1-indexed with `lines[lineNum - 1]`. We rebuild by line indexes.
346
+
347
+ if (action.kind === 'new-file') {
348
+ // Empty file → emit H1 + blank + new surface block (containing entry).
349
+ const surfaceBlock = `## ${dateOrVersion}\n\n${rendered}`;
350
+ const out = `# Changelog\n\n${surfaceBlock}`;
351
+ return { content: out.endsWith('\n') ? out : out + '\n', insertedAtLine: locateEntryHeaderLineInSurface(out, action.code, dateOrVersion) };
352
+ }
353
+
354
+ if (action.kind === 'new-surface') {
355
+ // Insert new surface immediately after H1 (and any blank line after H1).
356
+ // Find H1 line in the parse result; if absent we shouldn't be here.
357
+ let h1Idx = lines.findIndex(l => H1_RE.test(l));
358
+ if (h1Idx === -1) {
359
+ // shouldn't happen — H1 missing path throws elsewhere; defensive fallback:
360
+ const out = `# Changelog\n\n## ${dateOrVersion}\n\n${rendered}` + (text ? '\n' + text : '');
361
+ return { content: out, insertedAtLine: locateEntryHeaderLineInSurface(out, action.code, dateOrVersion) };
362
+ }
363
+ // Insert after H1 + at most one blank line.
364
+ let insertAt = h1Idx + 1;
365
+ if (lines[insertAt] !== undefined && lines[insertAt].trim() === '') insertAt++;
366
+ const block = `## ${dateOrVersion}\n\n${rendered}`;
367
+ const before = lines.slice(0, insertAt).join('\n');
368
+ const after = lines.slice(insertAt).join('\n');
369
+ let merged;
370
+ if (before.length === 0) {
371
+ merged = block + (after.length ? '\n' + after : '');
372
+ } else {
373
+ merged = before + '\n' + block + (after.length ? '\n' + after : '');
374
+ }
375
+ if (!merged.endsWith('\n')) merged += '\n';
376
+ // Ensure separation: a blank line between block and following surface heading.
377
+ merged = ensureBlankBefore(merged, '## ', `## ${dateOrVersion}`);
378
+ return { content: merged, insertedAtLine: locateEntryHeaderLineInSurface(merged, action.code, dateOrVersion) };
379
+ }
380
+
381
+ if (action.kind === 'append-to-surface') {
382
+ const surface = action.surface;
383
+ // Insertion point: end of the surface (last line of surface, exclusive).
384
+ // surface.endLine is 1-based exclusive.
385
+ let insertBefore = surface.endLine - 1; // 0-based index of next surface heading or EOF
386
+ // Trim trailing blank lines inside the surface so we get exactly one blank
387
+ // line of separation.
388
+ while (insertBefore > surface.startLine - 1 && (lines[insertBefore - 1] ?? '').trim() === '') {
389
+ insertBefore--;
390
+ }
391
+ const before = lines.slice(0, insertBefore).join('\n');
392
+ const after = lines.slice(insertBefore).join('\n');
393
+ // Ensure exactly one blank line between previous content and rendered, and
394
+ // one blank between rendered and after.
395
+ const sep1 = before.endsWith('\n') ? '\n' : (before.length ? '\n\n' : '');
396
+ let middle = (before.length ? sep1 : '') + rendered;
397
+ if (!middle.endsWith('\n')) middle += '\n';
398
+ let merged = before + middle.slice(before.endsWith('\n') ? 0 : 0);
399
+ // Simpler reconstruction:
400
+ const beforePart = lines.slice(0, insertBefore).join('\n');
401
+ const afterPart = lines.slice(insertBefore).join('\n');
402
+ const beforeNorm = beforePart.length ? beforePart.replace(/\n*$/, '\n') : '';
403
+ const afterNorm = afterPart.length ? afterPart.replace(/^\n*/, '') : '';
404
+ let out = beforeNorm + '\n' + rendered;
405
+ if (afterNorm.length) {
406
+ // Need a blank line before next surface heading.
407
+ out = out.replace(/\n*$/, '\n');
408
+ out += '\n' + afterNorm;
409
+ }
410
+ if (!out.endsWith('\n')) out += '\n';
411
+ return { content: out, insertedAtLine: locateEntryHeaderLineInSurface(out, action.code, dateOrVersion) };
412
+ }
413
+
414
+ if (action.kind === 'replace') {
415
+ const entry = action.entry;
416
+ // Replace lines [entry.startLine, entry.endLine) with rendered.
417
+ const startIdx = entry.startLine - 1;
418
+ const endIdx = entry.endLine - 1;
419
+ const before = lines.slice(0, startIdx).join('\n');
420
+ const after = lines.slice(endIdx).join('\n');
421
+ const beforeNorm = before.length ? before.replace(/\n*$/, '\n') : '';
422
+ const afterNorm = after.length ? after.replace(/^\n*/, '') : '';
423
+ let out = beforeNorm + rendered;
424
+ if (afterNorm.length) {
425
+ out = out.replace(/\n*$/, '\n');
426
+ out += '\n' + afterNorm;
427
+ }
428
+ if (!out.endsWith('\n')) out += '\n';
429
+ return { content: out, insertedAtLine: locateEntryHeaderLineInSurface(out, action.code, dateOrVersion) };
430
+ }
431
+
432
+ throw new Error(`changelog-writer: unknown action kind "${action.kind}"`);
433
+ }
434
+
435
+ function ensureBlankBefore(text, marker, exceptMarker) {
436
+ // Walk lines and ensure there is a blank line between adjacent surface
437
+ // headings. Specifically ensure that exceptMarker (the one we just wrote) is
438
+ // separated from any subsequent line starting with marker.
439
+ const lines = text.split('\n');
440
+ const out = [];
441
+ for (let i = 0; i < lines.length; i++) {
442
+ out.push(lines[i]);
443
+ if (lines[i] === exceptMarker) {
444
+ // if next non-empty starts with marker without a blank, insert one.
445
+ // Look ahead to find next non-empty line.
446
+ let j = i + 1;
447
+ while (j < lines.length && lines[j] === '') j++;
448
+ // We've already pushed lines[i]. Now just ensure one blank exists if next non-empty line starts with marker.
449
+ if (j < lines.length && lines[j].startsWith(marker) && j === i + 1) {
450
+ out.push('');
451
+ }
452
+ }
453
+ }
454
+ return out.join('\n');
455
+ }
456
+
457
+ /**
458
+ * Locate the entry header `### CODE — ` within a specific surface (matched by
459
+ * `## label` heading). Scans only the section between `## label` and the next
460
+ * `## ` heading or EOF, so duplicate codes across different surfaces don't
461
+ * collide.
462
+ */
463
+ function locateEntryHeaderLineInSurface(text, code, surfaceLabel) {
464
+ const lines = text.split('\n');
465
+ const surfaceLine = `## ${surfaceLabel}`;
466
+ const codeRe = new RegExp(`^### ${escapeRegex(code)}\\s+—\\s+`);
467
+ // Find the topmost matching surface heading; entries land in the first match.
468
+ for (let i = 0; i < lines.length; i++) {
469
+ if (lines[i] !== surfaceLine) continue;
470
+ for (let j = i + 1; j < lines.length; j++) {
471
+ if (lines[j].startsWith('## ')) break;
472
+ if (codeRe.test(lines[j])) return j + 1;
473
+ }
474
+ // Topmost surface scanned; if not found here, the entry isn't in this
475
+ // surface — search subsequent matching surfaces.
476
+ for (let k = i + 1; k < lines.length; k++) {
477
+ if (lines[k] !== surfaceLine) continue;
478
+ for (let m = k + 1; m < lines.length; m++) {
479
+ if (lines[m].startsWith('## ')) break;
480
+ if (codeRe.test(lines[m])) return m + 1;
481
+ }
482
+ }
483
+ break;
484
+ }
485
+ return -1;
486
+ }
487
+
488
+ function escapeRegex(s) {
489
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
490
+ }
491
+
492
+ /**
493
+ * @param {string} cwd
494
+ * @param {object} args
495
+ * @param {string} args.date_or_version
496
+ * @param {string} args.code
497
+ * @param {string} args.summary
498
+ * @param {string} [args.body]
499
+ * @param {object} [args.sections]
500
+ * @param {boolean} [args.force]
501
+ * @param {string} [args.idempotency_key]
502
+ * @returns {Promise<{ inserted_at: number, idempotent: boolean, surface: string }>}
503
+ */
504
+ export async function addChangelogEntry(cwd, args) {
505
+ validateCode(args.code);
506
+ validateDateOrVersion(args.date_or_version);
507
+ validateSummary(args.summary);
508
+ validateSections(args.sections);
509
+
510
+ return maybeIdempotent({ ...args, cwd }, () => {
511
+ const text = readChangelogText(cwd);
512
+ if (text.length > 0 && !H1_RE.test(text.split('\n')[0] ?? '')) {
513
+ throw formatError('first line must be "# Changelog" (line 1)');
514
+ }
515
+
516
+ const parsed = parseChangelog(text);
517
+
518
+ // Find existing entry across all matching surfaces.
519
+ const matchingSurfaces = parsed.surfaces.filter(s => s.label === args.date_or_version);
520
+ let existingSurface = null;
521
+ let existingEntry = null;
522
+ for (const s of matchingSurfaces) {
523
+ const e = s.entries.find(en => en.code === args.code);
524
+ if (e) { existingSurface = s; existingEntry = e; break; }
525
+ }
526
+
527
+ const rendered = renderEntry({
528
+ code: args.code,
529
+ summary: args.summary,
530
+ body: args.body,
531
+ sections: args.sections,
532
+ });
533
+
534
+ let action;
535
+ let chosenSurfaceLabel = args.date_or_version;
536
+ let chosenSurfaceStartLine = -1;
537
+
538
+ if (existingEntry && !args.force) {
539
+ // Storage-level idempotent no-op: no file write, no audit event
540
+ // (Decision 2 of design). Caller-supplied idempotency_key replays are
541
+ // handled separately by the maybeIdempotent wrapper above.
542
+ return {
543
+ inserted_at: existingEntry.startLine,
544
+ idempotent: true,
545
+ surface: existingSurface.label,
546
+ };
547
+ }
548
+
549
+ if (existingEntry && args.force) {
550
+ action = { kind: 'replace', entry: existingEntry, code: args.code };
551
+ chosenSurfaceLabel = existingSurface.label;
552
+ chosenSurfaceStartLine = existingSurface.startLine;
553
+ } else if (matchingSurfaces.length > 0) {
554
+ // No existing entry; insert into first (topmost) matching surface.
555
+ const surface = matchingSurfaces[0];
556
+ action = { kind: 'append-to-surface', surface, code: args.code };
557
+ chosenSurfaceLabel = surface.label;
558
+ chosenSurfaceStartLine = surface.startLine;
559
+ } else if (text.length === 0) {
560
+ action = { kind: 'new-file', code: args.code };
561
+ chosenSurfaceStartLine = 3; // line of new surface heading after H1 + blank
562
+ } else {
563
+ action = { kind: 'new-surface', code: args.code };
564
+ }
565
+
566
+ const { content, insertedAtLine } = buildNextContent(text, parsed, action, rendered, args.date_or_version);
567
+
568
+ // For new-surface, recompute chosenSurfaceStartLine from output.
569
+ if (action.kind === 'new-surface' || action.kind === 'new-file') {
570
+ const outLines = content.split('\n');
571
+ for (let i = 0; i < outLines.length; i++) {
572
+ if (outLines[i] === `## ${args.date_or_version}`) {
573
+ chosenSurfaceStartLine = i + 1;
574
+ break;
575
+ }
576
+ }
577
+ }
578
+
579
+ atomicWrite(cwd, content);
580
+
581
+ const event = {
582
+ tool: 'add_changelog_entry',
583
+ code: args.code,
584
+ surface_label: chosenSurfaceLabel,
585
+ surface_start_line: chosenSurfaceStartLine,
586
+ };
587
+ if (args.idempotency_key) event.idempotency_key = args.idempotency_key;
588
+ if (action.kind === 'replace') event.force = true;
589
+ safeAppendEvent(cwd, event);
590
+
591
+ return {
592
+ inserted_at: insertedAtLine,
593
+ idempotent: false,
594
+ surface: chosenSurfaceLabel,
595
+ };
596
+ });
597
+ }
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // getChangelogEntries
601
+ // ---------------------------------------------------------------------------
602
+
603
+ const DEFAULT_LIMIT = 50;
604
+ const MAX_LIMIT = 500;
605
+
606
+ /**
607
+ * @param {string} cwd
608
+ * @param {object} [opts]
609
+ * @param {string} [opts.since] — shorthand "24h"/"7d"/"30m" or ISO date.
610
+ * Date-only; version surfaces always pass through.
611
+ * @param {string} [opts.code] — exact-match feature code.
612
+ * @param {number} [opts.limit] — default 50, max 500.
613
+ * @returns {{ entries: Array, count: number }}
614
+ */
615
+ export function getChangelogEntries(cwd, opts = {}) {
616
+ const text = readChangelogText(cwd);
617
+ const parsed = parseChangelog(text);
618
+
619
+ const sinceMs = opts.since !== undefined ? normalizeSince(opts.since) : null;
620
+ const codeFilter = opts.code;
621
+ const rawLimit = typeof opts.limit === 'number' ? opts.limit : DEFAULT_LIMIT;
622
+ const limit = Math.max(0, Math.min(MAX_LIMIT, rawLimit));
623
+
624
+ const out = [];
625
+ outer: for (const s of parsed.surfaces) {
626
+ if (sinceMs !== null && s.kind === 'date') {
627
+ const surfaceMs = Date.parse(s.label);
628
+ if (Number.isNaN(surfaceMs) || surfaceMs < sinceMs) continue;
629
+ }
630
+ // Version surfaces: always pass through when `since` is set.
631
+ for (const e of s.entries) {
632
+ if (codeFilter && e.code !== codeFilter) continue;
633
+ out.push({
634
+ date_or_version: s.label,
635
+ code: e.code,
636
+ summary: e.summary,
637
+ body: e.body,
638
+ sections: e.sections,
639
+ unknownLabels: e.unknownLabels,
640
+ line_number: e.startLine,
641
+ });
642
+ if (out.length >= limit) break outer;
643
+ }
644
+ }
645
+
646
+ return { entries: out, count: out.length };
647
+ }