@massu/core 0.7.0 → 0.8.1

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.
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 Massu. All rights reserved.
3
+ // Licensed under BSL 1.1 - see LICENSE file for details.
4
+
5
+ // ============================================================
6
+ // PostToolUse Hook: Failure Classification
7
+ // Classifies bug fixes against known failure patterns before
8
+ // demanding the full incident loop. Prevents rule bloat by
9
+ // recognizing known patterns vs genuinely novel failures.
10
+ //
11
+ // Part of the Auto-Learning Pipeline:
12
+ // [Fix Detected] → [CLASSIFY] → Incident Report → Rule → Enforcement
13
+ //
14
+ // Classification:
15
+ // SCORE >= known threshold → KNOWN — Reference existing rules, no new deliverables
16
+ // SCORE >= similar threshold → SIMILAR — Check existing rules first
17
+ // SCORE < similar threshold → NEW — Full incident loop mandatory
18
+ //
19
+ // Triggers on: Edit|Write to code files
20
+ // Must complete in <1000ms.
21
+ // ============================================================
22
+
23
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from 'fs';
24
+ import { tmpdir } from 'os';
25
+ import { join, basename } from 'path';
26
+ import { getProjectRoot, getConfig } from '../config.ts';
27
+ import { getMemoryDb, scoreFailureClasses } from '../memory-db.ts';
28
+
29
+ interface HookInput {
30
+ session_id: string;
31
+ tool_name: string;
32
+ tool_input: {
33
+ file_path?: string;
34
+ old_string?: string;
35
+ new_string?: string;
36
+ content?: string;
37
+ };
38
+ }
39
+
40
+ // Bug fix detection heuristics
41
+ const BUG_FIX_INDICATORS = [
42
+ /\b(catch|error|throw|fail|fix|bug|broken|missing|crash|wrong|typo|incorrect)\b/i,
43
+ ];
44
+
45
+ const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|py|swift|rs|go|rb|sh)$/;
46
+
47
+ function getDedupeMarkerPath(sessionId: string, filePath: string): string {
48
+ // One reminder per file per calendar day
49
+ const day = new Date().toISOString().slice(0, 10);
50
+ const hash = simpleHash(filePath);
51
+ return join(tmpdir(), `massu-classify-${day}-${sessionId.slice(0, 8)}-${hash}`);
52
+ }
53
+
54
+ function simpleHash(str: string): string {
55
+ let h = 0;
56
+ for (let i = 0; i < str.length; i++) {
57
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0;
58
+ }
59
+ return Math.abs(h).toString(36);
60
+ }
61
+
62
+ function readFailureContextFiles(): string {
63
+ const dir = tmpdir();
64
+ let context = '';
65
+ try {
66
+ const files = readdirSync(dir).filter(f => f.startsWith('massu-failure-context-'));
67
+ for (const file of files) {
68
+ try {
69
+ context += ' ' + readFileSync(join(dir, file), 'utf-8');
70
+ } catch { /* ignore */ }
71
+ }
72
+ } catch { /* ignore */ }
73
+ return context.trim();
74
+ }
75
+
76
+ function cleanupFailureContextFiles(): void {
77
+ const dir = tmpdir();
78
+ try {
79
+ const files = readdirSync(dir).filter(f => f.startsWith('massu-failure-context-'));
80
+ for (const file of files) {
81
+ try { unlinkSync(join(dir, file)); } catch { /* ignore */ }
82
+ }
83
+ } catch { /* ignore */ }
84
+ }
85
+
86
+ async function main(): Promise<void> {
87
+ try {
88
+ const input = await readStdin();
89
+ const hookInput = JSON.parse(input) as HookInput;
90
+ const filePath = hookInput.tool_input?.file_path;
91
+
92
+ if (!filePath) {
93
+ process.exit(0);
94
+ return;
95
+ }
96
+
97
+ const config = getConfig();
98
+
99
+ // Check if feature is enabled
100
+ if (config.autoLearning?.enabled === false ||
101
+ config.autoLearning?.failureClassification?.enabled === false) {
102
+ process.exit(0);
103
+ return;
104
+ }
105
+
106
+ // Only check code files
107
+ if (!CODE_EXTENSIONS.test(filePath)) {
108
+ process.exit(0);
109
+ return;
110
+ }
111
+
112
+ // Skip incident/memory/doc files (those are pipeline output)
113
+ const root = getProjectRoot();
114
+ const incidentDir = config.autoLearning?.incidentDir ?? 'docs/incidents';
115
+ const memoryDir = config.autoLearning?.memoryDir ?? 'memory';
116
+ const relPath = filePath.startsWith(root + '/') ? filePath.slice(root.length + 1) : filePath;
117
+ if (relPath.startsWith(incidentDir) || relPath.includes(memoryDir) || relPath.includes('MEMORY.md')) {
118
+ process.exit(0);
119
+ return;
120
+ }
121
+
122
+ // Detect if this edit looks like a bug fix
123
+ const oldString = hookInput.tool_input?.old_string ?? '';
124
+ const newString = hookInput.tool_input?.new_string ?? '';
125
+ const content = hookInput.tool_input?.content ?? '';
126
+ const matchText = `${oldString} ${newString} ${content}`;
127
+
128
+ let isBugFix = false;
129
+
130
+ // Check for failure context markers (from UserPromptSubmit)
131
+ const promptContext = readFailureContextFiles();
132
+ if (promptContext) {
133
+ isBugFix = true;
134
+ }
135
+
136
+ // Check for bug fix indicators in the edit text
137
+ if (!isBugFix) {
138
+ for (const pattern of BUG_FIX_INDICATORS) {
139
+ if (pattern.test(matchText)) {
140
+ isBugFix = true;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+
146
+ // Check for column/key name changes (common fix pattern)
147
+ if (!isBugFix && oldString && newString) {
148
+ const oldKeys = oldString.match(/[a-z_]+:/g)?.sort().join(',');
149
+ const newKeys = newString.match(/[a-z_]+:/g)?.sort().join(',');
150
+ if (oldKeys && newKeys && oldKeys !== newKeys) {
151
+ isBugFix = true;
152
+ }
153
+ }
154
+
155
+ if (!isBugFix) {
156
+ process.exit(0);
157
+ return;
158
+ }
159
+
160
+ // De-duplicate: skip if already reminded for this file today
161
+ const dedupeMarker = getDedupeMarkerPath(hookInput.session_id, filePath);
162
+ if (existsSync(dedupeMarker)) {
163
+ process.exit(0);
164
+ return;
165
+ }
166
+
167
+ // Mark that we've classified this file
168
+ try {
169
+ require('fs').writeFileSync(dedupeMarker, '1');
170
+ } catch { /* ignore */ }
171
+
172
+ // Score against failure taxonomy in database
173
+ const db = getMemoryDb();
174
+ try {
175
+ const scoringConfig = config.autoLearning?.failureClassification?.scoring;
176
+ const thresholds = config.autoLearning?.failureClassification?.thresholds ?? { known: 5, similar: 3 };
177
+ const bestMatch = scoreFailureClasses(db, matchText, filePath, promptContext, scoringConfig);
178
+
179
+ if (!bestMatch || bestMatch.score === 0) {
180
+ // No taxonomy entries yet, or no match — full enforcement
181
+ outputNewPattern(basename(filePath), bestMatch);
182
+ } else if (bestMatch.score >= thresholds.known) {
183
+ outputKnownPattern(bestMatch);
184
+ } else if (bestMatch.score >= thresholds.similar) {
185
+ outputSimilarPattern(bestMatch);
186
+ } else {
187
+ outputNewPattern(basename(filePath), bestMatch);
188
+ }
189
+ } finally {
190
+ db.close();
191
+ }
192
+
193
+ // Clean up consumed context files to prevent cross-bug contamination
194
+ cleanupFailureContextFiles();
195
+ } catch {
196
+ // Best-effort: never block Claude Code
197
+ }
198
+ process.exit(0);
199
+ }
200
+
201
+ function outputKnownPattern(match: FailureClassMatch): void {
202
+ const lines: string[] = [];
203
+ lines.push('');
204
+ lines.push(`[KNOWN PATTERN] ${match.name} (score: ${match.score}, ${match.incidentCount} prior incident(s))`);
205
+ if (match.knownMessage) {
206
+ lines.push(` ${match.knownMessage}`);
207
+ }
208
+ if (match.rules.length > 0) {
209
+ lines.push(` Covered by: ${match.rules.join(', ')}`);
210
+ }
211
+ lines.push(' No new rules needed. Reference existing incident if logging.');
212
+ console.log(lines.join('\n'));
213
+ }
214
+
215
+ function outputSimilarPattern(match: FailureClassMatch): void {
216
+ const lines: string[] = [];
217
+ lines.push('');
218
+ lines.push(`[POSSIBLE MATCH] Resembles ${match.name} (score: ${match.score})`);
219
+ if (match.rules.length > 0) {
220
+ lines.push(` Check if existing rules cover this case: ${match.rules.join(', ')}`);
221
+ }
222
+ lines.push(' If genuinely new: create incident + prevention rule + enforcement.');
223
+ console.log(lines.join('\n'));
224
+ }
225
+
226
+ function outputNewPattern(fileName: string, match: FailureClassMatch | null): void {
227
+ const lines: string[] = [];
228
+ lines.push('');
229
+ if (match && match.score > 0) {
230
+ lines.push(`[NEW PATTERN] No known failure class matches this fix in ${fileName} (best: ${match.name}, score: ${match.score}).`);
231
+ } else {
232
+ lines.push(`[NEW PATTERN] Bug fix detected in ${fileName} — no failure classes in taxonomy yet.`);
233
+ }
234
+ lines.push(' Full incident loop required:');
235
+ lines.push(' 1. INCIDENT REPORT');
236
+ lines.push(' 2. PREVENTION RULE (if new failure pattern)');
237
+ lines.push(' 3. ENFORCEMENT (hook or static check)');
238
+ console.log(lines.join('\n'));
239
+ }
240
+
241
+ interface FailureClassMatch {
242
+ name: string;
243
+ score: number;
244
+ incidentCount: number;
245
+ rules: string[];
246
+ knownMessage: string;
247
+ }
248
+
249
+ function readStdin(): Promise<string> {
250
+ return new Promise((resolve) => {
251
+ let data = '';
252
+ process.stdin.setEncoding('utf-8');
253
+ process.stdin.on('data', (chunk: string) => { data += chunk; });
254
+ process.stdin.on('end', () => resolve(data));
255
+ setTimeout(() => resolve(data), 3000);
256
+ });
257
+ }
258
+
259
+ main();
@@ -17,6 +17,7 @@
17
17
  import { existsSync, readFileSync, readdirSync } from 'fs';
18
18
  import { basename, dirname, resolve } from 'path';
19
19
  import { getProjectRoot, getConfig } from '../config.ts';
20
+ import { getMemoryDb, scoreFailureClasses, appendIncidentToFailureClass, addFailureClass } from '../memory-db.ts';
20
21
 
21
22
  interface HookInput {
22
23
  session_id: string;
@@ -129,6 +130,47 @@ async function main(): Promise<void> {
129
130
  lines.push('');
130
131
 
131
132
  console.log(lines.join('\n'));
133
+
134
+ // ============================================================
135
+ // Taxonomy Update: Score incident against failure classes
136
+ // If KNOWN match → append incident to existing class
137
+ // If NEW → create stub entry with needs_review=true
138
+ // ============================================================
139
+ try {
140
+ const taxonomyConfig = config.autoLearning?.failureClassification;
141
+ if (taxonomyConfig?.enabled !== false) {
142
+ const db = getMemoryDb();
143
+ try {
144
+ const thresholds = taxonomyConfig?.thresholds ?? { known: 5, similar: 3 };
145
+ const scoringWeights = taxonomyConfig?.scoring;
146
+
147
+ // Extract incident number from filename (e.g., "incident-042.md" → "42")
148
+ const incidentNumMatch = basename(filePath, '.md').match(/(\d+)/);
149
+ const incidentId = incidentNumMatch ? incidentNumMatch[1] : basename(filePath, '.md');
150
+
151
+ // Score incident content against taxonomy
152
+ const bestMatch = scoreFailureClasses(db, content, filePath, title, scoringWeights);
153
+
154
+ if (bestMatch && bestMatch.score >= thresholds.similar) {
155
+ // Known or similar — append incident to existing class
156
+ appendIncidentToFailureClass(db, bestMatch.name, incidentId);
157
+ } else {
158
+ // New pattern — create stub entry for review
159
+ const stubName = `auto_${slug.replace(/[^a-z0-9_]/g, '_').slice(0, 50)}`;
160
+ addFailureClass(db, {
161
+ name: stubName,
162
+ description: `Auto-created from incident ${incidentId}: ${title.slice(0, 100)}`,
163
+ incidents: [incidentId],
164
+ needsReview: true,
165
+ });
166
+ }
167
+ } finally {
168
+ db.close();
169
+ }
170
+ }
171
+ } catch {
172
+ // Best-effort: taxonomy update failure must not block the pipeline
173
+ }
132
174
  } catch {
133
175
  // Best-effort: never block Claude Code
134
176
  }
@@ -55,7 +55,8 @@ async function main(): Promise<void> {
55
55
  }
56
56
 
57
57
  // Must be in a memory-like directory
58
- if (!relPath.includes(memoryDir) && !relPath.includes('memory/') && !relPath.includes('.claude/')) {
58
+ const claudeDir = config.conventions?.claudeDirName ?? '.claude';
59
+ if (!relPath.includes(memoryDir) && !relPath.includes('memory/') && !relPath.includes(claudeDir + '/')) {
59
60
  process.exit(0);
60
61
  return;
61
62
  }
@@ -52,7 +52,7 @@ async function main(): Promise<void> {
52
52
  process.stdout.write(
53
53
  '=== MASSU AI: Active ===\n' +
54
54
  'Session memory, code intelligence, and governance are now active.\n' +
55
- `11 hooks monitoring this session. Type "${getConfig().toolPrefix ?? 'massu'}_sync" to index your codebase.\n` +
55
+ `15 hooks monitoring this session. Type "${getConfig().toolPrefix ?? 'massu'}_sync" to index your codebase.\n` +
56
56
  '=== END MASSU ===\n\n'
57
57
  );
58
58
  }
@@ -8,7 +8,9 @@
8
8
  // ============================================================
9
9
 
10
10
  import { getMemoryDb, createSession, addUserPrompt, linkSessionToTask, autoDetectTaskId, addObservation } from '../memory-db.ts';
11
- import { existsSync } from 'fs';
11
+ import { existsSync, writeFileSync } from 'fs';
12
+ import { tmpdir } from 'os';
13
+ import { join } from 'path';
12
14
  import { getResolvedPaths } from '../config.ts';
13
15
 
14
16
  interface HookInput {
@@ -107,6 +109,24 @@ async function main(): Promise<void> {
107
109
  } catch (_memoryNagErr) {
108
110
  // Best-effort: never block prompt capture
109
111
  }
112
+
113
+ // 7. Failure context markers: write detected failure keywords to temp file
114
+ // so classify-failure.ts can use them for scoring
115
+ try {
116
+ const failureKeywords = [
117
+ 'bug', 'broken', 'crash', 'error', 'fail', 'fix', 'wrong', 'missing',
118
+ 'undefined', 'null', 'exception', 'stack trace', 'regression', 'revert',
119
+ 'doesn\'t work', 'not working', 'stopped working', 'broke',
120
+ ];
121
+ const promptLower = prompt.toLowerCase();
122
+ const matched = failureKeywords.filter(kw => promptLower.includes(kw));
123
+ if (matched.length > 0) {
124
+ const contextFile = join(tmpdir(), `massu-failure-context-${session_id.slice(0, 8)}-${Date.now()}`);
125
+ writeFileSync(contextFile, matched.join(' '), 'utf-8');
126
+ }
127
+ } catch {
128
+ // Best-effort: never block prompt capture
129
+ }
110
130
  } finally {
111
131
  db.close();
112
132
  }
package/src/license.ts CHANGED
@@ -54,7 +54,7 @@ export function tierLevel(tier: ToolTier): number {
54
54
  * Enterprise: audit, security, dependency
55
55
  */
56
56
  export const TOOL_TIER_MAP: Record<string, ToolTier> = {
57
- // --- Free tier (12 tools: core navigation + basic memory + regression + license) ---
57
+ // --- Free tier (13 tools: core navigation + basic memory + regression + license) ---
58
58
  sync: 'free',
59
59
  context: 'free',
60
60
  impact: 'free',
@@ -64,6 +64,7 @@ export const TOOL_TIER_MAP: Record<string, ToolTier> = {
64
64
  coupling_check: 'free',
65
65
  memory_search: 'free',
66
66
  memory_ingest: 'free',
67
+ memory_backfill: 'free',
67
68
  regression_risk: 'free',
68
69
  feature_health: 'free',
69
70
  license_status: 'free',
@@ -50,7 +50,6 @@ process.on('exit', () => {
50
50
  });
51
51
  process.on('SIGTERM', () => {
52
52
  for (const [name] of connections) disconnectServer(name);
53
- process.exit(0);
54
53
  });
55
54
 
56
55
  // Environment variables safe to forward to MCP subprocesses.
@@ -68,7 +67,7 @@ const ENV_DENY_PATTERNS = [
68
67
  function buildSubprocessEnv(): Record<string, string> {
69
68
  const env: Record<string, string> = {};
70
69
  // Derive safe env prefixes from the project name in massu.config.yaml.
71
- // e.g., project name "hedge" -> allow HEDGE_CONFIG_, HEDGE_LOG_ etc.
70
+ // e.g., project name "myapp" -> allow MYAPP_CONFIG_, MYAPP_LOG_ etc.
72
71
  // This makes the bridge work for any Massu user's project without hardcoding.
73
72
  const projectName = getConfig().project?.name?.toUpperCase() || '';
74
73
  const safePrefixes = projectName
package/src/memory-db.ts CHANGED
@@ -587,6 +587,29 @@ export function initMemorySchema(db: Database.Database): void {
587
587
  features TEXT DEFAULT '[]'
588
588
  );
589
589
  `);
590
+
591
+ // ============================================================
592
+ // Failure Classification: Taxonomy of known failure patterns
593
+ // ============================================================
594
+ db.exec(`
595
+ CREATE TABLE IF NOT EXISTS failure_classes (
596
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
597
+ name TEXT NOT NULL UNIQUE,
598
+ description TEXT NOT NULL,
599
+ diff_patterns TEXT NOT NULL DEFAULT '[]',
600
+ file_patterns TEXT NOT NULL DEFAULT '[]',
601
+ prompt_keywords TEXT NOT NULL DEFAULT '[]',
602
+ incidents TEXT NOT NULL DEFAULT '[]',
603
+ rules TEXT NOT NULL DEFAULT '[]',
604
+ scanner_checks TEXT NOT NULL DEFAULT '[]',
605
+ known_message TEXT NOT NULL DEFAULT '',
606
+ needs_review INTEGER NOT NULL DEFAULT 0,
607
+ created_at TEXT DEFAULT (datetime('now')),
608
+ updated_at TEXT DEFAULT (datetime('now'))
609
+ );
610
+ CREATE INDEX IF NOT EXISTS idx_fc_name ON failure_classes(name);
611
+ CREATE INDEX IF NOT EXISTS idx_fc_needs_review ON failure_classes(needs_review);
612
+ `);
590
613
  }
591
614
 
592
615
  // ============================================================
@@ -1387,3 +1410,181 @@ export function getObservabilityDbSize(db: Database.Database): {
1387
1410
  estimated_size_mb: Math.round((pageCount * pageSize) / (1024 * 1024) * 100) / 100,
1388
1411
  };
1389
1412
  }
1413
+
1414
+ // ============================================================
1415
+ // Failure Classification: CRUD functions
1416
+ // ============================================================
1417
+
1418
+ export interface FailureClass {
1419
+ id: number;
1420
+ name: string;
1421
+ description: string;
1422
+ diff_patterns: string[];
1423
+ file_patterns: string[];
1424
+ prompt_keywords: string[];
1425
+ incidents: string[];
1426
+ rules: string[];
1427
+ scanner_checks: string[];
1428
+ known_message: string;
1429
+ needs_review: boolean;
1430
+ }
1431
+
1432
+ export interface AddFailureClassOpts {
1433
+ name: string;
1434
+ description: string;
1435
+ diffPatterns?: string[];
1436
+ filePatterns?: string[];
1437
+ promptKeywords?: string[];
1438
+ incidents?: string[];
1439
+ rules?: string[];
1440
+ scannerChecks?: string[];
1441
+ knownMessage?: string;
1442
+ needsReview?: boolean;
1443
+ }
1444
+
1445
+ /**
1446
+ * Add a new failure class to the taxonomy.
1447
+ */
1448
+ export function addFailureClass(db: Database.Database, opts: AddFailureClassOpts): number {
1449
+ const result = db.prepare(`
1450
+ INSERT OR IGNORE INTO failure_classes (name, description, diff_patterns, file_patterns, prompt_keywords, incidents, rules, scanner_checks, known_message, needs_review)
1451
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1452
+ `).run(
1453
+ opts.name,
1454
+ opts.description,
1455
+ JSON.stringify(opts.diffPatterns ?? []),
1456
+ JSON.stringify(opts.filePatterns ?? []),
1457
+ JSON.stringify(opts.promptKeywords ?? []),
1458
+ JSON.stringify(opts.incidents ?? []),
1459
+ JSON.stringify(opts.rules ?? []),
1460
+ JSON.stringify(opts.scannerChecks ?? []),
1461
+ opts.knownMessage ?? '',
1462
+ opts.needsReview ? 1 : 0
1463
+ );
1464
+ return Number(result.lastInsertRowid);
1465
+ }
1466
+
1467
+ /**
1468
+ * Get all failure classes from the taxonomy.
1469
+ */
1470
+ export function getFailureClasses(db: Database.Database): FailureClass[] {
1471
+ const rows = db.prepare('SELECT * FROM failure_classes ORDER BY name').all() as Array<Record<string, unknown>>;
1472
+ return rows.map(row => ({
1473
+ id: row.id as number,
1474
+ name: row.name as string,
1475
+ description: row.description as string,
1476
+ diff_patterns: JSON.parse((row.diff_patterns as string) || '[]'),
1477
+ file_patterns: JSON.parse((row.file_patterns as string) || '[]'),
1478
+ prompt_keywords: JSON.parse((row.prompt_keywords as string) || '[]'),
1479
+ incidents: JSON.parse((row.incidents as string) || '[]'),
1480
+ rules: JSON.parse((row.rules as string) || '[]'),
1481
+ scanner_checks: JSON.parse((row.scanner_checks as string) || '[]'),
1482
+ known_message: row.known_message as string,
1483
+ needs_review: !!(row.needs_review as number),
1484
+ }));
1485
+ }
1486
+
1487
+ /**
1488
+ * Append an incident identifier to an existing failure class.
1489
+ */
1490
+ export function appendIncidentToFailureClass(db: Database.Database, className: string, incidentId: string): void {
1491
+ const row = db.prepare('SELECT incidents FROM failure_classes WHERE name = ?').get(className) as { incidents: string } | undefined;
1492
+ if (!row) return;
1493
+ const incidents: string[] = JSON.parse(row.incidents || '[]');
1494
+ if (!incidents.includes(incidentId)) {
1495
+ incidents.push(incidentId);
1496
+ db.prepare('UPDATE failure_classes SET incidents = ?, updated_at = datetime(\'now\') WHERE name = ?')
1497
+ .run(JSON.stringify(incidents), className);
1498
+ }
1499
+ }
1500
+
1501
+ export interface FailureClassMatch {
1502
+ name: string;
1503
+ score: number;
1504
+ incidentCount: number;
1505
+ rules: string[];
1506
+ knownMessage: string;
1507
+ }
1508
+
1509
+ /**
1510
+ * Score all failure classes against provided match text, file path, and prompt context.
1511
+ * Returns the best match with its score.
1512
+ *
1513
+ * Scoring:
1514
+ * +diffPatternWeight (default 3) per diff_pattern match
1515
+ * +filePatternWeight (default 2) per file_pattern match
1516
+ * +promptKeywordWeight (default 2) per prompt_keyword match
1517
+ */
1518
+ export function scoreFailureClasses(
1519
+ db: Database.Database,
1520
+ matchText: string,
1521
+ filePath: string,
1522
+ promptContext: string,
1523
+ weights?: { diffPatternWeight?: number; filePatternWeight?: number; promptKeywordWeight?: number }
1524
+ ): FailureClassMatch | null {
1525
+ const classes = getFailureClasses(db);
1526
+ if (classes.length === 0) return null;
1527
+
1528
+ const diffWeight = weights?.diffPatternWeight ?? 3;
1529
+ const fileWeight = weights?.filePatternWeight ?? 2;
1530
+ const promptWeight = weights?.promptKeywordWeight ?? 2;
1531
+
1532
+ let bestMatch: FailureClassMatch | null = null;
1533
+
1534
+ for (const fc of classes) {
1535
+ let score = 0;
1536
+
1537
+ for (const pattern of fc.diff_patterns) {
1538
+ if (!pattern) continue;
1539
+ try {
1540
+ if (new RegExp(pattern, 'i').test(matchText)) {
1541
+ score += diffWeight;
1542
+ }
1543
+ } catch {
1544
+ if (matchText.toLowerCase().includes(pattern.toLowerCase())) {
1545
+ score += diffWeight;
1546
+ }
1547
+ }
1548
+ }
1549
+
1550
+ for (const pattern of fc.file_patterns) {
1551
+ if (!pattern) continue;
1552
+ try {
1553
+ if (new RegExp(pattern).test(filePath)) {
1554
+ score += fileWeight;
1555
+ }
1556
+ } catch {
1557
+ if (filePath.includes(pattern)) {
1558
+ score += fileWeight;
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ if (promptContext) {
1564
+ for (const keyword of fc.prompt_keywords) {
1565
+ if (!keyword) continue;
1566
+ try {
1567
+ if (new RegExp(keyword, 'i').test(promptContext)) {
1568
+ score += promptWeight;
1569
+ }
1570
+ } catch {
1571
+ if (promptContext.toLowerCase().includes(keyword.toLowerCase())) {
1572
+ score += promptWeight;
1573
+ }
1574
+ }
1575
+ }
1576
+ }
1577
+
1578
+ if (!bestMatch || score > bestMatch.score) {
1579
+ bestMatch = {
1580
+ name: fc.name,
1581
+ score,
1582
+ incidentCount: fc.incidents.length,
1583
+ rules: fc.rules,
1584
+ knownMessage: fc.known_message,
1585
+ };
1586
+ }
1587
+ }
1588
+
1589
+ return bestMatch;
1590
+ }
@@ -11,7 +11,6 @@
11
11
  import type Database from 'better-sqlite3';
12
12
  import { readFileSync, existsSync, readdirSync } from 'fs';
13
13
  import { join } from 'path';
14
- import { parse as parseYaml } from 'yaml';
15
14
  import { addObservation } from './memory-db.ts';
16
15
 
17
16
  export type IngestResult = 'inserted' | 'updated' | 'skipped';
@@ -42,13 +41,21 @@ export function ingestMemoryFile(
42
41
 
43
42
  if (frontmatterMatch) {
44
43
  try {
45
- const fm = parseYaml(frontmatterMatch[1]) as Record<string, unknown>;
46
- name = (fm.name as string) ?? basename;
47
- description = (fm.description as string) ?? '';
48
- type = (fm.type as string) ?? 'discovery';
44
+ // Simple key: value parser for memory file frontmatter
45
+ // (avoids importing yaml library — frontmatter is flat key-value pairs)
46
+ const fm: Record<string, string> = {};
47
+ for (const line of frontmatterMatch[1].split('\n')) {
48
+ const sep = line.indexOf(':');
49
+ if (sep > 0) {
50
+ fm[line.slice(0, sep).trim()] = line.slice(sep + 1).trim();
51
+ }
52
+ }
53
+ name = fm.name ?? basename;
54
+ description = fm.description ?? '';
55
+ type = fm.type ?? 'discovery';
49
56
  confidence = fm.confidence != null ? Number(fm.confidence) : undefined;
50
57
  } catch {
51
- // Use defaults if YAML parsing fails
58
+ // Use defaults if frontmatter parsing fails
52
59
  }
53
60
  }
54
61