@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.
- package/README.md +3 -3
- package/dist/cli.js +48 -0
- package/dist/commands/end-of-session.js +67 -14
- package/dist/commands/state-compile.d.ts +108 -0
- package/dist/commands/state-compile.js +793 -0
- package/dist/embedder/ollama.d.ts +7 -0
- package/dist/embedder/ollama.js +27 -1
- package/package.json +5 -4
- package/scripts/hooks/pre-commit +43 -0
- package/templates/hooks/pre-commit +43 -0
- package/templates/starter-kit/methodfile.json +7 -1
|
@@ -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
|
+
}
|