@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.
- package/README.md +2 -2
- package/dist/cli.js +163 -19
- package/dist/hooks/auto-learning-pipeline.js +14 -2
- package/dist/hooks/classify-failure.js +1146 -0
- package/dist/hooks/cost-tracker.js +31 -0
- package/dist/hooks/fix-detector.js +12 -0
- package/dist/hooks/incident-pipeline.js +698 -10
- package/dist/hooks/post-edit-context.js +12 -0
- package/dist/hooks/post-tool-use.js +40 -4
- package/dist/hooks/pre-compact.js +31 -0
- package/dist/hooks/pre-delete-check.js +12 -0
- package/dist/hooks/quality-event.js +31 -0
- package/dist/hooks/rule-enforcement-pipeline.js +14 -1
- package/dist/hooks/session-end.js +31 -0
- package/dist/hooks/session-start.js +32 -1
- package/dist/hooks/user-prompt.js +63 -1
- package/package.json +1 -1
- package/reference/hook-execution-order.md +25 -17
- package/src/commands/doctor.ts +1 -1
- package/src/commands/init.ts +19 -16
- package/src/config.ts +12 -0
- package/src/hooks/auto-learning-pipeline.ts +3 -3
- package/src/hooks/classify-failure.ts +259 -0
- package/src/hooks/incident-pipeline.ts +42 -0
- package/src/hooks/rule-enforcement-pipeline.ts +2 -1
- package/src/hooks/session-start.ts +1 -1
- package/src/hooks/user-prompt.ts +21 -1
- package/src/license.ts +2 -1
- package/src/mcp-bridge-tools.ts +1 -2
- package/src/memory-db.ts +201 -0
- package/src/memory-file-ingest.ts +13 -6
|
@@ -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
|
-
|
|
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
|
-
`
|
|
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
|
}
|
package/src/hooks/user-prompt.ts
CHANGED
|
@@ -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 (
|
|
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',
|
package/src/mcp-bridge-tools.ts
CHANGED
|
@@ -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 "
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
58
|
+
// Use defaults if frontmatter parsing fails
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
|