#!/usr/bin/env node
// Loop Intervention Hook — Blocking Detour
// Knowledge Ratchet Phase 2
//
// Runs on UserPromptSubmit (synchronous, blocking).
// Checks if a correction loop is active. If yes, BLOCKS and outputs
// learned rules + past resolutions as a directive.
//
// Claude must read and acknowledge the rules before proceeding.
// If no loop is active, exits immediately (zero delay).
//
// Exit codes:
// 0 = no loop, allow prompt
// 2 = loop detected, block with rules (Claude sees stderr)
const fs = require('fs')
const path = require('path')
const STATE_FILE = '/tmp/claude-session-score.json'
const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json')
const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json')
const TUNED_PATTERNS_FILE = path.join(__dirname, 'data', 'tuned-patterns.json')
// Load tuned patterns — same source as realtime-score and fuse-signals
function loadTunedPatterns () {
if (fs.existsSync(TUNED_PATTERNS_FILE)) {
try {
const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8'))
const patterns = []
for (const tier of ['high', 'medium']) {
for (const p of (tuned.correction?.[tier] || [])) {
patterns.push(new RegExp(p.pattern, p.flags || 'i'))
}
}
const skip = (tuned.skip || []).map(p => new RegExp(p, 'i'))
if (patterns.length > 0) return { patterns, skip }
} catch {}
}
return null
}
const _tuned = loadTunedPatterns()
const CORRECTION_PATTERNS = _tuned?.patterns || [
/\b(wrong|not that|that's not|incorrect|stop doing)\b/i,
/\b(i said|i meant|i asked for|that's wrong)\b/i,
/\b(don't ever|never do that|please don't|do not do)\b/i,
/\b(why did you|why are you|that broke|you broke)\b/i,
/\b(revert|undo|put it back|roll back)\b/i,
]
const SKIP_PATTERNS = _tuned?.skip || [
/^!/, /^<command-name>/, /^<system-reminder>/, /^<task-notification>/,
/^fleeky@\w+/, /^Godot Engine/, /^\[Request interrupted/,
]
async function main () {
// Read the incoming prompt to check if it's a correction
let input = ''
for await (const chunk of process.stdin) { input += chunk }
let userText = ''
try { userText = JSON.parse(input).prompt || '' } catch { process.exit(0) }
// Quick check: is a loop already active?
if (!fs.existsSync(STATE_FILE)) process.exit(0)
let state
try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) }
catch { process.exit(0) }
// Skip terminal output, logs, system messages
if (SKIP_PATTERNS.some(p => p.test(userText))) process.exit(0)
// Check if THIS message is a correction
const isCorrection = CORRECTION_PATTERNS.some(p => p.test(userText))
// If the user is NOT correcting — they're giving instructions, saying "continue", etc.
// Let it through and cool down the loop state.
if (!isCorrection) {
if (state._loopActive && state._recentCorrections?.length > 0) {
// Non-correction message cools the loop — remove oldest correction
state._recentCorrections.shift()
if (state._recentCorrections.length < 2) {
state._loopActive = false
}
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}
process.exit(0) // allow — this is not a correction
}
// This IS a correction — update loop state
if (!state._recentCorrections) state._recentCorrections = []
state._recentCorrections.push(userText.slice(0, 200))
if (state._recentCorrections.length > 5) state._recentCorrections.shift()
state._loopActive = state._recentCorrections.length >= 2
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
// Only intervene if the loop has 2+ corrections
const recentCorr = state._recentCorrections
if (recentCorr.length < 2) process.exit(0)
// Only intervene once per loop escalation (on 2nd, 4th, 6th correction)
// Not every single correction — that's too aggressive
const corrCount = recentCorr.length
if (corrCount > 2 && corrCount % 2 !== 0) process.exit(0)
// Build the intervention message
const lines = [
'',
'⚠ LOOP INTERVENTION — You are in a correction loop',
'═'.repeat(50),
` ${corrCount} corrections in this sequence.`,
'',
' Recent corrections:',
]
for (const c of recentCorr.slice(-3)) {
lines.push(` ✗ "${c.slice(0, 100)}"`)
}
// Load learned rules for this context
const cwd = state._cwd || process.cwd()
const editedFiles = state.editedFiles || []
if (fs.existsSync(RULES_FILE)) {
try {
const rulesData = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8'))
// Find rules that match current context
const matchedRules = (rulesData.rules || []).filter(r => {
// Match by project (from cwd)
if (cwd.toLowerCase().includes(r.trigger.project)) return true
// Match by files
if (r.trigger.files?.some(f => editedFiles.some(ef => ef.includes(f)))) return true
return false
}).sort((a, b) => b.source.duration - a.source.duration)
if (matchedRules.length > 0) {
lines.push('')
lines.push(' LEARNED RULES for this context:')
for (const r of matchedRules.slice(0, 3)) {
lines.push(` When: ${r.trigger.symptom}`)
lines.push(` Do: ${r.action.description.slice(0, 120)}`)
lines.push(` Don't: ${r.antipattern.description.slice(0, 120)}`)
}
}
} catch {}
}
// Load similar past loops
if (fs.existsSync(LOOPS_FILE) && state._loopMatches?.matches?.length > 0) {
lines.push('')
lines.push(' PAST RESOLUTION:')
for (const m of state._loopMatches.matches.slice(0, 1)) {
if (m.resolutionFiles?.length > 0) {
lines.push(` Fix was in: ${m.resolutionFiles.join(', ')}`)
}
if (m.approval) {
lines.push(` What worked: "${m.approval.slice(0, 120)}"`)
}
}
}
lines.push('')
lines.push(' REQUIRED ACTIONS:')
lines.push(' 1. STOP what you are doing')
lines.push(' 2. Read the rules above')
lines.push(' 3. Enter planning mode if the loop has >3 corrections')
lines.push(' 4. Read the target files FULLY before making changes')
lines.push(' 5. Do NOT repeat the same approach that already failed')
lines.push('')
lines.push('═'.repeat(50))
// Output as additionalContext — Claude sees the rules alongside the user's message
// The prompt goes through, but Claude reads the intervention first
const output = { additionalContext: lines.join('\n') }
process.stdout.write(JSON.stringify(output))
process.exit(0) // allow prompt through, but inject context
}
main().catch(() => process.exit(0))