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