@nusoft/nuos-build-catalogue 0.33.3 → 0.36.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,793 @@
1
+ /**
2
+ * `nuos-catalogue state compile` — STATE.md hybrid-document recompile (WU 113b / D132).
3
+ *
4
+ * Reads canonical state from the **live markdown registers** (not the workflow
5
+ * store, which is stale under Mode 1) and splices the generated sections into
6
+ * the sentinel-delimited regions of STATE.md, leaving all authored prose
7
+ * byte-for-byte identical.
8
+ *
9
+ * **Source-of-truth for each generated region (D129 / Mode 1):**
10
+ * - Active WU: `.nuos-catalogue/active-wu` marker file (WU 136 pointer)
11
+ * + title/status resolved from `work-units/_index.md`
12
+ * - WUs in progress: 🟡 row count in `work-units/_index.md`
13
+ * - WUs completed: file count in `work-units/done/`
14
+ * - Blocked WUs: 🔴 rows in `work-units/_index.md`
15
+ * - Decisions: `decisions/_index.md` active section
16
+ * - Open questions: `open-questions/_index.md` active section
17
+ * - Risks: `risks/_index.md` active section
18
+ *
19
+ * The workflow store (`workflows.json`) is accepted as a parameter for API
20
+ * compatibility (the CLI always opens it), but is NOT consulted for any of
21
+ * the above — it is frozen at migration time and would produce stale counts.
22
+ *
23
+ * **No LLM in this path.** The adapter builds an `LLMCompilationOutput`
24
+ * directly from disk state. `renderArticleMarkdown` is called per section,
25
+ * then `spliceGeneratedRegions` writes only inside the sentinel pairs.
26
+ *
27
+ * **First-cutover boundary.** If a sentinel region is absent from the target
28
+ * STATE.md, this command reports the missing regions clearly and exits
29
+ * non-zero without guessing where to insert them. The one-time insertion of
30
+ * sentinels into the live file is a manual operator step (Stage B walkthrough).
31
+ *
32
+ * D132 / D129 boundary:
33
+ * - Generated regions: live markdown registers are source of truth; disk is
34
+ * rendered projection for these regions only.
35
+ * - Authored regions: disk remains the edit base (untouched by this command).
36
+ */
37
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
38
+ import path from 'node:path';
39
+ import { renderArticleMarkdown, spliceGeneratedRegions, checkArticleDrift, } from '@nusoft/nuwiki';
40
+ import { resolveIndexDir } from '../path-resolution.js';
41
+ // ---------------------------------------------------------------------------
42
+ // Sentinel configuration — the marker scheme for STATE.md generated regions.
43
+ // HTML-comment markers, compatible with STATE.md's existing nuos:sentinel scheme.
44
+ // The `{{key}}` placeholder is replaced by the region key; `{{marker}}` is
45
+ // replaced by the expanded marker.
46
+ // ---------------------------------------------------------------------------
47
+ export const STATE_SENTINEL_CONFIG = {
48
+ markerPattern: 'nuos:generated:{{key}}',
49
+ openTemplate: '<!-- {{marker}}:start -->',
50
+ closeTemplate: '<!-- {{marker}}:end -->',
51
+ };
52
+ // ---------------------------------------------------------------------------
53
+ // Region keys — one per generated section (per WU 113b section map).
54
+ // ---------------------------------------------------------------------------
55
+ export const STATE_REGION_KEYS = {
56
+ METADATA: 'metadata',
57
+ WHAT_IS_NEXT: 'what_is_next',
58
+ OPEN_QUESTIONS: 'open_questions',
59
+ RECENT_DECISIONS: 'recent_decisions',
60
+ RISKS: 'risks',
61
+ HEALTH_CHECK: 'health_check',
62
+ };
63
+ /**
64
+ * Reads canonical state from the live markdown registers and the active-WU
65
+ * marker file, and produces the generated content for each STATE.md region.
66
+ *
67
+ * No LLM call is made. The adapter derives all content deterministically.
68
+ * The workflow store parameter is accepted for API compatibility but is not
69
+ * consulted — see module-level comment for the source-of-truth map.
70
+ */
71
+ export async function buildStateCompilationOutput(input) {
72
+ const { buildRoot } = input;
73
+ const now = input.now ?? new Date().toISOString();
74
+ const today = now.slice(0, 10);
75
+ // 1. Active WU — from the .nuos-catalogue/active-wu marker file (WU 136).
76
+ // Title + status resolved from work-units/_index.md (live source).
77
+ const activeWu = await readActiveWuFromMarker(buildRoot);
78
+ // 2. Blocked WUs — from 🔴 rows in work-units/_index.md.
79
+ const blockedWorkflows = await readBlockedWorkflowsFromIndex(buildRoot);
80
+ // 3. Register indexes (all parsed from live disk files).
81
+ const unresolvedQuestions = await readUnresolvedQuestions(buildRoot);
82
+ const recentDecisions = await readRecentDecisions(buildRoot);
83
+ const activeRisks = await readActiveRisks(buildRoot);
84
+ const healthStats = await readHealthStatsFromDisk(buildRoot);
85
+ // 4. Build each section's text content.
86
+ const metadataText = renderMetadataSection(activeWu, today, healthStats);
87
+ const whatIsNextText = renderWhatIsNextSection(activeWu, blockedWorkflows);
88
+ const openQuestionsText = renderOpenQuestionsSection(unresolvedQuestions);
89
+ const recentDecisionsText = renderRecentDecisionsSection(recentDecisions);
90
+ const risksText = renderRisksSection(activeRisks);
91
+ const healthCheckText = renderHealthCheckSection(healthStats);
92
+ // 5. Assemble LLMCompilationOutput (one section per region, positionally ordered)
93
+ const sections = [
94
+ { key: STATE_REGION_KEYS.METADATA, heading: 'Metadata', text: metadataText, citationIds: [], position: 1 },
95
+ { key: STATE_REGION_KEYS.WHAT_IS_NEXT, heading: 'What is next', text: whatIsNextText, citationIds: [], position: 2 },
96
+ { key: STATE_REGION_KEYS.OPEN_QUESTIONS, heading: 'Open questions blocking active work', text: openQuestionsText, citationIds: [], position: 3 },
97
+ { key: STATE_REGION_KEYS.RECENT_DECISIONS, heading: 'Recent decisions', text: recentDecisionsText, citationIds: [], position: 4 },
98
+ { key: STATE_REGION_KEYS.RISKS, heading: 'Risks currently being watched', text: risksText, citationIds: [], position: 5 },
99
+ { key: STATE_REGION_KEYS.HEALTH_CHECK, heading: 'Health check', text: healthCheckText, citationIds: [], position: 6 },
100
+ ];
101
+ const compilationOutput = {
102
+ summary: `STATE.md compiled ${today} from live markdown registers. Active: ${activeWu?.handle ?? 'none'}.`,
103
+ sections,
104
+ citations: [],
105
+ outboundLinks: [],
106
+ };
107
+ // 5. Render each section to markdown (the splice expects the body text, no heading)
108
+ const regions = {};
109
+ for (const section of sections) {
110
+ const md = renderArticleMarkdown(compilationOutput, { sections: [section.key] });
111
+ // renderArticleMarkdown produces "## Heading\n\ntext\n" — we keep the full
112
+ // rendering including the heading so the sentinel region is self-contained.
113
+ regions[section.key] = md;
114
+ }
115
+ return { compilationOutput, regions };
116
+ }
117
+ export async function cmdStateCompile(store, args) {
118
+ const stateMdPath = args.stateMdPath ?? path.join(args.buildRoot, 'STATE.md');
119
+ // Read the current on-disk STATE.md — this is the edit base for authored prose.
120
+ let existingFile;
121
+ try {
122
+ existingFile = await readFile(stateMdPath, 'utf8');
123
+ }
124
+ catch (err) {
125
+ return {
126
+ output: `state compile: cannot read STATE.md at ${stateMdPath}\n ${err instanceof Error ? err.message : String(err)}`,
127
+ exitCode: 1,
128
+ };
129
+ }
130
+ // Build the compiled output from canonical state.
131
+ let compiled;
132
+ try {
133
+ compiled = await buildStateCompilationOutput({
134
+ store,
135
+ buildRoot: args.buildRoot,
136
+ now: args.now,
137
+ });
138
+ }
139
+ catch (err) {
140
+ return {
141
+ output: `state compile: adapter error — ${err instanceof Error ? err.message : String(err)}`,
142
+ exitCode: 1,
143
+ };
144
+ }
145
+ // First-cutover guard: check that every region's sentinel pair is present.
146
+ // If any are missing, report them clearly and exit without modifying anything.
147
+ const missingRegions = [];
148
+ for (const key of Object.keys(compiled.regions)) {
149
+ const open = STATE_SENTINEL_CONFIG.openTemplate.replace('{{marker}}', STATE_SENTINEL_CONFIG.markerPattern.replace('{{key}}', key));
150
+ if (!existingFile.includes(open)) {
151
+ missingRegions.push(key);
152
+ }
153
+ }
154
+ if (missingRegions.length > 0) {
155
+ const lines = [
156
+ 'state compile: the following sentinel regions are absent from STATE.md:',
157
+ '',
158
+ ];
159
+ for (const key of missingRegions) {
160
+ const marker = STATE_SENTINEL_CONFIG.markerPattern.replace('{{key}}', key);
161
+ lines.push(` missing: <!-- ${marker}:start --> / <!-- ${marker}:end -->`);
162
+ }
163
+ lines.push('');
164
+ lines.push('This is expected on first cutover. The sentinel pairs must be inserted');
165
+ lines.push('manually into STATE.md by the operator (Stage B walkthrough) before');
166
+ lines.push('`state compile` can manage those regions.');
167
+ lines.push('');
168
+ lines.push('For each missing region, add a sentinel pair at the appropriate location:');
169
+ lines.push(' <!-- nuos:generated:<key>:start -->');
170
+ lines.push(' (generated content will appear here)');
171
+ lines.push(' <!-- nuos:generated:<key>:end -->');
172
+ return {
173
+ output: lines.join('\n'),
174
+ exitCode: 1,
175
+ };
176
+ }
177
+ // Splice the generated regions into the existing file.
178
+ let spliceResult;
179
+ try {
180
+ spliceResult = spliceGeneratedRegions({
181
+ existingFile,
182
+ regions: compiled.regions,
183
+ sentinelConfig: STATE_SENTINEL_CONFIG,
184
+ });
185
+ }
186
+ catch (err) {
187
+ return {
188
+ output: `state compile: splice error — ${err instanceof Error ? err.message : String(err)}`,
189
+ exitCode: 1,
190
+ };
191
+ }
192
+ if (args.dryRun) {
193
+ const lines = [
194
+ '',
195
+ '── state compile (dry run) ──────────────────────────────────────────',
196
+ ` target: ${stateMdPath}`,
197
+ ` updated regions: ${spliceResult.updatedRegions.length > 0 ? spliceResult.updatedRegions.join(', ') : '(none — already current)'}`,
198
+ ` unchanged regions: ${spliceResult.unchangedRegions.join(', ')}`,
199
+ ' (dry run — STATE.md was not written)',
200
+ '─────────────────────────────────────────────────────────────────────',
201
+ '',
202
+ ];
203
+ return {
204
+ output: lines.join('\n'),
205
+ exitCode: 0,
206
+ updatedRegions: spliceResult.updatedRegions,
207
+ unchangedRegions: spliceResult.unchangedRegions,
208
+ };
209
+ }
210
+ // Write the spliced content back to disk.
211
+ try {
212
+ await writeFile(stateMdPath, spliceResult.merged, 'utf8');
213
+ }
214
+ catch (err) {
215
+ return {
216
+ output: `state compile: cannot write STATE.md at ${stateMdPath}\n ${err instanceof Error ? err.message : String(err)}`,
217
+ exitCode: 1,
218
+ };
219
+ }
220
+ const lines = [
221
+ '',
222
+ '── state compile ────────────────────────────────────────────────────',
223
+ ` target: ${stateMdPath}`,
224
+ ` updated regions: ${spliceResult.updatedRegions.length > 0 ? spliceResult.updatedRegions.join(', ') : '(none — already current)'}`,
225
+ ` unchanged regions: ${spliceResult.unchangedRegions.join(', ')}`,
226
+ '─────────────────────────────────────────────────────────────────────',
227
+ '',
228
+ ];
229
+ return {
230
+ output: lines.join('\n'),
231
+ exitCode: 0,
232
+ updatedRegions: spliceResult.updatedRegions,
233
+ unchangedRegions: spliceResult.unchangedRegions,
234
+ };
235
+ }
236
+ /**
237
+ * Expose `checkArticleDrift` with STATE.md's sentinel config pre-applied.
238
+ * Used by the pre-commit hook (Stage B) and tests.
239
+ */
240
+ export function checkStateMdDrift(fileContent, expectedRegions) {
241
+ return checkArticleDrift({
242
+ file: fileContent,
243
+ sentinelConfig: STATE_SENTINEL_CONFIG,
244
+ expectedRegions,
245
+ });
246
+ }
247
+ /**
248
+ * Check whether the generated regions of STATE.md match what the canonical
249
+ * state currently produces. Designed to be called by the pre-commit hook.
250
+ *
251
+ * Exit-code contract (fail-open):
252
+ * - exit 0 when generated regions are clean
253
+ * - exit 0 when STATE.md has no sentinel regions yet (pre-cutover)
254
+ * - exit 0 when the check cannot run (STATE.md unreadable, store missing)
255
+ * - exit 1 ONLY on confirmed generated-region drift
256
+ */
257
+ export async function cmdStateDriftCheck(store, args) {
258
+ const stateMdPath = args.stateMdPath ?? path.join(args.buildRoot, 'STATE.md');
259
+ // Read the current on-disk STATE.md — if unreadable, fail open.
260
+ let existingFile;
261
+ try {
262
+ existingFile = await readFile(stateMdPath, 'utf8');
263
+ }
264
+ catch {
265
+ return {
266
+ output: `state drift-check: STATE.md unreadable at ${stateMdPath} — skipping (fail open)`,
267
+ exitCode: 0,
268
+ verdict: 'skipped',
269
+ };
270
+ }
271
+ // Pre-cutover guard: if none of the sentinel open-markers are present,
272
+ // the file has no sentinel regions yet — skip gracefully (fail open).
273
+ const hasAnySentinel = Object.values(STATE_REGION_KEYS).some((key) => {
274
+ const open = STATE_SENTINEL_CONFIG.openTemplate.replace('{{marker}}', STATE_SENTINEL_CONFIG.markerPattern.replace('{{key}}', key));
275
+ return existingFile.includes(open);
276
+ });
277
+ if (!hasAnySentinel) {
278
+ return {
279
+ output: 'state drift-check: no sentinel regions found in STATE.md — skipping (pre-cutover)',
280
+ exitCode: 0,
281
+ verdict: 'skipped',
282
+ };
283
+ }
284
+ // Build expected regions from canonical state.
285
+ let compiled;
286
+ try {
287
+ compiled = await buildStateCompilationOutput({
288
+ store,
289
+ buildRoot: args.buildRoot,
290
+ now: args.now,
291
+ });
292
+ }
293
+ catch {
294
+ return {
295
+ output: `state drift-check: adapter error — skipping (fail open)`,
296
+ exitCode: 0,
297
+ verdict: 'skipped',
298
+ };
299
+ }
300
+ // Run the drift check.
301
+ let driftReport;
302
+ try {
303
+ driftReport = checkStateMdDrift(existingFile, compiled.regions);
304
+ }
305
+ catch {
306
+ return {
307
+ output: `state drift-check: drift-check error — skipping (fail open)`,
308
+ exitCode: 0,
309
+ verdict: 'skipped',
310
+ };
311
+ }
312
+ if (driftReport.clean) {
313
+ return {
314
+ output: 'state drift-check: generated regions are current — clean',
315
+ exitCode: 0,
316
+ verdict: 'clean',
317
+ };
318
+ }
319
+ // Confirmed generated-region drift — exit non-zero.
320
+ const driftedRegions = driftReport.regions
321
+ .filter((r) => r.status !== 'clean')
322
+ .map((r) => r.key);
323
+ const lines = [
324
+ '✖ state drift-check: generated regions in STATE.md have drifted from canonical state.',
325
+ '',
326
+ ` Drifted region(s): ${driftedRegions.join(', ')}`,
327
+ '',
328
+ ' These regions are compiled deterministically from the workflow store and',
329
+ ' register indexes. Hand-editing them will be overwritten on next recompile.',
330
+ '',
331
+ ' To fix: recompile the generated regions and re-stage STATE.md:',
332
+ ' nuos-catalogue state compile',
333
+ ' git add docs/build/STATE.md',
334
+ '',
335
+ ' Then re-commit.',
336
+ ];
337
+ return {
338
+ output: lines.join('\n'),
339
+ exitCode: 1,
340
+ verdict: 'drifted',
341
+ driftedRegions,
342
+ };
343
+ }
344
+ /**
345
+ * Read the active WU from the `.nuos-catalogue/active-wu` marker file (WU 136).
346
+ * The handle stored there (e.g. `wu-113b`) is used to locate the matching row
347
+ * in `work-units/_index.md` to resolve the title and status.
348
+ *
349
+ * Degrades gracefully when:
350
+ * - the marker file is absent or empty → returns null (no active WU declared)
351
+ * - the index row is not found → returns the handle with unknown title/status
352
+ * - the index file is unreadable → returns the handle with unknown title/status
353
+ */
354
+ async function readActiveWuFromMarker(buildRoot) {
355
+ const catalogueDir = resolveIndexDir(buildRoot);
356
+ const markerPath = path.join(catalogueDir, 'active-wu');
357
+ let handle;
358
+ try {
359
+ const raw = await readFile(markerPath, 'utf8');
360
+ handle = raw.trim();
361
+ }
362
+ catch {
363
+ return null; // marker absent — no active WU declared
364
+ }
365
+ if (!handle)
366
+ return null;
367
+ // The handle is e.g. "wu-113b". Strip the "wu-" prefix to get the ID as it
368
+ // appears in the _index.md ID column (e.g. "113b").
369
+ const idInIndex = handle.replace(/^wu-/i, '');
370
+ const slug = idInIndex;
371
+ const indexContent = await readIndexFile(path.join(buildRoot, 'work-units', '_index.md'));
372
+ if (!indexContent) {
373
+ return { handle, title: '(title unknown — index unreadable)', status: 'in_progress', slug };
374
+ }
375
+ // Parse the matching row. Row shape: `| 113b | [Title](file.md) | 🟡 in_progress — ... | ... |`
376
+ for (const line of indexContent.split('\n')) {
377
+ if (!/^\s*\|/.test(line))
378
+ continue;
379
+ const cells = line.split('|').map((c) => c.trim());
380
+ // cells[1] = ID cell, cells[2] = title cell, cells[3] = status cell
381
+ if (cells.length < 4)
382
+ continue;
383
+ const idCell = cells[1];
384
+ if (idCell !== idInIndex)
385
+ continue;
386
+ const titleCell = cells[2] ?? '';
387
+ // Strip markdown link syntax if present: [Title](file.md) → Title
388
+ const titleMatch = titleCell.match(/^\[([^\]]+)\]/) ?? titleCell.match(/^(.+)$/);
389
+ const title = titleMatch ? titleMatch[1].trim() : titleCell.trim();
390
+ const statusCell = cells[3] ?? '';
391
+ // Extract the status keyword (first word after the emoji, up to ' — ' or end)
392
+ const statusMatch = statusCell.match(/(?:🟡|🔴|🟢|🔵|🟣|✅|⚫)\s+(\S+)/);
393
+ const status = statusMatch ? statusMatch[1] : statusCell.split('—')[0].trim() || 'in_progress';
394
+ return { handle, title, status, slug };
395
+ }
396
+ // Handle declared but no matching row found in index
397
+ return { handle, title: '(title not found in work-units/_index.md)', status: 'in_progress', slug };
398
+ }
399
+ /**
400
+ * Read blocked WUs from 🔴 rows in `work-units/_index.md`.
401
+ * The workflow store is stale and must not be consulted for this.
402
+ */
403
+ async function readBlockedWorkflowsFromIndex(buildRoot) {
404
+ const indexContent = await readIndexFile(path.join(buildRoot, 'work-units', '_index.md'));
405
+ if (!indexContent)
406
+ return [];
407
+ const blocked = [];
408
+ for (const line of indexContent.split('\n')) {
409
+ if (!/^\s*\|/.test(line))
410
+ continue;
411
+ if (!line.includes('🔴'))
412
+ continue;
413
+ const cells = line.split('|').map((c) => c.trim());
414
+ if (cells.length < 3)
415
+ continue;
416
+ const idCell = cells[1];
417
+ if (!idCell || /^[-\s]*$/.test(idCell) || idCell === 'ID')
418
+ continue;
419
+ const titleCell = cells[2] ?? '';
420
+ const titleMatch = titleCell.match(/^\[([^\]]+)\]/) ?? titleCell.match(/^(.+)$/);
421
+ const title = titleMatch ? titleMatch[1].trim() : titleCell.trim();
422
+ const handle = `wu-${idCell}`;
423
+ blocked.push({ handle, title });
424
+ }
425
+ return blocked;
426
+ }
427
+ async function readRecentDecisions(buildRoot) {
428
+ const indexContent = await readIndexFile(path.join(buildRoot, 'decisions', '_index.md'));
429
+ if (!indexContent)
430
+ return [];
431
+ return parseDecisionsIndex(indexContent);
432
+ }
433
+ async function readUnresolvedQuestions(buildRoot) {
434
+ const indexContent = await readIndexFile(path.join(buildRoot, 'open-questions', '_index.md'));
435
+ if (!indexContent)
436
+ return [];
437
+ return parseQuestionsIndex(indexContent);
438
+ }
439
+ async function readActiveRisks(buildRoot) {
440
+ const indexContent = await readIndexFile(path.join(buildRoot, 'risks', '_index.md'));
441
+ if (!indexContent)
442
+ return [];
443
+ return parseRisksIndex(indexContent);
444
+ }
445
+ /**
446
+ * Derive health stats entirely from live disk sources:
447
+ * - in_progress / blocked counts: 🟡 / 🔴 rows in work-units/_index.md
448
+ * - completed count: files in work-units/done/
449
+ * - decisions count: active rows in decisions/_index.md
450
+ * - open questions: active rows in open-questions/_index.md
451
+ * - active risks: active rows in risks/_index.md
452
+ *
453
+ * The workflow store is NOT consulted (it is stale under Mode 1 — D129).
454
+ */
455
+ async function readHealthStatsFromDisk(buildRoot) {
456
+ const wuIndex = await readIndexFile(path.join(buildRoot, 'work-units', '_index.md'));
457
+ let inProgressWus = 0;
458
+ let blockedWus = 0;
459
+ let maxInProgressWuNum = 0;
460
+ if (wuIndex) {
461
+ for (const line of wuIndex.split('\n')) {
462
+ if (!/^\s*\|/.test(line))
463
+ continue;
464
+ const cells = line.split('|').map((c) => c.trim());
465
+ if (cells.length < 4)
466
+ continue;
467
+ const idCell = cells[1];
468
+ if (!idCell || /^[-\s]*$/.test(idCell) || idCell === 'ID')
469
+ continue;
470
+ const statusCell = cells[3] ?? '';
471
+ if (statusCell.includes('🟡')) {
472
+ inProgressWus++;
473
+ // Extract the numeric part of the ID for phase derivation
474
+ const numMatch = idCell.match(/^(\d+)/);
475
+ if (numMatch) {
476
+ const n = parseInt(numMatch[1], 10);
477
+ if (n > maxInProgressWuNum)
478
+ maxInProgressWuNum = n;
479
+ }
480
+ }
481
+ if (statusCell.includes('🔴'))
482
+ blockedWus++;
483
+ }
484
+ }
485
+ // Completed count: files in work-units/done/
486
+ let doneWus = 0;
487
+ try {
488
+ const doneEntries = await readdir(path.join(buildRoot, 'work-units', 'done'));
489
+ doneWus = doneEntries.filter((f) => f.endsWith('.md') && !f.startsWith('_')).length;
490
+ }
491
+ catch {
492
+ // done/ may not exist yet
493
+ }
494
+ // Decisions: active rows in decisions/_index.md
495
+ const decisionsIndex = await readIndexFile(path.join(buildRoot, 'decisions', '_index.md'));
496
+ let totalDecisions = 0;
497
+ if (decisionsIndex) {
498
+ const activeSection = decisionsIndex.split(/^## (?:Superseded|Withdrawn) decisions/im)[0];
499
+ for (const line of activeSection.split('\n')) {
500
+ if (!/^\s*\|/.test(line))
501
+ continue;
502
+ const cells = line.split('|').map((c) => c.trim());
503
+ if (cells.length < 3)
504
+ continue;
505
+ const idCell = cells[1];
506
+ if (!idCell || /^[-\s]*$/.test(idCell) || idCell === 'ID' || idCell === '---')
507
+ continue;
508
+ if (/^D\d+/i.test(idCell.replace(/^\[/, '')))
509
+ totalDecisions++;
510
+ }
511
+ }
512
+ // Open questions: active section
513
+ const questionsIndex = await readIndexFile(path.join(buildRoot, 'open-questions', '_index.md'));
514
+ let openQuestions = 0;
515
+ if (questionsIndex) {
516
+ const activeSection = questionsIndex.split(/^## Resolved questions/im)[0];
517
+ for (const line of activeSection.split('\n')) {
518
+ if (!/^\s*\|/.test(line))
519
+ continue;
520
+ const cells = line.split('|').map((c) => c.trim());
521
+ if (cells.length < 3)
522
+ continue;
523
+ const idCell = cells[1];
524
+ if (!idCell || /^[-\s]*$/.test(idCell) || idCell === 'ID' || idCell === '---')
525
+ continue;
526
+ if (/^Q\d+/i.test(idCell.replace(/^\[/, '')))
527
+ openQuestions++;
528
+ }
529
+ }
530
+ // Active risks: active section
531
+ const risksIndex = await readIndexFile(path.join(buildRoot, 'risks', '_index.md'));
532
+ let activeRisks = 0;
533
+ if (risksIndex) {
534
+ const activeSection = risksIndex.split(/^## Resolved risks/im)[0];
535
+ for (const line of activeSection.split('\n')) {
536
+ if (!/^\s*\|/.test(line))
537
+ continue;
538
+ const cells = line.split('|').map((c) => c.trim());
539
+ if (cells.length < 3)
540
+ continue;
541
+ const idCell = cells[1];
542
+ if (!idCell || /^[-\s]*$/.test(idCell) || idCell === 'ID' || idCell === '---')
543
+ continue;
544
+ if (/^R\d+/i.test(idCell))
545
+ activeRisks++;
546
+ }
547
+ }
548
+ return { inProgressWus, doneWus, blockedWus, totalDecisions, openQuestions, activeRisks, maxInProgressWuNum };
549
+ }
550
+ // ---------------------------------------------------------------------------
551
+ // Text renderers for each section
552
+ // ---------------------------------------------------------------------------
553
+ function renderMetadataSection(activeWu, today, stats) {
554
+ const phase = deriveCurrentPhase(stats.maxInProgressWuNum);
555
+ const lines = [
556
+ '| Field | Value |',
557
+ '| --- | --- |',
558
+ `| Last compiled | ${today} |`,
559
+ `| Current phase | ${phase} |`,
560
+ `| Active WU | ${activeWu ? `**${activeWu.handle}** — ${activeWu.title} (${activeWu.status ?? 'unknown'})` : '(no active WU declared — run `nuos-catalogue wu start <handle>`)'} |`,
561
+ `| WUs in progress | ${stats.inProgressWus} |`,
562
+ ];
563
+ return lines.join('\n');
564
+ }
565
+ /**
566
+ * Derive the current phase label from the highest in-progress WU number
567
+ * (read from the live `work-units/_index.md`, not the store).
568
+ */
569
+ function deriveCurrentPhase(maxInProgressWuNum) {
570
+ if (maxInProgressWuNum === 0)
571
+ return 'No active phase detected';
572
+ if (maxInProgressWuNum >= 100)
573
+ return 'Continuous Track 1 — NuOS leads the build';
574
+ if (maxInProgressWuNum >= 80)
575
+ return 'Phase 5 — Consumer shell + productisation';
576
+ if (maxInProgressWuNum >= 60)
577
+ return 'Phase 4 — Trifecta integration test';
578
+ if (maxInProgressWuNum >= 40)
579
+ return 'Phase 3 — NuWiki + trifecta';
580
+ if (maxInProgressWuNum >= 20)
581
+ return 'Phase 2 — NuFlow';
582
+ return 'Phase 1 — NuVector';
583
+ }
584
+ function renderWhatIsNextSection(activeWu, blockedWorkflows) {
585
+ if (!activeWu) {
586
+ return [
587
+ 'No active WU marker found. Declare the active WU with:',
588
+ ' nuos-catalogue wu start <handle>',
589
+ '',
590
+ 'Then recompile STATE.md with `nuos-catalogue state compile`.',
591
+ ].join('\n');
592
+ }
593
+ const lines = [
594
+ `**Active WU: ${activeWu.handle}** — ${activeWu.title}`,
595
+ `Status: \`${activeWu.status ?? 'in_progress'}\``,
596
+ ];
597
+ if (blockedWorkflows.length > 0) {
598
+ lines.push('');
599
+ lines.push('**Blocked work units requiring attention:**');
600
+ for (const b of blockedWorkflows) {
601
+ lines.push(`- ${b.handle} — ${b.title}`);
602
+ }
603
+ }
604
+ lines.push('');
605
+ lines.push('Continue the active WU. Recompile STATE.md at end-of-session via `nuos-catalogue state compile`.');
606
+ return lines.join('\n');
607
+ }
608
+ function renderOpenQuestionsSection(questions) {
609
+ if (questions.length === 0) {
610
+ return 'No unresolved open questions. See `docs/build/open-questions/_index.md` for the full register.';
611
+ }
612
+ const lines = [];
613
+ for (const q of questions.slice(0, 10)) {
614
+ const blocks = q.blocks ? ` — blocks: ${q.blocks}` : '';
615
+ lines.push(`- **${q.id}** — ${q.title}${blocks}`);
616
+ }
617
+ if (questions.length > 10) {
618
+ lines.push(`- *(${questions.length - 10} more — see open-questions/_index.md)*`);
619
+ }
620
+ return lines.join('\n');
621
+ }
622
+ function renderRecentDecisionsSection(decisions) {
623
+ if (decisions.length === 0) {
624
+ return 'No decisions found. See `docs/build/decisions/_index.md` for the full register.';
625
+ }
626
+ const recent = decisions.slice(0, 8);
627
+ const lines = [];
628
+ for (const d of recent) {
629
+ lines.push(`- **${d.handle}** — ${d.title}${d.status ? ` *(${d.status})*` : ''}`);
630
+ }
631
+ if (decisions.length > 8) {
632
+ lines.push(`- *(${decisions.length - 8} more — see decisions/_index.md)*`);
633
+ }
634
+ return lines.join('\n');
635
+ }
636
+ function renderRisksSection(risks) {
637
+ if (risks.length === 0) {
638
+ return 'No active risks found. See `docs/build/risks/_index.md` for the full register.';
639
+ }
640
+ const lines = [];
641
+ for (const r of risks.slice(0, 5)) {
642
+ lines.push(`- **${r.id}** (${r.severity}) — ${r.title} *(${r.status})*`);
643
+ }
644
+ if (risks.length > 5) {
645
+ lines.push(`- *(${risks.length - 5} more — see risks/_index.md)*`);
646
+ }
647
+ return lines.join('\n');
648
+ }
649
+ function renderHealthCheckSection(stats) {
650
+ const lines = [
651
+ '| Check | Count |',
652
+ '| --- | --- |',
653
+ `| WUs in progress | ${stats.inProgressWus} |`,
654
+ `| WUs completed | ${stats.doneWus} (files in work-units/done/) |`,
655
+ `| Decisions recorded | ${stats.totalDecisions} (active section) |`,
656
+ `| Open questions | ${stats.openQuestions} |`,
657
+ `| Active risks | ${stats.activeRisks} |`,
658
+ ];
659
+ if (stats.blockedWus > 0) {
660
+ lines.push(`| Blocked WUs | ${stats.blockedWus} — attention needed |`);
661
+ }
662
+ return lines.join('\n');
663
+ }
664
+ // ---------------------------------------------------------------------------
665
+ // Index file parsers
666
+ // ---------------------------------------------------------------------------
667
+ async function readIndexFile(filePath) {
668
+ try {
669
+ const { readFile: rf } = await import('node:fs/promises');
670
+ return await rf(filePath, 'utf8');
671
+ }
672
+ catch {
673
+ return null;
674
+ }
675
+ }
676
+ /**
677
+ * Parse the decisions _index.md table — active decisions only.
678
+ * Row shape: `| [D001](file.md) | Title | Date | Status |`
679
+ * or: `| D001 | Title | Date | Status |`
680
+ *
681
+ * The real decisions/_index.md has three terminal sections after the active
682
+ * table: `## Superseded decisions`, `## Withdrawn decisions`, and
683
+ * `## How to write a decision`. We split on the first non-active section
684
+ * (whichever of Superseded / Withdrawn appears first) so a high-numbered
685
+ * decision that is later superseded never leaks into the generated region.
686
+ */
687
+ function parseDecisionsIndex(content) {
688
+ const decisions = [];
689
+ // Scope to the active-decisions section only.
690
+ // Split on the first of the two non-active `##` headers that follow it.
691
+ const activeSection = content.split(/^## (?:Superseded|Withdrawn) decisions/im)[0];
692
+ const lines = activeSection.split('\n');
693
+ for (const line of lines) {
694
+ if (!/^\s*\|/.test(line))
695
+ continue;
696
+ const cells = line.split('|').map((c) => c.trim());
697
+ // Expect: [empty, id-cell, title, date, status, empty]
698
+ if (cells.length < 5)
699
+ continue;
700
+ const idCell = cells[1];
701
+ if (!idCell || !/^D\d+/i.test(idCell.replace(/^\[/, '')))
702
+ continue;
703
+ // Extract the handle — strip link markup if present
704
+ const handleMatch = idCell.match(/\[?(D\d+)\]?/i);
705
+ if (!handleMatch)
706
+ continue;
707
+ const handle = handleMatch[1];
708
+ const title = cells[2] ?? '';
709
+ if (!title || title === 'Title' || title === '---')
710
+ continue;
711
+ const status = cells[4] ?? null;
712
+ if (status === 'Status' || status === '---')
713
+ continue;
714
+ decisions.push({
715
+ handle,
716
+ title,
717
+ status: status || null,
718
+ fileModifiedAt: cells[3] ?? '',
719
+ });
720
+ }
721
+ // Sort by handle number descending to get most recent first
722
+ return decisions.sort((a, b) => {
723
+ const na = parseInt(a.handle.slice(1), 10);
724
+ const nb = parseInt(b.handle.slice(1), 10);
725
+ return nb - na;
726
+ });
727
+ }
728
+ /**
729
+ * Parse the open-questions _index.md active table.
730
+ * Row shape: `| [Q003](file.md) | Title | Blocks | Raised |`
731
+ * or: `| Q003 | Title | Blocks | Raised |`
732
+ */
733
+ function parseQuestionsIndex(content) {
734
+ const questions = [];
735
+ // Find the "Active questions" section — stop at "Resolved questions"
736
+ const activeSection = content.split(/^## Resolved questions/im)[0];
737
+ const lines = activeSection.split('\n');
738
+ for (const line of lines) {
739
+ if (!/^\s*\|/.test(line))
740
+ continue;
741
+ const cells = line.split('|').map((c) => c.trim());
742
+ if (cells.length < 4)
743
+ continue;
744
+ const idCell = cells[1];
745
+ if (!idCell || !/^Q\d+/i.test(idCell.replace(/^\[/, '')))
746
+ continue;
747
+ const idMatch = idCell.match(/\[?(Q\d+)\]?/i);
748
+ if (!idMatch)
749
+ continue;
750
+ const id = idMatch[1];
751
+ const title = cells[2] ?? '';
752
+ if (!title || title === 'Title' || title === '---')
753
+ continue;
754
+ const blocks = cells[3] ?? '';
755
+ if (blocks === 'Blocks' || blocks === '---')
756
+ continue;
757
+ questions.push({ id, title, blocks });
758
+ }
759
+ return questions;
760
+ }
761
+ /**
762
+ * Parse the risks _index.md active table.
763
+ * Row shape: `| R001 | Title | Severity | Likelihood | Status |`
764
+ */
765
+ function parseRisksIndex(content) {
766
+ const risks = [];
767
+ // Find the "Active risks" section — stop at "Resolved risks"
768
+ const activeSection = content.split(/^## Resolved risks/im)[0];
769
+ const lines = activeSection.split('\n');
770
+ for (const line of lines) {
771
+ if (!/^\s*\|/.test(line))
772
+ continue;
773
+ const cells = line.split('|').map((c) => c.trim());
774
+ if (cells.length < 6)
775
+ continue;
776
+ const idCell = cells[1];
777
+ if (!idCell || !/^R\d+/i.test(idCell))
778
+ continue;
779
+ if (idCell === 'ID' || idCell === '---')
780
+ continue;
781
+ const id = idCell;
782
+ const title = cells[2] ?? '';
783
+ if (!title || title === 'Title' || title === '---')
784
+ continue;
785
+ const severity = cells[3] ?? '';
786
+ const likelihood = cells[4] ?? '';
787
+ const status = cells[5] ?? '';
788
+ if (status === 'Status' || status === '---')
789
+ continue;
790
+ risks.push({ id, title, severity, likelihood, status });
791
+ }
792
+ return risks;
793
+ }