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,417 @@
1
+ /**
2
+ * register-spec.mjs — the core of `heyspex_register_spec`.
3
+ *
4
+ * The fix counterpart to `heyspex_validate_linkage` ([[HS-TASK-5K13]] under
5
+ * [[HS-EPIC-0037-spec-linkage-validation-system]]). Where validate-linkage
6
+ * *detects* a spec that is unreachable from the roadmap, this *links it in* —
7
+ * editing the vault files on both sides so the orphan (or one-sided
8
+ * parent/child mismatch) is resolved.
9
+ *
10
+ * Reachability is the manifest-v3 hierarchy
11
+ * ([[HS-TASK-JN9H-manifest-schema-v3-and-horizon-retirement]]), so what
12
+ * "register" means depends on the spec's kind:
13
+ *
14
+ * - milestone → add its basename to a roadmap's `roadmap_manifest.milestones`.
15
+ * - epic → set the epic's `milestone:` field AND add the epic to that
16
+ * milestone's `epics:` list (both sides).
17
+ * - task → set the task's `parent:` field AND add the task to that
18
+ * epic's `children:` list (both sides).
19
+ *
20
+ * The original 2026-05-18 task body described a `phase_id` / `section` API over
21
+ * the retired `phases[].tasks[]` manifest shape. That shape no longer exists;
22
+ * this implementation targets the live v3 manifest, the same hierarchy the
23
+ * validation core was re-grounded on. Inferring the parent from the spec's own
24
+ * `milestone:`/`parent:` field (when the back-reference is the only thing
25
+ * missing) lets the tool also repair the `parent_child_mismatches` class, not
26
+ * just full orphans.
27
+ *
28
+ * Self-contained (no import from spex-console/) so the vendored MCP server
29
+ * stays portable. Frontmatter is read with the same regex-over-raw approach the
30
+ * rest of the vendored server uses; writes go through the shared yaml-scalar
31
+ * quoter ([[HS-RES-COOA-deterministic-frontmatter-integrity]]).
32
+ *
33
+ * Faces ([[capability-three-faces-no-per-capability-slash]]): this core feeds
34
+ * - the `heyspex_register_spec` MCP tool (cli.mjs), and
35
+ * - the `register` CLI verb (cli.mjs) for terminal / disaster-recovery use.
36
+ */
37
+ import { readFileSync, writeFileSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+ import { collectVaultSpecs, validateLinkage } from './validate-linkage.mjs';
40
+ import { yamlScalar } from './yaml-scalar.mjs';
41
+ import { toCanonicalFamilyFirstId } from './spec-id-lib.mjs';
42
+
43
+ const SUBJECT_TYPES = new Set(['milestone', 'epic', 'task']);
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Small id/link helpers (kept local so the module stays self-contained — they
47
+ // mirror the ones in validate-linkage.mjs).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ // Canonical, ordering-independent core of a spec id (dual-read: a type-first id
51
+ // and its family-first form resolve to the SAME core, so byIdCore / listHasSpec
52
+ // match across orderings). [[HS-EPIC-DLE3]] / [[HS-EPIC-6SNQ]]. Falls back to the
53
+ // legacy anchored regex for unknown-type / hyphenated ids parseSpecId can't read.
54
+ function idCore(value) {
55
+ const canonical = toCanonicalFamilyFirstId(value);
56
+ if (canonical) return canonical.toUpperCase();
57
+ const m = String(value || '').match(/^(HS-[A-Z]+-[A-Za-z0-9]{2,})/i);
58
+ return m ? m[1].toUpperCase() : null;
59
+ }
60
+
61
+ function stripWikilink(value) {
62
+ if (typeof value !== 'string') return null;
63
+ const inner = value.match(/\[\[([^\]]+)\]\]/);
64
+ let t = (inner ? inner[1] : value).trim();
65
+ t = t.split('|')[0].split('#')[0].trim();
66
+ return t || null;
67
+ }
68
+
69
+ function err(error, message, suggestions) {
70
+ const out = { success: false, error, message };
71
+ if (suggestions && suggestions.length) out.suggestions = suggestions;
72
+ return out;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Frontmatter text edits (raw-string surgery — no YAML parse, CRLF-tolerant)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function detectEol(raw) {
80
+ return /\r\n/.test(raw) ? '\r\n' : '\n';
81
+ }
82
+
83
+ function getFrontmatter(raw) {
84
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
85
+ return m ? m[1] : null;
86
+ }
87
+
88
+ function replaceFrontmatter(raw, newBlock) {
89
+ const m = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
90
+ if (!m) return null;
91
+ return raw.slice(0, m.index) + m[1] + newBlock + m[3] + raw.slice(m.index + m[0].length);
92
+ }
93
+
94
+ // Set (replace or append) a scalar `field:` in the frontmatter block. `value`
95
+ // is an already-serialized YAML scalar (e.g. the output of yamlScalar()).
96
+ function setScalar(block, field, value, eol) {
97
+ const re = new RegExp(`^([ \\t]*${field}:)[ \\t]*.*$`, 'm');
98
+ if (re.test(block)) return block.replace(re, `$1 ${value}`);
99
+ return `${block}${eol}${field}: ${value}`;
100
+ }
101
+
102
+ // Does a raw list line (e.g. " - '[[HS-TASK-CCCC-foo]]'") refer to `ref`?
103
+ function lineRefMatches(line, ref) {
104
+ const im = line.match(/^[ \t]+-[ \t]*(.+?)[ \t]*$/);
105
+ if (!im) return false;
106
+ const target = stripWikilink(im[1].replace(/^['"]|['"]$/g, '')) ?? '';
107
+ const refCore = idCore(ref);
108
+ return target === stripWikilink(ref) || (!!refCore && idCore(target) === refCore);
109
+ }
110
+
111
+ /**
112
+ * Insert `itemValue` (already-formatted item content, without the `- ` prefix)
113
+ * into the YAML block list under `field:`. Handles nested keys (e.g. the
114
+ * `milestones:` list under `roadmap_manifest:`), an inline-empty `field: []`,
115
+ * and an absent field. Item indentation and the leading-`-` style are copied
116
+ * from existing items so the file's formatting is preserved.
117
+ *
118
+ * @returns {{ block: string } | { error: string, ref?: string }}
119
+ */
120
+ function insertListItem(block, field, itemValue, position, eol) {
121
+ const lines = block.split(/\r?\n/);
122
+ let keyIdx = -1;
123
+ let keyIndent = '';
124
+ let inlineVal = '';
125
+ const keyRe = new RegExp(`^([ \\t]*)${field}:[ \\t]*(.*)$`);
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const m = lines[i].match(keyRe);
128
+ if (m) {
129
+ keyIdx = i;
130
+ keyIndent = m[1];
131
+ inlineVal = m[2];
132
+ break;
133
+ }
134
+ }
135
+
136
+ // Field absent → append a fresh top-level block list at the end.
137
+ if (keyIdx === -1) {
138
+ return { block: `${block}${eol}${field}:${eol} - ${itemValue}` };
139
+ }
140
+
141
+ // `field: []` → expand into a block list with the single item.
142
+ if (/^\[\s*\]$/.test(inlineVal.trim())) {
143
+ lines[keyIdx] = `${keyIndent}${field}:`;
144
+ lines.splice(keyIdx + 1, 0, `${keyIndent} - ${itemValue}`);
145
+ return { block: lines.join(eol) };
146
+ }
147
+
148
+ // Walk the existing items (deeper-indented `- …` lines following the key).
149
+ let firstItem = -1;
150
+ let lastItem = -1;
151
+ let itemIndent = `${keyIndent} `;
152
+ for (let i = keyIdx + 1; i < lines.length; i++) {
153
+ const im = lines[i].match(/^([ \t]+)-[ \t]?(.*)$/);
154
+ if (im && im[1].length > keyIndent.length) {
155
+ if (firstItem === -1) {
156
+ firstItem = i;
157
+ itemIndent = im[1];
158
+ }
159
+ lastItem = i;
160
+ } else {
161
+ break;
162
+ }
163
+ }
164
+
165
+ const newLine = `${itemIndent}- ${itemValue}`;
166
+ let insertAt;
167
+ if (firstItem === -1) {
168
+ insertAt = keyIdx + 1; // key present but no items yet
169
+ } else if (position === 'first') {
170
+ insertAt = firstItem;
171
+ } else if (!position || position === 'last') {
172
+ insertAt = lastItem + 1;
173
+ } else if (position.startsWith('after:') || position.startsWith('before:')) {
174
+ const ref = position.slice(position.indexOf(':') + 1);
175
+ let matchIdx = -1;
176
+ for (let i = firstItem; i <= lastItem; i++) {
177
+ if (lineRefMatches(lines[i], ref)) {
178
+ matchIdx = i;
179
+ break;
180
+ }
181
+ }
182
+ if (matchIdx === -1) return { error: 'POSITION_REF_NOT_FOUND', ref };
183
+ insertAt = position.startsWith('after:') ? matchIdx + 1 : matchIdx;
184
+ } else {
185
+ return { error: 'BAD_POSITION', ref: position };
186
+ }
187
+
188
+ lines.splice(insertAt, 0, newLine);
189
+ return { block: lines.join(eol) };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Per-file mutation accumulator — collects edits then writes once.
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function loadFile(vaultRoot, relFile) {
197
+ const abs = join(vaultRoot, ...relFile.split('/'));
198
+ return { abs, raw: readFileSync(abs, 'utf-8') };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Core
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * @param {string} vaultRoot
207
+ * @param {object} opts
208
+ * @param {string} opts.spec_id Spec to register (id or basename).
209
+ * @param {string} [opts.parent_id] Target parent: the milestone for an
210
+ * epic, the epic for a task, or the roadmap for a milestone. When omitted
211
+ * for an epic/task, the spec's own `milestone:`/`parent:` field is used
212
+ * (repairs a one-sided back-reference); for a milestone it defaults to
213
+ * `roadmap_id`.
214
+ * @param {string} [opts.roadmap_id='HS-ROADMAP-PRODUCT'] Roadmap for milestones.
215
+ * @param {string} [opts.position='last'] 'first' | 'last' | 'after:<ref>' | 'before:<ref>'.
216
+ * @param {boolean} [opts.dryRun=false] Plan the edits without writing or re-validating.
217
+ * @param {string} [opts.now] ISO timestamp for `updated:` (injectable for tests).
218
+ * @param {boolean} [opts.updateTimestamps=true]
219
+ */
220
+ export function registerSpec(vaultRoot, opts = {}) {
221
+ const {
222
+ spec_id,
223
+ parent_id = null,
224
+ roadmap_id = 'HS-ROADMAP-PRODUCT',
225
+ position = 'last',
226
+ dryRun = false,
227
+ updateTimestamps = true,
228
+ } = opts;
229
+ const now = opts.now || new Date().toISOString();
230
+
231
+ if (!spec_id || !String(spec_id).trim()) {
232
+ return err('SPEC_ID_REQUIRED', 'spec_id is required.');
233
+ }
234
+
235
+ const specs = collectVaultSpecs(vaultRoot);
236
+ const byBasename = new Map();
237
+ const byId = new Map();
238
+ const byIdCore = new Map();
239
+ for (const s of specs) {
240
+ byBasename.set(s.basename, s);
241
+ if (s.id) byId.set(s.id, s);
242
+ if (s.idCore && !byIdCore.has(s.idCore)) byIdCore.set(s.idCore, s);
243
+ }
244
+ const resolveRef = (ref) => {
245
+ if (!ref) return null;
246
+ const t = stripWikilink(ref) ?? ref;
247
+ return byBasename.get(t) || byId.get(t) || byIdCore.get(idCore(t)) || null;
248
+ };
249
+ const listHasSpec = (list, target) =>
250
+ list.some((ref) => {
251
+ const t = stripWikilink(ref) ?? ref;
252
+ return t === target.basename || t === target.id || (!!target.idCore && idCore(t) === target.idCore);
253
+ });
254
+
255
+ const spec = resolveRef(spec_id);
256
+ if (!spec) {
257
+ const want = idCore(spec_id);
258
+ const suggestions = want
259
+ ? specs.filter((s) => s.idCore && s.idCore.startsWith(want.slice(0, 7))).map((s) => s.id).slice(0, 3)
260
+ : [];
261
+ return err('SPEC_NOT_FOUND', `Spec ${spec_id} does not exist in the vault.`, suggestions);
262
+ }
263
+ if (!SUBJECT_TYPES.has(spec.type)) {
264
+ return err(
265
+ 'UNSUPPORTED_TYPE',
266
+ `register_spec only links milestones, epics, and tasks into the roadmap; ${spec.id} is type "${spec.type}".`,
267
+ );
268
+ }
269
+
270
+ // Stage edits per file, then commit in one pass so a mid-write failure can't
271
+ // leave one side linked and the other not.
272
+ const edits = new Map(); // relFile -> { abs, block, eol, raw, actions: [] }
273
+ const stage = (record) => {
274
+ if (edits.has(record.file)) return edits.get(record.file);
275
+ const { abs, raw } = loadFile(vaultRoot, record.file);
276
+ const block = getFrontmatter(raw);
277
+ if (block == null) throw new Error(`No frontmatter in ${record.file}`);
278
+ const e = { abs, raw, block, eol: detectEol(raw), actions: [], touched: false };
279
+ edits.set(record.file, e);
280
+ return e;
281
+ };
282
+
283
+ const changes = [];
284
+
285
+ if (spec.type === 'milestone') {
286
+ // Target roadmap: explicit parent_id wins, else roadmap_id default.
287
+ const roadmapRef = parent_id || roadmap_id;
288
+ const roadmap = resolveRef(roadmapRef);
289
+ if (!roadmap || !roadmap.manifest) {
290
+ return err('ROADMAP_NOT_FOUND', `Roadmap ${roadmapRef} not found (or it has no roadmap_manifest).`);
291
+ }
292
+ const already = listHasSpec(roadmap.manifest.milestones, spec);
293
+ if (!already) {
294
+ const e = stage(roadmap);
295
+ const res = insertListItem(e.block, 'milestones', spec.basename, position, e.eol);
296
+ if (res.error) return err(res.error, positionErrorMessage(res), undefined);
297
+ e.block = res.block;
298
+ e.touched = true;
299
+ changes.push({ file: roadmap.file, action: `Added ${spec.basename} to roadmap_manifest.milestones` });
300
+ }
301
+ return finalize({
302
+ spec,
303
+ parent: { id: roadmap.id, type: 'roadmap' },
304
+ roadmap_id: roadmap.id,
305
+ already,
306
+ edits,
307
+ changes,
308
+ vaultRoot,
309
+ dryRun,
310
+ updateTimestamps,
311
+ now,
312
+ });
313
+ }
314
+
315
+ // epic → milestone (milestone:/epics:) or task → epic (parent:/children:)
316
+ const childField = spec.type === 'epic' ? 'milestone' : 'parent';
317
+ const parentListField = spec.type === 'epic' ? 'epics' : 'children';
318
+ const parentType = spec.type === 'epic' ? 'milestone' : 'epic';
319
+ const existingParentRef = spec.type === 'epic' ? spec.milestone : spec.parent;
320
+
321
+ const targetRef = parent_id || existingParentRef;
322
+ if (!targetRef) {
323
+ return err(
324
+ 'MISSING_PARENT',
325
+ `${spec.id} is a ${spec.type} with no ${parentType} parent. Pass parent_id (an existing HS-${parentType === 'milestone' ? 'MIL' : 'EPIC'}-* id) to register it.`,
326
+ );
327
+ }
328
+ const parent = resolveRef(targetRef);
329
+ if (!parent) {
330
+ return err('PARENT_NOT_FOUND', `Parent ${targetRef} not found in the vault.`);
331
+ }
332
+ if (parent.type !== parentType) {
333
+ return err(
334
+ 'PARENT_TYPE_MISMATCH',
335
+ `A ${spec.type} must be parented to a ${parentType}, but ${parent.id} is type "${parent.type}".`,
336
+ );
337
+ }
338
+
339
+ // Side 1: the child's own pointer field.
340
+ const fieldPointsAtParent =
341
+ existingParentRef &&
342
+ (stripWikilink(existingParentRef) === parent.basename ||
343
+ stripWikilink(existingParentRef) === parent.id ||
344
+ (!!parent.idCore && idCore(existingParentRef) === parent.idCore));
345
+ if (!fieldPointsAtParent) {
346
+ const e = stage(spec);
347
+ e.block = setScalar(e.block, childField, yamlScalar(`[[${parent.basename}]]`), e.eol);
348
+ e.touched = true;
349
+ changes.push({ file: spec.file, action: `Set ${childField}: [[${parent.basename}]]` });
350
+ }
351
+
352
+ // Side 2: the parent's back-reference list.
353
+ const parentLists = parentListField === 'epics' ? parent.epics : parent.children;
354
+ const listAlready = listHasSpec(parentLists, spec);
355
+ if (!listAlready) {
356
+ const e = stage(parent);
357
+ const res = insertListItem(e.block, parentListField, `'[[${spec.basename}]]'`, position, e.eol);
358
+ if (res.error) return err(res.error, positionErrorMessage(res), undefined);
359
+ e.block = res.block;
360
+ e.touched = true;
361
+ changes.push({ file: parent.file, action: `Added [[${spec.basename}]] to ${parent.id} ${parentListField}[]` });
362
+ }
363
+
364
+ return finalize({
365
+ spec,
366
+ parent: { id: parent.id, type: parent.type },
367
+ already: fieldPointsAtParent && listAlready,
368
+ edits,
369
+ changes,
370
+ vaultRoot,
371
+ dryRun,
372
+ updateTimestamps,
373
+ now,
374
+ });
375
+ }
376
+
377
+ function positionErrorMessage(res) {
378
+ if (res.error === 'POSITION_REF_NOT_FOUND') {
379
+ return `position ref "${res.ref}" was not found in the target list.`;
380
+ }
381
+ return `invalid position "${res.ref}". Use 'first', 'last', 'after:<ref>', or 'before:<ref>'.`;
382
+ }
383
+
384
+ // Commit staged edits to disk (unless dryRun) and re-validate the subject.
385
+ function finalize({ spec, parent, roadmap_id, already, edits, changes, vaultRoot, dryRun, updateTimestamps, now }) {
386
+ if (!dryRun) {
387
+ for (const e of edits.values()) {
388
+ if (!e.touched) continue;
389
+ let block = e.block;
390
+ if (updateTimestamps) block = setScalar(block, 'updated', yamlScalar(now), e.eol);
391
+ const next = replaceFrontmatter(e.raw, block);
392
+ if (next == null) throw new Error(`Failed to splice frontmatter for ${e.abs}`);
393
+ writeFileSync(e.abs, next, 'utf-8');
394
+ }
395
+ }
396
+
397
+ const result = {
398
+ success: true,
399
+ spec_id: spec.id,
400
+ type: spec.type,
401
+ parent,
402
+ already_registered: !!already && changes.length === 0,
403
+ changes,
404
+ dry_run: !!dryRun,
405
+ };
406
+ if (roadmap_id) result.roadmap_id = roadmap_id;
407
+
408
+ if (!dryRun) {
409
+ const v = validateLinkage(vaultRoot, { spec_id: spec.id, includeWarnings: false });
410
+ result.validation = {
411
+ valid: v.valid,
412
+ orphan_specs: v.orphan_specs,
413
+ parent_child_mismatches: v.parent_child_mismatches,
414
+ };
415
+ }
416
+ return result;
417
+ }