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,574 @@
1
+ /**
2
+ * validate-linkage.mjs — the core of `heyspex_validate_linkage`.
3
+ *
4
+ * Detects four classes of linkage breakage across the spec vault so work an
5
+ * agent creates does not quietly go missing (HS-TASK-0U1F under
6
+ * [[HS-EPIC-0037-spec-linkage-validation-system]]):
7
+ *
8
+ * 1. orphan_specs — milestone/epic/task not reachable from the roadmap.
9
+ * 2. broken_wikilinks — `[[link]]` that resolves to no file under specs/.
10
+ * 3. missing_manifest_refs — roadmap manifest entry pointing to a missing file
11
+ * (basename/unique-id match only — family idCore soft-match is intentionally
12
+ * NOT enough, so stale Naming V3 era-1/2 listings cannot greenlight).
13
+ * 4. parent_child_mismatches — one side of a parent/child link not pointing back.
14
+ *
15
+ * Reachability is expressed in the manifest-v3 hierarchy
16
+ * ([[HS-TASK-JN9H-manifest-schema-v3-and-horizon-retirement]]):
17
+ * roadmap → milestones (roadmap_manifest.milestones)
18
+ * → epics (epic `milestone:` ↔ milestone `epics:`)
19
+ * → tasks (task `parent:` ↔ epic `children:`).
20
+ * The original 2026-05-18 task body assumed the retired `phases[].tasks[]`
21
+ * shape; this implementation tracks the live v3 manifest instead and the
22
+ * orphan `reason`/`suggestion` strings reflect that.
23
+ *
24
+ * The module is self-contained (no import from spex-console/) so the vendored
25
+ * MCP server stays portable. Frontmatter is read with the same regex-over-raw
26
+ * approach `scripts/validate-vault-ids.mjs` uses, not a full YAML parser.
27
+ *
28
+ * Faces ([[capability-three-faces-no-per-capability-slash]]): this core feeds
29
+ * - the `heyspex_validate_linkage` MCP tool (cli.mjs), and
30
+ * - the `validate` CLI verb (cli.mjs, used by the pre-commit linkage gate).
31
+ */
32
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
33
+ import { join, relative } from 'node:path';
34
+ import { toCanonicalFamilyFirstId } from './spec-id-lib.mjs';
35
+
36
+ // Entity kinds that must be reachable from the roadmap. Everything else
37
+ // (research, protocol, memory-*, capture, diagnose, agent, review, adr, …) is
38
+ // exempt — those are referenced by wikilink, not parented into the manifest.
39
+ const SUBJECT_TYPES = new Set(['milestone', 'epic', 'task']);
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Frontmatter extraction (regex-over-raw — byte-tolerant, no YAML dependency)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function frontmatterBlock(raw) {
46
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
47
+ return m ? m[1] : '';
48
+ }
49
+
50
+ function scalarField(block, field) {
51
+ const m = block.match(new RegExp(`^${field}:[ \\t]*(.*)$`, 'm'));
52
+ if (!m) return null;
53
+ const v = m[1].trim();
54
+ if (!v) return null;
55
+ return v.replace(/^['"]|['"]$/g, '');
56
+ }
57
+
58
+ // Collect the raw list items under `field:` (a YAML block list). Each item may
59
+ // be a bare token (manifest entries) or a wikilink (children/related/epics);
60
+ // callers normalize with stripWikilink(). Returns [] when the field is absent
61
+ // or explicitly empty (`field: []`).
62
+ function listItems(block, field) {
63
+ if (new RegExp(`^[ \\t]*${field}:[ \\t]*\\[[ \\t]*\\][ \\t]*$`, 'm').test(block)) return [];
64
+ const m = block.match(
65
+ new RegExp(`^[ \\t]*${field}:[ \\t]*\\r?\\n((?:[ \\t]+-[^\\n]*(?:\\r?\\n|$))+)`, 'm'),
66
+ );
67
+ if (!m) return [];
68
+ const out = [];
69
+ for (const line of m[1].split(/\r?\n/)) {
70
+ const im = line.match(/^[ \t]+-[ \t]*(.+?)[ \t]*$/);
71
+ if (im) out.push(im[1].replace(/^['"]|['"]$/g, '').trim());
72
+ }
73
+ return out;
74
+ }
75
+
76
+ // `[[Foo|alias]]` / `[[Foo#anchor]]` / `Foo` → `Foo`. Returns null for empties.
77
+ function stripWikilink(value) {
78
+ if (typeof value !== 'string') return null;
79
+ const inner = value.match(/\[\[([^\]]+)\]\]/);
80
+ let target = (inner ? inner[1] : value).trim();
81
+ target = target.split('|')[0].split('#')[0].trim();
82
+ return target || null;
83
+ }
84
+
85
+ // The stable identity core of an id/basename: `HS-TASK-0U1F-foo` → `HS-TASK-0U1F`.
86
+ // Lets a `[[HS-TASK-0U1F]]` link (no slug) resolve to the full-slug file, and
87
+ // guards the protocol/research hyphenated-id case
88
+ // ([[hs-spec-id-regex-truncates-hyphenated-protocol-ids]]) by anchoring on the
89
+ // 4+ char suffix rather than splitting on every hyphen.
90
+ // Dual-read core: parseSpecId canonicalizes known types in EITHER ordering to one
91
+ // form so refs match across the type-first → family-first migration; falls back to
92
+ // the legacy anchored regex for unknown-type / hyphenated ids. [[HS-EPIC-DLE3]].
93
+ function idCore(value) {
94
+ const canonical = toCanonicalFamilyFirstId(value);
95
+ if (canonical) return canonical.toUpperCase();
96
+ const m = String(value || '').match(/^(HS-[A-Z]+-[A-Za-z0-9]{2,})/i);
97
+ return m ? m[1].toUpperCase() : null;
98
+ }
99
+
100
+ // Every "real" `[[wikilink]]` in the file, with 1-based line numbers for
101
+ // actionable reporting. Links inside fenced code blocks (``` / ~~~) and inline
102
+ // code spans (`...`) are skipped — specs routinely show `[[example]]` syntax as
103
+ // documentation, and those illustrative links must not be flagged as broken.
104
+ // Handles `![[embeds]]` and multiple links per line.
105
+ function extractLinks(raw) {
106
+ const links = [];
107
+ const lines = raw.split(/\r?\n/);
108
+ let inFence = false;
109
+ for (let i = 0; i < lines.length; i++) {
110
+ const line = lines[i];
111
+ if (/^[ \t]*(```|~~~)/.test(line)) {
112
+ inFence = !inFence;
113
+ continue;
114
+ }
115
+ if (inFence) continue;
116
+ // Drop inline code spans so `[[link]]` shown as code is not treated as real.
117
+ const scrubbed = line.replace(/`[^`]*`/g, '');
118
+ const re = /!?\[\[([^\]]+)\]\]/g;
119
+ let m;
120
+ while ((m = re.exec(scrubbed)) !== null) {
121
+ const target = m[1].split('|')[0].split('#')[0].trim();
122
+ if (target) links.push({ target, line: i + 1 });
123
+ }
124
+ }
125
+ return links;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Vault walk → normalized spec records
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function walkMarkdown(dir, acc = []) {
133
+ let entries;
134
+ try {
135
+ entries = readdirSync(dir, { withFileTypes: true });
136
+ } catch {
137
+ return acc;
138
+ }
139
+ for (const entry of entries) {
140
+ const full = join(dir, entry.name);
141
+ if (entry.isDirectory()) {
142
+ if (entry.name === '.trash' || entry.name === 'node_modules') continue;
143
+ walkMarkdown(full, acc);
144
+ } else if (entry.name.endsWith('.md')) {
145
+ acc.push(full);
146
+ }
147
+ }
148
+ return acc;
149
+ }
150
+
151
+ // Extract `## Related / lineage` markdown table rows and return wikilinks from
152
+ // first column. Returns [] if section does not exist.
153
+ function extractLineageTableLinks(raw) {
154
+ const lineageMatch = raw.match(/^##\s+Related\s*\/\s*lineage[\s\S]*?(?=^##\s|\Z)/m);
155
+ if (!lineageMatch) return [];
156
+ const section = lineageMatch[0];
157
+ // Find markdown table (| ... |)
158
+ const tableMatch = section.match(/^\|[^|]*\|[^|]*\|[\s\S]*?(?=\n\n|\Z)/m);
159
+ if (!tableMatch) return [];
160
+ const table = tableMatch[0];
161
+ const lines = table.split('\n');
162
+ const links = [];
163
+ // Skip header (first line) and separator (second line)
164
+ for (let i = 2; i < lines.length; i++) {
165
+ const line = lines[i].trim();
166
+ if (!line) break;
167
+ // First column is between first and second | delimiters
168
+ const match = line.match(/^\|\s*([^|]*?)\s*\|/);
169
+ if (match) {
170
+ const cellContent = match[1].trim();
171
+ const linkMatch = cellContent.match(/\[\[([^\]]+)\]\]/);
172
+ if (linkMatch) {
173
+ const target = linkMatch[1].split('|')[0].split('#')[0].trim();
174
+ if (target) links.push(target);
175
+ }
176
+ }
177
+ }
178
+ return links;
179
+ }
180
+
181
+ /**
182
+ * Read every `.md` under `<vaultRoot>/specs/` into a normalized record the
183
+ * analyzer consumes. Exported so the analyzer can be unit-tested with synthetic
184
+ * records (no disk) while the real path walks the vault.
185
+ */
186
+ export function collectVaultSpecs(vaultRoot) {
187
+ const specsDir = join(vaultRoot, 'specs');
188
+ if (!existsSync(specsDir)) return [];
189
+ const specs = [];
190
+ for (const abs of walkMarkdown(specsDir)) {
191
+ let raw;
192
+ try {
193
+ raw = readFileSync(abs, 'utf-8');
194
+ } catch {
195
+ continue;
196
+ }
197
+ const block = frontmatterBlock(raw);
198
+ const basename = abs.replace(/.*[\\/]/, '').replace(/\.md$/, '');
199
+ const id = scalarField(block, 'id') || basename;
200
+ const type = scalarField(block, 'type');
201
+ const manifestRoadmap = type === 'roadmap' || /^roadmap_manifest:/m.test(block);
202
+ specs.push({
203
+ id,
204
+ type,
205
+ basename,
206
+ idCore: idCore(id) || idCore(basename),
207
+ file: relative(vaultRoot, abs).split('\\').join('/'),
208
+ parent: stripWikilink(scalarField(block, 'parent')),
209
+ milestone: stripWikilink(scalarField(block, 'milestone')),
210
+ children: listItems(block, 'children').map(stripWikilink).filter(Boolean),
211
+ epics: listItems(block, 'epics').map(stripWikilink).filter(Boolean),
212
+ related: listItems(block, 'related').map(stripWikilink).filter(Boolean),
213
+ // `amends:` — the partial / non-terminal supersession tier ([[AMENDS-CONTRACT]] /
214
+ // HS-6DMR-EPIC). Lives ONLY on the amending (newer, canonical) doc; the reverse
215
+ // map "what amends spec X" is COMPUTED in analyzeLinkage — targets are never edited.
216
+ amends: listItems(block, 'amends').map(stripWikilink).filter(Boolean),
217
+ // `iterates_on:` — round-to-round iteration tier. Like amends, lives ONLY on the
218
+ // newer doc; the reverse map "what iterates_on spec X" is COMPUTED in analyzeLinkage.
219
+ iterates_on: listItems(block, 'iterates_on').map(stripWikilink).filter(Boolean),
220
+ links: extractLinks(raw),
221
+ lineage_table_links: extractLineageTableLinks(raw),
222
+ manifest: manifestRoadmap
223
+ ? {
224
+ milestones: listItems(block, 'milestones').map(stripWikilink).filter(Boolean),
225
+ initiatives: listItems(block, 'initiatives').map(stripWikilink).filter(Boolean),
226
+ }
227
+ : null,
228
+ });
229
+ }
230
+ return specs;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Analysis (pure — operates over normalized records)
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * @param {object[]} specs Normalized records (from collectVaultSpecs or a fixture).
239
+ * @param {object} options
240
+ * @param {boolean} [options.includeWarnings=true]
241
+ * @param {Set<string>|null} [options.scopeIds] When set, only report issues whose
242
+ * subject spec matches one of these ids/basenames/id-cores (pre-commit
243
+ * `--staged-only`). null = whole vault.
244
+ */
245
+ export function analyzeLinkage(specs, { includeWarnings = true, scopeIds = null } = {}) {
246
+ const byBasename = new Map();
247
+ const byId = new Map();
248
+ const byIdCore = new Map();
249
+ for (const s of specs) {
250
+ byBasename.set(s.basename, s);
251
+ if (s.id) byId.set(s.id, s);
252
+ if (s.idCore && !byIdCore.has(s.idCore)) byIdCore.set(s.idCore, s);
253
+ }
254
+
255
+ const resolveRef = (ref) => {
256
+ if (!ref) return null;
257
+ const t = stripWikilink(ref) ?? ref;
258
+ return byBasename.get(t) || byId.get(t) || byIdCore.get(idCore(t)) || null;
259
+ };
260
+ // Manifest entries must name an on-disk basename (or unique bare id). Family
261
+ // idCore alone is NOT enough — after Naming V3, an era-1/2 `HS-<family>-MIL-…`
262
+ // string shares idCore with the era-3 file but does not match the console's
263
+ // basename resolver, so roadmap rows go empty while linkage looked green.
264
+ const resolveManifestRef = (ref) => {
265
+ if (!ref) return null;
266
+ const t = stripWikilink(ref) ?? ref;
267
+ return byBasename.get(t) || byId.get(t) || null;
268
+ };
269
+ const refResolves = (ref) => resolveRef(ref) != null;
270
+ const refMatchesSpec = (ref, spec) => {
271
+ const t = stripWikilink(ref) ?? ref;
272
+ return t === spec.basename || t === spec.id || (!!spec.idCore && idCore(t) === spec.idCore);
273
+ };
274
+ const listContainsSpec = (list, spec) => list.some((ref) => refMatchesSpec(ref, spec));
275
+
276
+ // Manifest reachability index — union of every roadmap's milestone entries.
277
+ // Basename / bare-id only (no idCore) so a stale era-1/2 listing does not
278
+ // falsely cover the era-3 file on disk.
279
+ const manifestMilestoneRefs = new Set();
280
+ for (const s of specs) {
281
+ if (!s.manifest) continue;
282
+ for (const ref of s.manifest.milestones) {
283
+ manifestMilestoneRefs.add(ref);
284
+ }
285
+ }
286
+ const milestoneInManifest = (spec) =>
287
+ manifestMilestoneRefs.has(spec.basename) || manifestMilestoneRefs.has(spec.id);
288
+ const inScope = (spec) =>
289
+ !scopeIds ||
290
+ scopeIds.has(spec.id) ||
291
+ scopeIds.has(spec.basename) ||
292
+ (!!spec.idCore && scopeIds.has(spec.idCore));
293
+
294
+ // Amends reverse map ([[AMENDS-CONTRACT]] / HS-6DMR-EPIC) — the partial,
295
+ // non-terminal supersession tier. `amends:` lives only on the amending (newer)
296
+ // doc; "what amends spec X" is COMPUTED here by scanning every spec's amends
297
+ // list, so one edit on the source needs zero edits on N targets. Amended
298
+ // targets stay canonical + scheduled (this is resolution only, not filtering).
299
+ const amended_by = {}; // targetBasename → [sourceBasename, …]
300
+ const broken_amends = []; // amends entries that resolve to no spec under specs/
301
+ for (const spec of specs) {
302
+ for (const ref of spec.amends || []) {
303
+ const target = resolveRef(ref);
304
+ if (!target) {
305
+ if (inScope(spec)) {
306
+ broken_amends.push({
307
+ spec_id: spec.id,
308
+ file_path: spec.file,
309
+ ref: `[[${stripWikilink(ref) ?? ref}]]`,
310
+ error: 'amends: target does not resolve to a spec under specs/',
311
+ });
312
+ }
313
+ continue;
314
+ }
315
+ (amended_by[target.basename] ??= []).push(spec.basename);
316
+ }
317
+ }
318
+
319
+ // Iterates-on reverse map — round-to-round iteration tier. Like amends, `iterates_on:`
320
+ // lives only on the iterating (newer) doc; the reverse map "what iterates_on spec X"
321
+ // is COMPUTED here. Targets that don't resolve are reported in broken_iterates_on.
322
+ const iterated_by = {}; // targetBasename → [sourceBasename, …]
323
+ const broken_iterates_on = []; // iterates_on entries that resolve to no spec under specs/
324
+ for (const spec of specs) {
325
+ for (const ref of spec.iterates_on || []) {
326
+ const target = resolveRef(ref);
327
+ if (!target) {
328
+ if (inScope(spec)) {
329
+ broken_iterates_on.push({
330
+ spec_id: spec.id,
331
+ file_path: spec.file,
332
+ ref: `[[${stripWikilink(ref) ?? ref}]]`,
333
+ error: 'iterates_on: target does not resolve to a spec under specs/',
334
+ });
335
+ }
336
+ continue;
337
+ }
338
+ (iterated_by[target.basename] ??= []).push(spec.basename);
339
+ }
340
+ }
341
+
342
+ const orphan_specs = [];
343
+ const broken_wikilinks = [];
344
+ const missing_manifest_refs = [];
345
+ const parent_child_mismatches = [];
346
+ const warnings = [];
347
+ const seenMismatch = new Set();
348
+
349
+ const pushMismatch = (parent, child, issue, parentSpec, childSpec) => {
350
+ const key = `${parent}|${child}|${issue}`;
351
+ if (seenMismatch.has(key)) return;
352
+ seenMismatch.add(key);
353
+ if (!inScope(parentSpec) && !inScope(childSpec)) return;
354
+ parent_child_mismatches.push({ parent, child, issue });
355
+ };
356
+
357
+ let subjectsChecked = 0;
358
+ for (const spec of specs) {
359
+ // (2) broken wikilinks — every spec's outbound links, scoped to its owner.
360
+ if (inScope(spec)) {
361
+ for (const link of spec.links) {
362
+ if (!refResolves(link.target)) {
363
+ broken_wikilinks.push({ file: spec.file, line: link.line, link: `[[${link.target}]]` });
364
+ }
365
+ }
366
+ }
367
+
368
+ // (3) missing manifest refs — roadmap entries with no backing file.
369
+ // Strict: basename/id only. idCore soft-match would greenlight stale
370
+ // Naming V3-era family-first listings that the console cannot resolve.
371
+ if (spec.manifest && inScope(spec)) {
372
+ for (const ref of [...spec.manifest.milestones, ...spec.manifest.initiatives]) {
373
+ if (!resolveManifestRef(ref)) {
374
+ missing_manifest_refs.push({
375
+ manifest: spec.id,
376
+ ref,
377
+ error: 'File not found (manifest refs must match basename or unique id; family-core alone is not enough)',
378
+ });
379
+ }
380
+ }
381
+ }
382
+
383
+ if (!SUBJECT_TYPES.has(spec.type)) continue;
384
+ subjectsChecked++;
385
+
386
+ // (1) orphan detection, by entity kind.
387
+ if (spec.type === 'milestone') {
388
+ if (!milestoneInManifest(spec)) {
389
+ if (inScope(spec)) {
390
+ orphan_specs.push({
391
+ spec_id: spec.id,
392
+ file_path: spec.file,
393
+ type: spec.type,
394
+ suggestion: 'Add this milestone to a roadmap manifest (roadmap_manifest.milestones).',
395
+ reason: 'Milestone is not listed in any roadmap manifest, so it is unreachable from the roadmap.',
396
+ });
397
+ }
398
+ }
399
+ } else if (spec.type === 'epic') {
400
+ if (!spec.milestone || !refResolves(spec.milestone)) {
401
+ if (inScope(spec)) {
402
+ orphan_specs.push({
403
+ spec_id: spec.id,
404
+ file_path: spec.file,
405
+ type: spec.type,
406
+ suggestion: 'Set `milestone:` to an existing HS-MIL-* milestone.',
407
+ reason: spec.milestone
408
+ ? `Epic \`milestone:\` does not resolve to an existing milestone (${spec.milestone}).`
409
+ : 'Epic has no `milestone:` parent, so it is unreachable from the roadmap.',
410
+ });
411
+ }
412
+ } else {
413
+ const ms = resolveRef(spec.milestone);
414
+ if (ms && ms.type === 'milestone' && !listContainsSpec(ms.epics, spec)) {
415
+ pushMismatch(
416
+ ms.id,
417
+ spec.id,
418
+ 'Epic lists milestone but milestone is missing the epic in epics[]',
419
+ ms,
420
+ spec,
421
+ );
422
+ }
423
+ }
424
+ } else if (spec.type === 'task') {
425
+ if (!spec.parent || !refResolves(spec.parent)) {
426
+ if (inScope(spec)) {
427
+ orphan_specs.push({
428
+ spec_id: spec.id,
429
+ file_path: spec.file,
430
+ type: spec.type,
431
+ suggestion: 'Set `parent:` to an existing HS-EPIC-* epic.',
432
+ reason: spec.parent
433
+ ? `Task \`parent:\` does not resolve to an existing epic (${spec.parent}).`
434
+ : 'Task has no `parent:` epic, so it is unreachable from the roadmap.',
435
+ });
436
+ }
437
+ } else {
438
+ const epic = resolveRef(spec.parent);
439
+ if (epic && epic.type === 'epic' && !listContainsSpec(epic.children, spec)) {
440
+ pushMismatch(
441
+ epic.id,
442
+ spec.id,
443
+ 'Child lists parent but parent is missing the child in children[]',
444
+ epic,
445
+ spec,
446
+ );
447
+ }
448
+ }
449
+ }
450
+
451
+ // (4) reverse parent/child — declared children/epics must resolve under specs/.
452
+ // A stale wikilink to a file moved outside specs/ must NOT pass silently
453
+ // ([[HS-9A78-DIAG]] — previously `if (!child) continue` masked the gap).
454
+ if (spec.type === 'epic') {
455
+ for (const childRef of spec.children) {
456
+ const child = resolveRef(childRef);
457
+ const childLabel = stripWikilink(childRef) ?? childRef;
458
+ if (!child) {
459
+ if (inScope(spec)) {
460
+ pushMismatch(
461
+ spec.id,
462
+ childLabel,
463
+ 'Parent lists child that does not resolve to a spec under specs/',
464
+ spec,
465
+ spec,
466
+ );
467
+ }
468
+ continue;
469
+ }
470
+ if (!child.parent || !refMatchesSpec(child.parent, spec)) {
471
+ pushMismatch(
472
+ spec.id,
473
+ child.id,
474
+ 'Parent lists child but child `parent:` does not point back',
475
+ spec,
476
+ child,
477
+ );
478
+ }
479
+ }
480
+ }
481
+ if (spec.type === 'milestone') {
482
+ for (const epicRef of spec.epics) {
483
+ const epic = resolveRef(epicRef);
484
+ const epicLabel = stripWikilink(epicRef) ?? epicRef;
485
+ if (!epic) {
486
+ if (inScope(spec)) {
487
+ pushMismatch(
488
+ spec.id,
489
+ epicLabel,
490
+ 'Milestone lists epic that does not resolve to a spec under specs/',
491
+ spec,
492
+ spec,
493
+ );
494
+ }
495
+ continue;
496
+ }
497
+ if (!epic.milestone || !refMatchesSpec(epic.milestone, spec)) {
498
+ pushMismatch(
499
+ spec.id,
500
+ epic.id,
501
+ 'Milestone lists epic but epic `milestone:` does not point back',
502
+ spec,
503
+ epic,
504
+ );
505
+ }
506
+ }
507
+ }
508
+
509
+ // Soft warnings (gated) — the documented "no related[] links" nudge.
510
+ if (includeWarnings && inScope(spec) && spec.related.length === 0) {
511
+ warnings.push({ spec_id: spec.id, message: 'No related[] links defined' });
512
+ }
513
+
514
+ // Lineage table link resolution warnings — each wikilink in the first column
515
+ // of a "## Related / lineage" markdown table must resolve under specs/.
516
+ if (includeWarnings && inScope(spec)) {
517
+ for (const link of spec.lineage_table_links || []) {
518
+ if (!refResolves(link)) {
519
+ warnings.push({
520
+ spec_id: spec.id,
521
+ message: `Related/lineage table row wikilink does not resolve: [[${link}]]`,
522
+ });
523
+ }
524
+ }
525
+ }
526
+ }
527
+
528
+ const valid =
529
+ orphan_specs.length === 0 &&
530
+ broken_wikilinks.length === 0 &&
531
+ missing_manifest_refs.length === 0 &&
532
+ parent_child_mismatches.length === 0 &&
533
+ broken_amends.length === 0 &&
534
+ broken_iterates_on.length === 0;
535
+
536
+ return {
537
+ valid,
538
+ orphan_specs,
539
+ broken_wikilinks,
540
+ missing_manifest_refs,
541
+ parent_child_mismatches,
542
+ broken_amends,
543
+ amended_by,
544
+ broken_iterates_on,
545
+ iterated_by,
546
+ warnings: includeWarnings ? warnings : [],
547
+ summary: {
548
+ total_specs_checked: subjectsChecked,
549
+ orphans: orphan_specs.length,
550
+ broken_links: broken_wikilinks.length,
551
+ missing_refs: missing_manifest_refs.length,
552
+ mismatches: parent_child_mismatches.length,
553
+ broken_amends: broken_amends.length,
554
+ broken_iterates_on: broken_iterates_on.length,
555
+ },
556
+ };
557
+ }
558
+
559
+ /**
560
+ * End-to-end: walk a real vault and analyze it.
561
+ * @param {string} vaultRoot
562
+ * @param {object} [opts]
563
+ * @param {string} [opts.spec_id] Scope to a single spec.
564
+ * @param {string[]} [opts.spec_ids] Scope to several specs (overrides spec_id).
565
+ * @param {boolean} [opts.includeWarnings=true]
566
+ */
567
+ export function validateLinkage(vaultRoot, { spec_id = null, spec_ids = null, includeWarnings = true } = {}) {
568
+ const specs = collectVaultSpecs(vaultRoot);
569
+ const scope = spec_ids ?? (spec_id ? [spec_id] : null);
570
+ return analyzeLinkage(specs, {
571
+ includeWarnings,
572
+ scopeIds: scope && scope.length ? new Set(scope) : null,
573
+ });
574
+ }