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,280 @@
1
+ /**
2
+ * Shared frontmatter + transactional-write helpers for the HeySpex promotion
3
+ * tools. Extracted from `promote-research.mjs` ([[HS-TASK-QQHP]]) so that
4
+ * `promote-capture.mjs` ([[HS-TASK-WQCE]]) and any future promotion tool can
5
+ * reuse the same parsing, mutation, and commit/rollback machinery — the
6
+ * bidirectional invariants from [[HS-TASK-YJX6]] live in one place.
7
+ *
8
+ * All helpers operate on the frontmatter *block* (the inner YAML body between
9
+ * `---` fences) or on the full file body. They are tolerant of CRLF line
10
+ * endings and of YAML blocks that sit flush against the closing fence with no
11
+ * trailing newline.
12
+ */
13
+
14
+ import {
15
+ readFileSync,
16
+ writeFileSync,
17
+ existsSync,
18
+ readdirSync,
19
+ mkdirSync,
20
+ unlinkSync,
21
+ } from 'node:fs';
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // Date / string utilities
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+
27
+ export const VAULT_ID_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
28
+
29
+ export function isoNow() {
30
+ return new Date().toISOString();
31
+ }
32
+
33
+ export function todayDate() {
34
+ return isoNow().slice(0, 10);
35
+ }
36
+
37
+ export function deriveSlug(name, maxLen = 40) {
38
+ const slug = String(name || '')
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '-')
41
+ .replace(/-+/g, '-')
42
+ .replace(/^-+|-+$/g, '');
43
+ if (!slug) return 'untitled';
44
+ if (slug.length <= maxLen) return slug;
45
+ const cut = slug.slice(0, maxLen);
46
+ const lastDash = cut.lastIndexOf('-');
47
+ if (lastDash > 0) return cut.slice(0, lastDash);
48
+ return cut.replace(/-+$/, '') || 'untitled';
49
+ }
50
+
51
+ export function randomSuffix(length, randomUnit = Math.random) {
52
+ let out = '';
53
+ for (let i = 0; i < length; i++) {
54
+ const idx = Math.floor(randomUnit() * VAULT_ID_CHARSET.length);
55
+ out += VAULT_ID_CHARSET[Math.min(idx, VAULT_ID_CHARSET.length - 1)];
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // File discovery
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Resolve a `.md` filename in `dir` from an id-or-basename prefix.
66
+ *
67
+ * An EXACT basename match always wins over a prefix match: passing the complete
68
+ * basename (`HS-X5FH-TN54-RES-voice-profile-for-the-companion`) used to resolve
69
+ * to nothing, because `<basename>-` is not a prefix of `<basename>.md`, which
70
+ * forced callers to guess a truncated prefix
71
+ * ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]). Exact-first
72
+ * is strictly additive for the other callers: it can only turn a previous null
73
+ * (or a wrong sibling hit) into the file the caller literally named.
74
+ */
75
+ export function findFileByPrefix(dir, prefix) {
76
+ if (!existsSync(dir)) return null;
77
+ const exact = `${prefix}.md`;
78
+ let prefixHit = null;
79
+ for (const file of readdirSync(dir)) {
80
+ if (!file.endsWith('.md')) continue;
81
+ if (file === exact) return file;
82
+ if (prefixHit === null && file.startsWith(`${prefix}-`)) prefixHit = file;
83
+ }
84
+ return prefixHit;
85
+ }
86
+
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+ // Frontmatter parsing
89
+ // ─────────────────────────────────────────────────────────────────────────────
90
+
91
+ export function getFrontmatterBlock(raw) {
92
+ const m = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/);
93
+ if (!m) return null;
94
+ return {
95
+ open: m[1],
96
+ block: m[2],
97
+ close: m[3],
98
+ body: raw.slice(m[0].length),
99
+ full: m[0],
100
+ };
101
+ }
102
+
103
+ export function getScalarField(block, field) {
104
+ const re = new RegExp(`^${field}:[ \\t]*(.*)$`, 'm');
105
+ const m = block.match(re);
106
+ if (!m) return null;
107
+ const raw = m[1].trim();
108
+ if (!raw) return null;
109
+ return raw.replace(/^['"]|['"]$/g, '');
110
+ }
111
+
112
+ export function parseWikilinkBasename(value) {
113
+ if (!value) return null;
114
+ const m = value.match(/^\[\[([^\]]+)\]\]$/);
115
+ return m ? m[1] : null;
116
+ }
117
+
118
+ /**
119
+ * Parse a block-list frontmatter field into its list of wikilink basenames.
120
+ * Returns:
121
+ * - `[]` if the field is an inline empty array (`field: []`)
122
+ * - array of basenames if the field is a block list
123
+ * - `null` if the field is absent
124
+ */
125
+ export function parseListFieldBasenames(block, field) {
126
+ const emptyRe = new RegExp(`^${field}:[ \\t]*\\[[ \\t]*\\][ \\t]*$`, 'm');
127
+ if (emptyRe.test(block)) return [];
128
+ const headRe = new RegExp(`^${field}:[ \\t]*\\r?\\n`, 'm');
129
+ const headMatch = block.match(headRe);
130
+ if (!headMatch) return null;
131
+ const startIdx = headMatch.index + headMatch[0].length;
132
+ const lines = block.slice(startIdx).split(/\r?\n/);
133
+ const items = [];
134
+ for (const line of lines) {
135
+ if (/^[ \t]+-[^\n]*$/.test(line)) {
136
+ items.push(line);
137
+ continue;
138
+ }
139
+ break;
140
+ }
141
+ if (items.length === 0) return null;
142
+ const out = [];
143
+ for (const line of items) {
144
+ const wm = line.match(/-[ \t]*["']?\[\[([^\]]+)\]\]["']?/);
145
+ if (wm) out.push(wm[1]);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+ // Frontmatter mutation
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Append a wikilink basename to a list-shaped frontmatter field, idempotent.
156
+ * Creates the field as a block list if it does not already exist. Operates on
157
+ * the *normalized* block (always re-trimmed to no trailing newline) so down-
158
+ * stream concatenation with fm.close stays clean.
159
+ */
160
+ export function appendToListField(block, field, basename) {
161
+ const existing = parseListFieldBasenames(block, field);
162
+ if (existing && existing.includes(basename)) return { block, changed: false };
163
+
164
+ const emptyRe = new RegExp(`^${field}:[ \\t]*\\[[ \\t]*\\][ \\t]*$`, 'm');
165
+ if (emptyRe.test(block)) {
166
+ const replacement = `${field}:\n - "[[${basename}]]"`;
167
+ return { block: block.replace(emptyRe, replacement), changed: true };
168
+ }
169
+
170
+ if (existing !== null) {
171
+ const headRe = new RegExp(`^${field}:[ \\t]*\\r?\\n`, 'm');
172
+ const headMatch = block.match(headRe);
173
+ if (headMatch) {
174
+ const before = block.slice(0, headMatch.index);
175
+ const startIdx = headMatch.index + headMatch[0].length;
176
+ let endIdx = startIdx;
177
+ const lines = block.slice(startIdx).split(/\r?\n/);
178
+ for (const line of lines) {
179
+ if (/^[ \t]+-[^\n]*$/.test(line)) {
180
+ endIdx += line.length + 1;
181
+ continue;
182
+ }
183
+ break;
184
+ }
185
+ const after = endIdx > block.length ? '' : block.slice(endIdx - 1);
186
+ const allItems = [...existing, basename]
187
+ .map((b) => ` - "[[${b}]]"`)
188
+ .join('\n');
189
+ const next = `${before}${field}:\n${allItems}${after.startsWith('\n') ? after : '\n' + after}`;
190
+ return { block: next.replace(/\n+$/, ''), changed: true };
191
+ }
192
+ }
193
+
194
+ const appended = `${block}\n${field}:\n - "[[${basename}]]"`;
195
+ return { block: appended, changed: true };
196
+ }
197
+
198
+ export function setScalarField(block, field, value) {
199
+ const re = new RegExp(`^${field}:[ \\t]*(.*)$`, 'm');
200
+ if (re.test(block)) {
201
+ return block.replace(re, `${field}: ${value}`);
202
+ }
203
+ const trimmed = block.replace(/\r?\n$/, '');
204
+ return `${trimmed}\n${field}: ${value}`;
205
+ }
206
+
207
+ export function bumpUpdated(block, now) {
208
+ return setScalarField(block, 'updated', `"${now}"`);
209
+ }
210
+
211
+ export function appendChangeLogRow(body, dateStr, author, message) {
212
+ const row = `| ${dateStr} | ${author} | ${message} |`;
213
+ const headingMatch = body.match(/^## Change[ -]log\s*$/im);
214
+ if (!headingMatch) {
215
+ return (
216
+ body.replace(/\s*$/, '') +
217
+ `\n\n## Change log\n\n| Date | Author | Change |\n| --- | --- | --- |\n${row}\n`
218
+ );
219
+ }
220
+ return body.replace(/\s*$/, '') + `\n${row}\n`;
221
+ }
222
+
223
+ // ─────────────────────────────────────────────────────────────────────────────
224
+ // Transactional writes
225
+ // ─────────────────────────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Stage + commit a set of file writes with rollback. `writes` entries are
229
+ * `{ kind: 'create' | 'update', absPath, content }`. Rollback on any error:
230
+ * created files are removed, updated files restored from snapshot.
231
+ */
232
+ export function commitWrites(writes) {
233
+ const snapshots = new Map();
234
+ const performed = [];
235
+ try {
236
+ for (const w of writes) {
237
+ if (w.kind === 'create') {
238
+ if (existsSync(w.absPath)) {
239
+ throw new Error(`refusing to overwrite existing file: ${w.absPath}`);
240
+ }
241
+ snapshots.set(w.absPath, { existed: false });
242
+ const dir = w.absPath.slice(
243
+ 0,
244
+ Math.max(w.absPath.lastIndexOf('/'), w.absPath.lastIndexOf('\\')),
245
+ );
246
+ if (dir) mkdirSync(dir, { recursive: true });
247
+ writeFileSync(w.absPath, w.content, 'utf8');
248
+ performed.push(w);
249
+ } else if (w.kind === 'update') {
250
+ if (!existsSync(w.absPath)) {
251
+ throw new Error(`cannot update missing file: ${w.absPath}`);
252
+ }
253
+ if (!snapshots.has(w.absPath)) {
254
+ snapshots.set(w.absPath, {
255
+ existed: true,
256
+ original: readFileSync(w.absPath, 'utf8'),
257
+ });
258
+ }
259
+ writeFileSync(w.absPath, w.content, 'utf8');
260
+ performed.push(w);
261
+ }
262
+ }
263
+ return { ok: true };
264
+ } catch (err) {
265
+ for (const w of performed.reverse()) {
266
+ const snap = snapshots.get(w.absPath);
267
+ if (!snap) continue;
268
+ try {
269
+ if (snap.existed) {
270
+ writeFileSync(w.absPath, snap.original, 'utf8');
271
+ } else if (existsSync(w.absPath)) {
272
+ unlinkSync(w.absPath);
273
+ }
274
+ } catch (rollbackErr) {
275
+ err.rollbackError = rollbackErr.message;
276
+ }
277
+ }
278
+ return { ok: false, error: err };
279
+ }
280
+ }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * read-path.mjs — the single decision point every vault read the MCP server
4
+ * makes goes through (epic HS-T6GS "MCP server reads the vault through the
5
+ * ..."). Decides `disk` vs `neon` from the already-shipped dev-mode
6
+ * resolver (`resolveDevMode().readTier` in `scripts/lib/dev-mode.mjs`), and
7
+ * runs the matching read function.
8
+ *
9
+ * Transport-free by design: this module never imports console-fetch.mjs or
10
+ * talks to a network itself — callers hand it `diskRead`/`neonRead`
11
+ * functions and this module only picks which one runs.
12
+ *
13
+ * Dynamic-imports scripts/lib/dev-mode.mjs at call time — same self-
14
+ * containment pattern cli.mjs uses for handleDevServerStatus / handleDevMode
15
+ * (`pathToFileURL(join(root, ...)).href` then `await import(coreUrl)`), so
16
+ * vendor/ stays dependency-free.
17
+ *
18
+ * `scripts/lib/dev-mode.mjs` is PACKAGE code (the shared dev/prod mode core
19
+ * — see its own module doc), not vault content: no vault, customer or
20
+ * platform, ever contains a `scripts/` folder (confirmed against the
21
+ * customer-vault scaffold templates). It therefore must always be resolved
22
+ * relative to THIS PACKAGE's own install location — `PACKAGE_ROOT` below
23
+ * (`vendor/heyspex-mcp/read-path.mjs` -> two levels up: the repo root in the
24
+ * monorepo, `node_modules/heyspex` in a packed install) — never the
25
+ * resolved VAULT root (wherever the customer's `specs/` happens to live,
26
+ * which is a SEPARATE directory in every packed install).
27
+ *
28
+ * The pre-fix code conflated the two under one misleadingly-named constant
29
+ * (`VAULT_ROOT = join(__dirname, '..', '..')`): it was actually always
30
+ * computing the PACKAGE root (harmless in the monorepo, where repo root
31
+ * doubles as both), so once `scripts/lib/dev-mode.mjs` is added to
32
+ * `package.json` `files` (this task, sibling fix), the exact same
33
+ * computation resolves correctly in a packed install too — confirmed by
34
+ * spawning the REAL packed tarball's server against a scaffolded vault in a
35
+ * different directory (`scripts/verify-packed-mcp-loop.test.mjs`; using the
36
+ * VAULT root here instead throws `Cannot find module
37
+ * '<vault>/scripts/lib/dev-mode.mjs'` — that file never exists there).
38
+ * `PACKAGE_ROOT` is kept as its own clearly-named constant (never
39
+ * `VAULT_ROOT`) so the two concepts can't be silently re-conflated, and
40
+ * {@link resolveVaultRoot} exposes the SAME shared vault resolver
41
+ * (`getActiveConfig()` in `./vault-resolve.mjs`) every other MCP read uses,
42
+ * for any future caller here that genuinely needs the vault (not this
43
+ * module's own sibling code) — [[HS-PO09-0JNK-TASK-packed-mcp-server-runs-the-agent-loop]] AC3.
44
+ *
45
+ * `loadDevMode` is an injectable loader (defaults to the real dynamic
46
+ * import) so tests can stub scripts/lib/dev-mode.mjs and prove the verdict
47
+ * comes from `resolveDevMode` itself, not from string-matching this file.
48
+ */
49
+ import { dirname, join } from 'node:path';
50
+ import { fileURLToPath, pathToFileURL } from 'node:url';
51
+ import { getActiveConfig } from './vault-resolve.mjs';
52
+
53
+ const __dirname = dirname(fileURLToPath(import.meta.url));
54
+ // THIS PACKAGE's own root (vendor/heyspex-mcp/read-path.mjs -> two levels
55
+ // up) — repo root in the monorepo, `node_modules/heyspex` in a packed
56
+ // install. Used to locate this package's OWN sibling code (dev-mode.mjs);
57
+ // see the module doc above for why this is never the vault root.
58
+ const PACKAGE_ROOT = join(__dirname, '..', '..');
59
+
60
+ /**
61
+ * Resolve the VAULT root via the same shared 5-tier resolver every other MCP
62
+ * read uses (HEYSPEX_VAULT_ROOT / HEYSPEX_VAULT_DIR env, the cwd the server
63
+ * was started in/under, the offline registry pointer, cwd fallback). Not
64
+ * used by {@link loadRealDevMode} (see module doc — dev-mode.mjs is package
65
+ * code, not vault content); exposed for any future caller in this file that
66
+ * genuinely needs the vault. Never throws: falls back to {@link PACKAGE_ROOT}
67
+ * so a caller that somehow can't reach the resolver still gets SOME answer.
68
+ */
69
+ export function resolveVaultRoot() {
70
+ try {
71
+ const { repoRootAbs } = getActiveConfig();
72
+ if (repoRootAbs) return repoRootAbs;
73
+ } catch {
74
+ // fall through to the package-root default below
75
+ }
76
+ return PACKAGE_ROOT;
77
+ }
78
+
79
+ /** Real loader: dynamic-imports the shared dev-mode core from THIS PACKAGE's own root. */
80
+ export async function loadRealDevMode() {
81
+ const coreUrl = pathToFileURL(join(PACKAGE_ROOT, 'scripts', 'lib', 'dev-mode.mjs')).href;
82
+ return import(coreUrl);
83
+ }
84
+
85
+ /**
86
+ * Raised when a `neon`-tier read fails — names the console URL and the
87
+ * failing route so the caller sees exactly what was unreachable. The disk
88
+ * read is never attempted as a fallback (cloud-first tiers stay cloud-only).
89
+ */
90
+ export class ConsoleUnreachableCloudModeError extends Error {
91
+ constructor(message) {
92
+ super(message);
93
+ this.name = 'CONSOLE_UNREACHABLE_CLOUD_MODE';
94
+ }
95
+ }
96
+
97
+ /**
98
+ * resolveReadTier({ env, homeDir, loadDevMode? }) -> "disk" | "neon"
99
+ *
100
+ * The verdict is whatever `resolveDevMode(...).readTier` says: `disk` only
101
+ * for dev mode with local reliance, `neon` for prod and for dev with cloud
102
+ * reliance. `env`/`homeDir` are forwarded to `resolveDevMode` untouched
103
+ * (undefined lets it fall back to its own `process.env`/`homedir()`
104
+ * defaults). `loadDevMode` defaults to {@link loadRealDevMode}; tests
105
+ * override it with a stub module to prove this function never derives the
106
+ * verdict itself.
107
+ */
108
+ export async function resolveReadTier({ env, homeDir, loadDevMode = loadRealDevMode } = {}) {
109
+ const mod = await loadDevMode();
110
+ const resolved = mod.resolveDevMode({ env, homeDir });
111
+ return resolved.readTier;
112
+ }
113
+
114
+ /**
115
+ * readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl })
116
+ *
117
+ * Runs the read matching `tier`:
118
+ * - "disk": calls `diskRead()` (`neonRead` is never invoked).
119
+ * - "neon": calls `neonRead()`. On failure, wraps the error into a plain
120
+ * {@link ConsoleUnreachableCloudModeError} naming `consoleUrl` and
121
+ * `routeLabel`; `diskRead` is never called (no silent local fallback
122
+ * out of cloud reliance).
123
+ */
124
+ export async function readViaSelector({ tier, diskRead, neonRead, routeLabel, consoleUrl }) {
125
+ if (tier === 'disk') {
126
+ return diskRead();
127
+ }
128
+ if (tier === 'neon') {
129
+ try {
130
+ return await neonRead();
131
+ } catch (err) {
132
+ throw new ConsoleUnreachableCloudModeError(
133
+ `CONSOLE_UNREACHABLE_CLOUD_MODE: route "${routeLabel}" failed against console ${consoleUrl} — ${String(err?.message ?? err)}`,
134
+ );
135
+ }
136
+ }
137
+ throw new Error(`readViaSelector: unknown tier "${tier}" (expected "disk" | "neon")`);
138
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * HeySpex workflow-recipe MCP tools (pure logic).
3
+ *
4
+ * Backs the `heyspex_list_recipes` and `heyspex_start_recipe` MCP tools and the
5
+ * recipe-matching arm of `heyspex_preflight`, all mounted in cli.mjs. Recipes are
6
+ * the four stock workflow templates authored under specs/recipes/ by
7
+ * scripts/init-stock-recipes.mjs ([[HS-C28M-TASK]]); the entity shape is the
8
+ * workflow-recipe branch of schemas/heyspex-frontmatter.schema.json
9
+ * ([[HS-GWMJ-TASK]]).
10
+ *
11
+ * v1 has no catalog build step (there are only four files): this module scans
12
+ * specs/recipes/*.md directly at call time and parses the frontmatter with
13
+ * js-yaml — the same parser scripts/init-stock-recipes.mjs uses to AUTHOR the
14
+ * files, so read/write are symmetric. Pattern source: procedure-tools.mjs.
15
+ *
16
+ * Spec: [[HS-RQ9K-TASK]] under [[HS-5G0P-EPIC-workflow-recipe-skills-catalog]].
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import yaml from 'js-yaml';
22
+
23
+ const RECIPES_DIR = ['specs', 'recipes'];
24
+
25
+ function normalize(value) {
26
+ return String(value ?? '').toLowerCase().trim();
27
+ }
28
+
29
+ function asArray(value) {
30
+ return Array.isArray(value) ? value : [];
31
+ }
32
+
33
+ /** Parse the frontmatter block of a recipe markdown file. Returns null on miss. */
34
+ function parseRecipeFrontmatter(raw) {
35
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
36
+ if (!match) return null;
37
+ try {
38
+ const data = yaml.load(match[1]);
39
+ return data && typeof data === 'object' ? data : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** Shape a raw frontmatter object into the recipe record the tools return. */
46
+ function toRecipe(data, relPath) {
47
+ return {
48
+ id: data.id ?? null,
49
+ name: data.name ?? null,
50
+ description: data.description ?? '',
51
+ status: data.status ?? null,
52
+ intent_signatures: asArray(data.intent_signatures),
53
+ procedures: asArray(data.procedures),
54
+ tool_scope: asArray(data.tool_scope),
55
+ spec_template:
56
+ data.spec_template && typeof data.spec_template === 'object'
57
+ ? data.spec_template
58
+ : {},
59
+ capture_sequence: asArray(data.capture_sequence),
60
+ path: relPath,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Load every recipe under specs/recipes/. Returns [] when the folder is absent
66
+ * (a vault with no recipes is valid — the tools degrade to an empty catalog).
67
+ * Files whose frontmatter is missing or is not a workflow-recipe are skipped.
68
+ */
69
+ export function loadRecipes(vaultRoot) {
70
+ const dir = join(vaultRoot, ...RECIPES_DIR);
71
+ if (!existsSync(dir)) return [];
72
+ let files;
73
+ try {
74
+ files = readdirSync(dir).filter((f) => f.endsWith('.md'));
75
+ } catch {
76
+ return [];
77
+ }
78
+ const recipes = [];
79
+ for (const file of files.sort()) {
80
+ let raw;
81
+ try {
82
+ raw = readFileSync(join(dir, file), 'utf8');
83
+ } catch {
84
+ continue;
85
+ }
86
+ const data = parseRecipeFrontmatter(raw);
87
+ if (!data || data.type !== 'workflow-recipe') continue;
88
+ recipes.push(toRecipe(data, `specs/recipes/${file}`));
89
+ }
90
+ return recipes;
91
+ }
92
+
93
+ /**
94
+ * Score how strongly a recipe matches an intent string.
95
+ * - 'high' : an intent_signature equals the intent (token-exact).
96
+ * - 'medium' : an intent_signature is a substring of the intent (or vice
97
+ * versa), or the intent hits the recipe name/description.
98
+ * - null : no match.
99
+ * Comparison is case-insensitive and underscore/space/hyphen agnostic so
100
+ * "fix bug", "fix-bug", and "bug_fix" all reach the Bug fix flow.
101
+ */
102
+ function canonicalToken(value) {
103
+ return normalize(value).replace(/[\s_-]+/g, ' ').trim();
104
+ }
105
+
106
+ export function matchStrength(recipe, intent) {
107
+ const needle = canonicalToken(intent);
108
+ if (!needle) return null;
109
+ const signatures = recipe.intent_signatures.map(canonicalToken).filter(Boolean);
110
+ if (signatures.some((s) => s === needle)) return 'high';
111
+ if (signatures.some((s) => s.includes(needle) || needle.includes(s))) return 'medium';
112
+ const haystack = canonicalToken(`${recipe.name} ${recipe.description}`);
113
+ if (haystack.includes(needle)) return 'medium';
114
+ return null;
115
+ }
116
+
117
+ function summarize(recipe) {
118
+ return {
119
+ id: recipe.id,
120
+ name: recipe.name,
121
+ description: recipe.description,
122
+ intent_signatures: recipe.intent_signatures,
123
+ tool_scope: recipe.tool_scope,
124
+ path: recipe.path,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * heyspex_list_recipes({ intent? }) — return matching recipes.
130
+ * No intent → every recipe. With intent → only recipes whose intent_signatures
131
+ * (or name/description) match, each annotated with a match_strength and sorted
132
+ * strongest-first, then by name.
133
+ */
134
+ export function executeListRecipes(vaultRoot, args = {}) {
135
+ const recipes = loadRecipes(vaultRoot);
136
+ const intent = typeof args.intent === 'string' ? args.intent.trim() : '';
137
+
138
+ let matches;
139
+ if (!intent) {
140
+ matches = recipes.map((r) => ({ ...summarize(r), match_strength: null }));
141
+ } else {
142
+ matches = [];
143
+ for (const r of recipes) {
144
+ const strength = matchStrength(r, intent);
145
+ if (strength) matches.push({ ...summarize(r), match_strength: strength });
146
+ }
147
+ }
148
+
149
+ const rank = { high: 0, medium: 1 };
150
+ matches.sort((a, b) => {
151
+ const ra = rank[a.match_strength] ?? 2;
152
+ const rb = rank[b.match_strength] ?? 2;
153
+ if (ra !== rb) return ra - rb;
154
+ return String(a.name ?? '').localeCompare(String(b.name ?? ''));
155
+ });
156
+
157
+ return {
158
+ recipes: matches,
159
+ count: matches.length,
160
+ total: recipes.length,
161
+ filters_applied: { intent: intent || null },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * matchingRecipes(vaultRoot, intent) — the compact list attached to a preflight
167
+ * response. Same match rule as executeListRecipes but shaped for discovery
168
+ * (id/name/match_strength). Returns [] when nothing matches. Never throws.
169
+ */
170
+ export function matchingRecipes(vaultRoot, intent) {
171
+ if (typeof intent !== 'string' || !intent.trim()) return [];
172
+ let recipes;
173
+ try {
174
+ recipes = loadRecipes(vaultRoot);
175
+ } catch {
176
+ return [];
177
+ }
178
+ const out = [];
179
+ for (const r of recipes) {
180
+ const strength = matchStrength(r, intent);
181
+ if (strength) {
182
+ out.push({
183
+ id: r.id,
184
+ name: r.name,
185
+ description: r.description,
186
+ match_strength: strength,
187
+ start: { tool: 'heyspex_start_recipe', args: { recipe_id: r.id } },
188
+ });
189
+ }
190
+ }
191
+ const rank = { high: 0, medium: 1 };
192
+ out.sort((a, b) => (rank[a.match_strength] ?? 2) - (rank[b.match_strength] ?? 2));
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Resolve a recipe by id or slug. Accepts the authored id ("HS-RECIPE-bug-fix"),
198
+ * the bare slug ("bug-fix"), or an underscore/space variant ("bug fix").
199
+ */
200
+ export function resolveRecipe(vaultRoot, recipeId) {
201
+ const wanted = canonicalToken(recipeId);
202
+ if (!wanted) return null;
203
+ const recipes = loadRecipes(vaultRoot);
204
+ for (const r of recipes) {
205
+ const id = canonicalToken(r.id);
206
+ const slug = id.replace(/^hs recipe /, '');
207
+ if (id === wanted || slug === wanted) return r;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * Build the "start context" a recipe hands off: everything a capture walk needs
214
+ * to structure the start of work (the capture_sequence to walk, the
215
+ * spec_template to pre-fill, the tool_scope + recipe_id to stamp for provenance
216
+ * — stamped through the capture→file path by [[HS-98G6-TASK]]).
217
+ */
218
+ export function buildRecipeStartContext(recipe, args = {}) {
219
+ const captureText =
220
+ typeof args.capture_text === 'string' && args.capture_text.trim()
221
+ ? args.capture_text.trim()
222
+ : null;
223
+ return {
224
+ recipe_id: recipe.id,
225
+ name: recipe.name,
226
+ description: recipe.description,
227
+ capture_sequence: recipe.capture_sequence,
228
+ spec_template: recipe.spec_template,
229
+ tool_scope: recipe.tool_scope,
230
+ procedures: recipe.procedures,
231
+ ...(captureText ? { capture_text: captureText } : {}),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * heyspex_start_recipe({ recipe_id, capture_text? }) — pure arm. Resolves the
237
+ * recipe and returns its start context, or an error the shell surfaces. The
238
+ * console capture create/pin (the "creates/pins a capture" side effect) lives in
239
+ * cli.mjs's thin shell so this stays synchronous and testable without a server.
240
+ */
241
+ export function executeStartRecipe(vaultRoot, args = {}) {
242
+ const recipeId = typeof args.recipe_id === 'string' ? args.recipe_id.trim() : '';
243
+ if (!recipeId) {
244
+ return {
245
+ error: 'MISSING_RECIPE_ID',
246
+ message: 'recipe_id is required (e.g. "HS-RECIPE-bug-fix" or "bug-fix").',
247
+ };
248
+ }
249
+ const recipe = resolveRecipe(vaultRoot, recipeId);
250
+ if (!recipe) {
251
+ const known = loadRecipes(vaultRoot).map((r) => r.id).filter(Boolean);
252
+ return {
253
+ error: 'RECIPE_NOT_FOUND',
254
+ message: `No recipe matching "${recipeId}". Known recipes: ${known.join(', ') || '(none)'}. Call heyspex_list_recipes to browse.`,
255
+ };
256
+ }
257
+ return { recipe: buildRecipeStartContext(recipe, args) };
258
+ }