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
+ * Ingest external / proprietary plan documents into HeySpex vault specs.
3
+ * Cursor plans live under ~/.cursor/plans/ (or repo .cursor/plans/) and are not
4
+ * portable to Claude Code — vault specs must own canonical plan content.
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, basename, isAbsolute, normalize } from 'node:path';
10
+
11
+ const CANONICAL_SECTION_HEADING = '## Canonical implementation plan (HeySpex)';
12
+ const CANONICAL_SECTION_RE =
13
+ /## Canonical implementation plan \(HeySpex\)[\s\S]*?(?=\n## Change log|\n## NEXT STEP:|\n## NEXT STEP\s|$)/i;
14
+
15
+ /** @typedef {{ ref: string, resolvedPath: string | null, label: string }} PlanRef */
16
+
17
+ /**
18
+ * Detect external plan path references in markdown.
19
+ * @param {string} markdown
20
+ * @returns {PlanRef[]}
21
+ */
22
+ export function extractExternalPlanRefs(markdown) {
23
+ if (!markdown?.trim()) return [];
24
+
25
+ const refs = [];
26
+ const seen = new Set();
27
+
28
+ const add = (ref, label = basename(ref)) => {
29
+ const normalized = ref.replace(/\\/g, '/').trim();
30
+ if (!normalized || seen.has(normalized)) return;
31
+ if (normalized.includes('*')) return; // prose globs like .cursor/plans/*.plan.md
32
+ if (!/\.plan\.md$/i.test(normalized) && !/\.cursor\/plans\//i.test(normalized)) return;
33
+ seen.add(normalized);
34
+ refs.push({ ref: normalized, resolvedPath: null, label });
35
+ };
36
+
37
+ // Backtick paths
38
+ for (const m of markdown.matchAll(/`([^`\n]+\.plan\.md)`/gi)) {
39
+ add(m[1]);
40
+ }
41
+
42
+ // Bold labels: **Plan:** `path`
43
+ for (const m of markdown.matchAll(
44
+ /\*\*(?:Full plan|Plan|Layout plan|Implementation plan[^*]*)\*\*:?\s*`([^`]+)`/gi,
45
+ )) {
46
+ add(m[1]);
47
+ }
48
+
49
+ // Implementation plan line with middle dot separators
50
+ for (const m of markdown.matchAll(
51
+ /\*\*Implementation plan \(Cursor\):\*\*\s*`([^`]+)`/gi,
52
+ )) {
53
+ add(m[1]);
54
+ }
55
+
56
+ return refs;
57
+ }
58
+
59
+ /**
60
+ * Resolve a plan reference to an absolute file path.
61
+ * @param {string} vaultRoot
62
+ * @param {string} ref
63
+ * @returns {string | null}
64
+ */
65
+ export function resolvePlanPath(vaultRoot, ref) {
66
+ const cleaned = ref.replace(/\\/g, '/').trim();
67
+ const candidates = [];
68
+
69
+ if (isAbsolute(cleaned)) {
70
+ candidates.push(normalize(cleaned));
71
+ } else {
72
+ candidates.push(normalize(join(vaultRoot, cleaned)));
73
+
74
+ const planBasename = basename(cleaned);
75
+ if (cleaned.includes('.cursor/plans/') || cleaned.startsWith('.cursor/plans/')) {
76
+ candidates.push(normalize(join(homedir(), '.cursor', 'plans', planBasename)));
77
+ candidates.push(normalize(join(vaultRoot, '.cursor', 'plans', planBasename)));
78
+ }
79
+ }
80
+
81
+ for (const p of candidates) {
82
+ if (existsSync(p)) return p;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Strip Cursor plan YAML frontmatter (--- name/overview/todos ---).
89
+ * @param {string} raw
90
+ * @returns {string}
91
+ */
92
+ export function stripPlanFrontmatter(raw) {
93
+ if (!raw.startsWith('---')) return raw.trim();
94
+ const end = raw.indexOf('\n---', 3);
95
+ if (end === -1) return raw.trim();
96
+ return raw.slice(end + 4).trim();
97
+ }
98
+
99
+ /**
100
+ * @param {string} resolvedPath
101
+ * @param {string} ref
102
+ * @returns {{ content: string, sourceLabel: string }}
103
+ */
104
+ function readPlanFile(resolvedPath, ref) {
105
+ const raw = readFileSync(resolvedPath, 'utf8');
106
+ return {
107
+ content: stripPlanFrontmatter(raw),
108
+ sourceLabel: basename(ref) || basename(resolvedPath),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Build the canonical ingested section for vault specs.
114
+ * @param {Array<{ sourceLabel: string, content: string, resolvedPath: string }>} plans
115
+ * @param {string} [ingestedAt]
116
+ */
117
+ export function buildCanonicalPlanSection(plans, ingestedAt = new Date().toISOString().slice(0, 10)) {
118
+ if (!plans.length) return '';
119
+
120
+ const lines = [
121
+ CANONICAL_SECTION_HEADING,
122
+ '',
123
+ `> **Ingested:** ${ingestedAt}. Treat **this section** as the portable source of truth.`,
124
+ `> External paths (e.g. \`.cursor/plans/*.plan.md\`) are provenance only — Claude Code and other agents may not read them.`,
125
+ '',
126
+ ];
127
+
128
+ for (const plan of plans) {
129
+ lines.push(`### Source: ${plan.sourceLabel}`, '');
130
+ if (plan.resolvedPath) {
131
+ lines.push(`_Provenance: \`${plan.resolvedPath.replace(/\\/g, '/')}\`_`, '');
132
+ }
133
+ lines.push(plan.content.trim(), '', '---', '');
134
+ }
135
+
136
+ return lines.join('\n').trimEnd();
137
+ }
138
+
139
+ /**
140
+ * Remove dangling external-only plan pointer lines (replaced by canonical section).
141
+ * @param {string} markdown
142
+ */
143
+ export function stripExternalPlanPointers(markdown) {
144
+ let out = markdown;
145
+ const patterns = [
146
+ /^\*\*(?:Full plan|Plan|Layout plan)\*\*:?\s*`[^`\n]+\.plan\.md`.*\n/gm,
147
+ /^\*\*Implementation plan \(Cursor\):\*\*[^\n]*\n/gm,
148
+ /^> \*\*Layout plan:\*\*[^\n]*\n/gm,
149
+ ];
150
+ for (const re of patterns) {
151
+ out = out.replace(re, '');
152
+ }
153
+ return out.replace(/\n{3,}/g, '\n\n').trim();
154
+ }
155
+
156
+ /**
157
+ * Replace external plan refs in text with vault-local pointer.
158
+ * @param {string} markdown
159
+ */
160
+ export function rewritePlanRefsToCanonical(markdown) {
161
+ let out = markdown;
162
+ out = out.replace(
163
+ /`([^`\n]*\.cursor\/plans\/[^`]+\.plan\.md)`/gi,
164
+ '[[#canonical-implementation-plan-heyspex]] (in this spec)',
165
+ );
166
+ out = out.replace(
167
+ /\*\*(?:Full plan|Plan)\*\*:?\s*`[^`]+`/gi,
168
+ '**Canonical plan:** see ## Canonical implementation plan (HeySpex) in this spec',
169
+ );
170
+ return out;
171
+ }
172
+
173
+ /**
174
+ * @param {string} vaultRoot
175
+ * @param {string} markdown
176
+ * @param {{ extraPaths?: string[] }} [options]
177
+ */
178
+ export function resolveAndReadPlans(vaultRoot, markdown, options = {}) {
179
+ const refs = extractExternalPlanRefs(markdown);
180
+ const extraPaths = options.extraPaths || [];
181
+ const byRef = new Map();
182
+
183
+ for (const r of refs) {
184
+ const resolved = resolvePlanPath(vaultRoot, r.ref);
185
+ byRef.set(r.ref, { ...r, resolvedPath: resolved });
186
+ }
187
+
188
+ for (const p of extraPaths) {
189
+ const ref = p.replace(/\\/g, '/');
190
+ if (!byRef.has(ref)) {
191
+ byRef.set(ref, {
192
+ ref,
193
+ resolvedPath: resolvePlanPath(vaultRoot, ref),
194
+ label: basename(ref),
195
+ });
196
+ }
197
+ }
198
+
199
+ /** @type {Array<{ sourceLabel: string, content: string, resolvedPath: string, ref: string }>} */
200
+ const ingested = [];
201
+ /** @type {Array<{ ref: string, resolvedPath: string | null }>} */
202
+ const missing = [];
203
+
204
+ for (const entry of byRef.values()) {
205
+ if (!entry.resolvedPath) {
206
+ missing.push({ ref: entry.ref, resolvedPath: null });
207
+ continue;
208
+ }
209
+ try {
210
+ const { content, sourceLabel } = readPlanFile(entry.resolvedPath, entry.ref);
211
+ ingested.push({
212
+ sourceLabel,
213
+ content,
214
+ resolvedPath: entry.resolvedPath,
215
+ ref: entry.ref,
216
+ });
217
+ } catch {
218
+ missing.push({ ref: entry.ref, resolvedPath: entry.resolvedPath });
219
+ }
220
+ }
221
+
222
+ return { ingested, missing, refs: [...byRef.values()] };
223
+ }
224
+
225
+ /**
226
+ * Insert canonical plan before change log / NEXT STEP tail sections.
227
+ * @param {string} body
228
+ * @param {string} section
229
+ */
230
+ function insertCanonicalBeforeTailSections(body, section) {
231
+ const markers = ['\n## Change log', '\n## NEXT STEP'];
232
+ for (const marker of markers) {
233
+ const idx = body.indexOf(marker);
234
+ if (idx !== -1) {
235
+ return `${body.slice(0, idx).trim()}\n\n${section}\n${body.slice(idx)}`;
236
+ }
237
+ }
238
+ return `${body.trim()}\n\n${section}\n`;
239
+ }
240
+
241
+ /**
242
+ * Merge external plans into vault markdown (epic body, task body, or prompt).
243
+ * @param {string} vaultRoot
244
+ * @param {string} markdown
245
+ * @param {{ extraPaths?: string[], force?: boolean }} [options]
246
+ */
247
+ export function ingestPlansIntoMarkdown(vaultRoot, markdown, options = {}) {
248
+ const hasCanonical = CANONICAL_SECTION_RE.test(markdown);
249
+ if (hasCanonical && !options.force) {
250
+ return {
251
+ markdown,
252
+ ingested: [],
253
+ missing: [],
254
+ already_canonical: true,
255
+ changed: false,
256
+ };
257
+ }
258
+
259
+ const { ingested, missing } = resolveAndReadPlans(vaultRoot, markdown, {
260
+ extraPaths: options.extraPaths,
261
+ });
262
+
263
+ if (ingested.length === 0) {
264
+ return { markdown, ingested, missing, already_canonical: false, changed: false };
265
+ }
266
+
267
+ let body = stripExternalPlanPointers(markdown);
268
+ body = rewritePlanRefsToCanonical(body);
269
+
270
+ if (hasCanonical && options.force) {
271
+ body = body.replace(CANONICAL_SECTION_RE, '').trim();
272
+ }
273
+
274
+ const section = buildCanonicalPlanSection(ingested);
275
+ const markdownOut = insertCanonicalBeforeTailSections(body, section);
276
+
277
+ return {
278
+ markdown: markdownOut,
279
+ ingested,
280
+ missing,
281
+ already_canonical: false,
282
+ changed: true,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Prepare epic + implementation prompt for vault write (hs-push / hs-push-next).
288
+ * @param {string} vaultRoot
289
+ * @param {{ plan_markdown?: string, implementation_prompt?: string, source_plan_paths?: string[] }} args
290
+ */
291
+ export function prepareVaultPlanContent(vaultRoot, args) {
292
+ const extraPaths = args.source_plan_paths || [];
293
+
294
+ const planResult = ingestPlansIntoMarkdown(vaultRoot, args.plan_markdown || '', { extraPaths });
295
+ const promptSource = args.implementation_prompt || '';
296
+ const promptIngest = ingestPlansIntoMarkdown(vaultRoot, promptSource, { extraPaths });
297
+
298
+ /** @type {typeof planResult.ingested} */
299
+ const allIngested = [];
300
+ const seenPaths = new Set();
301
+ for (const item of [...planResult.ingested, ...promptIngest.ingested]) {
302
+ if (seenPaths.has(item.resolvedPath)) continue;
303
+ seenPaths.add(item.resolvedPath);
304
+ allIngested.push(item);
305
+ }
306
+
307
+ const missing = [...planResult.missing, ...promptIngest.missing].filter(
308
+ (m, i, arr) => arr.findIndex((x) => x.ref === m.ref) === i,
309
+ );
310
+
311
+ let planMarkdown = planResult.markdown;
312
+ let implementationPrompt = promptIngest.markdown;
313
+
314
+ // If plans only appeared in prompt, append canonical section to epic body
315
+ if (allIngested.length > 0 && !CANONICAL_SECTION_RE.test(planMarkdown)) {
316
+ const section = buildCanonicalPlanSection(allIngested);
317
+ planMarkdown = insertCanonicalBeforeTailSections(planMarkdown, section);
318
+ }
319
+
320
+ if (allIngested.length > 0) {
321
+ implementationPrompt = rewritePlanRefsToCanonical(
322
+ stripExternalPlanPointers(implementationPrompt),
323
+ );
324
+ if (!/canonical implementation plan/i.test(implementationPrompt)) {
325
+ implementationPrompt = `${implementationPrompt.trim()}\n\n**Canonical plan:** Full detail is in the epic spec under ## Canonical implementation plan (HeySpex).\n`;
326
+ }
327
+ }
328
+
329
+ return {
330
+ plan_markdown: planMarkdown,
331
+ implementation_prompt: implementationPrompt,
332
+ ingested_plans: allIngested.map((p) => ({
333
+ source: p.sourceLabel,
334
+ path: p.resolvedPath.replace(/\\/g, '/'),
335
+ })),
336
+ missing_plans: missing,
337
+ plans_ingested: allIngested.length > 0,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Extract canonical plan section from spec body for hs-next prompts.
343
+ * @param {string} body
344
+ * @returns {string | null}
345
+ */
346
+ export function extractCanonicalPlanSection(body) {
347
+ const m = body.match(CANONICAL_SECTION_RE);
348
+ return m ? m[0].trim() : null;
349
+ }
350
+
351
+ /**
352
+ * Enrich agent prompt with canonical + resolvable external plan content.
353
+ * @param {string} vaultRoot
354
+ * @param {string} specRelPath
355
+ * @param {string} specBody
356
+ * @param {string} promptMarkdown
357
+ * @param {{ persist?: boolean }} [options]
358
+ */
359
+ export function hydratePromptWithPlans(vaultRoot, specRelPath, specBody, promptMarkdown, options = {}) {
360
+ const canonical = extractCanonicalPlanSection(specBody);
361
+ const { ingested, missing } = resolveAndReadPlans(
362
+ vaultRoot,
363
+ `${specBody}\n\n${promptMarkdown}`,
364
+ );
365
+
366
+ const parts = [promptMarkdown.trim()];
367
+
368
+ if (canonical) {
369
+ parts.push('', '---', '', canonical);
370
+ } else if (ingested.length > 0) {
371
+ parts.push(
372
+ '',
373
+ '---',
374
+ '',
375
+ '> **Plan ingest (runtime):** External plan file(s) were resolved for this session. Re-run **hs-push-next** or persist with `persist_plan_ingest: true` on `heyspex_next_on_deck` to write into the vault spec.',
376
+ '',
377
+ buildCanonicalPlanSection(ingested),
378
+ );
379
+ }
380
+
381
+ if (missing.length > 0) {
382
+ parts.push(
383
+ '',
384
+ '---',
385
+ '',
386
+ '### ⚠️ Unresolved plan sources',
387
+ '',
388
+ ...missing.map(
389
+ (m) => `- \`${m.ref}\` — not found (checked vault root and ~/.cursor/plans/)`,
390
+ ),
391
+ );
392
+ }
393
+
394
+ let persistResult = null;
395
+ if (options.persist && ingested.length > 0 && !canonical) {
396
+ const merged = ingestPlansIntoMarkdown(vaultRoot, specBody, { force: false });
397
+ if (merged.changed) {
398
+ const fullPath = join(vaultRoot, specRelPath);
399
+ writeFileSync(fullPath, patchSpecBody(fullPath, merged.markdown), 'utf8');
400
+ persistResult = { spec_path: specRelPath, written: true };
401
+ }
402
+ }
403
+
404
+ return {
405
+ prompt_markdown: parts.filter(Boolean).join('\n'),
406
+ plans_in_prompt: ingested.map((p) => p.sourceLabel),
407
+ missing_plans: missing,
408
+ had_canonical_in_spec: Boolean(canonical),
409
+ persist_result: persistResult,
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Replace spec body while preserving frontmatter.
415
+ * @param {string} filePath
416
+ * @param {string} newBody
417
+ */
418
+ function patchSpecBody(filePath, newBody) {
419
+ const raw = readFileSync(filePath, 'utf8');
420
+ const fmEnd = raw.indexOf('\n---', 3);
421
+ if (!raw.startsWith('---') || fmEnd === -1) {
422
+ return newBody;
423
+ }
424
+ const frontmatter = raw.slice(0, fmEnd + 4);
425
+ return `${frontmatter}\n\n${newBody.trim()}\n`;
426
+ }
427
+
428
+ /**
429
+ * Ingest plans into an on-disk spec file (repair / hs-next persist).
430
+ * @param {string} vaultRoot
431
+ * @param {string} specRelPath
432
+ */
433
+ export function ingestPlansIntoSpecFile(vaultRoot, specRelPath) {
434
+ const fullPath = join(vaultRoot, specRelPath);
435
+ if (!existsSync(fullPath)) {
436
+ return { error: 'SPEC_NOT_FOUND', message: specRelPath };
437
+ }
438
+ const raw = readFileSync(fullPath, 'utf8');
439
+ const fmEnd = raw.indexOf('\n---', 3);
440
+ const body = fmEnd === -1 ? raw : raw.slice(fmEnd + 4);
441
+ const result = ingestPlansIntoMarkdown(vaultRoot, body, { force: false });
442
+ if (!result.changed) {
443
+ return {
444
+ spec_path: specRelPath,
445
+ changed: false,
446
+ ingested_plans: result.ingested,
447
+ missing_plans: result.missing,
448
+ already_canonical: result.already_canonical,
449
+ };
450
+ }
451
+ writeFileSync(fullPath, patchSpecBody(fullPath, result.markdown), 'utf8');
452
+ return {
453
+ spec_path: specRelPath,
454
+ changed: true,
455
+ ingested_plans: result.ingested.map((p) => ({
456
+ source: p.sourceLabel,
457
+ path: p.resolvedPath.replace(/\\/g, '/'),
458
+ })),
459
+ missing_plans: result.missing,
460
+ };
461
+ }
462
+
463
+ // ── Minimal self-test: node vendor/heyspex-mcp/plan-ingest.mjs
464
+ function runSelfTest() {
465
+ const refs = extractExternalPlanRefs(
466
+ '**Plan:** `.cursor/plans/foo.plan.md` and `C:\\Users\\x\\.cursor\\plans\\bar.plan.md`',
467
+ );
468
+ console.assert(refs.length >= 1, 'extract refs');
469
+ const stripped = stripPlanFrontmatter('---\nname: x\n---\n\n# Body\n');
470
+ console.assert(stripped.startsWith('# Body'), 'strip frontmatter');
471
+ console.log('plan-ingest.mjs: ok');
472
+ }
473
+
474
+ if (process.argv[1]?.endsWith('plan-ingest.mjs')) {
475
+ runSelfTest();
476
+ }