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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
}
|