@hegemonart/get-design-done 1.56.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,717 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/state/render-markdown.cjs - Phase 57 (SQL-03).
4
+ *
5
+ * renderStateMarkdown(db, cycle_id, sdk) -> string [SYNCHRONOUS]
6
+ *
7
+ * Reconstructs the exact ParseResult that parse(originalStateMd) would yield,
8
+ * from SQLite rows, then delegates to sdk.serialize(state, fidelity).
9
+ * This makes byte-equality with SDK canonical form guaranteed by construction.
10
+ *
11
+ * sdk = { serialize, parse } -- INJECTED, no internal dynamic import.
12
+ * All callers (state-store, tests) load the SDK asynchronously before calling
13
+ * this function, then pass it in. Nothing async inside this function or the
14
+ * transaction it is called from.
15
+ *
16
+ * Structured blocks (position/decisions/must_haves/blockers): reconstructed
17
+ * from their structured tables with raw_line fidelity (reparse-compare logic
18
+ * mirrors mutator.ts).
19
+ *
20
+ * Unstructured blocks (connections/timestamps/parallelism_decision/todos/
21
+ * prototyping/quality_gate): round-tripped verbatim from _block_meta.raw_body.
22
+ * If raw_body is stored, the block is emitted with its raw body verbatim.
23
+ * If not stored and no structured data, the block is omitted.
24
+ *
25
+ * Contract:
26
+ * - renderStateMarkdown(null, ...) throws TypeError.
27
+ * - renderStateMarkdown(undefined, ...) throws TypeError.
28
+ * - renderStateMarkdown(db, cycle_id, null) or missing sdk: throws TypeError.
29
+ * - Missing cycle_id row: throws Error.
30
+ *
31
+ * Optional additive views (derived markdown, not round-trip-critical):
32
+ * renderDecisionLog(db, cycle_id) -> string [returns Promise for compat]
33
+ * renderBlockers(db, cycle_id) -> string [returns Promise for compat]
34
+ */
35
+
36
+ const path = require('node:path');
37
+ const fs = require('node:fs');
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // BLOCK_ORDER (mirrored from parser.ts - canonical serialization order).
41
+ // ---------------------------------------------------------------------------
42
+ const BLOCK_ORDER = [
43
+ 'position',
44
+ 'decisions',
45
+ 'must_haves',
46
+ 'prototyping',
47
+ 'quality_gate',
48
+ 'connections',
49
+ 'blockers',
50
+ 'parallelism_decision',
51
+ 'todos',
52
+ 'timestamps',
53
+ ];
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Canonical emitters (fallback when raw_line is absent or re-parse drifted).
57
+ // These mirror mutator.ts canonical forms exactly.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** @param {string} v */
61
+ function quoteIfEmpty(v) {
62
+ return v === '' ? '""' : v;
63
+ }
64
+
65
+ /**
66
+ * Canonical position block body.
67
+ * @param {{stage:string, wave:number|string, task_progress:string, status:string,
68
+ * handoff_source:string, handoff_path:string, skipped_stages:string}} pos
69
+ */
70
+ function canonicalPosition(pos) {
71
+ return [
72
+ `stage: ${pos.stage}`,
73
+ `wave: ${pos.wave}`,
74
+ `task_progress: ${pos.task_progress}`,
75
+ `status: ${pos.status}`,
76
+ `handoff_source: ${quoteIfEmpty(pos.handoff_source || '')}`,
77
+ `handoff_path: ${quoteIfEmpty(pos.handoff_path || '')}`,
78
+ `skipped_stages: ${quoteIfEmpty(pos.skipped_stages || '')}`,
79
+ ].join('\n');
80
+ }
81
+
82
+ /**
83
+ * Canonical single decision line.
84
+ * @param {{id:string, text:string, status:string}} d
85
+ */
86
+ function canonicalDecision(d) {
87
+ return `${d.id}: ${d.text} (${d.status})`;
88
+ }
89
+
90
+ /**
91
+ * Canonical single must_have line.
92
+ * @param {{id:string, text:string, status:string}} m
93
+ */
94
+ function canonicalMustHave(m) {
95
+ return `${m.id}: ${m.text} | status: ${m.status}`;
96
+ }
97
+
98
+ /**
99
+ * Canonical single blocker line.
100
+ * @param {{stage:string, date:string, text:string}} b
101
+ */
102
+ function canonicalBlocker(b) {
103
+ return `[${b.stage}] [${b.date}]: ${b.text}`;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // tryReparse* helpers (mirror mutator.ts semantic-compare logic).
108
+ // Return the typed value if raw_line parses cleanly, else null.
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Re-parse a single position raw_body string.
113
+ * Returns a Position-like object or null.
114
+ * @param {string} raw
115
+ */
116
+ function tryReparsePosition(raw) {
117
+ try {
118
+ const fields = {};
119
+ for (const line of raw.split('\n')) {
120
+ const trimmed = line.trim();
121
+ if (trimmed === '' || trimmed.startsWith('<!--')) continue;
122
+ const idx = line.indexOf(':');
123
+ if (idx === -1) continue;
124
+ const key = line.slice(0, idx).trim();
125
+ let value = line.slice(idx + 1).trim();
126
+ if ((value.startsWith('"') && value.endsWith('"')) ||
127
+ (value.startsWith("'") && value.endsWith("'"))) {
128
+ value = value.slice(1, -1);
129
+ }
130
+ fields[key] = value;
131
+ }
132
+ const waveNum = Number(fields['wave'] ?? '1');
133
+ if (!Number.isFinite(waveNum)) return null;
134
+ return {
135
+ stage: fields['stage'] ?? '',
136
+ wave: waveNum,
137
+ task_progress: fields['task_progress'] ?? '0/0',
138
+ status: fields['status'] ?? 'initialized',
139
+ handoff_source: fields['handoff_source'] ?? '',
140
+ handoff_path: fields['handoff_path'] ?? '',
141
+ skipped_stages: fields['skipped_stages'] ?? '',
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Re-parse a single decision raw_line.
150
+ * Returns {id,text,status} or null.
151
+ * @param {string} raw
152
+ */
153
+ function tryReparseDecisionLine(raw) {
154
+ try {
155
+ const re = /^(D-\d+):\s*(.*?)\s*\((locked|tentative)\)\s*$/;
156
+ const t = raw.trim();
157
+ if (!t || t.startsWith('<!--')) return null;
158
+ const m = t.match(re);
159
+ if (!m) return null;
160
+ return { id: m[1] ?? '', text: m[2] ?? '', status: m[3] ?? '' };
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Re-parse a single must_have raw_line.
168
+ * Returns {id,text,status} or null.
169
+ * @param {string} raw
170
+ */
171
+ function tryReparseMustHaveLine(raw) {
172
+ try {
173
+ const re = /^(M-\d+):\s*(.*?)\s*\|\s*status:\s*(pending|pass|fail)\s*$/;
174
+ const t = raw.trim();
175
+ if (!t || t.startsWith('<!--')) return null;
176
+ const m = t.match(re);
177
+ if (!m) return null;
178
+ return { id: m[1] ?? '', text: m[2] ?? '', status: m[3] ?? '' };
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Re-parse a single blocker raw_line.
186
+ * Returns {stage,date,text} or null.
187
+ * @param {string} raw
188
+ */
189
+ function tryReparseBlockerLine(raw) {
190
+ try {
191
+ const re = /^\[([^\]]+)\]\s*\[([^\]]+)\]:\s*(.*)$/;
192
+ const t = raw.trim();
193
+ if (!t || t.startsWith('<!--')) return null;
194
+ const m = t.match(re);
195
+ if (!m) return null;
196
+ return { stage: m[1] ?? '', date: m[2] ?? '', text: m[3] ?? '' };
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Semantic equality helpers (mirroring mutator.ts).
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function positionEqual(a, b) {
207
+ return (
208
+ a.stage === b.stage &&
209
+ String(a.wave) === String(b.wave) &&
210
+ a.task_progress === b.task_progress &&
211
+ a.status === b.status &&
212
+ a.handoff_source === b.handoff_source &&
213
+ a.handoff_path === b.handoff_path &&
214
+ a.skipped_stages === b.skipped_stages
215
+ );
216
+ }
217
+
218
+ function decisionEqual(a, b) {
219
+ return a.id === b.id && a.text === b.text && a.status === b.status;
220
+ }
221
+
222
+ function mustHaveEqual(a, b) {
223
+ return a.id === b.id && a.text === b.text && a.status === b.status;
224
+ }
225
+
226
+ function blockerEqual(a, b) {
227
+ return a.stage === b.stage && a.date === b.date && a.text === b.text;
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Main: renderStateMarkdown(db, cycle_id, sdk) -> string [SYNCHRONOUS]
232
+ //
233
+ // sdk = { serialize, parse } — injected by callers (state-store or tests).
234
+ // No internal dynamic import. Safe to call from inside a better-sqlite3
235
+ // db.transaction() callback.
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /**
239
+ * Render STATE.md text from SQLite rows for the given cycle_id.
240
+ * Delegates to sdk.serialize() for guaranteed canonical-form round-trip.
241
+ *
242
+ * SYNCHRONOUS — no await, no dynamic import inside. sdk is injected.
243
+ *
244
+ * @param {any} db - better-sqlite3 Database instance (must be non-null)
245
+ * @param {string} cycle_id
246
+ * @param {{ serialize: Function, parse: Function }} sdk - injected SDK
247
+ * @returns {string}
248
+ * @throws {TypeError} if db is null/undefined or sdk is missing
249
+ * @throws {Error} if no state_position row exists for cycle_id
250
+ */
251
+ function renderStateMarkdown(db, cycle_id, sdk) {
252
+ if (db === null || db === undefined) {
253
+ throw new TypeError(
254
+ 'renderStateMarkdown: db must be a better-sqlite3 Database instance, got ' +
255
+ (db === null ? 'null' : 'undefined'),
256
+ );
257
+ }
258
+ if (!sdk || typeof sdk.serialize !== 'function' || typeof sdk.parse !== 'function') {
259
+ throw new TypeError(
260
+ 'renderStateMarkdown: sdk must be { serialize, parse } — inject the SDK before calling',
261
+ );
262
+ }
263
+
264
+ // --- 1. Fetch state_position row ---
265
+ const posRow = db.prepare(
266
+ 'SELECT * FROM state_position WHERE cycle_id = ?'
267
+ ).get(cycle_id);
268
+
269
+ if (!posRow) {
270
+ throw new Error(
271
+ `renderStateMarkdown: no state_position row found for cycle_id "${cycle_id}"`,
272
+ );
273
+ }
274
+
275
+ // --- 2. Fetch ordered rows for structured blocks ---
276
+ const decisionRows = db.prepare(
277
+ 'SELECT * FROM decisions WHERE cycle_id = ? ORDER BY ordinal ASC'
278
+ ).all(cycle_id);
279
+
280
+ const blockerRows = db.prepare(
281
+ 'SELECT * FROM blockers WHERE cycle_id = ? ORDER BY ordinal ASC'
282
+ ).all(cycle_id);
283
+
284
+ const mustHaveRows = db.prepare(
285
+ 'SELECT * FROM must_haves WHERE cycle_id = ? ORDER BY ordinal ASC'
286
+ ).all(cycle_id);
287
+
288
+ // --- 3. Fetch _block_meta (gaps + raw_body per block) ---
289
+ const blockMetaRows = db.prepare(
290
+ 'SELECT block, gap, raw_body FROM _block_meta WHERE cycle_id = ?'
291
+ ).all(cycle_id);
292
+
293
+ /** @type {Record<string, string>} */
294
+ const blockGaps = {};
295
+ /** @type {Record<string, string|null>} */
296
+ const blockRawBodies = {};
297
+
298
+ for (const row of blockMetaRows) {
299
+ if (row.gap !== undefined && row.gap !== null) {
300
+ blockGaps[row.block] = row.gap;
301
+ }
302
+ if (row.raw_body !== undefined) {
303
+ blockRawBodies[row.block] = row.raw_body;
304
+ }
305
+ }
306
+
307
+ // --- 4. Reconstruct raw_bodies for SDK fidelity ---
308
+ // For structured blocks: re-derive the raw_body by emitting each row
309
+ // with fidelity (raw_line verbatim when it matches structured fields,
310
+ // canonical form otherwise). This is EXACTLY what the SDK's serialize()
311
+ // does when it compares raw vs typed.
312
+ //
313
+ // For unstructured blocks: use verbatim raw_body from _block_meta.
314
+
315
+ /** @type {Record<string, string|null>} */
316
+ const raw_bodies = {
317
+ position: null,
318
+ decisions: null,
319
+ must_haves: null,
320
+ prototyping: null,
321
+ quality_gate: null,
322
+ connections: null,
323
+ blockers: null,
324
+ parallelism_decision: null,
325
+ todos: null,
326
+ timestamps: null,
327
+ };
328
+
329
+ // --- position raw_body ---
330
+ // Use stored raw_body from _block_meta if present; otherwise null (forces canonical).
331
+ const posRawBody = blockRawBodies['position'] !== undefined ? blockRawBodies['position'] : (posRow.raw_body || null);
332
+ raw_bodies.position = posRawBody;
333
+
334
+ // --- decisions raw_body ---
335
+ // Re-derive from row data with raw_line fidelity.
336
+ if (decisionRows.length > 0) {
337
+ const lines = decisionRows.map((row) => {
338
+ const structured = { id: row.id, text: row.body_md || '', status: row.status };
339
+ if (row.raw_line) {
340
+ const reparsed = tryReparseDecisionLine(row.raw_line);
341
+ if (reparsed !== null && decisionEqual(reparsed, structured)) {
342
+ return row.raw_line;
343
+ }
344
+ }
345
+ return canonicalDecision(structured);
346
+ });
347
+ raw_bodies.decisions = lines.join('\n');
348
+ } else if ('decisions' in blockGaps) {
349
+ // Block was present with no rows - preserve empty block.
350
+ raw_bodies.decisions = blockRawBodies['decisions'] !== undefined
351
+ ? blockRawBodies['decisions']
352
+ : '';
353
+ } else if (blockRawBodies['decisions'] !== undefined) {
354
+ raw_bodies.decisions = blockRawBodies['decisions'];
355
+ }
356
+
357
+ // --- must_haves raw_body ---
358
+ if (mustHaveRows.length > 0) {
359
+ const lines = mustHaveRows.map((row) => {
360
+ const structured = { id: row.id, text: row.body_md || '', status: row.status };
361
+ if (row.raw_line) {
362
+ const reparsed = tryReparseMustHaveLine(row.raw_line);
363
+ if (reparsed !== null && mustHaveEqual(reparsed, structured)) {
364
+ return row.raw_line;
365
+ }
366
+ }
367
+ return canonicalMustHave(structured);
368
+ });
369
+ raw_bodies.must_haves = lines.join('\n');
370
+ } else if ('must_haves' in blockGaps) {
371
+ raw_bodies.must_haves = blockRawBodies['must_haves'] !== undefined
372
+ ? blockRawBodies['must_haves']
373
+ : '';
374
+ } else if (blockRawBodies['must_haves'] !== undefined) {
375
+ raw_bodies.must_haves = blockRawBodies['must_haves'];
376
+ }
377
+
378
+ // --- blockers raw_body ---
379
+ // Only unresolved blockers go in the STATE.md <blockers> block.
380
+ const activeBlockers = blockerRows.filter((r) => !r.resolved_at);
381
+ if (activeBlockers.length > 0) {
382
+ const lines = activeBlockers.map((row) => {
383
+ // ALWAYS prefer raw_line for blockers (date-format hazard).
384
+ if (row.raw_line) return row.raw_line;
385
+ return canonicalBlocker({ stage: row.stage || '', date: row.date || '', text: row.body_md || '' });
386
+ });
387
+ raw_bodies.blockers = lines.join('\n');
388
+ } else if ('blockers' in blockGaps) {
389
+ raw_bodies.blockers = blockRawBodies['blockers'] !== undefined
390
+ ? blockRawBodies['blockers']
391
+ : '';
392
+ } else if (blockRawBodies['blockers'] !== undefined) {
393
+ raw_bodies.blockers = blockRawBodies['blockers'];
394
+ }
395
+
396
+ // --- unstructured blocks: verbatim from _block_meta.raw_body ---
397
+ for (const blockName of ['prototyping', 'quality_gate', 'connections', 'parallelism_decision', 'todos', 'timestamps']) {
398
+ if (blockRawBodies[blockName] !== undefined) {
399
+ raw_bodies[blockName] = blockRawBodies[blockName];
400
+ }
401
+ }
402
+
403
+ // --- 5. Reconstruct ParsedState from SQLite rows ---
404
+ // Parse the position raw_body to get structured Position fields.
405
+ let position;
406
+ if (posRawBody !== null) {
407
+ const reparsed = tryReparsePosition(posRawBody);
408
+ if (reparsed !== null) {
409
+ position = reparsed;
410
+ }
411
+ }
412
+ if (!position) {
413
+ position = {
414
+ stage: posRow.stage || '',
415
+ wave: posRow.wave != null ? Number(posRow.wave) : 1,
416
+ task_progress: posRow.task_progress || '0/0',
417
+ status: posRow.status || 'initialized',
418
+ handoff_source: posRow.handoff_source || '',
419
+ handoff_path: posRow.handoff_path || '',
420
+ skipped_stages: posRow.skipped_stages || '',
421
+ };
422
+ }
423
+
424
+ // Reconstruct decisions array.
425
+ const decisions = decisionRows.map((row) => ({
426
+ id: row.id,
427
+ text: row.body_md || '',
428
+ status: row.status || 'tentative',
429
+ }));
430
+
431
+ // Reconstruct must_haves array.
432
+ const must_haves = mustHaveRows.map((row) => ({
433
+ id: row.id,
434
+ text: row.body_md || '',
435
+ status: row.status || 'pending',
436
+ }));
437
+
438
+ // Reconstruct blockers array (unresolved only - these go in STATE.md).
439
+ const blockers = activeBlockers.map((row) => ({
440
+ stage: row.stage || '',
441
+ date: row.date || '',
442
+ text: row.body_md || '',
443
+ }));
444
+
445
+ // For unstructured blocks: parse from raw_body if available.
446
+ // Use sdk.parse to extract structured typed values.
447
+ let connections = {};
448
+ let timestamps = {};
449
+ let parallelism_decision = null;
450
+ let todos = null;
451
+ let prototyping = null;
452
+ let quality_gate = null;
453
+
454
+ // Parse connections from raw_body.
455
+ if (raw_bodies.connections !== null && raw_bodies.connections !== undefined) {
456
+ try {
457
+ const tempMd = `---\npipeline_state_version: 1.0\nstage: x\ncycle: x\nwave: 1\nstarted_at: \nlast_checkpoint: \n---\n\n<position>\nstage: x\nwave: 1\ntask_progress: 0/0\nstatus: initialized\nhandoff_source: ""\nhandoff_path: ""\nskipped_stages: ""\n</position>\n\n<connections>\n${raw_bodies.connections}\n</connections>\n`;
458
+ const parsed = sdk.parse(tempMd);
459
+ connections = parsed.state.connections;
460
+ } catch {
461
+ connections = {};
462
+ }
463
+ }
464
+
465
+ // Parse timestamps from raw_body.
466
+ if (raw_bodies.timestamps !== null && raw_bodies.timestamps !== undefined) {
467
+ try {
468
+ const tempMd = `---\npipeline_state_version: 1.0\nstage: x\ncycle: x\nwave: 1\nstarted_at: \nlast_checkpoint: \n---\n\n<position>\nstage: x\nwave: 1\ntask_progress: 0/0\nstatus: initialized\nhandoff_source: ""\nhandoff_path: ""\nskipped_stages: ""\n</position>\n\n<timestamps>\n${raw_bodies.timestamps}\n</timestamps>\n`;
469
+ const parsed = sdk.parse(tempMd);
470
+ timestamps = parsed.state.timestamps;
471
+ } catch {
472
+ timestamps = {};
473
+ }
474
+ }
475
+
476
+ // parallelism_decision is free-text.
477
+ if (raw_bodies.parallelism_decision !== null && raw_bodies.parallelism_decision !== undefined) {
478
+ parallelism_decision = raw_bodies.parallelism_decision;
479
+ }
480
+
481
+ // todos is free-text.
482
+ if (raw_bodies.todos !== null && raw_bodies.todos !== undefined) {
483
+ todos = raw_bodies.todos;
484
+ }
485
+
486
+ // prototyping: parse from raw_body.
487
+ if (raw_bodies.prototyping !== null && raw_bodies.prototyping !== undefined) {
488
+ try {
489
+ const tempMd = `---\npipeline_state_version: 1.0\nstage: x\ncycle: x\nwave: 1\nstarted_at: \nlast_checkpoint: \n---\n\n<position>\nstage: x\nwave: 1\ntask_progress: 0/0\nstatus: initialized\nhandoff_source: ""\nhandoff_path: ""\nskipped_stages: ""\n</position>\n\n<prototyping>\n${raw_bodies.prototyping}\n</prototyping>\n`;
490
+ const parsed = sdk.parse(tempMd);
491
+ prototyping = parsed.state.prototyping;
492
+ } catch {
493
+ prototyping = null;
494
+ }
495
+ }
496
+
497
+ // quality_gate: parse from raw_body.
498
+ if (raw_bodies.quality_gate !== null && raw_bodies.quality_gate !== undefined) {
499
+ try {
500
+ const tempMd = `---\npipeline_state_version: 1.0\nstage: x\ncycle: x\nwave: 1\nstarted_at: \nlast_checkpoint: \n---\n\n<position>\nstage: x\nwave: 1\ntask_progress: 0/0\nstatus: initialized\nhandoff_source: ""\nhandoff_path: ""\nskipped_stages: ""\n</position>\n\n<quality_gate>\n${raw_bodies.quality_gate}\n</quality_gate>\n`;
501
+ const parsed = sdk.parse(tempMd);
502
+ quality_gate = parsed.state.quality_gate;
503
+ } catch {
504
+ quality_gate = null;
505
+ }
506
+ }
507
+
508
+ // --- 6. Reconstruct ParsedState ---
509
+ const state = {
510
+ frontmatter: _parseFrontmatter(posRow.raw_frontmatter || '', cycle_id, posRow),
511
+ position,
512
+ decisions,
513
+ must_haves,
514
+ connections,
515
+ blockers,
516
+ parallelism_decision,
517
+ todos,
518
+ prototyping,
519
+ quality_gate,
520
+ timestamps,
521
+ body_preamble: posRow.body_preamble || '',
522
+ body_trailer: posRow.body_trailer || '',
523
+ };
524
+
525
+ // --- 7. Build block_gaps from _block_meta ---
526
+ /** @type {Record<string, string>} */
527
+ const block_gaps = {
528
+ position: '',
529
+ decisions: '',
530
+ must_haves: '',
531
+ prototyping: '',
532
+ quality_gate: '',
533
+ connections: '',
534
+ blockers: '',
535
+ parallelism_decision: '',
536
+ todos: '',
537
+ timestamps: '',
538
+ };
539
+ for (const [k, v] of Object.entries(blockGaps)) {
540
+ if (k in block_gaps) block_gaps[k] = v;
541
+ }
542
+
543
+ // --- 8. Delegate to sdk.serialize ---
544
+ return sdk.serialize(state, {
545
+ raw_frontmatter: posRow.raw_frontmatter || null,
546
+ raw_bodies,
547
+ block_gaps,
548
+ line_ending: posRow.line_ending || '\n',
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Parse frontmatter text into a Frontmatter object.
554
+ * Minimal inline parser (mirrors parseFrontmatter in parser.ts).
555
+ * Falls back to sensible defaults when raw_frontmatter is absent.
556
+ * @param {string} rawFm - verbatim frontmatter body (between --- fences)
557
+ * @param {string} cycleId
558
+ * @param {object} posRow - state_position row (fallback fields)
559
+ * @returns {object}
560
+ */
561
+ function _parseFrontmatter(rawFm, cycleId, posRow) {
562
+ const out = {};
563
+ for (const line of rawFm.split('\n')) {
564
+ const trimmed = line.trim();
565
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
566
+ const idx = line.indexOf(':');
567
+ if (idx === -1) continue;
568
+ const key = line.slice(0, idx).trim();
569
+ let value = line.slice(idx + 1).trim();
570
+ if ((value.startsWith('"') && value.endsWith('"')) ||
571
+ (value.startsWith("'") && value.endsWith("'"))) {
572
+ value = value.slice(1, -1);
573
+ }
574
+ if (key === 'wave') {
575
+ const n = Number(value);
576
+ out[key] = Number.isFinite(n) ? n : value;
577
+ } else {
578
+ out[key] = value;
579
+ }
580
+ }
581
+ const fm = {
582
+ pipeline_state_version: String(out['pipeline_state_version'] ?? '1.0'),
583
+ stage: String(out['stage'] ?? posRow.stage ?? ''),
584
+ cycle: String(out['cycle'] ?? cycleId ?? ''),
585
+ wave: typeof out['wave'] === 'number' ? out['wave'] : (posRow.wave != null ? Number(posRow.wave) : 1),
586
+ started_at: String(out['started_at'] ?? ''),
587
+ last_checkpoint: String(out['last_checkpoint'] ?? ''),
588
+ };
589
+ for (const [k, v] of Object.entries(out)) {
590
+ if (!(k in fm)) fm[k] = v;
591
+ }
592
+ return fm;
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // Optional additive views (derived markdown; NOT round-trip-critical).
597
+ // Return Promises for compatibility with callers that await them.
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /**
601
+ * Render a markdown decision log for the given cycle_id.
602
+ * Additive view - not round-trip-critical.
603
+ *
604
+ * @param {any} db - better-sqlite3 Database instance
605
+ * @param {string} [cycle_id] - if omitted, renders all decisions
606
+ * @returns {Promise<string>}
607
+ */
608
+ async function renderDecisionLog(db, cycle_id) {
609
+ if (db === null || db === undefined) {
610
+ throw new TypeError('renderDecisionLog: db must be a better-sqlite3 Database instance');
611
+ }
612
+
613
+ let rows;
614
+ if (cycle_id) {
615
+ rows = db.prepare(
616
+ 'SELECT * FROM decisions WHERE cycle_id = ? ORDER BY ordinal ASC'
617
+ ).all(cycle_id);
618
+ } else {
619
+ rows = db.prepare(
620
+ 'SELECT * FROM decisions ORDER BY cycle_id, ordinal ASC'
621
+ ).all();
622
+ }
623
+
624
+ if (!rows || rows.length === 0) {
625
+ return `# Decision Log\n\n_No decisions recorded._\n`;
626
+ }
627
+
628
+ const lines = [
629
+ '# Decision Log',
630
+ '',
631
+ ];
632
+
633
+ let lastCycle = null;
634
+ for (const row of rows) {
635
+ if (row.cycle_id !== lastCycle) {
636
+ if (lastCycle !== null) lines.push('');
637
+ lines.push(`## Cycle: ${row.cycle_id}`);
638
+ lines.push('');
639
+ lastCycle = row.cycle_id;
640
+ }
641
+ const status = row.status === 'locked' ? 'locked' : 'tentative';
642
+ const tags = row.tags ? ` [${row.tags}]` : '';
643
+ lines.push(`- **${row.id}** (${status})${tags}: ${row.body_md || ''}`);
644
+ }
645
+
646
+ lines.push('');
647
+ return lines.join('\n');
648
+ }
649
+
650
+ /**
651
+ * Render a markdown blockers report for the given cycle_id.
652
+ * Additive view - not round-trip-critical.
653
+ *
654
+ * @param {any} db - better-sqlite3 Database instance
655
+ * @param {string} [cycle_id] - if omitted, renders all active blockers
656
+ * @returns {Promise<string>}
657
+ */
658
+ async function renderBlockers(db, cycle_id) {
659
+ if (db === null || db === undefined) {
660
+ throw new TypeError('renderBlockers: db must be a better-sqlite3 Database instance');
661
+ }
662
+
663
+ let rows;
664
+ if (cycle_id) {
665
+ rows = db.prepare(
666
+ 'SELECT * FROM blockers WHERE cycle_id = ? AND resolved_at IS NULL ORDER BY ordinal ASC'
667
+ ).all(cycle_id);
668
+ } else {
669
+ rows = db.prepare(
670
+ 'SELECT * FROM blockers WHERE resolved_at IS NULL ORDER BY cycle_id, ordinal ASC'
671
+ ).all();
672
+ }
673
+
674
+ if (!rows || rows.length === 0) {
675
+ return `# Active Blockers\n\n_No active blockers._\n`;
676
+ }
677
+
678
+ const lines = [
679
+ '# Active Blockers',
680
+ '',
681
+ ];
682
+
683
+ let lastCycle = null;
684
+ for (const row of rows) {
685
+ if (row.cycle_id !== lastCycle) {
686
+ if (lastCycle !== null) lines.push('');
687
+ lines.push(`## Cycle: ${row.cycle_id}`);
688
+ lines.push('');
689
+ lastCycle = row.cycle_id;
690
+ }
691
+ const severity = row.severity ? ` [${row.severity}]` : '';
692
+ lines.push(`- **[${row.stage}] [${row.date}]**${severity}: ${row.body_md || ''}`);
693
+ }
694
+
695
+ lines.push('');
696
+ return lines.join('\n');
697
+ }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // Exports
701
+ // ---------------------------------------------------------------------------
702
+
703
+ module.exports = {
704
+ renderStateMarkdown,
705
+ renderDecisionLog,
706
+ renderBlockers,
707
+ // Export for tests
708
+ _BLOCK_ORDER: BLOCK_ORDER,
709
+ _tryReparseDecisionLine: tryReparseDecisionLine,
710
+ _tryReparseMustHaveLine: tryReparseMustHaveLine,
711
+ _tryReparseBlockerLine: tryReparseBlockerLine,
712
+ _tryReparsePosition: tryReparsePosition,
713
+ _canonicalPosition: canonicalPosition,
714
+ _canonicalDecision: canonicalDecision,
715
+ _canonicalMustHave: canonicalMustHave,
716
+ _canonicalBlocker: canonicalBlocker,
717
+ };