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,476 @@
1
+ /**
2
+ * Research-on-deck — pin file (`.heyspex/research-focus.yaml`) and `/hs-res` flow.
3
+ * [[idea-capture-guardrails]] · [[HS-TASK-HRS5-hs-res-skill-ship-from-stub]] · [[HS-EPIC-IDFV-unified-idea-workflow-v2]]
4
+ */
5
+
6
+ import {
7
+ readFileSync,
8
+ writeFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ rmSync,
12
+ readdirSync,
13
+ } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { parseSimpleFocusYaml } from './capture-focus.mjs';
16
+ import { yamlScalar } from './yaml-scalar.mjs';
17
+ import { familyOf, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
18
+
19
+ export function researchFocusPath(vaultRoot) {
20
+ return join(vaultRoot, '.heyspex', 'research-focus.yaml');
21
+ }
22
+
23
+ const VALID_SCOPES = new Set(['internal', 'codebase', 'external', 'all']);
24
+
25
+ export function loadResearchFocus(vaultRoot) {
26
+ const p = researchFocusPath(vaultRoot);
27
+ if (!existsSync(p)) return null;
28
+ try {
29
+ const focus = parseSimpleFocusYaml(readFileSync(p, 'utf-8'));
30
+ if (focus.expires) {
31
+ const expiry = new Date(focus.expires);
32
+ if (expiry < new Date()) return null;
33
+ }
34
+ return focus;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function writeResearchFocus(vaultRoot, { specPath, sourceIdeaId, scope, reason }) {
41
+ if (!VALID_SCOPES.has(scope)) {
42
+ return { error: 'INVALID_SCOPE', message: `scope must be one of ${[...VALID_SCOPES].join('|')}` };
43
+ }
44
+ const dir = join(vaultRoot, '.heyspex');
45
+ mkdirSync(dir, { recursive: true });
46
+ const lines = [
47
+ '# HeySpex research-on-deck — MCP /hs-res ([[idea-capture-guardrails]])',
48
+ 'kind: research',
49
+ `spec_path: ${specPath}`,
50
+ `source_idea_id: ${sourceIdeaId}`,
51
+ `pinned_at: ${new Date().toISOString()}`,
52
+ `scope: ${scope}`,
53
+ ];
54
+ if (reason) lines.push(`reason: ${JSON.stringify(reason)}`);
55
+ writeFileSync(researchFocusPath(vaultRoot), `${lines.join('\n')}\n`, 'utf8');
56
+ return { success: true };
57
+ }
58
+
59
+ export function clearResearchFocus(vaultRoot) {
60
+ const p = researchFocusPath(vaultRoot);
61
+ if (!existsSync(p)) return { existed: false };
62
+ rmSync(p);
63
+ return { existed: true };
64
+ }
65
+
66
+ function slugify(title) {
67
+ return String(title || 'research')
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, '-')
70
+ .replace(/^-|-$/g, '')
71
+ .slice(0, 40) || 'research';
72
+ }
73
+
74
+ function stringifyResearchSpec(frontmatter, body) {
75
+ const lines = ['---'];
76
+ for (const [k, v] of Object.entries(frontmatter)) {
77
+ if (v === null || v === undefined) continue;
78
+ if (Array.isArray(v)) {
79
+ lines.push(`${k}:`);
80
+ for (const item of v) lines.push(` - ${JSON.stringify(item)}`);
81
+ } else if (typeof v === 'string') {
82
+ lines.push(`${k}: ${yamlScalar(v)}`);
83
+ } else {
84
+ lines.push(`${k}: ${v}`);
85
+ }
86
+ }
87
+ lines.push('---', '', body.trim(), '');
88
+ return lines.join('\n');
89
+ }
90
+
91
+ /**
92
+ * Build a research-doc body from sections supplied by the agent.
93
+ * Required: executive_summary, content. Optional: sources [{ title, url, description }].
94
+ */
95
+ function buildResearchBody(args, { sourceIdeaId, scope }) {
96
+ const lines = [];
97
+ const title = args.title?.trim() || 'Research';
98
+ lines.push(`# ${title}`, '');
99
+ if (args.summary?.trim()) {
100
+ lines.push(args.summary.trim(), '');
101
+ }
102
+ lines.push('## Executive Summary', '');
103
+ lines.push(
104
+ args.executive_summary?.trim() ||
105
+ args.summary?.trim() ||
106
+ `_From idea [[${sourceIdeaId}]] (scope: ${scope})._`,
107
+ '',
108
+ );
109
+ if (args.findings?.trim() || args.content?.trim()) {
110
+ lines.push('## Findings', '');
111
+ lines.push((args.findings?.trim() || args.content?.trim() || ''), '');
112
+ }
113
+ if (Array.isArray(args.sources) && args.sources.length > 0) {
114
+ lines.push('## Sources', '');
115
+ for (const s of args.sources) {
116
+ if (!s) continue;
117
+ const titlePart = s.title || s.url || 'source';
118
+ if (s.url && s.title) {
119
+ lines.push(`- [${s.title}](${s.url})${s.description ? ` — ${s.description}` : ''}`);
120
+ } else if (s.url) {
121
+ lines.push(`- ${s.url}${s.description ? ` — ${s.description}` : ''}`);
122
+ } else {
123
+ lines.push(`- ${titlePart}${s.description ? ` — ${s.description}` : ''}`);
124
+ }
125
+ }
126
+ lines.push('');
127
+ }
128
+ lines.push('## Provenance', '');
129
+ lines.push(`- Source idea: [[${sourceIdeaId}]]`);
130
+ lines.push(`- Scope: ${scope}`);
131
+ lines.push(`- Captured via \`/hs-res\` on ${new Date().toISOString().slice(0, 10)}`);
132
+ return lines.join('\n');
133
+ }
134
+
135
+ /** Update a vault file's frontmatter by merging a patch; preserves body verbatim. */
136
+ function patchSpecFrontmatter(absPath, patch) {
137
+ const content = readFileSync(absPath, 'utf-8');
138
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
139
+ if (!m) {
140
+ throw new Error(`No frontmatter in ${absPath}`);
141
+ }
142
+ const yamlBlock = m[1];
143
+ const body = m[2];
144
+
145
+ // Parse existing keys naively so we can re-emit deterministically.
146
+ // For HS-IDEA frontmatter (small / flat), we update or append `promoted_to`
147
+ // and `updated`. Untouched keys stay byte-for-byte stable.
148
+ const lines = yamlBlock.split(/\r?\n/);
149
+ const updates = { ...patch };
150
+ const out = [];
151
+ for (const line of lines) {
152
+ const km = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):/);
153
+ if (km && Object.prototype.hasOwnProperty.call(updates, km[1])) {
154
+ const key = km[1];
155
+ const val = updates[key];
156
+ delete updates[key];
157
+ out.push(emitYamlLine(key, val));
158
+ } else {
159
+ out.push(line);
160
+ }
161
+ }
162
+ for (const [k, v] of Object.entries(updates)) {
163
+ out.push(emitYamlLine(k, v));
164
+ }
165
+ const newContent = `---\n${out.join('\n').replace(/\n+$/, '')}\n---\n${body.replace(/^\r?\n/, '')}`;
166
+ writeFileSync(absPath, newContent, 'utf8');
167
+ }
168
+
169
+ function emitYamlLine(key, value) {
170
+ if (value === null || value === undefined) return `${key}: null`;
171
+ if (typeof value === 'string') {
172
+ return `${key}: ${JSON.stringify(value)}`;
173
+ }
174
+ return `${key}: ${value}`;
175
+ }
176
+
177
+ function collectOccupiedResearchIds(vaultRoot) {
178
+ const dir = join(vaultRoot, 'specs', 'research');
179
+ const occupied = new Set();
180
+ if (!existsSync(dir)) return occupied;
181
+ for (const file of readdirSync(dir)) {
182
+ if (!file.endsWith('.md') || file.endsWith('.review.md')) continue;
183
+ // Dual-read: accept HS-RES-<key> and HS-<key>-RES, and key the occupied set
184
+ // on the canonical family-first id so it matches what allocateRandomVaultId
185
+ // generates/compares ([[HS-EPIC-DLE3]]).
186
+ const p = parseSpecId(file.replace(/\.md$/i, ''));
187
+ if (p && p.typeLabel === 'RES') occupied.add(`HS-${p.familyKey}-RES`);
188
+ }
189
+ return occupied;
190
+ }
191
+
192
+ /**
193
+ * Atomically write the research file, update the source idea's `promoted_to`,
194
+ * and clear the research-focus pin. Returns the new research id + path.
195
+ *
196
+ * deps: { allocateRandomVaultId, findSpecFile }
197
+ */
198
+ export function executeCompleteResearch(vaultRoot, args = {}, deps = {}) {
199
+ const focus = loadResearchFocus(vaultRoot);
200
+ // Source idea + scope can come from the pin or from explicit args (ad-hoc mode).
201
+ const sourceIdeaId = args.source_idea_id || focus?.source_idea_id;
202
+ const scope = args.scope || focus?.scope || 'internal';
203
+ if (!sourceIdeaId) {
204
+ return {
205
+ error: 'NO_SOURCE_IDEA',
206
+ message:
207
+ 'No source idea. Pin via console Promote (writes .heyspex/research-focus.yaml) or pass source_idea_id explicitly.',
208
+ };
209
+ }
210
+ if (!VALID_SCOPES.has(scope)) {
211
+ return { error: 'INVALID_SCOPE', message: `scope must be ${[...VALID_SCOPES].join('|')}` };
212
+ }
213
+ if (!args.title?.trim()) {
214
+ return { error: 'MISSING_TITLE', message: 'title is required' };
215
+ }
216
+ if (!args.executive_summary?.trim() && !args.summary?.trim()) {
217
+ return { error: 'MISSING_SUMMARY', message: 'executive_summary or summary is required' };
218
+ }
219
+ if (!args.content?.trim() && !args.findings?.trim()) {
220
+ return { error: 'MISSING_CONTENT', message: 'content or findings is required' };
221
+ }
222
+ // Completion source gate (HS-7HR9): external/all-scope research is a web-validated
223
+ // claim — refuse to finalize it with no cited source. Closes the "made-up research"
224
+ // gap where heyspex_complete_research accepted an empty sources array. `codebase`
225
+ // and `internal` scope are exempt (they cite code/vault, not URLs).
226
+ if (scope === 'external' || scope === 'all') {
227
+ const hasCitedUrl =
228
+ Array.isArray(args.sources) &&
229
+ args.sources.some((s) => s && typeof s.url === 'string' && /^https?:\/\//i.test(s.url.trim()));
230
+ if (!hasCitedUrl) {
231
+ return {
232
+ error: 'MISSING_SOURCES',
233
+ message:
234
+ `Scope "${scope}" is web-validated research — at least one cited source with a URL is required ` +
235
+ `to complete it (the ## Sources section cannot be empty). Add sources: [{ title, url, description }] ` +
236
+ `with a real http(s) URL, or complete this as scope "codebase"/"internal" if it cites code/vault instead of the web.`,
237
+ };
238
+ }
239
+ }
240
+ if (!deps.allocateRandomVaultId) {
241
+ return { error: 'MISSING_DEPS', message: 'allocateRandomVaultId is required' };
242
+ }
243
+ const ideaPath = deps.findSpecFile?.(vaultRoot, sourceIdeaId);
244
+ if (!ideaPath) {
245
+ // HS-CAP-* ids are minted by the console capture-workshop the moment the
246
+ // modal opens, but the markdown file is written lazily on first content
247
+ // (see spex-console/src/lib/capture-sessions/materialize.ts). The most common
248
+ // way to hit this branch is closing the modal without pressing Enter /
249
+ // Send on a note — the capture_sessions row stays `pending`, no file
250
+ // lands, and any /hs-res ad-hoc call against the visible id fails here.
251
+ const isCaptureId = parseSpecId(sourceIdeaId)?.typeLabel === 'CAP';
252
+ const message = isCaptureId
253
+ ? `Source capture ${sourceIdeaId} has no markdown file in specs/captures/. ` +
254
+ `The capture workshop assigns the id when the modal opens, but the file ` +
255
+ `is only written after you press Enter / Send on a note (lazy materialization). ` +
256
+ `Reopen the capture, send at least one note, watch for the "saved to ` +
257
+ `specs/captures/…" toast, then retry /hs-res.`
258
+ : `Source idea ${sourceIdeaId} not found in vault`;
259
+ return { error: 'IDEA_NOT_FOUND', message };
260
+ }
261
+
262
+ // Era-3 family inheritance ([[naming-v3-7-rulings-hs-namespace-kept]]): the
263
+ // research doc inherits the SOURCE IDEA/CAPTURE's family (familyOf: explicit
264
+ // `family:` frontmatter wins, else positional from id/basename). The legacy
265
+ // allocator's `HS-<family>-RES` draw is only the defensive fallback family
266
+ // source; `id:` keys onto the file key alone.
267
+ const ideaBasename = String(ideaPath).replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '');
268
+ let ideaFmId = null;
269
+ let ideaFamilyValue = null;
270
+ try {
271
+ const ideaRaw = readFileSync(join(vaultRoot, ideaPath), 'utf-8');
272
+ ideaFmId = ideaRaw.match(/^id:\s*(\S+)/m)?.[1]?.replace(/^['"]|['"]$/g, '') ?? null;
273
+ ideaFamilyValue = ideaRaw.match(/^family:\s*(\S+)/m)?.[1]?.replace(/^['"]|['"]$/g, '') ?? null;
274
+ } catch {
275
+ /* unreadable idea file — fall through to positional/allocator derivation */
276
+ }
277
+ const familyKey =
278
+ familyOf({
279
+ id: ideaFmId ?? sourceIdeaId,
280
+ basename: ideaBasename,
281
+ frontmatter: { family: ideaFamilyValue },
282
+ }) ??
283
+ parseSpecId(deps.allocateRandomVaultId('research', collectOccupiedResearchIds(vaultRoot)))?.familyKey ??
284
+ mintFamilyKey(vaultRoot);
285
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
286
+ // and (V3) the anchor for `id:` itself.
287
+ const fileKey = mintFileKey(vaultRoot);
288
+ const newId = `HS-${fileKey}`;
289
+ const slug = slugify(args.title);
290
+ const filename = `${newId}-${familyKey}-RES-${slug}.md`;
291
+ const researchDir = join(vaultRoot, 'specs', 'research');
292
+ mkdirSync(researchDir, { recursive: true });
293
+ const absResearchPath = join(researchDir, filename);
294
+
295
+ const today = new Date().toISOString().slice(0, 10);
296
+ const tags = Array.isArray(args.tags) ? args.tags : ['research'];
297
+ const related = Array.isArray(args.related) ? [...args.related] : [];
298
+ const sourceLink = `[[${sourceIdeaId}]]`;
299
+ if (!related.some((r) => String(r).includes(sourceIdeaId))) {
300
+ related.unshift(sourceLink);
301
+ }
302
+
303
+ const fm = {
304
+ id: newId,
305
+ file_id: fileKey,
306
+ family: familyKey,
307
+ name: args.title,
308
+ type: 'research',
309
+ status: 'open',
310
+ owner: args.owner || 'pwher',
311
+ created: today,
312
+ updated: today,
313
+ tags,
314
+ related,
315
+ source_idea: sourceLink,
316
+ scope,
317
+ };
318
+ const body = buildResearchBody(args, { sourceIdeaId, scope });
319
+ writeFileSync(absResearchPath, stringifyResearchSpec(fm, body), 'utf8');
320
+
321
+ // Update the source idea's promoted_to + updated date.
322
+ try {
323
+ patchSpecFrontmatter(join(vaultRoot, ideaPath), {
324
+ promoted_to: `[[${newId}]]`,
325
+ updated: today,
326
+ });
327
+ } catch (e) {
328
+ return {
329
+ error: 'IDEA_UPDATE_FAILED',
330
+ message: `Research file written to ${filename}, but updating ${sourceIdeaId} failed: ${e.message}`,
331
+ research_id: newId,
332
+ research_path: `specs/research/${filename}`,
333
+ };
334
+ }
335
+
336
+ const pinResult = clearResearchFocus(vaultRoot);
337
+
338
+ return {
339
+ success: true,
340
+ research_id: newId,
341
+ research_path: `specs/research/${filename}`,
342
+ source_idea_id: sourceIdeaId,
343
+ source_idea_path: ideaPath,
344
+ scope,
345
+ pin_cleared: pinResult.existed,
346
+ next_steps: [
347
+ `Open ${newId} in console to review the draft research`,
348
+ 'Add wikilinks back to related specs',
349
+ // Research is evergreen ([[research-is-evergreen]]): it never closes.
350
+ // Lineage is recorded via relational fields, not a status flip.
351
+ 'Add `enhances:` to bind this research to a milestone, or `bound_epic:` when an epic is filed from it',
352
+ ],
353
+ };
354
+ }
355
+
356
+ export function buildHsResPromptMarkdown(focus, options = {}) {
357
+ if (!focus?.spec_path || !focus?.source_idea_id) {
358
+ return `# /hs-res — no research pinned
359
+
360
+ No \`.heyspex/research-focus.yaml\` is set. To run \`/hs-res\`:
361
+
362
+ 1. Open the source idea in console Docs.
363
+ 2. Click **Promote** on the doc toolbar.
364
+ 3. Confirm a scope (internal / codebase / external / all).
365
+ 4. Return here and rerun \`/hs-res\`.
366
+
367
+ Or capture research ad-hoc by calling \`heyspex_complete_research\` with explicit \`source_idea_id\`, \`scope\`, \`title\`, \`executive_summary\`, \`content\`.
368
+ `;
369
+ }
370
+ const { source_idea_id, scope, spec_path, pinned_at } = focus;
371
+
372
+ // Stale-pin detection. The console Promote button validates via the
373
+ // disk-backed vault index, so a freshly-written pin always points at a
374
+ // real file — but ad-hoc YAML edits, an in-flight capture-session that
375
+ // was discarded after pinning, or a moved/renamed file can leave the
376
+ // pin pointing at nothing. The MCP findSpecFile scans disk only, so a
377
+ // stale pin would fail late inside heyspex_complete_research with the
378
+ // generic IDEA_NOT_FOUND. Catch it here so the agent doesn't waste a
379
+ // research session on a pin that can never resolve.
380
+ if (options.vaultRoot) {
381
+ const abs = join(options.vaultRoot, spec_path);
382
+ if (!existsSync(abs)) {
383
+ const isCaptureId = parseSpecId(source_idea_id)?.typeLabel === 'CAP';
384
+ const captureHint = isCaptureId
385
+ ? `\n\nThe pinned id looks like a capture-workshop session (\`HS-CAP-*\`). ` +
386
+ `The console assigns the id when the modal opens but only writes the ` +
387
+ `markdown file after you press Enter / Send on a note. If you closed ` +
388
+ `the modal without sending anything, the pin is referencing a row that ` +
389
+ `never materialized — clear the pin and re-Promote after the capture ` +
390
+ `is actually saved to \`specs/captures/\`.`
391
+ : '';
392
+ return `# /hs-res — stale pin
393
+
394
+ The pinned source idea \`${source_idea_id}\` points at \`${spec_path}\`, but that file does not exist on disk.${captureHint}
395
+
396
+ To recover:
397
+
398
+ 1. Call \`heyspex_active_research\` to confirm the pin state.
399
+ 2. Either re-create the source idea/capture so the file exists, or clear the pin via the console doc-toolbar (Un-pin) before retrying \`/hs-res\`.
400
+ `;
401
+ }
402
+ }
403
+ return `# /hs-res — research capture for ${source_idea_id}
404
+
405
+ **Source idea:** [[${source_idea_id}]] · \`${spec_path}\`
406
+ **Scope:** \`${scope || 'internal'}\` · **Pinned at:** \`${pinned_at || 'unknown'}\`
407
+
408
+ ---
409
+
410
+ ## Your job — research capture (NOT idea capture)
411
+
412
+ Run the deep dive at the confirmed scope, then call \`heyspex_complete_research\` once. The tool atomically writes \`specs/research/HS-RES-*\`, sets the idea's \`promoted_to\`, and clears this pin.
413
+
414
+ ## Beat 1 — Confirm scope with the user
415
+
416
+ Read the pinned scope above and confirm with the user before starting:
417
+
418
+ - **internal** — search \`specs/\`, memories, ADRs only. Use \`heyspex_recall_memories\` + vault grep.
419
+ - **codebase** — grep the repo (\`spex-console/\`, \`vendor/\`, \`scripts/\`, etc.).
420
+ - **external** — web-validated research via the **deep-research harness** (below), not an ad-hoc dive.
421
+ - **all** — vault + codebase + web (the web half runs through the deep-research harness).
422
+
423
+ If the user wants a different scope, suggest re-pinning via console Promote rather than overriding silently.
424
+
425
+ ## Beat 2 — Deep dive at the confirmed scope
426
+
427
+ Read **the source idea first** (\`specs/captures/...\` — load by id \`${source_idea_id}\`):
428
+
429
+ - **Core Idea** and **Concept** sections are your prompt
430
+ - Notes are user phrasing — useful for context, not for rewriting
431
+ - Don't write research sections back into the idea file
432
+
433
+ Then explore per scope. Capture findings as you go.
434
+
435
+ **For \`external\` / \`all\` scope, run the \`hs-res\` skill's confidence-ladder method — do NOT hand-roll a web dive and do NOT spawn a Sonnet-based research path.** Self-hosted Firecrawl (search/scrape) + DeepSeek (synthesis + adversarial critique) is the default and required first choice for every rung (owner ruling, [[research-routing-hard-rule-sonnet]], amended 2026-07-13: *"Sonnet is fallback. And fallback must be initiated after specific user permission, not silent fallback."*):
436
+
437
+ \`\`\`
438
+ Skill({ skill: 'hs-res', args: '<the residual research question>' })
439
+ \`\`\`
440
+
441
+ **Never** \`Workflow({ scriptPath: '.claude/workflows/deep-research-*.js' })\`, the built-in \`deep-research\` skill, or native \`WebSearch\`/\`WebFetch\` for this — those are the forbidden Sonnet-spawning paths the owner ruling exists to prevent. If Firecrawl or DeepSeek report unavailable (\`FIRECRAWL_UNAVAILABLE\` / \`SYNTHESIZE_UNAVAILABLE\`), stop and ask the user for explicit one-time permission before falling back to a Sonnet-based path — never silently. Ground first (the grounding gate blocks a launch that re-derives already-built/already-decided work — the question must be the residual delta). The skill's saved doc seeds the \`## Executive Summary\` + \`## Sources\` you pass to \`heyspex_complete_research\`.
442
+
443
+ ## Beat 3 — Write the research file
444
+
445
+ Call \`heyspex_complete_research\` with:
446
+
447
+ \`\`\`json
448
+ {
449
+ "title": "<descriptive research title>",
450
+ "executive_summary": "<3–5 sentence summary>",
451
+ "content": "<full markdown findings — tables, code refs, comparisons>",
452
+ "sources": [
453
+ { "title": "Source 1", "url": "https://…", "description": "what it provided" }
454
+ ],
455
+ "tags": ["research", "<topic>"],
456
+ "related": ["[[<related-spec>]]"]
457
+ }
458
+ \`\`\`
459
+
460
+ **For \`external\` / \`all\` scope, \`## Executive Summary\` and a non-empty \`## Sources\` (≥1 cited \`http(s)\` URL) are REQUIRED** — \`heyspex_complete_research\` rejects an empty \`sources\` array for these scopes (\`MISSING_SOURCES\`). Web-validated research must cite its web sources.
461
+
462
+ The MCP tool fills in \`source_idea_id\` and \`scope\` from the pin. It also:
463
+
464
+ - Allocates a fresh \`HS-RES-<suffix>\` id
465
+ - Writes \`specs/research/HS-RES-<id>-<slug>.md\` with \`type: research\`, \`status: open\`, the \`Executive Summary\`, \`Findings\`, \`Sources\`, and \`Provenance\` sections
466
+ - Sets the source idea's \`promoted_to: "[[HS-RES-<id>]]"\` and \`updated\`
467
+ - Deletes \`.heyspex/research-focus.yaml\`
468
+
469
+ ## Hard prohibitions
470
+
471
+ - **Never** write \`Executive Summary\` / \`Findings\` / \`Sources\` onto the idea file
472
+ - **Never** flip the idea's \`status\` to \`closed\` — \`promoted_to\` is the signal
473
+ - **Never** delete the idea file
474
+ - **Never** call \`heyspex_complete_research\` until the user confirms scope (beat 1)
475
+ `;
476
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * HeySpex research-resolution MCP tools — the agent face over the research policy knobs
3
+ * (agent_behaviors.research in .heyspex/config.yaml) and the interactive Firecrawl bridge,
4
+ * mounted in cli.mjs as:
5
+ * - heyspex_resolve_research_plan (read — open) — Part A's authoritative resolver
6
+ * - heyspex_firecrawl_research (read/network) — Part B's reachability bridge
7
+ *
8
+ * Fixes [[HS-LFUH-DIAG-W1YE]] (interactive research path silently downgrades
9
+ * `default_source: firecrawl-self-hosted` to web-native — never injected, no Firecrawl tool):
10
+ * - `heyspex_resolve_research_plan` is the CAN'T-GO-STALE-MID-SESSION source of truth for
11
+ * "which research method/backend applies right now" — the hook injection in
12
+ * scripts/claude-hooks/_intent-classifier-router.mjs is only a best-effort snapshot hint;
13
+ * this tool is what an agent calls to actually confirm it. It ONLY reads the configured
14
+ * plan (research doc's full profile/fallback/capability-gating resolution is explicitly
15
+ * out of scope — that is [[HS-X5GJ-RES-EU6J]], a separate feature built on top of this fix).
16
+ * - `heyspex_firecrawl_research` is the on-demand execution surface that makes the configured
17
+ * Firecrawl backend REACHABLE from an interactive turn — it is a thin MCP wrapper around
18
+ * dev-factory/dream-research/interactive-firecrawl-tool.mjs, which itself wraps the
19
+ * existing, self-tested default-backends.mjs/firecrawl-adapter.mjs implementation (no
20
+ * search/scrape logic is reimplemented here).
21
+ *
22
+ * Pattern source: settings-tools.mjs (pure-logic execute* functions; cli.mjs wraps).
23
+ */
24
+
25
+ import { listConfigLeaves } from './config-settings.mjs';
26
+ // The deep-research backend is loaded LAZILY through the bridge, never imported
27
+ // statically — a static import here pulls 25 SpexFleet / dream-research files into
28
+ // the MCP server's package closure. See dev-factory-bridge.mjs for the full
29
+ // rationale ([[HS-ZJSP-0JNK-TASK-ship-the-heyspex-mcp-server-as-an]]).
30
+ import { loadDreamResearch } from './dev-factory-bridge.mjs';
31
+
32
+ const RESEARCH_PREFIX = 'agent_behaviors.research.';
33
+
34
+ /**
35
+ * Resolve the CONFIGURED research plan from .heyspex/config.yaml — the authoritative,
36
+ * live-read source of truth (never a stale hook-injection snapshot). Read-only; scope is
37
+ * deliberately limited to reading the three existing knobs
38
+ * (default_method/default_agent/default_source) plus the resolved endpoint for a Firecrawl
39
+ * source — NOT the resource-profile/fallback-chain/capability-gating resolution the research
40
+ * doc's separate feature will add later.
41
+ */
42
+ export async function executeResolveResearchPlan(vaultRoot) {
43
+ const leaves = listConfigLeaves(vaultRoot).filter((s) => s.key.startsWith(RESEARCH_PREFIX));
44
+ const cfg = Object.fromEntries(leaves.map((s) => [s.key.slice(RESEARCH_PREFIX.length), s.value]));
45
+
46
+ const method = cfg.default_method ?? 'ladder';
47
+ const defaultAgent = cfg.default_agent ?? 'deepseek';
48
+ const defaultSource = cfg.default_source ?? 'web-native';
49
+
50
+ let endpoint = null;
51
+ if (defaultSource === 'firecrawl-self-hosted' || defaultSource === 'firecrawl-hosted') {
52
+ try {
53
+ const backend = await loadDreamResearch();
54
+ endpoint = backend.available ? backend.getBaseUrl() : null;
55
+ } catch {
56
+ endpoint = null; // fail-safe — an unreadable env/adapter must never break this read
57
+ }
58
+ }
59
+
60
+ const note =
61
+ defaultSource === 'firecrawl-self-hosted' || defaultSource === 'firecrawl-hosted'
62
+ ? 'For RUNG 0 `sequential`, call heyspex_firecrawl_research({ mode: "search"|"scrape", query|url }) ' +
63
+ 'to honor this backend instead of native WebSearch/WebFetch. It respects the existing availability ' +
64
+ 'gates (Ollama-XOR-Firecrawl lease for self-hosted / the attended-daytime credit gate for hosted) and ' +
65
+ 'reports an explicit { downgraded: true, reason } if the backend is unavailable — never substitute ' +
66
+ 'web-native silently. IMPORTANT (research-routing-hard-rule-sonnet, "Sonnet is the fallback, ' +
67
+ 'Firecrawl is the default"): once the fetch succeeds, hand the gathered text to DeepSeek for ' +
68
+ 'synthesis (dev-factory/dream-research/synthesize-cli.mjs, or the `hs-res` skill for anything ' +
69
+ 'beyond a single lookup) — do NOT reason over/summarize the results yourself. Sonnet doing its ' +
70
+ 'own synthesis is the fallback branch only, used after an explicit unreachability report and ' +
71
+ 'never silently.'
72
+ : 'default_source is web-native — native WebSearch/WebFetch already honors the configured default; no ' +
73
+ 'Firecrawl bridge call is needed.';
74
+
75
+ return {
76
+ ok: true,
77
+ method,
78
+ default_agent: defaultAgent,
79
+ default_source: defaultSource,
80
+ resource: { source: defaultSource, endpoint },
81
+ note,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Run one Firecrawl search or scrape for an interactive agent turn (rung 0 `sequential`) —
87
+ * the on-demand execution surface for whatever heyspex_resolve_research_plan resolved.
88
+ * Delegates entirely to dev-factory/dream-research/interactive-firecrawl-tool.mjs; this
89
+ * function only maps the MCP snake_case args onto that module's contract.
90
+ */
91
+ export async function executeFirecrawlResearch(args = {}) {
92
+ const backend = await loadDreamResearch();
93
+ if (!backend.available) {
94
+ // Explicit downgrade, never a silent substitution — same contract the
95
+ // underlying tool uses when a backend is gated off.
96
+ return {
97
+ ok: false,
98
+ error: 'backend_unavailable',
99
+ downgraded: true,
100
+ reason: backend.reason,
101
+ };
102
+ }
103
+ return backend.runFirecrawlResearchTool({
104
+ mode: args.mode,
105
+ query: args.query,
106
+ url: args.url,
107
+ spec_id: args.spec_id,
108
+ });
109
+ }