@hegemonart/get-design-done 1.53.0 → 1.55.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +88 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +2 -1
  6. package/agents/component-taxonomy-mapper.md +3 -0
  7. package/agents/motion-mapper.md +1 -0
  8. package/agents/token-mapper.md +3 -0
  9. package/bin/gdd-dashboard +91 -0
  10. package/dist/claude-code/.claude/skills/new-addendum/SKILL.md +81 -0
  11. package/package.json +2 -1
  12. package/reference/frameworks/astro.md +43 -0
  13. package/reference/frameworks/nextjs.md +44 -0
  14. package/reference/frameworks/remix.md +44 -0
  15. package/reference/frameworks/storybook.md +44 -0
  16. package/reference/frameworks/sveltekit.md +43 -0
  17. package/reference/frameworks/vite-react.md +43 -0
  18. package/reference/interaction.md +1 -0
  19. package/reference/motion/framer-motion.md +45 -0
  20. package/reference/motion/gsap.md +45 -0
  21. package/reference/motion/motion-one.md +44 -0
  22. package/reference/motion/react-spring.md +44 -0
  23. package/reference/motion.md +1 -0
  24. package/reference/registry.json +163 -1
  25. package/reference/registry.schema.json +18 -1
  26. package/reference/skill-graph.md +2 -1
  27. package/reference/systems/chakra.md +44 -0
  28. package/reference/systems/css-modules.md +44 -0
  29. package/reference/systems/mui.md +44 -0
  30. package/reference/systems/radix-themes.md +43 -0
  31. package/reference/systems/shadcn.md +45 -0
  32. package/reference/systems/styled-components.md +44 -0
  33. package/reference/systems/tailwind.md +44 -0
  34. package/reference/systems/vanilla-extract.md +44 -0
  35. package/scripts/lib/dashboard/graph-html.cjs +0 -0
  36. package/scripts/lib/detect/stack.cjs +455 -0
  37. package/scripts/lib/detect/stack.d.cts +44 -0
  38. package/scripts/lib/explore-parallel-runner/index.ts +138 -1
  39. package/scripts/lib/explore-parallel-runner/types.ts +27 -0
  40. package/scripts/lib/health-mirror/index.cjs +218 -1
  41. package/scripts/lib/manifest/skills.json +8 -0
  42. package/scripts/lib/mapper-spawn.cjs +257 -0
  43. package/scripts/lib/mapper-spawn.d.cts +60 -0
  44. package/scripts/lib/new-addendum.cjs +204 -0
  45. package/sdk/cli/commands/dashboard.ts +419 -0
  46. package/sdk/cli/index.js +1388 -3
  47. package/sdk/cli/index.ts +7 -0
  48. package/sdk/dashboard/data/_pkg-root.cjs +92 -0
  49. package/sdk/dashboard/data/cost-aggregator.cjs +187 -0
  50. package/sdk/dashboard/data/discovery.cjs +297 -0
  51. package/sdk/dashboard/data/risk-surface.cjs +136 -0
  52. package/sdk/dashboard/data/source.cjs +576 -0
  53. package/sdk/dashboard/tui/ansi.cjs +355 -0
  54. package/sdk/dashboard/tui/index.cjs +778 -0
  55. package/sdk/mcp/gdd-mcp/server.js +1117 -0
  56. package/skills/new-addendum/SKILL.md +81 -0
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+ // scripts/lib/mapper-spawn.cjs - Phase 54 (Composable Reference Addendums), executor B (COMP-01).
3
+ //
4
+ // Pre-spawn composition step for the explore mappers. Given a mapper name + a
5
+ // detected stack fingerprint, select the matching stack-addendum registry
6
+ // entries, read their bodies from the reference dir, and concat them into a
7
+ // single "## Stack-specific guidance" block that the runner appends to
8
+ // spec.prompt BEFORE spawnMapper (the agent bodies are NOT edited; addendums
9
+ // ride in spec.prompt).
10
+ //
11
+ // Design notes:
12
+ // * PURE w.r.t. its inputs: takes a stack OBJECT (does NOT import
13
+ // detect/stack.cjs - independent of executor A) and an explicit registry
14
+ // object + refDir. Reads addendum file bodies via node:fs only.
15
+ // * DEP-FREE (node:fs + node:path only) and NEVER throws. An absent
16
+ // registry, an entry with no readable file, or a malformed stack all
17
+ // degrade to an empty block; detected-but-unmatched stacks land in
18
+ // `missing` so the runner can raise the fallback flag (R6).
19
+ // * CAP 3 per spawn: at most one DS + one framework + one motion addendum.
20
+ // A 4th category (or a second entry in the same category) is ignored.
21
+ //
22
+ // Matching rule (so executor F wires it + executors C/D/E name addendums
23
+ // consistently): each stack-addendum entry resolves to a {category, key}:
24
+ // - category: explicit entry.kind / entry.category if present
25
+ // ('system'|'framework'|'motion'), else inferred from the path dir
26
+ // (reference/systems/* -> 'system', reference/frameworks/* ->
27
+ // 'framework', reference/motion/* -> 'motion').
28
+ // - key: explicit entry.stack if present, else the path basename without
29
+ // '.md', else the trailing '-'-segment of entry.name. Compared
30
+ // case-insensitively.
31
+ // The detected stack supplies the values to match:
32
+ // stack.ds matches a 'system' addendum whose key === ds
33
+ // stack.framework matches a 'framework' addendum whose key === framework
34
+ // stack.motion_libs matches a 'motion' addendum whose key is in the list
35
+ // The first matching entry per (category, value) wins; later duplicates are
36
+ // skipped. This keeps the cap at 1+1+1 = 3 without any category priority math.
37
+
38
+ const fs = require('node:fs');
39
+ const path = require('node:path');
40
+
41
+ const BLOCK_HEADER = '## Stack-specific guidance';
42
+ const ADDENDUM_SEPARATOR = '\n\n---\n\n';
43
+
44
+ // Map a detected-stack field onto the addendum category it selects from.
45
+ // Order is the canonical 1 DS + 1 framework + 1 motion fill order.
46
+ const CATEGORY_ORDER = ['system', 'framework', 'motion'];
47
+
48
+ /** Lowercase + trim a value to a comparable key, or '' for non-strings. */
49
+ function normKey(value) {
50
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
51
+ }
52
+
53
+ /** Path basename without a trailing `.md` (forward-slash + back-slash safe). */
54
+ function baseNameNoExt(p) {
55
+ if (typeof p !== 'string' || p.length === 0) return '';
56
+ const tail = p.replace(/\\/g, '/').split('/').pop() || '';
57
+ return tail.replace(/\.md$/i, '');
58
+ }
59
+
60
+ /**
61
+ * Classify a stack-addendum registry entry into { category, key }.
62
+ * `category` is one of CATEGORY_ORDER or null when it cannot be determined;
63
+ * `key` is the normalized stack identifier ('' when absent).
64
+ */
65
+ function classifyEntry(entry) {
66
+ // Category: explicit kind/category wins, else infer from the path directory.
67
+ let category = null;
68
+ const explicitKind = normKey(entry.kind || entry.category);
69
+ if (explicitKind === 'system' || explicitKind === 'ds' || explicitKind === 'design-system') {
70
+ category = 'system';
71
+ } else if (explicitKind === 'framework') {
72
+ category = 'framework';
73
+ } else if (explicitKind === 'motion') {
74
+ category = 'motion';
75
+ } else if (typeof entry.path === 'string') {
76
+ const p = entry.path.replace(/\\/g, '/');
77
+ if (/(^|\/)reference\/systems\//i.test(p) || /(^|\/)systems\//i.test(p)) category = 'system';
78
+ else if (/(^|\/)reference\/frameworks\//i.test(p) || /(^|\/)frameworks\//i.test(p)) category = 'framework';
79
+ else if (/(^|\/)reference\/motion\//i.test(p) || /(^|\/)motion\//i.test(p)) category = 'motion';
80
+ }
81
+
82
+ // Key: explicit `stack` field wins, else path basename, else name tail.
83
+ let key = normKey(entry.stack);
84
+ if (key === '') key = normKey(baseNameNoExt(entry.path));
85
+ if (key === '') {
86
+ // Fall back to the trailing '-'-segment of the entry name
87
+ // (e.g. "addendum-system-tailwind" -> "tailwind").
88
+ const nameParts = normKey(entry.name).split('-').filter(Boolean);
89
+ key = nameParts.length > 0 ? nameParts[nameParts.length - 1] : '';
90
+ }
91
+
92
+ return { category, key };
93
+ }
94
+
95
+ /** True when `entry` is a stack-addendum that composes into `mapperName`. */
96
+ function composesInto(entry, mapperName) {
97
+ if (!entry || entry.type !== 'stack-addendum') return false;
98
+ const list = entry.composes_into;
99
+ if (!Array.isArray(list)) return false;
100
+ return list.some((m) => normKey(m) === normKey(mapperName));
101
+ }
102
+
103
+ /**
104
+ * Read an addendum body from disk. Returns the trimmed file contents, or null
105
+ * when the file is missing / unreadable / empty. `entry.path` is resolved
106
+ * relative to `refDir` when it is not already absolute; a leading
107
+ * `reference/` segment is tolerated so registry paths (which are repo-root
108
+ * relative, e.g. "reference/systems/tailwind.md") resolve against a refDir
109
+ * that already points at the reference dir.
110
+ */
111
+ function readAddendumBody(entry, refDir) {
112
+ if (typeof entry.path !== 'string' || entry.path.length === 0) return null;
113
+ const rel = entry.path.replace(/\\/g, '/');
114
+ const candidates = [];
115
+ if (path.isAbsolute(rel)) {
116
+ candidates.push(rel);
117
+ } else {
118
+ candidates.push(path.resolve(refDir, rel));
119
+ // refDir may itself be the `reference/` dir; strip a redundant leading
120
+ // `reference/` so "reference/systems/x.md" still resolves.
121
+ const stripped = rel.replace(/^reference\//i, '');
122
+ if (stripped !== rel) candidates.push(path.resolve(refDir, stripped));
123
+ }
124
+ for (const abs of candidates) {
125
+ let body;
126
+ try {
127
+ body = fs.readFileSync(abs, 'utf8');
128
+ } catch {
129
+ continue;
130
+ }
131
+ const trimmed = body.replace(/\s+$/, '').replace(/^/, '');
132
+ if (trimmed.trim().length > 0) return trimmed;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Compose the stack-specific guidance block for one mapper.
139
+ *
140
+ * @param {string} mapperName the mapper the addendums must compose into
141
+ * (e.g. "token-mapper").
142
+ * @param {{ds?: string|null, framework?: string|null, motion_libs?: string[]}} stack
143
+ * the detected stack fingerprint (executor A shape).
144
+ * Null / undefined / {} -> empty block.
145
+ * @param {{registry?: object, refDir?: string, cap?: number}} [opts]
146
+ * - registry: the parsed reference/registry.json object ({ entries: [] }).
147
+ * - refDir: directory addendum `path`s resolve against (repo root or
148
+ * the reference/ dir).
149
+ * - cap: max addendums in the block (default 3).
150
+ * @returns {{block: string, used: string[], missing: string[]}}
151
+ * - block: "## Stack-specific guidance" text (incl. trailing bodies),
152
+ * or '' when nothing matched.
153
+ * - used: names (or path basenames) of the addendums included, in
154
+ * system -> framework -> motion order.
155
+ * - missing: detected stack values that had NO matching addendum, in the
156
+ * same order (drives the fallback flag).
157
+ */
158
+ function composeAddendums(mapperName, stack, opts) {
159
+ const used = [];
160
+ const missing = [];
161
+ const empty = () => ({ block: '', used, missing });
162
+
163
+ const o = opts || {};
164
+ const cap = Number.isInteger(o.cap) && o.cap >= 0 ? o.cap : 3;
165
+ const refDir = typeof o.refDir === 'string' && o.refDir.length > 0 ? o.refDir : process.cwd();
166
+
167
+ if (!stack || typeof stack !== 'object' || cap === 0) return empty();
168
+
169
+ const registry = o.registry;
170
+ const entries = registry && Array.isArray(registry.entries) ? registry.entries : [];
171
+
172
+ // The detected value we want to match, per category.
173
+ const detected = {
174
+ system: normKey(stack.ds),
175
+ framework: normKey(stack.framework),
176
+ // motion is a list; take the first non-empty entry (cap allows only one
177
+ // motion addendum, so the leading detected lib wins).
178
+ motion: Array.isArray(stack.motion_libs)
179
+ ? normKey(stack.motion_libs.find((m) => normKey(m) !== ''))
180
+ : '',
181
+ };
182
+
183
+ // Pre-classify candidate entries (only those composing into this mapper).
184
+ const candidates = [];
185
+ for (const entry of entries) {
186
+ if (!composesInto(entry, mapperName)) continue;
187
+ const { category, key } = classifyEntry(entry);
188
+ if (category === null || key === '') continue;
189
+ candidates.push({ entry, category, key });
190
+ }
191
+
192
+ const bodies = [];
193
+ for (const category of CATEGORY_ORDER) {
194
+ if (used.length >= cap) break;
195
+ const want = detected[category];
196
+ if (want === '') continue; // nothing detected in this category
197
+
198
+ const hit = candidates.find((c) => c.category === category && c.key === want);
199
+ if (!hit) {
200
+ // Detected this stack but no addendum registered for it -> fallback flag.
201
+ missing.push(want);
202
+ continue;
203
+ }
204
+ const body = readAddendumBody(hit.entry, refDir);
205
+ if (body === null) {
206
+ // Entry exists but the file is missing/empty: treat as no coverage.
207
+ missing.push(want);
208
+ continue;
209
+ }
210
+ bodies.push(body);
211
+ used.push(typeof hit.entry.name === 'string' && hit.entry.name.length > 0
212
+ ? hit.entry.name
213
+ : hit.key);
214
+ }
215
+
216
+ if (bodies.length === 0) return empty();
217
+
218
+ const block = `${BLOCK_HEADER}\n\n${bodies.join(ADDENDUM_SEPARATOR)}`;
219
+ return { block, used, missing };
220
+ }
221
+
222
+ /**
223
+ * Pre-spawn mutation helper for the explore runner. Composes the addendum
224
+ * block for `spec.name` and, when non-empty, APPENDS it to `spec.prompt`
225
+ * (separated by a blank line). Returns the same `spec` object (mutated in
226
+ * place) plus the compose metadata so the caller can surface `missing`.
227
+ *
228
+ * Backward-compatible + additive: an empty block leaves `spec.prompt`
229
+ * byte-for-byte unchanged. Never throws: a malformed spec returns unchanged
230
+ * with empty metadata.
231
+ *
232
+ * @param {{name?: string, prompt?: string}} spec a MapperSpec-shaped object.
233
+ * @param {object} stack detected stack (see composeAddendums).
234
+ * @param {object} [opts] registry/refDir/cap (see composeAddendums).
235
+ * @returns {{spec: object, block: string, used: string[], missing: string[]}}
236
+ */
237
+ function applyAddendums(spec, stack, opts) {
238
+ if (!spec || typeof spec !== 'object') {
239
+ return { spec, block: '', used: [], missing: [] };
240
+ }
241
+ const mapperName = typeof spec.name === 'string' ? spec.name : '';
242
+ const { block, used, missing } = composeAddendums(mapperName, stack, opts);
243
+ if (block !== '') {
244
+ const base = typeof spec.prompt === 'string' ? spec.prompt : '';
245
+ spec.prompt = base === '' ? block : `${base}\n\n${block}`;
246
+ }
247
+ return { spec, block, used, missing };
248
+ }
249
+
250
+ module.exports = {
251
+ composeAddendums,
252
+ applyAddendums,
253
+ // Exported for unit-level coverage + reuse by the runner wiring (executor F).
254
+ classifyEntry,
255
+ composesInto,
256
+ BLOCK_HEADER,
257
+ };
@@ -0,0 +1,60 @@
1
+ // scripts/lib/mapper-spawn.d.cts — types for mapper-spawn.cjs (Phase 54 COMP-01).
2
+
3
+ export interface DetectedStackInput {
4
+ ds?: string | null;
5
+ framework?: string | null;
6
+ motion_libs?: string[];
7
+ }
8
+
9
+ export interface ComposeAddendumsOptions {
10
+ /** Parsed reference/registry.json object ({ entries: [...] }). */
11
+ registry?: unknown;
12
+ /** Directory addendum `path`s resolve against (repo root or reference/). */
13
+ refDir?: string;
14
+ /** Max addendums in the block (default 3 = 1 system + 1 framework + 1 motion). */
15
+ cap?: number;
16
+ }
17
+
18
+ export interface ComposeAddendumsResult {
19
+ /** "## Stack-specific guidance" block text, or '' when nothing matched. */
20
+ block: string;
21
+ /** Names (or path basenames) of the addendums included. */
22
+ used: string[];
23
+ /** Detected stack values that had NO matching addendum (fallback flag). */
24
+ missing: string[];
25
+ }
26
+
27
+ export interface ApplyAddendumsResult extends ComposeAddendumsResult {
28
+ /** The (possibly mutated) spec object passed in. */
29
+ spec: unknown;
30
+ }
31
+
32
+ export interface ClassifiedEntry {
33
+ category: 'system' | 'framework' | 'motion' | null;
34
+ key: string;
35
+ }
36
+
37
+ /**
38
+ * Compose the stack-specific guidance block for one mapper. Pure (reads files);
39
+ * NEVER throws. Detected-but-unmatched stack values land in `missing`.
40
+ */
41
+ export function composeAddendums(
42
+ mapperName: string,
43
+ stack: DetectedStackInput | null | undefined,
44
+ opts?: ComposeAddendumsOptions,
45
+ ): ComposeAddendumsResult;
46
+
47
+ /**
48
+ * Pre-spawn helper: composes the block for `spec.name` and APPENDS it to
49
+ * `spec.prompt` (mutated in place) when non-empty. Backward-compatible: an
50
+ * empty block leaves `spec.prompt` byte-for-byte unchanged. NEVER throws.
51
+ */
52
+ export function applyAddendums(
53
+ spec: { name?: string; prompt?: string } | null | undefined,
54
+ stack: DetectedStackInput | null | undefined,
55
+ opts?: ComposeAddendumsOptions,
56
+ ): ApplyAddendumsResult;
57
+
58
+ export function classifyEntry(entry: Record<string, unknown>): ClassifiedEntry;
59
+ export function composesInto(entry: Record<string, unknown>, mapperName: string): boolean;
60
+ export const BLOCK_HEADER: string;
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/new-addendum.cjs — Phase 54 (Composable Reference Addendums), REG-01.
4
+ *
5
+ * Pure, dependency-free generator behind the `/gdd:new-addendum <kind> <name>`
6
+ * scaffolder skill (source/skills/new-addendum/SKILL.md). The SKILL.md drives
7
+ * the prompts; this module is the deterministic core it (and the test suite)
8
+ * call. Mirrors scripts/lib/manifest/scaffolder.cjs (the Phase 50 skill
9
+ * scaffolder): same ReDoS-safe NAME_RE, same throw-on-invalid contract, same
10
+ * "render a skeleton string, never touch the manifest" boundary.
11
+ *
12
+ * A stack addendum is a REGISTRY ENTRY, not a skill (CONTEXT R4). This
13
+ * scaffolder writes ONE reference/{systems|frameworks|motion}/<name>.md file
14
+ * with the locked frontmatter + the 4 mandatory sections. It does NOT touch
15
+ * reference/registry.json (the maintainer/orchestrator adds the entry + runs
16
+ * the registry round-trip), exactly as new-skill does not touch skills.json.
17
+ *
18
+ * Exports:
19
+ * buildAddendumRecord({ kind, name, composesInto }) -> a normalized record
20
+ * { name, kind, composes_into, phase, dir, path }. Validates kind against
21
+ * KINDS and name against NAME_RE; defaults composes_into by kind. Throws on
22
+ * invalid input.
23
+ * renderAddendumMd(record) -> the addendum skeleton string (frontmatter +
24
+ * the 4 mandatory sections: Conventions / File patterns / Gotchas /
25
+ * Example output). Em-dash-free (lint:prose-clean).
26
+ * targetPathFor(kind, name) -> the repo-root-relative path the file is
27
+ * written to (e.g. "reference/systems/<name>.md").
28
+ *
29
+ * Dependency-free of any third party (node:path only; no fs writes here — the
30
+ * SKILL.md writes the rendered string with the Write tool, same as new-skill).
31
+ */
32
+
33
+ // Slug rule mirrors scripts/lib/manifest/scaffolder.cjs NAME_RE and the
34
+ // registry.schema.json entry-name pattern: kebab-case, starts alnum,
35
+ // ^[a-z0-9][a-z0-9-._]*$. The `\w`-free char class is linear-time (no ReDoS).
36
+ const NAME_RE = /^[a-z0-9][a-z0-9-._]*$/;
37
+
38
+ // The three addendum categories (CONTEXT R4 / shared contracts). Maps each
39
+ // kind to its reference subdir and the default composes_into mapper list.
40
+ // The defaults mirror the round-1 addendum frontmatter that executors C/D/E
41
+ // shipped (systems + frameworks vs motion), so a scaffolded addendum is wired
42
+ // the same way the hand-authored ones are.
43
+ const KIND_SPEC = {
44
+ system: {
45
+ dir: 'reference/systems',
46
+ composesInto: ['token-mapper', 'component-taxonomy-mapper'],
47
+ label: 'design-system',
48
+ },
49
+ framework: {
50
+ dir: 'reference/frameworks',
51
+ composesInto: ['component-taxonomy-mapper', 'visual-hierarchy-mapper'],
52
+ label: 'framework',
53
+ },
54
+ motion: {
55
+ dir: 'reference/motion',
56
+ composesInto: ['motion-mapper'],
57
+ label: 'motion library',
58
+ },
59
+ };
60
+
61
+ const KINDS = Object.keys(KIND_SPEC);
62
+ const PHASE = 54;
63
+
64
+ function fail(msg) {
65
+ throw new Error(`new-addendum: ${msg}`);
66
+ }
67
+
68
+ /** Normalize a composes_into input (string or array) to a clean mapper list. */
69
+ function normalizeComposesInto(composesInto) {
70
+ if (composesInto == null) return undefined;
71
+ let parts;
72
+ if (Array.isArray(composesInto)) parts = composesInto;
73
+ else if (typeof composesInto === 'string') parts = composesInto.split(',');
74
+ else fail('composesInto must be an array or comma-separated string of mapper names');
75
+ const cleaned = parts.map((s) => String(s).trim()).filter(Boolean);
76
+ if (cleaned.length === 0) return undefined;
77
+ // Mapper names follow the same slug rule as everything else here.
78
+ for (const m of cleaned) {
79
+ if (!NAME_RE.test(m)) fail(`composesInto entry "${m}" is not a valid mapper slug`);
80
+ }
81
+ // De-dupe, preserve first-seen order.
82
+ return [...new Set(cleaned)];
83
+ }
84
+
85
+ /** Repo-root-relative target path for a (kind, name). Throws on invalid kind/name. */
86
+ function targetPathFor(kind, name) {
87
+ const spec = KIND_SPEC[kind];
88
+ if (!spec) fail(`kind "${kind}" must be one of: ${KINDS.join(', ')}`);
89
+ const n = typeof name === 'string' ? name.trim() : name;
90
+ if (!n || typeof n !== 'string' || !NAME_RE.test(n)) {
91
+ fail(`name "${name}" must match ${NAME_RE} (lower-case, starts alnum, kebab/dot/underscore)`);
92
+ }
93
+ return `${spec.dir}/${n}.md`;
94
+ }
95
+
96
+ /**
97
+ * Build a normalized addendum record from scaffolder inputs.
98
+ * @param {{ kind: string, name: string, composesInto?: string|string[] }} input
99
+ * @returns {{ name, kind, composes_into: string[], phase: number, dir: string, path: string }}
100
+ * @throws on an invalid kind, an invalid name, or a malformed composesInto.
101
+ */
102
+ function buildAddendumRecord(input) {
103
+ const opts = input || {};
104
+ const kind = typeof opts.kind === 'string' ? opts.kind.trim().toLowerCase() : opts.kind;
105
+ if (!kind || typeof kind !== 'string' || !KIND_SPEC[kind]) {
106
+ fail(`kind is required and must be one of: ${KINDS.join(', ')}`);
107
+ }
108
+ const name = typeof opts.name === 'string' ? opts.name.trim() : opts.name;
109
+ if (!name || typeof name !== 'string') fail('name is required (a kebab-case slug)');
110
+ if (!NAME_RE.test(name)) {
111
+ fail(`name "${name}" must match ${NAME_RE} (lower-case, starts alnum, kebab/dot/underscore)`);
112
+ }
113
+
114
+ const spec = KIND_SPEC[kind];
115
+ const composesInto = normalizeComposesInto(opts.composesInto) || spec.composesInto.slice();
116
+
117
+ return {
118
+ name,
119
+ kind,
120
+ composes_into: composesInto,
121
+ phase: PHASE,
122
+ dir: spec.dir,
123
+ path: `${spec.dir}/${name}.md`,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Render the addendum skeleton string for a record.
129
+ * Frontmatter keys are emitted in the canonical order the round-1 addendums
130
+ * use (name, kind, composes_into, phase). The body carries the 4 mandatory
131
+ * sections (Conventions / File patterns / Gotchas / Example output) with TODO
132
+ * placeholders + a vendor-attribution comment slot (house style). Em-dash-free.
133
+ * @param {object} record a buildAddendumRecord result (or raw {kind,name,...})
134
+ */
135
+ function renderAddendumMd(record) {
136
+ if (!record || typeof record !== 'object') fail('renderAddendumMd requires a record object');
137
+ // Validate / normalize defensively so renderAddendumMd(buildAddendumRecord(x))
138
+ // and renderAddendumMd(rawObject) both produce a contract-valid file.
139
+ const rec = buildAddendumRecord({
140
+ kind: record.kind,
141
+ name: record.name,
142
+ composesInto: record.composes_into,
143
+ });
144
+
145
+ const title = rec.name
146
+ .split(/[-._]/)
147
+ .filter(Boolean)
148
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
149
+ .join(' ');
150
+
151
+ const composes = `[${rec.composes_into.join(', ')}]`;
152
+
153
+ const lines = [
154
+ '---',
155
+ `name: ${rec.name}`,
156
+ `kind: ${rec.kind}`,
157
+ `composes_into: ${composes}`,
158
+ `phase: ${rec.phase}`,
159
+ '---',
160
+ `<!-- Vendor docs: TODO add the canonical ${rec.kind} documentation URL. -->`,
161
+ '',
162
+ `# ${title}`,
163
+ '',
164
+ '## Conventions',
165
+ '',
166
+ `- TODO: how this ${rec.kind} names and structures its tokens and components.`,
167
+ '- TODO: the one rule a mapper most often gets wrong here.',
168
+ '',
169
+ '## File patterns',
170
+ '',
171
+ '- TODO: the config files and source-file shapes that identify this stack.',
172
+ `- Identify via: TODO the detectStack signal (dep name or config file) for ${rec.name}.`,
173
+ '',
174
+ '## Gotchas',
175
+ '',
176
+ '- TODO: the usage that looks like a token but is not (flag as an anti-pattern node).',
177
+ '- TODO: a unit or naming trap a mapper must not mis-classify.',
178
+ '',
179
+ '## Example output',
180
+ '',
181
+ '```json',
182
+ '{',
183
+ ' "schema_version": "52.0",',
184
+ ' "nodes": [',
185
+ ' { "id": "tok.color.primary", "type": "token", "subtype": "color", "name": "TODO", "summary": "TODO brand primary token.", "complexity": "simple", "tags": ["color", "brand"] }',
186
+ ' ],',
187
+ ' "edges": []',
188
+ '}',
189
+ '```',
190
+ '',
191
+ ];
192
+
193
+ return lines.join('\n');
194
+ }
195
+
196
+ module.exports = {
197
+ buildAddendumRecord,
198
+ renderAddendumMd,
199
+ targetPathFor,
200
+ NAME_RE,
201
+ KINDS,
202
+ KIND_SPEC,
203
+ PHASE,
204
+ };