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