@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.
- package/bin/compose.js +279 -0
- package/bin/git-hooks/post-commit.template +61 -0
- package/lib/changelog-writer.js +647 -0
- package/lib/completion-writer.js +465 -0
- package/lib/feature-writer.js +324 -4
- package/lib/journal-writer.js +928 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +62 -0
- package/server/compose-mcp.js +216 -1
|
@@ -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
|
+
}
|