#!/usr/bin/env node
// Loop Detector — Knowledge Ratchet Phase 2
//
// Scans session turn data for correction loops:
// A loop is: correction → fumble → correction → ... → resolution
//
// A loop STARTS when a correction appears.
// A loop CONTINUES while: more corrections within N turns, high tool calls, low scores.
// A loop BREAKS when: approval comes, or 3+ clean turns (no corrections, decent score).
//
// For each loop, captures:
// - What went wrong (correction texts)
// - What was tried (edit files, tool calls)
// - What fixed it (the resolution — the turns that broke the loop)
// - Context (project, files, branch)
//
// Usage:
// node loop-detector.js # extract loops, save to data/loops.json
// node loop-detector.js --verbose # show per-loop detail
// node loop-detector.js --match "current context" # find similar past loops
const fs = require('fs')
const path = require('path')
const { classifyProject } = require('./classify-project')
const DATA_DIR = path.join(__dirname, 'data')
const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
// ─── Loop Detection Parameters ───
const MAX_GAP = 5 // max turns between corrections to still be "same loop"
const MIN_CORRECTIONS = 2 // need at least 2 corrections to be a loop (1 correction = normal)
const CLEAN_STREAK = 3 // 3 turns without correction = loop is over
function loadDetail () {
if (!fs.existsSync(DETAIL_FILE)) {
console.error('No fused-turns-detail.json. Run fuse-signals.js first.')
process.exit(1)
}
return JSON.parse(fs.readFileSync(DETAIL_FILE, 'utf8'))
}
// ─── Loop Extraction ───
function extractLoops (session) {
const turns = session.turns
if (turns.length < 3) return []
const loops = []
let currentLoop = null
let cleanCount = 0 // consecutive non-correction turns
for (let i = 0; i < turns.length; i++) {
const turn = turns[i]
if (turn.isCorrection) {
if (!currentLoop) {
// Start a new loop
currentLoop = {
startTurn: i,
endTurn: i,
corrections: [],
allTurns: [],
editFiles: new Set(),
totalToolCalls: 0,
}
}
currentLoop.corrections.push({
turn: i,
text: turn.text || '',
score: turn.score,
})
currentLoop.endTurn = i
cleanCount = 0
} else {
cleanCount++
}
// Add turn to current loop if we're in one
if (currentLoop) {
currentLoop.allTurns.push({
turn: i,
score: turn.score,
isCorrection: turn.isCorrection,
isApproval: turn.isApproval,
text: turn.text || '',
toolCalls: turn.toolCalls || 0,
editFiles: turn.editFiles || [],
})
currentLoop.totalToolCalls += turn.toolCalls || 0
for (const f of (turn.editFiles || [])) currentLoop.editFiles.add(f)
}
// Check if loop is over
if (currentLoop && cleanCount >= CLEAN_STREAK) {
// Loop broke — finalize it
if (currentLoop.corrections.length >= MIN_CORRECTIONS) {
loops.push(finalizeLoop(currentLoop, session))
}
currentLoop = null
cleanCount = 0
}
}
// Handle loop at end of session (never resolved)
if (currentLoop && currentLoop.corrections.length >= MIN_CORRECTIONS) {
const loop = finalizeLoop(currentLoop, session)
loop.resolved = false
loops.push(loop)
}
return loops
}
function finalizeLoop (raw, session) {
const lastCorrIdx = raw.corrections[raw.corrections.length - 1].turn
const turnsAfterLastCorr = raw.allTurns.filter(t => t.turn > lastCorrIdx)
// Resolution: the turns after the last correction that have good scores
// These contain the fix that broke the loop
const resolutionTurns = turnsAfterLastCorr.filter(t => !t.isCorrection)
const resolutionFiles = [...new Set(resolutionTurns.flatMap(t => t.editFiles))]
const resolutionApproval = resolutionTurns.find(t => t.isApproval)
// Classify the loop by what files were touched
const allFiles = [...raw.editFiles]
const project = classifyProject(session.cwd || '', allFiles)
const loopType = classifyType(raw.corrections)
// A loop is only truly resolved if:
// 1. There's an explicit approval after the corrections, OR
// 2. The clean turns after the loop have decent scores AND
// the loop doesn't end near the session end (user didn't just give up)
const totalTurns = raw.allTurns[raw.allTurns.length - 1]?.turn || 0
const sessionEnd = (session.turns?.length || totalTurns) - 1
const loopEndsTurn = raw.allTurns[raw.allTurns.length - 1]?.turn || 0
const nearSessionEnd = sessionEnd - loopEndsTurn < 5
const avgScoreAfter = resolutionTurns.length > 0
? resolutionTurns.reduce((s, t) => s + t.score, 0) / resolutionTurns.length
: 0
const isResolved = resolutionApproval
? true
: (!nearSessionEnd && avgScoreAfter > 0.6 && resolutionTurns.length >= CLEAN_STREAK)
return {
sessionId: session.sessionId,
date: session.date,
project,
type: loopType,
cwd: session.cwd,
gitBranch: session.gitBranch,
startTurn: raw.startTurn,
endTurn: raw.allTurns[raw.allTurns.length - 1]?.turn || raw.endTurn,
duration: (raw.allTurns[raw.allTurns.length - 1]?.turn || raw.endTurn) - raw.startTurn + 1,
correctionCount: raw.corrections.length,
totalToolCalls: raw.totalToolCalls,
resolved: isResolved,
// The problem: what corrections said
corrections: raw.corrections.map(c => c.text.slice(0, 200)),
// What was tried: files edited during the loop
filesTried: allFiles.slice(0, 20),
// The fix: what broke the loop
resolution: {
files: resolutionFiles.slice(0, 10),
approval: resolutionApproval?.text?.slice(0, 200) || null,
turnCount: resolutionTurns.length,
},
// Fingerprint for matching
fingerprint: buildFingerprint(project, loopType, allFiles),
}
}
// ─── Classification ───
// classifyProject is imported from classify-project.js (shared with
// loop-briefing.js). It uses hardcoded substring matching first for
// backwards compatibility, then walks parent dirs to find .git.
function classifyType (corrections) {
const allText = corrections.map(c => c.text).join(' ').toLowerCase()
if (/invisible|not (visible|showing|rendering)|can.t see|don.t see|flat|blank/.test(allText)) return 'not-rendering'
if (/crash|error|fail|exception|panic/.test(allText)) return 'runtime-error'
if (/same|still|worse|no change|didn.t (work|help|fix)/.test(allText)) return 'fix-failed'
if (/wrong|reversed|flipped|incorrect|cube/.test(allText)) return 'wrong-output'
if (/why (are|is|did)|approach|design|should we/.test(allText)) return 'wrong-approach'
if (/audit|planning|think deeper|step back/.test(allText)) return 'needs-rethink'
if (/hmm|hmmm/.test(allText)) return 'quality-doubt'
return 'other'
}
function buildFingerprint (project, type, files) {
// Extract unique file stems for matching
const stems = [...new Set(files.map(f => {
const base = path.basename(f)
return base.replace(/\.(js|cpp|h|gd|glsl|gdshader|tscn|tres)$/, '')
}))]
return `${project}:${type}:${stems.slice(0, 5).sort().join(',')}`
}
// ─── Loop Matching ───
function findSimilarLoops (currentFingerprint, pastLoops) {
const [proj, type, filesStr] = currentFingerprint.split(':')
const currentFiles = new Set(filesStr.split(',').filter(Boolean))
const scored = pastLoops
.filter(l => l.resolved) // only match against resolved loops
.map(loop => {
const [lProj, lType, lFilesStr] = loop.fingerprint.split(':')
const loopFiles = new Set(lFilesStr.split(',').filter(Boolean))
let score = 0
if (lProj === proj) score += 3 // same project = strong signal
if (lType === type) score += 2 // same error type
// File overlap
const overlap = [...currentFiles].filter(f => loopFiles.has(f)).length
score += overlap * 2
return { loop, score }
})
.filter(s => s.score >= 3) // minimum relevance
.sort((a, b) => b.score - a.score)
return scored.slice(0, 3).map(s => ({
score: s.score,
fingerprint: s.loop.fingerprint,
corrections: s.loop.corrections.slice(0, 3),
resolution: s.loop.resolution,
date: s.loop.date,
duration: s.loop.duration,
}))
}
// ─── Main ───
function main () {
const verbose = process.argv.includes('--verbose')
const matchArg = process.argv.find(a => a.startsWith('--match='))
const data = loadDetail()
// Extract loops from all sessions
const allLoops = []
for (const session of data.sessions) {
const loops = extractLoops(session)
allLoops.push(...loops)
}
// Sort by date
allLoops.sort((a, b) => (a.date || '').localeCompare(b.date || ''))
// ─── Report ───
console.log('═'.repeat(60))
console.log(' LOOP DETECTOR')
console.log('═'.repeat(60))
console.log(` Sessions analyzed: ${data.sessions.length}`)
console.log(` Loops found: ${allLoops.length}`)
console.log(` Resolved: ${allLoops.filter(l => l.resolved).length}`)
console.log(` Unresolved: ${allLoops.filter(l => !l.resolved).length}`)
// Stats
if (allLoops.length > 0) {
const avgDuration = allLoops.reduce((s, l) => s + l.duration, 0) / allLoops.length
const avgCorr = allLoops.reduce((s, l) => s + l.correctionCount, 0) / allLoops.length
const avgTools = allLoops.reduce((s, l) => s + l.totalToolCalls, 0) / allLoops.length
console.log(` Avg loop duration: ${avgDuration.toFixed(1)} turns`)
console.log(` Avg corrections per loop: ${avgCorr.toFixed(1)}`)
console.log(` Avg tool calls per loop: ${avgTools.toFixed(0)}`)
}
// By project
const byProject = {}
for (const loop of allLoops) {
if (!byProject[loop.project]) byProject[loop.project] = { count: 0, resolved: 0, avgDuration: 0 }
byProject[loop.project].count++
if (loop.resolved) byProject[loop.project].resolved++
byProject[loop.project].avgDuration += loop.duration
}
console.log('\n BY PROJECT')
console.log(' ' + '-'.repeat(56))
for (const [proj, stats] of Object.entries(byProject).sort((a, b) => b[1].count - a[1].count)) {
stats.avgDuration = (stats.avgDuration / stats.count).toFixed(1)
console.log(` ${proj.padEnd(20)} ${String(stats.count).padStart(3)} loops ${stats.resolved} resolved avg ${stats.avgDuration} turns`)
}
// By type
const byType = {}
for (const loop of allLoops) {
byType[loop.type] = (byType[loop.type] || 0) + 1
}
console.log('\n BY TYPE')
console.log(' ' + '-'.repeat(56))
for (const [type, count] of Object.entries(byType).sort((a, b) => b - a)) {
const bar = '█'.repeat(Math.round(count / allLoops.length * 20))
console.log(` ${bar.padEnd(10)} ${type.padEnd(20)} ${count}`)
}
// Show individual loops
if (verbose) {
console.log('\n ALL LOOPS')
console.log(' ' + '-'.repeat(56))
for (const loop of allLoops) {
const status = loop.resolved ? '✓' : '✗'
console.log(`\n ${status} ${loop.date} [${loop.project}/${loop.type}] ${loop.duration} turns, ${loop.correctionCount} corrections`)
console.log(` fingerprint: ${loop.fingerprint}`)
for (const c of loop.corrections.slice(0, 3)) {
console.log(` ✗ "${c.slice(0, 100)}"`)
}
if (loop.resolved && loop.resolution.files.length > 0) {
console.log(` fix: ${loop.resolution.files.slice(0, 3).map(f => path.basename(f)).join(', ')}`)
}
if (loop.resolved && loop.resolution.approval) {
console.log(` ✓ "${loop.resolution.approval.slice(0, 100)}"`)
}
}
}
// Match mode
if (matchArg) {
const query = matchArg.split('=')[1]
console.log(`\n MATCHING: "${query}"`)
console.log(' ' + '-'.repeat(56))
// Build a fingerprint from the query (project:type:files)
const matches = findSimilarLoops(query, allLoops)
if (matches.length === 0) {
console.log(' No similar past loops found.')
} else {
for (const m of matches) {
console.log(`\n Score: ${m.score} Date: ${m.date} Duration: ${m.duration} turns`)
console.log(` Fingerprint: ${m.fingerprint}`)
console.log(` Corrections: ${m.corrections.slice(0, 2).map(c => `"${c.slice(0, 80)}"`).join(', ')}`)
if (m.resolution.files.length > 0) {
console.log(` Fix files: ${m.resolution.files.map(f => path.basename(f)).join(', ')}`)
}
if (m.resolution.approval) {
console.log(` Approval: "${m.resolution.approval.slice(0, 100)}"`)
}
}
}
}
// Save
const report = {
generated: new Date().toISOString(),
totalLoops: allLoops.length,
resolved: allLoops.filter(l => l.resolved).length,
byProject,
byType,
loops: allLoops,
}
fs.writeFileSync(LOOPS_FILE, JSON.stringify(report, null, 2))
console.log(`\nSaved: ${LOOPS_FILE}`)
console.log('═'.repeat(60))
}
module.exports = { extractLoops, findSimilarLoops, buildFingerprint }
if (require.main === module) main()