@massu/core 0.6.3 → 0.7.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.
- package/dist/cli.js +29 -2
- package/dist/hooks/auto-learning-pipeline.js +469 -0
- package/dist/hooks/cost-tracker.js +28 -1
- package/dist/hooks/fix-detector.js +462 -0
- package/dist/hooks/incident-pipeline.js +426 -0
- package/dist/hooks/post-edit-context.js +28 -1
- package/dist/hooks/post-tool-use.js +28 -1
- package/dist/hooks/pre-compact.js +28 -1
- package/dist/hooks/pre-delete-check.js +28 -1
- package/dist/hooks/quality-event.js +28 -1
- package/dist/hooks/rule-enforcement-pipeline.js +440 -0
- package/dist/hooks/session-end.js +28 -1
- package/dist/hooks/session-start.js +28 -1
- package/dist/hooks/user-prompt.js +28 -1
- package/package.json +2 -2
- package/src/config.ts +31 -0
- package/src/hooks/auto-learning-pipeline.ts +195 -0
- package/src/hooks/fix-detector.ts +186 -0
- package/src/hooks/incident-pipeline.ts +148 -0
- package/src/hooks/rule-enforcement-pipeline.ts +158 -0
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
// Stop Hook: Auto-Learning Pipeline Enforcer
|
|
7
|
+
// At session end, checks if bug fixes were applied without
|
|
8
|
+
// completing the full incident → rule → enforcement pipeline.
|
|
9
|
+
// Outputs mandatory instructions for Claude to follow.
|
|
10
|
+
//
|
|
11
|
+
// Part of the Auto-Learning Pipeline:
|
|
12
|
+
// Fix Detected → [SESSION END CHECK] → Pipeline Instructions
|
|
13
|
+
//
|
|
14
|
+
// This is the FORCING FUNCTION that ensures no fix goes
|
|
15
|
+
// undocumented. Claude cannot end the session without completing
|
|
16
|
+
// the pipeline steps.
|
|
17
|
+
// ============================================================
|
|
18
|
+
|
|
19
|
+
import { execSync } from 'child_process';
|
|
20
|
+
import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
|
|
21
|
+
import { tmpdir } from 'os';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { getProjectRoot, getConfig } from '../config.ts';
|
|
24
|
+
|
|
25
|
+
interface HookInput {
|
|
26
|
+
session_id: string;
|
|
27
|
+
transcript_path: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FixSignal {
|
|
32
|
+
file: string;
|
|
33
|
+
signals: string[];
|
|
34
|
+
timestamp: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getSessionFlagPath(sessionId: string): string {
|
|
38
|
+
return join(tmpdir(), 'massu-auto-learning', `fixes-${sessionId.slice(0, 12)}.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main(): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
const input = await readStdin();
|
|
44
|
+
const hookInput = JSON.parse(input) as HookInput;
|
|
45
|
+
const config = getConfig();
|
|
46
|
+
|
|
47
|
+
// Check if auto-learning is enabled
|
|
48
|
+
if (config.autoLearning?.enabled === false) {
|
|
49
|
+
process.exit(0);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const root = getProjectRoot();
|
|
54
|
+
const incidentDir = config.autoLearning?.incidentDir ?? 'docs/incidents';
|
|
55
|
+
const memoryDir = config.autoLearning?.memoryDir ?? 'memory';
|
|
56
|
+
const autoLearn = config.autoLearning;
|
|
57
|
+
|
|
58
|
+
// Source 1: Session fix flags from fix-detector
|
|
59
|
+
const flagPath = getSessionFlagPath(hookInput.session_id);
|
|
60
|
+
let sessionFixes: FixSignal[] = [];
|
|
61
|
+
if (existsSync(flagPath)) {
|
|
62
|
+
try {
|
|
63
|
+
sessionFixes = readFileSync(flagPath, 'utf-8')
|
|
64
|
+
.split('\n')
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map(line => JSON.parse(line) as FixSignal);
|
|
67
|
+
} catch { /* ignore parse errors */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Source 2: Scan uncommitted git diff for fix patterns
|
|
71
|
+
let uncommittedFix = false;
|
|
72
|
+
try {
|
|
73
|
+
const diff = execSync('git diff --name-only', { cwd: root, timeout: 3000, encoding: 'utf-8' });
|
|
74
|
+
if (diff.trim()) {
|
|
75
|
+
const fullDiff = execSync('git diff', { cwd: root, timeout: 5000, encoding: 'utf-8' });
|
|
76
|
+
const fixPatterns = (fullDiff.match(/^\+.*(try|except|catch|guard|@MainActor|asyncio\.timeout|X-Service-Token|\.save\(|return False)/gm) || []).length;
|
|
77
|
+
const removedBroken = (fullDiff.match(/^-.*(bug|broken|crash|\.store\(|= nil|error)/gm) || []).length;
|
|
78
|
+
if (fixPatterns > 3 || removedBroken > 1) {
|
|
79
|
+
uncommittedFix = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch { /* git not available or no changes */ }
|
|
83
|
+
|
|
84
|
+
if (sessionFixes.length === 0 && !uncommittedFix) {
|
|
85
|
+
// Clean up flag file
|
|
86
|
+
cleanup(flagPath);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Build pipeline instructions
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('============================================================================');
|
|
95
|
+
lines.push(' MASSU AUTO-LEARNING PIPELINE — ACTION REQUIRED BEFORE SESSION END');
|
|
96
|
+
lines.push('============================================================================');
|
|
97
|
+
|
|
98
|
+
if (sessionFixes.length > 0) {
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push(` ${sessionFixes.length} bug fix(es) detected during this session:`);
|
|
101
|
+
lines.push('');
|
|
102
|
+
// Deduplicate by file
|
|
103
|
+
const byFile = new Map<string, string[]>();
|
|
104
|
+
for (const fix of sessionFixes) {
|
|
105
|
+
const existing = byFile.get(fix.file) ?? [];
|
|
106
|
+
existing.push(...fix.signals);
|
|
107
|
+
byFile.set(fix.file, [...new Set(existing)]);
|
|
108
|
+
}
|
|
109
|
+
for (const [file, signals] of byFile) {
|
|
110
|
+
lines.push(` - ${file} (${signals.join(', ')})`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (uncommittedFix) {
|
|
115
|
+
lines.push('');
|
|
116
|
+
lines.push(' Additional uncommitted fix patterns detected in git diff.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(' Complete these steps before this session ends:');
|
|
121
|
+
lines.push('');
|
|
122
|
+
|
|
123
|
+
if (autoLearn?.pipeline?.requireIncidentReport !== false) {
|
|
124
|
+
lines.push(' STEP 1: INCIDENT REPORT');
|
|
125
|
+
lines.push(` For each distinct bug fixed, create: ${incidentDir}/YYYY-MM-DD-<slug>.md`);
|
|
126
|
+
lines.push(' Include: Date, Severity, Symptoms, Root Cause, Fix, Files Changed, Prevention Rules');
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (autoLearn?.pipeline?.requirePreventionRule !== false) {
|
|
131
|
+
lines.push(' STEP 2: PREVENTION RULE');
|
|
132
|
+
lines.push(` For each incident, create: ${memoryDir}/feedback_<rule_name>.md`);
|
|
133
|
+
lines.push(' Include frontmatter (name, description, type: feedback) + Why + How to apply');
|
|
134
|
+
lines.push(` Update ${config.autoLearning?.memoryIndexFile ?? 'MEMORY.md'} index`);
|
|
135
|
+
lines.push('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (autoLearn?.pipeline?.requireEnforcement !== false) {
|
|
139
|
+
lines.push(' STEP 3: ENFORCEMENT PLACEMENT');
|
|
140
|
+
lines.push(' For each new rule, determine enforcement layer(s):');
|
|
141
|
+
lines.push(' a) If statically detectable → add to pattern-feedback hook');
|
|
142
|
+
lines.push(' b) If about editing certain files → add to blast-radius hook');
|
|
143
|
+
lines.push(' c) If about dangerous commands → add to dangerous-command hook');
|
|
144
|
+
lines.push(' d) If critical → add to pre-commit hook');
|
|
145
|
+
lines.push(' e) If needs runtime monitoring → create monitoring producer');
|
|
146
|
+
lines.push('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lines.push(' STEP 4: VERIFY');
|
|
150
|
+
lines.push(' Test any new enforcement hooks to confirm they detect violations.');
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push('============================================================================');
|
|
153
|
+
lines.push('');
|
|
154
|
+
|
|
155
|
+
console.log(lines.join('\n'));
|
|
156
|
+
|
|
157
|
+
// Clean up flag file
|
|
158
|
+
cleanup(flagPath);
|
|
159
|
+
} catch {
|
|
160
|
+
// Best-effort: never block Claude Code
|
|
161
|
+
}
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cleanup(flagPath: string): void {
|
|
166
|
+
try {
|
|
167
|
+
if (existsSync(flagPath)) unlinkSync(flagPath);
|
|
168
|
+
// Clean up old flag files (>24h)
|
|
169
|
+
const dir = join(tmpdir(), 'massu-auto-learning');
|
|
170
|
+
if (existsSync(dir)) {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
for (const file of readdirSync(dir)) {
|
|
173
|
+
const fullPath = join(dir, file);
|
|
174
|
+
try {
|
|
175
|
+
const stat = require('fs').statSync(fullPath);
|
|
176
|
+
if (now - stat.mtimeMs > 86400000) {
|
|
177
|
+
unlinkSync(fullPath);
|
|
178
|
+
}
|
|
179
|
+
} catch { /* ignore */ }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch { /* best effort */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readStdin(): Promise<string> {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
let data = '';
|
|
188
|
+
process.stdin.setEncoding('utf-8');
|
|
189
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; });
|
|
190
|
+
process.stdin.on('end', () => resolve(data));
|
|
191
|
+
setTimeout(() => resolve(data), 5000);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main();
|
|
@@ -0,0 +1,186 @@
|
|
|
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: Fix Detector
|
|
7
|
+
// Detects when a bug fix is being applied during a session by
|
|
8
|
+
// analyzing git diffs of edited files. Sets session-level state
|
|
9
|
+
// so the auto-learning pipeline can trigger at session end.
|
|
10
|
+
//
|
|
11
|
+
// Part of the Auto-Learning Pipeline:
|
|
12
|
+
// [Fix Detected] → Incident Report → Rule → Enforcement
|
|
13
|
+
//
|
|
14
|
+
// Must complete in <1000ms.
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
import { execSync } from 'child_process';
|
|
18
|
+
import { existsSync, appendFileSync, mkdirSync } from 'fs';
|
|
19
|
+
import { tmpdir } from 'os';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { getProjectRoot, getConfig } from '../config.ts';
|
|
22
|
+
|
|
23
|
+
interface HookInput {
|
|
24
|
+
session_id: string;
|
|
25
|
+
tool_name: string;
|
|
26
|
+
tool_input: { file_path?: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FixSignal {
|
|
30
|
+
file: string;
|
|
31
|
+
signals: string[];
|
|
32
|
+
timestamp: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fix detection heuristics — each returns true if the pattern matches
|
|
36
|
+
const FIX_HEURISTICS: Array<{ name: string; test: (diff: string) => boolean }> = [
|
|
37
|
+
{
|
|
38
|
+
name: 'removed_broken_code',
|
|
39
|
+
test: (diff) => /^-.*\b(bug|broken|wrong|incorrect|typo|crash|error|fail|miss|stale)\b/m.test(diff),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'added_error_handling',
|
|
43
|
+
test: (diff) => {
|
|
44
|
+
const added = (diff.match(/^\+.*(try|except|catch|guard|if.*nil|if.*None|validate|assert|raise|throw)/gm) || []).length;
|
|
45
|
+
return added > 2;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'method_name_correction',
|
|
50
|
+
test: (diff) => {
|
|
51
|
+
const removed = diff.match(/^-.*\.([a-z_]+)\(/m);
|
|
52
|
+
const added = diff.match(/^\+.*\.([a-z_]+)\(/m);
|
|
53
|
+
return !!(removed && added && removed[1] !== added[1]);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'auth_fix',
|
|
58
|
+
test: (diff) => /^\+.*(token|auth|header|X-Service|Bearer|credential)/im.test(diff),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'nil_handling_fix',
|
|
62
|
+
test: (diff) => /^\+.*(= nil|= None|\.isNil|is None|!= nil|is not None|guard let|if let|optional)/m.test(diff) && /^-/m.test(diff),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'concurrency_fix',
|
|
66
|
+
test: (diff) => /^\+.*(timeout|semaphore|lock|mutex|throttle|rate.limit|max_conn)/im.test(diff),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'async_pattern_fix',
|
|
70
|
+
test: (diff) => /^\+.*(@MainActor|async with|asyncio\.timeout|\.await)/.test(diff) && /^-/m.test(diff),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'added_missing_import',
|
|
74
|
+
test: (diff) => /^\+.*(import|from.*import|require)/.test(diff) && !/^-.*(import|from.*import|require)/m.test(diff),
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function getSessionFlagPath(sessionId: string): string {
|
|
79
|
+
const dir = join(tmpdir(), 'massu-auto-learning');
|
|
80
|
+
if (!existsSync(dir)) {
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
}
|
|
83
|
+
return join(dir, `fixes-${sessionId.slice(0, 12)}.jsonl`);
|
|
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 || !existsSync(filePath)) {
|
|
93
|
+
process.exit(0);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Only check code files
|
|
98
|
+
if (!/\.(py|swift|ts|tsx|js|jsx|rs|go|rb|sh)$/.test(filePath)) {
|
|
99
|
+
process.exit(0);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Skip incident/memory/doc files (those ARE pipeline output)
|
|
104
|
+
const config = getConfig();
|
|
105
|
+
const incidentDir = config.autoLearning?.incidentDir ?? 'docs/incidents';
|
|
106
|
+
const memoryDir = config.autoLearning?.memoryDir ?? 'memory';
|
|
107
|
+
if (filePath.includes(incidentDir) || filePath.includes(memoryDir) || filePath.includes('MEMORY.md')) {
|
|
108
|
+
process.exit(0);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if auto-learning is enabled
|
|
113
|
+
if (config.autoLearning?.enabled === false || config.autoLearning?.fixDetection?.enabled === false) {
|
|
114
|
+
process.exit(0);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Get git diff for this file
|
|
119
|
+
const root = getProjectRoot();
|
|
120
|
+
let diff = '';
|
|
121
|
+
try {
|
|
122
|
+
diff = execSync(`git diff -- "${filePath}"`, { cwd: root, timeout: 3000, encoding: 'utf-8' });
|
|
123
|
+
if (!diff) {
|
|
124
|
+
diff = execSync(`git diff HEAD -- "${filePath}"`, { cwd: root, timeout: 3000, encoding: 'utf-8' });
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
process.exit(0);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!diff) {
|
|
132
|
+
process.exit(0);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Run fix detection heuristics
|
|
137
|
+
const enabledSignals = new Set(config.autoLearning?.fixDetection?.signals ?? FIX_HEURISTICS.map(h => h.name));
|
|
138
|
+
const detected: string[] = [];
|
|
139
|
+
for (const heuristic of FIX_HEURISTICS) {
|
|
140
|
+
if (enabledSignals.has(heuristic.name) && heuristic.test(diff)) {
|
|
141
|
+
detected.push(heuristic.name);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (detected.length === 0) {
|
|
146
|
+
process.exit(0);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Record the fix detection
|
|
151
|
+
const signal: FixSignal = {
|
|
152
|
+
file: filePath,
|
|
153
|
+
signals: detected,
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const flagPath = getSessionFlagPath(hookInput.session_id);
|
|
158
|
+
appendFileSync(flagPath, JSON.stringify(signal) + '\n');
|
|
159
|
+
|
|
160
|
+
// Count total fixes this session
|
|
161
|
+
const lines = require('fs').readFileSync(flagPath, 'utf-8').split('\n').filter(Boolean);
|
|
162
|
+
if (lines.length === 1) {
|
|
163
|
+
// First fix detected — output advisory
|
|
164
|
+
console.log(
|
|
165
|
+
`[Massu Auto-Learning] Bug fix detected in ${filePath} (signals: ${detected.join(', ')}). ` +
|
|
166
|
+
`The auto-learning pipeline will prompt you at session end to create an incident report, ` +
|
|
167
|
+
`derive a prevention rule, and add enforcement.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Best-effort: never block Claude Code
|
|
172
|
+
}
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readStdin(): Promise<string> {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
let data = '';
|
|
179
|
+
process.stdin.setEncoding('utf-8');
|
|
180
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; });
|
|
181
|
+
process.stdin.on('end', () => resolve(data));
|
|
182
|
+
setTimeout(() => resolve(data), 3000);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main();
|
|
@@ -0,0 +1,148 @@
|
|
|
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: Incident-to-Rule Pipeline
|
|
7
|
+
// When a new incident report is written, automatically triggers
|
|
8
|
+
// the next step: deriving a prevention rule.
|
|
9
|
+
//
|
|
10
|
+
// Part of the Auto-Learning Pipeline:
|
|
11
|
+
// Fix Detected → [Incident Report] → RULE DERIVATION → Enforcement
|
|
12
|
+
//
|
|
13
|
+
// Triggers on: Write to docs/incidents/*.md (configurable)
|
|
14
|
+
// Must complete in <500ms.
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
18
|
+
import { basename, dirname, 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 incidentDir = config.autoLearning?.incidentDir ?? 'docs/incidents';
|
|
46
|
+
const memoryDir = config.autoLearning?.memoryDir ?? 'memory';
|
|
47
|
+
// Only trigger on incident report files
|
|
48
|
+
const relPath = filePath.startsWith(root + '/') ? filePath.slice(root.length + 1) : filePath;
|
|
49
|
+
if (!relPath.startsWith(incidentDir) || !relPath.endsWith('.md')) {
|
|
50
|
+
process.exit(0);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!existsSync(filePath)) {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract title from the incident report
|
|
60
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
61
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
62
|
+
const title = titleMatch?.[1] ?? basename(filePath, '.md');
|
|
63
|
+
|
|
64
|
+
// Check if a corresponding rule already exists
|
|
65
|
+
const slug = basename(filePath, '.md')
|
|
66
|
+
.replace(/^\d{4}-\d{2}-\d{2}-?/, '')
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
|
|
69
|
+
const memoryDirAbs = resolve(root, memoryDir);
|
|
70
|
+
let hasExistingRule = false;
|
|
71
|
+
if (existsSync(memoryDirAbs)) {
|
|
72
|
+
const ruleFiles = readdirSync(memoryDirAbs).filter(f => f.startsWith('feedback_'));
|
|
73
|
+
for (const ruleFile of ruleFiles) {
|
|
74
|
+
if (ruleFile.toLowerCase().includes(slug.slice(0, 20))) {
|
|
75
|
+
hasExistingRule = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hasExistingRule) {
|
|
82
|
+
// Rule already exists — no action needed
|
|
83
|
+
process.exit(0);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if incident has prevention rules section
|
|
88
|
+
const hasPrevention = /## Prevention Rules|## Prevention|## Rules/i.test(content);
|
|
89
|
+
|
|
90
|
+
if (config.autoLearning?.pipeline?.requirePreventionRule === false) {
|
|
91
|
+
process.exit(0);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Output rule derivation instructions
|
|
96
|
+
const lines: string[] = [];
|
|
97
|
+
lines.push('');
|
|
98
|
+
lines.push('============================================================================');
|
|
99
|
+
lines.push(' AUTO-LEARNING: Incident Report Created — Rule Derivation Required');
|
|
100
|
+
lines.push('============================================================================');
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push(` Incident: ${title}`);
|
|
103
|
+
lines.push(` File: ${filePath}`);
|
|
104
|
+
lines.push('');
|
|
105
|
+
|
|
106
|
+
if (!hasPrevention) {
|
|
107
|
+
lines.push(' No "## Prevention Rules" section found in the incident report.');
|
|
108
|
+
lines.push(' Add one first, then proceed with rule derivation.');
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push(' DERIVE A PREVENTION RULE:');
|
|
113
|
+
lines.push(` a) Read the incident root cause and prevention rules`);
|
|
114
|
+
lines.push(` b) Create: ${memoryDir}/feedback_<rule_name>.md`);
|
|
115
|
+
lines.push(' Template:');
|
|
116
|
+
lines.push(' ---');
|
|
117
|
+
lines.push(' name: <Rule Name>');
|
|
118
|
+
lines.push(' description: <one-line description>');
|
|
119
|
+
lines.push(' type: feedback');
|
|
120
|
+
lines.push(' ---');
|
|
121
|
+
lines.push(' <Rule statement>');
|
|
122
|
+
lines.push(' **Why:** <Root cause from incident>');
|
|
123
|
+
lines.push(' **How to apply:** <Concrete steps>');
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(` c) Add one-line entry to ${config.autoLearning?.memoryIndexFile ?? 'MEMORY.md'}`);
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push(' This step is MANDATORY per the auto-learning pipeline.');
|
|
128
|
+
lines.push('============================================================================');
|
|
129
|
+
lines.push('');
|
|
130
|
+
|
|
131
|
+
console.log(lines.join('\n'));
|
|
132
|
+
} catch {
|
|
133
|
+
// Best-effort: never block Claude Code
|
|
134
|
+
}
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readStdin(): Promise<string> {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
let data = '';
|
|
141
|
+
process.stdin.setEncoding('utf-8');
|
|
142
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; });
|
|
143
|
+
process.stdin.on('end', () => resolve(data));
|
|
144
|
+
setTimeout(() => resolve(data), 3000);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
main();
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
if (!relPath.includes(memoryDir) && !relPath.includes('memory/') && !relPath.includes('.claude/')) {
|
|
59
|
+
process.exit(0);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!existsSync(filePath)) {
|
|
64
|
+
process.exit(0);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (config.autoLearning?.pipeline?.requireEnforcement === false) {
|
|
69
|
+
process.exit(0);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Extract rule details from the file
|
|
74
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
75
|
+
const nameMatch = content.match(/^name:\s*(.+)/m);
|
|
76
|
+
const descMatch = content.match(/^description:\s*(.+)/m);
|
|
77
|
+
const ruleName = nameMatch?.[1]?.trim() ?? fileName;
|
|
78
|
+
const ruleDesc = descMatch?.[1]?.trim() ?? '';
|
|
79
|
+
|
|
80
|
+
// Check if this rule already has enforcement in any hook file
|
|
81
|
+
const enforcementDirAbs = resolve(root, enforcementDir);
|
|
82
|
+
let hasEnforcement = false;
|
|
83
|
+
if (existsSync(enforcementDirAbs)) {
|
|
84
|
+
const hookFiles = readdirSync(enforcementDirAbs).filter(f => f.endsWith('.sh') || f.endsWith('.ts') || f.endsWith('.js'));
|
|
85
|
+
for (const hookFile of hookFiles) {
|
|
86
|
+
try {
|
|
87
|
+
const hookContent = readFileSync(resolve(enforcementDirAbs, hookFile), 'utf-8');
|
|
88
|
+
if (hookContent.includes(fileName)) {
|
|
89
|
+
hasEnforcement = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
} catch { /* ignore read errors */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (hasEnforcement) {
|
|
97
|
+
// Already has enforcement — no action needed
|
|
98
|
+
process.exit(0);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Output enforcement placement instructions
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push('============================================================================');
|
|
106
|
+
lines.push(' AUTO-LEARNING: New Rule Created — Enforcement Placement Required');
|
|
107
|
+
lines.push('============================================================================');
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(` Rule: ${ruleName}`);
|
|
110
|
+
lines.push(` File: ${filePath}`);
|
|
111
|
+
if (ruleDesc) {
|
|
112
|
+
lines.push(` Description: ${ruleDesc}`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push(' This rule has NO automated enforcement yet. Add it now.');
|
|
116
|
+
lines.push('');
|
|
117
|
+
lines.push(' ANALYZE the rule and determine enforcement layer(s):');
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(' 1. STATICALLY DETECTABLE? (grep/regex can find violations in code)');
|
|
120
|
+
lines.push(` → Add check to: ${enforcementDir}/pattern-feedback hook`);
|
|
121
|
+
lines.push(` → Also add to pre-commit hook if critical`);
|
|
122
|
+
lines.push('');
|
|
123
|
+
lines.push(' 2. ABOUT EDITING CERTAIN FILES? (auth, infra, routers, etc.)');
|
|
124
|
+
lines.push(` → Add warning to: ${enforcementDir}/blast-radius hook`);
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push(' 3. ABOUT DANGEROUS COMMANDS? (kill, rm, destructive ops)');
|
|
127
|
+
lines.push(` → Add block to: ${enforcementDir}/dangerous-command hook`);
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push(' 4. NEEDS RUNTIME MONITORING? (can only be detected at runtime)');
|
|
130
|
+
lines.push(' → Create a monitoring/audit producer');
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(' 5. AI-GUIDANCE ONLY? (philosophy, process, judgment calls)');
|
|
133
|
+
lines.push(' → Memory rule is sufficient (already created)');
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push(' AFTER adding enforcement, test the hook to verify it detects violations.');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push(' This step is MANDATORY per the auto-learning pipeline.');
|
|
138
|
+
lines.push('============================================================================');
|
|
139
|
+
lines.push('');
|
|
140
|
+
|
|
141
|
+
console.log(lines.join('\n'));
|
|
142
|
+
} catch {
|
|
143
|
+
// Best-effort: never block Claude Code
|
|
144
|
+
}
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readStdin(): Promise<string> {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
let data = '';
|
|
151
|
+
process.stdin.setEncoding('utf-8');
|
|
152
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; });
|
|
153
|
+
process.stdin.on('end', () => resolve(data));
|
|
154
|
+
setTimeout(() => resolve(data), 3000);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
main();
|