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,421 @@
1
+ // gateway-skills-lib.mjs — shared core for user-authored gateway skills
2
+ // (HS-WEC3, design: specs/research/HS-P861-G9JR-RES-user-customizable-mcp-servers-and.md).
3
+ //
4
+ // One core module, three faces (capability-three-faces rule):
5
+ // - `hs gateway validate` (scripts/hs-gateway.mjs)
6
+ // - heyspex_validate_user_skills (vendor/heyspex-mcp/cli.mjs)
7
+ // - hs doctor --check-gateway (scripts/heyspex-doctor.mjs)
8
+ //
9
+ // Invocation-guardrail invariants this module owns:
10
+ // - Scope resolution is a deterministic exact-tag set operation. Never a
11
+ // semantic / similarity / top-K match: a wrong "no match" is safe (tool
12
+ // stays hidden, user can override); a wrong "close enough" silently
13
+ // exposes something out of scope.
14
+ // - Malformed scope declarations FAIL CLOSED: a skill whose tool_scope /
15
+ // requires_servers cannot be parsed is invalid and inert — it never falls
16
+ // back to "all tools". A skill with NO tool_scope key at all falls back to
17
+ // the documented HS-A4BY default (no declared scope = all tools available)
18
+ // and is flagged as such, not invented differently here.
19
+ // - This module never rewrites user content. Validation is read-only;
20
+ // callers (doctor included) report and suggest, they do not "fix" a
21
+ // user's declarations by guessing intent.
22
+ //
23
+ // Zero runtime dependencies on purpose: the doctor is the fallback tool that
24
+ // must run on a fresh clone before `npm install` has succeeded, so this file
25
+ // parses the small documented frontmatter subset itself instead of borrowing
26
+ // js-yaml from the spex-console workspace.
27
+
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+
31
+ /** Path of the user-skill root, relative to the vault root. */
32
+ export const USER_SKILLS_REL = path.join('.heyspex', 'skills', 'user');
33
+
34
+ /** Frontmatter fields the Skill File Format defines (research doc §Skill File Format). */
35
+ export const SKILL_KNOWN_FIELDS = new Set([
36
+ 'name',
37
+ 'description',
38
+ 'tool_scope',
39
+ 'origin',
40
+ 'version',
41
+ 'requires_servers',
42
+ ]);
43
+
44
+ export const SKILL_REQUIRED_FIELDS = ['name', 'description'];
45
+ export const SKILL_ORIGINS = new Set(['user', 'heyspex']);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Strict frontmatter parsing (documented subset only)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Split a SKILL.md into its frontmatter block and body. Returns null when no block. */
52
+ export function extractFrontmatterBlock(raw) {
53
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
54
+ if (!match) return null;
55
+ return { yamlBlock: match[1], body: raw.slice(match[0].length) };
56
+ }
57
+
58
+ function stripInlineComment(value) {
59
+ // Only for unquoted values: everything from the first ` #` is a comment.
60
+ const idx = value.search(/\s#/);
61
+ return idx === -1 ? value : value.slice(0, idx);
62
+ }
63
+
64
+ function parseScalar(rawValue, errors, lineNo) {
65
+ let v = rawValue.trim();
66
+ if (v.startsWith('"') || v.startsWith("'")) {
67
+ const quote = v[0];
68
+ const end = v.indexOf(quote, 1);
69
+ if (end === -1) {
70
+ errors.push({ code: 'yaml_unterminated_quote', message: `line ${lineNo}: unterminated ${quote}quoted${quote} string` });
71
+ return null;
72
+ }
73
+ return v.slice(1, end);
74
+ }
75
+ v = stripInlineComment(v).trim();
76
+ if (v === '') return '';
77
+ if (v === 'true') return true;
78
+ if (v === 'false') return false;
79
+ if (/^-?\d+$/.test(v)) return Number.parseInt(v, 10);
80
+ if (/^-?\d+\.\d+$/.test(v)) return Number.parseFloat(v);
81
+ return v;
82
+ }
83
+
84
+ function parseInlineArray(inner, errors, lineNo) {
85
+ const trimmed = inner.trim();
86
+ if (trimmed === '') return [];
87
+ return trimmed.split(',').map((part) => parseScalar(part, errors, lineNo));
88
+ }
89
+
90
+ /**
91
+ * Parse the strict frontmatter subset the Skill File Format documents:
92
+ * top-level `key: scalar`, `key: [a, b]`, `key:` + `- item` lists, and
93
+ * `|` / `>` block scalars. Anything else is an error — deliberately, so a
94
+ * malformed declaration surfaces instead of silently parsing to something
95
+ * broader than the user wrote (fail closed).
96
+ */
97
+ export function parseSkillFrontmatter(yamlBlock) {
98
+ const data = {};
99
+ const errors = [];
100
+ const lines = yamlBlock.split(/\r?\n/);
101
+ let pendingListKey = null;
102
+
103
+ for (let i = 0; i < lines.length; i += 1) {
104
+ const line = lines[i];
105
+ const lineNo = i + 1;
106
+ if (!line.trim() || line.trim().startsWith('#')) continue;
107
+
108
+ const listItem = /^\s+-\s*(.*)$/.exec(line) ?? /^-\s+(.*)$/.exec(line);
109
+ if (listItem) {
110
+ if (!pendingListKey) {
111
+ errors.push({ code: 'yaml_stray_list_item', message: `line ${lineNo}: list item without a preceding key` });
112
+ continue;
113
+ }
114
+ if (data[pendingListKey] === null) data[pendingListKey] = [];
115
+ data[pendingListKey].push(parseScalar(listItem[1], errors, lineNo));
116
+ continue;
117
+ }
118
+
119
+ if (/^\s/.test(line)) {
120
+ errors.push({ code: 'yaml_nested_unsupported', message: `line ${lineNo}: nested mappings are not part of the skill frontmatter format` });
121
+ pendingListKey = null;
122
+ continue;
123
+ }
124
+
125
+ const kv = /^([A-Za-z0-9_][A-Za-z0-9_-]*):(.*)$/.exec(line);
126
+ if (!kv) {
127
+ errors.push({ code: 'yaml_unparseable_line', message: `line ${lineNo}: cannot parse "${line.trim()}"` });
128
+ pendingListKey = null;
129
+ continue;
130
+ }
131
+
132
+ const key = kv[1];
133
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
134
+ errors.push({ code: 'yaml_duplicate_key', message: `line ${lineNo}: duplicate key "${key}"` });
135
+ }
136
+ const rest = kv[2].trim();
137
+ pendingListKey = null;
138
+
139
+ if (rest === '' ) {
140
+ data[key] = null; // either an empty value or a dash-list follows
141
+ pendingListKey = key;
142
+ continue;
143
+ }
144
+ if (rest === '|' || rest === '>') {
145
+ const collected = [];
146
+ while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) {
147
+ collected.push(lines[i + 1].trim());
148
+ i += 1;
149
+ }
150
+ while (collected.length && collected[collected.length - 1] === '') collected.pop();
151
+ data[key] = collected.join(rest === '|' ? '\n' : ' ');
152
+ continue;
153
+ }
154
+ const inline = /^\[(.*)\]$/.exec(stripInlineComment(rest).trim());
155
+ if (inline) {
156
+ data[key] = parseInlineArray(inline[1], errors, lineNo);
157
+ continue;
158
+ }
159
+ data[key] = parseScalar(rest, errors, lineNo);
160
+ }
161
+
162
+ return { data, errors };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Validation (read-only; never mutates user content)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function isNonEmptyString(v) {
170
+ return typeof v === 'string' && v.trim().length > 0;
171
+ }
172
+
173
+ function validateTagArray(value, field, errors, warnings) {
174
+ if (!Array.isArray(value)) {
175
+ errors.push({
176
+ code: `skill_${field}_malformed`,
177
+ field,
178
+ message: `${field} must be a list of tag strings (e.g. ${field}: ["internal-crm"]) — the skill stays inert until this parses`,
179
+ });
180
+ return [];
181
+ }
182
+ const tags = [];
183
+ for (const entry of value) {
184
+ if (!isNonEmptyString(entry)) {
185
+ errors.push({
186
+ code: `skill_${field}_malformed`,
187
+ field,
188
+ message: `${field} contains a non-string or empty entry — the skill stays inert until this parses`,
189
+ });
190
+ return [];
191
+ }
192
+ const tag = entry.trim();
193
+ if (tag !== entry || /[A-Z\s]/.test(tag)) {
194
+ warnings.push({
195
+ code: `skill_${field}_tag_style`,
196
+ field,
197
+ message: `${field} tag "${entry}" has whitespace or uppercase — scope matching is exact, so this only matches a server registered with the identical tag`,
198
+ });
199
+ }
200
+ if (tags.includes(tag)) {
201
+ warnings.push({ code: `skill_${field}_duplicate_tag`, field, message: `${field} lists "${tag}" more than once` });
202
+ continue;
203
+ }
204
+ tags.push(tag);
205
+ }
206
+ return tags;
207
+ }
208
+
209
+ /**
210
+ * Validate one skill document. Pure: takes raw file text, returns a report.
211
+ * errors → the skill is invalid and must be treated as inert (fail closed).
212
+ * warnings → advisory only; the skill still works.
213
+ */
214
+ export function validateSkillDocument(raw, { slug = null, relPath = null } = {}) {
215
+ const errors = [];
216
+ const warnings = [];
217
+ let frontmatter = {};
218
+ let scope = { declared: false, tool_scope: [], requires_servers: [] };
219
+
220
+ const block = extractFrontmatterBlock(raw);
221
+ if (!block) {
222
+ errors.push({ code: 'skill_missing_frontmatter', message: 'no frontmatter block (--- ... ---) at the top of the file' });
223
+ } else {
224
+ const parsed = parseSkillFrontmatter(block.yamlBlock);
225
+ frontmatter = parsed.data;
226
+ errors.push(...parsed.errors);
227
+
228
+ for (const field of SKILL_REQUIRED_FIELDS) {
229
+ if (!isNonEmptyString(frontmatter[field])) {
230
+ errors.push({ code: `skill_${field}_missing`, field, message: `required field "${field}" is missing or empty` });
231
+ }
232
+ }
233
+
234
+ if (slug && isNonEmptyString(frontmatter.name) && frontmatter.name.trim() !== slug) {
235
+ warnings.push({
236
+ code: 'skill_name_slug_mismatch',
237
+ field: 'name',
238
+ message: `name "${frontmatter.name.trim()}" does not match the directory slug "${slug}"`,
239
+ });
240
+ }
241
+
242
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'tool_scope')) {
243
+ if (frontmatter.tool_scope === null) {
244
+ errors.push({ code: 'skill_tool_scope_malformed', field: 'tool_scope', message: 'tool_scope has no value — the skill stays inert until this parses' });
245
+ } else {
246
+ scope = { ...scope, declared: true, tool_scope: validateTagArray(frontmatter.tool_scope, 'tool_scope', errors, warnings) };
247
+ }
248
+ } else {
249
+ // Documented HS-A4BY default: no declared scope means all tools
250
+ // available. Flagged so callers can surface it — but NOT an error, and
251
+ // NOT a different default invented for user content.
252
+ warnings.push({
253
+ code: 'skill_no_scope_declared',
254
+ field: 'tool_scope',
255
+ message: 'no tool_scope declared — per the spec-scoped-tool-access default this skill does not narrow the tool surface',
256
+ });
257
+ }
258
+
259
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'requires_servers') && frontmatter.requires_servers !== null) {
260
+ scope = { ...scope, requires_servers: validateTagArray(frontmatter.requires_servers, 'requires_servers', errors, warnings) };
261
+ }
262
+
263
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'origin')) {
264
+ if (!SKILL_ORIGINS.has(frontmatter.origin)) {
265
+ errors.push({ code: 'skill_origin_invalid', field: 'origin', message: `origin must be "user" or "heyspex", got "${frontmatter.origin}"` });
266
+ } else if (frontmatter.origin === 'heyspex') {
267
+ warnings.push({ code: 'skill_origin_heyspex_in_user_dir', field: 'origin', message: 'origin is "heyspex" but the file lives under skills/user/ — if you authored this skill, set origin: user' });
268
+ }
269
+ } else {
270
+ warnings.push({ code: 'skill_origin_missing', field: 'origin', message: 'add origin: user so the doctor can tell user skills from shipped ones' });
271
+ }
272
+
273
+ if (Object.prototype.hasOwnProperty.call(frontmatter, 'version')) {
274
+ if (!Number.isInteger(frontmatter.version) || frontmatter.version < 1) {
275
+ warnings.push({ code: 'skill_version_malformed', field: 'version', message: 'version should be a positive integer (bump it on intentional edits)' });
276
+ }
277
+ } else {
278
+ warnings.push({ code: 'skill_version_missing', field: 'version', message: 'add version: 1 — it lets the doctor tell intentional edits from drift' });
279
+ }
280
+
281
+ for (const key of Object.keys(frontmatter)) {
282
+ if (!SKILL_KNOWN_FIELDS.has(key)) {
283
+ warnings.push({ code: 'skill_unknown_field', field: key, message: `unknown frontmatter field "${key}" (known: ${[...SKILL_KNOWN_FIELDS].join(', ')})` });
284
+ }
285
+ }
286
+
287
+ if (!block.body.trim()) {
288
+ warnings.push({ code: 'skill_body_empty', message: 'skill body is empty — the intent classifier has nothing to match against' });
289
+ }
290
+ }
291
+
292
+ return {
293
+ ok: errors.length === 0,
294
+ slug,
295
+ relPath,
296
+ frontmatter,
297
+ scope,
298
+ errors,
299
+ warnings,
300
+ };
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Discovery + aggregate validation
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /** Files allowed at the top of skills/user/ without being flagged as misplaced. */
308
+ const TOP_LEVEL_ALLOWED = new Set(['README.md', '.gitkeep']);
309
+
310
+ export function discoverUserSkills(vaultRoot) {
311
+ const root = path.join(vaultRoot, USER_SKILLS_REL);
312
+ if (!fs.existsSync(root)) return { root, exists: false, entries: [], misplaced: [] };
313
+
314
+ const entries = [];
315
+ const misplaced = [];
316
+ for (const dirent of fs.readdirSync(root, { withFileTypes: true })) {
317
+ if (dirent.isDirectory()) {
318
+ const skillPath = path.join(root, dirent.name, 'SKILL.md');
319
+ if (fs.existsSync(skillPath)) {
320
+ entries.push({ slug: dirent.name, absPath: skillPath, relPath: path.join(USER_SKILLS_REL, dirent.name, 'SKILL.md') });
321
+ } else {
322
+ misplaced.push({ name: dirent.name, reason: `directory has no SKILL.md — expected ${path.join(USER_SKILLS_REL, dirent.name, 'SKILL.md')}` });
323
+ }
324
+ } else if (!TOP_LEVEL_ALLOWED.has(dirent.name)) {
325
+ misplaced.push({ name: dirent.name, reason: `loose file — skills live at ${path.join(USER_SKILLS_REL, '<slug>', 'SKILL.md')}` });
326
+ }
327
+ }
328
+ return { root, exists: true, entries, misplaced };
329
+ }
330
+
331
+ /** Validate every user skill under `<vaultRoot>/.heyspex/skills/user/`. Read-only. */
332
+ export function validateUserSkills(vaultRoot) {
333
+ const discovery = discoverUserSkills(vaultRoot);
334
+ const skills = discovery.entries.map((entry) => {
335
+ let raw;
336
+ try {
337
+ raw = fs.readFileSync(entry.absPath, 'utf8');
338
+ } catch (err) {
339
+ return {
340
+ ok: false,
341
+ slug: entry.slug,
342
+ relPath: entry.relPath,
343
+ frontmatter: {},
344
+ scope: { declared: false, tool_scope: [], requires_servers: [] },
345
+ errors: [{ code: 'skill_unreadable', message: `cannot read file: ${err.message}` }],
346
+ warnings: [],
347
+ };
348
+ }
349
+ return validateSkillDocument(raw, { slug: entry.slug, relPath: entry.relPath });
350
+ });
351
+
352
+ return {
353
+ root: discovery.root,
354
+ exists: discovery.exists,
355
+ misplaced: discovery.misplaced,
356
+ skills,
357
+ counts: {
358
+ total: skills.length,
359
+ valid: skills.filter((s) => s.ok).length,
360
+ invalid: skills.filter((s) => !s.ok).length,
361
+ with_warnings: skills.filter((s) => s.warnings.length > 0).length,
362
+ },
363
+ };
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Server registry (HS-DCCP) + orphan resolution
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * The gateway server registry is HS-DCCP-EPIC's to build (a Neon table per
372
+ * vault: { server_id, url, auth_type, encrypted_credentials, scope_tags },
373
+ * plus the `origin` column this epic adds). Until that lands there is nothing
374
+ * to read, so this returns available: false and callers must say so plainly
375
+ * rather than pretending an empty registry was probed.
376
+ */
377
+ export function getServerRegistry() {
378
+ return {
379
+ available: false,
380
+ reason: 'server registry not yet built (HS-DCCP-EPIC-thin-project-scoped-mcp-connector is open) — server registration and the live connectivity probe land with it',
381
+ servers: [],
382
+ scopeTags: [],
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Which valid skills declare a scope no registered server provides?
388
+ * Deterministic exact-tag set difference — by design never a similarity
389
+ * match. Orphaned skills are inert until fixed (fail closed), so this is a
390
+ * warning-grade signal: report + suggest, never rewrite the declaration.
391
+ */
392
+ export function computeOrphanReport(aggregate, registry = getServerRegistry()) {
393
+ const provided = new Set([
394
+ ...registry.scopeTags,
395
+ ...registry.servers.map((s) => s.server_id).filter(Boolean),
396
+ ]);
397
+
398
+ const orphans = [];
399
+ for (const skill of aggregate.skills) {
400
+ if (!skill.ok) continue; // invalid skills are already inert via gateway-skills
401
+ const declared = [...new Set([...skill.scope.tool_scope, ...skill.scope.requires_servers])];
402
+ if (declared.length === 0) continue;
403
+ if (!registry.available) {
404
+ orphans.push({ slug: skill.slug, relPath: skill.relPath, declared, missing: declared, reason: 'registry_unavailable' });
405
+ continue;
406
+ }
407
+ const missing = declared.filter((tag) => !provided.has(tag));
408
+ if (missing.length > 0) {
409
+ orphans.push({ slug: skill.slug, relPath: skill.relPath, declared, missing, reason: 'unmatched_tags' });
410
+ }
411
+ }
412
+ return { registry_available: registry.available, registry_reason: registry.available ? null : registry.reason, orphans };
413
+ }
414
+
415
+ /** One-call report used by all three faces. */
416
+ export function buildGatewayReport(vaultRoot) {
417
+ const skills = validateUserSkills(vaultRoot);
418
+ const registry = getServerRegistry(vaultRoot);
419
+ const orphanReport = computeOrphanReport(skills, registry);
420
+ return { skills, registry: { available: registry.available, reason: registry.available ? null : registry.reason }, orphans: orphanReport };
421
+ }
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * console-url-resolver.mjs — the ONE shared console-URL resolver
4
+ * ([[HS-GD45-MCPC-TASK-mcp-cloud-auth-shared-console-resolver]] of
5
+ * [[HS-UP6L-DZFX-RES-cloud-first-dogfooding-migration-what-s]]).
6
+ *
7
+ * Before this file, the SAME localhost-fallback chain was hand-duplicated in
8
+ * five places (`vendor/heyspex-mcp/cli.mjs`, `scripts/claude-hooks/
9
+ * claim-liveness-shared.mjs`, `scripts/claude-hooks/session-end.mjs`,
10
+ * `dev-factory/lib/breakdown-to-filing.mjs`, `dev-factory/lib/
11
+ * fleet-queue-neon.mjs`) — a config change (e.g. "point at the cloud
12
+ * console") had to be applied five times, and [[HS-G59F-DIAG-OQ34-console-url-silently-defaults-to]]
13
+ * documented the drift that causes. This module is the ONE implementation;
14
+ * all five now import/delegate to it.
15
+ *
16
+ * Precedence (cloud-first — an explicit override always wins over ambient
17
+ * discovery, and a paired cloud console outranks a stale local dev-server
18
+ * discovery file):
19
+ * 1. `HEYSPEX_CONSOLE_URL` env (legacy-cockpit: `HEYSPEX_COCKPIT_URL`)
20
+ * 2. `console_url:` in `~/.heyspex/credentials.yaml` — written by
21
+ * `hs mcp pair <code>` when a machine pairs with a deployed console,
22
+ * and by `hs dev local` (owner-only) when the owner's OWN machine
23
+ * overrides to `http://localhost:3000` — that write is rung 2, so it
24
+ * wins over the rung-4 default below without needing rung 1
25
+ * 3. `.heyspex/console-url` discovery file in the repo (legacy-cockpit `cockpit-url`)
26
+ * — written by the local dev-server launcher
27
+ * (`spex-console/scripts/ensure-next-dev.mjs`), ephemeral per session
28
+ * 4. `https://app.heyspex.com` (the cloud console — [[HS-3UZS-EKLJ-DIAG-a-brand-new-team-member-s-one-command]]
29
+ * Gap 1: a fresh clone with none of the above set has no way to
30
+ * discover the production console, and used to silently guess
31
+ * localhost instead, which for anyone but the owner is unreachable and
32
+ * degrades every registration to registry.json-only. The owner's own
33
+ * daily local tooling is unaffected — rung 2 above always wins for them.)
34
+ *
35
+ * Plain Node ESM, zero deps beyond `node:fs`/`node:os`/`node:path` — every
36
+ * consumer (vendor MCP server, Claude-hook scripts, dev-factory dispatchers)
37
+ * can import this directly with no build step and no risk of pulling in a
38
+ * dependency that isn't installed in their execution context.
39
+ */
40
+ import { readFileSync } from 'node:fs';
41
+ import { homedir } from 'node:os';
42
+ import { join } from 'node:path';
43
+
44
+ /**
45
+ * The rung-4 fallback when nothing else resolves — the cloud console, so a
46
+ * fresh clone reaches production instead of guessing localhost. Literal
47
+ * mirror of `CLOUD_CONSOLE_URL` (`spex-console/src/lib/dev-mode.ts`): this
48
+ * file stays dependency-free plain Node (see module doc), so it cannot
49
+ * import that TS module — keep the two literals in sync by hand.
50
+ */
51
+ export const DEFAULT_CONSOLE_URL = 'https://app.heyspex.com';
52
+
53
+ function trimTrailingSlash(url) {
54
+ return url.replace(/\/+$/, '');
55
+ }
56
+
57
+ /**
58
+ * Read `console_url:` out of `~/.heyspex/credentials.yaml` with a targeted
59
+ * regex rather than a full YAML parse — mirrors the same
60
+ * no-hard-yaml-dependency extraction `dev-factory/fleet/manager.mjs`
61
+ * already uses for `companion_token`, so every one of this resolver's five
62
+ * consumers (some of which run standalone, outside the npm workspace's
63
+ * hoisted `node_modules`) can call it with zero new dependencies.
64
+ */
65
+ export function readCredentialsConsoleUrl({ homeDir = homedir() } = {}) {
66
+ try {
67
+ const raw = readFileSync(join(homeDir, '.heyspex', 'credentials.yaml'), 'utf8');
68
+ const match = raw.match(/^console_url:\s*["']?([^"'\r\n#]+)/m);
69
+ const value = match?.[1]?.trim();
70
+ return value && value.length > 0 ? trimTrailingSlash(value) : null;
71
+ } catch {
72
+ // Missing file, missing key, or unreadable — no credentials-file override.
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /** Read the repo's `.heyspex/console-url` (or legacy-cockpit `cockpit-url`) discovery file. */
78
+ export function readDiscoveryFileConsoleUrl(repoRoot) {
79
+ if (!repoRoot) return null;
80
+ for (const name of ['console-url', 'cockpit-url' /* legacy-cockpit */]) {
81
+ // legacy-cockpit: pre-rename launchers only wrote cockpit-url — keep resolving it.
82
+ try {
83
+ const discovery = readFileSync(join(repoRoot, '.heyspex', name), 'utf8').trim();
84
+ if (discovery.length > 0) return trimTrailingSlash(discovery);
85
+ } catch {
86
+ // Missing or unreadable — try the next candidate.
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Thrown by {@link resolveConsoleUrl} in `strict` mode when nothing in the
94
+ * precedence chain (env → credentials file → discovery file) resolves —
95
+ * i.e. the caller is about to guess `DEFAULT_CONSOLE_URL` instead of knowing
96
+ * the real console URL ([[HS-OQ34-G59F-DIAG-console-url-silently-defaults-to]]:
97
+ * "not knowing the port produces a guess, not an error").
98
+ */
99
+ export class ConsoleUrlUnresolvedError extends Error {
100
+ constructor() {
101
+ super(
102
+ 'resolveConsoleUrl: no HEYSPEX_CONSOLE_URL/HEYSPEX_COCKPIT_URL env, no console_url: in ' + // legacy-cockpit env name
103
+ '~/.heyspex/credentials.yaml, and no .heyspex/console-url discovery file — refusing to ' +
104
+ `silently fall back to the ${DEFAULT_CONSOLE_URL} default in strict mode.`,
105
+ );
106
+ this.name = 'ConsoleUrlUnresolvedError';
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve the console base URL (no trailing slash) per the precedence above.
112
+ * Every input is injectable so callers/tests can run hermetically:
113
+ * - `repoRoot` — needed only for step 3 (discovery file); omit if the
114
+ * caller has no vault root to check (env/credentials-only resolution).
115
+ * - `env` — defaults to `process.env`.
116
+ * - `homeDir` — defaults to `os.homedir()`.
117
+ * - `strict` — when `true`, throw {@link ConsoleUrlUnresolvedError} instead
118
+ * of returning `DEFAULT_CONSOLE_URL` when nothing resolves. Opt-in only —
119
+ * default `false` preserves every existing caller's fail-open behavior.
120
+ *
121
+ * Non-strict (default): never throws — every read is wrapped and falls
122
+ * through to the next step, so a caller in a fail-open context (a
123
+ * Claude-hook script) stays fail-open by construction. Strict callers should
124
+ * catch {@link ConsoleUrlUnresolvedError} and surface/log the failure loudly
125
+ * instead of posting to a guessed URL.
126
+ *
127
+ * @param {object} [options]
128
+ * @param {string} [options.repoRoot] - needed only for step 3 (discovery file); omit if the
129
+ * caller has no vault root to check (env/credentials-only resolution).
130
+ * @param {NodeJS.ProcessEnv} [options.env] - defaults to `process.env`.
131
+ * @param {string} [options.homeDir] - defaults to `os.homedir()`.
132
+ * @param {boolean} [options.strict] - throw {@link ConsoleUrlUnresolvedError} instead of
133
+ * returning `DEFAULT_CONSOLE_URL` when nothing resolves. Default `false`.
134
+ */
135
+ export function resolveConsoleUrl({
136
+ repoRoot,
137
+ env = process.env,
138
+ homeDir = homedir(),
139
+ strict = false,
140
+ } = {}) {
141
+ const envUrl = (env.HEYSPEX_CONSOLE_URL ?? env.HEYSPEX_COCKPIT_URL)?.trim(); // legacy-cockpit env
142
+ if (envUrl) return trimTrailingSlash(envUrl);
143
+
144
+ const credentialsUrl = readCredentialsConsoleUrl({ homeDir });
145
+ if (credentialsUrl) return credentialsUrl;
146
+
147
+ const discoveryUrl = readDiscoveryFileConsoleUrl(repoRoot);
148
+ if (discoveryUrl) return discoveryUrl;
149
+
150
+ if (strict) throw new ConsoleUrlUnresolvedError();
151
+ return DEFAULT_CONSOLE_URL;
152
+ }