OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / loop-intervention.js

183 lines · javascript ref:
#!/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))