@nusoft/nuos-build-catalogue 0.25.0 → 0.27.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.
Files changed (37) hide show
  1. package/dist/cli.js +24 -0
  2. package/dist/commands/init.js +10 -32
  3. package/dist/commands/mode.d.ts +24 -0
  4. package/dist/commands/mode.js +82 -0
  5. package/dist/commands/render.d.ts +25 -0
  6. package/dist/commands/render.js +40 -0
  7. package/dist/render/architecture.d.ts +18 -0
  8. package/dist/render/architecture.js +74 -0
  9. package/dist/render/design-system.d.ts +26 -0
  10. package/dist/render/design-system.js +308 -0
  11. package/dist/render/html.d.ts +47 -0
  12. package/dist/render/html.js +258 -0
  13. package/dist/render/maps.d.ts +18 -0
  14. package/dist/render/maps.js +67 -0
  15. package/dist/render/parser.d.ts +54 -0
  16. package/dist/render/parser.js +162 -0
  17. package/dist/render/run.d.ts +29 -0
  18. package/dist/render/run.js +92 -0
  19. package/dist/render/surfaces.d.ts +23 -0
  20. package/dist/render/surfaces.js +144 -0
  21. package/package.json +2 -2
  22. package/templates/agents/coder.md +7 -5
  23. package/templates/agents/reviewer.md +6 -4
  24. package/templates/protocols/build-wu.md +13 -63
  25. package/templates/protocols/end-of-session.md +9 -3
  26. package/templates/protocols/persona-new.md +1 -1
  27. package/templates/protocols/plan-architecture.md +10 -24
  28. package/templates/protocols/plan-initial-wu.md +6 -26
  29. package/templates/protocols/plan-maps.md +5 -17
  30. package/templates/protocols/plan-orientation.md +13 -29
  31. package/templates/protocols/plan-review.md +2 -0
  32. package/templates/protocols/plan-uiux.md +35 -86
  33. package/templates/protocols/start-of-session.md +13 -1
  34. package/templates/protocols/wu-new.md +1 -1
  35. package/templates/starter-kit/CLAUDE.md +9 -37
  36. package/templates/starter-kit/docs/build/OPERATOR-MODES.md +44 -0
  37. package/templates/starter-kit/methodfile.json +5 -0
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Render the `maps/` register as a single companion HTML page.
3
+ *
4
+ * Each map file (`01-the-horizon.md`, `02-phases.md`, `03-near-term.md`, etc.)
5
+ * is parsed by its `## Section` structure and rendered as a card with the
6
+ * section contents underneath. Multiple maps become a vertical timeline so the
7
+ * operator sees them in their intended order (numeric prefix).
8
+ */
9
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { parseSections, isPlaceholder } from './parser.js';
13
+ import { pageWrapper, card, emptyState, escapeHtml, renderProse, } from './html.js';
14
+ const TEMPLATE_PATTERN = /-template\.md$/i;
15
+ export async function renderMaps(opts) {
16
+ const dir = path.join(opts.buildRoot, 'maps');
17
+ const outPath = path.join(dir, '_view.html');
18
+ if (!existsSync(dir)) {
19
+ return { written: false, outPath, mapCount: 0 };
20
+ }
21
+ const entries = await readdir(dir, { withFileTypes: true });
22
+ const maps = [];
23
+ for (const entry of entries) {
24
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
25
+ continue;
26
+ if (entry.name === '_index.md' || TEMPLATE_PATTERN.test(entry.name))
27
+ continue;
28
+ const md = await readFile(path.join(dir, entry.name), 'utf8');
29
+ if (isPlaceholder(md))
30
+ continue;
31
+ const titleMatch = md.match(/^#\s+(.+)$/m);
32
+ maps.push({
33
+ file: entry.name,
34
+ title: titleMatch ? titleMatch[1].trim() : entry.name,
35
+ sections: parseSections(md),
36
+ });
37
+ }
38
+ maps.sort((a, b) => a.file.localeCompare(b.file));
39
+ const body = maps.length === 0
40
+ ? card('No maps filed yet', emptyState('Phases A and D of planning produce this content.'))
41
+ : `<div class="timeline">${maps.map(renderMapCard).join('')}</div>`;
42
+ const html = pageWrapper({
43
+ title: 'Maps & journey',
44
+ projectName: opts.projectName,
45
+ generatedAt: opts.generatedAt,
46
+ sourceNote: '<code>docs/build/maps/</code>',
47
+ body,
48
+ });
49
+ await mkdir(dir, { recursive: true });
50
+ await writeFile(outPath, html, 'utf8');
51
+ return { written: true, outPath, mapCount: maps.length };
52
+ }
53
+ function renderMapCard(m) {
54
+ const sectionBlocks = [];
55
+ for (const [heading, body] of m.sections) {
56
+ if (heading === '' || heading.toLowerCase() === 'how this map changes')
57
+ continue;
58
+ const rendered = body.trim() ? renderProse(body) : '';
59
+ if (!rendered)
60
+ continue;
61
+ sectionBlocks.push(`<h4>${escapeHtml(heading)}</h4>${rendered}`);
62
+ }
63
+ return `<section class="card">
64
+ <h3>${escapeHtml(m.title)}</h3>
65
+ <div class="card__body">${sectionBlocks.join('')}</div>
66
+ </section>`;
67
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Shared markdown parsing helpers for the render module.
3
+ *
4
+ * The register markdown files in `templates/starter-kit/docs/build/` follow
5
+ * a few consistent shapes:
6
+ * - `## Section` headings with prose underneath (maps, surfaces, modules)
7
+ * - GFM tables with a header row, separator row, and data rows (design-system tokens)
8
+ * - Inline code in backticks for token names and hex values
9
+ *
10
+ * The parsers here are deliberately small and forgiving. If a file diverges
11
+ * from the template the renderer degrades to plain prose rather than throwing.
12
+ */
13
+ export interface ParsedTable {
14
+ headers: string[];
15
+ rows: string[][];
16
+ }
17
+ /**
18
+ * Split a markdown document into a map of `## Section` → body text.
19
+ * Content before the first H2 is filed under the empty string key.
20
+ * Body text is trimmed and includes any nested headings underneath the H2.
21
+ */
22
+ export declare function parseSections(md: string): Map<string, string>;
23
+ /**
24
+ * Parse the first GFM table in a markdown block. Returns null if none found.
25
+ * Cells with inline code/markdown are kept as-is; the renderer handles inline formatting.
26
+ */
27
+ export declare function parseTable(md: string): ParsedTable | null;
28
+ /**
29
+ * Extract every inline-code substring from a markdown blob (e.g. `colour.brand.primary`).
30
+ */
31
+ export declare function extractInlineCode(md: string): string[];
32
+ /**
33
+ * Extract every hex colour (`#rgb`, `#rrggbb`, `#rrggbbaa`) from a markdown blob.
34
+ * Filters out the all-zero `#000000` placeholder *if* the file still looks unfilled,
35
+ * because the starter-kit template seeds tokens with `#000000` to signal "TBD".
36
+ */
37
+ export declare function extractHexColours(md: string): string[];
38
+ /**
39
+ * Return the first non-blank, non-hint paragraph of a markdown block.
40
+ * "Hint" blockquotes (lines starting with `> *italics*`) are skipped because
41
+ * they're template scaffolding the operator forgot to delete.
42
+ */
43
+ export declare function firstParagraph(md: string): string;
44
+ /**
45
+ * Detect placeholder-only files. A file that still contains the literal
46
+ * "Replace bracketed placeholders" hint, or whose body is mostly `[bracketed]`
47
+ * scaffolding, hasn't been filled in yet and shouldn't be rendered as if it had.
48
+ */
49
+ export declare function isPlaceholder(md: string): boolean;
50
+ /**
51
+ * Strip the front-matter-y top of a register file: the `>` hint blockquotes and any
52
+ * **Status:** / **Last updated:** metadata. Leaves the actual content sections intact.
53
+ */
54
+ export declare function stripPreamble(md: string): string;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Shared markdown parsing helpers for the render module.
3
+ *
4
+ * The register markdown files in `templates/starter-kit/docs/build/` follow
5
+ * a few consistent shapes:
6
+ * - `## Section` headings with prose underneath (maps, surfaces, modules)
7
+ * - GFM tables with a header row, separator row, and data rows (design-system tokens)
8
+ * - Inline code in backticks for token names and hex values
9
+ *
10
+ * The parsers here are deliberately small and forgiving. If a file diverges
11
+ * from the template the renderer degrades to plain prose rather than throwing.
12
+ */
13
+ const HEADING_LINE = /^##\s+(.+?)\s*$/;
14
+ const TABLE_SEPARATOR = /^\|?\s*:?-+:?(\s*\|\s*:?-+:?)+\s*\|?\s*$/;
15
+ const HEX_COLOUR = /#(?:[0-9a-fA-F]{3,8})\b/g;
16
+ const INLINE_CODE = /`([^`]+)`/g;
17
+ const HINT_BLOCKQUOTE = /^>\s*\*[^*]+\*\s*$/;
18
+ /**
19
+ * Split a markdown document into a map of `## Section` → body text.
20
+ * Content before the first H2 is filed under the empty string key.
21
+ * Body text is trimmed and includes any nested headings underneath the H2.
22
+ */
23
+ export function parseSections(md) {
24
+ const sections = new Map();
25
+ const lines = md.split('\n');
26
+ let currentHeading = '';
27
+ let currentBody = [];
28
+ const flush = () => {
29
+ const body = currentBody.join('\n').trim();
30
+ if (currentHeading || body)
31
+ sections.set(currentHeading, body);
32
+ };
33
+ for (const line of lines) {
34
+ const match = HEADING_LINE.exec(line);
35
+ if (match) {
36
+ flush();
37
+ currentHeading = match[1];
38
+ currentBody = [];
39
+ }
40
+ else {
41
+ currentBody.push(line);
42
+ }
43
+ }
44
+ flush();
45
+ return sections;
46
+ }
47
+ /**
48
+ * Parse the first GFM table in a markdown block. Returns null if none found.
49
+ * Cells with inline code/markdown are kept as-is; the renderer handles inline formatting.
50
+ */
51
+ export function parseTable(md) {
52
+ const lines = md.split('\n');
53
+ for (let i = 0; i < lines.length - 1; i++) {
54
+ const header = lines[i].trim();
55
+ const separator = lines[i + 1].trim();
56
+ if (!header.includes('|') || !TABLE_SEPARATOR.test(separator))
57
+ continue;
58
+ const headers = splitTableRow(header);
59
+ const rows = [];
60
+ for (let j = i + 2; j < lines.length; j++) {
61
+ const row = lines[j].trim();
62
+ if (!row.includes('|'))
63
+ break;
64
+ rows.push(splitTableRow(row));
65
+ }
66
+ return { headers, rows };
67
+ }
68
+ return null;
69
+ }
70
+ function splitTableRow(line) {
71
+ // Trim leading/trailing pipes; split on |; trim each cell.
72
+ const trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
73
+ return trimmed.split('|').map((c) => c.trim());
74
+ }
75
+ /**
76
+ * Extract every inline-code substring from a markdown blob (e.g. `colour.brand.primary`).
77
+ */
78
+ export function extractInlineCode(md) {
79
+ const out = [];
80
+ let m;
81
+ const re = new RegExp(INLINE_CODE.source, 'g');
82
+ while ((m = re.exec(md)) !== null)
83
+ out.push(m[1]);
84
+ return out;
85
+ }
86
+ /**
87
+ * Extract every hex colour (`#rgb`, `#rrggbb`, `#rrggbbaa`) from a markdown blob.
88
+ * Filters out the all-zero `#000000` placeholder *if* the file still looks unfilled,
89
+ * because the starter-kit template seeds tokens with `#000000` to signal "TBD".
90
+ */
91
+ export function extractHexColours(md) {
92
+ return md.match(HEX_COLOUR) ?? [];
93
+ }
94
+ /**
95
+ * Return the first non-blank, non-hint paragraph of a markdown block.
96
+ * "Hint" blockquotes (lines starting with `> *italics*`) are skipped because
97
+ * they're template scaffolding the operator forgot to delete.
98
+ */
99
+ export function firstParagraph(md) {
100
+ const lines = md.split('\n');
101
+ const para = [];
102
+ for (const raw of lines) {
103
+ const line = raw.trim();
104
+ if (!line) {
105
+ if (para.length > 0)
106
+ break;
107
+ continue;
108
+ }
109
+ if (HINT_BLOCKQUOTE.test(line))
110
+ continue;
111
+ if (line.startsWith('#')) {
112
+ if (para.length > 0)
113
+ break;
114
+ continue;
115
+ }
116
+ para.push(line);
117
+ }
118
+ return para.join(' ').trim();
119
+ }
120
+ /**
121
+ * Detect placeholder-only files. A file that still contains the literal
122
+ * "Replace bracketed placeholders" hint, or whose body is mostly `[bracketed]`
123
+ * scaffolding, hasn't been filled in yet and shouldn't be rendered as if it had.
124
+ */
125
+ export function isPlaceholder(md) {
126
+ if (/Replace\s+(?:the\s+)?bracketed\s+placeholders?/i.test(md))
127
+ return true;
128
+ if (/Delete this hint block/i.test(md))
129
+ return true;
130
+ // A title that's itself a bracketed placeholder ("# [Module name]") is the canonical
131
+ // signal of an unfilled file — title bracketing dominates everything else.
132
+ const titleMatch = md.match(/^#\s+(.+)$/m);
133
+ if (titleMatch && /\[[^\]]+\]/.test(titleMatch[1]))
134
+ return true;
135
+ // Count [bracketed] vs total non-blank non-heading lines; if dominant, treat as placeholder.
136
+ const lines = md
137
+ .split('\n')
138
+ .map((l) => l.trim())
139
+ .filter((l) => l && !l.startsWith('#') && !l.startsWith('>'));
140
+ if (lines.length === 0)
141
+ return true;
142
+ const bracketed = lines.filter((l) => /^\[.+\]$/.test(l) || /^[-*]\s*\[.+\]/.test(l)).length;
143
+ return bracketed / lines.length > 0.6;
144
+ }
145
+ /**
146
+ * Strip the front-matter-y top of a register file: the `>` hint blockquotes and any
147
+ * **Status:** / **Last updated:** metadata. Leaves the actual content sections intact.
148
+ */
149
+ export function stripPreamble(md) {
150
+ const lines = md.split('\n');
151
+ const out = [];
152
+ let seenHeading = false;
153
+ for (const line of lines) {
154
+ if (HEADING_LINE.test(line))
155
+ seenHeading = true;
156
+ if (seenHeading)
157
+ out.push(line);
158
+ else if (line.startsWith('# '))
159
+ out.push(line);
160
+ }
161
+ return out.join('\n');
162
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Orchestrator for `nuos-catalogue render` — generates companion HTML views
3
+ * for the visual registers (ui-ux, design-system, maps, architecture) from
4
+ * the canonical markdown.
5
+ *
6
+ * Companions are *generated artefacts*, not source. The markdown remains the
7
+ * source of truth for every agent, every search, every diff. The companion
8
+ * exists so a human operator can scan visual artefacts (wireframes, swatches,
9
+ * timelines, module graphs) in their natural medium instead of reading prose
10
+ * descriptions of them.
11
+ */
12
+ export declare const RENDERABLE_REGISTERS: readonly ["surfaces", "design-system", "maps", "architecture"];
13
+ export type RenderableRegister = (typeof RENDERABLE_REGISTERS)[number];
14
+ export interface RenderOptions {
15
+ buildRoot: string;
16
+ /** Only render these registers (default: all). */
17
+ only?: RenderableRegister[];
18
+ /** Override "now" for deterministic output in tests. */
19
+ now?: () => Date;
20
+ }
21
+ export interface RenderReport {
22
+ results: {
23
+ register: RenderableRegister;
24
+ written: boolean;
25
+ outPath: string;
26
+ detail: string;
27
+ }[];
28
+ }
29
+ export declare function runRender(options: RenderOptions): Promise<RenderReport>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Orchestrator for `nuos-catalogue render` — generates companion HTML views
3
+ * for the visual registers (ui-ux, design-system, maps, architecture) from
4
+ * the canonical markdown.
5
+ *
6
+ * Companions are *generated artefacts*, not source. The markdown remains the
7
+ * source of truth for every agent, every search, every diff. The companion
8
+ * exists so a human operator can scan visual artefacts (wireframes, swatches,
9
+ * timelines, module graphs) in their natural medium instead of reading prose
10
+ * descriptions of them.
11
+ */
12
+ import { existsSync } from 'node:fs';
13
+ import { readFile } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { renderSurfaces } from './surfaces.js';
16
+ import { renderDesignSystem } from './design-system.js';
17
+ import { renderMaps } from './maps.js';
18
+ import { renderArchitecture } from './architecture.js';
19
+ export const RENDERABLE_REGISTERS = ['surfaces', 'design-system', 'maps', 'architecture'];
20
+ export async function runRender(options) {
21
+ const { buildRoot } = options;
22
+ const targets = options.only ?? [...RENDERABLE_REGISTERS];
23
+ const now = options.now ?? (() => new Date());
24
+ const generatedAt = isoDate(now());
25
+ const projectName = await resolveProjectName(buildRoot);
26
+ const ctx = { buildRoot, projectName, generatedAt };
27
+ const report = { results: [] };
28
+ for (const register of targets) {
29
+ switch (register) {
30
+ case 'surfaces': {
31
+ const r = await renderSurfaces(ctx);
32
+ report.results.push({
33
+ register,
34
+ written: r.written,
35
+ outPath: r.outPath,
36
+ detail: r.written ? `${r.surfaceCount} surfaces` : 'skipped (no ui-ux/)',
37
+ });
38
+ break;
39
+ }
40
+ case 'design-system': {
41
+ const r = await renderDesignSystem(ctx);
42
+ report.results.push({
43
+ register,
44
+ written: r.written,
45
+ outPath: r.outPath,
46
+ detail: r.written ? 'rendered' : 'skipped (no design-system/)',
47
+ });
48
+ break;
49
+ }
50
+ case 'maps': {
51
+ const r = await renderMaps(ctx);
52
+ report.results.push({
53
+ register,
54
+ written: r.written,
55
+ outPath: r.outPath,
56
+ detail: r.written ? `${r.mapCount} maps` : 'skipped (no maps/)',
57
+ });
58
+ break;
59
+ }
60
+ case 'architecture': {
61
+ const r = await renderArchitecture(ctx);
62
+ report.results.push({
63
+ register,
64
+ written: r.written,
65
+ outPath: r.outPath,
66
+ detail: r.written ? `${r.moduleCount} modules` : 'skipped (no architecture/)',
67
+ });
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ return report;
73
+ }
74
+ async function resolveProjectName(buildRoot) {
75
+ // buildRoot is `<project>/docs/build`; methodfile.json lives at the project root.
76
+ const projectRoot = path.resolve(buildRoot, '..', '..');
77
+ const methodfilePath = path.join(projectRoot, 'methodfile.json');
78
+ if (existsSync(methodfilePath)) {
79
+ try {
80
+ const mf = JSON.parse(await readFile(methodfilePath, 'utf8'));
81
+ if (mf.project?.name)
82
+ return mf.project.name;
83
+ }
84
+ catch {
85
+ // fall through to directory-name default
86
+ }
87
+ }
88
+ return path.basename(projectRoot);
89
+ }
90
+ function isoDate(d) {
91
+ return d.toISOString().slice(0, 10);
92
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Render the `ui-ux/` register as a single companion HTML page.
3
+ *
4
+ * Output structure:
5
+ * 1. Sitemap — every surface listed (linked to its card lower down), grouped
6
+ * where possible by the persona handle it serves.
7
+ * 2. Surface cards — one per surface markdown file, showing type, persona, the
8
+ * "What they see" prose, primary actions, contracts touched, design-system
9
+ * pieces used.
10
+ *
11
+ * The intent is the human-reviewable artefact the markdown can't be:
12
+ * a non-developer scanning the whole UI surface set in one scroll.
13
+ */
14
+ export interface SurfaceRenderResult {
15
+ written: boolean;
16
+ outPath: string;
17
+ surfaceCount: number;
18
+ }
19
+ export declare function renderSurfaces(opts: {
20
+ buildRoot: string;
21
+ projectName: string;
22
+ generatedAt: string;
23
+ }): Promise<SurfaceRenderResult>;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Render the `ui-ux/` register as a single companion HTML page.
3
+ *
4
+ * Output structure:
5
+ * 1. Sitemap — every surface listed (linked to its card lower down), grouped
6
+ * where possible by the persona handle it serves.
7
+ * 2. Surface cards — one per surface markdown file, showing type, persona, the
8
+ * "What they see" prose, primary actions, contracts touched, design-system
9
+ * pieces used.
10
+ *
11
+ * The intent is the human-reviewable artefact the markdown can't be:
12
+ * a non-developer scanning the whole UI surface set in one scroll.
13
+ */
14
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
15
+ import { existsSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { parseSections, firstParagraph, isPlaceholder, } from './parser.js';
18
+ import { pageWrapper, card, emptyState, escapeHtml, renderProse, renderInlineMarkdown, } from './html.js';
19
+ const SKIP_FILES = new Set(['_index.md', 'surface-template.md']);
20
+ export async function renderSurfaces(opts) {
21
+ const dir = path.join(opts.buildRoot, 'ui-ux');
22
+ const outPath = path.join(dir, '_view.html');
23
+ if (!existsSync(dir)) {
24
+ return { written: false, outPath, surfaceCount: 0 };
25
+ }
26
+ const surfaces = await loadSurfaces(dir);
27
+ const body = renderBody(surfaces);
28
+ const html = pageWrapper({
29
+ title: 'Surfaces & sitemap',
30
+ projectName: opts.projectName,
31
+ generatedAt: opts.generatedAt,
32
+ sourceNote: '<code>docs/build/ui-ux/</code>',
33
+ body,
34
+ });
35
+ await mkdir(dir, { recursive: true });
36
+ await writeFile(outPath, html, 'utf8');
37
+ return { written: true, outPath, surfaceCount: surfaces.length };
38
+ }
39
+ async function loadSurfaces(dir) {
40
+ const entries = await readdir(dir, { withFileTypes: true });
41
+ const surfaces = [];
42
+ for (const entry of entries) {
43
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
44
+ continue;
45
+ if (SKIP_FILES.has(entry.name))
46
+ continue;
47
+ const filePath = path.join(dir, entry.name);
48
+ const md = await readFile(filePath, 'utf8');
49
+ if (isPlaceholder(md))
50
+ continue;
51
+ surfaces.push(parseSurface(entry.name, md));
52
+ }
53
+ surfaces.sort((a, b) => a.title.localeCompare(b.title));
54
+ return surfaces;
55
+ }
56
+ function parseSurface(file, md) {
57
+ const sections = parseSections(md);
58
+ const preamble = sections.get('') ?? '';
59
+ const titleMatch = md.match(/^#\s+(.+)$/m);
60
+ return {
61
+ file,
62
+ slug: file.replace(/\.md$/, ''),
63
+ title: titleMatch ? titleMatch[1].trim() : file,
64
+ type: extractMetadata(preamble, 'Type'),
65
+ status: extractMetadata(preamble, 'Status'),
66
+ personaRefs: extractPersonaRefs(sections.get('Who uses this surface') ?? ''),
67
+ sections,
68
+ };
69
+ }
70
+ function extractMetadata(preamble, key) {
71
+ const re = new RegExp(`\\*\\*${key}:\\*\\*\\s*(.+)`, 'i');
72
+ const m = preamble.match(re);
73
+ return m ? m[1].trim() : null;
74
+ }
75
+ function extractPersonaRefs(body) {
76
+ const out = [];
77
+ const re = /P\d{3}/g;
78
+ let m;
79
+ while ((m = re.exec(body)) !== null)
80
+ out.push(m[0]);
81
+ return Array.from(new Set(out));
82
+ }
83
+ function renderBody(surfaces) {
84
+ if (surfaces.length === 0) {
85
+ return card('No surfaces filed yet', emptyState('Phase C of planning produces this content. Once surfaces are filed in docs/build/ui-ux/, this view will render them.'));
86
+ }
87
+ return [renderSitemap(surfaces), ...surfaces.map(renderSurfaceCard)].join('\n');
88
+ }
89
+ function renderSitemap(surfaces) {
90
+ const groups = new Map();
91
+ for (const s of surfaces) {
92
+ const key = s.personaRefs.length > 0 ? s.personaRefs.join(', ') : '(no persona linked)';
93
+ const bucket = groups.get(key) ?? [];
94
+ bucket.push(s);
95
+ groups.set(key, bucket);
96
+ }
97
+ const sections = [...groups.entries()]
98
+ .sort((a, b) => a[0].localeCompare(b[0]))
99
+ .map(([persona, group]) => {
100
+ const items = group
101
+ .map((s) => {
102
+ const typeTag = s.type ? ` <span class="tag">${escapeHtml(s.type)}</span>` : '';
103
+ return `<li><a href="#${escapeHtml(s.slug)}">${escapeHtml(s.title)}</a>${typeTag}</li>`;
104
+ })
105
+ .join('');
106
+ return `<h4>${escapeHtml(persona)}</h4><ul class="sitemap-list">${items}</ul>`;
107
+ })
108
+ .join('');
109
+ return card('Sitemap', sections);
110
+ }
111
+ function renderSurfaceCard(s) {
112
+ const tags = [];
113
+ if (s.type)
114
+ tags.push(`<span class="tag">${escapeHtml(s.type)}</span>`);
115
+ if (s.status)
116
+ tags.push(`<span class="tag tag--status">${escapeHtml(s.status)}</span>`);
117
+ for (const ref of s.personaRefs)
118
+ tags.push(`<span class="tag">${escapeHtml(ref)}</span>`);
119
+ const headerTags = tags.length > 0 ? `<div class="card__tags">${tags.join(' ')}</div>` : '';
120
+ const sectionBlocks = [headerTags];
121
+ for (const [heading, body] of s.sections) {
122
+ if (heading === '' || heading.toLowerCase() === 'notes')
123
+ continue;
124
+ if (heading === 'Open questions about this surface' && !body.trim())
125
+ continue;
126
+ const rendered = body.trim() ? renderProse(body) : '';
127
+ if (!rendered)
128
+ continue;
129
+ sectionBlocks.push(`<h4>${escapeHtml(heading)}</h4>${rendered}`);
130
+ }
131
+ // Best-effort wireframe: render the first paragraph of "What they see" as a
132
+ // hint label inside a skeletal frame. Not a real wireframe — a signal that
133
+ // this is a surface and roughly what it contains.
134
+ const seen = s.sections.get('What they see') ?? '';
135
+ const hint = firstParagraph(seen);
136
+ const wireframe = hint
137
+ ? `<div class="wireframe"><div class="wireframe__label">${renderInlineMarkdown(hint).slice(0, 240)}</div></div>`
138
+ : '';
139
+ return `<section id="${escapeHtml(s.slug)}" class="card">
140
+ <h3>${escapeHtml(s.title)}</h3>
141
+ ${wireframe}
142
+ <div class="card__body">${sectionBlocks.join('')}</div>
143
+ </section>`;
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nusoft/nuos-build-catalogue",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "NuOS build-catalogue tooling: semantic search (WU 110) + migration runner that lifts markdown artefacts into JSON-backed workflow records (WU 111, Phase G).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
20
20
  "prepublishOnly": "npm run build",
21
21
  "verify-storage": "tsx scripts/verify-persistence.ts",
22
- "test": "tsx --test tests/chunk.test.ts tests/metadata.test.ts tests/crawl.test.ts tests/migrate.test.ts tests/commands-read.test.ts tests/regenerate.test.ts tests/commands-write.test.ts tests/ac-parse.test.ts tests/create.test.ts tests/init.test.ts tests/wu-111-soak-findings.test.ts tests/plan.test.ts tests/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.test.ts tests/wu-active.test.ts tests/install-claude-hooks.test.ts",
22
+ "test": "tsx --test tests/chunk.test.ts tests/metadata.test.ts tests/crawl.test.ts tests/migrate.test.ts tests/commands-read.test.ts tests/regenerate.test.ts tests/commands-write.test.ts tests/ac-parse.test.ts tests/create.test.ts tests/init.test.ts tests/wu-111-soak-findings.test.ts tests/plan.test.ts tests/mode.test.ts tests/render.test.ts tests/swarm.test.ts tests/setup-progress-bar.test.ts tests/setup-ollama-pull.test.ts tests/setup-run-llm-setup.test.ts tests/wu-active.test.ts tests/install-claude-hooks.test.ts",
23
23
  "typecheck": "tsc --noEmit",
24
24
  "index": "tsx src/cli.ts index",
25
25
  "search": "tsx src/cli.ts search"
@@ -38,15 +38,17 @@ If anything in the work unit is ambiguous, **stop and surface the ambiguity to t
38
38
 
39
39
  1. **Plan the change in your head first**, then state it in 1-2 sentences before writing code. Match existing code idioms; don't introduce new patterns the project hasn't adopted.
40
40
 
41
- 2. **Make the smallest change that satisfies the work unit's acceptance criteria.** Don't refactor adjacent code "while you're there" unless the work unit explicitly asks for it.
41
+ 2. **Search before writing (DRY — strict).** Before adding any new helper, utility, type, component, hook, validator, query, styled primitive, or constant: grep the codebase for one that already does the job by name (the noun you'd naturally call it), by shape (signature, prop list, structural pattern), and in the conventional locations for the stack (`lib/`, `utils/`, `hooks/`, `components/`, `types/`, `db/queries/`, `schemas/`, equivalents). If found, import and use it (extend in-place if it needs a small addition). If close-but-not-quite, **stop and surface to the coordinator** — extending the existing thing is almost always cheaper than spawning a parallel implementation. This rule applies to new code in *this* WU; pre-existing duplication elsewhere is a follow-up to file, not your job. Search-and-reuse only — this is *not* a directive to extract net-new abstractions; the rule against premature abstraction (point 4) still applies.
42
42
 
43
- 3. **Write code that the tester can verify.** Every acceptance criterion in the work unit should be checkable by looking at the running system your code should make that easy.
43
+ 3. **Make the smallest change that satisfies the work unit's acceptance criteria.** Don't refactor adjacent code "while you're there" unless the work unit explicitly asks for it.
44
44
 
45
- 4. **Avoid speculative abstractions.** Three similar lines of code beats a premature abstraction. Don't design for hypothetical future requirements. The architect designs; you implement what's needed now.
45
+ 4. **Write code that the tester can verify.** Every acceptance criterion in the work unit should be checkable by looking at the running system your code should make that easy.
46
46
 
47
- 5. **No comments unless WHY is non-obvious.** A hidden constraint, a workaround for a specific bug, behaviour that would surprise a reader. If removing the comment wouldn't confuse a future reader, don't write it.
47
+ 5. **Avoid speculative abstractions.** Three similar lines of code beats a premature abstraction. Don't design for hypothetical future requirements. The architect designs; you implement what's needed now.
48
48
 
49
- 6. **Write testable code (vitest gate).** If `methodfile.json` declares `testing.framework: "vitest"` with `testing.enforced: true`, every source file you create or substantially modify in this WU must end up covered by at least one vitest test — the tester writes them, but your job is to make that cheap. Export the units the tester needs to reach; avoid burying observable logic inside untestable closures; keep side effects at the edges. Files that genuinely can't be unit-tested (pure type declarations, config glue) are fine — flag them in your notes so the reviewer doesn't treat them as drift.
49
+ 6. **No comments unless WHY is non-obvious.** A hidden constraint, a workaround for a specific bug, behaviour that would surprise a reader. If removing the comment wouldn't confuse a future reader, don't write it.
50
+
51
+ 7. **Write testable code (vitest gate).** If `methodfile.json` declares `testing.framework: "vitest"` with `testing.enforced: true`, every source file you create or substantially modify in this WU must end up covered by at least one vitest test — the tester writes them, but your job is to make that cheap. Export the units the tester needs to reach; avoid burying observable logic inside untestable closures; keep side effects at the edges. Files that genuinely can't be unit-tested (pure type declarations, config glue) are fine — flag them in your notes so the reviewer doesn't treat them as drift.
50
52
 
51
53
  ## When you finish
52
54
 
@@ -46,13 +46,15 @@ nuos-catalogue memory store --value="<the pattern and why it matters>" --wu=<han
46
46
 
47
47
  4. **Does it match existing code idioms?** New patterns introduced without justification are a yellow flag — surface them to the coordinator as either "rename to match existing X" or "intentional, file as new pattern in architecture/".
48
48
 
49
- 5. **Does it surface or hide changes future work needs to know?** If the coder modified an interface that downstream work depends on, the change should be in a decision file or a contract updatenot silent.
49
+ 5. **Does new code reinvent something the codebase already has? (DRY strict.)** For each new helper, utility, type, component, hook, validator, query, styled primitive, or constant the coder added in this WU, grep the codebase for an existing implementation that does the same job — by name, by signature/shape, and in the conventional locations for the stack (`lib/`, `utils/`, `hooks/`, `components/`, `types/`, `db/queries/`, `schemas/`, equivalents). If you find one, raise a **BLOCKER** citing the existing path; the coder must reuse it (extending the existing one in place if needed) rather than ship a parallel implementation. This applies only to *new* code in this WU vs. the *existing* codebase don't flag duplication within the WU's own output as a violation; the rule against premature abstraction still governs net-new shared code.
50
50
 
51
- 6. **Is there dead-weight or scope creep?** Refactors adjacent to the work unit that weren't asked for. Speculative abstractions. Unnecessary comments. Half-implementations of features not in this work unit.
51
+ 6. **Does it surface or hide changes future work needs to know?** If the coder modified an interface that downstream work depends on, the change should be in a decision file or a contract update — not silent.
52
52
 
53
- 7. **Is jargon being introduced into user-facing copy?** If the work unit serves a non-engineer persona, the surface text should match the project's voice file. Flag anything that sounds like dev-speak in a user-facing surface.
53
+ 7. **Is there dead-weight or scope creep?** Refactors adjacent to the work unit that weren't asked for. Speculative abstractions. Unnecessary comments. Half-implementations of features not in this work unit.
54
54
 
55
- 8. **Does the vitest gate pass (JS/TS projects)?** If `methodfile.json` declares `testing.framework: "vitest"` with `testing.enforced: true`, run both gates from [build-wu.md §Step 5.5](../protocols/build-wu.md):
55
+ 8. **Is jargon being introduced into user-facing copy?** If the work unit serves a non-engineer persona, the surface text should match the project's voice file. Flag anything that sounds like dev-speak in a user-facing surface.
56
+
57
+ 9. **Does the vitest gate pass (JS/TS projects)?** If `methodfile.json` declares `testing.framework: "vitest"` with `testing.enforced: true`, run both gates from [build-wu.md §Step 5.5](../protocols/build-wu.md):
56
58
  - **Gate A:** Run `npx vitest run` (or whatever `testing.command` says) from the implementation repo root. Capture the full output. Non-zero exit → BLOCKER finding with the failing test list.
57
59
  - **Gate B:** Compute `git diff --name-only <swarm-base>...HEAD`, filter to source files (`.ts/.tsx/.js/.jsx` under `src/`, `app/`, `routes/`, `pages/`, `lib/`, `components/`, `api/` — exclude `*.test.*`, `*.spec.*`, `*.d.ts`, configs). For each remaining file, grep the test directories for an import of that module or a colocated `*.test.*` file. Any uncovered file → BLOCKER finding naming the file. The coder may rebut by flagging files as genuinely untestable (type-only, config glue) in the WU notes — accept those rebuttals when reasonable.
58
60