@hegemonart/get-design-done 1.28.8 → 1.30.5

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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +116 -0
  4. package/README.de.md +25 -0
  5. package/README.fr.md +25 -0
  6. package/README.it.md +25 -0
  7. package/README.ja.md +25 -0
  8. package/README.ko.md +25 -0
  9. package/README.md +30 -0
  10. package/README.zh-CN.md +25 -0
  11. package/SKILL.md +2 -0
  12. package/agents/design-authority-watcher.md +42 -1
  13. package/agents/design-reflector.md +50 -0
  14. package/package.json +1 -1
  15. package/reference/capability-gap-stage-gate.md +261 -0
  16. package/reference/known-failure-modes.md +521 -0
  17. package/reference/pseudonymization-rules.md +189 -0
  18. package/reference/registry.json +22 -1
  19. package/reference/schemas/events.schema.json +158 -3
  20. package/reference/schemas/generated.d.ts +319 -4
  21. package/scripts/cli/gdd-events.mjs +35 -2
  22. package/scripts/gsd-cleanup-incubator.cjs +367 -0
  23. package/scripts/lib/apply-reflections/incubator-proposals.cjs +455 -0
  24. package/scripts/lib/authority-watcher/index.cjs +201 -0
  25. package/scripts/lib/bandit-router.cjs +92 -9
  26. package/scripts/lib/failure-mode-matcher.cjs +460 -0
  27. package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
  28. package/scripts/lib/incubator-author.cjs +845 -0
  29. package/scripts/lib/install/interactive.cjs +27 -2
  30. package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
  31. package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
  32. package/scripts/lib/issue-reporter/dedup.cjs +458 -0
  33. package/scripts/lib/issue-reporter/destination.cjs +37 -0
  34. package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
  35. package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
  36. package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
  37. package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
  38. package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
  39. package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
  40. package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
  41. package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
  42. package/scripts/lib/pseudonymize.cjs +444 -0
  43. package/scripts/lib/reflections-cycle-writer.cjs +172 -0
  44. package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
  45. package/scripts/lib/reflector-capability-gap-aggregator.cjs +352 -0
  46. package/scripts/lib/reflector-kfm-proposer.cjs +468 -0
  47. package/scripts/release-smoke-test.cjs +33 -2
  48. package/scripts/validate-incubator-scope.cjs +133 -0
  49. package/skills/apply-reflections/SKILL.md +20 -1
  50. package/skills/apply-reflections/apply-reflections-procedure.md +106 -4
  51. package/skills/fast/SKILL.md +46 -0
  52. package/skills/reflect/SKILL.md +9 -0
  53. package/skills/reflect/procedures/capability-gap-scan.md +120 -0
  54. package/skills/report-issue/SKILL.md +53 -0
  55. package/skills/report-issue/report-issue-procedure.md +120 -0
  56. package/skills/router/SKILL.md +5 -0
  57. package/skills/router/capability-gap-emitter.md +65 -0
  58. package/skills/update/SKILL.md +3 -2
@@ -26,6 +26,18 @@
26
26
  * neutral — the same TIER_PRIOR shape, on the assumption that we
27
27
  * have no prior to favour any delegate over local; data drives.
28
28
  *
29
+ * Bootstrap discipline (Phase 29 Plan 06 / CONTEXT D-04):
30
+ * - Default `prior_class` (omitted or 'default'): existing informed
31
+ * TIER_PRIOR bootstrap (Phase 23.5) — byte-for-byte unchanged.
32
+ * - `prior_class: 'promoted_incubator'`: Beta(2, 8) bootstrap for
33
+ * arms registered when `/gdd:apply-reflections accept` promotes
34
+ * an incubator draft. The conservative prior (posterior mean 0.2)
35
+ * suppresses preferential selection until ~8-10 successful pulls
36
+ * accumulate. The bandit-fairness gate IS the promotion staging
37
+ * mechanism (D-04: no two-step staging/ratify split).
38
+ * - The `prior_class` value is persisted on the arm so subsequent
39
+ * reads + decay calculations preserve it (forward-compat).
40
+ *
29
41
  * Atomic .tmp + rename. Discounted Thompson via per-arm time-decay
30
42
  * factor `rho^days_since_last_use` applied at sample time, not stored.
31
43
  *
@@ -59,6 +71,14 @@ const TIER_PRIOR = Object.freeze({
59
71
  const PRIOR_STRENGTH = 10;
60
72
  const DEFAULT_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
61
73
 
74
+ // Phase 29 Plan 06 / CONTEXT D-04. Conservative prior for arms
75
+ // bootstrapped via `/gdd:apply-reflections accept` (incubator → live
76
+ // agent/skill). Beta(2, 8) — posterior mean 0.2 — suppresses
77
+ // preferential selection until ~8-10 successful pulls accumulate.
78
+ // The bandit-fairness gate IS the staging mechanism (D-04: no
79
+ // two-step staging/ratify split).
80
+ const PROMOTED_INCUBATOR_PRIOR = Object.freeze({ alpha: 2, beta: 8 });
81
+
62
82
  // Plan 27-07 / D-08. Delegate context dimension. 'none' = local Anthropic
63
83
  // call; the other 5 are peer-CLI delegations via ACP/ASP. Adding this as
64
84
  // a third context dimension expands the arm space 6× (78 → ~468 contexts).
@@ -158,7 +178,26 @@ function reset(opts = {}) {
158
178
  return { deleted: existed, path: p, reason: opts.reason };
159
179
  }
160
180
 
161
- function priorFor(tier, strength) {
181
+ /**
182
+ * Compute the bootstrap prior for a freshly-created arm.
183
+ *
184
+ * @param {string} tier
185
+ * @param {number} strength
186
+ * @param {string} [prior_class] — 'default' (existing behaviour, omittable)
187
+ * or 'promoted_incubator' (Beta(2,8) bootstrap per Phase 29 Plan 06 /
188
+ * CONTEXT D-04). The promoted-incubator class is tier-independent —
189
+ * the conservative suppression applies uniformly across haiku/sonnet/
190
+ * opus until evidence accumulates.
191
+ * @returns {{alpha: number, beta: number}}
192
+ */
193
+ function priorFor(tier, strength, prior_class) {
194
+ if (prior_class === 'promoted_incubator') {
195
+ return {
196
+ alpha: PROMOTED_INCUBATOR_PRIOR.alpha,
197
+ beta: PROMOTED_INCUBATOR_PRIOR.beta,
198
+ };
199
+ }
200
+ // Default-path (Phase 23.5) — byte-for-byte unchanged.
162
201
  const prior = TIER_PRIOR[tier];
163
202
  if (prior === undefined) {
164
203
  return { alpha: strength / 2, beta: strength / 2 };
@@ -207,11 +246,17 @@ function findArm(arms, agent, bin, tier, delegate) {
207
246
  * 23.5 prior — no migration needed because the legacy slice and the
208
247
  * 'none' slice are independent contexts) and for the 5 peer delegates
209
248
  * (each starts neutral with the same TIER_PRIOR shape; data drives).
249
+ *
250
+ * For Phase 29 Plan 06: when `prior_class === 'promoted_incubator'`, the
251
+ * bootstrap prior is Beta(2, 8) regardless of tier/delegate (CONTEXT D-04).
252
+ * The `prior_class` is persisted on the arm so re-reads + decay preserve it.
253
+ * If omitted or 'default', no `prior_class` field is added (clean
254
+ * round-trip with existing posterior files — non-breaking change).
210
255
  */
211
- function ensureArm(posterior, agent, bin, tier, strength, delegate) {
256
+ function ensureArm(posterior, agent, bin, tier, strength, delegate, prior_class) {
212
257
  let arm = findArm(posterior.arms, agent, bin, tier, delegate);
213
258
  if (arm) return arm;
214
- const { alpha, beta } = priorFor(tier, strength);
259
+ const { alpha, beta } = priorFor(tier, strength, prior_class);
215
260
  arm = {
216
261
  agent,
217
262
  bin,
@@ -224,6 +269,9 @@ function ensureArm(posterior, agent, bin, tier, strength, delegate) {
224
269
  if (delegate !== undefined) {
225
270
  arm.delegate = delegate;
226
271
  }
272
+ if (prior_class !== undefined && prior_class !== 'default') {
273
+ arm.prior_class = prior_class;
274
+ }
227
275
  posterior.arms.push(arm);
228
276
  return arm;
229
277
  }
@@ -312,7 +360,10 @@ function decayArm(arm, opts = {}) {
312
360
  * counters. Bandit pull does NOT update the success/fail counters —
313
361
  * that happens in `update()` once the outcome is known.
314
362
  *
315
- * @param {{agent: string, bin: string, tiers?: string[], baseDir?: string, posteriorPath?: string, decay?: number, strength?: number, now?: Date}} input
363
+ * @param {{agent: string, bin: string, tiers?: string[], baseDir?: string, posteriorPath?: string, decay?: number, strength?: number, now?: Date, prior_class?: string}} input
364
+ * `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
365
+ * bootstraps fresh arms with Beta(2,8). Omitting it preserves Phase 23.5
366
+ * informed-prior behaviour (non-breaking).
316
367
  * @returns {{tier: string, samples: Record<string, number>, posteriorPath: string}}
317
368
  */
318
369
  function pull(input) {
@@ -332,7 +383,7 @@ function pull(input) {
332
383
  let bestTier = tiers[0];
333
384
  let bestSample = -1;
334
385
  for (const tier of tiers) {
335
- const arm = ensureArm(posterior, input.agent, input.bin, tier, strength);
386
+ const arm = ensureArm(posterior, input.agent, input.bin, tier, strength, undefined, input.prior_class);
336
387
  const decayed = decayArm(arm, { decay: input.decay, now, strength });
337
388
  const s = sampleBeta(decayed.alpha, decayed.beta);
338
389
  samples[tier] = s;
@@ -342,7 +393,7 @@ function pull(input) {
342
393
  }
343
394
  }
344
395
  // Bump counters on the chosen arm.
345
- const chosen = ensureArm(posterior, input.agent, input.bin, bestTier, strength);
396
+ const chosen = ensureArm(posterior, input.agent, input.bin, bestTier, strength, undefined, input.prior_class);
346
397
  chosen.last_used = now.toISOString();
347
398
  chosen.count += 1;
348
399
  const written = savePosterior(posterior, input);
@@ -353,7 +404,11 @@ function pull(input) {
353
404
  * Update the posterior with a reward signal. Reward is applied as a
354
405
  * Bernoulli observation: success → α += reward, β += (1 - reward).
355
406
  *
356
- * @param {{agent: string, bin: string, tier: string, reward: number, baseDir?: string, posteriorPath?: string, strength?: number}} input
407
+ * @param {{agent: string, bin: string, tier: string, reward: number, baseDir?: string, posteriorPath?: string, strength?: number, prior_class?: string}} input
408
+ * `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
409
+ * bootstraps fresh arms with Beta(2,8). Omitting preserves Phase 23.5
410
+ * informed-prior behaviour (non-breaking). The reward math is unchanged
411
+ * — `prior_class` only affects bootstrap, not the Bernoulli update.
357
412
  * @returns {{alpha: number, beta: number, posteriorPath: string}}
358
413
  */
359
414
  function update(input) {
@@ -369,7 +424,15 @@ function update(input) {
369
424
  // Reward must be in [0, 1].
370
425
  const r = Math.min(1, Math.max(0, input.reward));
371
426
  const posterior = loadPosterior(input);
372
- const arm = ensureArm(posterior, input.agent, input.bin, input.tier, input.strength ?? PRIOR_STRENGTH);
427
+ const arm = ensureArm(
428
+ posterior,
429
+ input.agent,
430
+ input.bin,
431
+ input.tier,
432
+ input.strength ?? PRIOR_STRENGTH,
433
+ undefined,
434
+ input.prior_class,
435
+ );
373
436
  arm.alpha += r;
374
437
  arm.beta += 1 - r;
375
438
  const p = savePosterior(posterior, input);
@@ -401,7 +464,11 @@ function update(input) {
401
464
  * decay?: number,
402
465
  * strength?: number,
403
466
  * now?: Date,
467
+ * prior_class?: string,
404
468
  * }} input
469
+ * `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
470
+ * bootstraps fresh arms with Beta(2,8). Omitting preserves Phase 23.5 +
471
+ * Plan 27-07 behaviour (non-breaking).
405
472
  * @returns {{
406
473
  * tier: string,
407
474
  * delegate: string,
@@ -435,7 +502,15 @@ function pullWithDelegate(input) {
435
502
  for (const delegate of delegates) {
436
503
  samples[delegate] = {};
437
504
  for (const tier of tiers) {
438
- const arm = ensureArm(posterior, input.agent, input.bin, tier, strength, delegate);
505
+ const arm = ensureArm(
506
+ posterior,
507
+ input.agent,
508
+ input.bin,
509
+ tier,
510
+ strength,
511
+ delegate,
512
+ input.prior_class,
513
+ );
439
514
  const decayed = decayArm(arm, { decay: input.decay, now, strength });
440
515
  const s = sampleBeta(decayed.alpha, decayed.beta);
441
516
  samples[delegate][tier] = s;
@@ -453,6 +528,7 @@ function pullWithDelegate(input) {
453
528
  bestTier,
454
529
  strength,
455
530
  bestDelegate,
531
+ input.prior_class,
456
532
  );
457
533
  chosen.last_used = now.toISOString();
458
534
  chosen.count += 1;
@@ -482,7 +558,12 @@ function pullWithDelegate(input) {
482
558
  * baseDir?: string,
483
559
  * posteriorPath?: string,
484
560
  * strength?: number,
561
+ * prior_class?: string,
485
562
  * }} input
563
+ * `prior_class` (optional, Phase 29 Plan 06 / D-04): 'promoted_incubator'
564
+ * bootstraps fresh arms with Beta(2,8). Omitting preserves Plan 27-07
565
+ * behaviour (non-breaking). The reward math is unchanged — `prior_class`
566
+ * only affects bootstrap, not the Bernoulli update.
486
567
  * @returns {{alpha: number, beta: number, posteriorPath: string}}
487
568
  */
488
569
  function updateWithDelegate(input) {
@@ -506,6 +587,7 @@ function updateWithDelegate(input) {
506
587
  input.tier,
507
588
  input.strength ?? PRIOR_STRENGTH,
508
589
  input.delegate,
590
+ input.prior_class,
509
591
  );
510
592
  arm.alpha += r;
511
593
  arm.beta += 1 - r;
@@ -569,6 +651,7 @@ module.exports = {
569
651
  DELEGATE_NONE,
570
652
  TIER_PRIOR,
571
653
  PRIOR_STRENGTH,
654
+ PROMOTED_INCUBATOR_PRIOR,
572
655
  TOUCHES_BINS,
573
656
  DEFAULT_POSTERIOR_PATH,
574
657
  SCHEMA_VERSION,
@@ -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
+ };