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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
};
|