heyspex 0.1.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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,449 @@
1
+ /**
2
+ * HeySpex Design System MCP tools
3
+ *
4
+ * Pure-logic module backing the five `heyspex_design_*` MCP tools mounted in
5
+ * cli.mjs. Reads from spex-console/public/design-system/ (registry.json + per-pattern
6
+ * r/<name>.json + tokens.dtcg.json) and spex-console/DESIGN.md.
7
+ *
8
+ * Spec: [[HS-TASK-DSMC-design-system-mcp-server]]
9
+ * Parent epic: [[HS-EPIC-06O0-design-system-v2-agentic-registry-hs-des]]
10
+ * Source of truth shape filed by [[HS-TASK-DSR1-design-system-registry-alignment]].
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
14
+ import { join, relative } from 'node:path';
15
+
16
+ const REGISTRY_DIR = ['spex-console', 'public', 'design-system'];
17
+ const ITEMS_DIR = ['spex-console', 'public', 'design-system', 'r'];
18
+ const TOKENS_FILE = ['spex-console', 'public', 'design-system', 'tokens.dtcg.json'];
19
+ const DESIGN_DOC = ['spex-console', 'DESIGN.md'];
20
+ const PROPOSALS_DIR = ['specs', 'design-proposals'];
21
+
22
+ const VALID_LAYERS = new Set(['primitive', 'semantic', 'component', 'pattern']);
23
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Filesystem readers
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ function readJson(path) {
30
+ return JSON.parse(readFileSync(path, 'utf8'));
31
+ }
32
+
33
+ export function loadRegistry(vaultRoot) {
34
+ const path = join(vaultRoot, ...REGISTRY_DIR, 'registry.json');
35
+ if (!existsSync(path)) return null;
36
+ return readJson(path);
37
+ }
38
+
39
+ export function loadRegistryItem(vaultRoot, name) {
40
+ const path = join(vaultRoot, ...ITEMS_DIR, `${name}.json`);
41
+ if (!existsSync(path)) return null;
42
+ return readJson(path);
43
+ }
44
+
45
+ export function listRegistryItemNames(vaultRoot) {
46
+ const registry = loadRegistry(vaultRoot);
47
+ if (!registry || !Array.isArray(registry.items)) return [];
48
+ return registry.items.map((it) => it.name).filter(Boolean);
49
+ }
50
+
51
+ export function loadAllRegistryItems(vaultRoot) {
52
+ const names = listRegistryItemNames(vaultRoot);
53
+ const items = [];
54
+ for (const name of names) {
55
+ const item = loadRegistryItem(vaultRoot, name);
56
+ if (item) items.push(item);
57
+ }
58
+ return items;
59
+ }
60
+
61
+ export function loadTokensDtcg(vaultRoot) {
62
+ const path = join(vaultRoot, ...TOKENS_FILE);
63
+ if (!existsSync(path)) return null;
64
+ return readJson(path);
65
+ }
66
+
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+ // list_patterns
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+
71
+ function normalize(value) {
72
+ return String(value ?? '').toLowerCase();
73
+ }
74
+
75
+ function summarizeItem(item) {
76
+ const description = String(item.description ?? '').trim();
77
+ // First sentence (up to first period+space) or first 200 chars.
78
+ let summary = description;
79
+ const sentenceEnd = summary.search(/\.(\s|$)/);
80
+ if (sentenceEnd > 0 && sentenceEnd < 220) {
81
+ summary = summary.slice(0, sentenceEnd + 1);
82
+ } else if (summary.length > 200) {
83
+ summary = summary.slice(0, 200).trimEnd() + '…';
84
+ }
85
+ return {
86
+ name: item.name,
87
+ title: item.title ?? null,
88
+ layer: item.hsLayer ?? null,
89
+ tags: Array.isArray(item.hsTags) ? item.hsTags : [],
90
+ summary,
91
+ };
92
+ }
93
+
94
+ export function executeListPatterns(vaultRoot, args = {}) {
95
+ const registry = loadRegistry(vaultRoot);
96
+ if (!registry) {
97
+ return { error: 'REGISTRY_NOT_FOUND', message: 'spex-console/public/design-system/registry.json not found' };
98
+ }
99
+
100
+ const tagFilter = args.tag ? normalize(args.tag) : null;
101
+ const layerFilter = args.layer ? normalize(args.layer) : null;
102
+ const queryFilter = args.query ? normalize(args.query) : null;
103
+
104
+ if (layerFilter && !VALID_LAYERS.has(layerFilter)) {
105
+ return { error: 'INVALID_LAYER', message: `layer must be one of: ${[...VALID_LAYERS].join(', ')}` };
106
+ }
107
+
108
+ const items = loadAllRegistryItems(vaultRoot);
109
+ const matches = [];
110
+
111
+ for (const item of items) {
112
+ if (tagFilter) {
113
+ const tags = (item.hsTags ?? []).map(normalize);
114
+ if (!tags.includes(tagFilter)) continue;
115
+ }
116
+ if (layerFilter) {
117
+ if (normalize(item.hsLayer) !== layerFilter) continue;
118
+ }
119
+ if (queryFilter) {
120
+ const haystack = [item.name, item.title, item.description].map(normalize).join(' ');
121
+ if (!haystack.includes(queryFilter)) continue;
122
+ }
123
+ matches.push(summarizeItem(item));
124
+ }
125
+
126
+ matches.sort((a, b) => a.name.localeCompare(b.name));
127
+
128
+ return {
129
+ patterns: matches,
130
+ count: matches.length,
131
+ total: items.length,
132
+ filters_applied: {
133
+ tag: args.tag ?? null,
134
+ layer: args.layer ?? null,
135
+ query: args.query ?? null,
136
+ },
137
+ };
138
+ }
139
+
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+ // get_pattern
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+
144
+ export function executeGetPattern(vaultRoot, args = {}) {
145
+ const name = String(args.name ?? '').trim();
146
+ if (!name) {
147
+ return { error: 'MISSING_NAME', message: 'name is required' };
148
+ }
149
+ const item = loadRegistryItem(vaultRoot, name);
150
+ if (!item) {
151
+ return { error: 'PATTERN_NOT_FOUND', message: `No registry item at spex-console/public/design-system/r/${name}.json` };
152
+ }
153
+ const includeCode = args.includeCode !== false; // default true
154
+ if (!includeCode && 'hsCodePreview' in item) {
155
+ const { hsCodePreview, ...rest } = item;
156
+ return { pattern: rest, source: `spex-console/public/design-system/r/${name}.json`, includeCode: false };
157
+ }
158
+ return { pattern: item, source: `spex-console/public/design-system/r/${name}.json`, includeCode: true };
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────────
162
+ // get_token
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ function normalizeVarName(input) {
166
+ const trimmed = String(input ?? '').trim();
167
+ if (!trimmed) return null;
168
+ return trimmed.startsWith('--') ? trimmed : `--${trimmed.replace(/^-+/, '')}`;
169
+ }
170
+
171
+ function varNameToDtcgPath(varName) {
172
+ // --accent-green → ['accent', 'green']
173
+ // --bg-rail-highlight → ['bg', 'rail', 'highlight']
174
+ // --status-ready → ['status', 'ready']
175
+ return varName.replace(/^--/, '').split('-').filter(Boolean);
176
+ }
177
+
178
+ function lookupDtcg(tokens, path) {
179
+ let node = tokens;
180
+ for (const segment of path) {
181
+ if (!node || typeof node !== 'object' || !(segment in node)) return null;
182
+ node = node[segment];
183
+ }
184
+ return node;
185
+ }
186
+
187
+ function bestDtcgMatch(tokens, varName) {
188
+ // Try longest-prefix match: --bg-rail-highlight may live at bg.rail.highlight
189
+ // or, if "rail-highlight" is a leaf name, at bg["rail-highlight"]. Walk down
190
+ // greedily by segment, then if that misses, try joining trailing segments.
191
+ if (!tokens) return null;
192
+ const segments = varNameToDtcgPath(varName);
193
+ // exact path match
194
+ const direct = lookupDtcg(tokens, segments);
195
+ if (direct && (direct.$value !== undefined || direct.$type !== undefined)) {
196
+ return { path: segments, node: direct };
197
+ }
198
+ // try collapsing trailing segments into one kebab leaf
199
+ for (let split = segments.length - 1; split >= 1; split--) {
200
+ const head = segments.slice(0, split);
201
+ const tail = segments.slice(split).join('-');
202
+ const node = lookupDtcg(tokens, [...head, tail]);
203
+ if (node && (node.$value !== undefined || node.$type !== undefined)) {
204
+ return { path: [...head, tail], node };
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ export function executeGetToken(vaultRoot, args = {}) {
211
+ const varName = normalizeVarName(args.var);
212
+ if (!varName) {
213
+ return { error: 'MISSING_VAR', message: 'var is required (e.g. "--accent-green")' };
214
+ }
215
+
216
+ const items = loadAllRegistryItems(vaultRoot);
217
+ const darkValues = new Map(); // value → [pattern names]
218
+ const lightValues = new Map();
219
+ const usedBy = new Set();
220
+
221
+ for (const item of items) {
222
+ const cssVars = item.cssVars ?? {};
223
+ const darkVal = cssVars.dark?.[varName];
224
+ const lightVal = cssVars.light?.[varName];
225
+ if (darkVal !== undefined) {
226
+ if (!darkValues.has(darkVal)) darkValues.set(darkVal, []);
227
+ darkValues.get(darkVal).push(item.name);
228
+ usedBy.add(item.name);
229
+ }
230
+ if (lightVal !== undefined) {
231
+ if (!lightValues.has(lightVal)) lightValues.set(lightVal, []);
232
+ lightValues.get(lightVal).push(item.name);
233
+ usedBy.add(item.name);
234
+ }
235
+ }
236
+
237
+ // DTCG fallback / metadata
238
+ const tokens = loadTokensDtcg(vaultRoot);
239
+ let dtcgKind = null;
240
+ let dtcgGroups = null;
241
+ if (tokens) {
242
+ const darkMatch = bestDtcgMatch(tokens.dark, varName);
243
+ const lightMatch = bestDtcgMatch(tokens.light, varName);
244
+ if (darkMatch?.node?.$type) dtcgKind = darkMatch.node.$type;
245
+ else if (lightMatch?.node?.$type) dtcgKind = lightMatch.node.$type;
246
+ if (darkMatch) dtcgGroups = darkMatch.path.slice(0, -1);
247
+ else if (lightMatch) dtcgGroups = lightMatch.path.slice(0, -1);
248
+ if (darkMatch && !darkValues.size && darkMatch.node.$value !== undefined) {
249
+ darkValues.set(darkMatch.node.$value, ['(tokens.dtcg.json)']);
250
+ }
251
+ if (lightMatch && !lightValues.size && lightMatch.node.$value !== undefined) {
252
+ lightValues.set(lightMatch.node.$value, ['(tokens.dtcg.json)']);
253
+ }
254
+ }
255
+
256
+ if (darkValues.size === 0 && lightValues.size === 0) {
257
+ return { error: 'TOKEN_NOT_FOUND', message: `${varName} not found in any registry item or tokens.dtcg.json` };
258
+ }
259
+
260
+ return {
261
+ var: varName,
262
+ value: {
263
+ dark: [...darkValues.entries()].map(([value, patterns]) => ({ value, patterns })),
264
+ light: [...lightValues.entries()].map(([value, patterns]) => ({ value, patterns })),
265
+ },
266
+ kind: dtcgKind,
267
+ groups: dtcgGroups,
268
+ usedBy: [...usedBy].sort(),
269
+ };
270
+ }
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // get_doc
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ export function executeGetDoc(vaultRoot, args = {}) {
277
+ const name = String(args.name ?? '').trim();
278
+ if (!name) {
279
+ return { error: 'MISSING_NAME', message: 'name is required' };
280
+ }
281
+ const docPath = join(vaultRoot, ...DESIGN_DOC);
282
+ if (!existsSync(docPath)) {
283
+ return { error: 'DESIGN_DOC_NOT_FOUND', message: 'spex-console/DESIGN.md not found' };
284
+ }
285
+ const content = readFileSync(docPath, 'utf8');
286
+ const lines = content.split(/\r?\n/);
287
+
288
+ // Anchor convention from DESIGN.md: a leading-line marker like
289
+ // **`pattern-name`** (...)
290
+ // or sometimes just **pattern-name** (...).
291
+ const anchorWithBackticks = `**\`${name}\`**`;
292
+ const anchorPlain = `**${name}**`;
293
+
294
+ let startLine = -1;
295
+ for (let i = 0; i < lines.length; i++) {
296
+ const line = lines[i];
297
+ if (line.startsWith(anchorWithBackticks) || line.startsWith(anchorPlain)) {
298
+ startLine = i;
299
+ break;
300
+ }
301
+ }
302
+
303
+ if (startLine === -1) {
304
+ return {
305
+ error: 'ANCHOR_NOT_FOUND',
306
+ message: `No "${anchorWithBackticks}" anchor in spex-console/DESIGN.md`,
307
+ hint: 'Anchors are leading-line markers like `**`pattern-name`**`. Check the registry item designDocAnchor field.',
308
+ };
309
+ }
310
+
311
+ // Excerpt ends at the next anchor / next heading / blank-then-anchor.
312
+ let endLine = lines.length;
313
+ for (let i = startLine + 1; i < lines.length; i++) {
314
+ const line = lines[i];
315
+ if (line.startsWith('## ') || line.startsWith('### ')) {
316
+ endLine = i;
317
+ break;
318
+ }
319
+ // Next `**` anchor at column 0
320
+ if ((line.startsWith('**`') && line.includes('`**')) || /^\*\*[a-z][a-z0-9-]+\*\*/.test(line)) {
321
+ endLine = i;
322
+ break;
323
+ }
324
+ }
325
+
326
+ const excerpt = lines.slice(startLine, endLine).join('\n').replace(/\s+$/g, '');
327
+
328
+ return {
329
+ name,
330
+ anchor: anchorWithBackticks,
331
+ line_start: startLine + 1,
332
+ line_end: endLine,
333
+ excerpt,
334
+ link: `spex-console/DESIGN.md#L${startLine + 1}`,
335
+ };
336
+ }
337
+
338
+ // ─────────────────────────────────────────────────────────────────────────────
339
+ // propose_pattern
340
+ // ─────────────────────────────────────────────────────────────────────────────
341
+
342
+ export function executeProposePattern(vaultRoot, args = {}) {
343
+ const name = String(args.name ?? '').trim();
344
+ if (!name) {
345
+ return { error: 'MISSING_NAME', message: 'name is required (kebab-case slug)' };
346
+ }
347
+ if (!NAME_PATTERN.test(name)) {
348
+ return {
349
+ error: 'INVALID_NAME',
350
+ message: 'name must be kebab-case (lowercase letters, digits, dashes) — e.g. "agent-handoff-callout"',
351
+ };
352
+ }
353
+
354
+ const title = String(args.title ?? '').trim();
355
+ const description = String(args.description ?? '').trim();
356
+ if (!title || !description) {
357
+ return { error: 'MISSING_FIELDS', message: 'title and description are required' };
358
+ }
359
+
360
+ const hsLayer = String(args.hsLayer ?? '').trim();
361
+ if (!VALID_LAYERS.has(hsLayer)) {
362
+ return {
363
+ error: 'INVALID_LAYER',
364
+ message: `hsLayer must be one of: ${[...VALID_LAYERS].join(', ')}`,
365
+ };
366
+ }
367
+
368
+ // Reject collisions with an already-shipped pattern.
369
+ const existingShipped = loadRegistryItem(vaultRoot, name);
370
+ if (existingShipped) {
371
+ return {
372
+ error: 'ALREADY_SHIPPED',
373
+ message: `A registry item already exists at spex-console/public/design-system/r/${name}.json — pick a different slug or update the shipped item directly.`,
374
+ };
375
+ }
376
+
377
+ const proposalsDir = join(vaultRoot, ...PROPOSALS_DIR);
378
+ const filename = `${name}.json`;
379
+ const filepath = join(proposalsDir, filename);
380
+
381
+ if (existsSync(filepath)) {
382
+ return {
383
+ error: 'PROPOSAL_EXISTS',
384
+ message: `Proposal already exists at specs/design-proposals/${filename} — edit the file directly or pick a different slug.`,
385
+ existing_path: relative(vaultRoot, filepath),
386
+ };
387
+ }
388
+
389
+ const now = new Date().toISOString();
390
+ const proposed_by = String(args.proposed_by ?? '').trim() || 'unknown agent';
391
+
392
+ const proposal = {
393
+ $schema: 'https://ui.shadcn.com/schema/registry-item.json',
394
+ name,
395
+ type: 'registry:ui',
396
+ title,
397
+ description,
398
+ hsLayer,
399
+ hsTags: Array.isArray(args.hsTags) ? args.hsTags : [],
400
+ hsDecisionRefs: Array.isArray(args.hsDecisionRefs) ? args.hsDecisionRefs : [],
401
+ designDocAnchor: String(args.designDocAnchor ?? name),
402
+ hsStatus: 'proposed',
403
+ hsProposedAt: now,
404
+ hsProposedBy: proposed_by,
405
+ };
406
+
407
+ if (args.cssVars && typeof args.cssVars === 'object') {
408
+ proposal.cssVars = args.cssVars;
409
+ }
410
+ if (typeof args.hsCodePreview === 'string' && args.hsCodePreview.length > 0) {
411
+ proposal.hsCodePreview = args.hsCodePreview;
412
+ }
413
+ proposal.files = [];
414
+ proposal.dependencies = [];
415
+ proposal.registryDependencies = [];
416
+
417
+ try {
418
+ if (!existsSync(proposalsDir)) {
419
+ mkdirSync(proposalsDir, { recursive: true });
420
+ }
421
+ writeFileSync(filepath, JSON.stringify(proposal, null, 2) + '\n', 'utf8');
422
+ } catch (err) {
423
+ return { error: 'WRITE_FAILED', message: err.message };
424
+ }
425
+
426
+ return {
427
+ success: true,
428
+ proposal_id: name,
429
+ filepath: relative(vaultRoot, filepath),
430
+ next_steps: [
431
+ `Open specs/design-proposals/${filename} and refine the cssVars + hsCodePreview blocks`,
432
+ `Once promoted, copy to spex-console/public/design-system/r/${filename} and add to registry.json items[]`,
433
+ `Add the matching prose anchor (**\`${name}\`**) to spex-console/DESIGN.md`,
434
+ ],
435
+ };
436
+ }
437
+
438
+ // ─────────────────────────────────────────────────────────────────────────────
439
+ // Discovery helper for slash prompts
440
+ // ─────────────────────────────────────────────────────────────────────────────
441
+
442
+ export function listProposals(vaultRoot) {
443
+ const dir = join(vaultRoot, ...PROPOSALS_DIR);
444
+ if (!existsSync(dir)) return [];
445
+ return readdirSync(dir)
446
+ .filter((f) => f.endsWith('.json'))
447
+ .map((f) => f.replace(/\.json$/, ''))
448
+ .sort();
449
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * dev-factory-bridge.mjs — the ONE lazy seam between the shippable MCP server and
3
+ * the SpexFleet / deep-research code that stays inside this repo.
4
+ *
5
+ * Why this file exists ([[HS-ZJSP-0JNK-TASK-ship-the-heyspex-mcp-server-as-an]]):
6
+ * `research-tools.mjs` used to `import` two modules from
7
+ * `../../dev-factory/dream-research/` at the top level. Those two static imports
8
+ * dragged **25 files** into the MCP server's static import closure —
9
+ * `dev-factory/router.mjs` (model routing), `dev-factory/cloud-clients.mjs`
10
+ * (provider clients + env loading), `dev-factory/cost-tracker.mjs`, and the whole
11
+ * `dev-factory/dream-research/` engine. Any npm package that ships a working MCP
12
+ * server therefore had to ship all of them, and `npm publish` is a one-way door.
13
+ *
14
+ * Loading them through `await import()` instead means:
15
+ * - inside this repo, behavior is identical (the modules are right there);
16
+ * - inside a published install they are simply absent, and the two research
17
+ * tools degrade to an explicit `unavailable` result instead of crashing the
18
+ * server at startup;
19
+ * - the published file list is decided by the open-core boundary
20
+ * ([[HS-8DET-0JNK-TASK-confirm-the-open-core-boundary-and]]), not by an
21
+ * accident of module graph shape.
22
+ *
23
+ * The resolution is memoized per process: one failed probe is enough, and a
24
+ * successful one is not re-paid on every tool call.
25
+ */
26
+
27
+ /** @type {Promise<{ available: boolean, reason?: string, getBaseUrl?: Function, runFirecrawlResearchTool?: Function }>|undefined} */
28
+ let dreamResearchPromise;
29
+
30
+ /**
31
+ * Lazily resolve the deep-research surface the MCP research tools delegate to.
32
+ *
33
+ * Never throws. When the modules are not present (a published install, which
34
+ * ships the MCP server without SpexFleet), returns
35
+ * `{ available: false, reason }` so callers can report an honest downgrade
36
+ * rather than a stack trace.
37
+ */
38
+ export function loadDreamResearch() {
39
+ if (dreamResearchPromise) return dreamResearchPromise;
40
+ dreamResearchPromise = (async () => {
41
+ try {
42
+ const [adapter, tool] = await Promise.all([
43
+ import('../../dev-factory/dream-research/firecrawl-adapter.mjs'),
44
+ import('../../dev-factory/dream-research/interactive-firecrawl-tool.mjs'),
45
+ ]);
46
+ return {
47
+ available: true,
48
+ getBaseUrl: adapter.getBaseUrl,
49
+ runFirecrawlResearchTool: tool.runFirecrawlResearchTool,
50
+ };
51
+ } catch (err) {
52
+ return {
53
+ available: false,
54
+ reason:
55
+ 'the deep-research backend is not part of this install ' +
56
+ `(${err?.code ?? err?.message ?? String(err)})`,
57
+ };
58
+ }
59
+ })();
60
+ return dreamResearchPromise;
61
+ }
62
+
63
+ /** Test-only: forget the memoized probe so a test can exercise both branches. */
64
+ export function resetDevFactoryBridgeCache() {
65
+ dreamResearchPromise = undefined;
66
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The ONE canonical diagnose body skeleton.
3
+ *
4
+ * Three writers inject a fill-in diagnose body — capture promotion
5
+ * (`promote-capture.mjs`), research promotion
6
+ * (`promote-research-to-diagnose.mjs`), and the deferred-work DLQ
7
+ * (`deferred-reclaim.mjs`). Until [[HS-I2SE-5UGW-DIAG-the-diagnose-skeleton-has-no-place-to]]
8
+ * each carried its own copy, so any change had to be made three times and the
9
+ * drift was invisible until someone diffed two filed diagnoses. The shared
10
+ * block below is the single definition; `diagnose-skeleton-drift.test.mjs`
11
+ * fails if any writer stops emitting it verbatim.
12
+ *
13
+ * Why these prompts and not just headings
14
+ * ([[resolution-diagnose-workflow]] §Step 2 / §Step 6):
15
+ *
16
+ * - **Root cause** asks the level check. An author who writes the first true
17
+ * statement they find has satisfied an open prompt and stopped one level
18
+ * above the cause. "If I fix exactly this, can the same class of failure
19
+ * still happen?" is the question that catches that.
20
+ * - **Red herrings** gives the investigation's dead ends somewhere to live.
21
+ * The fixer is often a sub-agent or a fleet builder who sees only this file;
22
+ * whatever was ruled out gets re-litigated, and re-litigated wrong, because
23
+ * the wrong fixes are the ones that look right.
24
+ * - **Sweep** is §Step 6's receipt. A skipped sweep and a clean sweep look
25
+ * identical afterwards unless one of them writes something down.
26
+ * - **Acceptance criteria** asks for the reversal check. "The test passes now"
27
+ * is also what a fix that merely moves the symptom produces; a check that
28
+ * FAILS when the fix is reverted is a different claim.
29
+ *
30
+ * PROMPTED, NEVER ENFORCED. Nothing validates that these sections are filled
31
+ * in, and nothing should. A one-line typo bug that files with an empty Red
32
+ * herrings and a one-clause Sweep is the intended outcome — the point is that
33
+ * the author is asked, not that the answer is long. Making them required would
34
+ * buy ceremonial empty tables and train everyone to type filler.
35
+ */
36
+
37
+ /** The level-check prompt for `## Root cause`. */
38
+ export const DIAGNOSE_ROOT_CAUSE_PROMPT =
39
+ '_TBD — the mechanism, not the observation. Before writing this, ask: if I fix exactly this, can the same class of failure still happen? If yes, this is still a symptom — go one level down._';
40
+
41
+ /** The prompt for `## Red herrings`. */
42
+ export const DIAGNOSE_RED_HERRINGS_PROMPT =
43
+ '_The tempting fixes that are wrong, and why each one is wrong. Anything you ruled out during investigation belongs here — the fixer cannot see your reasoning, only this file. A two-column table (tempting fix ❘ why it is wrong) reads well. Leave it empty for a trivial bug; it is prompted, not required._';
44
+
45
+ /** The prompt for `## Fix`. */
46
+ export const DIAGNOSE_FIX_PROMPT = '_TBD — the change that resolves it._';
47
+
48
+ /** The sibling-sweep receipt prompt for `## Sweep` ([[resolution-diagnose-workflow]] §Step 6). */
49
+ export const DIAGNOSE_SWEEP_PROMPT =
50
+ '_What you searched for other occurrences of this same bug, what you found, and the diagnose ids filed for each sibling. "Searched the other four tests in this file, none share the shape" is a fine receipt — a claim someone else can re-run. Silence is not._';
51
+
52
+ /** The reversal-check prompt for `## Acceptance criteria`. */
53
+ export const DIAGNOSE_ACCEPTANCE_PROMPT =
54
+ '_TBD — how we know it is fixed. At least one criterion must be a check that FAILS when the fix is reverted, named so someone else can run it; "the test passes now" is also what a symptom-level fix produces. Freeze it as a checks manifest (`checks:` on heyspex_file_spec, [[checks-manifest-authoring]]) when the bug is worth a regression guard._';
55
+
56
+ /**
57
+ * Every `##` heading a fully-injected diagnose skeleton carries, in order.
58
+ * `Symptom` / `Reproduction` / `Change log` are writer-seeded (each intake path
59
+ * has its own source material); the five in between are the shared block.
60
+ */
61
+ export const DIAGNOSE_SECTION_ORDER = Object.freeze([
62
+ 'Symptom',
63
+ 'Reproduction',
64
+ 'Root cause',
65
+ 'Red herrings',
66
+ 'Fix',
67
+ 'Sweep',
68
+ 'Acceptance criteria',
69
+ 'Change log',
70
+ ]);
71
+
72
+ /** The shared block's headings, in order — what the drift test compares. */
73
+ export const DIAGNOSE_SHARED_SECTIONS = Object.freeze([
74
+ 'Root cause',
75
+ 'Red herrings',
76
+ 'Fix',
77
+ 'Sweep',
78
+ 'Acceptance criteria',
79
+ ]);
80
+
81
+ /**
82
+ * The shared `## Root cause` → `## Acceptance criteria` block, verbatim.
83
+ *
84
+ * `notes` lets an intake path add a path-specific hint under a section without
85
+ * forking the prompt — the DLQ writer knows the likely cause of a reclaimed
86
+ * deferred task, and that hint is worth keeping. Keys are section names; the
87
+ * value is appended as its own line after the canonical prompt.
88
+ *
89
+ * Returns text with no leading and no trailing newline; callers join it into
90
+ * their own body with blank lines on either side.
91
+ */
92
+ export function buildDiagnoseSkeletonSections(notes = {}) {
93
+ const prompts = {
94
+ 'Root cause': DIAGNOSE_ROOT_CAUSE_PROMPT,
95
+ 'Red herrings': DIAGNOSE_RED_HERRINGS_PROMPT,
96
+ Fix: DIAGNOSE_FIX_PROMPT,
97
+ Sweep: DIAGNOSE_SWEEP_PROMPT,
98
+ 'Acceptance criteria': DIAGNOSE_ACCEPTANCE_PROMPT,
99
+ };
100
+ return DIAGNOSE_SHARED_SECTIONS.map((section) => {
101
+ const note = typeof notes[section] === 'string' && notes[section].trim() ? notes[section].trim() : '';
102
+ const bodyLines = note ? `${prompts[section]}\n\n${note}` : prompts[section];
103
+ return `## ${section}\n\n${bodyLines}`;
104
+ }).join('\n\n');
105
+ }
106
+
107
+ /** The `##` headings of a markdown body, in document order. */
108
+ export function diagnoseSectionHeadings(body) {
109
+ const out = [];
110
+ const re = /^##\s+(.+?)\s*$/gm;
111
+ let m;
112
+ while ((m = re.exec(String(body ?? ''))) !== null) out.push(m[1]);
113
+ return out;
114
+ }