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,309 @@
1
+ /**
2
+ * Ground first, then size — the two decisions the breakdown stage makes before it
3
+ * writes anything ([[HS-EYJ3]] of [[HS-ALBV]]).
4
+ *
5
+ * **Ground before sizing.** Epic §Scope: "Step one searches for an open milestone or
6
+ * epic already covering the area — not 'how big is this'." The worked case is
7
+ * [[HS-WZ62-JJIP-DIAG-console-loads-the-whole-vault-as-one-5]]: it reads
8
+ * milestone-sized, and both the milestone AND the epic that own it already existed,
9
+ * open and untouched since 2026-05-31, while seven separate fixes landed nearby and
10
+ * missed it. Sizing first is what produced those seven.
11
+ *
12
+ * **Count fixes, not length.** The size test counts things that can be verified
13
+ * separately, taken from what survives the write-up's own `## Fix` section. One fix
14
+ * means no breakdown at all — claim the write-up and fix it, exactly as today. "AI
15
+ * deciding the size" is explicitly out of scope for the epic: the count is authored
16
+ * by whoever ran the investigation, and this module only reads what they wrote.
17
+ *
18
+ * PURE + DETERMINISTIC. Lexical matching only, no AI, no network. Same posture as
19
+ * the scheduler: a human-reviewable rule, not a score
20
+ * ([[HS-8CD6-KF00-RES-unified-quality-signals-triage-as-the-on]]).
21
+ */
22
+
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ import { getFrontmatterBlock, getScalarField } from './promotion-helpers.mjs';
27
+
28
+ /**
29
+ * Statuses a candidate must NOT be in to count as covering the area. Mirrors the
30
+ * scheduler's claim-terminal set: attaching new work to a closed or cancelled
31
+ * container is how a "match" becomes a dead end.
32
+ */
33
+ export const COVERAGE_ELIGIBLE_STATUSES = new Set(['open', 'in_progress']);
34
+
35
+ /**
36
+ * Words that carry no subject. Without this the overlap scan matches every spec in
37
+ * the vault on "the", "console", "a" and ranks noise at the top, which reads as a
38
+ * confident answer and is worse than reporting nothing.
39
+ */
40
+ const STOPWORDS = new Set([
41
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', 'has', 'have',
42
+ 'in', 'into', 'is', 'it', 'its', 'not', 'of', 'on', 'one', 'or', 'that', 'the', 'their',
43
+ 'then', 'there', 'these', 'they', 'this', 'to', 'was', 'were', 'when', 'which', 'while',
44
+ 'with', 'without', 'all', 'any', 'can', 'cannot', 'no', 'only', 'out', 'over', 'so',
45
+ 'than', 'up', 'via', 'we', 'what', 'why', 'you', 'your', 'hs', 'spec', 'specs',
46
+ ]);
47
+
48
+ /** Lowercased alphanumeric terms of length ≥ 3 that are not stopwords. */
49
+ export function terms(text) {
50
+ if (typeof text !== 'string') return [];
51
+ return text
52
+ .toLowerCase()
53
+ .split(/[^a-z0-9]+/)
54
+ .filter((t) => t.length >= 3 && !STOPWORDS.has(t));
55
+ }
56
+
57
+ function frontmatterOf(raw) {
58
+ return getFrontmatterBlock(raw)?.block ?? '';
59
+ }
60
+
61
+ function listField(block, field) {
62
+ // Tags are a block list of bare scalars (not wikilinks), so parse them directly.
63
+ const headRe = new RegExp(`^${field}:[ \\t]*\\r?\\n`, 'm');
64
+ const head = block.match(headRe);
65
+ if (!head) {
66
+ const inline = getScalarField(block, field);
67
+ if (!inline || inline === '[]') return [];
68
+ return inline
69
+ .replace(/^\[|\]$/g, '')
70
+ .split(',')
71
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
72
+ .filter(Boolean);
73
+ }
74
+ const rest = block.slice(head.index + head[0].length).split(/\r?\n/);
75
+ const out = [];
76
+ for (const line of rest) {
77
+ const m = line.match(/^[ \t]+-[ \t]*(.*)$/);
78
+ if (!m) break;
79
+ const v = m[1].trim().replace(/^['"]|['"]$/g, '');
80
+ if (v) out.push(v);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** The searchable text of one spec: its name, its tags, and its summary. */
86
+ function subjectText(block, fallbackName) {
87
+ return [
88
+ getScalarField(block, 'name') ?? fallbackName ?? '',
89
+ listField(block, 'tags').join(' '),
90
+ getScalarField(block, 'summary') ?? '',
91
+ ].join(' ');
92
+ }
93
+
94
+ function scanDir(vaultRoot, dirParts, type) {
95
+ const dir = join(vaultRoot, ...dirParts);
96
+ if (!existsSync(dir)) return [];
97
+ const out = [];
98
+ for (const file of readdirSync(dir)) {
99
+ if (!file.toLowerCase().endsWith('.md')) continue;
100
+ let raw;
101
+ try {
102
+ raw = readFileSync(join(dir, file), 'utf8');
103
+ } catch {
104
+ continue;
105
+ }
106
+ const block = frontmatterOf(raw);
107
+ if (!block) continue;
108
+ const status = (getScalarField(block, 'status') ?? 'open').trim().toLowerCase();
109
+ if (!COVERAGE_ELIGIBLE_STATUSES.has(status)) continue;
110
+ if ((getScalarField(block, 'archived') ?? '').trim().toLowerCase() === 'true') continue;
111
+ if ((getScalarField(block, 'canonical') ?? '').trim().toLowerCase() === 'false') continue;
112
+ const basename = file.replace(/\.md$/i, '');
113
+ out.push({
114
+ type,
115
+ id: getScalarField(block, 'id') ?? basename,
116
+ basename,
117
+ name: getScalarField(block, 'name') ?? basename,
118
+ status,
119
+ milestone: getScalarField(block, 'milestone') ?? null,
120
+ terms: new Set(terms(subjectText(block, basename))),
121
+ });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * What already covers this area?
128
+ *
129
+ * Returns `{ milestones, epics }`, each ranked by how many of the source's own
130
+ * subject terms they share, and each hit NAMING the matched terms so a human can
131
+ * judge the match rather than trusting a number. Ties break on basename so the
132
+ * result is deterministic across runs.
133
+ *
134
+ * A hit needs at least `minOverlap` shared terms (default 2). One shared word is a
135
+ * coincidence at vault scale, and a coincidence presented as coverage is what would
136
+ * attach a bug fix to the wrong epic.
137
+ */
138
+ export function scanExistingCoverage(vaultRoot, source, opts = {}) {
139
+ const minOverlap = Number.isFinite(opts.minOverlap) ? opts.minOverlap : 2;
140
+ const limit = Number.isFinite(opts.limit) ? opts.limit : 5;
141
+
142
+ const block = frontmatterOf(source?.raw ?? '');
143
+ const sourceTerms = new Set(terms(subjectText(block, source?.basename)));
144
+ if (sourceTerms.size === 0) return { milestones: [], epics: [], source_terms: [] };
145
+
146
+ const rank = (candidates) =>
147
+ candidates
148
+ .map((c) => {
149
+ const matched = [...sourceTerms].filter((t) => c.terms.has(t)).sort();
150
+ return {
151
+ type: c.type,
152
+ id: c.id,
153
+ basename: c.basename,
154
+ name: c.name,
155
+ status: c.status,
156
+ milestone: c.milestone,
157
+ matched_terms: matched,
158
+ overlap: matched.length,
159
+ };
160
+ })
161
+ .filter((c) => c.overlap >= minOverlap)
162
+ .sort((a, b) => b.overlap - a.overlap || a.basename.localeCompare(b.basename))
163
+ .slice(0, limit);
164
+
165
+ return {
166
+ source_terms: [...sourceTerms].sort(),
167
+ milestones: rank(scanDir(vaultRoot, ['specs', 'milestones'], 'milestone')),
168
+ epics: rank(scanDir(vaultRoot, ['specs', 'epics'], 'epic')),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Extract the source's own `## Fix` section, verbatim. Returns `''` when there is
174
+ * none — a write-up with no Fix section has not been investigated far enough to be
175
+ * broken down, and the ladder says so rather than guessing.
176
+ */
177
+ export function fixSection(raw) {
178
+ const body = getFrontmatterBlock(raw)?.body ?? raw ?? '';
179
+ // No `m` flag on purpose: with it, `$` matches every LINE end, so the lazy capture
180
+ // terminates on the first newline and the section always comes back empty.
181
+ const m = body.match(/(?:^|\n)##[ \t]+Fix[^\n]*\n([\s\S]*?)(?=\n##[ \t]|$)/i);
182
+ return m ? m[1] : '';
183
+ }
184
+
185
+ /**
186
+ * The separately-verifiable fixes the write-up itself lists.
187
+ *
188
+ * Three spellings are counted, because all three are in the vault today and which
189
+ * one a write-up used says nothing about how many fixes it has:
190
+ * - numbered list items (`1.`, `2)`)
191
+ * - checklist items (`- [ ]`, `* [x]`)
192
+ * - `###` sub-headings inside the Fix section
193
+ *
194
+ * Whichever spelling the section actually uses wins, in that order — a section with
195
+ * sub-headings AND a numbered list under each would otherwise count the same fix
196
+ * twice. Plain bullets are NOT counted: they are how a Fix section carries notes,
197
+ * and counting them turned prose into a milestone in the shape this epic exists to
198
+ * prevent.
199
+ */
200
+ export function countFixes(raw) {
201
+ const section = fixSection(raw);
202
+ if (!section.trim()) return { fixes: [], style: 'none' };
203
+
204
+ const lines = section.split(/\r?\n/);
205
+ const grab = (re) =>
206
+ lines
207
+ .map((l) => l.match(re))
208
+ .filter(Boolean)
209
+ .map((m) => m[1].trim())
210
+ .filter(Boolean);
211
+
212
+ const subheadings = grab(/^###+[ \t]+(.*)$/);
213
+ if (subheadings.length > 0) return { fixes: subheadings, style: 'subheading' };
214
+
215
+ const checklist = grab(/^[ \t]*[-*][ \t]*\[[ xX]\][ \t]*(.*)$/);
216
+ if (checklist.length > 0) return { fixes: checklist, style: 'checklist' };
217
+
218
+ const numbered = grab(/^[ \t]*\d+[.)][ \t]+(.*)$/);
219
+ if (numbered.length > 0) return { fixes: numbered, style: 'numbered' };
220
+
221
+ return { fixes: [], style: 'prose' };
222
+ }
223
+
224
+ /**
225
+ * The sizing ladder.
226
+ *
227
+ * Three rungs, decided by the FIX COUNT and the coverage scan — never by body
228
+ * length, word count, or a model:
229
+ *
230
+ * 1 fix → `no_breakdown` (claim the write-up and fix it)
231
+ * 2+ fixes, coverage found → `epic` (attach downward — tasks under
232
+ * the epic, or a new epic under
233
+ * the milestone)
234
+ * 2+ fixes, no coverage at all → `milestone` (owner confirms before filing)
235
+ *
236
+ * `measure_first` outranks all three: when the write-up's own conclusion is that the
237
+ * cause is not yet known, the only thing filed is the measurement.
238
+ */
239
+ export function applyLadder({ raw, coverage, causeKnown = true }) {
240
+ const { fixes, style } = countFixes(raw);
241
+ const hasMilestone = (coverage?.milestones?.length ?? 0) > 0;
242
+ const hasEpic = (coverage?.epics?.length ?? 0) > 0;
243
+
244
+ const base = {
245
+ fix_count: fixes.length,
246
+ fixes,
247
+ fix_style: style,
248
+ counted_from: "the write-up's own ## Fix section",
249
+ coverage_found: { milestone: hasMilestone, epic: hasEpic },
250
+ };
251
+
252
+ if (!causeKnown) {
253
+ return {
254
+ ...base,
255
+ rung: 'measure_first',
256
+ reason:
257
+ "The write-up's own conclusion is that the cause is not yet known. File only the measurement; nothing else is broken down until it lands.",
258
+ };
259
+ }
260
+
261
+ if (fixes.length === 0) {
262
+ return {
263
+ ...base,
264
+ rung: 'no_fixes_listed',
265
+ reason:
266
+ 'The `## Fix` section lists no separately-verifiable fixes (numbered items, checklist items, or ### sub-headings). Write the fixes the investigation found, then re-run — the count is authored, never inferred.',
267
+ };
268
+ }
269
+
270
+ if (fixes.length === 1) {
271
+ return {
272
+ ...base,
273
+ rung: 'no_breakdown',
274
+ reason: `One fix (${fixes[0]}) — claim the write-up and fix it directly, exactly as today. No breakdown.`,
275
+ };
276
+ }
277
+
278
+ if (hasEpic || hasMilestone) {
279
+ return {
280
+ ...base,
281
+ rung: 'epic',
282
+ reason: `${fixes.length} separately-verifiable fixes, and open ${hasEpic ? 'epic' : 'milestone'} coverage already exists — place the work under what is already there rather than minting a milestone.`,
283
+ };
284
+ }
285
+
286
+ return {
287
+ ...base,
288
+ rung: 'milestone',
289
+ reason: `${fixes.length} separately-verifiable fixes and no open milestone or epic covers this area — a new milestone is proposed, and the owner confirms before anything is filed.`,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Does the write-up conclude the cause is not yet known?
295
+ *
296
+ * Read from the write-up's OWN `## Root cause` section — the measure-first hold is
297
+ * the author's conclusion, not a guess about how confident their prose sounds. An
298
+ * empty or missing Root cause section is NOT taken as unknown: plenty of one-fix
299
+ * write-ups leave it empty, and holding those would stall the common path.
300
+ */
301
+ export function causeIsKnown(raw) {
302
+ const body = getFrontmatterBlock(raw)?.body ?? raw ?? '';
303
+ const m = body.match(/(?:^|\n)##[ \t]+Root cause[^\n]*\n([\s\S]*?)(?=\n##[ \t]|$)/i);
304
+ const section = (m ? m[1] : '').trim();
305
+ if (!section) return true;
306
+ return !/\b(not yet known|cause is unknown|unknown cause|cause unknown|needs? measurement|measure first|not established)\b/i.test(
307
+ section,
308
+ );
309
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The breakdown stage's INPUT side — one door, two kinds of document.
3
+ *
4
+ * [[HS-ALBV-BFIX-EPIC-breakdown-stage-accepts-a-diagnose]] §Scope: "One stage, two
5
+ * inputs. Breakdown accepts a research doc or a bug write-up. Not a second parallel
6
+ * tool." A capture promotes to one or the other, so both sit at the same point in
7
+ * the line; the stage that already turns a research doc into a milestone and epics
8
+ * takes a second input type rather than getting a twin built beside it.
9
+ *
10
+ * This module is the half that finds and identifies the document. It was extracted
11
+ * verbatim from `promote-research.mjs`'s private `resolveResearchRef` /
12
+ * `findResearchByIdLocal` — the research path now calls in here, so the two inputs
13
+ * cannot drift apart on how a reference resolves.
14
+ *
15
+ * RESOLUTION-FIRST, then a type gate. No id-shape pattern-match is applied to the
16
+ * caller's string: a real vault id may be a bare era-3 file key (`HS-X5FH`) that
17
+ * carries no `RES` / `DIAG` label at all, and rejecting on shape made every research
18
+ * doc unpromotable once ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]).
19
+ * The "what kind of document is this?" question is answered by the resolved FILE's
20
+ * own `type:` frontmatter.
21
+ */
22
+
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ import { getFrontmatterBlock, getScalarField } from './promotion-helpers.mjs';
27
+
28
+ /**
29
+ * The kinds of document the breakdown stage accepts, and the folder each lives in.
30
+ *
31
+ * Adding a row here is the whole cost of a third input kind — the resolver, the
32
+ * type gate, and every error message read from this table.
33
+ */
34
+ export const BREAKDOWN_SOURCE_KINDS = {
35
+ research: { dir: ['specs', 'research'], label: 'research doc' },
36
+ diagnose: { dir: ['specs', 'diagnoses'], label: 'bug write-up' },
37
+ };
38
+
39
+ /**
40
+ * Normalize a caller-supplied reference to a bare id/basename: trims, drops
41
+ * surrounding quotes, unwraps a `[[wikilink]]`, strips a trailing `.md`. Returns
42
+ * `null` for anything that normalizes to empty.
43
+ */
44
+ export function normalizeSourceRef(raw) {
45
+ if (typeof raw !== 'string') return null;
46
+ let value = raw.trim().replace(/^['"]|['"]$/g, '').trim();
47
+ const wikilink = value.match(/^\[\[([^\]]+)\]\]$/);
48
+ if (wikilink) value = wikilink[1].trim();
49
+ value = value.replace(/\.md$/i, '').trim();
50
+ return value || null;
51
+ }
52
+
53
+ /**
54
+ * Pure matching logic for resolution step 2 (case-insensitive exact basename) —
55
+ * extracted so the ambiguity path can be unit-tested without two real on-disk files
56
+ * whose names differ only by case, which case-insensitive filesystems (Windows,
57
+ * default macOS) cannot actually hold as distinct files.
58
+ */
59
+ export function matchCaseInsensitiveExact(files, ref) {
60
+ const lowered = `${ref.toLowerCase()}.md`;
61
+ return files.filter((f) => f.toLowerCase() === lowered);
62
+ }
63
+
64
+ function loadRecord(vaultRoot, kind, file) {
65
+ const dirParts = BREAKDOWN_SOURCE_KINDS[kind].dir;
66
+ const absPath = join(vaultRoot, ...dirParts, file);
67
+ return {
68
+ kind,
69
+ basename: file.replace(/\.md$/i, ''),
70
+ absPath,
71
+ relPath: join(...dirParts, file),
72
+ raw: readFileSync(absPath, 'utf8'),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Resolve a reference within ONE kind's folder. Four steps, first hit wins:
78
+ *
79
+ * 1. exact basename
80
+ * 2. case-insensitive exact basename
81
+ * 3. frontmatter `id:` / `file_id:`
82
+ * 4. unambiguous id-prefix
83
+ *
84
+ * Returns `{ record }`, `{ ambiguous: [...] }`, or `{ miss: true }`. Ambiguity is
85
+ * reported rather than resolved — silently picking one of two candidates is how a
86
+ * breakdown lands against the wrong document.
87
+ */
88
+ function resolveWithinKind(vaultRoot, kind, ref) {
89
+ const dir = join(vaultRoot, ...BREAKDOWN_SOURCE_KINDS[kind].dir);
90
+ if (!existsSync(dir)) return { miss: true };
91
+
92
+ if (existsSync(join(dir, `${ref}.md`))) {
93
+ return { record: loadRecord(vaultRoot, kind, `${ref}.md`) };
94
+ }
95
+
96
+ const files = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md'));
97
+
98
+ const ciExact = matchCaseInsensitiveExact(files, ref);
99
+ if (ciExact.length === 1) return { record: loadRecord(vaultRoot, kind, ciExact[0]) };
100
+ if (ciExact.length > 1) {
101
+ return { ambiguous: ciExact.map((f) => f.replace(/\.md$/i, '')), step: 'case-insensitive basename' };
102
+ }
103
+
104
+ const idHits = [];
105
+ for (const file of files) {
106
+ let raw;
107
+ try {
108
+ raw = readFileSync(join(dir, file), 'utf8');
109
+ } catch {
110
+ continue;
111
+ }
112
+ const block = getFrontmatterBlock(raw)?.block;
113
+ if (!block) continue;
114
+ if (getScalarField(block, 'id') === ref || getScalarField(block, 'file_id') === ref) {
115
+ idHits.push(file);
116
+ }
117
+ }
118
+ if (idHits.length === 1) return { record: loadRecord(vaultRoot, kind, idHits[0]) };
119
+ if (idHits.length > 1) {
120
+ return { ambiguous: idHits.map((f) => f.replace(/\.md$/i, '')), step: 'frontmatter id' };
121
+ }
122
+
123
+ const prefixHits = files.filter((f) => f.startsWith(`${ref}-`));
124
+ if (prefixHits.length === 1) return { record: loadRecord(vaultRoot, kind, prefixHits[0]) };
125
+ if (prefixHits.length > 1) {
126
+ return { ambiguous: prefixHits.map((f) => f.replace(/\.md$/i, '')), step: 'id prefix' };
127
+ }
128
+
129
+ return { miss: true };
130
+ }
131
+
132
+ /**
133
+ * Resolve a reference to a breakdown source document.
134
+ *
135
+ * @param {string} vaultRoot
136
+ * @param {string} rawRef id, basename, `[[wikilink]]`, or `<basename>.md`
137
+ * @param {{ kinds?: string[] }} [opts] which kinds to search; defaults to both.
138
+ *
139
+ * On success returns `{ record }` where `record.kind` is read from the FILE's own
140
+ * `type:` frontmatter (falling back to the folder it was found in when the file
141
+ * declares no `type:` at all — warn-don't-repair,
142
+ * [[HS-DEC-vault-frontmatter-warn-dont-repair-on-read]]).
143
+ *
144
+ * A file that declares a `type:` the caller did not ask for is a TYPE MISMATCH, not
145
+ * a miss: telling the caller "not found" about a file that plainly exists sends them
146
+ * looking for a typo that isn't there.
147
+ */
148
+ export function resolveBreakdownSource(vaultRoot, rawRef, opts = {}) {
149
+ const kinds = Array.isArray(opts.kinds) && opts.kinds.length > 0
150
+ ? opts.kinds.filter((k) => k in BREAKDOWN_SOURCE_KINDS)
151
+ : Object.keys(BREAKDOWN_SOURCE_KINDS);
152
+
153
+ const ref = normalizeSourceRef(rawRef);
154
+ if (!ref) {
155
+ return { error: 'INVALID_ARGS', message: 'a source id or basename is required' };
156
+ }
157
+
158
+ // Search EVERY known kind's folder, not only the requested ones. A reference that
159
+ // resolves in a folder the caller did not ask for is the type-mismatch case, and
160
+ // reporting it as such is the difference between "you passed a diagnose to the
161
+ // research path" and a bare "not found".
162
+ const hits = [];
163
+ const ambiguities = [];
164
+ for (const kind of Object.keys(BREAKDOWN_SOURCE_KINDS)) {
165
+ const found = resolveWithinKind(vaultRoot, kind, ref);
166
+ if (found.record) hits.push(found.record);
167
+ else if (found.ambiguous) ambiguities.push({ kind, ...found });
168
+ }
169
+
170
+ if (hits.length > 1) {
171
+ return {
172
+ error: 'SOURCE_ID_AMBIGUOUS',
173
+ message: `${rawRef} resolves to ${hits.length} documents across ${hits.map((h) => h.kind).join(' + ')} — pass the full basename instead`,
174
+ candidates: hits.map((h) => h.basename),
175
+ };
176
+ }
177
+
178
+ if (hits.length === 0) {
179
+ if (ambiguities.length > 0) {
180
+ const first = ambiguities[0];
181
+ return {
182
+ error: 'SOURCE_ID_AMBIGUOUS',
183
+ message: `${rawRef} matches ${first.ambiguous.length} files under ${BREAKDOWN_SOURCE_KINDS[first.kind].dir.join('/')}/ by ${first.step} — pass the exact basename or the frontmatter id: instead`,
184
+ candidates: first.ambiguous,
185
+ };
186
+ }
187
+ return {
188
+ error: 'SOURCE_NOT_FOUND',
189
+ message: `${rawRef} not found under ${kinds.map((k) => BREAKDOWN_SOURCE_KINDS[k].dir.join('/') + '/').join(' or ')} (tried exact basename, case-insensitive basename, frontmatter id:, and id-prefix match)`,
190
+ searched_kinds: kinds,
191
+ };
192
+ }
193
+
194
+ const record = hits[0];
195
+ const block = getFrontmatterBlock(record.raw)?.block ?? '';
196
+ const declared = (getScalarField(block, 'type') ?? '').trim().toLowerCase();
197
+
198
+ // The file's own `type:` wins; an undeclared type falls back to the folder.
199
+ if (declared) {
200
+ if (!(declared in BREAKDOWN_SOURCE_KINDS)) {
201
+ return {
202
+ error: 'SOURCE_TYPE_MISMATCH',
203
+ message: `${rawRef} resolved to ${record.relPath}, which is \`type: ${declared}\` — the breakdown stage accepts a research doc or a bug write-up`,
204
+ resolved_basename: record.basename,
205
+ resolved_type: declared,
206
+ };
207
+ }
208
+ record.kind = declared;
209
+ }
210
+
211
+ if (!kinds.includes(record.kind)) {
212
+ return {
213
+ error: 'SOURCE_TYPE_MISMATCH',
214
+ message: `${rawRef} resolved to ${record.relPath}, a ${BREAKDOWN_SOURCE_KINDS[record.kind].label} — this call expects ${kinds.map((k) => BREAKDOWN_SOURCE_KINDS[k].label).join(' or ')}`,
215
+ resolved_basename: record.basename,
216
+ resolved_type: record.kind,
217
+ };
218
+ }
219
+
220
+ return { record };
221
+ }
222
+
223
+ /**
224
+ * Frontmatter fields every breakdown source carries, lifted once so callers do not
225
+ * each re-parse the block.
226
+ */
227
+ export function readSourceFrontmatter(record) {
228
+ const block = getFrontmatterBlock(record.raw)?.block ?? '';
229
+ return {
230
+ id: getScalarField(block, 'id') ?? record.basename,
231
+ name: getScalarField(block, 'name') ?? '',
232
+ family: getScalarField(block, 'family') ?? null,
233
+ status: getScalarField(block, 'status') ?? null,
234
+ type: getScalarField(block, 'type') ?? null,
235
+ };
236
+ }