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,1553 @@
1
+ /**
2
+ * /hs-promote-research MCP tool — research → milestone promotion with
3
+ * AI-assisted epic stubs. Spec: [[HS-TASK-QQHP]] of [[HS-EPIC-M2J3]].
4
+ *
5
+ * Two-phase flow driven by presence/absence of `accepted_stubs`:
6
+ *
7
+ * Phase 1 (review): caller invokes with `intent` ∈ {spawn, both} and no
8
+ * `accepted_stubs`. Tool runs the AI proposer (console `/api/ai/
9
+ * propose-epic-stubs`), returns the stubs to the agent for the human's
10
+ * confirmation step. No file writes.
11
+ *
12
+ * Phase 2 (execute): caller invokes again with `accepted_stubs` populated.
13
+ * Tool performs the transactional write set (milestone create-or-update,
14
+ * epic-stub files, research mutation, change-log rows) and rolls back
15
+ * anything written if a later step fails.
16
+ *
17
+ * `intent: "augment"` is single-phase (no AI call — just appends the research
18
+ * to the chosen epics' `enhances_epics:` reverse pointer on the research
19
+ * side).
20
+ *
21
+ * Linkage written per [[HS-TASK-YJX6]]:
22
+ * research.enhances : "[[HS-MIL-…]]" (single)
23
+ * research.spawned_epics : array of [[…]] (new epics)
24
+ * research.enhances_epics : array of [[…]] (augmented epics)
25
+ * (research.status stays `open` — research is evergreen; [[research-is-evergreen]].
26
+ * The `enhances` link, not a status flip, is the "already promoted" signal.)
27
+ * milestone.informed_by : appended with research wikilink
28
+ * milestone.epics : appended with each new epic wikilink
29
+ * new epic.parent : "[[HS-MIL-…]]"
30
+ * new epic.milestone : "[[HS-MIL-…]]"
31
+ * new epic.derived_from : "[[HS-RES-…]]"
32
+ *
33
+ * All writes are staged then committed; any failure rolls back the prior
34
+ * writes (created files deleted, mutated files restored from snapshot).
35
+ */
36
+
37
+ import {
38
+ readFileSync,
39
+ existsSync,
40
+ readdirSync,
41
+ mkdirSync,
42
+ writeFileSync,
43
+ } from 'node:fs';
44
+ import { createHash } from 'node:crypto';
45
+ import { join } from 'node:path';
46
+ import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
47
+ import { yamlScalar } from './yaml-scalar.mjs';
48
+ import {
49
+ matchCaseInsensitiveExact as sharedMatchCaseInsensitiveExact,
50
+ normalizeSourceRef,
51
+ resolveBreakdownSource,
52
+ } from './breakdown-source.mjs';
53
+ import { allocateFileKey, collectOccupiedKeysEitherKind, parseSpecId } from './spec-id-lib.mjs';
54
+ import {
55
+ appendEpicToMilestone,
56
+ normalizeMilestoneBasename,
57
+ collectOccupiedVaultIds,
58
+ } from './plan-push.mjs';
59
+ import { registerSpec } from './register-spec.mjs';
60
+ import {
61
+ isoNow,
62
+ todayDate,
63
+ deriveSlug,
64
+ randomSuffix,
65
+ findFileByPrefix,
66
+ getFrontmatterBlock,
67
+ getScalarField,
68
+ parseWikilinkBasename,
69
+ parseListFieldBasenames,
70
+ setScalarField,
71
+ appendToListField,
72
+ bumpUpdated,
73
+ appendChangeLogRow,
74
+ commitWrites,
75
+ } from './promotion-helpers.mjs';
76
+
77
+ /** Milestone statuses that allow attaching spawned epics without an override. */
78
+ export const MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES = ['open'];
79
+
80
+ /** In-progress attach is owner-gated — requires explicit override + reason. */
81
+ export const MILESTONE_ATTACH_IN_PROGRESS_STATUS = 'in_progress';
82
+ // Research-id validation is resolution-first: any string that resolves to a
83
+ // file under specs/research/ is accepted, and the type gate then reads that
84
+ // file's `type:` frontmatter. No id-shape pattern-match is applied to the
85
+ // caller's string ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]).
86
+
87
+ /**
88
+ * Allocate a fresh HS-MIL-XXXX id by scanning specs/milestones/ for the
89
+ * existing set. vault-id-lib doesn't currently support milestone (the only
90
+ * other site that allocates HS-MIL-* ids is by hand) so we keep this local
91
+ * to avoid widening the shared lib until a second caller appears.
92
+ */
93
+ function allocateMilestoneId(vaultRoot, options = {}) {
94
+ const occupied = new Set();
95
+ const dir = join(vaultRoot, 'specs', 'milestones');
96
+ // Normalize milestone ids/basenames of EITHER ordering to canonical
97
+ // family-first so collision detection works mid-migration ([[HS-EPIC-DLE3]]).
98
+ const addMil = (value) => {
99
+ const p = parseSpecId(value);
100
+ if (p && p.typeLabel === 'MIL') occupied.add(`HS-${p.familyKey}-MIL`);
101
+ };
102
+ if (existsSync(dir)) {
103
+ for (const file of readdirSync(dir)) {
104
+ if (!file.endsWith('.md')) continue;
105
+ addMil(file.replace(/\.md$/i, ''));
106
+ try {
107
+ const raw = readFileSync(join(dir, file), 'utf8');
108
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
109
+ if (idMatch) addMil(idMatch[1].replace(/^['"]|['"]$/g, ''));
110
+ } catch {
111
+ /* skip */
112
+ }
113
+ }
114
+ }
115
+ const randomUnit = options.randomUnit ?? Math.random;
116
+ let length = 4;
117
+ while (true) {
118
+ for (let attempt = 0; attempt < 10_000; attempt++) {
119
+ const id = `HS-${randomSuffix(length, randomUnit)}-MIL`; // family-first
120
+ if (!occupied.has(id)) return id;
121
+ }
122
+ length++;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Normalize a caller-supplied research reference to a bare id/basename.
128
+ *
129
+ * Thin alias over the shared breakdown-source normalizer ([[HS-K1TR]]) — kept as a
130
+ * named export because callers and tests reference it by this name.
131
+ */
132
+ export function normalizeResearchRef(raw) {
133
+ return normalizeSourceRef(raw);
134
+ }
135
+
136
+ /**
137
+ * Pure matching logic for resolution step 2 (case-insensitive exact basename),
138
+ * re-exported from the shared resolver so the research path and the diagnose path
139
+ * cannot drift on it.
140
+ */
141
+ function matchCaseInsensitiveExact(files, ref) {
142
+ return sharedMatchCaseInsensitiveExact(files, ref);
143
+ }
144
+
145
+ /**
146
+ * Resolve + type-check a RESEARCH reference.
147
+ *
148
+ * The four-step resolution ladder now lives in `breakdown-source.mjs` — one door
149
+ * for a research doc or a bug write-up ([[HS-ALBV]] §Scope "one stage, two inputs").
150
+ * This wrapper pins the search to `research` and maps the shared error codes back
151
+ * to the research-specific ones the promote-research contract already publishes.
152
+ */
153
+ function findResearchByIdLocal(vaultRoot, researchId) {
154
+ const resolved = resolveBreakdownSource(vaultRoot, researchId, { kinds: ['research'] });
155
+ if (!resolved.error) return { record: resolved.record };
156
+ switch (resolved.error) {
157
+ case 'INVALID_ARGS':
158
+ return { error: 'INVALID_ARGS', message: 'research_id is required' };
159
+ case 'SOURCE_ID_AMBIGUOUS':
160
+ return {
161
+ error: 'RESEARCH_ID_AMBIGUOUS',
162
+ message: resolved.message.replace(String(researchId), `research_id ${researchId}`),
163
+ candidates: resolved.candidates,
164
+ };
165
+ case 'SOURCE_TYPE_MISMATCH':
166
+ return {
167
+ error: 'RESEARCH_TYPE_MISMATCH',
168
+ message: `research_id ${researchId} resolved to ${resolved.resolved_basename ? `specs/research/${resolved.resolved_basename}.md` : 'a file'}, which is \`type: ${resolved.resolved_type}\` — not a research doc`,
169
+ resolved_basename: resolved.resolved_basename,
170
+ resolved_type: resolved.resolved_type,
171
+ };
172
+ default:
173
+ return {
174
+ error: 'RESEARCH_NOT_FOUND',
175
+ message: `research ${researchId} not found under specs/research/ (tried exact basename, frontmatter id:, and id-prefix match)`,
176
+ };
177
+ }
178
+ }
179
+
180
+ function findMilestoneByBasename(vaultRoot, basename) {
181
+ const absPath = join(vaultRoot, 'specs', 'milestones', `${basename}.md`);
182
+ if (!existsSync(absPath)) return null;
183
+ return {
184
+ basename,
185
+ absPath,
186
+ relPath: join('specs', 'milestones', `${basename}.md`),
187
+ raw: readFileSync(absPath, 'utf8'),
188
+ };
189
+ }
190
+
191
+ function findMilestoneById(vaultRoot, milestoneId) {
192
+ const dir = join(vaultRoot, 'specs', 'milestones');
193
+ if (!existsSync(dir)) return null;
194
+ for (const file of readdirSync(dir)) {
195
+ if (!file.endsWith('.md')) continue;
196
+ if (file.startsWith(`${milestoneId}-`)) {
197
+ const basename = file.replace(/\.md$/, '');
198
+ return findMilestoneByBasename(vaultRoot, basename);
199
+ }
200
+ }
201
+ return null;
202
+ }
203
+
204
+ function findEpicByIdOrBasename(vaultRoot, idOrBasename) {
205
+ const dir = join(vaultRoot, 'specs', 'epics');
206
+ if (!existsSync(dir)) return null;
207
+ if (/\.md$/.test(idOrBasename)) idOrBasename = idOrBasename.replace(/\.md$/, '');
208
+ // Try as basename first (HS-EPIC-XXXX-slug)
209
+ const directAbs = join(dir, `${idOrBasename}.md`);
210
+ if (existsSync(directAbs)) {
211
+ return {
212
+ basename: idOrBasename,
213
+ absPath: directAbs,
214
+ relPath: join('specs', 'epics', `${idOrBasename}.md`),
215
+ raw: readFileSync(directAbs, 'utf8'),
216
+ };
217
+ }
218
+ // Try as id prefix (HS-EPIC-XXXX)
219
+ for (const file of readdirSync(dir)) {
220
+ if (!file.endsWith('.md')) continue;
221
+ if (file.startsWith(`${idOrBasename}-`)) {
222
+ const basename = file.replace(/\.md$/, '');
223
+ const absPath = join(dir, file);
224
+ return {
225
+ basename,
226
+ absPath,
227
+ relPath: join('specs', 'epics', file),
228
+ raw: readFileSync(absPath, 'utf8'),
229
+ };
230
+ }
231
+ }
232
+ return null;
233
+ }
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // Pillar resolution (epic→pillar hard requirement, [[pillar-assignment-protocol]])
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ //
239
+ // `POST /api/specs` / `heyspex_file_spec` hard-block a `type: epic` whose
240
+ // `pillar:` is missing or does not resolve to an `HS-PILR-*` doc on disk
241
+ // (`400 pillar_required`, listing existing pillars). Spawned epics here write
242
+ // files directly and so bypass that filing-time gate — this mirrors the same
243
+ // contract inside the execute phase, checked BEFORE any write (HS-TASK-QQHP
244
+ // defect #2).
245
+
246
+ /** Strip quotes/wikilink wrapping from a caller-supplied pillar reference. */
247
+ function normalizePillarRef(raw) {
248
+ if (typeof raw !== 'string') return null;
249
+ let value = raw.trim();
250
+ if (!value) return null;
251
+ const wikilink = value.match(/^\[\[([^\]]+)\]\]$/);
252
+ if (wikilink) value = wikilink[1].trim();
253
+ value = value.replace(/^['"]|['"]$/g, '').trim();
254
+ // Strip a trailing `.md` (mirrors normalizeResearchRef) so "HS-PILR-x.md" /
255
+ // "[[HS-PILR-x.md]]" resolve against specs/pillars/HS-PILR-x.md instead of
256
+ // missing on a doubled ".md.md" join.
257
+ value = value.replace(/\.md$/i, '').trim();
258
+ return value || null;
259
+ }
260
+
261
+ /** Every pillar doc that exists under specs/pillars/ in the target vault. */
262
+ export function listVaultPillars(vaultRoot) {
263
+ const dir = join(vaultRoot, 'specs', 'pillars');
264
+ if (!existsSync(dir)) return [];
265
+ const out = [];
266
+ for (const file of readdirSync(dir)) {
267
+ if (!file.endsWith('.md')) continue;
268
+ const basename = file.replace(/\.md$/i, '');
269
+ let name = basename;
270
+ try {
271
+ const raw = readFileSync(join(dir, file), 'utf8');
272
+ const block = getFrontmatterBlock(raw)?.block ?? '';
273
+ name = getScalarField(block, 'name') ?? basename;
274
+ } catch {
275
+ /* keep basename as the display name */
276
+ }
277
+ out.push({ id: basename, name });
278
+ }
279
+ return out;
280
+ }
281
+
282
+ /**
283
+ * Resolve a caller-supplied pillar reference (bare `HS-PILR-<slug>` or a
284
+ * `[[wikilink]]`) to a pillar doc that exists on disk in the target vault.
285
+ * Returns `{ ok: true, basename, frontmatterValue }` — `frontmatterValue` is
286
+ * the normalized quoted-wikilink form (`'[[HS-PILR-<slug>]]'`) to write into
287
+ * the epic's frontmatter — or `{ ok: false }` when the reference is blank,
288
+ * malformed, or does not resolve.
289
+ */
290
+ export function resolvePillar(vaultRoot, raw) {
291
+ const basename = normalizePillarRef(raw);
292
+ if (!basename) return { ok: false };
293
+ const absPath = join(vaultRoot, 'specs', 'pillars', `${basename}.md`);
294
+ if (!existsSync(absPath)) return { ok: false };
295
+ return { ok: true, basename, frontmatterValue: `'[[${basename}]]'` };
296
+ }
297
+
298
+ // ─────────────────────────────────────────────────────────────────────────────
299
+ // AI proposer wiring
300
+ // ─────────────────────────────────────────────────────────────────────────────
301
+
302
+ /**
303
+ * Default proposer — POSTs to console `/api/ai/propose-epic-stubs`. Tests
304
+ * inject their own proposer via `deps.proposeStubs` so no live network is
305
+ * needed.
306
+ */
307
+ async function defaultProposeStubs(payload, deps) {
308
+ if (!deps?.consoleFetch) {
309
+ return {
310
+ ok: false,
311
+ reason: 'no_console_fetch',
312
+ message: 'console fetch dep not wired',
313
+ };
314
+ }
315
+ const result = await deps.consoleFetch('/api/ai/propose-epic-stubs', {
316
+ method: 'POST',
317
+ body: payload,
318
+ });
319
+ if (!result.ok) {
320
+ return {
321
+ ok: false,
322
+ reason: result.errorKind === 'timeout' ? 'console_timeout' : 'console_error',
323
+ message: `console /api/ai/propose-epic-stubs returned ${result.status}`,
324
+ console_response: result.body,
325
+ };
326
+ }
327
+ return result.body;
328
+ }
329
+
330
+ // ─────────────────────────────────────────────────────────────────────────────
331
+ // Argument validation + research/milestone loading
332
+ // ─────────────────────────────────────────────────────────────────────────────
333
+
334
+ function validateInputShape(args) {
335
+ if (!args || typeof args !== 'object') {
336
+ return { error: 'INVALID_ARGS', message: 'args must be an object' };
337
+ }
338
+ if (typeof args.research_id !== 'string' || !normalizeResearchRef(args.research_id)) {
339
+ return { error: 'INVALID_ARGS', message: 'research_id is required' };
340
+ }
341
+ // No type-label gate here on purpose. A real vault id may be a bare era-3
342
+ // file key (`HS-X5FH`) that carries no `RES` label at all — rejecting it
343
+ // shape-first made every research doc unpromotable
344
+ // ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]). The
345
+ // "is this a research doc?" question is answered by the resolved file's
346
+ // `type:` frontmatter in findResearchByIdLocal.
347
+ // Default policy ([[spec-hierarchy]] §Research → milestone/epic filing): NEW milestone
348
+ // unless the agent explicitly names an existing one.
349
+ if (args.target_milestone === undefined || args.target_milestone === null || args.target_milestone === '') {
350
+ args.target_milestone = 'NEW';
351
+ }
352
+ if (typeof args.target_milestone !== 'string' || !args.target_milestone.trim()) {
353
+ return { error: 'INVALID_ARGS', message: 'target_milestone must be a non-empty string or omitted (defaults to NEW)' };
354
+ }
355
+ args.target_milestone = args.target_milestone.trim();
356
+ if (args.target_milestone === 'NEW') {
357
+ if (
358
+ typeof args.new_milestone_name !== 'string' ||
359
+ !args.new_milestone_name.trim()
360
+ ) {
361
+ return {
362
+ error: 'INVALID_ARGS',
363
+ message: 'new_milestone_name is required when target_milestone === "NEW"',
364
+ };
365
+ }
366
+ }
367
+ if (!['spawn', 'augment', 'both'].includes(args.intent)) {
368
+ return {
369
+ error: 'INVALID_ARGS',
370
+ message: 'intent must be one of: spawn | augment | both',
371
+ };
372
+ }
373
+ if (
374
+ args.intent !== 'spawn' &&
375
+ (!Array.isArray(args.augment_epic_ids) || args.augment_epic_ids.length === 0)
376
+ ) {
377
+ return {
378
+ error: 'INVALID_ARGS',
379
+ message:
380
+ 'augment_epic_ids must be a non-empty array when intent includes "augment"',
381
+ };
382
+ }
383
+ if (
384
+ args.accepted_stubs !== undefined &&
385
+ !Array.isArray(args.accepted_stubs)
386
+ ) {
387
+ return {
388
+ error: 'INVALID_ARGS',
389
+ message: 'accepted_stubs must be an array when provided',
390
+ };
391
+ }
392
+ if (args.attach_to_in_progress_milestone !== undefined && typeof args.attach_to_in_progress_milestone !== 'boolean') {
393
+ return {
394
+ error: 'INVALID_ARGS',
395
+ message: 'attach_to_in_progress_milestone must be a boolean when provided',
396
+ };
397
+ }
398
+ if (args.attach_override_reason !== undefined && typeof args.attach_override_reason !== 'string') {
399
+ return {
400
+ error: 'INVALID_ARGS',
401
+ message: 'attach_override_reason must be a string when provided',
402
+ };
403
+ }
404
+ // Batch-default pillar for spawned epics ([[pillar-assignment-protocol]]).
405
+ // Shape/resolvability against specs/pillars/ is checked in runExecutePhase
406
+ // (it needs vaultRoot); this is just the type gate.
407
+ if (args.pillar !== undefined && typeof args.pillar !== 'string') {
408
+ return { error: 'INVALID_ARGS', message: 'pillar must be a string when provided' };
409
+ }
410
+ return null;
411
+ }
412
+
413
+ /**
414
+ * Enforce research-promotion milestone attach policy ([[spec-hierarchy]]):
415
+ * default is NEW; existing milestones must be `open` unless the rare
416
+ * in_progress override is explicitly requested with an audit reason.
417
+ */
418
+ export function validateExistingMilestoneAttachable(milestoneRaw, args = {}) {
419
+ const fm = getFrontmatterBlock(milestoneRaw);
420
+ if (!fm) {
421
+ return {
422
+ error: 'MILESTONE_FRONTMATTER_MISSING',
423
+ message: 'target milestone has no frontmatter — cannot verify attach policy',
424
+ };
425
+ }
426
+ const status = (getScalarField(fm.block, 'status') ?? 'open').trim().toLowerCase();
427
+ const basename = milestoneRaw.match(/^id:\s*(\S+)/m)?.[1] ?? 'milestone';
428
+
429
+ if (MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES.includes(status)) {
430
+ return { ok: true, status };
431
+ }
432
+
433
+ if (status === MILESTONE_ATTACH_IN_PROGRESS_STATUS) {
434
+ if (args.attach_to_in_progress_milestone === true) {
435
+ const reason = typeof args.attach_override_reason === 'string'
436
+ ? args.attach_override_reason.trim()
437
+ : '';
438
+ if (reason.length < 40) {
439
+ return {
440
+ error: 'ATTACH_OVERRIDE_REASON_REQUIRED',
441
+ message:
442
+ 'Attaching spawned epics to an in_progress milestone requires attach_to_in_progress_milestone: true and attach_override_reason (≥40 chars) documenting the owner exception. Default is target_milestone: "NEW".',
443
+ milestone_status: status,
444
+ };
445
+ }
446
+ return { ok: true, status, override: 'in_progress' };
447
+ }
448
+ return {
449
+ error: 'MILESTONE_IN_PROGRESS_ATTACH_BLOCKED',
450
+ message:
451
+ `Target milestone is status: ${status}. Spawning epics into an in-progress milestone is blocked by default — use target_milestone: "NEW" (the default) unless the owner explicitly approves the rare exception (attach_to_in_progress_milestone: true + attach_override_reason).`,
452
+ milestone_status: status,
453
+ policy: 'default_new_milestone',
454
+ };
455
+ }
456
+
457
+ return {
458
+ error: 'MILESTONE_NOT_ATTACHABLE',
459
+ message:
460
+ `Target milestone is status: ${status}. Only status: open milestones accept spawned epics without creating a new milestone. Use target_milestone: "NEW" (the default) or pick an open milestone that clearly extends the same thesis.`,
461
+ milestone_status: status,
462
+ policy: 'default_new_milestone',
463
+ };
464
+ }
465
+
466
+ // ─────────────────────────────────────────────────────────────────────────────
467
+ // Unfinished-stub gate — refuse to spawn milestones/epics from a research doc
468
+ // that is still just its promote-capture outline stub (owner correction
469
+ // 2026-08-12): outline-confirm only fixes the SHAPE of the research (its
470
+ // section headings); it is not the same as the research having been done.
471
+ // A prior session treated "the outline was approved" as "the research is
472
+ // complete" and immediately spawned a milestone + epics from a doc whose
473
+ // sections were still the literal `_TBD — fill in this section._` placeholder
474
+ // text buildResearchStubContent() in promote-capture.mjs writes for every
475
+ // outline section. This mechanically catches that failure mode regardless of
476
+ // whether the outline→epic-stub review gate is also respected.
477
+ // ─────────────────────────────────────────────────────────────────────────────
478
+ const TBD_PLACEHOLDER_RE = /_TBD\s*[—–-]\s*fill in this section\._/gi;
479
+
480
+ /** Count of unresolved promote-capture stub placeholders left in a research body. */
481
+ export function findUnresolvedResearchPlaceholders(raw) {
482
+ const body = getFrontmatterBlock(raw)?.body ?? raw;
483
+ const matches = body.match(TBD_PLACEHOLDER_RE);
484
+ return matches ? matches.length : 0;
485
+ }
486
+
487
+ function validateResearchState(research) {
488
+ const fm = getFrontmatterBlock(research.raw);
489
+ if (!fm) {
490
+ return { error: 'RESEARCH_FRONTMATTER_MISSING', message: 'research has no frontmatter' };
491
+ }
492
+ // Research is evergreen ([[research-is-evergreen]]): status stays `open` over
493
+ // the doc's whole life, so there is no `status: closed` re-promotion gate. The
494
+ // `enhances` link is the "already promoted" signal — see
495
+ // validateBidirectionalEnhances (RESEARCH_ALREADY_PROMOTED).
496
+ const status = getScalarField(fm.block, 'status');
497
+ const enhances = getScalarField(fm.block, 'enhances');
498
+ return { fm, status, enhances };
499
+ }
500
+
501
+ function resolveTargetMilestone(vaultRoot, args, occupiedMilestoneIds) {
502
+ if (args.target_milestone === 'NEW') {
503
+ return { mode: 'new', name: args.new_milestone_name.trim() };
504
+ }
505
+ // Accept either bare HS-MIL-XXXX, the full basename, or wikilink wrapping.
506
+ const basenameCandidate = normalizeMilestoneBasename(args.target_milestone);
507
+ if (basenameCandidate) {
508
+ const ms = findMilestoneByBasename(vaultRoot, basenameCandidate);
509
+ if (ms) return { mode: 'existing', milestone: ms };
510
+ }
511
+ if (parseSpecId(args.target_milestone)?.typeLabel === 'MIL') {
512
+ const ms = findMilestoneById(vaultRoot, args.target_milestone);
513
+ if (ms) return { mode: 'existing', milestone: ms };
514
+ }
515
+ return {
516
+ error: 'MILESTONE_NOT_FOUND',
517
+ message: `target_milestone ${args.target_milestone} did not resolve to a file under specs/milestones/ (looked for both basename and id)`,
518
+ };
519
+ }
520
+
521
+ function validateBidirectionalEnhances(researchEnhances, targetMilestoneBasename) {
522
+ if (!researchEnhances) return null;
523
+ const existingBasename = parseWikilinkBasename(researchEnhances);
524
+ if (!existingBasename) {
525
+ return {
526
+ error: 'RESEARCH_ENHANCES_MALFORMED',
527
+ message: `research \`enhances:\` is not a wikilink: ${researchEnhances}`,
528
+ };
529
+ }
530
+ if (existingBasename !== targetMilestoneBasename) {
531
+ return {
532
+ error: 'RESEARCH_ENHANCES_CONFLICT',
533
+ message: `research already has \`enhances: [[${existingBasename}]]\` but target_milestone resolves to ${targetMilestoneBasename}. Split or rewrite the research first ([[HS-TASK-GTH9-hs-split-research-mcp-tool]] / [[HS-TASK-YJX6]]).`,
534
+ existing: existingBasename,
535
+ target: targetMilestoneBasename,
536
+ };
537
+ }
538
+ // Same target: the research already enhances this milestone — i.e. it has
539
+ // already been promoted here. Under the evergreen rule the research stays
540
+ // `status: open`, so `enhances` (not status) is the "already promoted" signal.
541
+ // Block the re-run to avoid spawning duplicate epics. (A *failed* promote rolls
542
+ // back via commitWrites, leaving `enhances` unset, so legitimate retries are
543
+ // unaffected.)
544
+ return {
545
+ error: 'RESEARCH_ALREADY_PROMOTED',
546
+ message: `research already enhances [[${targetMilestoneBasename}]] — it has already been promoted to this milestone. Re-running would spawn duplicate epics; edit the milestone/epics directly, or split the research to target a different milestone.`,
547
+ existing: existingBasename,
548
+ target: targetMilestoneBasename,
549
+ };
550
+ }
551
+
552
+ // ─────────────────────────────────────────────────────────────────────────────
553
+ // Phase 1: review (AI proposal)
554
+ // ─────────────────────────────────────────────────────────────────────────────
555
+
556
+ async function runProposalPhase(args, ctx, deps) {
557
+ const proposer = deps?.proposeStubs ?? defaultProposeStubs;
558
+
559
+ // Build payload — body is the research markdown (sans frontmatter), bound
560
+ // to a soft excerpt cap so the console endpoint does not have to babysit
561
+ // an unbounded request body.
562
+ const researchBody = ctx.research.raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
563
+ const cappedBody = researchBody.length > 8000
564
+ ? researchBody.slice(0, 8000) + '\n\n…(truncated for AI proposal)'
565
+ : researchBody;
566
+
567
+ const milestoneTarget =
568
+ ctx.targetResolution.mode === 'new'
569
+ ? 'NEW'
570
+ : ctx.targetResolution.milestone.frontmatter?.id ??
571
+ ctx.targetResolution.milestone.basename;
572
+ const milestoneName =
573
+ ctx.targetResolution.mode === 'new'
574
+ ? ctx.targetResolution.name
575
+ : ctx.targetResolution.milestone.name ?? ctx.targetResolution.milestone.basename;
576
+ const existingEpicBasenames =
577
+ ctx.targetResolution.mode === 'existing'
578
+ ? ctx.targetResolution.milestone.epicsList ?? []
579
+ : [];
580
+
581
+ const payload = {
582
+ researchId: ctx.research.frontmatter?.id ?? args.research_id,
583
+ researchName: ctx.research.frontmatter?.name ?? '',
584
+ researchBody: cappedBody,
585
+ milestone: {
586
+ target: milestoneTarget,
587
+ name: milestoneName,
588
+ existingEpicBasenames,
589
+ },
590
+ spawnEpicCount: args.spawn_epic_count,
591
+ };
592
+
593
+ const proposalResult = await proposer(payload, deps);
594
+
595
+ // Record that this turn happened, and what the human is about to be shown.
596
+ // The execute turn refuses without this marker.
597
+ writeStubReviewMarker(ctx.vaultRoot, payload.researchId, {
598
+ stubs: proposalResult?.ok ? proposalResult.stubs : [],
599
+ proposerOk: proposalResult?.ok === true,
600
+ targetMilestone: milestoneTarget,
601
+ });
602
+
603
+ return {
604
+ phase: 'review',
605
+ research_id: payload.researchId,
606
+ research_title: payload.researchName,
607
+ research_status: ctx.researchStatus,
608
+ target_milestone: ctx.targetResolution.mode === 'new'
609
+ ? 'NEW'
610
+ : ctx.targetResolution.milestone.basename,
611
+ target_milestone_name: milestoneName,
612
+ new_milestone_name:
613
+ ctx.targetResolution.mode === 'new' ? ctx.targetResolution.name : undefined,
614
+ intent: args.intent,
615
+ augment_candidates:
616
+ ctx.targetResolution.mode === 'existing'
617
+ ? ctx.targetResolution.milestone.epicsList ?? []
618
+ : [],
619
+ proposed_stubs: proposalResult?.ok ? proposalResult.stubs : [],
620
+ ai_proposal: proposalResult?.ok
621
+ ? { ok: true, model: 'claude-haiku-4-5-20251001' }
622
+ : { ok: false, reason: proposalResult?.reason, message: proposalResult?.message },
623
+ milestone_attach_policy: {
624
+ default: 'NEW',
625
+ existing_milestone_requires_status: MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES,
626
+ in_progress_requires_override: true,
627
+ },
628
+ human_review_required: true,
629
+ next_call_hint:
630
+ 'Default is target_milestone: "NEW". Show proposed_stubs to the user, let them edit/remove, then call heyspex_promote_research again with the same args plus `accepted_stubs: [...]` to execute the writes. Attach to an existing milestone only when it is status: open and clearly extends the same thesis.',
631
+ };
632
+ }
633
+
634
+ // ─────────────────────────────────────────────────────────────────────────────
635
+ // Two-turn review gate ([[HS-EPIC-PLRV]] pattern, copied from the plan gate).
636
+ //
637
+ // The two-phase flow was a CONVENTION: phase 1 proposes stubs, the human edits
638
+ // them, phase 2 writes. Nothing enforced it. An agent could call phase 2
639
+ // directly with stubs it invented, and a milestone plus a handful of epics
640
+ // would land in the vault having never been shown to anyone. The two-turn rule
641
+ // existed only as prose in the slash command.
642
+ //
643
+ // The plan gate is the one gate in this system that holds, and it holds because
644
+ // of three things together: phase 1 writes a marker, phase 2 refuses without a
645
+ // matching one, and a thin answer is rejected rather than accepted. Same three
646
+ // here — the difference being that both phases live inside this one tool, so
647
+ // the gate is not a hook and `.heyspex/mode.json` cannot disarm it.
648
+ //
649
+ // Break-glass: HEYSPEX_PROMOTE_REVIEW_GATE_OFF=1.
650
+ // ─────────────────────────────────────────────────────────────────────────────
651
+
652
+ /** Minimum length for an explanation, matching attach_override_reason above. */
653
+ const EDIT_NOTE_MIN_LENGTH = 40;
654
+
655
+ /** Marker path for a research doc's proposal turn. Keyed on the research id — the
656
+ * one value stable across both turns, since the human is expected to EDIT the
657
+ * stubs in between (so hashing the stub content, as the plan gate does, would
658
+ * refuse every legitimate edit). */
659
+ export function stubReviewMarkerPath(vaultRoot, researchId) {
660
+ const hash = createHash('sha256').update(String(researchId ?? '')).digest('hex');
661
+ return join(vaultRoot, '.heyspex', 'promote-research', `${hash}.json`);
662
+ }
663
+
664
+ /** Normalize a stub name for comparison — case and punctuation are the human's
665
+ * to change; what matters is whether the accepted set descends from the
666
+ * proposed one at all. */
667
+ function normalizeStubName(name) {
668
+ return String(name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
669
+ }
670
+
671
+ /** Record that the proposal turn happened, and what was put in front of the human. */
672
+ export function writeStubReviewMarker(vaultRoot, researchId, proposal = {}) {
673
+ try {
674
+ const file = stubReviewMarkerPath(vaultRoot, researchId);
675
+ mkdirSync(join(file, '..'), { recursive: true });
676
+ writeFileSync(
677
+ file,
678
+ JSON.stringify(
679
+ {
680
+ research_id: researchId,
681
+ proposed_at: new Date().toISOString(),
682
+ proposed_stub_names: (proposal.stubs ?? []).map((s) => s?.name ?? ''),
683
+ proposer_ok: proposal.proposerOk === true,
684
+ target_milestone: proposal.targetMilestone ?? null,
685
+ },
686
+ null,
687
+ 2,
688
+ ) + '\n',
689
+ );
690
+ return true;
691
+ } catch {
692
+ // A marker we cannot write must not break the proposal turn — the execute
693
+ // turn will refuse for a missing marker, which is the safe direction.
694
+ return false;
695
+ }
696
+ }
697
+
698
+ function readStubReviewMarker(vaultRoot, researchId) {
699
+ try {
700
+ const file = stubReviewMarkerPath(vaultRoot, researchId);
701
+ if (!existsSync(file)) return null;
702
+ return JSON.parse(readFileSync(file, 'utf8'));
703
+ } catch {
704
+ return null;
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Refuse an execute turn that never had a proposal turn, or whose accepted
710
+ * stubs bear no relation to what was proposed. Returns an error object to
711
+ * return verbatim, or null to proceed.
712
+ *
713
+ * Pure decision logic apart from the marker read, so the drift rules are
714
+ * testable without a vault.
715
+ */
716
+ export function checkStubReviewGate({ marker, acceptedStubs = [], args = {} }) {
717
+ if (!marker) {
718
+ return {
719
+ error: 'STUB_REVIEW_REQUIRED',
720
+ message:
721
+ 'No proposal turn recorded for this research doc. Spawning a milestone and epics is a two-turn flow: call heyspex_promote_research WITHOUT accepted_stubs first, show the proposed stubs to the human and let them edit or remove them, then call again with accepted_stubs. Passing stubs straight through means nobody saw them. (Break-glass: HEYSPEX_PROMOTE_REVIEW_GATE_OFF=1.)',
722
+ remedy: 'run the proposal phase (omit accepted_stubs) and confirm with the human',
723
+ };
724
+ }
725
+
726
+ const proposedNames = (marker.proposed_stub_names ?? []).map(normalizeStubName).filter(Boolean);
727
+ // The proposer can legitimately return nothing (its endpoint was down, or the
728
+ // doc yielded no stubs). The human then authors from scratch, so there is no
729
+ // proposal to drift from — the marker alone proves the turn happened.
730
+ if (proposedNames.length === 0) return null;
731
+
732
+ const acceptedNames = acceptedStubs.map((s) => normalizeStubName(s?.name)).filter(Boolean);
733
+ const overlap = acceptedNames.filter((n) => proposedNames.includes(n)).length;
734
+ if (overlap > 0) return null;
735
+
736
+ const note = typeof args.stub_edit_note === 'string' ? args.stub_edit_note.trim() : '';
737
+ if (note.length >= EDIT_NOTE_MIN_LENGTH) return null;
738
+
739
+ return {
740
+ error: 'STUB_REVIEW_DRIFT',
741
+ message:
742
+ `None of the ${acceptedNames.length} accepted stub(s) match any of the ${proposedNames.length} that were proposed and reviewed. That is either a full rewrite by the human — which is fine — or stubs invented after the review, which is what this gate exists to catch. Pass stub_edit_note (≥${EDIT_NOTE_MIN_LENGTH} chars) saying what the human changed and why, or re-run the proposal turn.`,
743
+ proposed_stub_names: marker.proposed_stub_names ?? [],
744
+ accepted_stub_names: acceptedStubs.map((s) => s?.name ?? ''),
745
+ };
746
+ }
747
+
748
+ // ─────────────────────────────────────────────────────────────────────────────
749
+ // Phase 2: execute (transactional writes)
750
+ // ─────────────────────────────────────────────────────────────────────────────
751
+
752
+ function loadMilestoneEpicsList(milestoneRecord) {
753
+ const fm = getFrontmatterBlock(milestoneRecord.raw);
754
+ if (!fm) return [];
755
+ const list = parseListFieldBasenames(fm.block, 'epics');
756
+ return list ?? [];
757
+ }
758
+
759
+ function loadFrontmatterScalar(raw, field) {
760
+ const fm = getFrontmatterBlock(raw);
761
+ if (!fm) return null;
762
+ return getScalarField(fm.block, field);
763
+ }
764
+
765
+ /**
766
+ * Owner-intent carry-through — resolve the source capture behind a research doc.
767
+ *
768
+ * The capture holds the OWNER'S words; research is evidence *about* those words.
769
+ * Spawned milestones/epics used to carry only `informed_by` / `derived_from`
770
+ * pointers to the research, so downstream planning re-derived intent from the
771
+ * research alone. When a research recommendation contradicted the capture,
772
+ * nothing surfaced it — the 2026-08-11 SpexCrawl drift cost four owner
773
+ * corrections, every one of them answered by text already sitting in the capture
774
+ * ([[HS-GIXC-CAP-KPZT-heyspex-should-actively-detect-and-preve]]).
775
+ *
776
+ * The bug path already does this correctly — `buildDiagnoseContent` in
777
+ * promote-capture.mjs drops the capture's Core Idea into the diagnose Symptom.
778
+ * This brings the plan path to the same parity.
779
+ *
780
+ * Fail-open by construction: no resolvable capture returns null and the stubs
781
+ * render exactly as they did before.
782
+ */
783
+ function resolveOwnerIntent(vaultRoot, researchRaw) {
784
+ try {
785
+ const fm = getFrontmatterBlock(researchRaw);
786
+ if (!fm) return null;
787
+ let basenames = parseListFieldBasenames(fm.block, 'sources_captures') ?? [];
788
+ if (basenames.length === 0) {
789
+ // Research saved via heyspex_save_research bypasses the stub scaffold and
790
+ // so has no sources_captures: — fall back to capture-typed related: entries.
791
+ basenames = (parseListFieldBasenames(fm.block, 'related') ?? []).filter((b) =>
792
+ /(^|-)CAP(-|$)/i.test(b),
793
+ );
794
+ }
795
+ if (basenames.length === 0) return null;
796
+ const capturesDir = join(vaultRoot, 'specs', 'captures');
797
+ for (const basename of basenames) {
798
+ // Wikilinks may be either the full file basename or bare id (`HS-VNK3-CAP`),
799
+ // so fall back to prefix resolution — the id-only form is the common case.
800
+ let abs = join(capturesDir, `${basename}.md`);
801
+ if (!existsSync(abs)) {
802
+ const byPrefix = findFileByPrefix(capturesDir, basename);
803
+ if (!byPrefix) continue;
804
+ abs = join(capturesDir, byPrefix);
805
+ }
806
+ const raw = readFileSync(abs, 'utf8');
807
+ const body = getFrontmatterBlock(raw)?.body ?? raw;
808
+ const m = body.match(/#\s*Core Idea\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
809
+ const coreIdea = m ? m[1].trim() : '';
810
+ if (coreIdea) return { basename, coreIdea };
811
+ }
812
+ return null;
813
+ } catch {
814
+ return null;
815
+ }
816
+ }
817
+
818
+ /**
819
+ * Pull the research doc's own `## JTBD (job-to-be-done)` section body, so
820
+ * spawned milestone/epic stubs can re-derive their altitude's slice of the
821
+ * job instead of starting from a blank placeholder ([[research-capture-protocol]]
822
+ * §"JTBD (job-to-be-done) framing"). Returns '' when the research has no such
823
+ * section (older docs, or research not yet updated) — callers then render a
824
+ * plain placeholder prompt.
825
+ */
826
+ function extractResearchJtbd(researchRaw) {
827
+ const fm = getFrontmatterBlock(researchRaw);
828
+ const body = fm ? fm.body : researchRaw;
829
+ const m = body.match(/##\s*JTBD\s*\(job-to-be-done\)\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
830
+ if (!m) return '';
831
+ return m[1].trim();
832
+ }
833
+
834
+ /**
835
+ * Render the `## JTBD (job-to-be-done)` section injected above
836
+ * `## Intended outcome` in spawned milestone/epic stubs — re-derived (not
837
+ * copied) to that altitude's slice of the job, same discipline as the
838
+ * existing Intended-outcome cascade ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]).
839
+ */
840
+ function renderJtbdSection(researchJtbd, altitudeLabel) {
841
+ const seedBlock = researchJtbd
842
+ ? `**Job stated by research:**\n${researchJtbd}\n\n**This ${altitudeLabel}'s slice of the job:** _TBD — re-derive what part of that job this ${altitudeLabel} serves; do not copy the research text verbatim._`
843
+ : `**Job:** _TBD — the source research has no JTBD section yet; state the job this ${altitudeLabel} serves._`;
844
+ return `## JTBD (job-to-be-done)
845
+
846
+ <!-- Re-derive to this ${altitudeLabel}'s slice of the job — do NOT copy verbatim (same discipline as Intended outcome, [[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]). -->
847
+ ${seedBlock}
848
+
849
+ `;
850
+ }
851
+
852
+ /** Render the owner-intent block injected above `## Intended outcome`. */
853
+ function renderOwnerIntentSection(ownerIntent) {
854
+ if (!ownerIntent) return '';
855
+ const quoted = ownerIntent.coreIdea
856
+ .split('\n')
857
+ .map((line) => `> ${line}`.trimEnd())
858
+ .join('\n');
859
+ return `## Owner intent — verbatim, from [[${ownerIntent.basename}]]
860
+
861
+ ${quoted}
862
+
863
+ _This is the owner's own statement of what they asked for. Research is evidence **about** this intent, never a replacement for it. **Where the research and this text disagree, this text wins** — and the disagreement must be stated explicitly below rather than silently resolved in the research's favour._
864
+
865
+ `;
866
+ }
867
+
868
+ /** Frontmatter line linking a spec back to the capture that originated it. */
869
+ function renderSourceCaptureField(ownerIntent) {
870
+ return ownerIntent ? `source_capture: "[[${ownerIntent.basename}]]"\n` : '';
871
+ }
872
+
873
+ function buildMilestoneStubContent(
874
+ milestoneId,
875
+ fileKey,
876
+ familyKey,
877
+ milestoneName,
878
+ researchBasename,
879
+ now,
880
+ ownerIntent = null,
881
+ researchJtbd = '',
882
+ ) {
883
+ const today = now.slice(0, 10);
884
+ return `---
885
+ id: ${milestoneId}
886
+ file_id: ${fileKey}
887
+ family: ${familyKey}
888
+ name: ${yamlScalar(milestoneName)}
889
+ type: milestone
890
+ status: open
891
+ owner: pwher
892
+ created: "${now}"
893
+ updated: "${now}"
894
+ target_date: ""
895
+ tags:
896
+ - milestone
897
+ epics: []
898
+ informed_by:
899
+ - "[[${researchBasename}]]"
900
+ ${renderSourceCaptureField(ownerIntent)}related: []
901
+ ---
902
+
903
+ # ${milestoneName}
904
+
905
+ _Milestone stub created by /hs-promote-research from [[${researchBasename}]]. Replace this prose with the actual milestone framing — why these epics ship together as one coherent restructure or release._
906
+
907
+ ${renderOwnerIntentSection(ownerIntent)}${renderJtbdSection(researchJtbd, 'milestone')}## Intended outcome
908
+
909
+ <!-- Re-derive from [[${researchBasename}]]'s intended outcome — do NOT copy it ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]).${ownerIntent ? ' Check it against the Owner intent above; where they disagree, the capture wins — say so here.' : ''} -->
910
+ **Outcome at the milestone altitude:** _What outcome does this milestone deliver?_
911
+
912
+ **Moves (↑ link):** _Which research outcome does it move — and by what mechanism of contribution? Name the mechanism; don't echo the parent's words._
913
+
914
+ ## Change log
915
+
916
+ | Date | Author | Change |
917
+ | --- | --- | --- |
918
+ | ${today} | promote-research (agent) | Created from research [[${researchBasename}]]. |
919
+ `;
920
+ }
921
+
922
+ function buildEpicContent(
923
+ { id, fileKey, familyKey, basename, name, kind, effort, depends_on, rationale, pillarFrontmatterValue },
924
+ milestoneBasename,
925
+ researchBasename,
926
+ now,
927
+ ownerIntent = null,
928
+ researchJtbd = '',
929
+ ) {
930
+ const today = now.slice(0, 10);
931
+ const dependsBlock =
932
+ Array.isArray(depends_on) && depends_on.length > 0
933
+ ? `depends_on:\n${depends_on.map((d) => ` - "[[${d}]]"`).join('\n')}`
934
+ : 'depends_on: []';
935
+ return `---
936
+ id: ${id}
937
+ file_id: ${fileKey}
938
+ family: ${familyKey}
939
+ name: ${yamlScalar(name)}
940
+ type: epic
941
+ kind: ${kind}
942
+ effort: ${effort}
943
+ requires_user_testing: false
944
+ status: open
945
+ owner: pwher
946
+ created: "${now}"
947
+ updated: "${now}"
948
+ parent: "[[${milestoneBasename}]]"
949
+ milestone: "[[${milestoneBasename}]]"
950
+ pillar: ${pillarFrontmatterValue}
951
+ derived_from: "[[${researchBasename}]]"
952
+ ${renderSourceCaptureField(ownerIntent)}${dependsBlock}
953
+ tags:
954
+ - epic
955
+ children: []
956
+ related: []
957
+ ---
958
+
959
+ # ${name}
960
+
961
+ ${rationale ? rationale.trim() : '_Epic stub spawned by /hs-promote-research; rationale and acceptance criteria pending._'}
962
+
963
+ ${renderOwnerIntentSection(ownerIntent)}${renderJtbdSection(researchJtbd, 'epic')}## Intended outcome
964
+
965
+ <!-- Re-derive from [[${milestoneBasename}]]'s intended outcome — do NOT copy it ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]).${ownerIntent ? ' Check it against the Owner intent above; where they disagree, the capture wins — say so here.' : ''} -->
966
+ **User-visible outcome of this epic:** _What changes for the user when this epic ships?_
967
+
968
+ **Moves (↑ link):** _Which milestone outcome does it move — and by what mechanism of contribution?_
969
+
970
+ ## Change log
971
+
972
+ | Date | Author | Change |
973
+ | --- | --- | --- |
974
+ | ${today} | promote-research (agent) | Spawned by /hs-promote-research from research [[${researchBasename}]] under milestone [[${milestoneBasename}]]. |
975
+ `;
976
+ }
977
+
978
+ /**
979
+ * Shape a valid family key must satisfy ([[HS-8O69-EPIC-LKF9-relational-family-graph-authoring]] /
980
+ * validator `FAMILY_FIELD_MALFORMED`) — mirrors spec-id-lib.mjs's (module-
981
+ * private) `FAMILY_KEY_SHAPE_RE` and validate-vault-ids.mjs's own copy of the
982
+ * same shape.
983
+ */
984
+ const FAMILY_KEY_SHAPE_RE = /^[A-Z0-9]{4,}$/;
985
+
986
+ /**
987
+ * The family key a spawned milestone/epic should inherit from its source
988
+ * research doc ([[HS-GZM9-DIAG-G419]]). Tries each candidate in order and
989
+ * returns the FIRST one that is well-formed — a malformed candidate
990
+ * (lowercase, <4 chars, punctuation) is skipped, never propagated, so it can
991
+ * never leak into every spawned file and trip the validator's
992
+ * `FAMILY_FIELD_MALFORMED`:
993
+ *
994
+ * 1. the research doc's explicit `family:` frontmatter field
995
+ * 2. the family key parsed positionally from the research doc's `id:`
996
+ * 3. the family key parsed positionally from the research doc's basename
997
+ *
998
+ * Returns null when no candidate is derivable/well-formed — callers fall
999
+ * back further to the freshly-minted key of the spec being created.
1000
+ *
1001
+ * @param {{ frontmatter?: { id?: string|null, family?: string|null }, basename?: string|null }} research
1002
+ */
1003
+ function inheritedResearchFamilyKey(research) {
1004
+ const candidates = [
1005
+ research?.frontmatter?.family,
1006
+ parseSpecId(research?.frontmatter?.id)?.familyKey,
1007
+ parseSpecId(research?.basename)?.familyKey,
1008
+ ];
1009
+ for (const candidate of candidates) {
1010
+ if (typeof candidate === 'string' && FAMILY_KEY_SHAPE_RE.test(candidate)) {
1011
+ return candidate;
1012
+ }
1013
+ }
1014
+ return null;
1015
+ }
1016
+
1017
+ async function runExecutePhase(args, ctx, deps) {
1018
+ const now = (deps?.clock ?? isoNow)();
1019
+ const today = now.slice(0, 10);
1020
+ const author = 'promote-research (agent)';
1021
+
1022
+ // Family inheritance ([[HS-GZM9-DIAG-G419]], mirrors promote-capture.mjs /
1023
+ // split-research.mjs): the spawned milestone AND every spawned epic inherit
1024
+ // the SOURCE RESEARCH DOC's family key — explicit `family:` frontmatter
1025
+ // wins IF well-formed, else the family key parsed positionally from the
1026
+ // research doc's id/basename (era-aware dual-read). Era-3 file-key-first
1027
+ // ([[naming-v3-7-rulings-hs-namespace-kept]]): the inherited family lands in
1028
+ // the basename (`HS-<fileKey>-<familyKey>-<TYPE>-<slug>`) and in `family:`;
1029
+ // `id:` keys onto the file key alone.
1030
+ const inheritedFamilyKey = inheritedResearchFamilyKey(ctx.research);
1031
+
1032
+ // ── Resolve / allocate target milestone basename + path ───────────────────
1033
+ let milestoneBasename;
1034
+ let milestoneAbsPath;
1035
+ let milestoneRelPath;
1036
+ let milestoneCreated = false;
1037
+ let milestoneId;
1038
+ let milestoneFileKey = null;
1039
+ let milestoneFamilyKey = null;
1040
+ let milestoneInformedByAlreadyHasResearch = false;
1041
+ let existingMilestoneEpics = [];
1042
+
1043
+ if (ctx.targetResolution.mode === 'new') {
1044
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1045
+ // and (V3) the anchor for `id:` itself.
1046
+ milestoneFileKey = allocateFileKey(collectOccupiedKeysEitherKind(ctx.vaultRoot), {
1047
+ randomUnit: deps?.randomUnit,
1048
+ });
1049
+ // Family: inherit from the research doc; the legacy allocator's
1050
+ // `HS-<family>-MIL` draw is only the fallback family source (never blank —
1051
+ // last resort is the fresh file key itself, matching the old chain).
1052
+ milestoneFamilyKey =
1053
+ inheritedFamilyKey ??
1054
+ parseSpecId(allocateMilestoneId(ctx.vaultRoot, { randomUnit: deps?.randomUnit }))?.familyKey ??
1055
+ milestoneFileKey;
1056
+ milestoneId = `HS-${milestoneFileKey}`;
1057
+ const slug = deriveSlug(ctx.targetResolution.name);
1058
+ milestoneBasename = `${milestoneId}-${milestoneFamilyKey}-MIL-${slug}`;
1059
+ milestoneAbsPath = join(ctx.vaultRoot, 'specs', 'milestones', `${milestoneBasename}.md`);
1060
+ milestoneRelPath = join('specs', 'milestones', `${milestoneBasename}.md`);
1061
+ milestoneCreated = true;
1062
+ } else {
1063
+ const ms = ctx.targetResolution.milestone;
1064
+ milestoneBasename = ms.basename;
1065
+ milestoneAbsPath = ms.absPath;
1066
+ milestoneRelPath = ms.relPath;
1067
+ milestoneId = loadFrontmatterScalar(ms.raw, 'id') ?? milestoneBasename;
1068
+ existingMilestoneEpics = ms.epicsList ?? [];
1069
+ const informedByList = parseListFieldBasenames(getFrontmatterBlock(ms.raw).block, 'informed_by') ?? [];
1070
+ milestoneInformedByAlreadyHasResearch = informedByList.includes(
1071
+ ctx.research.basename,
1072
+ );
1073
+ }
1074
+
1075
+ // ── Resolve augment epics + validate they live under the target milestone ─
1076
+ const augmentEpics = [];
1077
+ if (args.intent === 'augment' || args.intent === 'both') {
1078
+ for (const epicRef of args.augment_epic_ids) {
1079
+ const found = findEpicByIdOrBasename(ctx.vaultRoot, epicRef);
1080
+ if (!found) {
1081
+ return {
1082
+ error: 'AUGMENT_EPIC_NOT_FOUND',
1083
+ message: `augment_epic_ids entry ${epicRef} did not resolve to a file under specs/epics/`,
1084
+ };
1085
+ }
1086
+ // If the milestone exists already, the epic must already belong to it.
1087
+ if (ctx.targetResolution.mode === 'existing' && !existingMilestoneEpics.includes(found.basename)) {
1088
+ return {
1089
+ error: 'AUGMENT_EPIC_NOT_IN_MILESTONE',
1090
+ message: `epic ${found.basename} is not listed in milestone ${milestoneBasename}'s \`epics:\` array`,
1091
+ };
1092
+ }
1093
+ augmentEpics.push(found);
1094
+ }
1095
+ }
1096
+
1097
+ // ── Prepare spawned epic records (from accepted_stubs) ────────────────────
1098
+ const acceptedStubs = Array.isArray(args.accepted_stubs) ? args.accepted_stubs : [];
1099
+ if ((args.intent === 'spawn' || args.intent === 'both') && acceptedStubs.length === 0) {
1100
+ return {
1101
+ error: 'NO_ACCEPTED_STUBS',
1102
+ message:
1103
+ 'intent includes "spawn" but no accepted_stubs provided. Run the proposal phase first (omit accepted_stubs) and confirm with the human.',
1104
+ };
1105
+ }
1106
+
1107
+ const spawnedEpicSpecs = [];
1108
+ if (acceptedStubs.length > 0) {
1109
+ const { epics: occupiedEpics } = collectOccupiedVaultIds(ctx.vaultRoot);
1110
+ // One occupied set across the whole loop — files are not on disk yet, so a
1111
+ // fresh vault walk per stub would let two stubs draw the same file key.
1112
+ const occupiedFileKeys = collectOccupiedKeysEitherKind(ctx.vaultRoot); // union: file+family keys (HS-SJOT)
1113
+ for (const stub of acceptedStubs) {
1114
+ if (!stub?.name?.trim()) {
1115
+ return {
1116
+ error: 'INVALID_STUB',
1117
+ message: 'every accepted stub must have a non-empty name',
1118
+ };
1119
+ }
1120
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1121
+ // and (V3) the anchor for `id:` itself.
1122
+ const fileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
1123
+ occupiedFileKeys.add(fileKey);
1124
+ // Family: inherit from the research doc; the legacy allocator's
1125
+ // `HS-<family>-EPIC` draw is only the fallback family source.
1126
+ let epicFamilyKey = inheritedFamilyKey;
1127
+ if (!epicFamilyKey) {
1128
+ const allocatedEpicFamilyId = allocateRandomVaultId('epic', occupiedEpics, {
1129
+ randomUnit: deps?.randomUnit,
1130
+ });
1131
+ occupiedEpics.add(allocatedEpicFamilyId);
1132
+ epicFamilyKey = parseSpecId(allocatedEpicFamilyId)?.familyKey ?? fileKey;
1133
+ }
1134
+ const epicId = `HS-${fileKey}`;
1135
+ const slug = deriveSlug(stub.name);
1136
+ const basename = `${epicId}-${epicFamilyKey}-EPIC-${slug}`;
1137
+ spawnedEpicSpecs.push({
1138
+ id: epicId,
1139
+ fileKey,
1140
+ familyKey: epicFamilyKey,
1141
+ basename,
1142
+ name: stub.name.trim(),
1143
+ kind: stub.kind ?? 'feature',
1144
+ effort: typeof stub.effort === 'number' ? stub.effort : 3,
1145
+ depends_on: Array.isArray(stub.depends_on) ? stub.depends_on : [],
1146
+ rationale: typeof stub.rationale === 'string' ? stub.rationale : '',
1147
+ // Per-stub pillar override; falls back to the batch-default `args.pillar`
1148
+ // in the gate below (stub value wins — [[pillar-assignment-protocol]]).
1149
+ pillar: typeof stub.pillar === 'string' ? stub.pillar : undefined,
1150
+ relPath: join('specs', 'epics', `${basename}.md`),
1151
+ absPath: join(ctx.vaultRoot, 'specs', 'epics', `${basename}.md`),
1152
+ });
1153
+ }
1154
+ }
1155
+
1156
+ // ── Resolve depends_on against sibling stubs + existing vault specs ───────
1157
+ // ([[HS-UM2Z-TH36-DIAG]]). Phase-2 `accepted_stubs[].depends_on` carries
1158
+ // human-readable stub NAMES (the only sibling identifier available before
1159
+ // ids are minted, above) — writing them straight into frontmatter as
1160
+ // `[[<name>]]` produces a wikilink that resolves to nothing. Substitute
1161
+ // each entry that matches a sibling stub's `name` with that sibling's
1162
+ // freshly-minted basename; else resolve against an existing vault epic by
1163
+ // id/basename; else DROP it (warn) rather than write an unresolvable link.
1164
+ const depWarnings = [];
1165
+ if (spawnedEpicSpecs.length > 0) {
1166
+ const siblingNameToBasename = new Map();
1167
+ for (const s of spawnedEpicSpecs) {
1168
+ siblingNameToBasename.set(s.name.trim().toLowerCase(), s.basename);
1169
+ }
1170
+ for (const spec of spawnedEpicSpecs) {
1171
+ const resolved = [];
1172
+ for (const raw of spec.depends_on) {
1173
+ const d = typeof raw === 'string' ? raw.trim() : '';
1174
+ if (!d) continue;
1175
+ let target = siblingNameToBasename.get(d.toLowerCase());
1176
+ if (!target) {
1177
+ const found = findEpicByIdOrBasename(ctx.vaultRoot, d);
1178
+ if (found) target = found.basename;
1179
+ }
1180
+ if (target) {
1181
+ resolved.push(target);
1182
+ } else {
1183
+ depWarnings.push({ epic: spec.name, unresolved: d });
1184
+ }
1185
+ }
1186
+ spec.depends_on = resolved;
1187
+ }
1188
+ }
1189
+
1190
+ // ── Pillar gate — every spawned epic must resolve to a pillar doc that
1191
+ // exists on disk BEFORE any file is written (no partial spawn), mirroring
1192
+ // POST /api/specs' `pillar_required` hard-block ([[pillar-assignment-
1193
+ // protocol]]). Per-stub `pillar` wins over the top-level `args.pillar`
1194
+ // batch default. HS-TASK-QQHP defect #2.
1195
+ if (spawnedEpicSpecs.length > 0) {
1196
+ for (const spec of spawnedEpicSpecs) {
1197
+ const pillarRef = spec.pillar ?? args.pillar;
1198
+ const resolved = resolvePillar(ctx.vaultRoot, pillarRef);
1199
+ if (!resolved.ok) {
1200
+ return {
1201
+ error: 'pillar_required',
1202
+ message: pillarRef
1203
+ ? `epic "${spec.name}" pillar \`${pillarRef}\` does not resolve to a pillar under specs/pillars/ — pick an existing pillar or author a new one first`
1204
+ : `epic "${spec.name}" requires a \`pillar:\` wikilink to an HS-PILR theme — pass a top-level \`pillar\` batch default or a per-stub \`pillar\`; pick an existing pillar or author a new one first`,
1205
+ existing_pillars: listVaultPillars(ctx.vaultRoot),
1206
+ create_pillar: {
1207
+ tool: 'heyspex_file_spec',
1208
+ call: { type: 'pillar', name: '<theme name>' },
1209
+ note: 'When no existing theme fits, author a net-new pillar, then re-file with `pillar: [[<new HS-PILR id>]]` (top-level `pillar` batch default or a per-stub override).',
1210
+ },
1211
+ };
1212
+ }
1213
+ spec.pillarFrontmatterValue = resolved.frontmatterValue;
1214
+ }
1215
+ }
1216
+
1217
+ // ── Stage all writes ──────────────────────────────────────────────────────
1218
+ const writes = [];
1219
+
1220
+ // The owner's own words from the originating capture, carried onto every spec
1221
+ // this promotion creates so intent survives the hop into the plan graph.
1222
+ // Null when no capture resolves — stubs then render exactly as before.
1223
+ const ownerIntent = resolveOwnerIntent(ctx.vaultRoot, ctx.research.raw);
1224
+ // HS-M1MW: seed spawned stubs' JTBD sections from the research doc's own
1225
+ // JTBD section, when present.
1226
+ const researchJtbd = extractResearchJtbd(ctx.research.raw);
1227
+
1228
+ // 1. Milestone (create-if-new, else update later via line-surgical helper).
1229
+ // Family inheritance (HS-TASK-QQHP defect #1) is resolved once, up top,
1230
+ // where the era-3 basenames/ids are composed.
1231
+ if (milestoneCreated) {
1232
+ const newContent = buildMilestoneStubContent(
1233
+ milestoneId,
1234
+ milestoneFileKey,
1235
+ milestoneFamilyKey,
1236
+ ctx.targetResolution.name,
1237
+ ctx.research.basename,
1238
+ now,
1239
+ ownerIntent,
1240
+ researchJtbd,
1241
+ );
1242
+ writes.push({ kind: 'create', absPath: milestoneAbsPath, content: newContent });
1243
+ }
1244
+
1245
+ // 2. Spawned epic files (spec.familyKey was resolved at mint time above).
1246
+ for (const spec of spawnedEpicSpecs) {
1247
+ const content = buildEpicContent(
1248
+ spec,
1249
+ milestoneBasename,
1250
+ ctx.research.basename,
1251
+ now,
1252
+ ownerIntent,
1253
+ researchJtbd,
1254
+ );
1255
+ writes.push({ kind: 'create', absPath: spec.absPath, content });
1256
+ }
1257
+
1258
+ // 3. Updated research file (set enhances + spawned_epics + enhances_epics;
1259
+ // status stays `open` — research is evergreen, [[research-is-evergreen]]).
1260
+ const researchContent = buildUpdatedResearchContent(
1261
+ ctx.research.raw,
1262
+ {
1263
+ milestoneBasename,
1264
+ spawnedEpicBasenames: spawnedEpicSpecs.map((s) => s.basename),
1265
+ augmentedEpicBasenames: augmentEpics.map((e) => e.basename),
1266
+ now,
1267
+ today,
1268
+ author,
1269
+ },
1270
+ );
1271
+ writes.push({ kind: 'update', absPath: ctx.research.absPath, content: researchContent });
1272
+
1273
+ // 4. Updated milestone file (only if not just-created and we need informed_by/epics updates).
1274
+ if (!milestoneCreated) {
1275
+ const updatedMilestoneContent = buildUpdatedMilestoneContent(
1276
+ ctx.targetResolution.milestone.raw,
1277
+ {
1278
+ researchBasename: ctx.research.basename,
1279
+ now,
1280
+ },
1281
+ );
1282
+ if (updatedMilestoneContent !== ctx.targetResolution.milestone.raw) {
1283
+ writes.push({ kind: 'update', absPath: milestoneAbsPath, content: updatedMilestoneContent });
1284
+ }
1285
+ }
1286
+
1287
+ // ── Commit ────────────────────────────────────────────────────────────────
1288
+ const commitResult = commitWrites(writes);
1289
+ if (!commitResult.ok) {
1290
+ return {
1291
+ error: 'WRITE_FAILED',
1292
+ message: commitResult.error?.message ?? 'unknown write failure',
1293
+ rollback_error: commitResult.error?.rollbackError,
1294
+ };
1295
+ }
1296
+
1297
+ // ── Post-commit: append each new epic basename to milestone.epics via the
1298
+ // shared line-surgical helper, so the bidirectional integrity rule from
1299
+ // [[HS-TASK-T5PX]] (milestone.epics ⇔ epic.milestone) is preserved.
1300
+ for (const spec of spawnedEpicSpecs) {
1301
+ try {
1302
+ appendEpicToMilestone(ctx.vaultRoot, milestoneBasename, spec.basename);
1303
+ } catch (err) {
1304
+ return {
1305
+ error: 'MILESTONE_EPICS_MIRROR_FAILED',
1306
+ message: `epic files written but failed to append to milestone.epics: ${err.message}`,
1307
+ partial: true,
1308
+ };
1309
+ }
1310
+ }
1311
+
1312
+ // ── Post-commit: register a freshly-created milestone into the default
1313
+ // roadmap's `roadmap_manifest.milestones` ([[HS-6DL4-2OO7-DIAG]]) — the
1314
+ // same manifest-append `registerSpec` already performs for
1315
+ // `heyspex_file_spec`'s createSpec path, so a promote-research-spawned
1316
+ // milestone doesn't sit unreachable from the roadmap. Best-effort: a
1317
+ // missing/misconfigured roadmap doc is surfaced as a warning rather than
1318
+ // failing an otherwise-successful promote (the milestone/epic files are
1319
+ // already committed at this point).
1320
+ let roadmapRegistration;
1321
+ if (milestoneCreated) {
1322
+ try {
1323
+ const reg = registerSpec(ctx.vaultRoot, {
1324
+ spec_id: milestoneBasename,
1325
+ roadmap_id: args.roadmap_id || 'HS-ROADMAP-PRODUCT',
1326
+ updateTimestamps: false,
1327
+ });
1328
+ roadmapRegistration = reg?.success === false
1329
+ ? { ok: false, error: reg.error, message: reg.message }
1330
+ : { ok: true, roadmap_id: reg?.roadmap_id, already_registered: !!reg?.already_registered };
1331
+ } catch (err) {
1332
+ roadmapRegistration = { ok: false, error: 'ROADMAP_REGISTER_THREW', message: err.message };
1333
+ }
1334
+ }
1335
+
1336
+ return {
1337
+ phase: 'executed',
1338
+ milestone_id: milestoneId,
1339
+ milestone_basename: milestoneBasename,
1340
+ milestone_path: milestoneRelPath,
1341
+ milestone_created: milestoneCreated,
1342
+ spawned_epics: spawnedEpicSpecs.map((s) => ({
1343
+ id: s.id,
1344
+ basename: s.basename,
1345
+ path: s.relPath,
1346
+ })),
1347
+ augmented_epics: augmentEpics.map((e) => e.basename),
1348
+ research_id: ctx.research.frontmatter?.id ?? args.research_id,
1349
+ research_path: ctx.research.relPath,
1350
+ research_status_after: ctx.researchStatus ?? 'open',
1351
+ writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
1352
+ human_review_required: false,
1353
+ ...(depWarnings.length > 0 ? { depends_on_warnings: depWarnings } : {}),
1354
+ ...(roadmapRegistration ? { roadmap_registration: roadmapRegistration } : {}),
1355
+ };
1356
+ }
1357
+
1358
+ function buildUpdatedResearchContent(raw, params) {
1359
+ const fm = getFrontmatterBlock(raw);
1360
+ if (!fm) {
1361
+ throw new Error('research has no frontmatter — cannot promote');
1362
+ }
1363
+ let block = fm.block;
1364
+ // 1. enhances: single wikilink to target milestone.
1365
+ block = setScalarField(block, 'enhances', `"[[${params.milestoneBasename}]]"`);
1366
+ // 2. spawned_epics: append each new epic basename.
1367
+ for (const basename of params.spawnedEpicBasenames) {
1368
+ block = appendToListField(block, 'spawned_epics', basename).block;
1369
+ }
1370
+ // 3. enhances_epics: append each augmented epic basename.
1371
+ for (const basename of params.augmentedEpicBasenames) {
1372
+ block = appendToListField(block, 'enhances_epics', basename).block;
1373
+ }
1374
+ // 4. updated: now. (status is intentionally NOT touched — research is
1375
+ // evergreen and stays `status: open`; see [[research-is-evergreen]].)
1376
+ block = bumpUpdated(block, params.now);
1377
+
1378
+ const change = `Promoted to milestone [[${params.milestoneBasename}]] via /hs-promote-research; spawned ${params.spawnedEpicBasenames.length} epic(s), augmented ${params.augmentedEpicBasenames.length} epic(s). Research stays evergreen (status: open).`;
1379
+ const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
1380
+ return `${fm.open}${block}${fm.close}${newBody}`;
1381
+ }
1382
+
1383
+ function buildUpdatedMilestoneContent(raw, params) {
1384
+ const fm = getFrontmatterBlock(raw);
1385
+ if (!fm) {
1386
+ throw new Error('milestone has no frontmatter — cannot update informed_by');
1387
+ }
1388
+ const informedByList = parseListFieldBasenames(fm.block, 'informed_by') ?? [];
1389
+ if (informedByList.includes(params.researchBasename)) return raw;
1390
+ let block = fm.block;
1391
+ block = appendToListField(block, 'informed_by', params.researchBasename).block;
1392
+ block = bumpUpdated(block, params.now);
1393
+ return `${fm.open}${block}${fm.close}${fm.body}`;
1394
+ }
1395
+
1396
+ // ─────────────────────────────────────────────────────────────────────────────
1397
+ // Public entry point
1398
+ // ─────────────────────────────────────────────────────────────────────────────
1399
+
1400
+ export async function executePromoteResearch(vaultRoot, args = {}, deps = {}) {
1401
+ const validationError = validateInputShape(args);
1402
+ if (validationError) return validationError;
1403
+
1404
+ // 1. Load research file. Resolution happens FIRST (exact basename → .md /
1405
+ // wikilink-stripped → frontmatter id: → unambiguous prefix); the "is this
1406
+ // really research?" check reads the resolved file's `type:`.
1407
+ const resolved = findResearchByIdLocal(vaultRoot, args.research_id);
1408
+ if (!resolved || resolved.error) {
1409
+ return (
1410
+ resolved ?? {
1411
+ error: 'RESEARCH_NOT_FOUND',
1412
+ message: `research ${args.research_id} not found under specs/research/`,
1413
+ }
1414
+ );
1415
+ }
1416
+ const research = resolved.record;
1417
+ research.frontmatter = {};
1418
+ const researchFm = getFrontmatterBlock(research.raw);
1419
+ if (researchFm) {
1420
+ research.frontmatter.id = getScalarField(researchFm.block, 'id') ?? args.research_id;
1421
+ research.frontmatter.name = getScalarField(researchFm.block, 'name') ?? '';
1422
+ // Explicit family inheritance source (HS-TASK-QQHP defect #1) — see
1423
+ // inheritedFamilyKey in runExecutePhase.
1424
+ research.frontmatter.family = getScalarField(researchFm.block, 'family') ?? null;
1425
+ }
1426
+
1427
+ const researchState = validateResearchState(research);
1428
+ if (researchState.error) return researchState;
1429
+
1430
+ // 1b. Unfinished-stub gate — block promotion (both phases; a review call
1431
+ // proposing epic stubs from an unresearched doc is no more useful than an
1432
+ // execute call spawning them) until the research body has actually been
1433
+ // written. See findUnresolvedResearchPlaceholders above.
1434
+ const unresolvedPlaceholders = findUnresolvedResearchPlaceholders(research.raw);
1435
+ if (unresolvedPlaceholders > 0) {
1436
+ return {
1437
+ error: 'RESEARCH_STILL_STUB',
1438
+ message:
1439
+ `research ${research.basename} still has ${unresolvedPlaceholders} unresolved ` +
1440
+ '"_TBD — fill in this section._" placeholder(s) from its promote-capture outline ' +
1441
+ 'stub — the research findings must be authored before promoting it into a ' +
1442
+ 'milestone/epics. Fill it in by running the /hs-res method (SpexCrawl gather via ' +
1443
+ 'the Companion + DeepSeek synthesis); do NOT author the research yourself in the ' +
1444
+ 'main session — that skips the routed research seats. Approving the OUTLINE only ' +
1445
+ 'confirms its shape (section headings); it is not the same as the research having ' +
1446
+ 'been done, and spawning work items from an unresearched stub produces epics with ' +
1447
+ 'no real findings behind them.',
1448
+ research_path: research.relPath,
1449
+ unresolved_placeholder_count: unresolvedPlaceholders,
1450
+ };
1451
+ }
1452
+
1453
+ // 2. Resolve target milestone.
1454
+ const targetResolution = resolveTargetMilestone(vaultRoot, args);
1455
+ if (targetResolution.error) return targetResolution;
1456
+
1457
+ if (targetResolution.mode === 'existing') {
1458
+ const fm = getFrontmatterBlock(targetResolution.milestone.raw);
1459
+ targetResolution.milestone.epicsList = fm
1460
+ ? parseListFieldBasenames(fm.block, 'epics') ?? []
1461
+ : [];
1462
+ targetResolution.milestone.frontmatter = fm
1463
+ ? {
1464
+ id: getScalarField(fm.block, 'id'),
1465
+ name: getScalarField(fm.block, 'name'),
1466
+ }
1467
+ : {};
1468
+ targetResolution.milestone.name = targetResolution.milestone.frontmatter.name ?? targetResolution.milestone.basename;
1469
+ }
1470
+
1471
+ // 3. Bidirectional conflict check (AC #4) + milestone attach policy.
1472
+ if (targetResolution.mode === 'existing') {
1473
+ const attachPolicy = validateExistingMilestoneAttachable(
1474
+ targetResolution.milestone.raw,
1475
+ args,
1476
+ );
1477
+ if (attachPolicy.error) return attachPolicy;
1478
+
1479
+ const conflict = validateBidirectionalEnhances(
1480
+ researchState.enhances,
1481
+ targetResolution.milestone.basename,
1482
+ );
1483
+ if (conflict) return conflict;
1484
+ } else if (researchState.enhances) {
1485
+ // Already enhances a milestone but target is NEW — refuse, the human should
1486
+ // either split the research or pick the existing milestone.
1487
+ return {
1488
+ error: 'RESEARCH_ENHANCES_CONFLICT',
1489
+ message: `research already enhances ${researchState.enhances}; pass target_milestone matching that basename or split the research first.`,
1490
+ existing: researchState.enhances,
1491
+ };
1492
+ }
1493
+
1494
+ const ctx = {
1495
+ vaultRoot,
1496
+ research,
1497
+ researchStatus: researchState.status,
1498
+ targetResolution,
1499
+ };
1500
+
1501
+ // 4. Phase split.
1502
+ const hasAcceptedStubs =
1503
+ Array.isArray(args.accepted_stubs) && args.accepted_stubs.length > 0;
1504
+ const wantsStubProposal =
1505
+ (args.intent === 'spawn' || args.intent === 'both') && !hasAcceptedStubs;
1506
+
1507
+ if (wantsStubProposal) {
1508
+ return runProposalPhase(args, ctx, deps);
1509
+ }
1510
+
1511
+ // Two-turn review gate: an execute turn that spawns epics must descend from a
1512
+ // proposal turn the human actually saw. `augment` writes no epic stubs, so it
1513
+ // is single-phase by design and not gated here.
1514
+ if (hasAcceptedStubs && process.env.HEYSPEX_PROMOTE_REVIEW_GATE_OFF !== '1') {
1515
+ const gate = checkStubReviewGate({
1516
+ marker: readStubReviewMarker(vaultRoot, research.frontmatter?.id ?? args.research_id),
1517
+ acceptedStubs: args.accepted_stubs,
1518
+ args,
1519
+ });
1520
+ if (gate) return gate;
1521
+ }
1522
+
1523
+ return runExecutePhase(args, ctx, deps);
1524
+ }
1525
+
1526
+ export const __testables = {
1527
+ appendToListField,
1528
+ setScalarField,
1529
+ buildUpdatedResearchContent,
1530
+ buildUpdatedMilestoneContent,
1531
+ buildMilestoneStubContent,
1532
+ buildEpicContent,
1533
+ resolveOwnerIntent,
1534
+ renderOwnerIntentSection,
1535
+ parseListFieldBasenames,
1536
+ getFrontmatterBlock,
1537
+ allocateMilestoneId,
1538
+ validateInputShape,
1539
+ normalizeResearchRef,
1540
+ findResearchByIdLocal,
1541
+ validateExistingMilestoneAttachable,
1542
+ MILESTONE_ATTACH_DEFAULT_ALLOWED_STATUSES,
1543
+ resolvePillar,
1544
+ listVaultPillars,
1545
+ normalizePillarRef,
1546
+ inheritedResearchFamilyKey,
1547
+ matchCaseInsensitiveExact,
1548
+ findUnresolvedResearchPlaceholders,
1549
+ checkStubReviewGate,
1550
+ writeStubReviewMarker,
1551
+ stubReviewMarkerPath,
1552
+ EDIT_NOTE_MIN_LENGTH,
1553
+ };