@hegemonart/get-design-done 1.53.0 → 1.54.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +41 -0
  4. package/README.md +2 -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/dist/claude-code/.claude/skills/new-addendum/SKILL.md +81 -0
  10. package/package.json +1 -1
  11. package/reference/frameworks/astro.md +43 -0
  12. package/reference/frameworks/nextjs.md +44 -0
  13. package/reference/frameworks/remix.md +44 -0
  14. package/reference/frameworks/storybook.md +44 -0
  15. package/reference/frameworks/sveltekit.md +43 -0
  16. package/reference/frameworks/vite-react.md +43 -0
  17. package/reference/interaction.md +1 -0
  18. package/reference/motion/framer-motion.md +45 -0
  19. package/reference/motion/gsap.md +45 -0
  20. package/reference/motion/motion-one.md +44 -0
  21. package/reference/motion/react-spring.md +44 -0
  22. package/reference/motion.md +1 -0
  23. package/reference/registry.json +163 -1
  24. package/reference/registry.schema.json +18 -1
  25. package/reference/skill-graph.md +2 -1
  26. package/reference/systems/chakra.md +44 -0
  27. package/reference/systems/css-modules.md +44 -0
  28. package/reference/systems/mui.md +44 -0
  29. package/reference/systems/radix-themes.md +43 -0
  30. package/reference/systems/shadcn.md +45 -0
  31. package/reference/systems/styled-components.md +44 -0
  32. package/reference/systems/tailwind.md +44 -0
  33. package/reference/systems/vanilla-extract.md +44 -0
  34. package/scripts/lib/detect/stack.cjs +455 -0
  35. package/scripts/lib/detect/stack.d.cts +44 -0
  36. package/scripts/lib/explore-parallel-runner/index.ts +138 -1
  37. package/scripts/lib/explore-parallel-runner/types.ts +27 -0
  38. package/scripts/lib/health-mirror/index.cjs +73 -1
  39. package/scripts/lib/manifest/skills.json +8 -0
  40. package/scripts/lib/mapper-spawn.cjs +257 -0
  41. package/scripts/lib/mapper-spawn.d.cts +60 -0
  42. package/scripts/lib/new-addendum.cjs +204 -0
  43. package/sdk/cli/index.js +1135 -1
  44. package/sdk/mcp/gdd-mcp/server.js +1047 -0
  45. package/skills/new-addendum/SKILL.md +81 -0
@@ -30,6 +30,12 @@ import { resolveConcurrency } from '../parallelism-engine/concurrency-tuner.cjs'
30
30
  // way as concurrency-tuner.cjs above. Only invoked when opts.incremental.graph
31
31
  // is supplied — the default explore path never loads its ESM/TS dependencies.
32
32
  import { planIncremental } from '../mappers/incremental-discover.cjs';
33
+ // Phase 54 (REG-01): stack detection + addendum composition. Both CJS, imported
34
+ // the same way. The pre-spawn step (composeMapperSpecs below) calls detectStack
35
+ // ONCE and applyAddendums per spec; both are wrapped in try/catch so a failure
36
+ // degrades to the unmodified Phase-21 spec roster.
37
+ import { detectStack } from '../detect/stack.cjs';
38
+ import { applyAddendums } from '../mapper-spawn.cjs';
33
39
 
34
40
  import {
35
41
  isParallelismSafe,
@@ -105,6 +111,129 @@ export const DEFAULT_MAPPERS: readonly MapperSpec[] = Object.freeze([
105
111
  }),
106
112
  ]);
107
113
 
114
+ // ---------------------------------------------------------------------------
115
+ // Phase 54 (REG-01) — pre-spawn stack-addendum composition
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Derive the agent name an addendum's composes_into list matches against. */
119
+ function agentNameOf(spec: MapperSpec): string {
120
+ // Addendum composes_into uses the AGENT filename (token-mapper,
121
+ // component-taxonomy-mapper, motion-mapper, visual-hierarchy-mapper), not the
122
+ // short MapperSpec.name (token, component-taxonomy, ...). Derive it from the
123
+ // agentPath basename so the registry match keys line up.
124
+ const base = spec.agentPath
125
+ .replace(/\\/g, '/')
126
+ .split('/')
127
+ .pop()
128
+ ?.replace(/\.md$/i, '');
129
+ return base && base.length > 0 ? base : spec.name;
130
+ }
131
+
132
+ /**
133
+ * Compose stack-specific guidance into each mapper spec BEFORE spawn.
134
+ *
135
+ * Detects the project stack ONCE (detect/stack.cjs#detectStack) and, for each
136
+ * spec, appends the matching `type:"stack-addendum"` reference bodies to the
137
+ * prompt (mapper-spawn.cjs#applyAddendums, cap 1 system + 1 framework + 1
138
+ * motion). The match is keyed by the spec's AGENT name against the registry
139
+ * `composes_into` list.
140
+ *
141
+ * Contract:
142
+ * * ADDITIVE + BACKWARD-COMPATIBLE: a spec with no matching addendum (or no
143
+ * detected stack) is returned with a byte-for-byte unchanged prompt. When
144
+ * NOTHING matches across all specs, the original `specs` array is returned
145
+ * unchanged (same reference), so the Phase-21 path is identical.
146
+ * * NEVER THROWS: detection / registry / file-read failures degrade to the
147
+ * unmodified roster. Dispatch is never blocked by this step.
148
+ * * The frozen DEFAULT_MAPPERS entries are never mutated — a spec that gains
149
+ * an addendum is returned as a fresh object.
150
+ *
151
+ * @returns `{ specs, missingByMapper }` — the (possibly) recomposed roster +
152
+ * a per-agent-name list of detected stack values that had NO addendum
153
+ * for that mapper (drives the R6 fallback flag / health coverage row).
154
+ */
155
+ function composeMapperSpecs(
156
+ specs: readonly MapperSpec[],
157
+ cwd: string,
158
+ addendumOpts: ExploreRunnerOptions['addendums'],
159
+ logger: ReturnType<typeof getLogger>,
160
+ ): { specs: readonly MapperSpec[]; missingByMapper: Record<string, string[]> } {
161
+ const missingByMapper: Record<string, string[]> = {};
162
+ const opt = addendumOpts ?? {};
163
+ if (opt.enabled === false) return { specs, missingByMapper };
164
+
165
+ try {
166
+ const root: string = typeof opt.root === 'string' ? opt.root : cwd;
167
+ const detect = typeof opt.detectStack === 'function' ? opt.detectStack : detectStack;
168
+ const stack = detect(root) as {
169
+ ds?: string | null;
170
+ framework?: string | null;
171
+ motion_libs?: string[];
172
+ } | null;
173
+
174
+ // Resolve the registry + refDir. Defaults read the shipped registry and the
175
+ // repo reference/ dir; tests inject both. A missing registry simply yields
176
+ // no matches (applyAddendums degrades to an empty block).
177
+ let registry: unknown = opt.registry;
178
+ const refDir: string =
179
+ typeof opt.refDir === 'string'
180
+ ? opt.refDir
181
+ : resolvePath(cwd, 'reference');
182
+ if (registry === undefined) {
183
+ try {
184
+ // Lazy require so the default path only touches disk when enabled.
185
+ const { loadRegistry } = require('../reference-registry.cjs') as {
186
+ loadRegistry: (o: { cwd?: string }) => unknown;
187
+ };
188
+ registry = loadRegistry({ cwd });
189
+ } catch {
190
+ registry = undefined; // no registry ⇒ no addendums (unchanged prompts)
191
+ }
192
+ }
193
+
194
+ let anyChanged = false;
195
+ const recomposed: MapperSpec[] = specs.map((spec) => {
196
+ const agentName = agentNameOf(spec);
197
+ // applyAddendums mutates a spec-shaped object's `.prompt` in place; we feed
198
+ // it a throwaway carrying the AGENT name so the registry match keys align,
199
+ // then copy the (possibly) augmented prompt back onto a fresh spec.
200
+ const carrier = { name: agentName, prompt: spec.prompt };
201
+ const { block, missing } = applyAddendums(carrier, stack, {
202
+ registry,
203
+ refDir,
204
+ }) as { block: string; used: string[]; missing: string[] };
205
+
206
+ if (Array.isArray(missing) && missing.length > 0) {
207
+ missingByMapper[agentName] = missing;
208
+ }
209
+ if (block && block.length > 0 && carrier.prompt !== spec.prompt) {
210
+ anyChanged = true;
211
+ return Object.freeze({ ...spec, prompt: carrier.prompt });
212
+ }
213
+ return spec;
214
+ });
215
+
216
+ if (anyChanged) {
217
+ logger.info('explore.runner.addendums_composed', {
218
+ mappers_augmented: recomposed.filter((s, i) => s !== specs[i]).length,
219
+ ds: stack && stack.ds ? stack.ds : null,
220
+ framework: stack && stack.framework ? stack.framework : null,
221
+ motion_libs:
222
+ stack && Array.isArray(stack.motion_libs) ? stack.motion_libs.length : 0,
223
+ });
224
+ return { specs: Object.freeze(recomposed), missingByMapper };
225
+ }
226
+ // Nothing matched — return the original roster reference unchanged.
227
+ return { specs, missingByMapper };
228
+ } catch (err) {
229
+ // The addendum step must NEVER break dispatch. Degrade to the unmodified
230
+ // roster + surface a warn for observability.
231
+ const message: string = err instanceof Error ? err.message : String(err);
232
+ logger.warn('explore.runner.addendums_failed', { message });
233
+ return { specs, missingByMapper };
234
+ }
235
+ }
236
+
108
237
  // ---------------------------------------------------------------------------
109
238
  // run — main orchestrator
110
239
  // ---------------------------------------------------------------------------
@@ -123,7 +252,7 @@ export const DEFAULT_MAPPERS: readonly MapperSpec[] = Object.freeze([
123
252
  export async function run(
124
253
  opts: ExploreRunnerOptions,
125
254
  ): Promise<ExploreRunnerResult> {
126
- const specs: readonly MapperSpec[] = opts.mappers ?? DEFAULT_MAPPERS;
255
+ const baseSpecs: readonly MapperSpec[] = opts.mappers ?? DEFAULT_MAPPERS;
127
256
  const cwd: string = opts.cwd ?? process.cwd();
128
257
  // Phase 27.6 D-07: data-driven concurrency default. Falls back to
129
258
  // min(cpu-1, 8) when no `parallelism.verdict` events exist in
@@ -132,6 +261,14 @@ export async function run(
132
261
 
133
262
  const logger = getLogger().child('explore.runner');
134
263
 
264
+ // --- Phase 54 (REG-01): compose stack addendums into mapper prompts -------
265
+ //
266
+ // Fingerprint the project ONCE and append the matching stack-addendum bodies
267
+ // to each mapper's prompt BEFORE partitioning / spawn. ADDITIVE +
268
+ // backward-compatible: no detected stack / no matching addendum ⇒ `specs` is
269
+ // the unchanged roster reference. NEVER throws (composeMapperSpecs guards).
270
+ const { specs } = composeMapperSpecs(baseSpecs, cwd, opts.addendums, logger);
271
+
135
272
  const outputPath: string = resolvePath(cwd, '.design/DESIGN-PATTERNS.md');
136
273
 
137
274
  // --- Phase 53 (DISC-01): incremental batching ----------------------------
@@ -138,6 +138,33 @@ export interface ExploreRunnerOptions {
138
138
  /** Forwarded into classify's projectStats.thresholds. */
139
139
  readonly thresholds?: unknown;
140
140
  };
141
+ /**
142
+ * Phase 54 (REG-01) — OPTIONAL composable stack addendums. Before spawning,
143
+ * the runner fingerprints the project (`scripts/lib/detect/stack.cjs#detectStack`)
144
+ * ONCE and appends the matching `type:"stack-addendum"` reference bodies to
145
+ * each mapper's prompt via `scripts/lib/mapper-spawn.cjs#applyAddendums`
146
+ * (cap 1 design-system + 1 framework + 1 motion per spawn). The addendum is
147
+ * selected by the mapper's AGENT name (e.g. token-mapper) against the
148
+ * registry `composes_into` list.
149
+ *
150
+ * BACKWARD-COMPATIBLE + ADDITIVE: defaults ON, but a project with no
151
+ * detected stack (or no matching addendum) gets a byte-for-byte unchanged
152
+ * prompt. The whole step is wrapped in try/catch so a detection/registry
153
+ * failure NEVER aborts dispatch. Set `enabled: false` to opt out entirely.
154
+ * The `detectStack` / `registry` / `refDir` fields are test-injection seams.
155
+ */
156
+ readonly addendums?: {
157
+ /** Opt out of stack-addendum composition entirely. Default: enabled. */
158
+ readonly enabled?: boolean;
159
+ /** Detection root. Defaults to `cwd`. */
160
+ readonly root?: string;
161
+ /** Injected detectStack (tests). Defaults to detect/stack.cjs#detectStack. */
162
+ readonly detectStack?: (root: string) => unknown;
163
+ /** Injected registry object (tests). Defaults to reference/registry.json. */
164
+ readonly registry?: unknown;
165
+ /** Reference dir addendum paths resolve against. Defaults to repo reference/. */
166
+ readonly refDir?: string;
167
+ };
141
168
  }
142
169
 
143
170
  /**
@@ -8,7 +8,7 @@
8
8
  // Surface:
9
9
  // async getHealthChecks(rootDir) → { checks: HealthCheck[] }
10
10
  //
11
- // The 7 checks (in stable order) are:
11
+ // The 9 checks (in stable order) are:
12
12
  // 1. claude_md — CLAUDE.md presence
13
13
  // 2. planning_dir — .planning/ presence
14
14
  // 3. design_dir — .design/ presence
@@ -16,6 +16,8 @@
16
16
  // 5. issue_reporter — kill-switch state (Plan 30-06 / D-08)
17
17
  // 6. figma_extract — extract readiness + Free-tier signal (Plan 31-09)
18
18
  // 7. skill_discipline — using-gdd bootstrap + SessionStart inject (Plan 32-07)
19
+ // 8. harness_freshness — per-harness last_verified age (Phase 44)
20
+ // 9. stack_addendums — Phase 54 coverage: N/M detected stacks have addendums
19
21
  //
20
22
  // Check 5 was added in Plan 30-06 — surfaces the report-issue kill-switch
21
23
  // (env or config disable) so users can verify why the command is
@@ -238,6 +240,76 @@ async function getHealthChecks(rootDir) {
238
240
  checks.push({ name: 'harness_freshness', status, detail });
239
241
  }
240
242
 
243
+ // 9. stack_addendums — Phase 54 coverage row. Fingerprints the project
244
+ // (detect/stack.cjs) and reports how many DETECTED stacks (design-system +
245
+ // framework + motion libs) have a registered type:"stack-addendum" entry in
246
+ // reference/registry.json. PURE read-only (detection reads files but never
247
+ // networks; registry is a local JSON read). GRACEFUL-ABSENT: no detected
248
+ // stack -> "no stacks detected"; an unreadable registry -> "registry
249
+ // unavailable". status is 'ok' on full coverage or nothing-to-cover,
250
+ // otherwise 'warn'. NEVER throws.
251
+ {
252
+ let status = 'ok';
253
+ let detail;
254
+ try {
255
+ const { detectStack } = require('../detect/stack.cjs');
256
+ const stack = detectStack(rootDir) || { ds: null, framework: null, motion_libs: [] };
257
+
258
+ // The detected stack values to cover, paired with the category an
259
+ // addendum must classify into to count as coverage.
260
+ const wanted = [];
261
+ if (typeof stack.ds === 'string' && stack.ds) wanted.push({ category: 'system', key: stack.ds });
262
+ if (typeof stack.framework === 'string' && stack.framework) {
263
+ wanted.push({ category: 'framework', key: stack.framework });
264
+ }
265
+ if (Array.isArray(stack.motion_libs)) {
266
+ for (const m of stack.motion_libs) {
267
+ if (typeof m === 'string' && m) wanted.push({ category: 'motion', key: m });
268
+ }
269
+ }
270
+
271
+ if (wanted.length === 0) {
272
+ detail = 'stack addendums: no stacks detected';
273
+ status = 'ok';
274
+ } else {
275
+ // Load the registry + classify its stack-addendum entries. A missing or
276
+ // malformed registry counts as zero coverage (warn), never a throw.
277
+ let entries = [];
278
+ try {
279
+ const reg = JSON.parse(
280
+ fs.readFileSync(path.join(rootDir, 'reference', 'registry.json'), 'utf8')
281
+ );
282
+ entries = Array.isArray(reg.entries) ? reg.entries : [];
283
+ } catch {
284
+ entries = null; // distinguish "registry unavailable" from "zero coverage"
285
+ }
286
+
287
+ if (entries === null) {
288
+ detail = 'stack addendums: registry unavailable';
289
+ status = 'warn';
290
+ } else {
291
+ const { classifyEntry } = require('../mapper-spawn.cjs');
292
+ // Build the set of {category|key} an addendum exists for.
293
+ const covered = new Set();
294
+ for (const e of entries) {
295
+ if (!e || e.type !== 'stack-addendum') continue;
296
+ const { category, key } = classifyEntry(e);
297
+ if (category && key) covered.add(category + '|' + key);
298
+ }
299
+ const have = wanted.filter((w) => covered.has(w.category + '|' + String(w.key).toLowerCase())).length;
300
+ const total = wanted.length;
301
+ status = have >= total ? 'ok' : 'warn';
302
+ detail = `stack addendums: ${have}/${total} detected stacks have addendums`;
303
+ }
304
+ }
305
+ } catch {
306
+ // Absolute safety net — the health probe must never crash on this check.
307
+ status = 'warn';
308
+ detail = 'stack addendums: unavailable';
309
+ }
310
+ checks.push({ name: 'stack_addendums', status, detail });
311
+ }
312
+
241
313
  return { checks };
242
314
  }
243
315
 
@@ -260,6 +260,14 @@
260
260
  "user_invocable": true,
261
261
  "registered_in_phase": "52"
262
262
  },
263
+ {
264
+ "name": "new-addendum",
265
+ "description": "Scaffolds a new Phase-54 composable reference addendum for a design-system, framework, or motion library: validates the kind and the slug, defaults composes_into by kind, and writes a 4-section skeleton at reference/{systems|frameworks|motion}/<name>.md from the pure generator. Use when adding stack-specific guidance that an explore mapper should compose at spawn time and you want the frontmatter, the composes_into wiring, and the mandatory sections correct from the first commit. Activates for requests involving authoring a reference addendum, adding stack-specific mapper guidance, scaffolding a systems or frameworks or motion doc, or registering a new design-system.",
266
+ "argument_hint": "<kind> <name>",
267
+ "tools": "Read, Write, Bash, AskUserQuestion",
268
+ "user_invocable": true,
269
+ "registered_in_phase": "54"
270
+ },
263
271
  {
264
272
  "name": "new-cycle",
265
273
  "description": "Start a new design cycle. Creates cycle scope in STATE.md, initializes .design/CYCLES.md entry. Each cycle has its own goal and tracks its own decisions/tasks/pipeline runs.",
@@ -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;