@massu/core 0.6.3 → 0.8.0

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,159 @@
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: Rule-to-Enforcement Pipeline
7
+ // When a new prevention rule (feedback_*.md) is written,
8
+ // automatically triggers the final step: enforcement placement.
9
+ //
10
+ // Part of the Auto-Learning Pipeline:
11
+ // Fix Detected → Incident Report → Rule Derived → [ENFORCEMENT]
12
+ //
13
+ // Triggers on: Write to memory/feedback_*.md (configurable)
14
+ // Must complete in <500ms.
15
+ // ============================================================
16
+
17
+ import { existsSync, readFileSync, readdirSync } from 'fs';
18
+ import { basename, resolve } from 'path';
19
+ import { getProjectRoot, getConfig } from '../config.ts';
20
+
21
+ interface HookInput {
22
+ session_id: string;
23
+ tool_name: string;
24
+ tool_input: { file_path?: string; content?: string };
25
+ }
26
+
27
+ async function main(): Promise<void> {
28
+ try {
29
+ const input = await readStdin();
30
+ const hookInput = JSON.parse(input) as HookInput;
31
+ const filePath = hookInput.tool_input?.file_path;
32
+
33
+ if (!filePath) {
34
+ process.exit(0);
35
+ return;
36
+ }
37
+
38
+ const config = getConfig();
39
+ if (config.autoLearning?.enabled === false) {
40
+ process.exit(0);
41
+ return;
42
+ }
43
+
44
+ const root = getProjectRoot();
45
+ const memoryDir = config.autoLearning?.memoryDir ?? 'memory';
46
+ const enforcementDir = config.autoLearning?.enforcementHooksDir ?? 'scripts/hooks';
47
+ // Only trigger on feedback rule files
48
+ const relPath = filePath.startsWith(root + '/') ? filePath.slice(root.length + 1) : filePath;
49
+ const fileName = basename(filePath);
50
+
51
+ // Match feedback_*.md in either relative or absolute memory paths
52
+ if (!fileName.startsWith('feedback_') || !fileName.endsWith('.md')) {
53
+ process.exit(0);
54
+ return;
55
+ }
56
+
57
+ // Must be in a memory-like directory
58
+ const claudeDir = config.conventions?.claudeDirName ?? '.claude';
59
+ if (!relPath.includes(memoryDir) && !relPath.includes('memory/') && !relPath.includes(claudeDir + '/')) {
60
+ process.exit(0);
61
+ return;
62
+ }
63
+
64
+ if (!existsSync(filePath)) {
65
+ process.exit(0);
66
+ return;
67
+ }
68
+
69
+ if (config.autoLearning?.pipeline?.requireEnforcement === false) {
70
+ process.exit(0);
71
+ return;
72
+ }
73
+
74
+ // Extract rule details from the file
75
+ const content = readFileSync(filePath, 'utf-8');
76
+ const nameMatch = content.match(/^name:\s*(.+)/m);
77
+ const descMatch = content.match(/^description:\s*(.+)/m);
78
+ const ruleName = nameMatch?.[1]?.trim() ?? fileName;
79
+ const ruleDesc = descMatch?.[1]?.trim() ?? '';
80
+
81
+ // Check if this rule already has enforcement in any hook file
82
+ const enforcementDirAbs = resolve(root, enforcementDir);
83
+ let hasEnforcement = false;
84
+ if (existsSync(enforcementDirAbs)) {
85
+ const hookFiles = readdirSync(enforcementDirAbs).filter(f => f.endsWith('.sh') || f.endsWith('.ts') || f.endsWith('.js'));
86
+ for (const hookFile of hookFiles) {
87
+ try {
88
+ const hookContent = readFileSync(resolve(enforcementDirAbs, hookFile), 'utf-8');
89
+ if (hookContent.includes(fileName)) {
90
+ hasEnforcement = true;
91
+ break;
92
+ }
93
+ } catch { /* ignore read errors */ }
94
+ }
95
+ }
96
+
97
+ if (hasEnforcement) {
98
+ // Already has enforcement — no action needed
99
+ process.exit(0);
100
+ return;
101
+ }
102
+
103
+ // Output enforcement placement instructions
104
+ const lines: string[] = [];
105
+ lines.push('');
106
+ lines.push('============================================================================');
107
+ lines.push(' AUTO-LEARNING: New Rule Created — Enforcement Placement Required');
108
+ lines.push('============================================================================');
109
+ lines.push('');
110
+ lines.push(` Rule: ${ruleName}`);
111
+ lines.push(` File: ${filePath}`);
112
+ if (ruleDesc) {
113
+ lines.push(` Description: ${ruleDesc}`);
114
+ }
115
+ lines.push('');
116
+ lines.push(' This rule has NO automated enforcement yet. Add it now.');
117
+ lines.push('');
118
+ lines.push(' ANALYZE the rule and determine enforcement layer(s):');
119
+ lines.push('');
120
+ lines.push(' 1. STATICALLY DETECTABLE? (grep/regex can find violations in code)');
121
+ lines.push(` → Add check to: ${enforcementDir}/pattern-feedback hook`);
122
+ lines.push(` → Also add to pre-commit hook if critical`);
123
+ lines.push('');
124
+ lines.push(' 2. ABOUT EDITING CERTAIN FILES? (auth, infra, routers, etc.)');
125
+ lines.push(` → Add warning to: ${enforcementDir}/blast-radius hook`);
126
+ lines.push('');
127
+ lines.push(' 3. ABOUT DANGEROUS COMMANDS? (kill, rm, destructive ops)');
128
+ lines.push(` → Add block to: ${enforcementDir}/dangerous-command hook`);
129
+ lines.push('');
130
+ lines.push(' 4. NEEDS RUNTIME MONITORING? (can only be detected at runtime)');
131
+ lines.push(' → Create a monitoring/audit producer');
132
+ lines.push('');
133
+ lines.push(' 5. AI-GUIDANCE ONLY? (philosophy, process, judgment calls)');
134
+ lines.push(' → Memory rule is sufficient (already created)');
135
+ lines.push('');
136
+ lines.push(' AFTER adding enforcement, test the hook to verify it detects violations.');
137
+ lines.push('');
138
+ lines.push(' This step is MANDATORY per the auto-learning pipeline.');
139
+ lines.push('============================================================================');
140
+ lines.push('');
141
+
142
+ console.log(lines.join('\n'));
143
+ } catch {
144
+ // Best-effort: never block Claude Code
145
+ }
146
+ process.exit(0);
147
+ }
148
+
149
+ function readStdin(): Promise<string> {
150
+ return new Promise((resolve) => {
151
+ let data = '';
152
+ process.stdin.setEncoding('utf-8');
153
+ process.stdin.on('data', (chunk: string) => { data += chunk; });
154
+ process.stdin.on('end', () => resolve(data));
155
+ setTimeout(() => resolve(data), 3000);
156
+ });
157
+ }
158
+
159
+ main();
@@ -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',
@@ -68,7 +68,7 @@ const ENV_DENY_PATTERNS = [
68
68
  function buildSubprocessEnv(): Record<string, string> {
69
69
  const env: Record<string, string> = {};
70
70
  // Derive safe env prefixes from the project name in massu.config.yaml.
71
- // e.g., project name "hedge" -> allow HEDGE_CONFIG_, HEDGE_LOG_ etc.
71
+ // e.g., project name "myapp" -> allow MYAPP_CONFIG_, MYAPP_LOG_ etc.
72
72
  // This makes the bridge work for any Massu user's project without hardcoding.
73
73
  const projectName = getConfig().project?.name?.toUpperCase() || '';
74
74
  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
+ }