@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +116 -0
- package/README.de.md +25 -0
- package/README.fr.md +25 -0
- package/README.it.md +25 -0
- package/README.ja.md +25 -0
- package/README.ko.md +25 -0
- package/README.md +30 -0
- package/README.zh-CN.md +25 -0
- package/SKILL.md +2 -0
- package/agents/design-authority-watcher.md +42 -1
- package/agents/design-reflector.md +50 -0
- package/package.json +1 -1
- package/reference/capability-gap-stage-gate.md +261 -0
- package/reference/known-failure-modes.md +521 -0
- package/reference/pseudonymization-rules.md +189 -0
- package/reference/registry.json +22 -1
- package/reference/schemas/events.schema.json +158 -3
- package/reference/schemas/generated.d.ts +319 -4
- package/scripts/cli/gdd-events.mjs +35 -2
- package/scripts/gsd-cleanup-incubator.cjs +367 -0
- package/scripts/lib/apply-reflections/incubator-proposals.cjs +455 -0
- package/scripts/lib/authority-watcher/index.cjs +201 -0
- package/scripts/lib/bandit-router.cjs +92 -9
- package/scripts/lib/failure-mode-matcher.cjs +460 -0
- package/scripts/lib/gsd-health-mirror/index.cjs +37 -1
- package/scripts/lib/incubator-author.cjs +845 -0
- package/scripts/lib/install/interactive.cjs +27 -2
- package/scripts/lib/issue-reporter/cli-flag-report.cjs +153 -0
- package/scripts/lib/issue-reporter/consent-prompt.cjs +231 -0
- package/scripts/lib/issue-reporter/dedup.cjs +458 -0
- package/scripts/lib/issue-reporter/destination.cjs +37 -0
- package/scripts/lib/issue-reporter/draft-writer.cjs +157 -0
- package/scripts/lib/issue-reporter/gh-absent-fallback.cjs +220 -0
- package/scripts/lib/issue-reporter/gh-submit.cjs +114 -0
- package/scripts/lib/issue-reporter/kill-switch.cjs +122 -0
- package/scripts/lib/issue-reporter/payload-assembly.cjs +367 -0
- package/scripts/lib/issue-reporter/privacy-diff.cjs +385 -0
- package/scripts/lib/issue-reporter/report-flow.cjs +269 -0
- package/scripts/lib/issue-reporter/triage-matcher.cjs +270 -0
- package/scripts/lib/pseudonymize.cjs +444 -0
- package/scripts/lib/reflections-cycle-writer.cjs +172 -0
- package/scripts/lib/reflector/capability-gap-scan.cjs +751 -0
- package/scripts/lib/reflector-capability-gap-aggregator.cjs +352 -0
- package/scripts/lib/reflector-kfm-proposer.cjs +468 -0
- package/scripts/release-smoke-test.cjs +33 -2
- package/scripts/validate-incubator-scope.cjs +133 -0
- package/skills/apply-reflections/SKILL.md +20 -1
- package/skills/apply-reflections/apply-reflections-procedure.md +106 -4
- package/skills/fast/SKILL.md +46 -0
- package/skills/reflect/SKILL.md +9 -0
- package/skills/reflect/procedures/capability-gap-scan.md +120 -0
- package/skills/report-issue/SKILL.md +53 -0
- package/skills/report-issue/report-issue-procedure.md +120 -0
- package/skills/router/SKILL.md +5 -0
- package/skills/router/capability-gap-emitter.md +65 -0
- 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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
};
|