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,527 @@
1
+ /**
2
+ * Delegation defer — split a claimed task's remainder into a child spec with a
3
+ * handoff envelope. Spec: [[HS-TASK-1U0Z-hs-defer-mcp-tool-and-slash-command]]
4
+ * / [[HS-EPIC-YQ5A-delegation-defer-work-from-one-agent-to]].
5
+ *
6
+ * Transactional writes (child create + parent update + epic children back-link)
7
+ * via `commitWrites` from [[./promotion-helpers.mjs]]. Post-write orchestration
8
+ * (pin on-deck, release claim, WIP ledger) lives in the console route wrapper.
9
+ */
10
+
11
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { allocateRandomVaultId } from '../../scripts/vault-id-lib.mjs';
15
+ import { familyOf, mintFamilyKey, mintFileKey, parseSpecId } from './spec-id-lib.mjs';
16
+ import { yamlScalar } from './yaml-scalar.mjs';
17
+ import {
18
+ isoNow,
19
+ deriveSlug,
20
+ findFileByPrefix,
21
+ getFrontmatterBlock,
22
+ getScalarField,
23
+ parseWikilinkBasename,
24
+ parseListFieldBasenames,
25
+ appendToListField,
26
+ setScalarField,
27
+ bumpUpdated,
28
+ appendChangeLogRow,
29
+ commitWrites,
30
+ } from './promotion-helpers.mjs';
31
+ import {
32
+ validateHandoffEnvelopeFields,
33
+ } from '../../spex-console/src/lib/indexer/handoff-envelope.mjs';
34
+
35
+ const VALID_PARENT_STATUSES = new Set(['in_review', 'in_progress']);
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Discovery
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ function findTaskByIdLocal(vaultRoot, taskId) {
42
+ const dir = join(vaultRoot, 'specs', 'tasks');
43
+ if (!existsSync(dir)) return null;
44
+ const direct = findFileByPrefix(dir, taskId);
45
+ const tryFile = (file) => {
46
+ const basename = file.replace(/\.md$/, '');
47
+ const absPath = join(dir, file);
48
+ return {
49
+ basename,
50
+ absPath,
51
+ relPath: join('specs', 'tasks', file),
52
+ raw: readFileSync(absPath, 'utf8'),
53
+ };
54
+ };
55
+ if (direct) return tryFile(direct);
56
+ for (const file of readdirSync(dir)) {
57
+ if (!file.endsWith('.md')) continue;
58
+ const raw = readFileSync(join(dir, file), 'utf8');
59
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
60
+ if (idMatch) {
61
+ const id = idMatch[1].replace(/^['"]|['"]$/g, '');
62
+ if (id === taskId) {
63
+ return tryFile(file);
64
+ }
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function findEpicByBasenameLocal(vaultRoot, basename) {
71
+ const dir = join(vaultRoot, 'specs', 'epics');
72
+ if (!existsSync(dir)) return null;
73
+ const file = `${basename}.md`;
74
+ const absPath = join(dir, file);
75
+ if (!existsSync(absPath)) {
76
+ const prefixed = findFileByPrefix(dir, basename.split('-').slice(0, 2).join('-'));
77
+ if (!prefixed) return null;
78
+ return {
79
+ basename: prefixed.replace(/\.md$/, ''),
80
+ absPath: join(dir, prefixed),
81
+ raw: readFileSync(join(dir, prefixed), 'utf8'),
82
+ };
83
+ }
84
+ return {
85
+ basename,
86
+ absPath,
87
+ raw: readFileSync(absPath, 'utf8'),
88
+ };
89
+ }
90
+
91
+ function collectOccupiedTaskIds(vaultRoot) {
92
+ const occupied = new Set();
93
+ const dir = join(vaultRoot, 'specs', 'tasks');
94
+ if (!existsSync(dir)) return occupied;
95
+ // Normalize task ids of EITHER ordering to the canonical family-first form so
96
+ // suffix-collision detection works mid-migration ([[HS-EPIC-DLE3]]).
97
+ const addTask = (value) => {
98
+ const p = parseSpecId(value);
99
+ if (p && p.typeLabel === 'TASK') occupied.add(`HS-${p.familyKey}-TASK`);
100
+ };
101
+ for (const file of readdirSync(dir)) {
102
+ if (!file.endsWith('.md')) continue;
103
+ addTask(file.replace(/\.md$/i, ''));
104
+ try {
105
+ const raw = readFileSync(join(dir, file), 'utf8');
106
+ const idMatch = raw.match(/^id:\s*(\S+)/m);
107
+ if (idMatch) addTask(idMatch[1].replace(/^['"]|['"]$/g, ''));
108
+ } catch {
109
+ /* skip */
110
+ }
111
+ }
112
+ return occupied;
113
+ }
114
+
115
+ /**
116
+ * Idempotent defer lookup — same parent + `handoff_id` returns the existing split child
117
+ * ([[HS-TASK-W146-defer-idempotency-handoff-id-and-cross-a]]).
118
+ */
119
+ function findExistingChildByHandoffId(vaultRoot, parentBasename, handoffId) {
120
+ const needle = String(handoffId ?? '').trim();
121
+ if (!needle) return null;
122
+ const dir = join(vaultRoot, 'specs', 'tasks');
123
+ if (!existsSync(dir)) return null;
124
+ for (const file of readdirSync(dir)) {
125
+ if (!file.endsWith('.md')) continue;
126
+ const raw = readFileSync(join(dir, file), 'utf8');
127
+ const fm = getFrontmatterBlock(raw);
128
+ if (!fm) continue;
129
+ const splitOf = getScalarField(fm.block, 'split_of');
130
+ const existingHandoff = getScalarField(fm.block, 'handoff_id');
131
+ if (!splitOf || !existingHandoff) continue;
132
+ if (parseWikilinkBasename(splitOf) !== parentBasename) continue;
133
+ if (String(existingHandoff).trim() !== needle) continue;
134
+ const idMatch = fm.block.match(/^id:\s*(\S+)/m);
135
+ const childId = idMatch ? idMatch[1].replace(/^['"]|['"]$/g, '') : null;
136
+ return {
137
+ basename: file.replace(/\.md$/, ''),
138
+ absPath: join(dir, file),
139
+ relPath: join('specs', 'tasks', file),
140
+ childId,
141
+ raw,
142
+ };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function nextSplitIndex(vaultRoot, parentBasename) {
148
+ const dir = join(vaultRoot, 'specs', 'tasks');
149
+ if (!existsSync(dir)) return 1;
150
+ let max = 0;
151
+ for (const file of readdirSync(dir)) {
152
+ if (!file.endsWith('.md')) continue;
153
+ const raw = readFileSync(join(dir, file), 'utf8');
154
+ const fm = getFrontmatterBlock(raw);
155
+ if (!fm) continue;
156
+ const splitOf = getScalarField(fm.block, 'split_of');
157
+ const splitIndexRaw = getScalarField(fm.block, 'split_index');
158
+ if (!splitOf || !splitIndexRaw) continue;
159
+ const target = parseWikilinkBasename(splitOf);
160
+ if (target !== parentBasename) continue;
161
+ const idx = Number.parseInt(splitIndexRaw, 10);
162
+ if (Number.isFinite(idx) && idx > max) max = idx;
163
+ }
164
+ return max + 1;
165
+ }
166
+
167
+ // ─────────────────────────────────────────────────────────────────────────────
168
+ // Envelope + content builders
169
+ // ─────────────────────────────────────────────────────────────────────────────
170
+
171
+ function yamlList(key, items) {
172
+ if (!items.length) return `${key}: []`;
173
+ return `${key}:\n${items.map((item) => ` - ${yamlScalar(item)}`).join('\n')}`;
174
+ }
175
+
176
+ function buildHandoffEnvelopeSection(envelope) {
177
+ const yamlBlock = [
178
+ `reason: ${yamlScalar(envelope.reason)}`,
179
+ yamlList('summary_done', envelope.summary_done),
180
+ yamlList('remaining', envelope.remaining),
181
+ yamlList('success_criteria', envelope.success_criteria),
182
+ ].join('\n');
183
+ return `## Handoff envelope
184
+
185
+ \`\`\`yaml
186
+ ${yamlBlock}
187
+ \`\`\``;
188
+ }
189
+
190
+ function buildChildTaskContent({
191
+ childId,
192
+ childFileKey,
193
+ childFamilyKey,
194
+ childName,
195
+ parentTask,
196
+ parentFm,
197
+ splitIndex,
198
+ envelope,
199
+ effort,
200
+ handoffId,
201
+ now,
202
+ }) {
203
+ const slug = deriveSlug(childName);
204
+ const parentBasename = parentTask.basename;
205
+ const parentEpicLink = getScalarField(parentFm.block, 'parent') ?? '';
206
+ const parentMilestoneLink = getScalarField(parentFm.block, 'milestone') ?? '';
207
+ const kind = getScalarField(parentFm.block, 'kind') ?? 'feature';
208
+ const requiresUserTesting = getScalarField(parentFm.block, 'requires_user_testing') ?? 'false';
209
+ const tags = parseListFieldBasenames(parentFm.block, 'tags') ?? ['near-term'];
210
+ const tagsYaml =
211
+ tags.length === 0
212
+ ? 'tags: []'
213
+ : `tags:\n${tags.map((t) => ` - ${yamlScalar(t)}`).join('\n')}`;
214
+
215
+ const summaryLines = [
216
+ `Continuation of ${getScalarField(parentFm.block, 'name') ?? parentTask.basename} (split ${splitIndex}).`,
217
+ envelope.reason,
218
+ ].filter(Boolean);
219
+
220
+ const fm = `---
221
+ id: ${childId}
222
+ file_id: ${childFileKey}
223
+ family: ${childFamilyKey}
224
+ name: ${yamlScalar(childName)}
225
+ type: task
226
+ status: open
227
+ kind: ${yamlScalar(kind)}
228
+ effort: ${effort}
229
+ owner: ${yamlScalar(getScalarField(parentFm.block, 'owner') ?? 'pwher')}
230
+ parent: '${parentEpicLink}'
231
+ milestone: '${parentMilestoneLink}'
232
+ requires_user_testing: ${requiresUserTesting}
233
+ summary: |-
234
+ ${summaryLines.map((l) => ` ${l}`).join('\n')}
235
+ ${tagsYaml}
236
+ split_of: '[[${parentBasename}]]'
237
+ split_index: ${splitIndex}${handoffId ? `\nhandoff_id: ${yamlScalar(handoffId)}` : ''}
238
+ created: '${now}'
239
+ updated: '${now}'
240
+ ---
241
+ `;
242
+
243
+ const scopeItems = envelope.remaining.map((line) => `- ${line}`).join('\n');
244
+ const body = `## Scope
245
+
246
+ Deferred remainder from [[${parentBasename}]]:
247
+
248
+ ${scopeItems}
249
+
250
+ ${buildHandoffEnvelopeSection(envelope)}
251
+
252
+ ## Acceptance criteria
253
+
254
+ ${envelope.success_criteria.map((c, i) => `${i + 1}. ${c}`).join('\n')}
255
+ `;
256
+
257
+ return fm + body;
258
+ }
259
+
260
+ function ensureSplitSection(body, childBasename, splitIndex, reason) {
261
+ const heading = '## Split';
262
+ const line = `- Split ${splitIndex}: [[${childBasename}]] — ${reason}`;
263
+ if (!body.match(/^## Split\s*$/im)) {
264
+ return `${body.replace(/\s*$/, '')}\n\n${heading}\n\n${line}\n`;
265
+ }
266
+ if (body.includes(`[[${childBasename}]]`)) return body;
267
+ const idx = body.search(/^## Split\s*$/im);
268
+ const afterHeading = body.indexOf('\n', idx) + 1;
269
+ return `${body.slice(0, afterHeading)}${line}\n${body.slice(afterHeading)}`;
270
+ }
271
+
272
+ function buildUpdatedParentContent(parentRaw, { childBasename, splitIndex, parentStatus, reason, now, author }) {
273
+ const fm = getFrontmatterBlock(parentRaw);
274
+ if (!fm) return { changed: false, content: parentRaw };
275
+
276
+ let block = fm.block;
277
+ if (parentStatus && getScalarField(block, 'status') !== parentStatus) {
278
+ block = setScalarField(block, 'status', parentStatus);
279
+ }
280
+ block = bumpUpdated(block, now);
281
+
282
+ let body = fm.body;
283
+ body = ensureSplitSection(body, childBasename, splitIndex, reason);
284
+ body = appendChangeLogRow(body, now.slice(0, 10), author, `Deferred remainder → [[${childBasename}]] (split ${splitIndex})`);
285
+
286
+ return {
287
+ changed: true,
288
+ content: `${fm.open}${block}${fm.close}${body}`,
289
+ };
290
+ }
291
+
292
+ function buildUpdatedEpicContent(epicRaw, childBasename) {
293
+ const fm = getFrontmatterBlock(epicRaw);
294
+ if (!fm) return { changed: false, content: epicRaw };
295
+ const appended = appendToListField(fm.block, 'children', childBasename);
296
+ if (!appended.changed) return { changed: false, content: epicRaw };
297
+ return {
298
+ changed: true,
299
+ content: `${fm.open}${appended.block}${fm.close}${fm.body}`,
300
+ };
301
+ }
302
+
303
+ // ─────────────────────────────────────────────────────────────────────────────
304
+ // Validation
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+
307
+ function validateInputShape(args) {
308
+ if (!args || typeof args !== 'object') {
309
+ return { error: 'INVALID_ARGS', message: 'args must be an object' };
310
+ }
311
+ const parsedSpecId = typeof args.spec_id === 'string' ? parseSpecId(args.spec_id) : null;
312
+ if (!parsedSpecId || parsedSpecId.typeLabel !== 'TASK') {
313
+ return {
314
+ error: 'INVALID_ARGS',
315
+ message: 'spec_id must be a task id (HS-<key>-TASK or legacy HS-TASK-<key>)',
316
+ };
317
+ }
318
+ const envelope = {
319
+ reason: typeof args.reason === 'string' ? args.reason.trim() : '',
320
+ summary_done: Array.isArray(args.summary_done) ? args.summary_done.map(String).filter((s) => s.trim()) : [],
321
+ remaining: Array.isArray(args.remaining) ? args.remaining.map(String).filter((s) => s.trim()) : [],
322
+ success_criteria: Array.isArray(args.success_criteria)
323
+ ? args.success_criteria.map(String).filter((s) => s.trim())
324
+ : [],
325
+ };
326
+ const findings = validateHandoffEnvelopeFields(envelope);
327
+ if (findings.length > 0) {
328
+ return {
329
+ error: 'INVALID_ENVELOPE',
330
+ message: findings.map((f) => f.message).join('; '),
331
+ findings,
332
+ };
333
+ }
334
+ if (
335
+ args.parent_status !== undefined &&
336
+ args.parent_status !== null &&
337
+ !VALID_PARENT_STATUSES.has(String(args.parent_status))
338
+ ) {
339
+ return {
340
+ error: 'INVALID_ARGS',
341
+ message: 'parent_status must be "in_review" or "in_progress" when provided',
342
+ };
343
+ }
344
+ if (args.effort !== undefined && args.effort !== null) {
345
+ const effort = Number(args.effort);
346
+ if (!Number.isFinite(effort) || effort < 1) {
347
+ return { error: 'INVALID_ARGS', message: 'effort must be a positive number when provided' };
348
+ }
349
+ }
350
+ const handoffId =
351
+ typeof args.handoff_id === 'string' && args.handoff_id.trim() ? args.handoff_id.trim() : null;
352
+ if (handoffId !== null && handoffId.length === 0) {
353
+ return { error: 'INVALID_ARGS', message: 'handoff_id must be a non-empty string when provided' };
354
+ }
355
+ return { envelope, handoffId };
356
+ }
357
+
358
+ // ─────────────────────────────────────────────────────────────────────────────
359
+ // Execute
360
+ // ─────────────────────────────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * @param {string} vaultRoot repo root (parent of specs/)
364
+ * @param {object} args
365
+ * @param {object} [deps]
366
+ */
367
+ export async function executeDeferSpec(vaultRoot, args = {}, deps = {}) {
368
+ const shape = validateInputShape(args);
369
+ if (shape.error) return shape;
370
+ const envelope = shape.envelope;
371
+ const handoffId = shape.handoffId ?? null;
372
+
373
+ const parent = findTaskByIdLocal(vaultRoot, args.spec_id);
374
+ if (!parent) {
375
+ return { error: 'TASK_NOT_FOUND', message: `task ${args.spec_id} not found under specs/tasks/` };
376
+ }
377
+
378
+ if (handoffId) {
379
+ const existing = findExistingChildByHandoffId(vaultRoot, parent.basename, handoffId);
380
+ if (existing) {
381
+ const existingFm = getFrontmatterBlock(existing.raw);
382
+ const existingId =
383
+ existing.childId ??
384
+ (existingFm ? getScalarField(existingFm.block, 'id')?.replace(/^['"]|['"]$/g, '') : null);
385
+ return {
386
+ ok: true,
387
+ idempotent: true,
388
+ parent_id: args.spec_id,
389
+ parent_basename: parent.basename,
390
+ parent_status: getScalarField(getFrontmatterBlock(parent.raw)?.block ?? '', 'status') ?? 'open',
391
+ child_id: existingId ?? existing.basename,
392
+ child_basename: existing.basename,
393
+ child_path: existing.relPath,
394
+ split_index: existingFm ? Number.parseInt(getScalarField(existingFm.block, 'split_index') ?? '1', 10) : 1,
395
+ epic_id: null,
396
+ envelope,
397
+ handoff_id: handoffId,
398
+ };
399
+ }
400
+ }
401
+
402
+ const parentFm = getFrontmatterBlock(parent.raw);
403
+ if (!parentFm) {
404
+ return { error: 'INVALID_PARENT', message: 'parent task has no frontmatter block' };
405
+ }
406
+
407
+ const parentStatus = args.parent_status ?? 'in_review';
408
+ const effort =
409
+ args.effort !== undefined && args.effort !== null
410
+ ? Number(args.effort)
411
+ : Number.parseInt(getScalarField(parentFm.block, 'effort') ?? '3', 10) || 3;
412
+
413
+ const parentName = getScalarField(parentFm.block, 'name') ?? parent.basename;
414
+ const childName =
415
+ typeof args.child_name === 'string' && args.child_name.trim()
416
+ ? args.child_name.trim()
417
+ : `${parentName.replace(/^['"]|['"]$/g, '')} (continued)`;
418
+
419
+ const splitIndex = nextSplitIndex(vaultRoot, parent.basename);
420
+ const now = (deps?.clock ?? isoNow)();
421
+ const author = typeof args.author === 'string' && args.author.trim() ? args.author.trim() : 'defer-spec (agent)';
422
+
423
+ // Era-3 family inheritance ([[naming-v3-7-rulings-hs-namespace-kept]]): the
424
+ // split child inherits the PARENT task's family (familyOf: explicit
425
+ // `family:` frontmatter wins, else positional from id/basename). The legacy
426
+ // allocator's `HS-<family>-TASK` draw is only the defensive fallback family
427
+ // source; `id:` keys onto the file key alone.
428
+ const parentIdValue = getScalarField(parentFm.block, 'id')?.replace(/^['"]|['"]$/g, '') ?? null;
429
+ const parentFamilyValue = getScalarField(parentFm.block, 'family')?.replace(/^['"]|['"]$/g, '') ?? null;
430
+ const childFamilyKey =
431
+ familyOf({
432
+ id: parentIdValue,
433
+ basename: parent.basename,
434
+ frontmatter: { family: parentFamilyValue },
435
+ }) ??
436
+ parseSpecId(
437
+ allocateRandomVaultId('task', collectOccupiedTaskIds(vaultRoot), { randomUnit: deps?.randomUnit }),
438
+ )?.familyKey ??
439
+ mintFamilyKey(vaultRoot, { randomUnit: deps?.randomUnit });
440
+ // Per-file FILE KEY (owner ruling 2026-07-10) — filename + `file_id:` SSOT,
441
+ // and (V3) the anchor for `id:` itself.
442
+ const childFileKey = mintFileKey(vaultRoot, { randomUnit: deps?.randomUnit });
443
+ const childId = `HS-${childFileKey}`;
444
+ const childSlug = deriveSlug(childName);
445
+ const childBasename = `${childId}-${childFamilyKey}-TASK-${childSlug}`;
446
+ const childAbsPath = join(vaultRoot, 'specs', 'tasks', `${childBasename}.md`);
447
+
448
+ const writes = [];
449
+ writes.push({
450
+ kind: 'create',
451
+ absPath: childAbsPath,
452
+ content: buildChildTaskContent({
453
+ childId,
454
+ childFileKey,
455
+ childFamilyKey,
456
+ childName,
457
+ parentTask: parent,
458
+ parentFm,
459
+ splitIndex,
460
+ envelope,
461
+ effort,
462
+ handoffId,
463
+ now,
464
+ }),
465
+ });
466
+
467
+ const parentUpdate = buildUpdatedParentContent(parent.raw, {
468
+ childBasename,
469
+ splitIndex,
470
+ parentStatus,
471
+ reason: envelope.reason,
472
+ now,
473
+ author,
474
+ });
475
+ if (parentUpdate.changed) {
476
+ writes.push({ kind: 'update', absPath: parent.absPath, content: parentUpdate.content });
477
+ }
478
+
479
+ const epicLink = getScalarField(parentFm.block, 'parent');
480
+ const epicBasename = epicLink ? parseWikilinkBasename(epicLink) : null;
481
+ let epicBasenameResolved = epicBasename;
482
+ if (epicBasename) {
483
+ const epic = findEpicByBasenameLocal(vaultRoot, epicBasename);
484
+ if (epic) {
485
+ epicBasenameResolved = epic.basename;
486
+ const epicUpdate = buildUpdatedEpicContent(epic.raw, childBasename);
487
+ if (epicUpdate.changed) {
488
+ writes.push({ kind: 'update', absPath: epic.absPath, content: epicUpdate.content });
489
+ }
490
+ }
491
+ }
492
+
493
+ const commitResult = commitWrites(writes);
494
+ if (!commitResult.ok) {
495
+ return {
496
+ error: 'WRITE_FAILED',
497
+ message: commitResult.error?.message ?? 'unknown write failure',
498
+ rollback_error: commitResult.error?.rollbackError,
499
+ };
500
+ }
501
+
502
+ return {
503
+ ok: true,
504
+ parent_id: args.spec_id,
505
+ parent_basename: parent.basename,
506
+ parent_status: parentStatus,
507
+ child_id: childId,
508
+ child_basename: childBasename,
509
+ child_path: join('specs', 'tasks', `${childBasename}.md`),
510
+ split_index: splitIndex,
511
+ epic_id: epicBasenameResolved,
512
+ envelope,
513
+ handoff_id: handoffId,
514
+ writes: writes.map((w) => ({ kind: w.kind, path: w.absPath })),
515
+ };
516
+ }
517
+
518
+ export const __testables = {
519
+ validateInputShape,
520
+ findTaskByIdLocal,
521
+ findExistingChildByHandoffId,
522
+ nextSplitIndex,
523
+ buildHandoffEnvelopeSection,
524
+ buildChildTaskContent,
525
+ buildUpdatedParentContent,
526
+ ensureSplitSection,
527
+ };