@hegemonart/get-design-done 1.30.0 → 1.30.6

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 (49) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +103 -0
  4. package/README.de.md +2 -0
  5. package/README.fr.md +2 -0
  6. package/README.it.md +2 -0
  7. package/README.ja.md +2 -0
  8. package/README.ko.md +2 -0
  9. package/README.md +3 -1
  10. package/README.zh-CN.md +2 -0
  11. package/agents/design-authority-watcher.md +42 -1
  12. package/agents/design-integration-checker.md +1 -1
  13. package/agents/design-planner.md +1 -1
  14. package/agents/gdd-graph-refresh.md +90 -0
  15. package/bin/gdd-graph +261 -0
  16. package/connections/connections.md +10 -9
  17. package/connections/graphify.md +65 -54
  18. package/package.json +4 -2
  19. package/reference/capability-gap-stage-gate.md +7 -4
  20. package/reference/known-failure-modes.md +337 -1
  21. package/reference/model-tiers.md +2 -2
  22. package/reference/schemas/events.schema.json +61 -0
  23. package/reference/start-interview.md +1 -1
  24. package/scripts/detect-stale-refs.cjs +6 -0
  25. package/scripts/lib/apply-reflections/incubator-proposals.cjs +10 -3
  26. package/scripts/lib/authority-watcher/index.cjs +201 -0
  27. package/scripts/lib/failure-mode-matcher.cjs +460 -0
  28. package/scripts/lib/graph/atomic-write.mjs +68 -0
  29. package/scripts/lib/graph/build.mjs +124 -0
  30. package/scripts/lib/graph/diff.mjs +90 -0
  31. package/scripts/lib/graph/index.mjs +14 -0
  32. package/scripts/lib/graph/query.mjs +155 -0
  33. package/scripts/lib/graph/schema.json +69 -0
  34. package/scripts/lib/graph/schema.mjs +47 -0
  35. package/scripts/lib/graph/status.mjs +88 -0
  36. package/scripts/lib/graph/token-estimate.mjs +27 -0
  37. package/scripts/lib/graph/upsert.mjs +210 -0
  38. package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +1 -1
  39. package/scripts/lib/install/interactive.cjs +27 -2
  40. package/scripts/lib/reflector-capability-gap-aggregator.cjs +32 -0
  41. package/scripts/lib/reflector-kfm-proposer.cjs +468 -0
  42. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
  43. package/skills/apply-reflections/SKILL.md +4 -0
  44. package/skills/apply-reflections/apply-reflections-procedure.md +38 -4
  45. package/skills/connections/connections-onboarding.md +6 -6
  46. package/skills/graphify/SKILL.md +11 -10
  47. package/skills/scan/scan-procedure.md +9 -8
  48. package/agents/gdd-graphify-sync.md +0 -110
  49. /package/scripts/lib/{gsd-health-mirror → health-mirror}/index.d.cts +0 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * scripts/lib/authority-watcher/index.cjs — Plan 30.5-03 Task 2.
3
+ *
4
+ * Programmatic surface for the authority-watcher pipeline. The user-facing
5
+ * fetcher lives in `agents/design-authority-watcher.md` (Phase 13.2 — runs
6
+ * inside Claude's sub-agent harness with `WebFetch`). This module is the
7
+ * pure-CommonJS counterpart that consumes already-fetched article records
8
+ * and emits structured events for the Phase 30.5-03 reflector pipeline.
9
+ *
10
+ * D-06 ship: kfm-candidate event class. When an article's title matches
11
+ * the failure-mode whitelist patterns (case-insensitive), we emit a single
12
+ * `kfm-candidate` event. Reflector (Plan 30.5-03 Task 1) consumes these
13
+ * events into the SAME incubator draft surface as capability_gap clusters.
14
+ *
15
+ * Public API:
16
+ * classifyArticles(articles, options?) → Array<Event>
17
+ * matchesKfmWhitelist(title) → boolean
18
+ * buildKfmCandidate(article, options?) → Event
19
+ *
20
+ * Article shape (subset — matches the watcher agent's normalised entries):
21
+ * { id: string, title: string, url?: string, link?: string,
22
+ * summary?: string, feed_id?: string, published?: string }
23
+ *
24
+ * Event shape (validates against reference/schemas/events.schema.json
25
+ * KfmCandidatePayload, allOf[1] branch):
26
+ * {
27
+ * type: 'kfm-candidate',
28
+ * timestamp: '<ISO>',
29
+ * sessionId: '<id>',
30
+ * payload: { event_id, source: 'authority_watcher', article_url,
31
+ * article_title, suggested_symptom,
32
+ * suggested_pattern_hint, raw_excerpt },
33
+ * event_type: 'kfm-candidate' // duplicate of `type` for ergonomic .filter()
34
+ * }
35
+ *
36
+ * No `fs` writes — this module returns events for the caller (the agent's
37
+ * Bash sandbox) to persist. Zero npm deps.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ // -------------------------------------------------------------------
43
+ // Constants
44
+ // -------------------------------------------------------------------
45
+
46
+ /**
47
+ * Whitelist patterns per Plan 30.5-03 Task 2 step 2. Each pattern matches
48
+ * a title that is plausibly about a failure mode / troubleshooting topic.
49
+ * Case-insensitive, deliberately broad — false positives are gated by
50
+ * the apply-reflections user-review step.
51
+ */
52
+ const KFM_WHITELIST_PATTERNS = Object.freeze([
53
+ /common errors/i,
54
+ /failure modes/i,
55
+ /troubleshooting/i,
56
+ /known issues/i,
57
+ /pitfalls/i,
58
+ ]);
59
+
60
+ const MAX_RAW_EXCERPT = 500;
61
+
62
+ // -------------------------------------------------------------------
63
+ // Helpers
64
+ // -------------------------------------------------------------------
65
+
66
+ function asString(x) {
67
+ return typeof x === 'string' ? x : '';
68
+ }
69
+
70
+ /**
71
+ * Truncate to maxLen with a `…` (single char, byte-counted) suffix.
72
+ * Returns at most maxLen characters including the suffix.
73
+ */
74
+ function truncateExcerpt(text, maxLen) {
75
+ const s = asString(text);
76
+ if (s.length <= maxLen) return s;
77
+ // Hard truncate at maxLen, keep the last char as ellipsis.
78
+ return `${s.slice(0, maxLen - 1)}…`;
79
+ }
80
+
81
+ /**
82
+ * Derive a one-line symptom string from an article record. Preference
83
+ * order: explicit title (≤180 chars), then first 180 chars of summary.
84
+ */
85
+ function deriveSymptom(article) {
86
+ const title = asString(article && article.title).trim();
87
+ if (title.length > 0) {
88
+ return title.slice(0, 180);
89
+ }
90
+ const summary = asString(article && article.summary).trim().replace(/\s+/g, ' ');
91
+ if (summary.length > 0) {
92
+ return summary.slice(0, 180);
93
+ }
94
+ return 'untitled';
95
+ }
96
+
97
+ /**
98
+ * Best-effort regex fragment hint. We DO NOT emit a real regex — this is
99
+ * a keyword bag the user is expected to refine via the apply-reflections
100
+ * edit action. Empty string is legal (schema allows empty `suggested_pattern_hint`).
101
+ */
102
+ function derivePatternHint(article) {
103
+ const title = asString(article && article.title);
104
+ const summary = asString(article && article.summary);
105
+ // Find ALL-CAPS error-code-shaped tokens (EACCES, ENOENT, EUSAGE, TS6133, etc.)
106
+ const codeRe = /\b[A-Z][A-Z0-9_]{3,15}\b/g;
107
+ const seen = new Set();
108
+ const hits = [];
109
+ for (const src of [title, summary]) {
110
+ const matches = src.match(codeRe) || [];
111
+ for (const m of matches) {
112
+ if (!seen.has(m)) {
113
+ seen.add(m);
114
+ hits.push(m);
115
+ }
116
+ if (hits.length >= 3) break;
117
+ }
118
+ if (hits.length >= 3) break;
119
+ }
120
+ return hits.join('|');
121
+ }
122
+
123
+ // -------------------------------------------------------------------
124
+ // Public API
125
+ // -------------------------------------------------------------------
126
+
127
+ /**
128
+ * Returns true if an article title matches any whitelist pattern.
129
+ */
130
+ function matchesKfmWhitelist(title) {
131
+ const s = asString(title);
132
+ if (s.length === 0) return false;
133
+ for (const re of KFM_WHITELIST_PATTERNS) {
134
+ if (re.test(s)) return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Build a kfm-candidate event from a single article record.
141
+ * Schema-compliant — every required field present + raw_excerpt ≤ 500.
142
+ */
143
+ function buildKfmCandidate(article, options) {
144
+ const opts = options || {};
145
+ const articleUrl = asString(article && (article.url || article.link || article.permalink));
146
+ const articleTitle = asString(article && article.title) || 'Untitled';
147
+ const summary = asString(article && article.summary);
148
+ const eventId = opts.eventId || `kfm-cand-${asString(article && article.id) || 'noid'}-${Date.now()}`;
149
+ const timestamp = opts.now || new Date().toISOString();
150
+ const sessionId = opts.sessionId || 'authority-watcher';
151
+
152
+ const payload = {
153
+ event_id: eventId,
154
+ source: 'authority_watcher',
155
+ article_url: articleUrl,
156
+ article_title: articleTitle,
157
+ suggested_symptom: deriveSymptom(article),
158
+ suggested_pattern_hint: derivePatternHint(article),
159
+ raw_excerpt: truncateExcerpt(summary, MAX_RAW_EXCERPT),
160
+ };
161
+
162
+ return {
163
+ type: 'kfm-candidate',
164
+ timestamp,
165
+ sessionId,
166
+ payload,
167
+ // duplicated at envelope-level for ergonomic .filter() in consumers
168
+ // that don't unpack the payload.
169
+ event_type: 'kfm-candidate',
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Classify a list of fetched articles into events. Emits one kfm-candidate
175
+ * per whitelist-matched article. Other articles produce no events here
176
+ * (the watcher agent's pre-existing classification — heuristic-update,
177
+ * spec-change, etc. — is handled outside this module).
178
+ */
179
+ function classifyArticles(articles, options) {
180
+ if (!Array.isArray(articles)) return [];
181
+ const out = [];
182
+ for (const a of articles) {
183
+ if (!a || typeof a !== 'object') continue;
184
+ if (matchesKfmWhitelist(a.title)) {
185
+ out.push(buildKfmCandidate(a, options));
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ module.exports = {
192
+ classifyArticles,
193
+ matchesKfmWhitelist,
194
+ buildKfmCandidate,
195
+ // Exposed for tests / advanced consumers.
196
+ KFM_WHITELIST_PATTERNS,
197
+ MAX_RAW_EXCERPT,
198
+ _deriveSymptom: deriveSymptom,
199
+ _derivePatternHint: derivePatternHint,
200
+ _truncateExcerpt: truncateExcerpt,
201
+ };
@@ -0,0 +1,460 @@
1
+ /**
2
+ * scripts/lib/failure-mode-matcher.cjs — Plan 30.5-02
3
+ *
4
+ * Fuzzy bag-of-words matcher for the known-failure-modes catalogue.
5
+ * Additive sibling to Phase 30's exact-match `triage-matcher.cjs`
6
+ * (D-04 — that file MUST remain byte-identical to its HEAD state and
7
+ * is guarded by `tests/failure-mode-matcher.test.cjs` case 13).
8
+ *
9
+ * match(errorContext, options) → [
10
+ * { modeId, confidence, symptom?, root_cause?, fix?, severity?,
11
+ * propose_report?, related_phases?, diagnosis?, remedy? },
12
+ * ...
13
+ * ]
14
+ *
15
+ * Inputs:
16
+ * - errorContext.message: string (error.message)
17
+ * - errorContext.stack: string (optional)
18
+ * - options.topN: number (default 3, per D-08)
19
+ * - options.threshold: number (default 0.4, per D-07)
20
+ * - options.cataloguePath: string (override; default points at
21
+ * `reference/known-failure-modes.md`)
22
+ *
23
+ * Pipeline:
24
+ * 1. Parse catalogue (yaml-in-markdown), skip entries that fail validation.
25
+ * 2. Tokenize haystack (`message + stack`) and each entry's bag
26
+ * (`symptom + root_cause + un-regexed pattern`, with old-shape
27
+ * `diagnosis + remedy` fallback for backward-compat).
28
+ * 3. Score with cosine similarity over term-frequency vectors.
29
+ * 4. Drop entries below threshold; sort by [score DESC, modeId ASC];
30
+ * slice to topN.
31
+ * 5. Apply top-1 dominance: if top1 − top2 ≥ 0.15, collapse to [top1].
32
+ *
33
+ * Determinism contract (D-07):
34
+ * - No Math.random, no Date.now, no I/O outside the cataloguePath read.
35
+ * - Object iteration is always over sorted keys.
36
+ * - Result ordering ties are broken by modeId ASC.
37
+ * - JSON.stringify(match(x, o)) is identical across invocations
38
+ * when (x, o, catalogue file bytes) are identical (test case 12).
39
+ *
40
+ * D-10: tests use synthetic fixtures under `tests/fixtures/failure-mode-matcher/`.
41
+ *
42
+ * Pure CommonJS, zero npm dependencies.
43
+ */
44
+
45
+ 'use strict';
46
+
47
+ const fs = require('node:fs');
48
+ const path = require('node:path');
49
+
50
+ // -------------------------------------------------------------------
51
+ // Constants
52
+ // -------------------------------------------------------------------
53
+
54
+ const DEFAULT_TOP_N = 3; // D-08
55
+ const DEFAULT_THRESHOLD = 0.4; // D-07
56
+ const DOMINANCE_DELTA = 0.15; // D-08 collapse threshold
57
+ const MIN_TOKEN_LEN = 3;
58
+ const SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
59
+
60
+ // Inline stop-word set — kept small for determinism + audit reviewability.
61
+ const STOP_WORDS = new Set([
62
+ 'the', 'a', 'an', 'is', 'in', 'of', 'to', 'for', 'on', 'at',
63
+ 'by', 'with', 'and', 'or', 'but', 'as', 'if', 'it', 'its',
64
+ 'this', 'that', 'from', 'be', 'are',
65
+ ]);
66
+
67
+ // Strips backslashes and regex operator characters so a pattern string
68
+ // reduces to a recoverable keyword bag.
69
+ const REGEX_OPERATORS = /[\\\[\]{}()|^$.*+?]/g;
70
+
71
+ // -------------------------------------------------------------------
72
+ // Path resolution
73
+ // -------------------------------------------------------------------
74
+
75
+ function findRepoRoot() {
76
+ let dir = __dirname;
77
+ for (let i = 0; i < 12; i++) {
78
+ if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
79
+ const parent = path.dirname(dir);
80
+ if (parent === dir) break;
81
+ dir = parent;
82
+ }
83
+ return path.resolve(__dirname, '..', '..');
84
+ }
85
+
86
+ const DEFAULT_CATALOGUE_PATH = path.join(
87
+ findRepoRoot(),
88
+ 'reference',
89
+ 'known-failure-modes.md'
90
+ );
91
+
92
+ // -------------------------------------------------------------------
93
+ // Tokenizer
94
+ // -------------------------------------------------------------------
95
+
96
+ /**
97
+ * Lowercase → split on non-word characters → drop stop-words → drop short tokens.
98
+ * Pure-functional; never throws on non-string input (returns []).
99
+ *
100
+ * @param {string | undefined | null} s
101
+ * @returns {string[]}
102
+ */
103
+ function tokenize(s) {
104
+ if (typeof s !== 'string' || s.length === 0) return [];
105
+ const out = [];
106
+ const parts = s.toLowerCase().split(/\W+/);
107
+ for (const t of parts) {
108
+ if (!t || t.length < MIN_TOKEN_LEN) continue;
109
+ if (STOP_WORDS.has(t)) continue;
110
+ out.push(t);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Strip backslashes + regex operator chars to recover keywords from a pattern.
117
+ * Returns a whitespace-normalised string suitable for tokenize().
118
+ *
119
+ * @param {string | undefined} pattern
120
+ * @returns {string}
121
+ */
122
+ function unregexPattern(pattern) {
123
+ if (typeof pattern !== 'string' || pattern.length === 0) return '';
124
+ return pattern.replace(REGEX_OPERATORS, ' ');
125
+ }
126
+
127
+ // -------------------------------------------------------------------
128
+ // Catalogue parser (yaml-in-markdown)
129
+ //
130
+ // Mirrors the shape used by triage-matcher.cjs but is intentionally a
131
+ // separate implementation — D-04 forbids modifying the Phase 30 parser
132
+ // or coupling this module to it.
133
+ // -------------------------------------------------------------------
134
+
135
+ /**
136
+ * Extract fenced ```yaml blocks and parse each as a flat key:value map.
137
+ * Entries that fail validation (regex compile, missing required fields)
138
+ * are skipped with a one-line console.warn and never thrown.
139
+ *
140
+ * @param {string} markdown
141
+ * @returns {Array<object>}
142
+ */
143
+ function parseEntries(markdown) {
144
+ const out = [];
145
+ const blockRe = /```yaml\s*\n([\s\S]*?)\n```/g;
146
+ let m;
147
+ while ((m = blockRe.exec(markdown)) !== null) {
148
+ const body = m[1];
149
+ /** @type {Record<string,string>} */
150
+ const fields = {};
151
+ /** @type {Record<string,string[]>} */
152
+ const arrayFields = {};
153
+
154
+ for (const rawLine of body.split(/\r?\n/)) {
155
+ const line = rawLine.replace(/\s+$/, '');
156
+ if (!line) continue;
157
+ // Array-shorthand `[a, b, c]`
158
+ const arrMatch = line.match(
159
+ /^\s*([A-Za-z_][\w-]*)\s*:\s*\[(.*)\]\s*$/
160
+ );
161
+ if (arrMatch) {
162
+ const items = arrMatch[2]
163
+ .split(',')
164
+ .map((s) => s.trim())
165
+ .filter(Boolean)
166
+ .map((s) => {
167
+ if (
168
+ (s.startsWith("'") && s.endsWith("'")) ||
169
+ (s.startsWith('"') && s.endsWith('"'))
170
+ ) {
171
+ return s.slice(1, -1);
172
+ }
173
+ return s;
174
+ });
175
+ arrayFields[arrMatch[1]] = items;
176
+ continue;
177
+ }
178
+ const kv = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/);
179
+ if (!kv) continue;
180
+ let v = kv[2];
181
+ if (
182
+ (v.startsWith("'") && v.endsWith("'")) ||
183
+ (v.startsWith('"') && v.endsWith('"'))
184
+ ) {
185
+ v = v.slice(1, -1);
186
+ v = v.replace(/''/g, "'");
187
+ }
188
+ fields[kv[1]] = v;
189
+ }
190
+
191
+ // Minimum viable entry: id + pattern + (one of symptom|diagnosis).
192
+ if (!fields.id || !fields.pattern) continue;
193
+ const hasNewShape =
194
+ fields.symptom || fields.root_cause || fields.fix;
195
+ const hasOldShape = fields.diagnosis || fields.remedy;
196
+ if (!hasNewShape && !hasOldShape) continue;
197
+
198
+ // Validate regex (skip-on-error per D-04 parity with Phase 30 matcher).
199
+ try {
200
+ // We don't store the RegExp — the fuzzy matcher does NOT regex-test
201
+ // the haystack; this compile is purely a sanity check so malformed
202
+ // entries are filtered out before scoring.
203
+ // eslint-disable-next-line no-new
204
+ new RegExp(fields.pattern);
205
+ } catch (e) {
206
+ console.warn(
207
+ `[failure-mode-matcher] skip ${fields.id}: invalid regex (${
208
+ (e && e.message) || 'compile error'
209
+ })`
210
+ );
211
+ continue;
212
+ }
213
+
214
+ if (fields.severity && !SEVERITIES.has(fields.severity)) {
215
+ console.warn(
216
+ `[failure-mode-matcher] skip ${fields.id}: invalid severity '${fields.severity}'`
217
+ );
218
+ continue;
219
+ }
220
+
221
+ const entry = {
222
+ id: fields.id,
223
+ pattern: fields.pattern,
224
+ };
225
+ if (fields.symptom) entry.symptom = fields.symptom;
226
+ if (fields.root_cause) entry.root_cause = fields.root_cause;
227
+ if (fields.fix) entry.fix = fields.fix;
228
+ if (fields.diagnosis) entry.diagnosis = fields.diagnosis;
229
+ if (fields.remedy) entry.remedy = fields.remedy;
230
+ if (fields.severity) entry.severity = fields.severity;
231
+ if (fields.propose_report !== undefined) {
232
+ entry.propose_report = fields.propose_report === 'true';
233
+ }
234
+ if (fields.first_observed_cycle) {
235
+ entry.first_observed_cycle = fields.first_observed_cycle;
236
+ }
237
+ if (arrayFields.related_phases) {
238
+ entry.related_phases = arrayFields.related_phases;
239
+ }
240
+ out.push(entry);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Load + parse a catalogue path. Never throws.
247
+ *
248
+ * @param {string} cataloguePath
249
+ * @returns {Array<object>}
250
+ */
251
+ function loadCatalogue(cataloguePath) {
252
+ let md;
253
+ try {
254
+ md = fs.readFileSync(cataloguePath, 'utf8');
255
+ } catch (e) {
256
+ console.warn(
257
+ `[failure-mode-matcher] catalogue unreadable at ${cataloguePath}: ${
258
+ (e && e.message) || 'read error'
259
+ }`
260
+ );
261
+ return [];
262
+ }
263
+ try {
264
+ return parseEntries(md);
265
+ } catch (e) {
266
+ console.warn(
267
+ `[failure-mode-matcher] catalogue parse failed at ${cataloguePath}: ${
268
+ (e && e.message) || 'parse error'
269
+ }`
270
+ );
271
+ return [];
272
+ }
273
+ }
274
+
275
+ // -------------------------------------------------------------------
276
+ // Bag-of-words construction + cosine similarity
277
+ // -------------------------------------------------------------------
278
+
279
+ /**
280
+ * Build the haystack token list from an errorContext.
281
+ * @param {object | null | undefined} errorContext
282
+ * @returns {string[]}
283
+ */
284
+ function buildHaystack(errorContext) {
285
+ if (!errorContext || typeof errorContext !== 'object') return [];
286
+ const msg =
287
+ typeof errorContext.message === 'string' ? errorContext.message : '';
288
+ const stk =
289
+ typeof errorContext.stack === 'string' ? errorContext.stack : '';
290
+ return tokenize([msg, stk].filter(Boolean).join(' '));
291
+ }
292
+
293
+ /**
294
+ * Build the entry's keyword bag.
295
+ * - New shape: symptom + root_cause + un-regexed pattern.
296
+ * - Old shape (backcompat): diagnosis + remedy + un-regexed pattern.
297
+ * @param {object} entry
298
+ * @returns {string[]}
299
+ */
300
+ function buildEntryBag(entry) {
301
+ const newPieces = [entry.symptom, entry.root_cause, entry.fix]
302
+ .filter((x) => typeof x === 'string' && x.length > 0)
303
+ .join(' ');
304
+ const oldPieces = [entry.diagnosis, entry.remedy]
305
+ .filter((x) => typeof x === 'string' && x.length > 0)
306
+ .join(' ');
307
+ const patternKeywords = unregexPattern(entry.pattern);
308
+ const source =
309
+ newPieces.length > 0
310
+ ? `${newPieces} ${patternKeywords}`
311
+ : `${oldPieces} ${patternKeywords}`;
312
+ return tokenize(source);
313
+ }
314
+
315
+ /**
316
+ * Term-frequency map for a token list.
317
+ * @param {string[]} tokens
318
+ * @returns {Map<string, number>}
319
+ */
320
+ function termFrequency(tokens) {
321
+ const tf = new Map();
322
+ for (const t of tokens) {
323
+ tf.set(t, (tf.get(t) || 0) + 1);
324
+ }
325
+ return tf;
326
+ }
327
+
328
+ /**
329
+ * Cosine similarity over two TF maps. Returns 0 on either-side empty
330
+ * vector (guards divide-by-zero).
331
+ * @param {Map<string, number>} a
332
+ * @param {Map<string, number>} b
333
+ * @returns {number}
334
+ */
335
+ function cosineSimilarity(a, b) {
336
+ if (a.size === 0 || b.size === 0) return 0;
337
+ let dot = 0;
338
+ let normA = 0;
339
+ let normB = 0;
340
+ for (const v of a.values()) normA += v * v;
341
+ for (const v of b.values()) normB += v * v;
342
+ // Iterate the smaller map for the dot product.
343
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
344
+ for (const [tok, count] of small) {
345
+ const other = large.get(tok);
346
+ if (other !== undefined) dot += count * other;
347
+ }
348
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
349
+ if (denom === 0) return 0;
350
+ return dot / denom;
351
+ }
352
+
353
+ // -------------------------------------------------------------------
354
+ // Public API
355
+ // -------------------------------------------------------------------
356
+
357
+ /**
358
+ * Match an error context against the failure-mode catalogue.
359
+ *
360
+ * @param {{message?: string, stack?: string} | null | undefined} errorContext
361
+ * @param {{topN?: number, threshold?: number, cataloguePath?: string}} [options]
362
+ * @returns {Array<object>}
363
+ */
364
+ function match(errorContext, options) {
365
+ const opts = options || {};
366
+ const topN = Number.isFinite(opts.topN) && opts.topN > 0
367
+ ? Math.floor(opts.topN)
368
+ : DEFAULT_TOP_N;
369
+ const threshold = Number.isFinite(opts.threshold)
370
+ ? opts.threshold
371
+ : DEFAULT_THRESHOLD;
372
+ const cataloguePath = typeof opts.cataloguePath === 'string' && opts.cataloguePath.length > 0
373
+ ? opts.cataloguePath
374
+ : DEFAULT_CATALOGUE_PATH;
375
+
376
+ const haystackTokens = buildHaystack(errorContext);
377
+ if (haystackTokens.length === 0) return [];
378
+
379
+ const entries = loadCatalogue(cataloguePath);
380
+ if (!Array.isArray(entries) || entries.length === 0) return [];
381
+
382
+ const haystackTf = termFrequency(haystackTokens);
383
+
384
+ // Score every entry.
385
+ const scored = [];
386
+ for (const entry of entries) {
387
+ const entryTokens = buildEntryBag(entry);
388
+ if (entryTokens.length === 0) continue;
389
+ const entryTf = termFrequency(entryTokens);
390
+ const confidence = cosineSimilarity(haystackTf, entryTf);
391
+ if (confidence < threshold) continue;
392
+ scored.push({ entry, confidence });
393
+ }
394
+
395
+ // Sort: confidence DESC, modeId ASC for deterministic tie-break.
396
+ scored.sort((a, b) => {
397
+ if (b.confidence !== a.confidence) return b.confidence - a.confidence;
398
+ if (a.entry.id < b.entry.id) return -1;
399
+ if (a.entry.id > b.entry.id) return 1;
400
+ return 0;
401
+ });
402
+
403
+ // Slice to topN.
404
+ const sliced = scored.slice(0, topN);
405
+
406
+ // Top-1 dominance — D-08.
407
+ if (
408
+ sliced.length >= 2 &&
409
+ sliced[0].confidence - sliced[1].confidence >= DOMINANCE_DELTA
410
+ ) {
411
+ return [shapeResult(sliced[0].entry, sliced[0].confidence)];
412
+ }
413
+
414
+ return sliced.map((s) => shapeResult(s.entry, s.confidence));
415
+ }
416
+
417
+ /**
418
+ * Shape a single candidate result. modeId + confidence are mandatory;
419
+ * remaining catalogue fields ride along when present. Field order is
420
+ * fixed for deterministic JSON serialisation.
421
+ *
422
+ * @param {object} entry
423
+ * @param {number} confidence
424
+ * @returns {object}
425
+ */
426
+ function shapeResult(entry, confidence) {
427
+ const out = {
428
+ modeId: entry.id,
429
+ confidence,
430
+ };
431
+ if (entry.symptom !== undefined) out.symptom = entry.symptom;
432
+ if (entry.root_cause !== undefined) out.root_cause = entry.root_cause;
433
+ if (entry.fix !== undefined) out.fix = entry.fix;
434
+ if (entry.severity !== undefined) out.severity = entry.severity;
435
+ if (entry.propose_report !== undefined) {
436
+ out.propose_report = entry.propose_report;
437
+ }
438
+ if (entry.related_phases !== undefined) {
439
+ out.related_phases = entry.related_phases;
440
+ }
441
+ if (entry.diagnosis !== undefined) out.diagnosis = entry.diagnosis;
442
+ if (entry.remedy !== undefined) out.remedy = entry.remedy;
443
+ if (entry.first_observed_cycle !== undefined) {
444
+ out.first_observed_cycle = entry.first_observed_cycle;
445
+ }
446
+ return out;
447
+ }
448
+
449
+ module.exports = {
450
+ match,
451
+ // Exposed for higher-level consumers that may want catalogue
452
+ // introspection without invoking the scorer. Internal use only.
453
+ _tokenize: tokenize,
454
+ _loadCatalogue: loadCatalogue,
455
+ _parseEntries: parseEntries,
456
+ _cosineSimilarity: cosineSimilarity,
457
+ _DEFAULT_TOP_N: DEFAULT_TOP_N,
458
+ _DEFAULT_THRESHOLD: DEFAULT_THRESHOLD,
459
+ _DOMINANCE_DELTA: DOMINANCE_DELTA,
460
+ };