@smartmemory/compose 0.1.5-beta → 0.1.6-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,928 @@
1
+ /**
2
+ * journal-writer.js — typed writer + reader for compose/docs/journal/.
3
+ *
4
+ * Sub-ticket #4 of COMP-MCP-FEATURE-MGMT (COMP-MCP-JOURNAL-WRITER).
5
+ *
6
+ * Five exports:
7
+ * writeJournalEntry(cwd, args) — write/overwrite a session entry + index row
8
+ * getJournalEntries(cwd, opts) — read, filter, sort, limit
9
+ * parseJournalEntry(text) — parse a single entry file
10
+ * parseJournalIndex(text) — parse docs/journal/README.md
11
+ * renderJournalEntry(args) — render canonical Markdown from typed args
12
+ *
13
+ * No new dependencies. Hand-rolled frontmatter parser/encoder. Reuses
14
+ * lib/idempotency.js (checkOrInsert, acquireLock), lib/feature-events.js
15
+ * (appendEvent, normalizeSince). Advisory lock on journal-counter.lock.
16
+ */
17
+
18
+ import {
19
+ readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync,
20
+ unlinkSync, renameSync as _renameSync,
21
+ } from 'node:fs';
22
+
23
+ // Indirection layer so tests can monkeypatch individual fs operations
24
+ // without needing an external module loader.
25
+ export const _fsHooks = {
26
+ renameSync: _renameSync,
27
+ };
28
+ import { join, dirname } from 'node:path';
29
+
30
+ import { appendEvent, normalizeSince } from './feature-events.js';
31
+ import { checkOrInsert } from './idempotency.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Constants & regexes
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const JOURNAL_DIR = 'docs/journal';
38
+ const INDEX_FILE = 'README.md';
39
+ const ENTRIES_HEADING = '## Entries';
40
+
41
+ const TABLE_HEADER_RE = /^\|\s*Date\s*\|\s*Entry\s*\|\s*Summary\s*\|\s*$/;
42
+ const TABLE_SEP_RE = /^\|[\s-]+\|[\s-]+\|[\s-]+\|\s*$/;
43
+ const ROW_RE = /^\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*\[Session\s+(\d+):\s*(.+?)\]\(([^)]+)\)\s*\|\s*(.*?)\s*\|\s*$/;
44
+ const FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-session-(\d+)-([a-z0-9][a-z0-9-]*[a-z0-9])\.md$/;
45
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
46
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
47
+ const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
48
+
49
+ const REQUIRED_SECTIONS = ['what_happened', 'what_we_built', 'what_we_learned', 'open_threads'];
50
+ const SECTION_HEADINGS = {
51
+ what_happened: '## What happened',
52
+ what_we_built: '## What we built',
53
+ what_we_learned: '## What we learned',
54
+ open_threads: '## Open threads',
55
+ };
56
+
57
+ // Reverse map: heading text -> section key (case- and whitespace-insensitive).
58
+ // Keys are normalized: lowercased and internal whitespace runs collapsed.
59
+ const HEADING_TO_KEY = {};
60
+ for (const [k, v] of Object.entries(SECTION_HEADINGS)) {
61
+ HEADING_TO_KEY[v.toLowerCase().replace(/\s+/g, ' ')] = k;
62
+ }
63
+
64
+ /** Normalize a heading string for canonical lookup. */
65
+ function normalizeHeading(h) {
66
+ return h.toLowerCase().replace(/\s+/g, ' ');
67
+ }
68
+
69
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
70
+ const TITLE_RE = /^# Session (\d+) — (.+?)\s*$/;
71
+ const HR_RE = /^---\s*$/;
72
+ const ITALIC_LINE_RE = /^\*([^*].+[^*])\*\s*$|^\*([^*])\*\s*$/;
73
+ const FRONTMATTER_KEYS = ['date', 'session_number', 'slug', 'summary', 'feature_code', 'closing_line'];
74
+
75
+ const DEFAULT_LIMIT = 50;
76
+ const MAX_LIMIT = 500;
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Error helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function inputError(message) {
83
+ const err = new Error(message);
84
+ err.code = 'INVALID_INPUT';
85
+ return err;
86
+ }
87
+
88
+ function formatError(message) {
89
+ const err = new Error(message);
90
+ err.code = 'JOURNAL_FORMAT';
91
+ return err;
92
+ }
93
+
94
+ function indexFormatError(message) {
95
+ const err = new Error(message);
96
+ err.code = 'JOURNAL_INDEX_FORMAT';
97
+ return err;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Frontmatter parser & encoder
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Parse a one-level "YAML-ish" frontmatter block (key: value, no nesting).
106
+ * Returns an object with string values except session_number which is coerced
107
+ * to int. Throws JOURNAL_FORMAT on missing closing ---.
108
+ */
109
+ function parseFrontmatter(raw) {
110
+ const result = {};
111
+ const lines = raw.split('\n');
112
+ for (const line of lines) {
113
+ if (!line.trim()) continue;
114
+ const colon = line.indexOf(':');
115
+ if (colon === -1) continue;
116
+ const key = line.slice(0, colon).trim();
117
+ let value = line.slice(colon + 1).trim();
118
+
119
+ // Unescape double-quoted values.
120
+ // Decode \\ FIRST (using a placeholder) so that a literal \n sequence
121
+ // (encoded as \\n) is not incorrectly converted to a real newline.
122
+ if (value.startsWith('"') && value.endsWith('"')) {
123
+ const BACKSLASH_PLACEHOLDER = '\x00BS\x00';
124
+ value = value.slice(1, -1)
125
+ .replace(/\\\\/g, BACKSLASH_PLACEHOLDER)
126
+ .replace(/\\n/g, '\n')
127
+ .replace(/\\"/g, '"')
128
+ .replace(new RegExp(BACKSLASH_PLACEHOLDER, 'g'), '\\');
129
+ }
130
+
131
+ if (key === 'session_number') {
132
+ const n = parseInt(value, 10);
133
+ result[key] = Number.isNaN(n) ? value : n;
134
+ } else {
135
+ result[key] = value;
136
+ }
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * Encode a value for frontmatter. Bare for simple strings, double-quoted
143
+ * with escaping otherwise.
144
+ */
145
+ function encodeFrontmatterValue(value) {
146
+ if (typeof value === 'number') return String(value);
147
+ const s = String(value);
148
+ const needsQuotes = s.includes('\n') ||
149
+ s.includes('"') ||
150
+ /^[\[{!&*#]/.test(s) ||
151
+ s.startsWith(' ') ||
152
+ s.endsWith(' ') ||
153
+ s.includes(':');
154
+ if (!needsQuotes) return s;
155
+ const escaped = s
156
+ .replace(/\\/g, '\\\\')
157
+ .replace(/"/g, '\\"')
158
+ .replace(/\n/g, '\\n');
159
+ return `"${escaped}"`;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // parseJournalEntry
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Parse a journal entry markdown file.
168
+ *
169
+ * Returns:
170
+ * {
171
+ * frontmatter: object,
172
+ * title: string | null,
173
+ * date: string | null,
174
+ * feature_code: string | null,
175
+ * sections: { what_happened, what_we_built, what_we_learned, open_threads },
176
+ * unknownSections: [{ heading, body, startLine }],
177
+ * closing_line: string | null,
178
+ * startLines: { what_happened?, what_we_built?, what_we_learned?, open_threads? }
179
+ * }
180
+ */
181
+ export function parseJournalEntry(text) {
182
+ let frontmatter = {};
183
+ let body = text;
184
+
185
+ // 1. Strip and parse frontmatter.
186
+ const fmMatch = text.match(FRONTMATTER_RE);
187
+ if (fmMatch) {
188
+ frontmatter = parseFrontmatter(fmMatch[1]);
189
+ body = text.slice(fmMatch[0].length);
190
+ }
191
+
192
+ const lines = body.split('\n');
193
+
194
+ // 2. Find H1 title.
195
+ let title = null;
196
+ let titleSessionNumber = null;
197
+ for (let i = 0; i < lines.length; i++) {
198
+ const m = lines[i].match(TITLE_RE);
199
+ if (m) {
200
+ titleSessionNumber = parseInt(m[1], 10);
201
+ title = m[2].trim();
202
+ break;
203
+ }
204
+ }
205
+
206
+ // session_number: frontmatter takes precedence over H1.
207
+ const sessionNumber = (frontmatter.session_number !== undefined)
208
+ ? frontmatter.session_number
209
+ : titleSessionNumber;
210
+
211
+ // date and feature_code from frontmatter.
212
+ const date = frontmatter.date || null;
213
+ const feature_code = frontmatter.feature_code || null;
214
+ const slug = frontmatter.slug || null;
215
+
216
+ // 3. Walk lines after H1 to collect sections.
217
+ const sections = {
218
+ what_happened: '',
219
+ what_we_built: '',
220
+ what_we_learned: '',
221
+ open_threads: '',
222
+ };
223
+ const unknownSections = [];
224
+ const startLines = {};
225
+
226
+ let currentKey = null; // canonical key or null
227
+ let currentUnknown = null; // { heading, startLine }
228
+ let currentBuf = [];
229
+
230
+ // fmOffset: the frontmatter consumed some lines from the original text.
231
+ // For startLine reporting, we report line numbers within the body (post-fm).
232
+ // We use 1-based within-body line numbers.
233
+
234
+ function flushCurrent(endIdx) {
235
+ const body_ = trimEdges(currentBuf.join('\n'));
236
+ if (currentKey !== null) {
237
+ sections[currentKey] = body_;
238
+ } else if (currentUnknown !== null) {
239
+ unknownSections.push({
240
+ heading: currentUnknown.heading,
241
+ body: body_,
242
+ startLine: currentUnknown.startLine,
243
+ });
244
+ }
245
+ currentKey = null;
246
+ currentUnknown = null;
247
+ currentBuf = [];
248
+ }
249
+
250
+ let pastH1 = false;
251
+ let hrIndex = -1; // index (0-based) of HR found after last section
252
+
253
+ for (let i = 0; i < lines.length; i++) {
254
+ const line = lines[i];
255
+ const lineNum = i + 1; // 1-based
256
+
257
+ // Wait until we pass the H1.
258
+ if (!pastH1) {
259
+ if (TITLE_RE.test(line)) pastH1 = true;
260
+ continue;
261
+ }
262
+
263
+ // Check for an H2 heading.
264
+ if (line.startsWith('## ')) {
265
+ flushCurrent(lineNum);
266
+ const headingText = line.trim();
267
+ const key = HEADING_TO_KEY[normalizeHeading(headingText)];
268
+ if (key) {
269
+ currentKey = key;
270
+ startLines[key] = lineNum;
271
+ } else {
272
+ const heading = headingText.slice(3).trim();
273
+ currentUnknown = { heading, startLine: lineNum };
274
+ }
275
+ continue;
276
+ }
277
+
278
+ // Check for HR (only relevant if we're in a section).
279
+ if (HR_RE.test(line) && (currentKey !== null || currentUnknown !== null)) {
280
+ // HR is the delimiter between Open threads and closing line.
281
+ // Stop accreting into current section.
282
+ flushCurrent(lineNum);
283
+ hrIndex = i;
284
+ break;
285
+ }
286
+
287
+ // Accrete into current buffer.
288
+ if (currentKey !== null || currentUnknown !== null) {
289
+ currentBuf.push(line);
290
+ }
291
+ }
292
+
293
+ // Flush anything remaining if no HR was found.
294
+ if (currentKey !== null || currentUnknown !== null) {
295
+ flushCurrent(lines.length + 1);
296
+ }
297
+
298
+ // 4. Determine closing_line.
299
+ // Priority: frontmatter > body parse > null.
300
+ let closing_line = frontmatter.closing_line || null;
301
+
302
+ if (closing_line === null && hrIndex !== -1) {
303
+ // Find the next non-blank line after the HR.
304
+ for (let i = hrIndex + 1; i < lines.length; i++) {
305
+ if (lines[i].trim() === '') continue;
306
+ const candidate = lines[i].trim();
307
+ // Match *text* (italic) pattern.
308
+ const italicMatch = candidate.match(ITALIC_LINE_RE);
309
+ if (italicMatch) {
310
+ closing_line = (italicMatch[1] !== undefined ? italicMatch[1] : italicMatch[2]);
311
+ } else {
312
+ closing_line = candidate;
313
+ }
314
+ break;
315
+ }
316
+ }
317
+
318
+ return {
319
+ frontmatter,
320
+ title,
321
+ date,
322
+ feature_code,
323
+ slug,
324
+ session_number: sessionNumber,
325
+ sections,
326
+ unknownSections,
327
+ closing_line,
328
+ startLines,
329
+ };
330
+ }
331
+
332
+ function trimEdges(s) {
333
+ return s.replace(/^\n+/, '').replace(/\n+$/, '');
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // parseJournalIndex
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * Parse compose/docs/journal/README.md.
342
+ *
343
+ * Returns:
344
+ * { preamble, table_header_line, separator_line, rows, postamble }
345
+ *
346
+ * Throws JOURNAL_INDEX_FORMAT if the file is missing the ## Entries heading,
347
+ * malformed table header, or missing separator.
348
+ *
349
+ * rows: Array of { date, session_number, slug, link_path, summary, line }
350
+ * or opaque { raw, line, slug: null, session_number: null } for unmatched rows.
351
+ */
352
+ export function parseJournalIndex(text) {
353
+ const lines = text.split('\n');
354
+
355
+ // 1. Find ## Entries heading.
356
+ const entriesIdx = lines.findIndex(l => l.trim() === ENTRIES_HEADING);
357
+ if (entriesIdx === -1) {
358
+ throw indexFormatError(`journal index missing "${ENTRIES_HEADING}" heading`);
359
+ }
360
+
361
+ const preambleLines = lines.slice(0, entriesIdx);
362
+
363
+ // 2. Find table header — first non-blank line after ## Entries.
364
+ let headerIdx = -1;
365
+ for (let i = entriesIdx + 1; i < lines.length; i++) {
366
+ if (lines[i].trim() === '') continue;
367
+ if (!TABLE_HEADER_RE.test(lines[i])) {
368
+ throw indexFormatError(
369
+ `journal index: expected table header "| Date | Entry | Summary |" at line ${i + 1}, got: ${lines[i]}`
370
+ );
371
+ }
372
+ headerIdx = i;
373
+ break;
374
+ }
375
+ if (headerIdx === -1) {
376
+ throw indexFormatError('journal index: table header not found after ## Entries');
377
+ }
378
+
379
+ // 3. Next line must be separator.
380
+ const sepIdx = headerIdx + 1;
381
+ if (sepIdx >= lines.length || !TABLE_SEP_RE.test(lines[sepIdx])) {
382
+ throw indexFormatError(
383
+ `journal index: expected table separator at line ${sepIdx + 1}, got: ${lines[sepIdx] ?? '<EOF>'}`
384
+ );
385
+ }
386
+
387
+ // 4. Parse rows.
388
+ const rows = [];
389
+ let tableEndIdx = sepIdx + 1;
390
+ for (let i = sepIdx + 1; i < lines.length; i++) {
391
+ const line = lines[i];
392
+ if (line.trim() === '') {
393
+ // Blank line ends the table.
394
+ tableEndIdx = i;
395
+ break;
396
+ }
397
+ if (!line.startsWith('|')) {
398
+ // Non-table line ends the table.
399
+ tableEndIdx = i;
400
+ break;
401
+ }
402
+ const m = line.match(ROW_RE);
403
+ if (m) {
404
+ rows.push({
405
+ date: m[1],
406
+ session_number: parseInt(m[2], 10),
407
+ summary_link_text: m[3].trim(),
408
+ link_path: m[4].trim(),
409
+ summary: m[5].trim(),
410
+ line: i + 1, // 1-based
411
+ slug: extractSlugFromPath(m[4].trim()),
412
+ raw: line,
413
+ });
414
+ } else {
415
+ // Opaque row.
416
+ rows.push({ raw: line, line: i + 1, slug: null, session_number: null });
417
+ }
418
+ tableEndIdx = i + 1;
419
+ }
420
+
421
+ const postambleLines = lines.slice(tableEndIdx);
422
+
423
+ return {
424
+ preamble: preambleLines.join('\n'),
425
+ table_header_line: headerIdx + 1, // 1-based
426
+ separator_line: sepIdx + 1, // 1-based
427
+ rows,
428
+ postamble: postambleLines.join('\n'),
429
+ };
430
+ }
431
+
432
+ function extractSlugFromPath(linkPath) {
433
+ const base = linkPath.split('/').pop() || '';
434
+ const m = base.match(FILENAME_RE);
435
+ return m ? m[3] : null;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // renderJournalEntry
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /**
443
+ * Render a canonical journal entry file.
444
+ *
445
+ * Args:
446
+ * date, slug, session_number, sections (all 4 keys),
447
+ * summary_for_index, feature_code?, closing_line?
448
+ *
449
+ * Returns a string ending with \n.
450
+ */
451
+ export function renderJournalEntry({
452
+ date,
453
+ slug,
454
+ session_number,
455
+ sections,
456
+ summary_for_index,
457
+ feature_code,
458
+ closing_line,
459
+ }) {
460
+ // Build frontmatter.
461
+ const fmLines = ['---'];
462
+ fmLines.push(`date: ${encodeFrontmatterValue(date)}`);
463
+ fmLines.push(`session_number: ${session_number}`);
464
+ fmLines.push(`slug: ${encodeFrontmatterValue(slug)}`);
465
+ fmLines.push(`summary: ${encodeFrontmatterValue(summary_for_index)}`);
466
+ if (feature_code) fmLines.push(`feature_code: ${encodeFrontmatterValue(feature_code)}`);
467
+ if (closing_line) fmLines.push(`closing_line: ${encodeFrontmatterValue(closing_line)}`);
468
+ fmLines.push('---');
469
+
470
+ // Derive title.
471
+ let title;
472
+ if (feature_code) {
473
+ title = feature_code;
474
+ } else if (summary_for_index) {
475
+ // Up to first colon, or 80 chars, whichever is shorter.
476
+ const colonIdx = summary_for_index.indexOf(':');
477
+ const truncated = colonIdx !== -1 ? summary_for_index.slice(0, colonIdx) : summary_for_index;
478
+ title = truncated.length > 80 ? truncated.slice(0, 80) : truncated;
479
+ } else {
480
+ title = slug;
481
+ }
482
+
483
+ const blocks = [];
484
+ blocks.push(fmLines.join('\n'));
485
+ blocks.push(`# Session ${session_number} — ${title}`);
486
+
487
+ // Header metadata block.
488
+ const metaLines = [`**Date:** ${date}`];
489
+ if (feature_code) metaLines.push(`**Feature:** \`${feature_code}\``);
490
+ blocks.push(metaLines.join('\n'));
491
+
492
+ // Four canonical sections.
493
+ for (const key of REQUIRED_SECTIONS) {
494
+ blocks.push(`${SECTION_HEADINGS[key]}\n\n${sections[key]}`);
495
+ }
496
+
497
+ // Closing line block.
498
+ if (closing_line) {
499
+ blocks.push(`---\n\n*${closing_line}*`);
500
+ }
501
+
502
+ return blocks.join('\n\n') + '\n';
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Atomic write helper
507
+ // ---------------------------------------------------------------------------
508
+
509
+ function atomicWrite(targetPath, content) {
510
+ const tmp = targetPath + '.tmp';
511
+ mkdirSync(dirname(targetPath), { recursive: true });
512
+ try {
513
+ writeFileSync(tmp, content, 'utf-8');
514
+ _fsHooks.renameSync(tmp, targetPath);
515
+ } catch (err) {
516
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* ignore */ }
517
+ throw err;
518
+ }
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Audit helper
523
+ // ---------------------------------------------------------------------------
524
+
525
+ function safeAppendEvent(cwd, event) {
526
+ try {
527
+ appendEvent(cwd, event);
528
+ } catch (err) {
529
+ // eslint-disable-next-line no-console
530
+ console.warn(`[journal-writer] audit append failed for ${event.tool}: ${err.message}`);
531
+ }
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Idempotent wrapper (mirrors changelog-writer.js)
536
+ // ---------------------------------------------------------------------------
537
+
538
+ function maybeIdempotent(args, fn) {
539
+ if (args.idempotency_key) {
540
+ return checkOrInsert(args.cwd, args.idempotency_key, fn).then(({ result }) => result);
541
+ }
542
+ return Promise.resolve().then(fn);
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Advisory lock for journal counter
547
+ // ---------------------------------------------------------------------------
548
+
549
+ const LOCK_TIMEOUT_MS = 5000;
550
+ const LOCK_RETRY_MS = 25;
551
+
552
+ function journalLockPath(cwd) {
553
+ return join(cwd, '.compose', 'data', 'journal-counter.lock');
554
+ }
555
+
556
+ async function acquireJournalLock(cwd) {
557
+ const { rmSync, statSync } = await import('node:fs');
558
+ const path = journalLockPath(cwd);
559
+ mkdirSync(dirname(path), { recursive: true });
560
+
561
+ const start = Date.now();
562
+ // eslint-disable-next-line no-constant-condition
563
+ while (true) {
564
+ try {
565
+ mkdirSync(path);
566
+ return () => {
567
+ try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ }
568
+ };
569
+ } catch (err) {
570
+ if (err.code !== 'EEXIST') throw err;
571
+ // Stale lock recovery.
572
+ try {
573
+ const { mtimeMs } = statSync(path);
574
+ if (Date.now() - mtimeMs > LOCK_TIMEOUT_MS) {
575
+ rmSync(path, { recursive: true, force: true });
576
+ continue;
577
+ }
578
+ } catch { /* stat raced; loop and retry */ }
579
+
580
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
581
+ throw new Error(`journal-writer lock timeout after ${LOCK_TIMEOUT_MS}ms: ${path}`);
582
+ }
583
+ await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
584
+ }
585
+ }
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // Input validation
590
+ // ---------------------------------------------------------------------------
591
+
592
+ function validateArgs(args) {
593
+ if (typeof args.date !== 'string' || !DATE_RE.test(args.date)) {
594
+ throw inputError(`journal-writer: invalid date "${args.date}" — must match YYYY-MM-DD`);
595
+ }
596
+ if (typeof args.slug !== 'string' || !SLUG_RE.test(args.slug)) {
597
+ throw inputError(`journal-writer: invalid slug "${args.slug}" — must match /^[a-z0-9][a-z0-9-]*[a-z0-9]$/`);
598
+ }
599
+ if (!args.sections || typeof args.sections !== 'object') {
600
+ throw inputError('journal-writer: sections is required');
601
+ }
602
+ for (const key of REQUIRED_SECTIONS) {
603
+ if (typeof args.sections[key] !== 'string' || args.sections[key].trim().length === 0) {
604
+ throw inputError(`journal-writer: sections.${key} is required and must be a non-empty string`);
605
+ }
606
+ }
607
+ if (typeof args.summary_for_index !== 'string' || args.summary_for_index.trim().length === 0) {
608
+ throw inputError('journal-writer: summary_for_index is required and must be non-empty');
609
+ }
610
+ if (args.summary_for_index.includes('\n')) {
611
+ throw inputError('journal-writer: summary_for_index must not contain newlines');
612
+ }
613
+ if (args.summary_for_index.includes('|')) {
614
+ throw inputError('journal-writer: summary_for_index must not contain "|"');
615
+ }
616
+ if (args.feature_code !== undefined && args.feature_code !== null) {
617
+ if (typeof args.feature_code !== 'string' || !FEATURE_CODE_RE.test(args.feature_code)) {
618
+ throw inputError(`journal-writer: invalid feature_code "${args.feature_code}"`);
619
+ }
620
+ }
621
+ if (args.closing_line !== undefined && args.closing_line !== null) {
622
+ if (typeof args.closing_line !== 'string' || args.closing_line.trim().length === 0) {
623
+ throw inputError('journal-writer: closing_line must be a non-empty string when provided');
624
+ }
625
+ if (args.closing_line.includes('\n')) {
626
+ throw inputError('journal-writer: closing_line must not contain newlines');
627
+ }
628
+ }
629
+ }
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // writeJournalEntry
633
+ // ---------------------------------------------------------------------------
634
+
635
+ /**
636
+ * @param {string} cwd
637
+ * @param {object} args
638
+ * @param {string} args.date
639
+ * @param {string} args.slug
640
+ * @param {object} args.sections — all four keys, each non-empty string
641
+ * @param {string} args.summary_for_index
642
+ * @param {string} [args.feature_code]
643
+ * @param {string} [args.closing_line]
644
+ * @param {boolean} [args.force]
645
+ * @param {string} [args.idempotency_key]
646
+ * @returns {Promise<{ path: string, session_number: number, index_line: number, idempotent: boolean }>}
647
+ */
648
+ export async function writeJournalEntry(cwd, args) {
649
+ validateArgs(args);
650
+
651
+ return maybeIdempotent({ ...args, cwd }, async () => {
652
+ const journalDir = join(cwd, JOURNAL_DIR);
653
+ const indexPath = join(journalDir, INDEX_FILE);
654
+
655
+ // Pre-flight: read + parse index before any disk mutation.
656
+ if (!existsSync(indexPath)) {
657
+ throw indexFormatError(`journal-writer: index file not found at ${indexPath}`);
658
+ }
659
+ const indexText = readFileSync(indexPath, 'utf-8');
660
+ let parsed;
661
+ try {
662
+ parsed = parseJournalIndex(indexText);
663
+ } catch (err) {
664
+ // Re-throw with JOURNAL_INDEX_FORMAT so callers see the right code.
665
+ if (err.code === 'JOURNAL_INDEX_FORMAT') throw err;
666
+ throw indexFormatError(`journal-writer: index parse failed: ${err.message}`);
667
+ }
668
+
669
+ // Acquire advisory lock.
670
+ const releaseLock = await acquireJournalLock(cwd);
671
+
672
+ try {
673
+ // List existing entries.
674
+ mkdirSync(journalDir, { recursive: true });
675
+ const dirEntries = readdirSync(journalDir);
676
+ const existing = [];
677
+ for (const name of dirEntries) {
678
+ const m = name.match(FILENAME_RE);
679
+ if (!m) continue;
680
+ existing.push({
681
+ date: m[1],
682
+ session_number: parseInt(m[2], 10),
683
+ slug: m[3],
684
+ filename: name,
685
+ path: join(journalDir, name),
686
+ });
687
+ }
688
+
689
+ // Storage-level dedup.
690
+ const dup = existing.find(e => e.date === args.date && e.slug === args.slug);
691
+
692
+ if (dup && !args.force) {
693
+ // Idempotent no-op: re-read the index INSIDE the lock so that
694
+ // index_line reflects any rows inserted by concurrent writers
695
+ // between the pre-flight parse and now.
696
+ const dupFilename = dup.filename;
697
+ const lockedIndexText = readFileSync(indexPath, 'utf-8');
698
+ const lockedParsed = parseJournalIndex(lockedIndexText);
699
+ const idxRow = lockedParsed.rows.find(r => r.link_path === dupFilename);
700
+ const index_line = idxRow ? idxRow.line : -1;
701
+ return {
702
+ path: dup.path,
703
+ session_number: dup.session_number,
704
+ index_line,
705
+ idempotent: true,
706
+ };
707
+ }
708
+
709
+ let session_number;
710
+ let action; // 'insert' | 'overwrite'
711
+
712
+ if (dup && args.force) {
713
+ session_number = dup.session_number;
714
+ action = 'overwrite';
715
+ } else {
716
+ // Compute next global session number.
717
+ const maxSession = existing.length > 0
718
+ ? Math.max(...existing.map(e => e.session_number))
719
+ : -1;
720
+ session_number = maxSession + 1;
721
+ action = 'insert';
722
+ }
723
+
724
+ const filename = `${args.date}-session-${session_number}-${args.slug}.md`;
725
+ const entryPath = join(journalDir, filename);
726
+
727
+ // Render entry.
728
+ const entryContent = renderJournalEntry({
729
+ date: args.date,
730
+ slug: args.slug,
731
+ session_number,
732
+ sections: args.sections,
733
+ summary_for_index: args.summary_for_index,
734
+ feature_code: args.feature_code,
735
+ closing_line: args.closing_line,
736
+ });
737
+
738
+ // Build new index row.
739
+ const rowSummaryLinkText = `Session ${session_number}: ${args.summary_for_index}`;
740
+ const newRow = `| ${args.date} | [${rowSummaryLinkText}](${filename}) | ${args.summary_for_index} |`;
741
+
742
+ // Re-read index (may have changed while we were computing).
743
+ const freshIndexText = readFileSync(indexPath, 'utf-8');
744
+ const freshParsed = parseJournalIndex(freshIndexText);
745
+
746
+ let newIndexText;
747
+ let index_line;
748
+
749
+ if (action === 'overwrite') {
750
+ // Replace the row in place.
751
+ const lines = freshIndexText.split('\n');
752
+ const existingRowIdx = freshParsed.rows.findIndex(r => r.link_path === filename);
753
+ if (existingRowIdx !== -1) {
754
+ const rowLine = freshParsed.rows[existingRowIdx].line; // 1-based
755
+ lines[rowLine - 1] = newRow;
756
+ newIndexText = lines.join('\n');
757
+ index_line = rowLine;
758
+ } else {
759
+ // Row not found in index — insert at top.
760
+ const insertAt = freshParsed.separator_line; // 1-based
761
+ const linesArr = freshIndexText.split('\n');
762
+ linesArr.splice(insertAt, 0, newRow); // after separator
763
+ newIndexText = linesArr.join('\n');
764
+ index_line = insertAt + 1; // 1-based line of new row
765
+ }
766
+ } else {
767
+ // Insert immediately after separator line.
768
+ const linesArr = freshIndexText.split('\n');
769
+ const insertAt = freshParsed.separator_line; // 1-based index of separator
770
+ linesArr.splice(insertAt, 0, newRow); // splice at position = separator_line (0-based = separator_line-1+1 = separator_line)
771
+ newIndexText = linesArr.join('\n');
772
+ index_line = insertAt + 1; // 1-based
773
+ }
774
+
775
+ // Atomic writes with compensating-action rollback.
776
+ //
777
+ // New-entry path: write entry first, then index. If index write fails,
778
+ // delete the entry file so the journal stays consistent.
779
+ //
780
+ // Force-overwrite path: read the current entry content first (for
781
+ // rollback), then write the new entry, then the index. If index write
782
+ // fails, restore the original entry content.
783
+ if (action === 'insert') {
784
+ atomicWrite(entryPath, entryContent);
785
+ try {
786
+ atomicWrite(indexPath, newIndexText);
787
+ } catch (indexErr) {
788
+ // Compensate: remove the entry file we just wrote.
789
+ let unlinkMsg = '';
790
+ try {
791
+ unlinkSync(entryPath);
792
+ } catch (unlinkErr) {
793
+ unlinkMsg = `; additionally, rollback unlink of ${entryPath} failed: ${unlinkErr.message}`;
794
+ }
795
+ const err = new Error(
796
+ `JOURNAL_PARTIAL_WRITE: index write failed after writing entry ${entryPath}${unlinkMsg}`,
797
+ );
798
+ err.code = 'JOURNAL_PARTIAL_WRITE';
799
+ err.cause = indexErr;
800
+ throw err;
801
+ }
802
+ } else {
803
+ // overwrite: capture prior content for rollback.
804
+ const priorContent = existsSync(entryPath) ? readFileSync(entryPath, 'utf-8') : null;
805
+ atomicWrite(entryPath, entryContent);
806
+ try {
807
+ atomicWrite(indexPath, newIndexText);
808
+ } catch (indexErr) {
809
+ // Compensate: restore the original entry content.
810
+ let restoreMsg = '';
811
+ if (priorContent !== null) {
812
+ try {
813
+ atomicWrite(entryPath, priorContent);
814
+ } catch (restoreErr) {
815
+ restoreMsg = `; additionally, restore of ${entryPath} failed: ${restoreErr.message}`;
816
+ }
817
+ }
818
+ const err = new Error(
819
+ `JOURNAL_PARTIAL_WRITE: index write failed after overwriting entry ${entryPath}${restoreMsg}`,
820
+ );
821
+ err.code = 'JOURNAL_PARTIAL_WRITE';
822
+ err.cause = indexErr;
823
+ throw err;
824
+ }
825
+ }
826
+
827
+ // Audit event.
828
+ const event = {
829
+ tool: 'write_journal_entry',
830
+ date: args.date,
831
+ slug: args.slug,
832
+ session_number,
833
+ };
834
+ if (args.feature_code) event.feature_code = args.feature_code;
835
+ if (args.force) event.force = true;
836
+ if (args.idempotency_key) event.idempotency_key = args.idempotency_key;
837
+ safeAppendEvent(cwd, event);
838
+
839
+ return { path: entryPath, session_number, index_line, idempotent: false };
840
+ } finally {
841
+ releaseLock();
842
+ }
843
+ });
844
+ }
845
+
846
+ // ---------------------------------------------------------------------------
847
+ // getJournalEntries
848
+ // ---------------------------------------------------------------------------
849
+
850
+ /**
851
+ * @param {string} cwd
852
+ * @param {object} [opts]
853
+ * @param {string} [opts.since]
854
+ * @param {string} [opts.feature_code]
855
+ * @param {number} [opts.session]
856
+ * @param {number} [opts.limit]
857
+ * @returns {{ entries: Array, count: number }}
858
+ */
859
+ export function getJournalEntries(cwd, opts = {}) {
860
+ const journalDir = join(cwd, JOURNAL_DIR);
861
+ if (!existsSync(journalDir)) return { entries: [], count: 0 };
862
+
863
+ const sinceMs = opts.since !== undefined ? normalizeSince(opts.since) : null;
864
+ const featureFilter = opts.feature_code || null;
865
+ const sessionFilter = opts.session !== undefined ? opts.session : null;
866
+ const rawLimit = typeof opts.limit === 'number' ? opts.limit : DEFAULT_LIMIT;
867
+ const limit = Math.max(0, Math.min(MAX_LIMIT, rawLimit));
868
+
869
+ const dirEntries = readdirSync(journalDir);
870
+ const matched = [];
871
+
872
+ for (const name of dirEntries) {
873
+ const m = name.match(FILENAME_RE);
874
+ if (!m) continue;
875
+
876
+ const entryDate = m[1];
877
+ const entrySession = parseInt(m[2], 10);
878
+ const entrySlug = m[3];
879
+ const entryPath = join(journalDir, name);
880
+
881
+ // Apply since filter early (using filename date).
882
+ if (sinceMs !== null) {
883
+ const entryMs = Date.parse(entryDate);
884
+ if (Number.isNaN(entryMs) || entryMs < sinceMs) continue;
885
+ }
886
+
887
+ // Apply session filter early.
888
+ if (sessionFilter !== null && entrySession !== sessionFilter) continue;
889
+
890
+ // Read and parse.
891
+ let parsed;
892
+ try {
893
+ const text = readFileSync(entryPath, 'utf-8');
894
+ parsed = parseJournalEntry(text);
895
+ } catch {
896
+ continue;
897
+ }
898
+
899
+ // Apply feature_code filter.
900
+ if (featureFilter !== null && parsed.feature_code !== featureFilter) continue;
901
+
902
+ matched.push({
903
+ date: entryDate,
904
+ session_number: entrySession,
905
+ slug: entrySlug,
906
+ path: entryPath,
907
+ summary: parsed.frontmatter.summary || null,
908
+ feature_code: parsed.feature_code,
909
+ sections: {
910
+ what_happened: parsed.sections.what_happened,
911
+ what_we_built: parsed.sections.what_we_built,
912
+ what_we_learned: parsed.sections.what_we_learned,
913
+ open_threads: parsed.sections.open_threads,
914
+ },
915
+ unknownSections: parsed.unknownSections,
916
+ closing_line: parsed.closing_line,
917
+ });
918
+ }
919
+
920
+ // Sort newest-first: date desc, session_number desc.
921
+ matched.sort((a, b) => {
922
+ if (b.date !== a.date) return b.date.localeCompare(a.date);
923
+ return b.session_number - a.session_number;
924
+ });
925
+
926
+ const entries = matched.slice(0, limit);
927
+ return { entries, count: entries.length };
928
+ }