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,1280 @@
1
+ /**
2
+ * /hs-promote-capture MCP tool — capture(s) → research promotion with
3
+ * AI-assisted outline. Spec: [[HS-TASK-WQCE]] of [[HS-EPIC-M2J3]].
4
+ *
5
+ * Symmetric to [[HS-TASK-QQHP]] (/hs-promote-research) but at the lower edge
6
+ * of the lifecycle: one or more `HS-CAP-*` captures get aggregated into a
7
+ * single `HS-RES-*` research doc, with bidirectional linkage wired both ways.
8
+ *
9
+ * Two-phase flow driven by presence/absence of `accepted_outline`:
10
+ *
11
+ * Phase 1 (review): caller invokes with `target_research: "NEW"` and no
12
+ * `accepted_outline`. Returns capture summaries to the calling agent, which
13
+ * authors the outline and shows it to the human for confirmation. No file writes.
14
+ *
15
+ * Phase 2 (execute): caller invokes again with `accepted_outline` populated
16
+ * (or directly with an existing `target_research: "HS-RES-XXX"`). Tool
17
+ * performs the transactional write set:
18
+ * - create-or-update the research file (sources_captures appended)
19
+ * - mutate each capture's `informs_research:` (idempotent append)
20
+ * - set `promoted_to:` on each capture ONLY when it was previously
21
+ * unset/null (binary first-promotion semantics per
22
+ * [[entity-model]] §capture)
23
+ * - append change-log rows on the research + each capture
24
+ * and rolls back anything written if a later step fails.
25
+ *
26
+ * Closed-research target is NOT a hard error (unlike QQHP, where closed
27
+ * research refuses re-promotion). Per spec §5, the tool warns
28
+ * (`warning: "TARGET_RESEARCH_CLOSED"`) and allows the append; the audit
29
+ * trail in the research's change-log records the post-close addition.
30
+ *
31
+ * Linkage written per [[HS-TASK-YJX6]]:
32
+ * research.sources_captures : appended with each capture wikilink
33
+ * capture.informs_research : appended with the research wikilink
34
+ * capture.promoted_to : set to research wikilink on FIRST promotion
35
+ *
36
+ * All writes are staged then committed via the shared `commitWrites`
37
+ * helper from [[./promotion-helpers.mjs]]; any failure rolls back the prior
38
+ * writes (created files deleted, mutated files restored from snapshot).
39
+ */
40
+
41
+ import {
42
+ readFileSync,
43
+ existsSync,
44
+ readdirSync,
45
+ } from 'node:fs';
46
+ import { join } from 'node:path';
47
+ // Sufficiency gate at promotion ([[HS-0CFX-TASK]] / [[HS-3EFV-EPIC]]) — the same
48
+ // dev-factory scorer the Phase-1 entry gate uses (cross-import precedented by
49
+ // fleet-readiness.mjs in cli.mjs; deliberately NOT a third mirror copy).
50
+ import { scoreSufficiency } from '../../dev-factory/lib/sufficiency-score.mjs';
51
+ // Already-expanded bypass ([[HS-55YM-DIAG]]) — reuses the existing
52
+ // capture-focus.mjs deep-dive reader rather than a third regex copy.
53
+ import { extractCaptureExpandedConceptFromBody } from './capture-focus.mjs';
54
+ import {
55
+ mintFileKey,
56
+ mintFamilyKey,
57
+ parseSpecId,
58
+ familyOf,
59
+ bareFileKeyOf,
60
+ lookupFileKeyOf,
61
+ shortLookupIdOf,
62
+ } from './spec-id-lib.mjs';
63
+ import { yamlScalar } from './yaml-scalar.mjs';
64
+ import { suggestResearchType } from './research-type-suggest.mjs';
65
+ import { buildDiagnoseSkeletonSections } from './diagnose-skeleton.mjs';
66
+ // HS-QOBQ: re-check kind at promote time — a capture wrongly filed as `idea`
67
+ // (the default) whose body actually reads as a bug report should be re-kinded
68
+ // before promoting, not silently routed to research (capture-guardrails
69
+ // §"Which exit?"). Same lexical guess the intent classifier suggests at
70
+ // capture time, so both call sites agree.
71
+ import { checkCaptureKindMismatch } from './capture-kind-suggest.mjs';
72
+ import {
73
+ isoNow,
74
+ todayDate,
75
+ deriveSlug,
76
+ findFileByPrefix,
77
+ getFrontmatterBlock,
78
+ getScalarField,
79
+ parseListFieldBasenames,
80
+ setScalarField,
81
+ appendToListField,
82
+ bumpUpdated,
83
+ appendChangeLogRow,
84
+ commitWrites,
85
+ } from './promotion-helpers.mjs';
86
+ // Reuse the exact same placeholder-count logic RESEARCH_STILL_STUB uses to
87
+ // refuse `heyspex_promote_research` on a stub research doc — proactively
88
+ // surfaced here at creation/extension time instead of only reactively at
89
+ // the downstream refusal ([[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]]).
90
+ import { findUnresolvedResearchPlaceholders } from './promote-research.mjs';
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+ // Dual-read id classification ([[HS-MIL-KY1F]] family-first migration).
94
+ //
95
+ // The vault now mints family-first ids (`HS-<familyKey>-<TYPE>`, e.g.
96
+ // `HS-IY9V-CAP`) but legacy specs may still carry the type-first ordering
97
+ // (`HS-CAP-IY9V`). We classify an id/basename by its parsed **type label**
98
+ // rather than a fixed-ordering regex, so both orderings resolve. See
99
+ // vendor/heyspex-mcp/spec-id-lib.mjs (`parseSpecId`).
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+
102
+ /** True when `id` (either ordering) parses to a spec with the given type label. */
103
+ function hasTypeLabel(id, label) {
104
+ const p = parseSpecId(id);
105
+ return !!p && p.typeLabel === label;
106
+ }
107
+
108
+ const isCaptureId = (id) => hasTypeLabel(id, 'CAP');
109
+ const isResearchId = (id) => hasTypeLabel(id, 'RES');
110
+ const isMilestoneId = (id) => hasTypeLabel(id, 'MIL');
111
+
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ // Family inheritance (capture → research), owner ruling 2026-07-12
114
+ // ([[family-id-is-inherited-capture-research]] / [[HS-3JUN-DIAG-HZ8T]]),
115
+ // rewritten for the V3 shared-surname model
116
+ // ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]],
117
+ // [[naming-v3-7-rulings-hs-namespace-kept]]).
118
+ //
119
+ // Research promoted from a capture INHERITS the FIRST/primary source
120
+ // capture's family key — UNCONDITIONALLY, no fallback. Under era-3 minting,
121
+ // `id:` keys onto the per-file file key alone (`HS-<fileKey>`), not the
122
+ // family key, so two research docs sharing one family can never collide on
123
+ // `id:` — the disambiguation problem `FAMILY_INHERITANCE_FALLBACK` guarded
124
+ // against ([[HS-3JUN-DIAG-HZ8T]]) no longer exists. See
125
+ // vault-id-assignment-protocol §Family inheritance ("`FAMILY_INHERITANCE_
126
+ // FALLBACK` is removed, not documented as a case").
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * The family key of a promotion source: explicit `frontmatter.family` when
131
+ * present and well-formed, else the positional family key parsed from `id`,
132
+ * else the positional key parsed from the BASENAME — the same "explicit wins,
133
+ * else positional" precedence as {@link familyOf} (which this delegates to).
134
+ * Returns null when none is derivable.
135
+ *
136
+ * The basename argument is load-bearing under era-3 naming: a minted `id:` is
137
+ * the bare file key (`HS-6VT0`), which parseSpecId cannot read a family out
138
+ * of — the family rides in the basename (`HS-6VT0-84HC-CAP-<slug>`). Passing
139
+ * only the id silently fell through to mintFamilyKey and broke the owner's
140
+ * unconditional-inheritance ruling ([[family-id-is-inherited-capture-research]]).
141
+ * Callers inherit the result directly — no candidate-vs-occupied collision
142
+ * check is needed (see block comment above).
143
+ */
144
+ function inheritedFamilyKey(sourceId, sourceFrontmatter, sourceBasename) {
145
+ return familyOf({ id: sourceId, basename: sourceBasename, frontmatter: sourceFrontmatter });
146
+ }
147
+
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+ // `research_type` — the declared purpose of a research doc, required by
150
+ // [[entity-model]] §research / `schemas/heyspex-frontmatter.schema.json`
151
+ // ([[HS-120I-TASK-implement-research-type-field-end-to-end]]). Written here on
152
+ // every NEW research doc this tool creates — never blank.
153
+ // ─────────────────────────────────────────────────────────────────────────────
154
+
155
+ export const RESEARCH_TYPE_VALUES = [
156
+ 'new-feature',
157
+ 'enhancement',
158
+ 'diagnose',
159
+ 'code-audit',
160
+ 'reference',
161
+ ];
162
+ export const DEFAULT_RESEARCH_TYPE = 'reference';
163
+
164
+ /**
165
+ * Resolve the `research_type` to write for a newly created research doc.
166
+ * Precedence: an explicit `accepted_outline.research_type` (human-confirmed)
167
+ * wins; otherwise a `suggestedType` — computed by
168
+ * [[HS-MVC1-EPIC-deterministic-type-assist-suggest-resear]]'s
169
+ * `suggestResearchType()` from the source capture's `kind` (see
170
+ * `runExecutePhase` below) — is used when present; falls back to
171
+ * {@link DEFAULT_RESEARCH_TYPE}. Never returns a blank/invalid value.
172
+ * @param {unknown} providedValue
173
+ * @param {unknown} [suggestedType] — kind-derived suggestion (HS-MVC1).
174
+ * @returns {string}
175
+ */
176
+ export function resolveResearchType(providedValue, suggestedType = null) {
177
+ if (typeof providedValue === 'string' && RESEARCH_TYPE_VALUES.includes(providedValue)) {
178
+ return providedValue;
179
+ }
180
+ if (typeof suggestedType === 'string' && RESEARCH_TYPE_VALUES.includes(suggestedType)) {
181
+ return suggestedType;
182
+ }
183
+ return DEFAULT_RESEARCH_TYPE;
184
+ }
185
+
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+ // `scope` — how broad a search context this research was run at, per
188
+ // [[entity-model]] §research / `schemas/heyspex-frontmatter.schema.json`
189
+ // ([[HS-NGD1-TASK-consolidate-and-fix-the-research-scope-field]]). NOT
190
+ // required (consistency fix only — no grace window, unlike `research_type`).
191
+ // Written here with the same enum + same default as `heyspex_save_research`
192
+ // and `heyspex_complete_research`.
193
+ // ─────────────────────────────────────────────────────────────────────────────
194
+
195
+ export const RESEARCH_SCOPE_VALUES = ['internal', 'codebase', 'external', 'all'];
196
+ export const DEFAULT_RESEARCH_SCOPE = 'internal';
197
+
198
+ /**
199
+ * Resolve the `scope` to write for a newly created research doc. Falls back
200
+ * to {@link DEFAULT_RESEARCH_SCOPE} for a missing/invalid value.
201
+ * @param {unknown} providedValue
202
+ * @returns {string}
203
+ */
204
+ export function resolveResearchScope(providedValue) {
205
+ if (typeof providedValue === 'string' && RESEARCH_SCOPE_VALUES.includes(providedValue)) {
206
+ return providedValue;
207
+ }
208
+ return DEFAULT_RESEARCH_SCOPE;
209
+ }
210
+
211
+ /**
212
+ * Canonical family-first bare id (`HS-<familyKey>-<TYPE>`) for an id/basename of
213
+ * EITHER ordering, gated to a single type label — or null. Feeds
214
+ * {@link collectOccupiedResearchIds}/{@link collectOccupiedDiagnoseIds}
215
+ * (dual-read occupancy sets, kept for their own test coverage — no longer
216
+ * consulted for era-3 minting, which keys `id:` on the vault-wide-unique file
217
+ * key instead of a per-kind family+type bucket).
218
+ */
219
+ function canonicalIdOfType(value, label) {
220
+ const p = parseSpecId(value);
221
+ if (!p || p.typeLabel !== label) return null;
222
+ return `HS-${p.familyKey}-${p.typeLabel}`;
223
+ }
224
+
225
+ // ─────────────────────────────────────────────────────────────────────────────
226
+ // File loaders
227
+ // ─────────────────────────────────────────────────────────────────────────────
228
+
229
+ function findCaptureByIdLocal(vaultRoot, captureId) {
230
+ const dir = join(vaultRoot, 'specs', 'captures');
231
+ if (!existsSync(dir)) return null;
232
+ const direct = findFileByPrefix(dir, captureId);
233
+ if (direct) {
234
+ const basename = direct.replace(/\.md$/, '');
235
+ const absPath = join(dir, direct);
236
+ return {
237
+ basename,
238
+ absPath,
239
+ relPath: join('specs', 'captures', direct),
240
+ raw: readFileSync(absPath, 'utf8'),
241
+ };
242
+ }
243
+ for (const file of readdirSync(dir)) {
244
+ if (!file.endsWith('.md')) continue;
245
+ const raw = readFileSync(join(dir, file), 'utf8');
246
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
247
+ if (idMatch) {
248
+ const id = idMatch[1].replace(/^['"]|['"]$/g, '');
249
+ if (id === captureId) {
250
+ const basename = file.replace(/\.md$/, '');
251
+ return {
252
+ basename,
253
+ absPath: join(dir, file),
254
+ relPath: join('specs', 'captures', file),
255
+ raw,
256
+ };
257
+ }
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
263
+ function findResearchByIdLocal(vaultRoot, researchId) {
264
+ const dir = join(vaultRoot, 'specs', 'research');
265
+ if (!existsSync(dir)) return null;
266
+ const direct = findFileByPrefix(dir, researchId);
267
+ const tryFile = (file) => {
268
+ const basename = file.replace(/\.md$/, '');
269
+ const absPath = join(dir, file);
270
+ return {
271
+ basename,
272
+ absPath,
273
+ relPath: join('specs', 'research', file),
274
+ raw: readFileSync(absPath, 'utf8'),
275
+ };
276
+ };
277
+ if (direct) return tryFile(direct);
278
+ for (const file of readdirSync(dir)) {
279
+ if (!file.endsWith('.md')) continue;
280
+ const raw = readFileSync(join(dir, file), 'utf8');
281
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
282
+ if (idMatch) {
283
+ const id = idMatch[1].replace(/^['"]|['"]$/g, '');
284
+ if (id === researchId) {
285
+ const basename = file.replace(/\.md$/, '');
286
+ return {
287
+ basename,
288
+ absPath: join(dir, file),
289
+ relPath: join('specs', 'research', file),
290
+ raw,
291
+ };
292
+ }
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+
298
+ function findResearchByBasename(vaultRoot, basename) {
299
+ const absPath = join(vaultRoot, 'specs', 'research', `${basename}.md`);
300
+ if (!existsSync(absPath)) return null;
301
+ return {
302
+ basename,
303
+ absPath,
304
+ relPath: join('specs', 'research', `${basename}.md`),
305
+ raw: readFileSync(absPath, 'utf8'),
306
+ };
307
+ }
308
+
309
+ function collectOccupiedResearchIds(vaultRoot) {
310
+ const occupied = new Set();
311
+ const dir = join(vaultRoot, 'specs', 'research');
312
+ if (!existsSync(dir)) return occupied;
313
+ for (const file of readdirSync(dir)) {
314
+ if (!file.endsWith('.md')) continue;
315
+ const fromBasename = canonicalIdOfType(file.replace(/\.md$/, ''), 'RES');
316
+ if (fromBasename) occupied.add(fromBasename);
317
+ try {
318
+ const raw = readFileSync(join(dir, file), 'utf8');
319
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
320
+ if (idMatch) {
321
+ const id = idMatch[1].replace(/^['"]|['"]$/g, '');
322
+ const fromId = canonicalIdOfType(id, 'RES');
323
+ if (fromId) occupied.add(fromId);
324
+ }
325
+ } catch {
326
+ /* skip */
327
+ }
328
+ }
329
+ return occupied;
330
+ }
331
+
332
+ // ─────────────────────────────────────────────────────────────────────────────
333
+ // Argument validation
334
+ // ─────────────────────────────────────────────────────────────────────────────
335
+
336
+ function validateInputShape(args) {
337
+ if (!args || typeof args !== 'object') {
338
+ return { error: 'INVALID_ARGS', message: 'args must be an object' };
339
+ }
340
+ if (!Array.isArray(args.capture_ids) || args.capture_ids.length === 0) {
341
+ return {
342
+ error: 'INVALID_ARGS',
343
+ message: 'capture_ids must be a non-empty array',
344
+ };
345
+ }
346
+ for (const id of args.capture_ids) {
347
+ // Era-3 minted ids are the bare file key (`HS-<fileKey>`) — deliberately
348
+ // TYPE-LESS, so no label check is possible on the id itself. Any
349
+ // recognizable era-3 form (bare key, display long `HS-<fileKey>-<familyKey>`,
350
+ // full basename) is accepted via lookupFileKeyOf (owner ruling 2026-08-21:
351
+ // resolvers recognize every form the human might paste). The loader
352
+ // (findCaptureByIdLocal, scoped to specs/captures/) is what proves it
353
+ // names a capture, and misses fail as CAPTURE_NOT_FOUND.
354
+ if (typeof id !== 'string' || (!isCaptureId(id) && !lookupFileKeyOf(id))) {
355
+ return {
356
+ error: 'INVALID_ARGS',
357
+ message: `capture_ids entry ${id} is not a capture id (expected HS-<key>-CAP, legacy HS-CAP-<key>, or an era-3 id/basename carrying the file key)`,
358
+ };
359
+ }
360
+ }
361
+ if (typeof args.target_research !== 'string' || !args.target_research.trim()) {
362
+ return { error: 'INVALID_ARGS', message: 'target_research is required' };
363
+ }
364
+ if (args.target_research === 'NEW') {
365
+ if (
366
+ typeof args.new_research_name !== 'string' ||
367
+ !args.new_research_name.trim()
368
+ ) {
369
+ return {
370
+ error: 'INVALID_ARGS',
371
+ message: 'new_research_name is required when target_research === "NEW"',
372
+ };
373
+ }
374
+ } else if (!isResearchId(args.target_research) && !lookupFileKeyOf(args.target_research)) {
375
+ // Any era-3 form accepted for the same reason as capture_ids above — the
376
+ // research loader (specs/research/ scoped) is the existence/type check
377
+ // ([[HS-DQ9H-8J7V-DIAG-promote-capture-cannot-target-era-3]]).
378
+ return {
379
+ error: 'INVALID_ARGS',
380
+ message: `target_research ${args.target_research} is not a research id (expected HS-<key>-RES, legacy HS-RES-<key>, or an era-3 id/basename carrying the file key) or the literal "NEW"`,
381
+ };
382
+ }
383
+ if (
384
+ args.enhances !== undefined &&
385
+ args.enhances !== null &&
386
+ args.enhances !== ''
387
+ ) {
388
+ if (typeof args.enhances !== 'string' || !isMilestoneId(args.enhances)) {
389
+ return {
390
+ error: 'INVALID_ARGS',
391
+ message: `enhances ${args.enhances} must be a milestone id or basename (HS-<key>-MIL, or legacy HS-MIL-<key>)`,
392
+ };
393
+ }
394
+ }
395
+ if (args.accepted_outline !== undefined && args.accepted_outline !== null) {
396
+ if (typeof args.accepted_outline !== 'object') {
397
+ return {
398
+ error: 'INVALID_ARGS',
399
+ message: 'accepted_outline must be an object when provided',
400
+ };
401
+ }
402
+ if (
403
+ typeof args.accepted_outline.name !== 'string' ||
404
+ !args.accepted_outline.name.trim()
405
+ ) {
406
+ return {
407
+ error: 'INVALID_ARGS',
408
+ message: 'accepted_outline.name is required',
409
+ };
410
+ }
411
+ if (!Array.isArray(args.accepted_outline.outline)) {
412
+ return {
413
+ error: 'INVALID_ARGS',
414
+ message: 'accepted_outline.outline must be an array of section names',
415
+ };
416
+ }
417
+ if (
418
+ args.accepted_outline.research_type !== undefined &&
419
+ args.accepted_outline.research_type !== null &&
420
+ !RESEARCH_TYPE_VALUES.includes(args.accepted_outline.research_type)
421
+ ) {
422
+ return {
423
+ error: 'INVALID_ARGS',
424
+ message: `accepted_outline.research_type ${JSON.stringify(args.accepted_outline.research_type)} must be one of: ${RESEARCH_TYPE_VALUES.join(', ')}`,
425
+ };
426
+ }
427
+ if (
428
+ args.accepted_outline.scope !== undefined &&
429
+ args.accepted_outline.scope !== null &&
430
+ !RESEARCH_SCOPE_VALUES.includes(args.accepted_outline.scope)
431
+ ) {
432
+ return {
433
+ error: 'INVALID_ARGS',
434
+ message: `accepted_outline.scope ${JSON.stringify(args.accepted_outline.scope)} must be one of: ${RESEARCH_SCOPE_VALUES.join(', ')}`,
435
+ };
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ // ─────────────────────────────────────────────────────────────────────────────
442
+ // Phase 1: review (AI proposal)
443
+ // ─────────────────────────────────────────────────────────────────────────────
444
+
445
+ async function runProposalPhase(args, ctx, _deps) {
446
+ const captureSummaries = ctx.captures.map((c) => {
447
+ const fm = getFrontmatterBlock(c.raw);
448
+ const name = fm ? getScalarField(fm.block, 'name') : null;
449
+ const tagsList = fm ? parseListFieldBasenames(fm.block, 'tags') : null;
450
+ const body = c.raw
451
+ .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
452
+ .slice(0, 4000);
453
+ // HS-QOBQ: re-check the stored kind against the content at the moment of
454
+ // promotion — the diagnose's own rule is "a capture mistakenly filed as
455
+ // the wrong kind is re-kinded before promoting, not promoted twice."
456
+ const kindCheck = checkCaptureKindMismatch(c.frontmatter?.kind, body);
457
+ return {
458
+ id: c.frontmatter?.id ?? c.basename,
459
+ name: name ?? c.basename,
460
+ body,
461
+ tags: Array.isArray(tagsList) ? tagsList : [],
462
+ kind: kindCheck.storedKind,
463
+ ...(kindCheck.mismatch ? { kind_suggestion: kindCheck.suggestedKind } : {}),
464
+ };
465
+ });
466
+
467
+ const mismatched = captureSummaries.filter((c) => c.kind_suggestion);
468
+
469
+ return {
470
+ phase: 'review',
471
+ capture_ids: ctx.captures.map((c) => c.frontmatter?.id ?? c.basename),
472
+ capture_basenames: ctx.captures.map((c) => c.basename),
473
+ target_research: 'NEW',
474
+ new_research_name: args.new_research_name,
475
+ proposed_outline: null,
476
+ capture_summaries: captureSummaries,
477
+ ai_proposal: {
478
+ ok: false,
479
+ reason: 'agent_authors_outline',
480
+ message:
481
+ 'Outline authoring is delegated to the calling agent — no server-side AI call is made. The agent reads capture_summaries and proposes the outline directly.',
482
+ },
483
+ ...(mismatched.length > 0
484
+ ? {
485
+ warning: 'CAPTURE_KIND_MISMATCH',
486
+ kind_mismatch_message:
487
+ `${mismatched.length} capture${mismatched.length === 1 ? '' : 's'} read as a different kind than stored ` +
488
+ `(${mismatched.map((c) => `${c.id}: stored ${c.kind} → reads like ${c.kind_suggestion}`).join('; ')}). ` +
489
+ 'Per capture-guardrails "Which exit?": a mismatched capture should be re-kinded before promoting, ' +
490
+ 'not promoted twice — offer the reclassification (and, for bug-fix, the promote-to-diagnose exit ' +
491
+ 'instead) to the user before authoring a research outline; call heyspex_update_capture with kind ' +
492
+ 'ONLY on their yes ([[HS-5HYS]] elicitation — suggest, never auto-apply).',
493
+ }
494
+ : {}),
495
+ human_review_required: true,
496
+ next_call_hint:
497
+ 'Author the outline from capture_summaries, show it to the user for confirmation, then call heyspex_promote_capture again with the same args plus `accepted_outline: { name, outline, tags, enhances_suggestion? }` to execute the writes.',
498
+ };
499
+ }
500
+
501
+ // ─────────────────────────────────────────────────────────────────────────────
502
+ // Phase 2: execute (transactional writes)
503
+ // ─────────────────────────────────────────────────────────────────────────────
504
+
505
+ function buildResearchStubContent(researchId, fileKey, familyKey, outline, captureBasenames, enhances, now, suggestedType = null, jtbdSeed = '') {
506
+ const today = now.slice(0, 10);
507
+ const tagsBlock =
508
+ outline.tags && outline.tags.length > 0
509
+ ? `tags:\n${outline.tags.map((t) => ` - "${t}"`).join('\n')}`
510
+ : 'tags: []';
511
+ const sourcesBlock =
512
+ captureBasenames.length === 0
513
+ ? 'sources_captures: []'
514
+ : `sources_captures:\n${captureBasenames
515
+ .map((b) => ` - "[[${b}]]"`)
516
+ .join('\n')}`;
517
+ // HS-MVC1: an explicit outline.research_type (human-confirmed) still wins;
518
+ // suggestedType (kind-derived, see runExecutePhase) fills in only when the
519
+ // outline left research_type unset — never overrides an explicit choice.
520
+ const researchType = resolveResearchType(outline.research_type, suggestedType);
521
+ const researchScope = resolveResearchScope(outline.scope);
522
+ const enhancesLine = enhances ? `enhances: "[[${enhances}]]"\n` : '';
523
+ // Drop any agent-authored "conclusion & intended outcome" heading from the
524
+ // outline — the stub always injects the canonical one below, so we avoid a
525
+ // duplicate section ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]).
526
+ const sectionsBody = outline.outline
527
+ .filter(
528
+ (section) =>
529
+ !/^\s*(conclusion\b.*intended outcome|intended outcome|conclusion & intended outcome)\s*$/i.test(
530
+ section,
531
+ ),
532
+ )
533
+ .map((section) => `## ${section}\n\n_TBD — fill in this section._\n`)
534
+ .join('\n');
535
+ return `---
536
+ id: ${researchId}
537
+ file_id: ${fileKey}
538
+ family: ${familyKey}
539
+ name: ${yamlScalar(outline.name)}
540
+ type: research
541
+ research_type: ${researchType}
542
+ scope: ${researchScope}
543
+ status: open
544
+ priority: 2
545
+ owner: pwher
546
+ created: "${now}"
547
+ updated: "${now}"
548
+ ${enhancesLine}${sourcesBlock}
549
+ ${tagsBlock}
550
+ related: []
551
+ ---
552
+
553
+ # ${outline.name}
554
+
555
+ _Research stub created by /hs-promote-capture from ${captureBasenames.length} capture${captureBasenames.length === 1 ? '' : 's'}. Each section header below is a placeholder — fill in the body during the research pass._
556
+
557
+ ## Conclusion & intended outcome
558
+
559
+ <!-- The root outcome that milestones/epics/tasks spawned from this research refine — translate, don't copy ([[HS-K4D4-2I29-RES-outcome-cascade-through-a-requirements-h]]). -->
560
+ **Conclusion (one line):** _Acting on this research, what is the conclusion?_
561
+
562
+ **What acting on this achieves:** _What changes for the user or system if we act on it? This is the outcome lower levels re-derive at their own altitude._
563
+
564
+ ## JTBD (job-to-be-done)
565
+
566
+ <!-- Validated or revised by research — per [[research-capture-protocol]]'s "JTBD (job-to-be-done) framing" §JTBD-questioning section. State plainly whether research confirms the captured job or revises it, and why. -->
567
+ ${
568
+ jtbdSeed
569
+ ? `**Job, as captured (pending research validation):**\n${jtbdSeed}`
570
+ : '**Job, as captured:** _TBD — the source capture had no interim JTBD block; state the job this research serves._'
571
+ }
572
+
573
+ **Validated or revised by research:** _TBD — once research runs, state plainly whether it confirms this job or revises it (and what changed, if revised)._
574
+
575
+ ${sectionsBody}
576
+ ## Change log
577
+
578
+ | Date | Author | Change |
579
+ | --- | --- | --- |
580
+ | ${today} | promote-capture (agent) | Created from ${captureBasenames.length} capture${captureBasenames.length === 1 ? '' : 's'}: ${captureBasenames.map((b) => `[[${b}]]`).join(', ')}. |
581
+ `;
582
+ }
583
+
584
+ function buildUpdatedResearchContent(raw, params) {
585
+ const fm = getFrontmatterBlock(raw);
586
+ if (!fm) {
587
+ throw new Error('research has no frontmatter — cannot promote captures into it');
588
+ }
589
+ let block = fm.block;
590
+ let changed = false;
591
+ for (const basename of params.captureBasenames) {
592
+ const next = appendToListField(block, 'sources_captures', basename);
593
+ if (next.changed) changed = true;
594
+ block = next.block;
595
+ }
596
+ if (!changed) {
597
+ return { content: raw, changed: false };
598
+ }
599
+ block = bumpUpdated(block, params.now);
600
+ const change = `Added ${params.captureBasenames.length} capture source${params.captureBasenames.length === 1 ? '' : 's'} via /hs-promote-capture: ${params.captureBasenames
601
+ .map((b) => `[[${b}]]`)
602
+ .join(', ')}.${params.warningClosed ? ' (target was status: closed — append recorded on closed research.)' : ''}`;
603
+ const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
604
+ return { content: `${fm.open}${block}${fm.close}${newBody}`, changed: true };
605
+ }
606
+
607
+ function buildUpdatedCaptureContent(raw, params) {
608
+ const fm = getFrontmatterBlock(raw);
609
+ if (!fm) {
610
+ throw new Error('capture has no frontmatter — cannot link informs_research');
611
+ }
612
+ let block = fm.block;
613
+ let changed = false;
614
+ let setPromotedTo = false;
615
+
616
+ // 1. informs_research — always append (idempotent).
617
+ const informsResult = appendToListField(block, 'informs_research', params.researchBasename);
618
+ if (informsResult.changed) changed = true;
619
+ block = informsResult.block;
620
+
621
+ // 2. promoted_to — set only when currently null/absent (FIRST-promotion semantics).
622
+ const currentPromoted = getScalarField(block, 'promoted_to');
623
+ const isUnset =
624
+ currentPromoted === null ||
625
+ currentPromoted === 'null' ||
626
+ currentPromoted === '' ||
627
+ currentPromoted === '~';
628
+ if (isUnset) {
629
+ block = setScalarField(block, 'promoted_to', `"[[${params.researchBasename}]]"`);
630
+ setPromotedTo = true;
631
+ changed = true;
632
+ }
633
+
634
+ if (!changed) {
635
+ return { content: raw, changed: false, setPromotedTo: false };
636
+ }
637
+
638
+ block = bumpUpdated(block, params.now);
639
+ const fragments = ['Linked to research'];
640
+ fragments.push(`[[${params.researchBasename}]]`);
641
+ if (setPromotedTo) fragments.push('(first promotion — promoted_to set)');
642
+ const change = `${fragments.join(' ')} via /hs-promote-capture.`;
643
+ const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
644
+ return {
645
+ content: `${fm.open}${block}${fm.close}${newBody}`,
646
+ changed: true,
647
+ setPromotedTo,
648
+ };
649
+ }
650
+
651
+ async function runExecutePhase(args, ctx, deps) {
652
+ const now = (deps?.clock ?? isoNow)();
653
+ const today = now.slice(0, 10);
654
+ const author = 'promote-capture (agent)';
655
+
656
+ // ── Resolve / allocate target research basename + path ────────────────────
657
+ let researchBasename;
658
+ let researchAbsPath;
659
+ let researchRelPath;
660
+ let researchCreated = false;
661
+ let researchId;
662
+ let researchFileKey = null;
663
+ let researchFamilyKey = null;
664
+ let warningClosed = false;
665
+ let outlineForCreate = null;
666
+
667
+ if (ctx.targetResolution.mode === 'new') {
668
+ // Family inheritance (owner ruling 2026-07-12, see block comment above
669
+ // inheritedFamilyKey): the FIRST/primary source capture's family key wins
670
+ // unconditionally when several captures are promoted together —
671
+ // deterministic, mirrors the HS-MVC1 research_type suggestion precedent
672
+ // (ctx.captures[0]) just below. No fallback: era-3 `id:` keys onto the
673
+ // file key alone, so a shared family can never collide.
674
+ const primaryCapture = ctx.captures[0] ?? null;
675
+ researchFamilyKey =
676
+ (primaryCapture
677
+ ? inheritedFamilyKey(
678
+ primaryCapture.frontmatter?.id ?? null,
679
+ primaryCapture.frontmatter,
680
+ primaryCapture.basename,
681
+ )
682
+ : null) ?? mintFamilyKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
683
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
684
+ // and (V3) the anchor for `id:` itself ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]).
685
+ researchFileKey = mintFileKey(ctx.vaultRoot, { randomUnit: deps?.randomUnit });
686
+ researchId = `HS-${researchFileKey}`;
687
+ const outlineName = args.accepted_outline?.name ?? args.new_research_name;
688
+ const slug = deriveSlug(outlineName);
689
+ researchBasename = `${researchId}-${researchFamilyKey}-RES-${slug}`;
690
+ researchAbsPath = join(
691
+ ctx.vaultRoot,
692
+ 'specs',
693
+ 'research',
694
+ `${researchBasename}.md`,
695
+ );
696
+ researchRelPath = join('specs', 'research', `${researchBasename}.md`);
697
+ researchCreated = true;
698
+
699
+ // Sanity: accepted_outline is required to actually create the file.
700
+ if (!args.accepted_outline) {
701
+ return {
702
+ error: 'NO_ACCEPTED_OUTLINE',
703
+ message:
704
+ 'target_research is "NEW" but no accepted_outline was provided. Call the proposal phase first (omit accepted_outline) and confirm with the human.',
705
+ };
706
+ }
707
+ const baseOutline = {
708
+ name: args.accepted_outline.name.trim(),
709
+ outline: Array.isArray(args.accepted_outline.outline)
710
+ ? args.accepted_outline.outline.filter(
711
+ (s) => typeof s === 'string' && s.trim(),
712
+ )
713
+ : [],
714
+ tags: Array.isArray(args.accepted_outline.tags)
715
+ ? args.accepted_outline.tags.filter(
716
+ (t) => typeof t === 'string' && t.trim(),
717
+ )
718
+ : [],
719
+ // Raw passthrough — resolved (default/validated/suggestion-filled) by
720
+ // buildResearchStubContent via resolveResearchType(). When left null
721
+ // here (no explicit human choice), buildResearchStubContent's second
722
+ // resolveResearchType() arg receives a kind-derived suggestion
723
+ // computed just below (HS-MVC1) instead of falling straight to the
724
+ // blanket DEFAULT_RESEARCH_TYPE.
725
+ research_type:
726
+ typeof args.accepted_outline.research_type === 'string'
727
+ ? args.accepted_outline.research_type
728
+ : null,
729
+ // Raw passthrough — resolved (default/validated) by buildResearchStubContent
730
+ // via resolveResearchScope(). NOT required — a consistency fix only
731
+ // ([[HS-NGD1-TASK-consolidate-and-fix-the-research-scope-field]]).
732
+ scope:
733
+ typeof args.accepted_outline.scope === 'string' ? args.accepted_outline.scope : null,
734
+ };
735
+ if (baseOutline.outline.length === 0) {
736
+ baseOutline.outline = ['Executive Summary', 'Sources'];
737
+ }
738
+ outlineForCreate = baseOutline;
739
+ } else {
740
+ const r = ctx.targetResolution.research;
741
+ researchBasename = r.basename;
742
+ researchAbsPath = r.absPath;
743
+ researchRelPath = r.relPath;
744
+ researchId =
745
+ getScalarField(getFrontmatterBlock(r.raw)?.block ?? '', 'id') ?? researchBasename;
746
+ const status = getScalarField(
747
+ getFrontmatterBlock(r.raw)?.block ?? '',
748
+ 'status',
749
+ );
750
+ if (status === 'closed') warningClosed = true;
751
+ }
752
+
753
+ // ── Stage writes ──────────────────────────────────────────────────────────
754
+ const writes = [];
755
+
756
+ const captureBasenames = ctx.captures.map((c) => c.basename);
757
+
758
+ // Tracks the research doc's body content as it will exist after this call
759
+ // (freshly created, freshly extended, or unchanged) so the success response
760
+ // can proactively report whether it is still an unresolved outline stub —
761
+ // see the `findUnresolvedResearchPlaceholders` import above.
762
+ let finalResearchContent = null;
763
+
764
+ if (researchCreated) {
765
+ const enhancesBasename = resolveEnhancesBasename(args);
766
+ // HS-MVC1: derive a kind→research_type suggestion from the FIRST source
767
+ // capture (deterministic — most promotions are single-capture; when
768
+ // multiple captures with differing kinds are promoted together, the
769
+ // first one's kind wins rather than attempting to merge/reconcile).
770
+ // An explicit outline.research_type still overrides this inside
771
+ // buildResearchStubContent's resolveResearchType() call.
772
+ const suggestedType =
773
+ ctx.captures.length > 0 ? suggestResearchType(ctx.captures[0].frontmatter?.kind) : null;
774
+ // HS-M1MW: seed the stub's JTBD section from the primary capture's
775
+ // interim `JTBD (job-to-be-done):` leading block, when present.
776
+ const jtbdSeed =
777
+ ctx.captures.length > 0 ? extractCaptureJtbd(ctx.captures[0].raw) : '';
778
+ const newContent = buildResearchStubContent(
779
+ researchId,
780
+ researchFileKey,
781
+ researchFamilyKey,
782
+ outlineForCreate,
783
+ captureBasenames,
784
+ enhancesBasename,
785
+ now,
786
+ suggestedType,
787
+ jtbdSeed,
788
+ );
789
+ writes.push({ kind: 'create', absPath: researchAbsPath, content: newContent });
790
+ finalResearchContent = newContent;
791
+ } else {
792
+ const updated = buildUpdatedResearchContent(
793
+ ctx.targetResolution.research.raw,
794
+ {
795
+ captureBasenames,
796
+ warningClosed,
797
+ now,
798
+ today,
799
+ author,
800
+ },
801
+ );
802
+ if (updated.changed) {
803
+ writes.push({
804
+ kind: 'update',
805
+ absPath: researchAbsPath,
806
+ content: updated.content,
807
+ });
808
+ }
809
+ finalResearchContent = updated.changed ? updated.content : ctx.targetResolution.research.raw;
810
+ }
811
+
812
+ // Proactive stub warning ([[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]]):
813
+ // mirrors RESEARCH_STILL_STUB's own placeholder count/language, surfaced here
814
+ // at creation/extension time instead of only reactively when
815
+ // `heyspex_promote_research` later refuses. Non-fatal — the write still
816
+ // proceeds; this only informs the caller what to do next.
817
+ const unresolvedPlaceholderCount = finalResearchContent
818
+ ? findUnresolvedResearchPlaceholders(finalResearchContent)
819
+ : 0;
820
+ const researchStillStub = unresolvedPlaceholderCount > 0;
821
+ const stubNextStep = researchStillStub
822
+ ? `This research doc is an outline of section headings only (${unresolvedPlaceholderCount} ` +
823
+ 'unresolved "_TBD — fill in this section._" placeholder(s)) — not authored findings yet. ' +
824
+ 'Run the /hs-res method against it (SpexCrawl gather via the Companion + DeepSeek synthesis) ' +
825
+ 'before calling heyspex_promote_research; do NOT author the research yourself in the main ' +
826
+ 'session — that skips the routed research seats.'
827
+ : undefined;
828
+
829
+ // Capture mutations.
830
+ const capturePromotionResults = [];
831
+ for (const c of ctx.captures) {
832
+ const updated = buildUpdatedCaptureContent(c.raw, {
833
+ researchBasename,
834
+ now,
835
+ today,
836
+ author,
837
+ });
838
+ capturePromotionResults.push({
839
+ capture_id: c.frontmatter?.id ?? c.basename,
840
+ capture_basename: c.basename,
841
+ informs_research_added: updated.changed,
842
+ promoted_to_set: updated.setPromotedTo,
843
+ });
844
+ if (updated.changed) {
845
+ writes.push({
846
+ kind: 'update',
847
+ absPath: c.absPath,
848
+ content: updated.content,
849
+ });
850
+ }
851
+ }
852
+
853
+ if (writes.length === 0) {
854
+ // Idempotent no-op: research already references every input capture and
855
+ // each capture already references the research. Surface this clearly.
856
+ return {
857
+ phase: 'executed',
858
+ research_id: researchId,
859
+ research_basename: researchBasename,
860
+ research_path: researchRelPath,
861
+ research_created: false,
862
+ captures: capturePromotionResults,
863
+ writes: [],
864
+ warning: warningClosed ? 'TARGET_RESEARCH_CLOSED' : undefined,
865
+ no_op: true,
866
+ human_review_required: false,
867
+ research_still_stub: researchStillStub,
868
+ unresolved_placeholder_count: unresolvedPlaceholderCount,
869
+ next_step: stubNextStep,
870
+ };
871
+ }
872
+
873
+ // ── Commit ────────────────────────────────────────────────────────────────
874
+ const commitResult = commitWrites(writes);
875
+ if (!commitResult.ok) {
876
+ return {
877
+ error: 'WRITE_FAILED',
878
+ message: commitResult.error?.message ?? 'unknown write failure',
879
+ rollback_error: commitResult.error?.rollbackError,
880
+ };
881
+ }
882
+
883
+ return {
884
+ phase: 'executed',
885
+ research_id: researchId,
886
+ research_basename: researchBasename,
887
+ research_path: researchRelPath,
888
+ research_created: researchCreated,
889
+ captures: capturePromotionResults,
890
+ writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
891
+ // TARGET_RESEARCH_CLOSED only occurs on the existing-research branch
892
+ // (researchCreated === false). Family inheritance on the NEW-research
893
+ // branch no longer has a fallback case to warn about (era-3 minting —
894
+ // see the block comment above inheritedFamilyKey).
895
+ warning: warningClosed ? 'TARGET_RESEARCH_CLOSED' : undefined,
896
+ human_review_required: false,
897
+ // Proactive stub signal — same placeholder count RESEARCH_STILL_STUB would
898
+ // later refuse on, surfaced here instead of only reactively. See
899
+ // [[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]].
900
+ research_still_stub: researchStillStub,
901
+ unresolved_placeholder_count: unresolvedPlaceholderCount,
902
+ next_step: stubNextStep,
903
+ };
904
+ }
905
+
906
+ function resolveEnhancesBasename(args) {
907
+ if (!args.enhances) return null;
908
+ // Pass through a milestone id/basename of either ordering (validated upstream
909
+ // by isMilestoneId). A bare id relies on the caller supplying a real existing
910
+ // milestone basename when they need the link to survive the vault validator.
911
+ return args.enhances;
912
+ }
913
+
914
+ // ─────────────────────────────────────────────────────────────────────────────
915
+ // Public entry point
916
+ // ─────────────────────────────────────────────────────────────────────────────
917
+
918
+ export async function executePromoteCapture(vaultRoot, args = {}, deps = {}) {
919
+ const validationError = validateInputShape(args);
920
+ if (validationError) return validationError;
921
+
922
+ // 1. Load every input capture; fail on first miss. Ids collapse to the
923
+ // short stored form first (display long `HS-XV4W-HV6H` → `HS-XV4W`) so the
924
+ // basename-prefix and frontmatter-id matches inside the loader hit for any
925
+ // pasted form — legacy `HS-CAP-*` ids pass through unchanged.
926
+ const captures = [];
927
+ for (const rawCaptureId of args.capture_ids) {
928
+ const captureId = shortLookupIdOf(rawCaptureId) ?? rawCaptureId;
929
+ const capture = findCaptureByIdLocal(vaultRoot, captureId);
930
+ if (!capture) {
931
+ return {
932
+ error: 'CAPTURE_NOT_FOUND',
933
+ message: `capture ${captureId} not found under specs/captures/`,
934
+ };
935
+ }
936
+ capture.frontmatter = {};
937
+ const fm = getFrontmatterBlock(capture.raw);
938
+ if (fm) {
939
+ capture.frontmatter.id = getScalarField(fm.block, 'id') ?? captureId;
940
+ capture.frontmatter.name = getScalarField(fm.block, 'name') ?? '';
941
+ // `family:` feeds the capture→research family-inheritance ruling
942
+ // ([[family-id-is-inherited-capture-research]]) — explicit frontmatter
943
+ // wins over the positional basename read inside inheritedFamilyKey.
944
+ capture.frontmatter.family = getScalarField(fm.block, 'family') ?? null;
945
+ // `kind:` drives the HS-MVC1 research_type suggestion below — read
946
+ // raw (unvalidated); an unrecognized/missing value safely falls back
947
+ // to the default inside suggestResearchType().
948
+ capture.frontmatter.kind = getScalarField(fm.block, 'kind') ?? null;
949
+ }
950
+ captures.push(capture);
951
+ }
952
+
953
+ // 1b. Sufficiency gate ([[HS-0CFX-TASK]]): an insufficient capture does not
954
+ // silently become research — the caller gets the missing elements as specific
955
+ // asks (elicit, improve the capture, re-promote), or overrides deliberately
956
+ // with `force_promote: true` (recorded in the response). Deterministic lexical
957
+ // scorer; injectable for tests via deps.scoreSufficiencyFn.
958
+ if (args.force_promote !== true) {
959
+ const scoreFn = deps.scoreSufficiencyFn ?? scoreSufficiency;
960
+ const insufficient = [];
961
+ for (const capture of captures) {
962
+ const fm = getFrontmatterBlock(capture.raw);
963
+ const body = fm ? fm.body : capture.raw;
964
+ // Already-expanded bypass ([[HS-55YM-DIAG]]): the gate's pre-expand,
965
+ // pipeline-entry rationale ([[HS-KNIF-TASK-BN60]] — catch weak,
966
+ // source-only input before an UNATTENDED pipeline expands it) does not
967
+ // apply once a human/agent has already grounded/expanded the capture —
968
+ // `## Expanded Concept` populated means real, reviewed content already
969
+ // exists, so re-blocking promotion here is not meaningful. Skip scoring
970
+ // entirely (no force_promote needed). Mirrored in
971
+ // spex-console/src/lib/capture-vault.ts's gateCapturePromotion — no
972
+ // drift between the two gate call sites.
973
+ if (extractCaptureExpandedConceptFromBody(body).trim().length > 0) {
974
+ continue;
975
+ }
976
+ const doc = {
977
+ frontmatter: { kind: capture.frontmatter?.kind ?? undefined },
978
+ body,
979
+ };
980
+ let suff;
981
+ try {
982
+ suff = scoreFn(doc);
983
+ } catch {
984
+ suff = { pass: false, score: 0, missing: ['*scorer-error*'] };
985
+ }
986
+ if (!suff?.pass) {
987
+ insufficient.push({
988
+ capture_id: capture.frontmatter?.id ?? 'unknown',
989
+ score: suff?.score ?? 0,
990
+ missing: suff?.missing ?? [],
991
+ });
992
+ }
993
+ }
994
+ if (insufficient.length > 0) {
995
+ return {
996
+ error: 'CAPTURE_INSUFFICIENT',
997
+ message:
998
+ 'Promotion blocked: capture(s) below the sufficiency bar. Elicit the missing elements from the user (add them to the capture), then re-promote — or, if the human explicitly wants to proceed anyway, retry with force_promote: true.',
999
+ insufficient,
1000
+ how_to_resolve:
1001
+ 'Missing element keys map to heyspex_update_capture arguments (call it with the SAME spec_id, then re-promote): core-idea → core_idea, example → example (label real examples "Real example:"), done-criteria → done_when (a "## Done When" checklist), kind → kind (a non-default classification: bug-fix/clip/reference/question), user-stories → user_stories. Elicit each from the user before writing it — do not invent content to pass the gate.',
1002
+ };
1003
+ }
1004
+ }
1005
+
1006
+ // 2. Resolve target research.
1007
+ let targetResolution;
1008
+ if (args.target_research === 'NEW') {
1009
+ targetResolution = { mode: 'new' };
1010
+ } else {
1011
+ const research = findResearchByIdLocal(
1012
+ vaultRoot,
1013
+ shortLookupIdOf(args.target_research) ?? args.target_research,
1014
+ );
1015
+ if (!research) {
1016
+ return {
1017
+ error: 'RESEARCH_NOT_FOUND',
1018
+ message: `target_research ${args.target_research} not found under specs/research/`,
1019
+ };
1020
+ }
1021
+ targetResolution = { mode: 'existing', research };
1022
+ }
1023
+
1024
+ const ctx = { vaultRoot, captures, targetResolution };
1025
+
1026
+ // 3. Phase split.
1027
+ // - target_research === "NEW" + no accepted_outline → run AI proposer (Phase 1).
1028
+ // - existing target_research → skip AI, go straight to execute.
1029
+ // - target_research === "NEW" + accepted_outline → execute.
1030
+ const wantsProposal =
1031
+ targetResolution.mode === 'new' && !args.accepted_outline;
1032
+ if (wantsProposal) {
1033
+ return runProposalPhase(args, ctx, deps);
1034
+ }
1035
+
1036
+ return runExecutePhase(args, ctx, deps);
1037
+ }
1038
+
1039
+ // ─────────────────────────────────────────────────────────────────────────────
1040
+ // Capture → DIAGNOSE promotion ([[HS-MIL-BFIX]]). A bug-report capture becomes a
1041
+ // first-class `HS-DIAG-*` diagnose spec (the executable bug fix). 1:1 (one
1042
+ // capture → one diagnose), no AI outline — simpler than research aggregation.
1043
+ // Reuses the same promotion-helpers + transactional commitWrites.
1044
+ // ─────────────────────────────────────────────────────────────────────────────
1045
+
1046
+ const VALID_DIAGNOSE_SEVERITIES = new Set(['error', 'warning', 'info']);
1047
+
1048
+ function collectOccupiedDiagnoseIds(vaultRoot) {
1049
+ const occupied = new Set();
1050
+ const dir = join(vaultRoot, 'specs', 'diagnoses');
1051
+ if (!existsSync(dir)) return occupied;
1052
+ for (const file of readdirSync(dir)) {
1053
+ if (!file.endsWith('.md')) continue;
1054
+ const fromBasename = canonicalIdOfType(file.replace(/\.md$/, ''), 'DIAG');
1055
+ if (fromBasename) occupied.add(fromBasename);
1056
+ try {
1057
+ const raw = readFileSync(join(dir, file), 'utf8');
1058
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
1059
+ if (idMatch) {
1060
+ const id = idMatch[1].replace(/^['"]|['"]$/g, '');
1061
+ const fromId = canonicalIdOfType(id, 'DIAG');
1062
+ if (fromId) occupied.add(fromId);
1063
+ }
1064
+ } catch {
1065
+ /* skip */
1066
+ }
1067
+ }
1068
+ return occupied;
1069
+ }
1070
+
1071
+ /** Pull the `# Core Idea` paragraph from a v2 capture body as the bug symptom. */
1072
+ function extractCaptureCoreIdea(raw) {
1073
+ const fm = getFrontmatterBlock(raw);
1074
+ const body = fm ? fm.body : raw;
1075
+ const m = body.match(/#\s*Core Idea\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
1076
+ if (!m) return '';
1077
+ return m[1].trim();
1078
+ }
1079
+
1080
+ /**
1081
+ * Pull the interim `JTBD (job-to-be-done):` leading block from a capture's
1082
+ * `## Expanded Concept` section ([[research-capture-protocol]] §"JTBD
1083
+ * (job-to-be-done) framing" — interim home until [[HS-TIE7-PR20-TASK-jtbd-job-to-be-done-field-on-captures]]
1084
+ * ships a dedicated field). Returns '' when no such block is present so
1085
+ * callers can fall back to a placeholder prompt.
1086
+ */
1087
+ function extractCaptureJtbd(raw) {
1088
+ const fm = getFrontmatterBlock(raw);
1089
+ const body = fm ? fm.body : raw;
1090
+ const section = body.match(/##\s*Expanded Concept\s*\n+([\s\S]*?)(?=\n#{1,6}\s|$)/i);
1091
+ if (!section) return '';
1092
+ const m = section[1].match(/JTBD\s*\(job-to-be-done\):\s*\n+((?:\s*-[^\n]*\n?)+)/i);
1093
+ if (!m) return '';
1094
+ return m[1].trim();
1095
+ }
1096
+
1097
+ function buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, captureBasename, coreIdea, now) {
1098
+ const today = now.slice(0, 10);
1099
+ const symptom = coreIdea
1100
+ ? coreIdea
1101
+ : '_Describe the observed bug (promoted from the source capture below)._';
1102
+ return `---
1103
+ id: ${diagnoseId}
1104
+ file_id: ${fileKey}
1105
+ family: ${familyKey}
1106
+ name: ${yamlScalar(name)}
1107
+ type: diagnose
1108
+ status: open
1109
+ severity: ${severity}
1110
+ owner: pwher
1111
+ created: "${now}"
1112
+ updated: "${now}"
1113
+ promoted_from: "[[${captureBasename}]]"
1114
+ tags:
1115
+ - "bug-fix"
1116
+ related:
1117
+ - "[[${captureBasename}]]"
1118
+ ---
1119
+
1120
+ # ${name}
1121
+
1122
+ ## Symptom
1123
+
1124
+ ${symptom}
1125
+
1126
+ ## Reproduction
1127
+
1128
+ _TBD — steps to reproduce._
1129
+
1130
+ ${buildDiagnoseSkeletonSections()}
1131
+
1132
+ ## Change log
1133
+
1134
+ | Date | Author | Change |
1135
+ | --- | --- | --- |
1136
+ | ${today} | promote-capture (agent) | Created from capture [[${captureBasename}]] via /hs-promote-capture (diagnose). |
1137
+ `;
1138
+ }
1139
+
1140
+ /** Capture mutation for diagnose promotion: set promoted_to (first) + informs_diagnose. */
1141
+ function buildUpdatedCaptureContentForDiagnose(raw, params) {
1142
+ const fm = getFrontmatterBlock(raw);
1143
+ if (!fm) {
1144
+ throw new Error('capture has no frontmatter — cannot link informs_diagnose');
1145
+ }
1146
+ let block = fm.block;
1147
+ let changed = false;
1148
+ let setPromotedTo = false;
1149
+
1150
+ const informsResult = appendToListField(block, 'informs_diagnose', params.diagnoseBasename);
1151
+ if (informsResult.changed) changed = true;
1152
+ block = informsResult.block;
1153
+
1154
+ const currentPromoted = getScalarField(block, 'promoted_to');
1155
+ const isUnset =
1156
+ currentPromoted === null ||
1157
+ currentPromoted === 'null' ||
1158
+ currentPromoted === '' ||
1159
+ currentPromoted === '~';
1160
+ if (isUnset) {
1161
+ block = setScalarField(block, 'promoted_to', `"[[${params.diagnoseBasename}]]"`);
1162
+ setPromotedTo = true;
1163
+ changed = true;
1164
+ }
1165
+
1166
+ if (!changed) return { content: raw, changed: false, setPromotedTo: false };
1167
+
1168
+ block = bumpUpdated(block, params.now);
1169
+ const fragments = ['Promoted to diagnose', `[[${params.diagnoseBasename}]]`];
1170
+ if (setPromotedTo) fragments.push('(first promotion — promoted_to set)');
1171
+ const change = `${fragments.join(' ')} via /hs-promote-capture.`;
1172
+ const newBody = appendChangeLogRow(fm.body, params.today, params.author, change);
1173
+ return { content: `${fm.open}${block}${fm.close}${newBody}`, changed: true, setPromotedTo };
1174
+ }
1175
+
1176
+ /**
1177
+ * Promote a single capture into a new `HS-DIAG-*` diagnose spec. Single-phase
1178
+ * (no AI outline): the capture's Core Idea seeds the Symptom; the rest is a
1179
+ * fill-in skeleton. Bidirectional linkage: diagnose.promoted_from ⇔
1180
+ * capture.promoted_to / informs_diagnose.
1181
+ */
1182
+ export async function executePromoteCaptureToDiagnose(vaultRoot, args = {}, deps = {}) {
1183
+ // Same any-form id tolerance as executePromoteCapture (owner ruling
1184
+ // 2026-08-21): collapse a display/basename form to the stored short id.
1185
+ const rawCaptureId = args.capture_id;
1186
+ if (typeof rawCaptureId !== 'string' || (!isCaptureId(rawCaptureId) && !lookupFileKeyOf(rawCaptureId))) {
1187
+ return { error: 'INVALID_CAPTURE_ID', message: 'capture_id must be a capture id (HS-<key>-CAP, legacy HS-CAP-<key>, or an era-3 id/basename carrying the file key)' };
1188
+ }
1189
+ const captureId = isCaptureId(rawCaptureId) ? rawCaptureId : (shortLookupIdOf(rawCaptureId) ?? rawCaptureId);
1190
+ const severityRaw = typeof args.severity === 'string' ? args.severity.trim().toLowerCase() : 'warning';
1191
+ const severity = VALID_DIAGNOSE_SEVERITIES.has(severityRaw) ? severityRaw : 'warning';
1192
+
1193
+ const capture = findCaptureByIdLocal(vaultRoot, captureId);
1194
+ if (!capture) {
1195
+ return { error: 'CAPTURE_NOT_FOUND', message: `capture ${captureId} not found under specs/captures/` };
1196
+ }
1197
+
1198
+ const captureFm = getFrontmatterBlock(capture.raw);
1199
+ const captureName = captureFm ? (getScalarField(captureFm.block, 'name') ?? '') : '';
1200
+ const name = (typeof args.name === 'string' && args.name.trim())
1201
+ ? args.name.trim()
1202
+ : (captureName || `Bug from ${captureId}`).replace(/^Capture\s+/i, '');
1203
+
1204
+ const now = (deps?.clock ?? isoNow)();
1205
+ const today = now.slice(0, 10);
1206
+ const coreIdea = extractCaptureCoreIdea(capture.raw);
1207
+
1208
+ // A capture→diagnose promotion has no documented family-inheritance hop
1209
+ // (unlike capture→research) — mints a fresh family, same as any other
1210
+ // parentless spec.
1211
+ const familyKey = mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
1212
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
1213
+ // and (V3) the anchor for `id:` itself.
1214
+ const fileKey = mintFileKey(vaultRoot, { randomUnit: deps?.randomUnit });
1215
+ const diagnoseId = `HS-${fileKey}`;
1216
+ const slug = deriveSlug(name);
1217
+ const diagnoseBasename = `${diagnoseId}-${familyKey}-DIAG-${slug}`;
1218
+ const diagnoseRelPath = `specs/diagnoses/${diagnoseBasename}.md`;
1219
+ const diagnoseAbsPath = join(vaultRoot, 'specs', 'diagnoses', `${diagnoseBasename}.md`);
1220
+
1221
+ const writes = [];
1222
+ writes.push({
1223
+ kind: 'create',
1224
+ absPath: diagnoseAbsPath,
1225
+ content: buildDiagnoseContent(diagnoseId, fileKey, familyKey, name, severity, capture.basename, coreIdea, now),
1226
+ });
1227
+
1228
+ const captureUpdate = buildUpdatedCaptureContentForDiagnose(capture.raw, {
1229
+ diagnoseBasename,
1230
+ now,
1231
+ today,
1232
+ author: 'promote-capture (agent)',
1233
+ });
1234
+ if (captureUpdate.changed) {
1235
+ writes.push({ kind: 'update', absPath: capture.absPath, content: captureUpdate.content });
1236
+ }
1237
+
1238
+ const commitResult = commitWrites(writes);
1239
+ if (!commitResult.ok) {
1240
+ return {
1241
+ error: 'WRITE_FAILED',
1242
+ message: commitResult.error?.message ?? 'unknown write failure',
1243
+ rollback_error: commitResult.error?.rollbackError,
1244
+ };
1245
+ }
1246
+
1247
+ return {
1248
+ phase: 'executed',
1249
+ diagnose_id: diagnoseId,
1250
+ diagnose_basename: diagnoseBasename,
1251
+ diagnose_path: diagnoseRelPath,
1252
+ diagnose_created: true,
1253
+ severity,
1254
+ capture: {
1255
+ capture_id: captureId,
1256
+ capture_basename: capture.basename,
1257
+ informs_diagnose_added: captureUpdate.changed,
1258
+ promoted_to_set: captureUpdate.setPromotedTo,
1259
+ },
1260
+ writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
1261
+ human_review_required: false,
1262
+ };
1263
+ }
1264
+
1265
+ export const __testables = {
1266
+ validateInputShape,
1267
+ findCaptureByIdLocal,
1268
+ findResearchByIdLocal,
1269
+ findResearchByBasename,
1270
+ collectOccupiedResearchIds,
1271
+ collectOccupiedDiagnoseIds,
1272
+ extractCaptureCoreIdea,
1273
+ buildDiagnoseContent,
1274
+ buildResearchStubContent,
1275
+ buildUpdatedResearchContent,
1276
+ buildUpdatedCaptureContent,
1277
+ resolveEnhancesBasename,
1278
+ resolveResearchType,
1279
+ inheritedFamilyKey,
1280
+ };