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,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
|
+
};
|