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,832 @@
1
+ /**
2
+ * /hs-split-research MCP tool — split a single research doc into N child
3
+ * research docs (each targeting one milestone) before promotion. Spec:
4
+ * [[HS-TASK-GTH9]] of [[HS-EPIC-M2J3]].
5
+ *
6
+ * Single-phase flow (caller supplies body_slice + sources_captures partitions):
7
+ *
8
+ * 1. Load + validate the original research (must be type: research, not
9
+ * already split, ≥2 split entries provided).
10
+ * 2. For each split entry: resolve target_milestone (existing HS-MIL-* or
11
+ * "NEW" placeholder), verify sources_captures ⊆ original.sources_captures,
12
+ * allocate HS-RES-<id>, build child basename.
13
+ * 3. Stage writes transactionally:
14
+ * - Create N child research files (split_from, sources_captures,
15
+ * body_slice, "Split-from:" callout prepended, change-log row).
16
+ * - Update original: set split_into: [children], prepend "Split:" callout,
17
+ * append change-log row. Status stays `open` — research is evergreen.
18
+ * - For each capture in any child's sources_captures: append the child
19
+ * research basename to capture.informs_research (bidirectional rule
20
+ * per [[HS-TASK-YJX6]]).
21
+ * 4. Commit. On any single-step failure, roll back all writes.
22
+ *
23
+ * Children land as status: open — promotion to a milestone happens later via
24
+ * /hs-promote-research ([[HS-TASK-QQHP]]); split is content partitioning, not
25
+ * milestone binding. The "target_milestone" arg is recorded in each child's
26
+ * body callout (for human readability) but does NOT write to child.enhances
27
+ * (which is only required at promotion time, per [[HS-TASK-YJX6]]).
28
+ *
29
+ * The split archive stays `status: open` too (research is evergreen,
30
+ * [[research-is-evergreen]]) and sets `split_into:` to record its children. It
31
+ * carries no `enhances:` of its own (each child enhances its own milestone at
32
+ * promotion), which is fine under the "enhances REQUIRED when set" rule in
33
+ * [[entity-model]]. Re-split is guarded by the presence of `split_into:`, not by
34
+ * a status flip.
35
+ */
36
+
37
+ import {
38
+ readFileSync,
39
+ writeFileSync,
40
+ existsSync,
41
+ readdirSync,
42
+ mkdirSync,
43
+ unlinkSync,
44
+ } from 'node:fs';
45
+ import { join } from 'node:path';
46
+ import { deriveSlug } from './promotion-helpers.mjs';
47
+ import { yamlScalar } from './yaml-scalar.mjs';
48
+ import {
49
+ allocateFileKey,
50
+ collectOccupiedKeysEitherKind,
51
+ mintFamilyKey,
52
+ parseSpecId,
53
+ familyOf,
54
+ } from './spec-id-lib.mjs';
55
+ // Same sweep as heyspex_promote_capture's proactive stub warning
56
+ // ([[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]]): a caller can
57
+ // split a research doc that is still its own promote-capture outline stub
58
+ // (e.g. splitting before running /hs-res) — each child then inherits the
59
+ // same `_TBD — fill in this section._` placeholders, and without this the
60
+ // caller would only learn that reactively when heyspex_promote_research
61
+ // later refuses a child with RESEARCH_STILL_STUB.
62
+ import { findUnresolvedResearchPlaceholders } from './promote-research.mjs';
63
+
64
+ // Id validation/classification is via parseSpecId (dual-read: accepts type-first
65
+ // HS-RES-<key> / HS-MIL-<key> / HS-CAP-<key> and the family-first orderings, plus
66
+ // legacy numeric keys), so no hand-rolled type-first regexes are kept here
67
+ // ([[HS-EPIC-DLE3]]).
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+ // Family inheritance (split children → parent research's family), owner
71
+ // ruling 2026-07-12 ([[family-id-is-inherited-capture-research]] /
72
+ // [[HS-3JUN-DIAG-HZ8T]]), rewritten for the V3 shared-surname model
73
+ // ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]],
74
+ // [[naming-v3-7-rulings-hs-namespace-kept]]). Same disambiguation rule as
75
+ // promote-capture.mjs (duplicated per this file's own "independently
76
+ // shippable" convention): under era-3 minting, `id:` keys onto the per-file
77
+ // file key alone, not the family key, so any number of research docs — the
78
+ // archived original AND every split child — can now legally share one
79
+ // family key at once. Every child inherits the ORIGINAL research's family
80
+ // key UNCONDITIONALLY; there is no collision to fall back from, so
81
+ // `FAMILY_INHERITANCE_FALLBACK` is dead.
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * The family key `sourceId` carries: explicit `frontmatter.family` when
86
+ * present, else the positional family key parsed from `id` (dual-read,
87
+ * either ordering) — delegates to {@link familyOf}. Returns null when
88
+ * `sourceId` doesn't parse and no frontmatter was supplied.
89
+ */
90
+ function inheritedFamilyKey(sourceId, sourceFrontmatter) {
91
+ return familyOf({ id: sourceId, frontmatter: sourceFrontmatter });
92
+ }
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Generic helpers (mirrored from promote-research.mjs — duplicated rather
96
+ // than imported to keep the two MCP tools independently shippable; if a third
97
+ // caller emerges, extract into a shared research-frontmatter.mjs module).
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+
100
+ function isoNow() {
101
+ return new Date().toISOString();
102
+ }
103
+
104
+ function getFrontmatterBlock(raw) {
105
+ const m = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/);
106
+ if (!m) return null;
107
+ return {
108
+ open: m[1],
109
+ block: m[2],
110
+ close: m[3],
111
+ body: raw.slice(m[0].length),
112
+ full: m[0],
113
+ };
114
+ }
115
+
116
+ function getScalarField(block, field) {
117
+ const re = new RegExp(`^${field}:[ \\t]*(.*)$`, 'm');
118
+ const m = block.match(re);
119
+ if (!m) return null;
120
+ const raw = m[1].trim();
121
+ if (!raw) return null;
122
+ return raw.replace(/^['"]|['"]$/g, '');
123
+ }
124
+
125
+ function parseWikilinkBasename(value) {
126
+ if (!value) return null;
127
+ const m = value.match(/^\[\[([^\]]+)\]\]$/);
128
+ return m ? m[1] : null;
129
+ }
130
+
131
+ function parseListFieldBasenames(block, field) {
132
+ const emptyRe = new RegExp(`^${field}:[ \\t]*\\[[ \\t]*\\][ \\t]*$`, 'm');
133
+ if (emptyRe.test(block)) return [];
134
+ const headRe = new RegExp(`^${field}:[ \\t]*\\r?\\n`, 'm');
135
+ const headMatch = block.match(headRe);
136
+ if (!headMatch) return null;
137
+ const startIdx = headMatch.index + headMatch[0].length;
138
+ const lines = block.slice(startIdx).split(/\r?\n/);
139
+ const items = [];
140
+ for (const line of lines) {
141
+ if (/^[ \t]+-[^\n]*$/.test(line)) {
142
+ items.push(line);
143
+ continue;
144
+ }
145
+ break;
146
+ }
147
+ if (items.length === 0) return null;
148
+ const out = [];
149
+ for (const line of items) {
150
+ const wm = line.match(/-[ \t]*["']?\[\[([^\]]+)\]\]["']?/);
151
+ if (wm) out.push(wm[1]);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function appendToListField(block, field, basename) {
157
+ const existing = parseListFieldBasenames(block, field);
158
+ if (existing && existing.includes(basename)) return { block, changed: false };
159
+
160
+ const emptyRe = new RegExp(`^${field}:[ \\t]*\\[[ \\t]*\\][ \\t]*$`, 'm');
161
+ if (emptyRe.test(block)) {
162
+ const replacement = `${field}:\n - "[[${basename}]]"`;
163
+ return { block: block.replace(emptyRe, replacement), changed: true };
164
+ }
165
+
166
+ if (existing !== null) {
167
+ const headRe = new RegExp(`^${field}:[ \\t]*\\r?\\n`, 'm');
168
+ const headMatch = block.match(headRe);
169
+ if (headMatch) {
170
+ const before = block.slice(0, headMatch.index);
171
+ const startIdx = headMatch.index + headMatch[0].length;
172
+ let endIdx = startIdx;
173
+ const lines = block.slice(startIdx).split(/\r?\n/);
174
+ for (const line of lines) {
175
+ if (/^[ \t]+-[^\n]*$/.test(line)) {
176
+ endIdx += line.length + 1;
177
+ continue;
178
+ }
179
+ break;
180
+ }
181
+ const after = endIdx > block.length ? '' : block.slice(endIdx - 1);
182
+ const allItems = [...existing, basename]
183
+ .map((b) => ` - "[[${b}]]"`)
184
+ .join('\n');
185
+ const next = `${before}${field}:\n${allItems}${after.startsWith('\n') ? after : '\n' + after}`;
186
+ return { block: next.replace(/\n+$/, ''), changed: true };
187
+ }
188
+ }
189
+
190
+ const appended = `${block}\n${field}:\n - "[[${basename}]]"`;
191
+ return { block: appended, changed: true };
192
+ }
193
+
194
+ function setScalarField(block, field, value) {
195
+ const re = new RegExp(`^${field}:[ \\t]*(.*)$`, 'm');
196
+ if (re.test(block)) {
197
+ return block.replace(re, `${field}: ${value}`);
198
+ }
199
+ const trimmed = block.replace(/\r?\n$/, '');
200
+ return `${trimmed}\n${field}: ${value}`;
201
+ }
202
+
203
+ function bumpUpdated(block, now) {
204
+ return setScalarField(block, 'updated', `"${now}"`);
205
+ }
206
+
207
+ function appendChangeLogRow(body, dateStr, author, message) {
208
+ const row = `| ${dateStr} | ${author} | ${message} |`;
209
+ const headingMatch = body.match(/^## Change[ -]log\s*$/im);
210
+ if (!headingMatch) {
211
+ return (
212
+ body.replace(/\s*$/, '') +
213
+ `\n\n## Change log\n\n| Date | Author | Change |\n| --- | --- | --- |\n${row}\n`
214
+ );
215
+ }
216
+ return body.replace(/\s*$/, '') + `\n${row}\n`;
217
+ }
218
+
219
+ function commitWrites(writes) {
220
+ const snapshots = new Map();
221
+ const performed = [];
222
+ try {
223
+ for (const w of writes) {
224
+ if (w.kind === 'create') {
225
+ if (existsSync(w.absPath)) {
226
+ throw new Error(`refusing to overwrite existing file: ${w.absPath}`);
227
+ }
228
+ snapshots.set(w.absPath, { existed: false });
229
+ const dir = w.absPath.slice(
230
+ 0,
231
+ Math.max(w.absPath.lastIndexOf('/'), w.absPath.lastIndexOf('\\')),
232
+ );
233
+ if (dir) mkdirSync(dir, { recursive: true });
234
+ writeFileSync(w.absPath, w.content, 'utf8');
235
+ performed.push(w);
236
+ } else if (w.kind === 'update') {
237
+ if (!existsSync(w.absPath)) {
238
+ throw new Error(`cannot update missing file: ${w.absPath}`);
239
+ }
240
+ if (!snapshots.has(w.absPath)) {
241
+ snapshots.set(w.absPath, {
242
+ existed: true,
243
+ original: readFileSync(w.absPath, 'utf8'),
244
+ });
245
+ }
246
+ writeFileSync(w.absPath, w.content, 'utf8');
247
+ performed.push(w);
248
+ }
249
+ }
250
+ return { ok: true };
251
+ } catch (err) {
252
+ for (const w of performed.reverse()) {
253
+ const snap = snapshots.get(w.absPath);
254
+ if (!snap) continue;
255
+ try {
256
+ if (snap.existed) {
257
+ writeFileSync(w.absPath, snap.original, 'utf8');
258
+ } else if (existsSync(w.absPath)) {
259
+ unlinkSync(w.absPath);
260
+ }
261
+ } catch (rollbackErr) {
262
+ err.rollbackError = rollbackErr.message;
263
+ }
264
+ }
265
+ return { ok: false, error: err };
266
+ }
267
+ }
268
+
269
+ // ─────────────────────────────────────────────────────────────────────────────
270
+ // Vault lookups
271
+ // ─────────────────────────────────────────────────────────────────────────────
272
+
273
+ function findFileByPrefix(dir, prefix) {
274
+ if (!existsSync(dir)) return null;
275
+ const exact = `${prefix}.md`;
276
+ let prefixHit = null;
277
+ for (const file of readdirSync(dir)) {
278
+ if (!file.endsWith('.md')) continue;
279
+ if (file === exact) return file;
280
+ if (prefixHit === null && file.startsWith(`${prefix}-`)) prefixHit = file;
281
+ }
282
+ return prefixHit;
283
+ }
284
+
285
+ /**
286
+ * Resolve a research reference to a file under specs/research/. Same
287
+ * resolution order as promote-research: exact basename (wikilink/`.md`
288
+ * tolerant) → frontmatter `id:`/`file_id:` → `<ref>-` prefix. Returns the
289
+ * record or null; the caller applies the `type: research` gate
290
+ * ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]).
291
+ */
292
+ function findResearchByIdLocal(vaultRoot, researchId) {
293
+ const dir = join(vaultRoot, 'specs', 'research');
294
+ if (!existsSync(dir)) return null;
295
+ let ref = typeof researchId === 'string' ? researchId.trim().replace(/^['"]|['"]$/g, '').trim() : '';
296
+ ref = (parseWikilinkBasename(ref) ?? ref).replace(/\.md$/i, '').trim();
297
+ if (!ref) return null;
298
+
299
+ const tryFile = (file) => {
300
+ const basename = file.replace(/\.md$/i, '');
301
+ const absPath = join(dir, file);
302
+ const raw = readFileSync(absPath, 'utf8');
303
+ return { basename, absPath, relPath: join('specs', 'research', file), raw };
304
+ };
305
+
306
+ // 1. Exact basename (also covers the truncated-prefix form via step 3).
307
+ if (existsSync(join(dir, `${ref}.md`))) return tryFile(`${ref}.md`);
308
+
309
+ const files = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md'));
310
+
311
+ // 2. Frontmatter id: / file_id: scan.
312
+ for (const file of files) {
313
+ let raw;
314
+ try {
315
+ raw = readFileSync(join(dir, file), 'utf8');
316
+ } catch {
317
+ continue;
318
+ }
319
+ const block = getFrontmatterBlock(raw)?.block;
320
+ if (!block) continue;
321
+ if (getScalarField(block, 'id') === ref || getScalarField(block, 'file_id') === ref) {
322
+ return tryFile(file);
323
+ }
324
+ }
325
+
326
+ // 3. Prefix match (last resort).
327
+ const direct = findFileByPrefix(dir, ref);
328
+ return direct ? tryFile(direct) : null;
329
+ }
330
+
331
+ function findMilestoneByIdOrBasename(vaultRoot, ref) {
332
+ const dir = join(vaultRoot, 'specs', 'milestones');
333
+ if (!existsSync(dir)) return null;
334
+ let normalized = ref;
335
+ if (/\.md$/.test(normalized)) normalized = normalized.replace(/\.md$/, '');
336
+ const wikilink = parseWikilinkBasename(normalized);
337
+ if (wikilink) normalized = wikilink;
338
+
339
+ const directAbs = join(dir, `${normalized}.md`);
340
+ if (existsSync(directAbs)) {
341
+ return {
342
+ basename: normalized,
343
+ absPath: directAbs,
344
+ relPath: join('specs', 'milestones', `${normalized}.md`),
345
+ raw: readFileSync(directAbs, 'utf8'),
346
+ };
347
+ }
348
+ if (parseSpecId(normalized)?.typeLabel === 'MIL') {
349
+ for (const file of readdirSync(dir)) {
350
+ if (!file.endsWith('.md')) continue;
351
+ if (file.startsWith(`${normalized}-`)) {
352
+ const basename = file.replace(/\.md$/, '');
353
+ return {
354
+ basename,
355
+ absPath: join(dir, file),
356
+ relPath: join('specs', 'milestones', file),
357
+ raw: readFileSync(join(dir, file), 'utf8'),
358
+ };
359
+ }
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+
365
+ function findCaptureByIdOrBasename(vaultRoot, ref) {
366
+ const dir = join(vaultRoot, 'specs', 'captures');
367
+ if (!existsSync(dir)) return null;
368
+ let normalized = ref;
369
+ if (/\.md$/.test(normalized)) normalized = normalized.replace(/\.md$/, '');
370
+ const wikilink = parseWikilinkBasename(normalized);
371
+ if (wikilink) normalized = wikilink;
372
+
373
+ const directAbs = join(dir, `${normalized}.md`);
374
+ if (existsSync(directAbs)) {
375
+ return {
376
+ basename: normalized,
377
+ absPath: directAbs,
378
+ relPath: join('specs', 'captures', `${normalized}.md`),
379
+ raw: readFileSync(directAbs, 'utf8'),
380
+ };
381
+ }
382
+ if (parseSpecId(normalized)?.typeLabel === 'CAP') {
383
+ for (const file of readdirSync(dir)) {
384
+ if (!file.endsWith('.md')) continue;
385
+ if (file.startsWith(`${normalized}-`)) {
386
+ const basename = file.replace(/\.md$/, '');
387
+ return {
388
+ basename,
389
+ absPath: join(dir, file),
390
+ relPath: join('specs', 'captures', file),
391
+ raw: readFileSync(join(dir, file), 'utf8'),
392
+ };
393
+ }
394
+ }
395
+ }
396
+ return null;
397
+ }
398
+
399
+ function collectOccupiedResearchIds(vaultRoot) {
400
+ const occupied = new Set();
401
+ const dir = join(vaultRoot, 'specs', 'research');
402
+ if (!existsSync(dir)) return occupied;
403
+ for (const file of readdirSync(dir)) {
404
+ if (!file.endsWith('.md')) continue;
405
+ // Key the occupied set on the canonical family-first id (dual-read on both
406
+ // the basename and the frontmatter id) so suffix-collision detection matches
407
+ // what allocateRandomVaultId generates/compares ([[HS-EPIC-DLE3]]).
408
+ const fromName = parseSpecId(file.replace(/\.md$/i, ''));
409
+ if (fromName && fromName.typeLabel === 'RES') {
410
+ occupied.add(`HS-${fromName.familyKey}-RES`);
411
+ }
412
+ try {
413
+ const raw = readFileSync(join(dir, file), 'utf8');
414
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
415
+ if (idMatch) {
416
+ const id = idMatch[1].replace(/^['"]|['"]$/g, '');
417
+ const fromId = parseSpecId(id);
418
+ if (fromId && fromId.typeLabel === 'RES') {
419
+ occupied.add(`HS-${fromId.familyKey}-RES`);
420
+ }
421
+ }
422
+ } catch {
423
+ /* skip */
424
+ }
425
+ }
426
+ return occupied;
427
+ }
428
+
429
+ // ─────────────────────────────────────────────────────────────────────────────
430
+ // Input validation
431
+ // ─────────────────────────────────────────────────────────────────────────────
432
+
433
+ function validateInputShape(args) {
434
+ if (!args || typeof args !== 'object') {
435
+ return { error: 'INVALID_ARGS', message: 'args must be an object' };
436
+ }
437
+ if (typeof args.research_id !== 'string' || !args.research_id.trim()) {
438
+ return { error: 'INVALID_ARGS', message: 'research_id is required' };
439
+ }
440
+ // No id-shape gate: a bare era-3 file id (`HS-X5FH`) carries no `RES` label,
441
+ // and rejecting it here fired before the resolver could find the file. The
442
+ // `type: research` check below (WRONG_TYPE) is the real gate.
443
+ if (!Array.isArray(args.splits) || args.splits.length < 2) {
444
+ return {
445
+ error: 'INVALID_ARGS',
446
+ message: 'splits must be an array of at least 2 entries (splitting into 1 is a no-op)',
447
+ };
448
+ }
449
+ for (let i = 0; i < args.splits.length; i++) {
450
+ const s = args.splits[i];
451
+ if (!s || typeof s !== 'object') {
452
+ return { error: 'INVALID_ARGS', message: `splits[${i}] must be an object` };
453
+ }
454
+ if (typeof s.name !== 'string' || !s.name.trim()) {
455
+ return { error: 'INVALID_ARGS', message: `splits[${i}].name is required` };
456
+ }
457
+ if (typeof s.target_milestone !== 'string' || !s.target_milestone.trim()) {
458
+ return {
459
+ error: 'INVALID_ARGS',
460
+ message: `splits[${i}].target_milestone is required (HS-MIL-* id, basename, or the literal "NEW")`,
461
+ };
462
+ }
463
+ if (
464
+ s.target_milestone === 'NEW' &&
465
+ (typeof s.new_milestone_name !== 'string' || !s.new_milestone_name.trim())
466
+ ) {
467
+ return {
468
+ error: 'INVALID_ARGS',
469
+ message: `splits[${i}].new_milestone_name is required when target_milestone === "NEW"`,
470
+ };
471
+ }
472
+ if (typeof s.body_slice !== 'string' || !s.body_slice.trim()) {
473
+ return {
474
+ error: 'INVALID_ARGS',
475
+ message: `splits[${i}].body_slice is required (non-empty string — caller-prepared partition of original body)`,
476
+ };
477
+ }
478
+ if (!Array.isArray(s.sources_captures)) {
479
+ return {
480
+ error: 'INVALID_ARGS',
481
+ message: `splits[${i}].sources_captures must be an array (may be empty)`,
482
+ };
483
+ }
484
+ }
485
+ return null;
486
+ }
487
+
488
+ // ─────────────────────────────────────────────────────────────────────────────
489
+ // Child + original content builders
490
+ // ─────────────────────────────────────────────────────────────────────────────
491
+
492
+ function buildChildResearchContent({
493
+ id,
494
+ fileKey,
495
+ familyKey,
496
+ basename,
497
+ name,
498
+ originalBasename,
499
+ siblingBasenames,
500
+ targetMilestone,
501
+ capturesBasenames,
502
+ bodySlice,
503
+ now,
504
+ today,
505
+ author,
506
+ }) {
507
+ const sourcesBlock =
508
+ capturesBasenames.length === 0
509
+ ? 'sources_captures: []'
510
+ : `sources_captures:\n${capturesBasenames.map((b) => ` - "[[${b}]]"`).join('\n')}`;
511
+
512
+ const siblings = siblingBasenames.filter((b) => b !== basename);
513
+ const siblingPhrase =
514
+ siblings.length === 0
515
+ ? ''
516
+ : siblings.length === 1
517
+ ? `; sibling slice lives at [[${siblings[0]}]]`
518
+ : `; sibling slices live at ${siblings.map((b) => `[[${b}]]`).join(', ')}`;
519
+
520
+ const milestonePhrase = targetMilestone.absent
521
+ ? 'targets a new milestone (to be created at promotion via /hs-promote-research)'
522
+ : `targets [[${targetMilestone.basename}]] specifically`;
523
+
524
+ const calloutLine = `> **Split-from:** [[${originalBasename}]]. This research ${milestonePhrase}${siblingPhrase}.`;
525
+
526
+ return `---
527
+ id: ${id}
528
+ file_id: ${fileKey}
529
+ family: ${familyKey}
530
+ name: ${yamlScalar(name)}
531
+ type: research
532
+ status: open
533
+ owner: pwher
534
+ created: "${now}"
535
+ updated: "${now}"
536
+ split_from: "[[${originalBasename}]]"
537
+ ${sourcesBlock}
538
+ tags:
539
+ - research
540
+ - split-child
541
+ related: []
542
+ ---
543
+
544
+ ${calloutLine}
545
+
546
+ ${bodySlice.trim()}
547
+
548
+ ## Change log
549
+
550
+ | Date | Author | Change |
551
+ | --- | --- | --- |
552
+ | ${today} | ${author} | Created via /hs-split-research from [[${originalBasename}]]. |
553
+ `;
554
+ }
555
+
556
+ function buildUpdatedOriginalContent(raw, params) {
557
+ const fm = getFrontmatterBlock(raw);
558
+ if (!fm) throw new Error('original research has no frontmatter — cannot split');
559
+ let block = fm.block;
560
+ for (const childBasename of params.childBasenames) {
561
+ block = appendToListField(block, 'split_into', childBasename).block;
562
+ }
563
+ // status is intentionally NOT touched — the archive stays `status: open`
564
+ // (research is evergreen; see [[research-is-evergreen]]). `split_into:` records
565
+ // the children and guards against re-split.
566
+ block = bumpUpdated(block, params.now);
567
+
568
+ const childList =
569
+ params.childBasenames.length === 2
570
+ ? `${params.childBasenames.map((b) => `[[${b}]]`).join(' and ')}`
571
+ : params.childBasenames.map((b) => `[[${b}]]`).join(', ');
572
+ const callout = `> **Split:** This research was split into ${childList} on ${params.today} via /hs-split-research. Original content preserved below for audit.`;
573
+ const newBody = `\n${callout}\n${fm.body}`;
574
+ const changeMsg = `Split into ${params.childBasenames.length} child research doc(s) via /hs-split-research. Archive stays evergreen (status: open); split_into records the children.`;
575
+ const finalBody = appendChangeLogRow(newBody, params.today, params.author, changeMsg);
576
+ return `${fm.open}${block}${fm.close}${finalBody}`;
577
+ }
578
+
579
+ function buildUpdatedCaptureContent(raw, childBasenames, now) {
580
+ const fm = getFrontmatterBlock(raw);
581
+ if (!fm) throw new Error('capture has no frontmatter — cannot update informs_research');
582
+ let block = fm.block;
583
+ let changed = false;
584
+ for (const child of childBasenames) {
585
+ const next = appendToListField(block, 'informs_research', child);
586
+ if (next.changed) {
587
+ block = next.block;
588
+ changed = true;
589
+ }
590
+ }
591
+ if (!changed) return raw;
592
+ block = bumpUpdated(block, now);
593
+ return `${fm.open}${block}${fm.close}${fm.body}`;
594
+ }
595
+
596
+ // ─────────────────────────────────────────────────────────────────────────────
597
+ // Public entry point
598
+ // ─────────────────────────────────────────────────────────────────────────────
599
+
600
+ export async function executeSplitResearch(vaultRoot, args = {}, deps = {}) {
601
+ const validationError = validateInputShape(args);
602
+ if (validationError) return validationError;
603
+
604
+ const research = findResearchByIdLocal(vaultRoot, args.research_id);
605
+ if (!research) {
606
+ return {
607
+ error: 'RESEARCH_NOT_FOUND',
608
+ message: `research ${args.research_id} not found under specs/research/`,
609
+ };
610
+ }
611
+
612
+ const fm = getFrontmatterBlock(research.raw);
613
+ if (!fm) {
614
+ return {
615
+ error: 'RESEARCH_FRONTMATTER_MISSING',
616
+ message: `research ${research.basename} has no frontmatter`,
617
+ };
618
+ }
619
+
620
+ const type = getScalarField(fm.block, 'type');
621
+ if (type !== 'research') {
622
+ return {
623
+ error: 'WRONG_TYPE',
624
+ message: `${research.basename} is not type: research (got ${type ?? 'null'})`,
625
+ };
626
+ }
627
+
628
+ // Research is evergreen ([[research-is-evergreen]]) — a split archive stays
629
+ // `status: open`, so re-split is guarded by the presence of `split_into:`
630
+ // (set on the first split), not by a status flip.
631
+ const existingSplitInto = parseListFieldBasenames(fm.block, 'split_into');
632
+ if (existingSplitInto && existingSplitInto.length > 0) {
633
+ return {
634
+ error: 'RESEARCH_ALREADY_SPLIT',
635
+ message: `research ${research.basename} already has \`split_into:\` set (${existingSplitInto.join(', ')}) — /hs-split-research refuses to re-split an archive. File a new research doc if further partitioning is needed.`,
636
+ };
637
+ }
638
+
639
+ const originalCaptureBasenames = parseListFieldBasenames(fm.block, 'sources_captures') ?? [];
640
+ // Family inheritance source: the ORIGINAL research's own family (see block
641
+ // comment above inheritedFamilyKey) — every child inherits THIS family key
642
+ // UNCONDITIONALLY, per the owner ruling "split children stay in the parent
643
+ // research's family". V3 shared-surname model: any number of children AND
644
+ // the archived original may now legally share it at once.
645
+ const originalId = getScalarField(fm.block, 'id') ?? args.research_id;
646
+ const originalFamilyKey = inheritedFamilyKey(originalId, {
647
+ family: getScalarField(fm.block, 'family'),
648
+ });
649
+
650
+ // Resolve all splits up-front, before any writes.
651
+ // One occupied set across the whole loop (owner ruling 2026-07-10) — children
652
+ // are not on disk yet, so a fresh vault walk per split could repeat a key.
653
+ const occupiedFileKeys = collectOccupiedKeysEitherKind(vaultRoot); // union: file+family keys (HS-SJOT)
654
+ const childRecords = [];
655
+ const captureChildMap = new Map();
656
+
657
+ for (let i = 0; i < args.splits.length; i++) {
658
+ const split = args.splits[i];
659
+
660
+ let targetMilestone;
661
+ if (split.target_milestone === 'NEW') {
662
+ targetMilestone = { absent: true, newName: split.new_milestone_name.trim() };
663
+ } else {
664
+ const ms = findMilestoneByIdOrBasename(vaultRoot, split.target_milestone);
665
+ if (!ms) {
666
+ return {
667
+ error: 'MILESTONE_NOT_FOUND',
668
+ message: `splits[${i}].target_milestone ${split.target_milestone} did not resolve to a file under specs/milestones/ (pass HS-MIL-<id>, the full basename, or "NEW")`,
669
+ };
670
+ }
671
+ targetMilestone = { absent: false, basename: ms.basename };
672
+ }
673
+
674
+ const childCaptureBasenames = [];
675
+ for (const captureRef of split.sources_captures) {
676
+ const cap = findCaptureByIdOrBasename(vaultRoot, captureRef);
677
+ if (!cap) {
678
+ return {
679
+ error: 'CAPTURE_NOT_FOUND',
680
+ message: `splits[${i}].sources_captures entry ${captureRef} did not resolve to a file under specs/captures/`,
681
+ };
682
+ }
683
+ if (!originalCaptureBasenames.includes(cap.basename)) {
684
+ return {
685
+ error: 'CAPTURE_NOT_IN_ORIGINAL',
686
+ message: `splits[${i}].sources_captures entry ${cap.basename} is not present in the original research's sources_captures (subset requirement per /hs-split-research §4 — children inherit a subset of the original's captures)`,
687
+ };
688
+ }
689
+ childCaptureBasenames.push(cap.basename);
690
+ }
691
+
692
+ // Family inheritance (owner ruling 2026-07-12) — see block comment above
693
+ // inheritedFamilyKey. Every child inherits the ORIGINAL's family key
694
+ // unconditionally; a fresh mint only in the defensive edge case where the
695
+ // original itself has no derivable family (should not occur for a real
696
+ // research doc).
697
+ const childFamilyKey =
698
+ originalFamilyKey ?? mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
699
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
700
+ // and (V3) the anchor for `id:` itself
701
+ // ([[HS-ZU1Q-TASK-PNAS-flip-new-spec-minting-to-the-file-key]]).
702
+ const childFileKey = allocateFileKey(occupiedFileKeys, { randomUnit: deps?.randomUnit });
703
+ occupiedFileKeys.add(childFileKey);
704
+ const childId = `HS-${childFileKey}`;
705
+ const slug = deriveSlug(split.name);
706
+ const basename = `${childId}-${childFamilyKey}-RES-${slug}`;
707
+
708
+ childRecords.push({
709
+ id: childId,
710
+ fileKey: childFileKey,
711
+ familyKey: childFamilyKey,
712
+ basename,
713
+ name: split.name.trim(),
714
+ targetMilestone,
715
+ capturesBasenames: childCaptureBasenames,
716
+ bodySlice: split.body_slice,
717
+ });
718
+
719
+ for (const capBasename of childCaptureBasenames) {
720
+ if (!captureChildMap.has(capBasename)) captureChildMap.set(capBasename, []);
721
+ captureChildMap.get(capBasename).push(basename);
722
+ }
723
+ }
724
+
725
+ // Stage writes.
726
+ const now = (deps?.clock ?? isoNow)();
727
+ const today = now.slice(0, 10);
728
+ const author = 'split-research (agent)';
729
+ const writes = [];
730
+ const childBasenames = childRecords.map((c) => c.basename);
731
+
732
+ for (const child of childRecords) {
733
+ const content = buildChildResearchContent({
734
+ id: child.id,
735
+ fileKey: child.fileKey,
736
+ familyKey: child.familyKey,
737
+ basename: child.basename,
738
+ name: child.name,
739
+ originalBasename: research.basename,
740
+ siblingBasenames: childBasenames,
741
+ targetMilestone: child.targetMilestone,
742
+ capturesBasenames: child.capturesBasenames,
743
+ bodySlice: child.bodySlice,
744
+ now,
745
+ today,
746
+ author,
747
+ });
748
+ child.unresolvedPlaceholderCount = findUnresolvedResearchPlaceholders(content);
749
+ writes.push({
750
+ kind: 'create',
751
+ absPath: join(vaultRoot, 'specs', 'research', `${child.basename}.md`),
752
+ content,
753
+ });
754
+ }
755
+
756
+ const originalUpdated = buildUpdatedOriginalContent(research.raw, {
757
+ childBasenames,
758
+ now,
759
+ today,
760
+ author,
761
+ });
762
+ writes.push({
763
+ kind: 'update',
764
+ absPath: research.absPath,
765
+ content: originalUpdated,
766
+ });
767
+
768
+ for (const [capBasename, childList] of captureChildMap.entries()) {
769
+ const capAbs = join(vaultRoot, 'specs', 'captures', `${capBasename}.md`);
770
+ const capRaw = readFileSync(capAbs, 'utf8');
771
+ const capUpdated = buildUpdatedCaptureContent(capRaw, childList, now);
772
+ if (capUpdated !== capRaw) {
773
+ writes.push({ kind: 'update', absPath: capAbs, content: capUpdated });
774
+ }
775
+ }
776
+
777
+ const commitResult = commitWrites(writes);
778
+ if (!commitResult.ok) {
779
+ return {
780
+ error: 'WRITE_FAILED',
781
+ message: commitResult.error?.message ?? 'unknown write failure',
782
+ rollback_error: commitResult.error?.rollbackError,
783
+ };
784
+ }
785
+
786
+ const anyChildStillStub = childRecords.some((c) => c.unresolvedPlaceholderCount > 0);
787
+
788
+ return {
789
+ archive_id: getScalarField(fm.block, 'id') ?? args.research_id,
790
+ archive_basename: research.basename,
791
+ archive_path: research.relPath,
792
+ archive_status_after: getScalarField(fm.block, 'status') ?? 'open',
793
+ children: childRecords.map((c) => ({
794
+ id: c.id,
795
+ basename: c.basename,
796
+ path: join('specs', 'research', `${c.basename}.md`),
797
+ target_milestone: c.targetMilestone.absent ? 'NEW' : c.targetMilestone.basename,
798
+ sources_captures: c.capturesBasenames,
799
+ // Same placeholder count RESEARCH_STILL_STUB would later refuse
800
+ // heyspex_promote_research on for this child — surfaced here proactively
801
+ // ([[HS-DSFO-49G3-DIAG-promote-capture-s-success-response]]).
802
+ research_still_stub: c.unresolvedPlaceholderCount > 0,
803
+ unresolved_placeholder_count: c.unresolvedPlaceholderCount,
804
+ })),
805
+ writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
806
+ // No fallback warning: every child unconditionally inherits the
807
+ // original's family key (V3 shared-surname model — see the block comment
808
+ // above inheritedFamilyKey).
809
+ warning: undefined,
810
+ next_step: anyChildStillStub
811
+ ? 'One or more split children still carry unresolved "_TBD — fill in this section._" ' +
812
+ 'placeholders (inherited from the original research\'s outline) — they are outlines, ' +
813
+ 'not authored findings. Run the /hs-res method against each still-stub child before ' +
814
+ 'calling heyspex_promote_research on it; it will otherwise refuse with RESEARCH_STILL_STUB.'
815
+ : undefined,
816
+ };
817
+ }
818
+
819
+ export const __testables = {
820
+ validateInputShape,
821
+ appendToListField,
822
+ parseListFieldBasenames,
823
+ getFrontmatterBlock,
824
+ buildChildResearchContent,
825
+ buildUpdatedOriginalContent,
826
+ buildUpdatedCaptureContent,
827
+ collectOccupiedResearchIds,
828
+ findResearchByIdLocal,
829
+ findMilestoneByIdOrBasename,
830
+ findCaptureByIdOrBasename,
831
+ inheritedFamilyKey,
832
+ };