| … | … | @@ -1,10 +1,6 @@ |
| 1 | corrections.log | |
| 2 | data/deep-analysis.json | |
| 1 | data/*.json | |
| 2 | data/*.html | |
| 3 | data/scan-cache/ | |
| 4 | data/session-summaries/ | |
| 3 | 5 | data/llm-batches/ |
| 4 | data/llm-cursor.json | |
| 5 | data/migration-state.json | |
| 6 | data/overrides.json | |
| 7 | data/regression-report.json | |
| 8 | data/scorecard.tsv | |
| 9 | questions/ | |
| 10 | debug-hook.js | |
| 6 | data/results-cache/ |
| … | … | @@ -1,12 +1,16 @@ |
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | |
| 3 | // Full analysis pipeline — runs all three steps in sequence | |
| 3 | // Full analysis pipeline — runs all seven steps in sequence | |
| 4 | 4 | // Usage: node analyze-all.js [--limit=N] |
| 5 | 5 | // |
| 6 | 6 | // Steps: |
| 7 | 7 | // 1. LLM analyzer (semantic correction detection) |
| 8 | // 2. Full ratchet pipeline (extract → validate → regress → migrate) | |
| 9 | // 3. Deep sentiment analysis | |
| 8 | // 2. Regex tuner (LLM-driven pattern discovery) | |
| 9 | // 3. Sensor fusion scoring (fuse-signals.js) | |
| 10 | // 4. Meta-ratchet (self-adjusting sensor weights) | |
| 11 | // 5. Coverage map (unknown-unknowns) | |
| 12 | // 6. Ratchet pipeline (extract → validate → regress → migrate) | |
| 13 | // 7. Deep sentiment analysis | |
| 10 | 14 | |
| 11 | 15 | const { execSync } = require('child_process') |
| 12 | 16 | const path = require('path') |
| … | … | @@ -29,17 +33,37 @@ console.log(`Knowledge Ratchet — Full Analysis`) |
| 29 | 33 | console.log(`Started: ${new Date().toISOString()}`) |
| 30 | 34 | |
| 31 | 35 | run( |
| 32 | 'Step 1/3: LLM Semantic Analysis', | |
| 36 | 'Step 1/7: LLM Semantic Analysis', | |
| 33 | 37 | `node ${path.join(DIR, 'llm-analyzer.js')} ${limitArg}` |
| 34 | 38 | ) |
| 35 | 39 | |
| 36 | 40 | run( |
| 37 | 'Step 2/3: Ratchet Pipeline (extract → validate → regress → migrate)', | |
| 41 | 'Step 2/7: Regex Tuner (LLM-driven pattern discovery)', | |
| 42 | `node ${path.join(DIR, 'tune-regex.js')}` | |
| 43 | ) | |
| 44 | ||
| 45 | run( | |
| 46 | 'Step 3/7: Sensor Fusion Scoring', | |
| 47 | `node ${path.join(DIR, 'fuse-signals.js')}` | |
| 48 | ) | |
| 49 | ||
| 50 | run( | |
| 51 | 'Step 4/7: Meta-Ratchet (weight learning)', | |
| 52 | `node ${path.join(DIR, 'meta-ratchet.js')}` | |
| 53 | ) | |
| 54 | ||
| 55 | run( | |
| 56 | 'Step 5/7: Coverage Map (unknown-unknowns)', | |
| 57 | `node ${path.join(DIR, 'coverage-map.js')} --summary` | |
| 58 | ) | |
| 59 | ||
| 60 | run( | |
| 61 | 'Step 6/7: Ratchet Pipeline (extract → validate → regress → migrate)', | |
| 38 | 62 | `node ${path.join(DIR, 'run.js')} full` |
| 39 | 63 | ) |
| 40 | 64 | |
| 41 | 65 | run( |
| 42 | 'Step 3/3: Deep Sentiment Analysis', | |
| 66 | 'Step 7/7: Deep Sentiment Analysis', | |
| 43 | 67 | `node ${path.join(DIR, 'analyze-transcripts.js')}` |
| 44 | 68 | ) |
| 45 | 69 |
| … | … | @@ -0,0 +1,716 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Ratchet Effectiveness Benchmarks — Knowledge Ratchet Phase 2 | |
| 4 | // | |
| 5 | // Three validation benchmarks to determine if the system is working: | |
| 6 | // | |
| 7 | // T3: Coverage Map Accuracy | |
| 8 | // Do high-uncertainty topics actually have higher correction rates? | |
| 9 | // Correlate uncertainty with observed correction rate per topic. | |
| 10 | // | |
| 11 | // R1: Repeat Correction Rate | |
| 12 | // For each principle, count violations per week. A working ratchet | |
| 13 | // means the line goes down and stays down. | |
| 14 | // | |
| 15 | // R3: First-Attempt Success Rate | |
| 16 | // What fraction of sessions complete with zero corrections? | |
| 17 | // Track weekly. Ratchet = ratio improves over time. | |
| 18 | // | |
| 19 | // Usage: | |
| 20 | // node benchmark-ratchet.js # all benchmarks | |
| 21 | // node benchmark-ratchet.js t3 # just T3 | |
| 22 | // node benchmark-ratchet.js r1 # just R1 | |
| 23 | // node benchmark-ratchet.js r3 # just R3 | |
| 24 | ||
| 25 | const fs = require('fs') | |
| 26 | const path = require('path') | |
| 27 | ||
| 28 | const DATA_DIR = path.join(__dirname, 'data') | |
| 29 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') | |
| 30 | ||
| 31 | function loadJSON (file) { | |
| 32 | if (!fs.existsSync(file)) return null | |
| 33 | try { return JSON.parse(fs.readFileSync(file, 'utf8')) } | |
| 34 | catch { return null } | |
| 35 | } | |
| 36 | ||
| 37 | // ─── Shared: Load corrections with timestamps ─── | |
| 38 | ||
| 39 | function loadCorrections () { | |
| 40 | if (!fs.existsSync(CORRECTIONS_LOG)) return [] | |
| 41 | const raw = fs.readFileSync(CORRECTIONS_LOG, 'utf8') | |
| 42 | return raw.split('\n---\n') | |
| 43 | .filter(entry => entry.trim()) | |
| 44 | .map(entry => { | |
| 45 | const lines = entry.trim().split('\n') | |
| 46 | const tsMatch = lines[0]?.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/) | |
| 47 | const typeMatch = lines[0]?.match(/\[(correction|approval)\]/) | |
| 48 | return { | |
| 49 | timestamp: tsMatch?.[1] || null, | |
| 50 | type: typeMatch?.[1] || null, | |
| 51 | text: lines.slice(1).filter(l => !l.startsWith('cwd:') && !l.startsWith('session:')).join(' ').toLowerCase(), | |
| 52 | cwd: lines.find(l => l.startsWith('cwd:'))?.replace('cwd:', '').trim() || '', | |
| 53 | } | |
| 54 | }) | |
| 55 | .filter(c => c.timestamp && c.type) | |
| 56 | } | |
| 57 | ||
| 58 | function weekKey (timestamp) { | |
| 59 | const d = new Date(timestamp) | |
| 60 | d.setDate(d.getDate() - d.getDay()) | |
| 61 | return d.toISOString().slice(0, 10) | |
| 62 | } | |
| 63 | ||
| 64 | // ═══════════════════════════════════════════════════════════ | |
| 65 | // T3: Coverage Map Accuracy | |
| 66 | // ═══════════════════════════════════════════════════════════ | |
| 67 | ||
| 68 | function benchmarkT3 () { | |
| 69 | const coverage = loadJSON(path.join(DATA_DIR, 'coverage-map.json')) | |
| 70 | if (!coverage) { | |
| 71 | console.log(' No coverage-map.json found. Run coverage-map.js first.') | |
| 72 | return null | |
| 73 | } | |
| 74 | ||
| 75 | // Two validations: | |
| 76 | // A) Does failureRisk correlate with correction rate? (should be ~1.0 by design) | |
| 77 | // B) Does failureRisk predict FUTURE corrections? (the real test) | |
| 78 | // Compare failureRisk computed from first-half data against correction rate in second-half. | |
| 79 | ||
| 80 | const points = [] | |
| 81 | for (const [topic, data] of Object.entries(coverage.coverage)) { | |
| 82 | const total = data.corrections + data.approvals | |
| 83 | if (total < 2) continue | |
| 84 | ||
| 85 | const correctionRate = data.corrections / total | |
| 86 | points.push({ | |
| 87 | topic, | |
| 88 | uncertainty: data.uncertainty, | |
| 89 | failureRisk: data.failureRisk ?? 0.5, | |
| 90 | correctionRate, | |
| 91 | observations: data.observations, | |
| 92 | corrections: data.corrections, | |
| 93 | approvals: data.approvals, | |
| 94 | status: data.status, | |
| 95 | }) | |
| 96 | } | |
| 97 | ||
| 98 | if (points.length < 5) { | |
| 99 | console.log(' Not enough topics with data for correlation.') | |
| 100 | return null | |
| 101 | } | |
| 102 | ||
| 103 | // A) Correlation: failureRisk vs correctionRate (sanity check — should be high) | |
| 104 | const riskCorrelation = rankCorrelation( | |
| 105 | points.map(p => p.failureRisk), | |
| 106 | points.map(p => p.correctionRate) | |
| 107 | ) | |
| 108 | ||
| 109 | // Also check old uncertainty formula for comparison | |
| 110 | const uncertaintyCorrelation = rankCorrelation( | |
| 111 | points.map(p => p.uncertainty), | |
| 112 | points.map(p => p.correctionRate) | |
| 113 | ) | |
| 114 | ||
| 115 | // B) Bin by failureRisk and compare correction rates | |
| 116 | const bins = { | |
| 117 | low: points.filter(p => p.failureRisk < 0.3), | |
| 118 | medium: points.filter(p => p.failureRisk >= 0.3 && p.failureRisk < 0.6), | |
| 119 | high: points.filter(p => p.failureRisk >= 0.6), | |
| 120 | } | |
| 121 | ||
| 122 | const binStats = {} | |
| 123 | for (const [bin, pts] of Object.entries(bins)) { | |
| 124 | if (pts.length === 0) continue | |
| 125 | const avgCorrRate = pts.reduce((s, p) => s + p.correctionRate, 0) / pts.length | |
| 126 | const avgRisk = pts.reduce((s, p) => s + p.failureRisk, 0) / pts.length | |
| 127 | binStats[bin] = { | |
| 128 | count: pts.length, | |
| 129 | avgCorrectionRate: Math.round(avgCorrRate * 1000) / 1000, | |
| 130 | avgFailureRisk: Math.round(avgRisk * 1000) / 1000, | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | // ─── Report ─── | |
| 135 | console.log('═'.repeat(60)) | |
| 136 | console.log(' T3: COVERAGE MAP ACCURACY') | |
| 137 | console.log('═'.repeat(60)) | |
| 138 | console.log(` Topics with data: ${points.length}`) | |
| 139 | console.log(` Spearman correlation (failureRisk vs correctionRate): ${riskCorrelation.toFixed(3)}`) | |
| 140 | console.log(` Spearman correlation (old uncertainty vs correctionRate): ${uncertaintyCorrelation.toFixed(3)}`) | |
| 141 | ||
| 142 | if (riskCorrelation > 0.5) { | |
| 143 | console.log(' ✓ PASS — failureRisk correlates with actual correction rate') | |
| 144 | } else if (riskCorrelation > 0.2) { | |
| 145 | console.log(' ~ WEAK — Some correlation but not strong') | |
| 146 | } else { | |
| 147 | console.log(' ✗ FAIL — No meaningful correlation') | |
| 148 | } | |
| 149 | ||
| 150 | const improvement = riskCorrelation - uncertaintyCorrelation | |
| 151 | if (improvement > 0.1) { | |
| 152 | console.log(` ✓ New formula is better than old by ${improvement.toFixed(3)}`) | |
| 153 | } else if (improvement < -0.1) { | |
| 154 | console.log(` ✗ Old formula was actually better by ${(-improvement).toFixed(3)}`) | |
| 155 | } else { | |
| 156 | console.log(` ~ Similar performance (delta ${improvement.toFixed(3)})`) | |
| 157 | } | |
| 158 | ||
| 159 | console.log('\n Correction rate by failure risk level:') | |
| 160 | for (const [bin, stats] of Object.entries(binStats)) { | |
| 161 | const bar = '█'.repeat(Math.round(stats.avgCorrectionRate * 20)) | |
| 162 | console.log(` ${bin.padEnd(8)} (${String(stats.count).padStart(3)} topics): ${bar} ${(stats.avgCorrectionRate * 100).toFixed(1)}% corr rate (avg risk: ${stats.avgFailureRisk.toFixed(3)})`) | |
| 163 | } | |
| 164 | ||
| 165 | // Monotonicity check: do the bins increase? | |
| 166 | const binOrder = ['low', 'medium', 'high'] | |
| 167 | const binRates = binOrder.map(b => binStats[b]?.avgCorrectionRate).filter(v => v !== undefined) | |
| 168 | const isMonotonic = binRates.every((v, i) => i === 0 || v >= binRates[i - 1]) | |
| 169 | console.log(`\n Monotonicity: ${isMonotonic ? '✓ PASS — higher risk = higher correction rate' : '✗ FAIL — bins not monotonically increasing'}`) | |
| 170 | ||
| 171 | // Show extreme topics | |
| 172 | const sorted = [...points].sort((a, b) => b.failureRisk - a.failureRisk) | |
| 173 | console.log('\n Highest failure risk topics:') | |
| 174 | for (const p of sorted.slice(0, 5)) { | |
| 175 | console.log(` ${p.topic.padEnd(35)} risk=${p.failureRisk.toFixed(3)} corr=${(p.correctionRate * 100).toFixed(0)}% (${p.corrections}✗ ${p.approvals}✓)`) | |
| 176 | } | |
| 177 | console.log('\n Lowest failure risk topics:') | |
| 178 | for (const p of sorted.slice(-5)) { | |
| 179 | console.log(` ${p.topic.padEnd(35)} risk=${p.failureRisk.toFixed(3)} corr=${(p.correctionRate * 100).toFixed(0)}% (${p.corrections}✗ ${p.approvals}✓)`) | |
| 180 | } | |
| 181 | ||
| 182 | return { riskCorrelation, uncertaintyCorrelation, improvement, bins: binStats, topicCount: points.length } | |
| 183 | } | |
| 184 | ||
| 185 | // Spearman rank correlation | |
| 186 | function rankCorrelation (xs, ys) { | |
| 187 | const n = xs.length | |
| 188 | if (n < 3) return 0 | |
| 189 | ||
| 190 | const rankX = computeRanks(xs) | |
| 191 | const rankY = computeRanks(ys) | |
| 192 | ||
| 193 | let sumD2 = 0 | |
| 194 | for (let i = 0; i < n; i++) { | |
| 195 | const d = rankX[i] - rankY[i] | |
| 196 | sumD2 += d * d | |
| 197 | } | |
| 198 | ||
| 199 | return 1 - (6 * sumD2) / (n * (n * n - 1)) | |
| 200 | } | |
| 201 | ||
| 202 | function computeRanks (values) { | |
| 203 | const indexed = values.map((v, i) => ({ v, i })) | |
| 204 | indexed.sort((a, b) => a.v - b.v) | |
| 205 | const ranks = new Array(values.length) | |
| 206 | for (let i = 0; i < indexed.length; i++) { | |
| 207 | ranks[indexed[i].i] = i + 1 | |
| 208 | } | |
| 209 | return ranks | |
| 210 | } | |
| 211 | ||
| 212 | // ═══════════════════════════════════════════════════════════ | |
| 213 | // R1: Repeat Correction Rate per Principle per Week | |
| 214 | // ═══════════════════════════════════════════════════════════ | |
| 215 | ||
| 216 | function benchmarkR1 () { | |
| 217 | // R1: Weekly correction DENSITY trend from fused scores | |
| 218 | // Since we can't attribute corrections to specific principles without LLM | |
| 219 | // semantic matching, we track the aggregate correction density per week. | |
| 220 | // A working ratchet means density goes down over time. | |
| 221 | ||
| 222 | const fused = loadJSON(path.join(DATA_DIR, 'fused-scores.json')) | |
| 223 | if (!fused) { | |
| 224 | console.log(' No fused-scores.json found.') | |
| 225 | return null | |
| 226 | } | |
| 227 | ||
| 228 | // Group sessions by week | |
| 229 | const weekly = {} | |
| 230 | for (const session of fused.sessions) { | |
| 231 | if (!session.date) continue | |
| 232 | const week = weekKey(session.date) | |
| 233 | if (!weekly[week]) weekly[week] = { sessions: 0, turns: 0, corrections: 0, approvals: 0, scores: [] } | |
| 234 | weekly[week].sessions++ | |
| 235 | weekly[week].turns += session.turns | |
| 236 | weekly[week].corrections += session.corrections | |
| 237 | weekly[week].approvals += session.approvals | |
| 238 | weekly[week].scores.push(session.score) | |
| 239 | } | |
| 240 | ||
| 241 | const allWeeks = Object.keys(weekly).sort() | |
| 242 | if (allWeeks.length < 2) { | |
| 243 | console.log(' Not enough weeks of data.') | |
| 244 | return null | |
| 245 | } | |
| 246 | ||
| 247 | // ─── Report ─── | |
| 248 | console.log('═'.repeat(60)) | |
| 249 | console.log(' R1: WEEKLY QUALITY TREND (from fused scores)') | |
| 250 | console.log('═'.repeat(60)) | |
| 251 | ||
| 252 | const densities = [] | |
| 253 | const fusedScores = [] | |
| 254 | ||
| 255 | for (const week of allWeeks) { | |
| 256 | const w = weekly[week] | |
| 257 | const density = w.turns > 0 ? w.corrections / w.turns : 0 | |
| 258 | const avgScore = w.scores.length > 0 ? w.scores.reduce((a, b) => a + b, 0) / w.scores.length : 0 | |
| 259 | densities.push(density) | |
| 260 | fusedScores.push(avgScore) | |
| 261 | ||
| 262 | const bar = '█'.repeat(Math.round((1 - density) * 10)) + '░'.repeat(Math.round(density * 10)) | |
| 263 | console.log(` ${week} ${bar} density=${(density * 100).toFixed(1)}% score=${avgScore.toFixed(3)} (${w.sessions} sess, ${w.turns} turns, ${w.corrections}✗ ${w.approvals}✓)`) | |
| 264 | } | |
| 265 | ||
| 266 | // Trend analysis | |
| 267 | let trend = 'stable' | |
| 268 | let anyRatcheted = false | |
| 269 | let anySlipped = false | |
| 270 | ||
| 271 | if (densities.length >= 4) { | |
| 272 | const half = Math.floor(densities.length / 2) | |
| 273 | const firstDensity = densities.slice(0, half).reduce((a, b) => a + b, 0) / half | |
| 274 | const secondDensity = densities.slice(half).reduce((a, b) => a + b, 0) / (densities.length - half) | |
| 275 | const firstScore = fusedScores.slice(0, half).reduce((a, b) => a + b, 0) / half | |
| 276 | const secondScore = fusedScores.slice(half).reduce((a, b) => a + b, 0) / (fusedScores.length - half) | |
| 277 | ||
| 278 | console.log('\n TREND') | |
| 279 | console.log(' ' + '-'.repeat(56)) | |
| 280 | console.log(` First half: density=${(firstDensity * 100).toFixed(1)}% score=${firstScore.toFixed(3)}`) | |
| 281 | console.log(` Second half: density=${(secondDensity * 100).toFixed(1)}% score=${secondScore.toFixed(3)}`) | |
| 282 | ||
| 283 | const densityDelta = secondDensity - firstDensity | |
| 284 | const scoreDelta = secondScore - firstScore | |
| 285 | ||
| 286 | console.log(` Density delta: ${densityDelta >= 0 ? '+' : ''}${(densityDelta * 100).toFixed(1)}pp`) | |
| 287 | console.log(` Score delta: ${scoreDelta >= 0 ? '+' : ''}${scoreDelta.toFixed(3)}`) | |
| 288 | ||
| 289 | if (densityDelta < -2 && scoreDelta > 0.02) { | |
| 290 | trend = 'ratcheted ↓' | |
| 291 | anyRatcheted = true | |
| 292 | console.log(' ✓ RATCHETING — correction density down, fused score up') | |
| 293 | } else if (densityDelta > 2 && scoreDelta < -0.02) { | |
| 294 | trend = 'SLIPPED ↑' | |
| 295 | anySlipped = true | |
| 296 | console.log(' ✗ SLIPPED — correction density up, fused score down') | |
| 297 | } else { | |
| 298 | console.log(' ~ STABLE') | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | console.log('\n SUMMARY') | |
| 303 | console.log(' ' + '-'.repeat(56)) | |
| 304 | if (anyRatcheted) console.log(' ✓ Quality is ratcheting — fewer corrections, higher scores') | |
| 305 | if (anySlipped) console.log(' ✗ Quality slipped — more corrections, lower scores') | |
| 306 | if (!anyRatcheted && !anySlipped) console.log(' ~ No significant trend detected yet') | |
| 307 | console.log(' Note: per-principle attribution requires LLM semantic matching (future work)') | |
| 308 | ||
| 309 | return { trend, weekCount: allWeeks.length, weekly } | |
| 310 | } | |
| 311 | ||
| 312 | // ═══════════════════════════════════════════════════════════ | |
| 313 | // R3: First-Attempt Success Rate | |
| 314 | // ═══════════════════════════════════════════════════════════ | |
| 315 | ||
| 316 | function benchmarkR3 () { | |
| 317 | const fused = loadJSON(path.join(DATA_DIR, 'fused-scores.json')) | |
| 318 | if (!fused) { | |
| 319 | console.log(' No fused-scores.json found. Run fuse-signals.js first.') | |
| 320 | return null | |
| 321 | } | |
| 322 | ||
| 323 | // Correction DENSITY: corrections per turn, not binary presence. | |
| 324 | // A 200-turn session with 3 corrections (1.5%) is better than | |
| 325 | // a 5-turn session with 2 corrections (40%). | |
| 326 | ||
| 327 | // Per-session density | |
| 328 | const sessionDensities = fused.sessions | |
| 329 | .filter(s => s.turns >= 2) | |
| 330 | .map(s => ({ | |
| 331 | ...s, | |
| 332 | density: s.turns > 0 ? s.corrections / s.turns : 0, | |
| 333 | })) | |
| 334 | ||
| 335 | // Group by week | |
| 336 | const weekly = {} | |
| 337 | for (const s of sessionDensities) { | |
| 338 | if (!s.date) continue | |
| 339 | const week = weekKey(s.date) | |
| 340 | if (!weekly[week]) weekly[week] = { | |
| 341 | sessions: 0, totalTurns: 0, totalCorrections: 0, totalApprovals: 0, | |
| 342 | densities: [], scores: [], clean: 0, | |
| 343 | } | |
| 344 | weekly[week].sessions++ | |
| 345 | weekly[week].totalTurns += s.turns | |
| 346 | weekly[week].totalCorrections += s.corrections | |
| 347 | weekly[week].totalApprovals += s.approvals | |
| 348 | weekly[week].densities.push(s.density) | |
| 349 | weekly[week].scores.push(s.score) | |
| 350 | if (s.corrections === 0) weekly[week].clean++ | |
| 351 | } | |
| 352 | ||
| 353 | const weeks = Object.entries(weekly).sort() | |
| 354 | ||
| 355 | // Overall stats | |
| 356 | const totalTurns = sessionDensities.reduce((s, d) => s + d.turns, 0) | |
| 357 | const totalCorr = sessionDensities.reduce((s, d) => s + d.corrections, 0) | |
| 358 | const overallDensity = totalTurns > 0 ? totalCorr / totalTurns : 0 | |
| 359 | const cleanCount = sessionDensities.filter(s => s.corrections === 0).length | |
| 360 | ||
| 361 | // ─── Report ─── | |
| 362 | console.log('═'.repeat(60)) | |
| 363 | console.log(' R3: SESSION QUALITY (correction density)') | |
| 364 | console.log('═'.repeat(60)) | |
| 365 | console.log(` Sessions: ${sessionDensities.length} (${totalTurns} total turns)`) | |
| 366 | console.log(` Total corrections: ${totalCorr}`) | |
| 367 | console.log(` Overall density: ${(overallDensity * 100).toFixed(2)}% (corrections per turn)`) | |
| 368 | console.log(` Clean sessions (0 corrections): ${cleanCount} (${(cleanCount / sessionDensities.length * 100).toFixed(0)}%)`) | |
| 369 | ||
| 370 | console.log('\n WEEKLY BREAKDOWN') | |
| 371 | console.log(' ' + '-'.repeat(60)) | |
| 372 | console.log(' week sess turns corr density clean% fused') | |
| 373 | console.log(' ' + '-'.repeat(60)) | |
| 374 | ||
| 375 | const densities = [] | |
| 376 | for (const [week, data] of weeks) { | |
| 377 | const weekDensity = data.totalTurns > 0 ? data.totalCorrections / data.totalTurns : 0 | |
| 378 | const cleanRate = data.clean / data.sessions | |
| 379 | const avgScore = data.scores.reduce((a, b) => a + b, 0) / data.scores.length | |
| 380 | densities.push(weekDensity) | |
| 381 | ||
| 382 | // Bar: lower density = better (invert for display) | |
| 383 | const quality = Math.max(0, 1 - weekDensity * 10) // 0% density = full bar, 10% = empty | |
| 384 | const bar = '█'.repeat(Math.round(quality * 10)) + '░'.repeat(10 - Math.round(quality * 10)) | |
| 385 | console.log(` ${week} ${String(data.sessions).padStart(4)} ${String(data.totalTurns).padStart(5)} ${String(data.totalCorrections).padStart(4)} ${bar} ${(weekDensity * 100).toFixed(1)}% ${(cleanRate * 100).toFixed(0).padStart(4)}% ${avgScore.toFixed(3)}`) | |
| 386 | } | |
| 387 | ||
| 388 | // Trend | |
| 389 | if (densities.length >= 4) { | |
| 390 | const half = Math.floor(densities.length / 2) | |
| 391 | const firstAvg = densities.slice(0, half).reduce((a, b) => a + b, 0) / half | |
| 392 | const secondAvg = densities.slice(half).reduce((a, b) => a + b, 0) / (densities.length - half) | |
| 393 | ||
| 394 | console.log('\n TREND (lower density = better)') | |
| 395 | console.log(' ' + '-'.repeat(60)) | |
| 396 | console.log(` First half avg density: ${(firstAvg * 100).toFixed(2)}%`) | |
| 397 | console.log(` Second half avg density: ${(secondAvg * 100).toFixed(2)}%`) | |
| 398 | const delta = secondAvg - firstAvg | |
| 399 | console.log(` Delta: ${delta >= 0 ? '+' : ''}${(delta * 100).toFixed(2)}pp`) | |
| 400 | ||
| 401 | if (delta < -1) { | |
| 402 | console.log(' ✓ IMPROVING — Fewer corrections per turn over time') | |
| 403 | } else if (delta > 1) { | |
| 404 | console.log(' ✗ DECLINING — More corrections per turn over time') | |
| 405 | } else { | |
| 406 | console.log(' ~ STABLE') | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 410 | // By session length — density normalizes this, so both should be comparable | |
| 411 | const shortSessions = sessionDensities.filter(s => s.turns <= 10) | |
| 412 | const longSessions = sessionDensities.filter(s => s.turns > 10) | |
| 413 | if (shortSessions.length > 0 && longSessions.length > 0) { | |
| 414 | const shortDensity = shortSessions.reduce((s, d) => s + d.density, 0) / shortSessions.length | |
| 415 | const longDensity = longSessions.reduce((s, d) => s + d.density, 0) / longSessions.length | |
| 416 | console.log('\n BY SESSION LENGTH (density should be comparable)') | |
| 417 | console.log(' ' + '-'.repeat(60)) | |
| 418 | console.log(` Short (≤10 turns): ${(shortDensity * 100).toFixed(2)}% density (${shortSessions.length} sessions)`) | |
| 419 | console.log(` Long (>10 turns): ${(longDensity * 100).toFixed(2)}% density (${longSessions.length} sessions)`) | |
| 420 | if (Math.abs(shortDensity - longDensity) < 2) { | |
| 421 | console.log(' ✓ Density comparable — metric normalizes for session length') | |
| 422 | } else { | |
| 423 | console.log(` ~ ${shortDensity < longDensity ? 'Short' : 'Long'} sessions have lower density`) | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | return { overallDensity, cleanRate: cleanCount / sessionDensities.length, weekCount: weeks.length } | |
| 428 | } | |
| 429 | ||
| 430 | // ═══════════════════════════════════════════════════════════ | |
| 431 | // R2: Novel vs Repeat Corrections | |
| 432 | // ═══════════════════════════════════════════════════════════ | |
| 433 | ||
| 434 | function benchmarkR2 () { | |
| 435 | const detail = loadJSON(path.join(DATA_DIR, 'fused-turns-detail.json')) | |
| 436 | if (!detail) { console.log(' No detail data.'); return null } | |
| 437 | ||
| 438 | // Collect all correction texts chronologically | |
| 439 | const allCorrections = [] | |
| 440 | for (const sess of detail.sessions) { | |
| 441 | for (const turn of (sess.turns || [])) { | |
| 442 | if (turn.isCorrection && turn.text) { | |
| 443 | allCorrections.push({ text: turn.text, date: sess.date, session: sess.sessionId }) | |
| 444 | } | |
| 445 | } | |
| 446 | } | |
| 447 | ||
| 448 | if (allCorrections.length < 5) { console.log(' Not enough corrections.'); return null } | |
| 449 | ||
| 450 | // For each correction, check similarity to all PREVIOUS corrections | |
| 451 | // Jaccard similarity on word sets, threshold 0.4 = repeat | |
| 452 | function wordSet (text) { | |
| 453 | return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2)) | |
| 454 | } | |
| 455 | ||
| 456 | function jaccard (a, b) { | |
| 457 | const intersection = [...a].filter(w => b.has(w)).length | |
| 458 | const union = new Set([...a, ...b]).size | |
| 459 | return union > 0 ? intersection / union : 0 | |
| 460 | } | |
| 461 | ||
| 462 | let novel = 0 | |
| 463 | let repeat = 0 | |
| 464 | const novelByWeek = {} | |
| 465 | const repeatByWeek = {} | |
| 466 | const pastSets = [] | |
| 467 | ||
| 468 | for (const corr of allCorrections) { | |
| 469 | const words = wordSet(corr.text) | |
| 470 | const week = weekKey(corr.date || '2026-01-01') | |
| 471 | ||
| 472 | // Check against all previous corrections | |
| 473 | let isRepeat = false | |
| 474 | for (const past of pastSets) { | |
| 475 | if (jaccard(words, past) > 0.4) { | |
| 476 | isRepeat = true | |
| 477 | break | |
| 478 | } | |
| 479 | } | |
| 480 | ||
| 481 | if (isRepeat) { | |
| 482 | repeat++ | |
| 483 | repeatByWeek[week] = (repeatByWeek[week] || 0) + 1 | |
| 484 | } else { | |
| 485 | novel++ | |
| 486 | novelByWeek[week] = (novelByWeek[week] || 0) + 1 | |
| 487 | } | |
| 488 | pastSets.push(words) | |
| 489 | } | |
| 490 | ||
| 491 | const allWeeks = [...new Set([...Object.keys(novelByWeek), ...Object.keys(repeatByWeek)])].sort() | |
| 492 | ||
| 493 | // ─── Report ─── | |
| 494 | console.log('═'.repeat(60)) | |
| 495 | console.log(' R2: NOVEL vs REPEAT CORRECTIONS') | |
| 496 | console.log('═'.repeat(60)) | |
| 497 | console.log(` Total corrections: ${allCorrections.length}`) | |
| 498 | console.log(` Novel: ${novel} (${(novel / allCorrections.length * 100).toFixed(0)}%)`) | |
| 499 | console.log(` Repeat: ${repeat} (${(repeat / allCorrections.length * 100).toFixed(0)}%)`) | |
| 500 | ||
| 501 | const novelRate = novel / allCorrections.length | |
| 502 | if (novelRate > 0.8) { | |
| 503 | console.log(' ✓ RATCHETING — >80% of corrections are novel (old mistakes eliminated)') | |
| 504 | } else if (novelRate > 0.5) { | |
| 505 | console.log(' ~ PARTIAL — Some repeat corrections still occurring') | |
| 506 | } else { | |
| 507 | console.log(' ✗ NOT RATCHETING — >50% of corrections are repeats') | |
| 508 | } | |
| 509 | ||
| 510 | console.log('\n WEEKLY (novel / repeat)') | |
| 511 | console.log(' ' + '-'.repeat(60)) | |
| 512 | for (const week of allWeeks) { | |
| 513 | const n = novelByWeek[week] || 0 | |
| 514 | const r = repeatByWeek[week] || 0 | |
| 515 | const total = n + r | |
| 516 | const pct = total > 0 ? (n / total * 100).toFixed(0) : 'n/a' | |
| 517 | const bar = total > 0 | |
| 518 | ? '█'.repeat(Math.round(n / total * 10)) + '░'.repeat(Math.round(r / total * 10)) | |
| 519 | : '' | |
| 520 | console.log(` ${week} ${bar} novel=${n} repeat=${r} (${pct}% novel)`) | |
| 521 | } | |
| 522 | ||
| 523 | // Trend | |
| 524 | if (allWeeks.length >= 4) { | |
| 525 | const half = Math.floor(allWeeks.length / 2) | |
| 526 | const firstNovel = allWeeks.slice(0, half).reduce((s, w) => s + (novelByWeek[w] || 0), 0) | |
| 527 | const firstRepeat = allWeeks.slice(0, half).reduce((s, w) => s + (repeatByWeek[w] || 0), 0) | |
| 528 | const secondNovel = allWeeks.slice(half).reduce((s, w) => s + (novelByWeek[w] || 0), 0) | |
| 529 | const secondRepeat = allWeeks.slice(half).reduce((s, w) => s + (repeatByWeek[w] || 0), 0) | |
| 530 | const firstRate = (firstNovel + firstRepeat) > 0 ? firstNovel / (firstNovel + firstRepeat) : 0 | |
| 531 | const secondRate = (secondNovel + secondRepeat) > 0 ? secondNovel / (secondNovel + secondRepeat) : 0 | |
| 532 | ||
| 533 | console.log(`\n First half novel rate: ${(firstRate * 100).toFixed(0)}%`) | |
| 534 | console.log(` Second half novel rate: ${(secondRate * 100).toFixed(0)}%`) | |
| 535 | const delta = secondRate - firstRate | |
| 536 | console.log(` Delta: ${delta >= 0 ? '+' : ''}${(delta * 100).toFixed(0)}pp`) | |
| 537 | } | |
| 538 | ||
| 539 | return { novel, repeat, novelRate, weekCount: allWeeks.length } | |
| 540 | } | |
| 541 | ||
| 542 | // ═══════════════════════════════════════════════════════════ | |
| 543 | // R4: Loop Resolution Speed | |
| 544 | // ═══════════════════════════════════════════════════════════ | |
| 545 | ||
| 546 | function benchmarkR4 () { | |
| 547 | const loops = loadJSON(path.join(DATA_DIR, 'loops.json')) | |
| 548 | if (!loops) { console.log(' No loops data.'); return null } | |
| 549 | ||
| 550 | const resolved = loops.loops.filter(l => l.resolved && l.date) | |
| 551 | if (resolved.length < 5) { console.log(' Not enough resolved loops.'); return null } | |
| 552 | ||
| 553 | // Group by type and week, track avg duration | |
| 554 | const byTypeWeek = {} | |
| 555 | for (const l of resolved) { | |
| 556 | const d = new Date(l.date) | |
| 557 | d.setDate(d.getDate() - d.getDay()) | |
| 558 | const week = d.toISOString().slice(0, 10) | |
| 559 | if (!byTypeWeek[l.type]) byTypeWeek[l.type] = {} | |
| 560 | if (!byTypeWeek[l.type][week]) byTypeWeek[l.type][week] = [] | |
| 561 | byTypeWeek[l.type][week].push(l.duration) | |
| 562 | } | |
| 563 | ||
| 564 | // Overall weekly avg | |
| 565 | const overallWeekly = {} | |
| 566 | for (const l of resolved) { | |
| 567 | const d = new Date(l.date) | |
| 568 | d.setDate(d.getDate() - d.getDay()) | |
| 569 | const week = d.toISOString().slice(0, 10) | |
| 570 | if (!overallWeekly[week]) overallWeekly[week] = [] | |
| 571 | overallWeekly[week].push(l.duration) | |
| 572 | } | |
| 573 | ||
| 574 | const allWeeks = Object.keys(overallWeekly).sort() | |
| 575 | ||
| 576 | console.log('═'.repeat(60)) | |
| 577 | console.log(' R4: LOOP RESOLUTION SPEED') | |
| 578 | console.log('═'.repeat(60)) | |
| 579 | console.log(` Resolved loops: ${resolved.length}`) | |
| 580 | console.log(` Avg resolution: ${(resolved.reduce((s, l) => s + l.duration, 0) / resolved.length).toFixed(1)} turns`) | |
| 581 | ||
| 582 | console.log('\n WEEKLY AVG RESOLUTION (turns to fix)') | |
| 583 | console.log(' ' + '-'.repeat(60)) | |
| 584 | const weeklyAvgs = [] | |
| 585 | for (const week of allWeeks) { | |
| 586 | const durations = overallWeekly[week] | |
| 587 | const avg = durations.reduce((a, b) => a + b, 0) / durations.length | |
| 588 | weeklyAvgs.push(avg) | |
| 589 | const bar = '█'.repeat(Math.min(20, Math.round(avg))) | |
| 590 | console.log(` ${week} ${bar} ${avg.toFixed(1)} turns (${durations.length} loops)`) | |
| 591 | } | |
| 592 | ||
| 593 | // Trend | |
| 594 | if (weeklyAvgs.length >= 4) { | |
| 595 | const half = Math.floor(weeklyAvgs.length / 2) | |
| 596 | const firstAvg = weeklyAvgs.slice(0, half).reduce((a, b) => a + b, 0) / half | |
| 597 | const secondAvg = weeklyAvgs.slice(half).reduce((a, b) => a + b, 0) / (weeklyAvgs.length - half) | |
| 598 | ||
| 599 | console.log(`\n First half avg: ${firstAvg.toFixed(1)} turns`) | |
| 600 | console.log(` Second half avg: ${secondAvg.toFixed(1)} turns`) | |
| 601 | const delta = secondAvg - firstAvg | |
| 602 | console.log(` Delta: ${delta >= 0 ? '+' : ''}${delta.toFixed(1)} turns`) | |
| 603 | ||
| 604 | if (delta < -1) { | |
| 605 | console.log(' ✓ IMPROVING — Loops resolving faster') | |
| 606 | } else if (delta > 1) { | |
| 607 | console.log(' ✗ SLOWER — Loops taking longer to resolve') | |
| 608 | } else { | |
| 609 | console.log(' ~ STABLE') | |
| 610 | } | |
| 611 | } | |
| 612 | ||
| 613 | // By type | |
| 614 | console.log('\n BY TYPE (first half → second half)') | |
| 615 | console.log(' ' + '-'.repeat(60)) | |
| 616 | for (const [type, weeks] of Object.entries(byTypeWeek).sort()) { | |
| 617 | const sorted = Object.entries(weeks).sort() | |
| 618 | const allDurations = sorted.flatMap(([, d]) => d) | |
| 619 | if (allDurations.length < 3) continue | |
| 620 | ||
| 621 | const half = Math.floor(sorted.length / 2) | |
| 622 | const firstDurations = sorted.slice(0, half).flatMap(([, d]) => d) | |
| 623 | const secondDurations = sorted.slice(half).flatMap(([, d]) => d) | |
| 624 | const firstAvg = firstDurations.length > 0 ? firstDurations.reduce((a, b) => a + b, 0) / firstDurations.length : 0 | |
| 625 | const secondAvg = secondDurations.length > 0 ? secondDurations.reduce((a, b) => a + b, 0) / secondDurations.length : 0 | |
| 626 | const delta = secondAvg - firstAvg | |
| 627 | const arrow = delta < -1 ? '↓' : delta > 1 ? '↑' : '=' | |
| 628 | ||
| 629 | console.log(` ${arrow} ${type.padEnd(20)} ${firstAvg.toFixed(1)} → ${secondAvg.toFixed(1)} turns (${delta >= 0 ? '+' : ''}${delta.toFixed(1)})`) | |
| 630 | } | |
| 631 | ||
| 632 | return { avgResolution: resolved.reduce((s, l) => s + l.duration, 0) / resolved.length, weekCount: allWeeks.length } | |
| 633 | } | |
| 634 | ||
| 635 | // ─── Main ─── | |
| 636 | ||
| 637 | const RATCHET_ACTIVE_SINCE = '2026-04-07' // date the full system went live | |
| 638 | ||
| 639 | function main () { | |
| 640 | const target = process.argv[2] | |
| 641 | const results = {} | |
| 642 | ||
| 643 | console.log(`Ratchet active since: ${RATCHET_ACTIVE_SINCE}\n`) | |
| 644 | ||
| 645 | if (!target || target === 't3') { | |
| 646 | results.t3 = benchmarkT3() | |
| 647 | console.log('') | |
| 648 | } | |
| 649 | ||
| 650 | if (!target || target === 'r1') { | |
| 651 | results.r1 = benchmarkR1() | |
| 652 | console.log('') | |
| 653 | } | |
| 654 | ||
| 655 | if (!target || target === 'r2') { | |
| 656 | results.r2 = benchmarkR2() | |
| 657 | console.log('') | |
| 658 | } | |
| 659 | ||
| 660 | if (!target || target === 'r3') { | |
| 661 | results.r3 = benchmarkR3() | |
| 662 | console.log('') | |
| 663 | } | |
| 664 | ||
| 665 | if (!target || target === 'r4') { | |
| 666 | results.r4 = benchmarkR4() | |
| 667 | console.log('') | |
| 668 | } | |
| 669 | ||
| 670 | // Save current results | |
| 671 | const reportFile = path.join(DATA_DIR, 'benchmark-results.json') | |
| 672 | const report = { | |
| 673 | timestamp: new Date().toISOString(), | |
| 674 | ratchetActiveSince: RATCHET_ACTIVE_SINCE, | |
| 675 | ...results, | |
| 676 | } | |
| 677 | fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)) | |
| 678 | ||
| 679 | // Append to history (trend tracking) | |
| 680 | const historyFile = path.join(DATA_DIR, 'benchmark-history.json') | |
| 681 | let history = [] | |
| 682 | if (fs.existsSync(historyFile)) { | |
| 683 | try { history = JSON.parse(fs.readFileSync(historyFile, 'utf8')) } catch {} | |
| 684 | } | |
| 685 | ||
| 686 | const snapshot = { | |
| 687 | date: new Date().toISOString().slice(0, 10), | |
| 688 | timestamp: new Date().toISOString(), | |
| 689 | novelRate: results.r2?.novelRate ?? null, | |
| 690 | repeatRate: results.r2 ? 1 - results.r2.novelRate : null, | |
| 691 | density: results.r3?.overallDensity ?? null, | |
| 692 | avgResolution: results.r4?.avgResolution ?? null, | |
| 693 | totalLoops: results.r4?.weekCount ?? null, | |
| 694 | sessions: results.r3?.weekCount ?? null, | |
| 695 | rulesCount: null, | |
| 696 | } | |
| 697 | ||
| 698 | // Load rules count | |
| 699 | const rulesFile = path.join(DATA_DIR, 'learned-rules.json') | |
| 700 | if (fs.existsSync(rulesFile)) { | |
| 701 | try { snapshot.rulesCount = JSON.parse(fs.readFileSync(rulesFile, 'utf8')).totalRules } catch {} | |
| 702 | } | |
| 703 | ||
| 704 | // Dedup by date — only one entry per day | |
| 705 | history = history.filter(h => h.date !== snapshot.date) | |
| 706 | history.push(snapshot) | |
| 707 | ||
| 708 | // Keep last 365 days | |
| 709 | if (history.length > 365) history = history.slice(-365) | |
| 710 | ||
| 711 | fs.writeFileSync(historyFile, JSON.stringify(history, null, 2)) | |
| 712 | console.log(`Results saved: ${reportFile}`) | |
| 713 | console.log(`History: ${history.length} entries in ${historyFile}`) | |
| 714 | } | |
| 715 | ||
| 716 | main() |
| … | … | @@ -0,0 +1,386 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Coverage Map — Knowledge Ratchet Phase 2 | |
| 4 | // | |
| 5 | // Maps the knowledge space and identifies gaps (unknown unknowns). | |
| 6 | // Topics with zero observations have uncertainty = 1.0 — we can't distinguish | |
| 7 | // knowledge from ignorance below the observation threshold (event horizon). | |
| 8 | // | |
| 9 | // Usage: | |
| 10 | // node coverage-map.js # full analysis | |
| 11 | // node coverage-map.js --summary # just the summary stats | |
| 12 | ||
| 13 | const fs = require('fs') | |
| 14 | const path = require('path') | |
| 15 | ||
| 16 | const DATA_DIR = path.join(__dirname, 'data') | |
| 17 | const OUTPUT_FILE = path.join(DATA_DIR, 'coverage-map.json') | |
| 18 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') | |
| 19 | const FUSED_SCORES_FILE = path.join(DATA_DIR, 'fused-scores.json') | |
| 20 | const SKILLS_DIR = path.join(__dirname, '..', '.claude', 'skills') | |
| 21 | ||
| 22 | // ─── Knowledge Space Definition ─── | |
| 23 | // Three levels of topic taxonomy: | |
| 24 | // Level 1: Project areas (from working directories and known projects) | |
| 25 | // Level 2: Module areas (from skills directory) | |
| 26 | // Level 3: Concept areas (derived from correction/approval text) | |
| 27 | ||
| 28 | // Level 1: Projects — manually defined, maps cwds to topic names | |
| 29 | const PROJECT_TOPICS = { | |
| 30 | 'hypernote': { keywords: ['hypernote', 'note', 'notebook', 'hn'] }, | |
| 31 | 'pear-git': { keywords: ['pear-git', 'pgh', 'git-remote', 'punch://'] }, | |
| 32 | 'dc-terrain': { keywords: ['dc-terrain', 'terrain', 'dual contouring', 'voxel', 'mesh', 'sdf', 'density'] }, | |
| 33 | 'nvidia-rtx': { keywords: ['rtx', 'nvidia', 'ray tracing', 'dlss', 'blas', 'tlas', 'rt pipeline'] }, | |
| 34 | 'blender-bridge': { keywords: ['blender', 'geonode', 'blender-bridge', 'addon'] }, | |
| 35 | 'proc-nodes': { keywords: ['proc-nodes', 'procedural', 'fbm', 'erosion', 'gradient noise'] }, | |
| 36 | 'hyper-tps': { keywords: ['hyper-tps', 'tps', 'multiplayer', 'third person'] }, | |
| 37 | 'hyper-social': { keywords: ['hyper-social', 'social', 'social-web', 'identity'] }, | |
| 38 | 'hyper-booking': { keywords: ['hyper-booking', 'booking', 'calendar'] }, | |
| 39 | 'hyper-overlay': { keywords: ['hyper-overlay', 'overlay', 'content-addressed', 'chunker'] }, | |
| 40 | 'hyper-identity': { keywords: ['hyper-identity', 'identity', 'keypair', 'anproto'] }, | |
| 41 | 'hyper-wallet': { keywords: ['hyper-wallet', 'wallet', 'bitcoin', 'wdk'] }, | |
| 42 | 'wirecore': { keywords: ['wirecore', 'wiredove', 'social network'] }, | |
| 43 | 'hyper-lab': { keywords: ['hyper-lab', 'multi-device', 'test orchestration'] }, | |
| 44 | 'hyperident': { keywords: ['hyperident', 'vpn', 'gateway', 'tun'] }, | |
| 45 | 'nubis-clouds': { keywords: ['nubis', 'clouds', 'volumetric', 'compositor', 'atmosphere'] }, | |
| 46 | 'godot-engine': { keywords: ['godot', 'gdscript', 'gdextension', 'shader'] }, | |
| 47 | 'fleekyflux': { keywords: ['fleekyflux', 'website', '7rnx'] }, | |
| 48 | } | |
| 49 | ||
| 50 | // Level 3: Concept areas — cross-cutting concerns | |
| 51 | const CONCEPT_TOPICS = { | |
| 52 | 'deployment': { keywords: ['deploy', 'stage', 'release', 'seed', 'pear-git', 'ci/cd', 'pear run'] }, | |
| 53 | 'testing': { keywords: ['test', 'brittle', 'assertion', 'spec', 'verify'] }, | |
| 54 | 'storage': { keywords: ['storage', 'rocksdb', 'hyperbee', 'corestore', 'database', 'persist'] }, | |
| 55 | 'auth': { keywords: ['auth', 'identity', 'challenge', 'bearer', 'token', 'login'] }, | |
| 56 | 'replication': { keywords: ['replicate', 'replication', 'hyperswarm', 'peer', 'sync', 'swarm'] }, | |
| 57 | 'multiwriter': { keywords: ['multiwriter', 'autobase', 'writer', 'linearize', 'apply'] }, | |
| 58 | 'ui-frontend': { keywords: ['frontend', 'html', 'css', 'javascript', 'dom', 'ui', 'browser'] }, | |
| 59 | 'cli': { keywords: ['cli', 'command', 'argv', 'terminal', 'prompt'] }, | |
| 60 | 'networking': { keywords: ['network', 'tcp', 'udp', 'dht', 'nat', 'hole-punch'] }, | |
| 61 | 'crypto': { keywords: ['crypto', 'encrypt', 'sodium', 'noise', 'keypair', 'sign'] }, | |
| 62 | 'streaming': { keywords: ['stream', 'hls', 'video', 'media', 'ffmpeg'] }, | |
| 63 | 'git-operations': { keywords: ['git', 'commit', 'branch', 'merge', 'push', 'pull'] }, | |
| 64 | 'module-system': { keywords: ['module', 'require', 'import', 'esm', 'bare-pack', 'bundle'] }, | |
| 65 | 'performance': { keywords: ['performance', 'optimize', 'benchmark', 'profile', 'cache', 'speed'] }, | |
| 66 | 'debugging': { keywords: ['debug', 'error', 'crash', 'fix', 'bug', 'issue', 'broken'] }, | |
| 67 | 'gpu-compute': { keywords: ['gpu', 'compute', 'shader', 'glsl', 'vulkan', 'rendering'] }, | |
| 68 | 'tmux-process': { keywords: ['tmux', 'session', 'process', 'systemd', 'service', 'daemon'] }, | |
| 69 | } | |
| 70 | ||
| 71 | // ─── Data Loading ─── | |
| 72 | ||
| 73 | function loadCorrections () { | |
| 74 | if (!fs.existsSync(CORRECTIONS_LOG)) return [] | |
| 75 | const raw = fs.readFileSync(CORRECTIONS_LOG, 'utf8') | |
| 76 | return raw.split('\n---\n') | |
| 77 | .filter(entry => entry.trim()) | |
| 78 | .map(entry => { | |
| 79 | const lines = entry.trim().split('\n') | |
| 80 | const timestampMatch = lines[0]?.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/) | |
| 81 | const typeMatch = lines[0]?.match(/\[(correction|approval)\]/) | |
| 82 | const cwdLine = lines.find(l => l.startsWith('cwd:')) | |
| 83 | const text = lines.filter(l => !l.startsWith('cwd:') && !l.startsWith('session:') && l !== lines[0]) | |
| 84 | .join(' ').toLowerCase() | |
| 85 | ||
| 86 | return { | |
| 87 | timestamp: timestampMatch?.[1] || null, | |
| 88 | type: typeMatch?.[1] || null, | |
| 89 | text, | |
| 90 | cwd: cwdLine?.replace('cwd:', '').trim() || '', | |
| 91 | } | |
| 92 | }) | |
| 93 | .filter(c => c.timestamp && c.type) | |
| 94 | } | |
| 95 | ||
| 96 | function loadFusedScores () { | |
| 97 | if (!fs.existsSync(FUSED_SCORES_FILE)) return null | |
| 98 | try { return JSON.parse(fs.readFileSync(FUSED_SCORES_FILE, 'utf8')) } | |
| 99 | catch { return null } | |
| 100 | } | |
| 101 | ||
| 102 | function loadSkillTopics () { | |
| 103 | // Level 2: Module areas from skills directory | |
| 104 | const topics = {} | |
| 105 | if (!fs.existsSync(SKILLS_DIR)) return topics | |
| 106 | ||
| 107 | for (const dir of fs.readdirSync(SKILLS_DIR)) { | |
| 108 | const skillDir = path.join(SKILLS_DIR, dir) | |
| 109 | try { | |
| 110 | if (!fs.statSync(skillDir).isDirectory()) continue | |
| 111 | } catch { | |
| 112 | continue // broken symlink or inaccessible | |
| 113 | } | |
| 114 | ||
| 115 | // Use directory name as topic | |
| 116 | // Only use the full hyphenated name as keyword — splitting creates | |
| 117 | // false matches (e.g. "bits-to-bytes" → "to" matches everything) | |
| 118 | const name = dir | |
| 119 | const keywords = [name] | |
| 120 | ||
| 121 | topics[`module:${name}`] = { keywords, source: 'skill' } | |
| 122 | } | |
| 123 | ||
| 124 | return topics | |
| 125 | } | |
| 126 | ||
| 127 | // ─── Topic Matching ─── | |
| 128 | ||
| 129 | function matchTopics (text, topics) { | |
| 130 | const matched = [] | |
| 131 | const lowerText = text.toLowerCase() | |
| 132 | ||
| 133 | for (const [topicName, topicDef] of Object.entries(topics)) { | |
| 134 | for (const keyword of topicDef.keywords) { | |
| 135 | if (lowerText.includes(keyword.toLowerCase())) { | |
| 136 | matched.push(topicName) | |
| 137 | break | |
| 138 | } | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | return matched | |
| 143 | } | |
| 144 | ||
| 145 | function matchTopicFromCwd (cwd, topics) { | |
| 146 | const matched = [] | |
| 147 | const lowerCwd = cwd.toLowerCase() | |
| 148 | ||
| 149 | for (const [topicName, topicDef] of Object.entries(topics)) { | |
| 150 | for (const keyword of topicDef.keywords) { | |
| 151 | if (lowerCwd.includes(keyword.toLowerCase())) { | |
| 152 | matched.push(topicName) | |
| 153 | break | |
| 154 | } | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | return matched | |
| 159 | } | |
| 160 | ||
| 161 | // ─── Main Analysis ─── | |
| 162 | ||
| 163 | function analyze () { | |
| 164 | const fusedData = loadFusedScores() | |
| 165 | if (!fusedData) { | |
| 166 | console.error('No fused-scores.json found. Run fuse-signals.js first.') | |
| 167 | process.exit(1) | |
| 168 | } | |
| 169 | ||
| 170 | // Build complete topic registry | |
| 171 | const allTopics = {} | |
| 172 | ||
| 173 | // Level 1: Projects | |
| 174 | for (const [name, def] of Object.entries(PROJECT_TOPICS)) { | |
| 175 | allTopics[`project:${name}`] = { ...def, level: 'project' } | |
| 176 | } | |
| 177 | ||
| 178 | // Level 2: Modules (from skills) | |
| 179 | const moduleTopics = loadSkillTopics() | |
| 180 | for (const [name, def] of Object.entries(moduleTopics)) { | |
| 181 | allTopics[name] = { ...def, level: 'module' } | |
| 182 | } | |
| 183 | ||
| 184 | // Level 3: Concepts | |
| 185 | for (const [name, def] of Object.entries(CONCEPT_TOPICS)) { | |
| 186 | allTopics[`concept:${name}`] = { ...def, level: 'concept' } | |
| 187 | } | |
| 188 | ||
| 189 | // Initialize coverage for all topics | |
| 190 | const coverage = {} | |
| 191 | for (const topicName of Object.keys(allTopics)) { | |
| 192 | coverage[topicName] = { | |
| 193 | observations: 0, | |
| 194 | corrections: 0, | |
| 195 | approvals: 0, | |
| 196 | uncertainty: 1.0, | |
| 197 | status: 'unobserved', | |
| 198 | lastSeen: null, | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | // PRIMARY DATA SOURCE: fused-scores.json (LLM-weighted, per-session) | |
| 203 | // This replaces corrections.log which was polluted with 4700+ noisy regex entries. | |
| 204 | // Each session has cwd for topic attribution + LLM-weighted correction/approval counts. | |
| 205 | if (fusedData?.sessions) { | |
| 206 | for (const session of fusedData.sessions) { | |
| 207 | const cwdMatches = session.cwd ? matchTopicFromCwd(session.cwd, allTopics) : [] | |
| 208 | for (const topic of cwdMatches) { | |
| 209 | if (!coverage[topic]) continue | |
| 210 | coverage[topic].observations += session.turns | |
| 211 | coverage[topic].corrections += session.corrections | |
| 212 | coverage[topic].approvals += session.approvals | |
| 213 | if (session.date && (!coverage[topic].lastSeen || session.date > coverage[topic].lastSeen)) { | |
| 214 | coverage[topic].lastSeen = session.date | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | // Compute failure risk and status for each topic | |
| 221 | // Two metrics: | |
| 222 | // uncertainty — how confident are we in our assessment? (data volume) | |
| 223 | // failureRisk — how likely is this topic to produce corrections? (quality signal) | |
| 224 | // | |
| 225 | // The old formula (1/(1+obs)) only measured volume. The new formula combines: | |
| 226 | // 1. Correction rate (corrections / total signals) — raw quality | |
| 227 | // 2. Recency decay — recent corrections weigh more (problems may be fixed) | |
| 228 | // 3. Data confidence — low observation count = high uncertainty | |
| 229 | const now = new Date() | |
| 230 | ||
| 231 | for (const [topicName, data] of Object.entries(coverage)) { | |
| 232 | const total = data.corrections + data.approvals | |
| 233 | ||
| 234 | // Data confidence: 1/(1+observations) — how much data do we have? | |
| 235 | data.uncertainty = Math.round((1 / (1 + data.observations)) * 1000) / 1000 | |
| 236 | ||
| 237 | if (data.observations === 0) { | |
| 238 | data.status = 'unobserved' | |
| 239 | data.failureRisk = null // can't compute — event horizon | |
| 240 | } else if (total === 0) { | |
| 241 | data.status = 'observed' | |
| 242 | data.failureRisk = 0.5 // seen but no quality signal | |
| 243 | } else { | |
| 244 | // Correction rate | |
| 245 | const correctionRate = data.corrections / total | |
| 246 | ||
| 247 | // Recency factor: if lastSeen is recent, data is fresher | |
| 248 | // Decay: halve the weight of data older than 14 days | |
| 249 | let recencyWeight = 1.0 | |
| 250 | if (data.lastSeen) { | |
| 251 | const daysSince = (now - new Date(data.lastSeen)) / 86400000 | |
| 252 | recencyWeight = daysSince <= 7 ? 1.0 | |
| 253 | : daysSince <= 14 ? 0.8 | |
| 254 | : daysSince <= 30 ? 0.5 | |
| 255 | : 0.3 | |
| 256 | } | |
| 257 | ||
| 258 | // Failure risk = correction rate, boosted by recency and dampened by data volume | |
| 259 | // More data = more confident in the rate | |
| 260 | // Recent data = rate is more relevant | |
| 261 | const dataConfidence = Math.min(1.0, total / 20) // saturates at 20 signals | |
| 262 | data.failureRisk = Math.round(correctionRate * recencyWeight * dataConfidence * 1000) / 1000 | |
| 263 | ||
| 264 | // Status classification | |
| 265 | const approvalRate = data.approvals / total | |
| 266 | if (approvalRate >= 0.7) data.status = 'positive' | |
| 267 | else if (approvalRate >= 0.4) data.status = 'mixed' | |
| 268 | else data.status = 'negative' | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | return { allTopics, coverage } | |
| 273 | } | |
| 274 | ||
| 275 | // ─── Main ─── | |
| 276 | ||
| 277 | function main () { | |
| 278 | if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) | |
| 279 | ||
| 280 | const { allTopics, coverage } = analyze() | |
| 281 | const summaryOnly = process.argv.includes('--summary') | |
| 282 | ||
| 283 | // Compute aggregate stats | |
| 284 | const topicCount = Object.keys(coverage).length | |
| 285 | const observed = Object.values(coverage).filter(c => c.status !== 'unobserved').length | |
| 286 | const unobserved = topicCount - observed | |
| 287 | const positive = Object.values(coverage).filter(c => c.status === 'positive').length | |
| 288 | const negative = Object.values(coverage).filter(c => c.status === 'negative').length | |
| 289 | const mixed = Object.values(coverage).filter(c => c.status === 'mixed').length | |
| 290 | ||
| 291 | const unknownScore = unobserved / topicCount | |
| 292 | const avgUncertainty = Object.values(coverage).reduce((s, c) => s + c.uncertainty, 0) / topicCount | |
| 293 | ||
| 294 | // Group by level | |
| 295 | const byLevel = { project: [], module: [], concept: [] } | |
| 296 | for (const [name, data] of Object.entries(coverage)) { | |
| 297 | const level = allTopics[name]?.level || 'module' | |
| 298 | byLevel[level].push({ name, ...data }) | |
| 299 | } | |
| 300 | ||
| 301 | // ─── Output ─── | |
| 302 | const report = { | |
| 303 | generated: new Date().toISOString(), | |
| 304 | summary: { | |
| 305 | totalTopics: topicCount, | |
| 306 | observed, | |
| 307 | unobserved, | |
| 308 | positive, | |
| 309 | negative, | |
| 310 | mixed, | |
| 311 | unknownScore: Math.round(unknownScore * 1000) / 1000, | |
| 312 | avgUncertainty: Math.round(avgUncertainty * 1000) / 1000, | |
| 313 | }, | |
| 314 | byLevel: { | |
| 315 | project: byLevel.project.length, | |
| 316 | module: byLevel.module.length, | |
| 317 | concept: byLevel.concept.length, | |
| 318 | }, | |
| 319 | coverage, | |
| 320 | } | |
| 321 | ||
| 322 | fs.writeFileSync(OUTPUT_FILE, JSON.stringify(report, null, 2)) | |
| 323 | ||
| 324 | // ─── Console Report ─── | |
| 325 | console.log(`${'═'.repeat(60)}`) | |
| 326 | console.log(' KNOWLEDGE COVERAGE MAP') | |
| 327 | console.log('═'.repeat(60)) | |
| 328 | console.log(` Total topics: ${topicCount}`) | |
| 329 | console.log(` Observed: ${observed} (${(observed/topicCount*100).toFixed(0)}%)`) | |
| 330 | console.log(` Unobserved: ${unobserved} (${(unobserved/topicCount*100).toFixed(0)}%) ← event horizon`) | |
| 331 | console.log(` Unknown score: ${unknownScore.toFixed(3)} (lower = better coverage)`) | |
| 332 | console.log(` Avg uncertainty: ${avgUncertainty.toFixed(3)}`) | |
| 333 | console.log('') | |
| 334 | console.log(` Status breakdown:`) | |
| 335 | console.log(` positive: ${positive} (high approval rate)`) | |
| 336 | console.log(` mixed: ${mixed} (balanced corrections/approvals)`) | |
| 337 | console.log(` negative: ${negative} (high correction rate)`) | |
| 338 | console.log(` unobserved: ${unobserved} (no data — event horizon)`) | |
| 339 | ||
| 340 | if (summaryOnly) { | |
| 341 | console.log(`\nFull report: ${OUTPUT_FILE}`) | |
| 342 | return | |
| 343 | } | |
| 344 | ||
| 345 | // Show topics by status | |
| 346 | for (const level of ['project', 'concept', 'module']) { | |
| 347 | const topics = byLevel[level].sort((a, b) => b.observations - a.observations) | |
| 348 | ||
| 349 | console.log(`\n ${level.toUpperCase()} TOPICS (${topics.length})`) | |
| 350 | console.log(' ' + '-'.repeat(56)) | |
| 351 | ||
| 352 | // Observed topics with data | |
| 353 | const withData = topics.filter(t => t.status !== 'unobserved') | |
| 354 | if (withData.length > 0) { | |
| 355 | for (const t of withData) { | |
| 356 | const icon = t.status === 'positive' ? '✓' | |
| 357 | : t.status === 'negative' ? '✗' | |
| 358 | : t.status === 'mixed' ? '~' | |
| 359 | : '?' | |
| 360 | const shortName = t.name.replace(/^(project|module|concept):/, '') | |
| 361 | console.log(` ${icon} ${shortName.padEnd(25)} obs=${String(t.observations).padStart(4)} ${t.corrections}✗ ${t.approvals}✓ unc=${t.uncertainty.toFixed(3)} last=${t.lastSeen?.slice(0, 10) || '?'}`) | |
| 362 | } | |
| 363 | } | |
| 364 | ||
| 365 | // Unobserved topics (show first 10 for module level, all for others) | |
| 366 | const noData = topics.filter(t => t.status === 'unobserved') | |
| 367 | if (noData.length > 0) { | |
| 368 | const showCount = level === 'module' ? 10 : noData.length | |
| 369 | const shown = noData.slice(0, showCount) | |
| 370 | console.log(` — unobserved (${noData.length} topics, uncertainty=1.0):`) | |
| 371 | const names = shown.map(t => t.name.replace(/^(project|module|concept):/, '')) | |
| 372 | // Print in rows of 4 | |
| 373 | for (let i = 0; i < names.length; i += 4) { | |
| 374 | console.log(` ${names.slice(i, i + 4).join(', ')}`) | |
| 375 | } | |
| 376 | if (noData.length > showCount) { | |
| 377 | console.log(` ... and ${noData.length - showCount} more`) | |
| 378 | } | |
| 379 | } | |
| 380 | } | |
| 381 | ||
| 382 | console.log(`\n${'═'.repeat(60)}`) | |
| 383 | console.log(`Full report: ${OUTPUT_FILE}`) | |
| 384 | } | |
| 385 | ||
| 386 | main() |
| … | … | @@ -0,0 +1,527 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Ratchet Dashboard — HTML Visualization | |
| 4 | // Generates a self-contained HTML dashboard from all ratchet data. | |
| 5 | // | |
| 6 | // Usage: | |
| 7 | // node dashboard.js # generate only | |
| 8 | // node dashboard.js --no-open # same (alias) | |
| 9 | // node dashboard.js --serve # serve on port 9077 | |
| 10 | // node dashboard.js --serve=PORT # serve on custom port | |
| 11 | ||
| 12 | const fs = require('fs') | |
| 13 | const path = require('path') | |
| 14 | const { execSync } = require('child_process') | |
| 15 | ||
| 16 | const DATA_DIR = path.join(__dirname, 'data') | |
| 17 | const OUTPUT_FILE = path.join(DATA_DIR, 'dashboard.html') | |
| 18 | ||
| 19 | function loadJSON (name) { | |
| 20 | const file = path.join(DATA_DIR, name) | |
| 21 | if (!fs.existsSync(file)) return null | |
| 22 | try { return JSON.parse(fs.readFileSync(file, 'utf8')) } | |
| 23 | catch { return null } | |
| 24 | } | |
| 25 | ||
| 26 | function generate () { | |
| 27 | // Live session state | |
| 28 | let liveSession = null | |
| 29 | try { | |
| 30 | if (fs.existsSync('/tmp/claude-session-score.json')) { | |
| 31 | liveSession = JSON.parse(fs.readFileSync('/tmp/claude-session-score.json', 'utf8')) | |
| 32 | } | |
| 33 | } catch {} | |
| 34 | ||
| 35 | const fused = loadJSON('fused-scores.json') | |
| 36 | const loops = loadJSON('loops.json') | |
| 37 | const projectMaps = loadJSON('project-maps.json') | |
| 38 | const benchmarks = loadJSON('benchmark-results.json') | |
| 39 | const benchmarkHistory = loadJSON('benchmark-history.json') || [] | |
| 40 | const weightHistory = loadJSON('weight-history.json') | |
| 41 | const coverage = loadJSON('coverage-map.json') | |
| 42 | const learnedRules = loadJSON('learned-rules.json') | |
| 43 | ||
| 44 | // ─── Prepare chart data ─── | |
| 45 | ||
| 46 | // Weekly trend: density + fused score | |
| 47 | const weeklyData = [] | |
| 48 | if (fused?.weekly) { | |
| 49 | for (const [week, data] of Object.entries(fused.weekly).sort()) { | |
| 50 | const density = data.totalScore != null | |
| 51 | ? null | |
| 52 | : (fused.sessions || []) | |
| 53 | .filter(s => { | |
| 54 | const d = new Date(s.date) | |
| 55 | d.setDate(d.getDate() - d.getDay()) | |
| 56 | return d.toISOString().slice(0, 10) === week | |
| 57 | }) | |
| 58 | .reduce((acc, s) => { | |
| 59 | acc.turns += s.turns | |
| 60 | acc.corr += s.corrections | |
| 61 | return acc | |
| 62 | }, { turns: 0, corr: 0 }) | |
| 63 | ||
| 64 | weeklyData.push({ | |
| 65 | week, | |
| 66 | score: data.avgScore, | |
| 67 | sessions: data.sessions, | |
| 68 | corrections: data.corrections, | |
| 69 | approvals: data.approvals, | |
| 70 | density: density ? (density.turns > 0 ? density.corr / density.turns : 0) : null, | |
| 71 | }) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | // Session scores for scatter plot | |
| 76 | const sessionData = (fused?.sessions || []).map(s => ({ | |
| 77 | date: s.date, | |
| 78 | score: s.score, | |
| 79 | turns: s.turns, | |
| 80 | corrections: s.corrections, | |
| 81 | approvals: s.approvals, | |
| 82 | branch: s.gitBranch, | |
| 83 | })) | |
| 84 | ||
| 85 | // Loops by project | |
| 86 | const loopsByProject = {} | |
| 87 | if (loops?.loops) { | |
| 88 | for (const l of loops.loops) { | |
| 89 | if (!loopsByProject[l.project]) loopsByProject[l.project] = { total: 0, resolved: 0, avgDuration: 0, totalDuration: 0 } | |
| 90 | loopsByProject[l.project].total++ | |
| 91 | if (l.resolved) loopsByProject[l.project].resolved++ | |
| 92 | loopsByProject[l.project].totalDuration += l.duration | |
| 93 | } | |
| 94 | for (const v of Object.values(loopsByProject)) { | |
| 95 | v.avgDuration = v.total > 0 ? (v.totalDuration / v.total) : 0 | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | // Loops over time (weekly) | |
| 100 | const loopsWeekly = {} | |
| 101 | if (loops?.loops) { | |
| 102 | for (const l of loops.loops) { | |
| 103 | if (!l.date) continue | |
| 104 | const d = new Date(l.date) | |
| 105 | d.setDate(d.getDate() - d.getDay()) | |
| 106 | const week = d.toISOString().slice(0, 10) | |
| 107 | if (!loopsWeekly[week]) loopsWeekly[week] = { total: 0, resolved: 0 } | |
| 108 | loopsWeekly[week].total++ | |
| 109 | if (l.resolved) loopsWeekly[week].resolved++ | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | // Project subsystem danger map | |
| 114 | const dangerMap = {} | |
| 115 | if (projectMaps?.projects) { | |
| 116 | for (const [proj, data] of Object.entries(projectMaps.projects)) { | |
| 117 | dangerMap[proj] = Object.entries(data.subsystems || {}) | |
| 118 | .filter(([, s]) => s.loopCount > 0) | |
| 119 | .map(([name, s]) => ({ name, files: s.fileCount, loops: s.loopCount, danger: s.dangerLevel })) | |
| 120 | .sort((a, b) => b.loops - a.loops) | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | // Sensor weight evolution | |
| 125 | const weightData = (weightHistory || []).map(h => ({ | |
| 126 | date: h.timestamp?.slice(0, 10), | |
| 127 | weights: h.weights, | |
| 128 | changes: h.changes?.length || 0, | |
| 129 | })) | |
| 130 | ||
| 131 | // Coverage summary | |
| 132 | const coverageSummary = coverage?.summary || {} | |
| 133 | ||
| 134 | // ─── Pre-compute ratchet status cards ─── | |
| 135 | const latest = benchmarkHistory.length > 0 ? benchmarkHistory[benchmarkHistory.length - 1] : null | |
| 136 | const prev = benchmarkHistory.length > 1 ? benchmarkHistory[benchmarkHistory.length - 2] : null | |
| 137 | ||
| 138 | const novelRate = latest?.novelRate != null ? (latest.novelRate * 100).toFixed(0) : '—' | |
| 139 | const densityPct = latest?.density != null ? (latest.density * 100).toFixed(1) : '—' | |
| 140 | const resolution = latest?.avgResolution != null ? latest.avgResolution.toFixed(1) : '—' | |
| 141 | const rulesCount = latest?.rulesCount ?? learnedRules?.totalRules ?? 0 | |
| 142 | ||
| 143 | function ratchetArrow (delta, invertGood) { | |
| 144 | if (delta == null) return '' | |
| 145 | const good = invertGood ? delta < 0 : delta > 0 | |
| 146 | const bad = invertGood ? delta > 0 : delta < 0 | |
| 147 | if (good) return '<span class="green"> ▲</span>' | |
| 148 | if (bad) return '<span class="red"> ▼</span>' | |
| 149 | return '<span class="dim"> ▬</span>' | |
| 150 | } | |
| 151 | function ratchetColor (val, goodThresh, warnThresh, invert) { | |
| 152 | if (val === '—') return '' | |
| 153 | const v = parseFloat(val) | |
| 154 | if (invert) return v < goodThresh ? 'green' : v < warnThresh ? 'yellow' : 'red' | |
| 155 | return v > goodThresh ? 'green' : v > warnThresh ? 'yellow' : 'red' | |
| 156 | } | |
| 157 | ||
| 158 | const novelDelta = prev?.novelRate != null && latest?.novelRate != null ? latest.novelRate - prev.novelRate : null | |
| 159 | const densityDelta = prev?.density != null && latest?.density != null ? latest.density - prev.density : null | |
| 160 | const resDelta = prev?.avgResolution != null && latest?.avgResolution != null ? latest.avgResolution - prev.avgResolution : null | |
| 161 | ||
| 162 | const novelColor = ratchetColor(novelRate, 90, 80, false) | |
| 163 | const densityColor = ratchetColor(densityPct, 15, 25, true) | |
| 164 | const resColor = ratchetColor(resolution, 6, 10, true) | |
| 165 | const novelArrow = ratchetArrow(novelDelta, false) | |
| 166 | const densityArrow = ratchetArrow(densityDelta, true) | |
| 167 | const resArrow = ratchetArrow(resDelta, true) | |
| 168 | ||
| 169 | // Live session | |
| 170 | const liveScore = liveSession?.score?.toFixed(2) || '—' | |
| 171 | const liveColor = liveSession?.score > 0.7 ? 'green' : liveSession?.score > 0.5 ? 'yellow' : 'red' | |
| 172 | const liveBorder = liveSession?._loopActive ? '#da3633' : liveSession?.score > 0.7 ? '#238636' : '#9e6a03' | |
| 173 | const liveLabel = liveSession?._loopActive ? '⟳ LIVE — LOOP ACTIVE' : '● LIVE SESSION' | |
| 174 | const liveLabelColor = liveSession?._loopActive ? '#f85149' : '#3fb950' | |
| 175 | const liveTraj = liveSession?.trajectory === 'improving' ? '▲ improving' : liveSession?.trajectory === 'declining' ? '▼ declining' : '▬ stable' | |
| 176 | const liveTrajColor = liveSession?.trajectory === 'improving' ? 'green' : liveSession?.trajectory === 'declining' ? 'red' : 'dim' | |
| 177 | ||
| 178 | // ─── Generate HTML ─── | |
| 179 | const html = `<!DOCTYPE html> | |
| 180 | <html lang="en"> | |
| 181 | <head> | |
| 182 | <meta charset="utf-8"> | |
| 183 | <title>Knowledge Ratchet Dashboard</title> | |
| 184 | <style> | |
| 185 | * { box-sizing: border-box; margin: 0; padding: 0; } | |
| 186 | body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: #0d1117; color: #c9d1d9; padding: 20px; } | |
| 187 | h1 { color: #58a6ff; margin-bottom: 8px; font-size: 24px; } | |
| 188 | h2 { color: #8b949e; font-size: 14px; margin-bottom: 20px; } | |
| 189 | h3 { color: #58a6ff; font-size: 16px; margin: 20px 0 10px; border-bottom: 1px solid #21262d; padding-bottom: 5px; } | |
| 190 | .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 20px; } | |
| 191 | .card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; } | |
| 192 | .card-title { color: #8b949e; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; } | |
| 193 | .big-number { font-size: 36px; font-weight: bold; } | |
| 194 | .green { color: #3fb950; } | |
| 195 | .red { color: #f85149; } | |
| 196 | .yellow { color: #d29922; } | |
| 197 | .blue { color: #58a6ff; } | |
| 198 | .dim { color: #484f58; } | |
| 199 | svg { width: 100%; } | |
| 200 | .chart-container { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; margin-bottom: 16px; } | |
| 201 | table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 202 | th { text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #21262d; font-weight: normal; } | |
| 203 | td { padding: 6px 8px; border-bottom: 1px solid #21262d; } | |
| 204 | .bar { display: inline-block; height: 14px; border-radius: 2px; } | |
| 205 | .bar-green { background: #238636; } | |
| 206 | .bar-red { background: #da3633; } | |
| 207 | .bar-yellow { background: #9e6a03; } | |
| 208 | .danger-high { color: #f85149; font-weight: bold; } | |
| 209 | .danger-medium { color: #d29922; } | |
| 210 | .danger-low { color: #3fb950; } | |
| 211 | .tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; margin: 1px; } | |
| 212 | .tag-high { background: #da363322; color: #f85149; border: 1px solid #da363355; } | |
| 213 | .tag-medium { background: #9e6a0322; color: #d29922; border: 1px solid #9e6a0355; } | |
| 214 | .tag-low { background: #23863622; color: #3fb950; border: 1px solid #23863655; } | |
| 215 | .timestamp { color: #484f58; font-size: 11px; margin-top: 20px; } | |
| 216 | </style> | |
| 217 | </head> | |
| 218 | <body> | |
| 219 | <h1>Knowledge Ratchet</h1> | |
| 220 | <h2>Intelligence quality tracking — generated ${new Date().toISOString().slice(0, 19)}</h2> | |
| 221 | ||
| 222 | ${liveSession && liveSession.turns > 0 ? ` | |
| 223 | <div class="card" style="border-color: ${liveBorder}; margin-bottom: 16px;"> | |
| 224 | <div class="card-title" style="color: ${liveLabelColor}">${liveLabel}</div> | |
| 225 | <div style="display: flex; gap: 32px; align-items: baseline;"> | |
| 226 | <div><span class="big-number ${liveColor}">${liveScore}</span><span class="dim"> score</span></div> | |
| 227 | <div><span class="green" style="font-size:20px">${liveSession.approvals} approved</span> <span class="red" style="font-size:20px">${liveSession.corrections} corrected</span><span class="dim"> in ${liveSession.turns} turns</span></div> | |
| 228 | <div><span class="dim">${liveSession.toolCalls} tools</span></div> | |
| 229 | <div><span class="${liveTrajColor}">${liveTraj}</span></div> | |
| 230 | </div> | |
| 231 | </div> | |
| 232 | ` : ''} | |
| 233 | ||
| 234 | <!-- Ratchet Status — Is Claude getting smarter? --> | |
| 235 | <div class="grid"> | |
| 236 | <div class="card"> | |
| 237 | <div class="card-title">Novel Corrections (target: >90%)</div> | |
| 238 | <div class="big-number ${novelColor}">${novelRate}%${novelArrow}</div> | |
| 239 | <div class="dim">old mistakes eliminated — higher is better</div> | |
| 240 | </div> | |
| 241 | <div class="card"> | |
| 242 | <div class="card-title">Correction Density (target: <15%)</div> | |
| 243 | <div class="big-number ${densityColor}">${densityPct}%${densityArrow}</div> | |
| 244 | <div class="dim">corrections per turn — lower is better</div> | |
| 245 | </div> | |
| 246 | <div class="card"> | |
| 247 | <div class="card-title">Loop Resolution (target: <6 turns)</div> | |
| 248 | <div class="big-number ${resColor}">${resolution}${resArrow}</div> | |
| 249 | <div class="dim">turns to fix a loop — lower is better</div> | |
| 250 | </div> | |
| 251 | <div class="card"> | |
| 252 | <div class="card-title">Learned Rules</div> | |
| 253 | <div class="big-number blue">${rulesCount}</div> | |
| 254 | <div class="dim">${sessionData.length} sessions, ${loops?.totalLoops || 0} loops, ${loops?.resolved || 0} resolved</div> | |
| 255 | </div> | |
| 256 | </div> | |
| 257 | ||
| 258 | <!-- Weekly Fused Score Trend --> | |
| 259 | <div class="chart-container"> | |
| 260 | <div class="card-title">Weekly Fused Score Trend</div> | |
| 261 | ${renderLineChart(weeklyData.map(w => w.score), weeklyData.map(w => w.week), { height: 120, color: '#58a6ff', label: 'score' })} | |
| 262 | </div> | |
| 263 | ||
| 264 | <!-- Weekly Correction Density --> | |
| 265 | <div class="chart-container"> | |
| 266 | <div class="card-title">Weekly Corrections vs Approvals</div> | |
| 267 | ${renderBarChart(weeklyData, { height: 120 })} | |
| 268 | </div> | |
| 269 | ||
| 270 | <!-- Loops Over Time --> | |
| 271 | <div class="chart-container"> | |
| 272 | <div class="card-title">Loops Per Week</div> | |
| 273 | ${renderLineChart( | |
| 274 | Object.entries(loopsWeekly).sort().map(([, v]) => v.total), | |
| 275 | Object.entries(loopsWeekly).sort().map(([k]) => k), | |
| 276 | { height: 100, color: '#f85149', label: 'loops' } | |
| 277 | )} | |
| 278 | </div> | |
| 279 | ||
| 280 | <!-- Session Scatter --> | |
| 281 | <div class="chart-container"> | |
| 282 | <div class="card-title">Session Scores (each dot = one session, size = turns)</div> | |
| 283 | ${renderScatter(sessionData, { height: 150 })} | |
| 284 | </div> | |
| 285 | ||
| 286 | <!-- Loops by Project --> | |
| 287 | <h3>Loops by Project</h3> | |
| 288 | <table> | |
| 289 | <tr><th>Project</th><th>Loops</th><th>Resolved</th><th>Avg Duration</th><th></th></tr> | |
| 290 | ${Object.entries(loopsByProject).sort((a, b) => b[1].total - a[1].total).map(([proj, data]) => { | |
| 291 | const pct = data.total > 0 ? (data.resolved / data.total * 100) : 0 | |
| 292 | return `<tr> | |
| 293 | <td>${proj}</td> | |
| 294 | <td>${data.total}</td> | |
| 295 | <td>${data.resolved} (${pct.toFixed(0)}%)</td> | |
| 296 | <td>${data.avgDuration.toFixed(1)} turns</td> | |
| 297 | <td><span class="bar bar-red" style="width:${Math.min(data.total, 100)}px"></span></td> | |
| 298 | </tr>` | |
| 299 | }).join('\n ')} | |
| 300 | </table> | |
| 301 | ||
| 302 | <!-- Subsystem Danger Map --> | |
| 303 | <h3>Subsystem Danger Map</h3> | |
| 304 | ${Object.entries(dangerMap).map(([proj, subs]) => { | |
| 305 | if (subs.length === 0) return '' | |
| 306 | return `<div style="margin-bottom:12px"> | |
| 307 | <strong>${proj}</strong> | |
| 308 | <div>${subs.map(s => | |
| 309 | `<span class="tag tag-${s.danger}">${s.name} (${s.loops}L/${s.files}F)</span>` | |
| 310 | ).join(' ')}</div> | |
| 311 | </div>` | |
| 312 | }).join('\n')} | |
| 313 | ||
| 314 | <!-- Sensor Weights --> | |
| 315 | <h3>Sensor Weight Evolution</h3> | |
| 316 | <table> | |
| 317 | <tr><th>Sensor</th><th>Weight</th><th>Bar</th></tr> | |
| 318 | ${fused?.sensorWeights ? Object.entries(fused.sensorWeights).sort((a, b) => b[1] - a[1]).map(([name, weight]) => { | |
| 319 | const w = Math.round(weight * 100) | |
| 320 | return `<tr> | |
| 321 | <td>${name}</td> | |
| 322 | <td>${weight.toFixed(3)}</td> | |
| 323 | <td><span class="bar bar-green" style="width:${w}px"></span></td> | |
| 324 | </tr>` | |
| 325 | }).join('\n ') : '<tr><td colspan="3" class="dim">No weight data</td></tr>'} | |
| 326 | </table> | |
| 327 | ||
| 328 | <!-- Ratchet Intelligence Trends --> | |
| 329 | <h3>Intelligence Ratchet Trends</h3> | |
| 330 | ${benchmarkHistory.length >= 2 ? ` | |
| 331 | <div class="grid"> | |
| 332 | <div class="card"> | |
| 333 | <div class="card-title">Novel Correction Rate (higher = better)</div> | |
| 334 | ${renderLineChart( | |
| 335 | benchmarkHistory.filter(h => h.novelRate != null).map(h => h.novelRate), | |
| 336 | benchmarkHistory.filter(h => h.novelRate != null).map(h => h.date), | |
| 337 | { height: 100, color: '#3fb950', label: 'novel rate' } | |
| 338 | )} | |
| 339 | </div> | |
| 340 | <div class="card"> | |
| 341 | <div class="card-title">Correction Density (lower = better)</div> | |
| 342 | ${renderLineChart( | |
| 343 | benchmarkHistory.filter(h => h.density != null).map(h => h.density), | |
| 344 | benchmarkHistory.filter(h => h.density != null).map(h => h.date), | |
| 345 | { height: 100, color: '#f85149', label: 'density' } | |
| 346 | )} | |
| 347 | </div> | |
| 348 | <div class="card"> | |
| 349 | <div class="card-title">Loop Resolution Speed (lower = better)</div> | |
| 350 | ${renderLineChart( | |
| 351 | benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.avgResolution), | |
| 352 | benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.date), | |
| 353 | { height: 100, color: '#d29922', label: 'turns' } | |
| 354 | )} | |
| 355 | </div> | |
| 356 | <div class="card"> | |
| 357 | <div class="card-title">Learned Rules (higher = more knowledge)</div> | |
| 358 | ${renderLineChart( | |
| 359 | benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.rulesCount), | |
| 360 | benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.date), | |
| 361 | { height: 100, color: '#58a6ff', label: 'rules' } | |
| 362 | )} | |
| 363 | </div> | |
| 364 | </div> | |
| 365 | ||
| 366 | <div class="card" style="margin-top:16px"> | |
| 367 | <div class="card-title">Ratchet Scorecard — Is it working?</div> | |
| 368 | <table> | |
| 369 | <tr><th>Metric</th><th>Current</th><th>Target</th><th>Status</th></tr> | |
| 370 | <tr> | |
| 371 | <td>Novel correction rate</td> | |
| 372 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate != null ? (benchmarkHistory[benchmarkHistory.length-1].novelRate * 100).toFixed(0) + '%' : 'n/a'}</td> | |
| 373 | <td>>90%</td> | |
| 374 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate > 0.9 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.novelRate > 0.8 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 375 | </tr> | |
| 376 | <tr> | |
| 377 | <td>Correction density</td> | |
| 378 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density != null ? (benchmarkHistory[benchmarkHistory.length-1].density * 100).toFixed(1) + '%' : 'n/a'}</td> | |
| 379 | <td><15%</td> | |
| 380 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density < 0.15 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.density < 0.25 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 381 | </tr> | |
| 382 | <tr> | |
| 383 | <td>Avg loop resolution</td> | |
| 384 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution != null ? benchmarkHistory[benchmarkHistory.length-1].avgResolution.toFixed(1) + ' turns' : 'n/a'}</td> | |
| 385 | <td><6 turns</td> | |
| 386 | <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution < 6 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.avgResolution < 10 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td> | |
| 387 | </tr> | |
| 388 | <tr> | |
| 389 | <td>Learned rules</td> | |
| 390 | <td>${learnedRules?.totalRules || 0}</td> | |
| 391 | <td>growing</td> | |
| 392 | <td><span class="blue">${learnedRules?.totalRules > 0 ? '✓' : '~'}</span></td> | |
| 393 | </tr> | |
| 394 | </table> | |
| 395 | </div> | |
| 396 | ` : '<div class="card"><div class="dim">Not enough benchmark history yet. Run a few sessions to see trends.</div></div>'} | |
| 397 | ||
| 398 | <div class="timestamp"> | |
| 399 | Dashboard generated: ${new Date().toISOString()}<br> | |
| 400 | Ratchet active since: 2026-04-07 | ${benchmarkHistory.length} benchmark snapshots | |
| 401 | </div> | |
| 402 | </body> | |
| 403 | </html>` | |
| 404 | ||
| 405 | fs.writeFileSync(OUTPUT_FILE, html) | |
| 406 | console.log(`Dashboard: ${OUTPUT_FILE}`) | |
| 407 | ||
| 408 | } | |
| 409 | ||
| 410 | function serve (port) { | |
| 411 | const http = require('http') | |
| 412 | generate() // initial generation | |
| 413 | http.createServer((req, res) => { | |
| 414 | try { | |
| 415 | generate() // regenerate on each request — always fresh data | |
| 416 | res.writeHead(200, { 'Content-Type': 'text/html' }) | |
| 417 | res.end(fs.readFileSync(OUTPUT_FILE, 'utf8')) | |
| 418 | } catch (err) { | |
| 419 | res.writeHead(500, { 'Content-Type': 'text/plain' }) | |
| 420 | res.end('Dashboard generation failed: ' + err.message) | |
| 421 | } | |
| 422 | }).listen(port, () => console.log(`Ratchet dashboard: http://localhost:${port}`)) | |
| 423 | } | |
| 424 | ||
| 425 | // ─── CLI ─── | |
| 426 | const serveArg = process.argv.find(a => a.startsWith('--serve')) | |
| 427 | if (serveArg) { | |
| 428 | const port = serveArg.includes('=') ? parseInt(serveArg.split('=')[1]) : 9077 | |
| 429 | serve(port) | |
| 430 | } else { | |
| 431 | generate() | |
| 432 | } | |
| 433 | ||
| 434 | // ─── SVG Chart Renderers ─── | |
| 435 | ||
| 436 | function renderLineChart (values, labels, opts = {}) { | |
| 437 | const { height = 100, color = '#58a6ff', label = '' } = opts | |
| 438 | if (values.length === 0) return '<div class="dim">No data</div>' | |
| 439 | ||
| 440 | const w = 600 | |
| 441 | const h = height | |
| 442 | const pad = 30 | |
| 443 | const min = Math.min(...values) * 0.95 | |
| 444 | const max = Math.max(...values) * 1.05 | |
| 445 | const range = max - min || 1 | |
| 446 | ||
| 447 | const points = values.map((v, i) => { | |
| 448 | const x = pad + (i / Math.max(1, values.length - 1)) * (w - pad * 2) | |
| 449 | const y = h - pad - ((v - min) / range) * (h - pad * 2) | |
| 450 | return `${x},${y}` | |
| 451 | }) | |
| 452 | ||
| 453 | const labelElements = labels.map((l, i) => { | |
| 454 | if (i % Math.max(1, Math.floor(labels.length / 6)) !== 0) return '' | |
| 455 | const x = pad + (i / Math.max(1, values.length - 1)) * (w - pad * 2) | |
| 456 | return `<text x="${x}" y="${h - 5}" fill="#484f58" font-size="10" text-anchor="middle">${l.slice(5)}</text>` | |
| 457 | }).join('') | |
| 458 | ||
| 459 | return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg"> | |
| 460 | <polyline points="${points.join(' ')}" fill="none" stroke="${color}" stroke-width="2"/> | |
| 461 | ${values.map((v, i) => { | |
| 462 | const x = pad + (i / Math.max(1, values.length - 1)) * (w - pad * 2) | |
| 463 | const y = h - pad - ((v - min) / range) * (h - pad * 2) | |
| 464 | return `<circle cx="${x}" cy="${y}" r="3" fill="${color}"/>` | |
| 465 | }).join('')} | |
| 466 | ${labelElements} | |
| 467 | <text x="${pad}" y="15" fill="#8b949e" font-size="11">${label} ${values[values.length - 1]?.toFixed(3) || ''}</text> | |
| 468 | </svg>` | |
| 469 | } | |
| 470 | ||
| 471 | function renderBarChart (weeklyData, opts = {}) { | |
| 472 | const { height = 120 } = opts | |
| 473 | if (weeklyData.length === 0) return '<div class="dim">No data</div>' | |
| 474 | ||
| 475 | const w = 600 | |
| 476 | const h = height | |
| 477 | const pad = 30 | |
| 478 | const barW = Math.max(8, (w - pad * 2) / weeklyData.length / 2 - 2) | |
| 479 | ||
| 480 | const maxVal = Math.max(...weeklyData.map(d => d.corrections + d.approvals), 1) | |
| 481 | ||
| 482 | return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg"> | |
| 483 | ${weeklyData.map((d, i) => { | |
| 484 | const x = pad + (i / Math.max(1, weeklyData.length - 1)) * (w - pad * 2) | |
| 485 | const corrH = (d.corrections / maxVal) * (h - pad * 2) | |
| 486 | const apprH = (d.approvals / maxVal) * (h - pad * 2) | |
| 487 | return ` | |
| 488 | <rect x="${x - barW}" y="${h - pad - corrH}" width="${barW}" height="${corrH}" fill="#da3633" opacity="0.7"/> | |
| 489 | <rect x="${x}" y="${h - pad - apprH}" width="${barW}" height="${apprH}" fill="#238636" opacity="0.7"/> | |
| 490 | <text x="${x}" y="${h - 5}" fill="#484f58" font-size="9" text-anchor="middle">${d.week.slice(5)}</text> | |
| 491 | ` | |
| 492 | }).join('')} | |
| 493 | <text x="${pad}" y="15" fill="#da3633" font-size="11">■ corrections</text> | |
| 494 | <text x="${pad + 100}" y="15" fill="#238636" font-size="11">■ approvals</text> | |
| 495 | </svg>` | |
| 496 | } | |
| 497 | ||
| 498 | function renderScatter (sessions, opts = {}) { | |
| 499 | const { height = 150 } = opts | |
| 500 | if (sessions.length === 0) return '<div class="dim">No data</div>' | |
| 501 | ||
| 502 | const w = 600 | |
| 503 | const h = height | |
| 504 | const pad = 30 | |
| 505 | ||
| 506 | // X axis = date index, Y axis = score | |
| 507 | const sorted = [...sessions].sort((a, b) => (a.date || '').localeCompare(b.date || '')) | |
| 508 | const minScore = 0.4 | |
| 509 | const maxScore = 1.0 | |
| 510 | const range = maxScore - minScore | |
| 511 | ||
| 512 | return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg"> | |
| 513 | ${sorted.map((s, i) => { | |
| 514 | const x = pad + (i / Math.max(1, sorted.length - 1)) * (w - pad * 2) | |
| 515 | const y = h - pad - ((s.score - minScore) / range) * (h - pad * 2) | |
| 516 | const r = Math.min(8, Math.max(2, Math.sqrt(s.turns) * 0.5)) | |
| 517 | const color = s.corrections === 0 ? '#3fb950' : s.score < 0.6 ? '#f85149' : '#d29922' | |
| 518 | return `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6"> | |
| 519 | <title>${s.date} score=${s.score.toFixed(3)} ${s.turns}T ${s.corrections}✗ ${s.approvals}✓</title> | |
| 520 | </circle>` | |
| 521 | }).join('')} | |
| 522 | <line x1="${pad}" y1="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2)}" x2="${w - pad}" y2="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2)}" stroke="#484f58" stroke-dasharray="4"/> | |
| 523 | <text x="${w - pad + 5}" y="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2) + 4}" fill="#484f58" font-size="9">0.7</text> | |
| 524 | </svg>` | |
| 525 | } | |
| 526 | ||
| 527 | // Entry point moved to CLI section above generate()/serve() |
| … | … | @@ -0,0 +1,940 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Sensor Fusion Scorer — Knowledge Ratchet Phase 2 | |
| 4 | // | |
| 5 | // Reads Claude Code JSONL transcripts and extracts 18 sensors per turn, | |
| 6 | // normalizes each to [0,1] (1 = good, 0 = bad), applies weights, | |
| 7 | // and computes a fused quality score per session. | |
| 8 | // | |
| 9 | // Usage: | |
| 10 | // node fuse-signals.js # analyze all sessions | |
| 11 | // node fuse-signals.js --recent=7 # only last 7 days | |
| 12 | // node fuse-signals.js --session=UUID # single session | |
| 13 | // node fuse-signals.js --verbose # print per-turn details | |
| 14 | ||
| 15 | const fs = require('fs') | |
| 16 | const path = require('path') | |
| 17 | const crypto = require('crypto') | |
| 18 | const { scanAll, scanSession, SESSIONS_DIRS } = require('./scan-sessions') | |
| 19 | ||
| 20 | const DATA_DIR = path.join(__dirname, 'data') | |
| 21 | const OUTPUT_FILE = path.join(DATA_DIR, 'fused-scores.json') | |
| 22 | const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json') | |
| 23 | const WEIGHTS_FILE = path.join(DATA_DIR, 'sensor-weights.json') | |
| 24 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') | |
| 25 | ||
| 26 | // ─── Default Sensor Weights ─── | |
| 27 | // Higher weight = more influence on fused score. | |
| 28 | // Negative-polarity sensors (correction, frustration, etc.) are inverted during normalization. | |
| 29 | // These are the initial hand-tuned weights — the meta-ratchet will adjust them later | |
| 30 | // based on which sensors best predict future corrections. | |
| 31 | const DEFAULT_WEIGHTS = { | |
| 32 | correction: 1.0, // Direct correction signal | |
| 33 | approval: 0.8, // Direct approval signal | |
| 34 | toolCallCount: 0.4, // Many tool calls = fumbling | |
| 35 | turnDuration: 0.2, // Long turns = hard problem or fumbling | |
| 36 | interruptions: 0.9, // User interrupted = strong frustration | |
| 37 | userProvidesAnswer: 0.7, // User had to give the answer | |
| 38 | messageLengthTrend: 0.3, // Shortening messages = disengagement | |
| 39 | tokenEfficiency: 0.3, // Output tokens vs useful text | |
| 40 | editSurvival: 0.5, // Edits that stuck vs reverted | |
| 41 | buildErrors: 0.6, // Errors in tool results | |
| 42 | permissionShift: 0.4, // Trust level changes | |
| 43 | cacheHitRate: 0.1, // Context reuse (informational) | |
| 44 | thinkingRatio: 0.2, // High thinking vs output = confusion | |
| 45 | sidechainUsage: 0.3, // Sidechains = main path failed | |
| 46 | queueDepth: 0.2, // Message queuing = user engagement | |
| 47 | timeBetweenTurns: 0.1, // Time between user messages | |
| 48 | } | |
| 49 | ||
| 50 | function loadWeights () { | |
| 51 | if (fs.existsSync(WEIGHTS_FILE)) { | |
| 52 | try { | |
| 53 | const saved = JSON.parse(fs.readFileSync(WEIGHTS_FILE, 'utf8')) | |
| 54 | return { ...DEFAULT_WEIGHTS, ...saved } | |
| 55 | } catch {} | |
| 56 | } | |
| 57 | return { ...DEFAULT_WEIGHTS } | |
| 58 | } | |
| 59 | ||
| 60 | // ─── LLM Classification Index ─── | |
| 61 | // Build a lookup from corrections.log: timestamp prefix → { type, source, confidence } | |
| 62 | // LLM-confirmed signals get full weight; regex-only guesses get discounted. | |
| 63 | ||
| 64 | function loadLLMIndex () { | |
| 65 | const index = {} // key: timestamp prefix (first 20 chars) → { type, source, confidence } | |
| 66 | if (!fs.existsSync(CORRECTIONS_LOG)) return index | |
| 67 | ||
| 68 | const raw = fs.readFileSync(CORRECTIONS_LOG, 'utf8') | |
| 69 | for (const entry of raw.split('\n---\n')) { | |
| 70 | if (!entry.trim()) continue | |
| 71 | const firstLine = entry.trim().split('\n')[0] | |
| 72 | ||
| 73 | const tsMatch = firstLine.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+)/) | |
| 74 | const typeMatch = firstLine.match(/\[(correction|approval)\]/) | |
| 75 | if (!tsMatch || !typeMatch) continue | |
| 76 | ||
| 77 | const key = tsMatch[1].slice(0, 20) // e.g. "2026-03-15T20:51:52." | |
| 78 | const type = typeMatch[1] | |
| 79 | ||
| 80 | let source = 'regex' | |
| 81 | let confidence = 0.5 | |
| 82 | ||
| 83 | const llmMatch = firstLine.match(/\(llm, confidence: ([\d.]+)/) | |
| 84 | const autoMatch = firstLine.match(/\(auto, confidence: ([\d.]+)/) | |
| 85 | ||
| 86 | if (llmMatch) { | |
| 87 | source = 'llm' | |
| 88 | confidence = parseFloat(llmMatch[1]) | |
| 89 | } else if (autoMatch) { | |
| 90 | source = 'auto' | |
| 91 | confidence = parseFloat(autoMatch[1]) | |
| 92 | } | |
| 93 | ||
| 94 | index[key] = { type, source, confidence } | |
| 95 | } | |
| 96 | ||
| 97 | return index | |
| 98 | } | |
| 99 | ||
| 100 | // ─── Correction/Approval Detection ─── | |
| 101 | // Loads tuned patterns from tune-regex.js output if available, | |
| 102 | // falls back to hardcoded defaults otherwise. | |
| 103 | ||
| 104 | const TUNED_PATTERNS_FILE = path.join(DATA_DIR, 'tuned-patterns.json') | |
| 105 | ||
| 106 | function loadTunedPatterns () { | |
| 107 | if (fs.existsSync(TUNED_PATTERNS_FILE)) { | |
| 108 | try { | |
| 109 | const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8')) | |
| 110 | // Merge high + medium tiers into correction patterns | |
| 111 | const corrPatterns = [] | |
| 112 | for (const tier of ['high', 'medium']) { | |
| 113 | for (const p of (tuned.correction?.[tier] || [])) { | |
| 114 | corrPatterns.push(new RegExp(p.pattern, p.flags || 'i')) | |
| 115 | } | |
| 116 | } | |
| 117 | const apprPatterns = [] | |
| 118 | for (const p of (tuned.approval?.high || [])) { | |
| 119 | apprPatterns.push(new RegExp(p.pattern, p.flags || 'i')) | |
| 120 | } | |
| 121 | if (corrPatterns.length > 0) return { correction: corrPatterns, approval: apprPatterns } | |
| 122 | } catch {} | |
| 123 | } | |
| 124 | return null | |
| 125 | } | |
| 126 | ||
| 127 | const _tuned = loadTunedPatterns() | |
| 128 | ||
| 129 | const CORRECTION_PATTERNS = _tuned?.correction || [ | |
| 130 | /\b(no[,.]|wrong|not that|that's not|incorrect|stop doing)\b/i, | |
| 131 | /\b(i said|i meant|i asked for|that's wrong)\b/i, | |
| 132 | /\b(again|already told you|how many times|we discussed this)\b/i, | |
| 133 | /\b(don't ever|never do that|stop|please don't|do not)\b/i, | |
| 134 | /\b(why did you|why are you|that broke|you broke|you just)\b/i, | |
| 135 | /\b(revert|undo|go back|roll back|put it back)\b/i, | |
| 136 | /\b(let me (just |)do it|i'll (just |)do it|forget it|never\s*mind)\b/i, | |
| 137 | ] | |
| 138 | ||
| 139 | const APPROVAL_PATTERNS = _tuned?.approval || [ | |
| 140 | /\b(perfect|exactly|great|lgtm|looks good|nice work|nailed it)\b/i, | |
| 141 | /\b(that works|that's right|correct|good job|well done)\b/i, | |
| 142 | /\b(ship it|merge it|push it|deploy it|commit it)\b/i, | |
| 143 | /\b(thanks|thank you|yes)\b/i, | |
| 144 | ] | |
| 145 | ||
| 146 | const USER_PROVIDES_ANSWER_PATTERNS = [ | |
| 147 | /\bthe (answer|solution|fix|issue|problem) is\b/i, | |
| 148 | /\byou need to\b/i, | |
| 149 | /\bhere'?s (what|how)\b/i, | |
| 150 | /\bjust (do|use|add|remove|change)\b/i, | |
| 151 | /```[\s\S]{20,}```/, // user pasting code blocks | |
| 152 | ] | |
| 153 | ||
| 154 | const SKIP_PATTERNS = [ | |
| 155 | /^!/, | |
| 156 | /^<command-name>/, | |
| 157 | /^<system-reminder>/, | |
| 158 | /^<task-notification>/, | |
| 159 | /^<local-command/, | |
| 160 | /^Base directory for this skill/, | |
| 161 | /^Implement the following plan/, | |
| 162 | /^This session is being continued/, | |
| 163 | /^Service Unavailable/, | |
| 164 | /^fleeky@\w+ /, | |
| 165 | /tool_use_id/, | |
| 166 | /tool_result/, | |
| 167 | /^Godot Engine v/, | |
| 168 | /^Vulkan \d/, | |
| 169 | /^\[Request interrupted/, | |
| 170 | ] | |
| 171 | ||
| 172 | const INTERRUPTION_PATTERN = /^\[Request interrupted/ | |
| 173 | ||
| 174 | // ─── Text Helpers ─── | |
| 175 | ||
| 176 | function isRealUserMessage (text) { | |
| 177 | if (!text || text.length < 3) return false | |
| 178 | if (SKIP_PATTERNS.some(p => p.test(text))) return false | |
| 179 | return true | |
| 180 | } | |
| 181 | ||
| 182 | // ─── Turn Builder ─── | |
| 183 | // A "turn" = one user message + all assistant responses until the next user message. | |
| 184 | // Works with pre-extracted entries from scan-sessions.js. | |
| 185 | ||
| 186 | function buildTurns (entries) { | |
| 187 | const turns = [] | |
| 188 | let currentTurn = null | |
| 189 | ||
| 190 | for (const entry of entries) { | |
| 191 | if (entry.type === 'user') { | |
| 192 | if (entry.toolUseResult || entry.sourceToolAssistantUUID) { | |
| 193 | if (currentTurn) currentTurn.toolResults.push(entry) | |
| 194 | continue | |
| 195 | } | |
| 196 | ||
| 197 | if (!isRealUserMessage(entry.text)) { | |
| 198 | if (currentTurn) currentTurn.metaEntries.push(entry) | |
| 199 | continue | |
| 200 | } | |
| 201 | ||
| 202 | if (currentTurn) turns.push(currentTurn) | |
| 203 | currentTurn = { | |
| 204 | userText: entry.text, | |
| 205 | userTimestamp: entry.timestamp, | |
| 206 | permissionMode: entry.permissionMode, | |
| 207 | cwd: entry.cwd, | |
| 208 | gitBranch: entry.gitBranch, | |
| 209 | isSidechain: entry.isSidechain || false, | |
| 210 | assistantMessages: [], | |
| 211 | toolResults: [], | |
| 212 | systemEntries: [], | |
| 213 | metaEntries: [], | |
| 214 | queueOps: [], | |
| 215 | } | |
| 216 | } else if (entry.type === 'assistant') { | |
| 217 | if (currentTurn) currentTurn.assistantMessages.push(entry) | |
| 218 | } else if (entry.type === 'system') { | |
| 219 | if (currentTurn) currentTurn.systemEntries.push(entry) | |
| 220 | } else if (entry.type === 'queue-operation') { | |
| 221 | if (currentTurn) currentTurn.queueOps.push(entry) | |
| 222 | } | |
| 223 | } | |
| 224 | if (currentTurn) turns.push(currentTurn) | |
| 225 | return turns | |
| 226 | } | |
| 227 | ||
| 228 | // ─── Sensor Extractors ─── | |
| 229 | // Work with pre-extracted assistant entries from scan-sessions.js. | |
| 230 | ||
| 231 | function extractToolCallCount (turn) { | |
| 232 | return turn.assistantMessages.reduce((sum, msg) => sum + (msg.toolUseCount || 0), 0) | |
| 233 | } | |
| 234 | ||
| 235 | function extractEditFiles (turn) { | |
| 236 | return turn.assistantMessages.flatMap(msg => msg.editFiles || []) | |
| 237 | } | |
| 238 | ||
| 239 | function extractTurnDuration (turn) { | |
| 240 | for (const sys of turn.systemEntries) { | |
| 241 | if (sys.subtype === 'turn_duration' && sys.durationMs) return sys.durationMs | |
| 242 | } | |
| 243 | return null | |
| 244 | } | |
| 245 | ||
| 246 | function extractTokenUsage (turn) { | |
| 247 | let totalOutput = 0, totalCacheRead = 0, totalCacheCreate = 0, totalInput = 0 | |
| 248 | for (const msg of turn.assistantMessages) { | |
| 249 | totalOutput += msg.outputTokens || 0 | |
| 250 | totalCacheRead += msg.cacheRead || 0 | |
| 251 | totalCacheCreate += msg.cacheCreate || 0 | |
| 252 | totalInput += msg.inputTokens || 0 | |
| 253 | } | |
| 254 | return { totalOutput, totalCacheRead, totalCacheCreate, totalInput } | |
| 255 | } | |
| 256 | ||
| 257 | function extractThinkingVsOutput (turn) { | |
| 258 | let thinkingChars = 0, outputChars = 0 | |
| 259 | for (const msg of turn.assistantMessages) { | |
| 260 | thinkingChars += msg.thinkingChars || 0 | |
| 261 | outputChars += msg.textChars || 0 | |
| 262 | } | |
| 263 | return { thinkingChars, outputChars } | |
| 264 | } | |
| 265 | ||
| 266 | function extractToolUseChars (turn) { | |
| 267 | return turn.assistantMessages.reduce((sum, msg) => sum + (msg.toolUseChars || 0), 0) | |
| 268 | } | |
| 269 | ||
| 270 | function extractBuildErrors (turn) { | |
| 271 | let errorCount = 0 | |
| 272 | for (const tr of turn.toolResults) { | |
| 273 | const text = tr.text || '' | |
| 274 | if (/error|Error|ERROR|FAIL|fatal|panic|exception|TypeError|ReferenceError|SyntaxError/i.test(text)) { | |
| 275 | errorCount++ | |
| 276 | } | |
| 277 | } | |
| 278 | return errorCount | |
| 279 | } | |
| 280 | ||
| 281 | // ─── Per-Turn Scoring ─── | |
| 282 | ||
| 283 | function scoreTurn (turn, prevTurn, weights, llmIndex) { | |
| 284 | const sensors = {} | |
| 285 | ||
| 286 | // 1. Correction/Approval — with LLM confidence weighting | |
| 287 | // Three signal quality tiers: | |
| 288 | // LLM-confirmed (confidence 0.7+): full correction/approval weight | |
| 289 | // Regex-detected, no LLM data: discounted (might be false positive) | |
| 290 | // LLM says neutral but regex says correction: trust LLM, treat as neutral | |
| 291 | const text = turn.userText | |
| 292 | const regexCorrection = CORRECTION_PATTERNS.some(p => p.test(text)) | |
| 293 | const regexApproval = APPROVAL_PATTERNS.some(p => p.test(text)) | |
| 294 | ||
| 295 | // Check LLM index for this turn's timestamp | |
| 296 | const tsKey = turn.userTimestamp?.slice(0, 20) | |
| 297 | const llmSignal = tsKey ? llmIndex[tsKey] : null | |
| 298 | ||
| 299 | let isCorrection = false | |
| 300 | let isApproval = false | |
| 301 | let signalConfidence = 0.5 // regex default | |
| 302 | ||
| 303 | if (llmSignal) { | |
| 304 | // LLM has classified this message — trust it over regex | |
| 305 | isCorrection = llmSignal.type === 'correction' && llmSignal.confidence >= 0.5 | |
| 306 | isApproval = llmSignal.type === 'approval' && llmSignal.confidence >= 0.5 | |
| 307 | signalConfidence = llmSignal.confidence | |
| 308 | } else { | |
| 309 | // No LLM data — use regex but at reduced confidence | |
| 310 | isCorrection = regexCorrection | |
| 311 | isApproval = regexApproval | |
| 312 | signalConfidence = 0.5 | |
| 313 | } | |
| 314 | ||
| 315 | // Sensor values scaled by confidence: | |
| 316 | // LLM correction at 0.9 confidence → sensor = 0.0 (strong signal) | |
| 317 | // Regex correction at 0.5 confidence → sensor = 0.35 (weaker signal) | |
| 318 | // No signal → sensor = 0.7 (neutral) | |
| 319 | if (isCorrection) { | |
| 320 | sensors.correction = 0.7 * (1 - signalConfidence) // 0.0 at conf=1.0, 0.35 at conf=0.5 | |
| 321 | } else { | |
| 322 | sensors.correction = 0.7 | |
| 323 | } | |
| 324 | ||
| 325 | if (isApproval) { | |
| 326 | sensors.approval = 0.5 + 0.5 * signalConfidence // 1.0 at conf=1.0, 0.75 at conf=0.5 | |
| 327 | } else { | |
| 328 | sensors.approval = 0.5 | |
| 329 | } | |
| 330 | ||
| 331 | // 2. Tool call count — normalize: 0-3 calls = good, 4-8 = okay, 8+ = fumbling | |
| 332 | const toolCalls = extractToolCallCount(turn) | |
| 333 | sensors.toolCallCount = toolCalls <= 3 ? 1.0 | |
| 334 | : toolCalls <= 8 ? 1.0 - (toolCalls - 3) * 0.1 | |
| 335 | : Math.max(0.1, 1.0 - (toolCalls - 3) * 0.1) | |
| 336 | ||
| 337 | // 3. Turn duration — normalize: <30s = good, 30s-5min = normal, >5min = concerning | |
| 338 | const durationMs = extractTurnDuration(turn) | |
| 339 | if (durationMs !== null) { | |
| 340 | const durationSec = durationMs / 1000 | |
| 341 | sensors.turnDuration = durationSec <= 30 ? 1.0 | |
| 342 | : durationSec <= 300 ? 1.0 - (durationSec - 30) * 0.002 | |
| 343 | : Math.max(0.1, 0.46 - (durationSec - 300) * 0.001) | |
| 344 | } else { | |
| 345 | sensors.turnDuration = 0.7 // no data = assume okay | |
| 346 | } | |
| 347 | ||
| 348 | // 4. Interruptions | |
| 349 | sensors.interruptions = INTERRUPTION_PATTERN.test(text) ? 0.0 : 1.0 | |
| 350 | ||
| 351 | // 5. User provides answer | |
| 352 | const providesAnswer = USER_PROVIDES_ANSWER_PATTERNS.some(p => p.test(text)) | |
| 353 | sensors.userProvidesAnswer = providesAnswer ? 0.2 : 1.0 | |
| 354 | ||
| 355 | // 6. Message length trend (relative to previous turn) | |
| 356 | if (prevTurn && prevTurn.userText.length > 30) { | |
| 357 | const ratio = text.length / prevTurn.userText.length | |
| 358 | // Shortening is potentially bad, but very short approvals are fine | |
| 359 | if (ratio < 0.3 && !isApproval && text.length < 20) { | |
| 360 | sensors.messageLengthTrend = 0.3 // terse response, not approval = disengagement | |
| 361 | } else { | |
| 362 | sensors.messageLengthTrend = Math.min(1.0, 0.5 + ratio * 0.5) | |
| 363 | } | |
| 364 | } else { | |
| 365 | sensors.messageLengthTrend = 0.7 // no comparison = neutral | |
| 366 | } | |
| 367 | ||
| 368 | // 7. Token efficiency — output tokens vs useful content (text + tool calls) | |
| 369 | const usage = extractTokenUsage(turn) | |
| 370 | const { thinkingChars, outputChars } = extractThinkingVsOutput(turn) | |
| 371 | // Tool use blocks are useful output too — estimate their char contribution | |
| 372 | const toolUseChars = extractToolUseChars(turn) | |
| 373 | const totalUsefulChars = outputChars + toolUseChars | |
| 374 | if (usage.totalOutput > 0 && totalUsefulChars > 0) { | |
| 375 | // Roughly 4 chars per token. Compare useful content tokens vs total output tokens | |
| 376 | const expectedTokens = totalUsefulChars / 4 | |
| 377 | const efficiency = Math.min(1.0, expectedTokens / Math.max(1, usage.totalOutput)) | |
| 378 | sensors.tokenEfficiency = Math.max(0.2, efficiency) | |
| 379 | } else { | |
| 380 | sensors.tokenEfficiency = 0.7 // no data | |
| 381 | } | |
| 382 | ||
| 383 | // 8. Edit survival — checked by looking ahead (caller handles this) | |
| 384 | // Default to 1.0, overridden by session-level analysis | |
| 385 | sensors.editSurvival = 1.0 | |
| 386 | ||
| 387 | // 9. Build errors in tool results | |
| 388 | const buildErrors = extractBuildErrors(turn) | |
| 389 | sensors.buildErrors = buildErrors === 0 ? 1.0 | |
| 390 | : buildErrors === 1 ? 0.6 | |
| 391 | : buildErrors <= 3 ? 0.3 | |
| 392 | : 0.1 | |
| 393 | ||
| 394 | // 10. Permission mode shift | |
| 395 | if (prevTurn && prevTurn.permissionMode && turn.permissionMode) { | |
| 396 | const prev = prevTurn.permissionMode | |
| 397 | const curr = turn.permissionMode | |
| 398 | if (prev === 'acceptEdits' && curr === 'default') { | |
| 399 | sensors.permissionShift = 0.1 // trust revoked = very bad signal | |
| 400 | } else if (prev === 'default' && curr === 'acceptEdits') { | |
| 401 | sensors.permissionShift = 1.0 // trust granted | |
| 402 | } else { | |
| 403 | sensors.permissionShift = 0.7 // no change | |
| 404 | } | |
| 405 | } else { | |
| 406 | sensors.permissionShift = 0.7 | |
| 407 | } | |
| 408 | ||
| 409 | // 11. Cache hit rate | |
| 410 | const totalCache = usage.totalCacheRead + usage.totalCacheCreate | |
| 411 | if (totalCache > 0) { | |
| 412 | sensors.cacheHitRate = usage.totalCacheRead / totalCache | |
| 413 | } else { | |
| 414 | sensors.cacheHitRate = 0.5 // no cache data | |
| 415 | } | |
| 416 | ||
| 417 | // 12. Thinking ratio — high thinking relative to output = confusion | |
| 418 | if (thinkingChars > 0 && outputChars > 0) { | |
| 419 | const ratio = thinkingChars / (thinkingChars + outputChars) | |
| 420 | // Some thinking is normal (0.3-0.5). Very high (>0.8) = confusion | |
| 421 | sensors.thinkingRatio = ratio <= 0.5 ? 1.0 | |
| 422 | : ratio <= 0.8 ? 1.0 - (ratio - 0.5) * 2 | |
| 423 | : Math.max(0.1, 0.4 - (ratio - 0.8) * 2) | |
| 424 | } else { | |
| 425 | sensors.thinkingRatio = 0.7 | |
| 426 | } | |
| 427 | ||
| 428 | // 13. Sidechain usage | |
| 429 | sensors.sidechainUsage = turn.isSidechain ? 0.3 : 1.0 | |
| 430 | ||
| 431 | // 14. Queue depth (enqueue = user typing while Claude works = engagement OR frustration) | |
| 432 | const enqueues = turn.queueOps.filter(q => q.operation === 'enqueue').length | |
| 433 | sensors.queueDepth = enqueues === 0 ? 0.7 | |
| 434 | : enqueues === 1 ? 0.8 // single queue = engaged | |
| 435 | : 0.5 // multiple queues = frustrated, typing faster than Claude works | |
| 436 | ||
| 437 | // 15. Time between turns | |
| 438 | if (prevTurn && prevTurn.userTimestamp && turn.userTimestamp) { | |
| 439 | const deltaSec = (new Date(turn.userTimestamp) - new Date(prevTurn.userTimestamp)) / 1000 | |
| 440 | // Very fast response after long Claude turn could mean skipped output | |
| 441 | // Very long gap could mean user left | |
| 442 | sensors.timeBetweenTurns = deltaSec <= 5 ? 0.9 // quick = engaged | |
| 443 | : deltaSec <= 60 ? 1.0 // normal | |
| 444 | : deltaSec <= 300 ? 0.8 // reading | |
| 445 | : 0.5 // long gap = might have left | |
| 446 | } else { | |
| 447 | sensors.timeBetweenTurns = 0.7 | |
| 448 | } | |
| 449 | ||
| 450 | // ─── Fuse ─── | |
| 451 | let weightedSum = 0 | |
| 452 | let totalWeight = 0 | |
| 453 | ||
| 454 | for (const [sensor, value] of Object.entries(sensors)) { | |
| 455 | const weight = weights[sensor] || 0 | |
| 456 | weightedSum += value * weight | |
| 457 | totalWeight += weight | |
| 458 | } | |
| 459 | ||
| 460 | const fusedScore = totalWeight > 0 ? weightedSum / totalWeight : 0.5 | |
| 461 | ||
| 462 | return { | |
| 463 | score: Math.round(fusedScore * 1000) / 1000, | |
| 464 | sensors, | |
| 465 | toolCalls, | |
| 466 | editFiles: extractEditFiles(turn), | |
| 467 | isCorrection, | |
| 468 | isApproval, | |
| 469 | textSnippet: text.slice(0, 150), | |
| 470 | } | |
| 471 | } | |
| 472 | ||
| 473 | // ─── Edit Survival Analysis ─── | |
| 474 | // Look ahead: if an Edit to file F in turn N is followed by another Edit to F in turn N+1 or N+2, | |
| 475 | // the first edit likely didn't survive (was a failed attempt). | |
| 476 | ||
| 477 | function analyzeEditSurvival (turns, turnScores) { | |
| 478 | for (let i = 0; i < turns.length; i++) { | |
| 479 | const editFiles = extractEditFiles(turns[i]) | |
| 480 | if (editFiles.length === 0) continue | |
| 481 | ||
| 482 | let reverted = 0 | |
| 483 | for (const fp of editFiles) { | |
| 484 | // Check next 2 turns for re-edits to same file | |
| 485 | for (let j = i + 1; j <= Math.min(i + 2, turns.length - 1); j++) { | |
| 486 | const nextEdits = extractEditFiles(turns[j]) | |
| 487 | if (nextEdits.includes(fp)) { | |
| 488 | // Same file edited again — possible revert | |
| 489 | // Only count if the next turn had a correction | |
| 490 | if (turnScores[j] && turnScores[j].isCorrection) { | |
| 491 | reverted++ | |
| 492 | break | |
| 493 | } | |
| 494 | } | |
| 495 | } | |
| 496 | } | |
| 497 | ||
| 498 | const survivalRate = editFiles.length > 0 | |
| 499 | ? 1.0 - (reverted / editFiles.length) | |
| 500 | : 1.0 | |
| 501 | ||
| 502 | if (turnScores[i]) { | |
| 503 | turnScores[i].sensors.editSurvival = Math.max(0.1, survivalRate) | |
| 504 | // Recompute fused score with updated edit survival | |
| 505 | // (skip full recompute — just adjust proportionally) | |
| 506 | } | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | // ─── Session Analysis ─── | |
| 511 | ||
| 512 | function analyzeSession (scannedSession, weights, llmIndex) { | |
| 513 | const { entries, sessionId, sessionDate } = scannedSession | |
| 514 | ||
| 515 | const turns = buildTurns(entries) | |
| 516 | if (turns.length < 2) return null // need at least 2 turns for meaningful analysis | |
| 517 | ||
| 518 | // Score each turn | |
| 519 | const turnScores = [] | |
| 520 | for (let i = 0; i < turns.length; i++) { | |
| 521 | const prevTurn = i > 0 ? turns[i - 1] : null | |
| 522 | const score = scoreTurn(turns[i], prevTurn, weights, llmIndex) | |
| 523 | turnScores.push(score) | |
| 524 | } | |
| 525 | ||
| 526 | // Post-process: edit survival | |
| 527 | analyzeEditSurvival(turns, turnScores) | |
| 528 | ||
| 529 | // Session-level aggregation | |
| 530 | // Two components: | |
| 531 | // 1. Per-turn average (70%) — weighted average of turn scores | |
| 532 | // 2. Session-level signals (30%) — aggregate metrics that don't dilute with turn count | |
| 533 | const perTurnScores = turnScores.map(t => t.score) | |
| 534 | const turnAvg = perTurnScores.reduce((a, b) => a + b, 0) / perTurnScores.length | |
| 535 | ||
| 536 | // Session-level signals (0-1 scale, 1 = good) | |
| 537 | const sessionSignals = {} | |
| 538 | ||
| 539 | // Correction ratio — what fraction of turns had corrections? | |
| 540 | // 0% corrections = 1.0, 5% = 0.75, 10% = 0.5, 20%+ = 0.1 | |
| 541 | const correctionRatio = turnScores.filter(t => t.isCorrection).length / turnScores.length | |
| 542 | sessionSignals.correctionRate = Math.max(0.1, 1.0 - correctionRatio * 5) | |
| 543 | ||
| 544 | // Approval ratio — what fraction had approvals? | |
| 545 | const approvalRatio = turnScores.filter(t => t.isApproval).length / turnScores.length | |
| 546 | sessionSignals.approvalRate = Math.min(1.0, 0.3 + approvalRatio * 3) | |
| 547 | ||
| 548 | // Correction-to-approval ratio — direct quality measure | |
| 549 | const totalC = turnScores.filter(t => t.isCorrection).length | |
| 550 | const totalA = turnScores.filter(t => t.isApproval).length | |
| 551 | if (totalC + totalA > 0) { | |
| 552 | sessionSignals.correctionApprovalRatio = totalA / (totalC + totalA) | |
| 553 | } else { | |
| 554 | sessionSignals.correctionApprovalRatio = 0.5 | |
| 555 | } | |
| 556 | ||
| 557 | // Correction burst — consecutive corrections are much worse than spread out | |
| 558 | let maxCorrectionStreak = 0 | |
| 559 | let streak = 0 | |
| 560 | for (const t of turnScores) { | |
| 561 | if (t.isCorrection) { streak++; maxCorrectionStreak = Math.max(maxCorrectionStreak, streak) } | |
| 562 | else streak = 0 | |
| 563 | } | |
| 564 | sessionSignals.correctionBurst = maxCorrectionStreak <= 1 ? 1.0 | |
| 565 | : maxCorrectionStreak === 2 ? 0.6 | |
| 566 | : maxCorrectionStreak === 3 ? 0.3 | |
| 567 | : 0.1 | |
| 568 | ||
| 569 | // Message length trajectory across session | |
| 570 | const userLengths = turns.map(t => t.userText.length).filter(l => l > 5) | |
| 571 | if (userLengths.length >= 4) { | |
| 572 | const half = Math.floor(userLengths.length / 2) | |
| 573 | const firstAvg = userLengths.slice(0, half).reduce((a, b) => a + b, 0) / half | |
| 574 | const secondAvg = userLengths.slice(half).reduce((a, b) => a + b, 0) / (userLengths.length - half) | |
| 575 | const ratio = firstAvg > 0 ? secondAvg / firstAvg : 1 | |
| 576 | sessionSignals.engagementTrajectory = ratio >= 0.5 ? Math.min(1.0, 0.5 + ratio * 0.3) : 0.3 | |
| 577 | } else { | |
| 578 | sessionSignals.engagementTrajectory = 0.7 | |
| 579 | } | |
| 580 | ||
| 581 | // Fuse: 70% per-turn average + 30% session-level | |
| 582 | const sessionSignalAvg = Object.values(sessionSignals).reduce((a, b) => a + b, 0) / Object.values(sessionSignals).length | |
| 583 | const avgScore = turnAvg * 0.7 + sessionSignalAvg * 0.3 | |
| 584 | ||
| 585 | // Trajectory: compare first half vs second half | |
| 586 | let trajectory = 'stable' | |
| 587 | if (perTurnScores.length >= 4) { | |
| 588 | const half = Math.floor(perTurnScores.length / 2) | |
| 589 | const firstHalf = perTurnScores.slice(0, half).reduce((a, b) => a + b, 0) / half | |
| 590 | const secondHalf = perTurnScores.slice(half).reduce((a, b) => a + b, 0) / (perTurnScores.length - half) | |
| 591 | const delta = secondHalf - firstHalf | |
| 592 | if (delta > 0.1) trajectory = 'improving' | |
| 593 | else if (delta < -0.1) trajectory = 'declining' | |
| 594 | } | |
| 595 | ||
| 596 | // Sensor averages across all turns | |
| 597 | const sensorNames = Object.keys(turnScores[0]?.sensors || {}) | |
| 598 | const sensorAverages = {} | |
| 599 | for (const name of sensorNames) { | |
| 600 | const values = turnScores.map(t => t.sensors[name]).filter(v => v !== undefined) | |
| 601 | sensorAverages[name] = values.length > 0 | |
| 602 | ? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 1000) / 1000 | |
| 603 | : 0.5 | |
| 604 | } | |
| 605 | ||
| 606 | // Count corrections and approvals | |
| 607 | const corrections = turnScores.filter(t => t.isCorrection).length | |
| 608 | const approvals = turnScores.filter(t => t.isApproval).length | |
| 609 | const totalToolCalls = turnScores.reduce((sum, t) => sum + t.toolCalls, 0) | |
| 610 | ||
| 611 | // Git branch and cwd (most common across turns) | |
| 612 | const branches = turns.map(t => t.gitBranch).filter(Boolean) | |
| 613 | const cwds = turns.map(t => t.cwd).filter(Boolean) | |
| 614 | const gitBranch = mode(branches) || null | |
| 615 | const cwd = mode(cwds) || null | |
| 616 | ||
| 617 | return { | |
| 618 | sessionId, | |
| 619 | date: sessionDate?.slice(0, 10), | |
| 620 | timestamp: sessionDate, | |
| 621 | gitBranch, | |
| 622 | cwd, | |
| 623 | turns: turns.length, | |
| 624 | score: Math.round(avgScore * 1000) / 1000, | |
| 625 | corrections, | |
| 626 | approvals, | |
| 627 | totalToolCalls, | |
| 628 | trajectory, | |
| 629 | sessionSignals, | |
| 630 | sensorAverages, | |
| 631 | turnScores, | |
| 632 | } | |
| 633 | } | |
| 634 | ||
| 635 | function mode (arr) { | |
| 636 | if (arr.length === 0) return null | |
| 637 | const counts = {} | |
| 638 | for (const v of arr) counts[v] = (counts[v] || 0) + 1 | |
| 639 | return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0] | |
| 640 | } | |
| 641 | ||
| 642 | // ─── Cross-Session Analysis ─── | |
| 643 | ||
| 644 | function detectSessionRestarts (sessions) { | |
| 645 | // Detect sessions that restart on the same topic (same branch + cwd within 2 hours) | |
| 646 | // This signals abandonment of the previous session | |
| 647 | const sorted = [...sessions].sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')) | |
| 648 | const restarts = [] | |
| 649 | ||
| 650 | for (let i = 1; i < sorted.length; i++) { | |
| 651 | const prev = sorted[i - 1] | |
| 652 | const curr = sorted[i] | |
| 653 | if (!prev.timestamp || !curr.timestamp) continue | |
| 654 | ||
| 655 | const deltaSec = (new Date(curr.timestamp) - new Date(prev.timestamp)) / 1000 | |
| 656 | const sameContext = prev.gitBranch && curr.gitBranch && | |
| 657 | prev.gitBranch === curr.gitBranch && | |
| 658 | prev.cwd === curr.cwd | |
| 659 | ||
| 660 | if (sameContext && deltaSec < 7200 && deltaSec > 60) { | |
| 661 | restarts.push({ | |
| 662 | from: prev.sessionId, | |
| 663 | to: curr.sessionId, | |
| 664 | deltaSec: Math.round(deltaSec), | |
| 665 | branch: curr.gitBranch, | |
| 666 | }) | |
| 667 | // Penalize the abandoned session | |
| 668 | if (prev.score) prev.score = Math.max(0.1, prev.score - 0.1) | |
| 669 | } | |
| 670 | } | |
| 671 | ||
| 672 | return restarts | |
| 673 | } | |
| 674 | ||
| 675 | function computeWeeklyScores (sessions) { | |
| 676 | const weeks = {} | |
| 677 | for (const s of sessions) { | |
| 678 | if (!s.date) continue | |
| 679 | const d = new Date(s.date) | |
| 680 | d.setDate(d.getDate() - d.getDay()) | |
| 681 | const weekKey = d.toISOString().slice(0, 10) | |
| 682 | if (!weeks[weekKey]) { | |
| 683 | weeks[weekKey] = { sessions: 0, totalScore: 0, corrections: 0, approvals: 0, toolCalls: 0 } | |
| 684 | } | |
| 685 | weeks[weekKey].sessions++ | |
| 686 | weeks[weekKey].totalScore += s.score | |
| 687 | weeks[weekKey].corrections += s.corrections | |
| 688 | weeks[weekKey].approvals += s.approvals | |
| 689 | weeks[weekKey].toolCalls += s.totalToolCalls | |
| 690 | } | |
| 691 | for (const w of Object.values(weeks)) { | |
| 692 | w.avgScore = Math.round((w.totalScore / w.sessions) * 1000) / 1000 | |
| 693 | } | |
| 694 | return weeks | |
| 695 | } | |
| 696 | ||
| 697 | // ─── Main ─── | |
| 698 | ||
| 699 | async function main () { | |
| 700 | if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) | |
| 701 | ||
| 702 | const weights = loadWeights() | |
| 703 | const llmIndex = loadLLMIndex() | |
| 704 | console.log(`LLM index: ${Object.keys(llmIndex).length} classified messages`) | |
| 705 | ||
| 706 | // CLI args | |
| 707 | const recentArg = process.argv.find(a => a.startsWith('--recent=')) | |
| 708 | const sessionArg = process.argv.find(a => a.startsWith('--session=')) | |
| 709 | const verbose = process.argv.includes('--verbose') | |
| 710 | const noCache = process.argv.includes('--no-cache') | |
| 711 | ||
| 712 | const recentDays = recentArg ? parseInt(recentArg.split('=')[1]) : null | |
| 713 | const targetSession = sessionArg ? sessionArg.split('=')[1] : null | |
| 714 | ||
| 715 | // Results cache: keyed by filename + mtime + weights hash | |
| 716 | // ~2-5KB per session (scored results) vs ~3MB per session (raw entries) | |
| 717 | const RESULTS_CACHE_DIR = path.join(DATA_DIR, 'results-cache') | |
| 718 | if (!fs.existsSync(RESULTS_CACHE_DIR)) fs.mkdirSync(RESULTS_CACHE_DIR, { recursive: true }) | |
| 719 | ||
| 720 | const weightsHash = crypto.createHash('md5') | |
| 721 | .update(JSON.stringify(weights) + JSON.stringify(Object.keys(llmIndex).length)) | |
| 722 | .digest('hex').slice(0, 8) | |
| 723 | ||
| 724 | // Collect all transcript files | |
| 725 | const allFiles = [] | |
| 726 | for (const dir of SESSIONS_DIRS) { | |
| 727 | if (!fs.existsSync(dir)) continue | |
| 728 | const files = fs.readdirSync(dir) | |
| 729 | .filter(f => f.endsWith('.jsonl') && !f.includes('subagent')) | |
| 730 | .map(f => path.join(dir, f)) | |
| 731 | allFiles.push(...files) | |
| 732 | } | |
| 733 | ||
| 734 | if (targetSession) { | |
| 735 | const match = allFiles.find(f => path.basename(f).includes(targetSession)) | |
| 736 | if (match) { allFiles.length = 0; allFiles.push(match) } | |
| 737 | } | |
| 738 | ||
| 739 | const sessions = [] | |
| 740 | let skipped = 0 | |
| 741 | let cacheHits = 0 | |
| 742 | let cacheMisses = 0 | |
| 743 | ||
| 744 | const cutoffDate = recentDays | |
| 745 | ? new Date(Date.now() - recentDays * 86400000).toISOString() | |
| 746 | : null | |
| 747 | ||
| 748 | for (const file of allFiles) { | |
| 749 | let fileMtime | |
| 750 | try { | |
| 751 | const stat = fs.statSync(file) | |
| 752 | fileMtime = stat.mtimeMs | |
| 753 | if (cutoffDate && stat.mtime.toISOString() < cutoffDate) { skipped++; continue } | |
| 754 | } catch { continue } | |
| 755 | ||
| 756 | // Try results cache | |
| 757 | const cacheFile = path.join(RESULTS_CACHE_DIR, path.basename(file, '.jsonl') + `-${weightsHash}.json`) | |
| 758 | ||
| 759 | if (!noCache && fs.existsSync(cacheFile)) { | |
| 760 | try { | |
| 761 | const cacheStat = fs.statSync(cacheFile) | |
| 762 | if (cacheStat.mtimeMs > fileMtime) { | |
| 763 | // Cache is newer than source — use it | |
| 764 | const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) | |
| 765 | if (cached) { sessions.push(cached); cacheHits++; continue } | |
| 766 | } | |
| 767 | } catch {} | |
| 768 | } | |
| 769 | ||
| 770 | // Cache miss — full scan + analyze | |
| 771 | const scanned = await scanSession(file) | |
| 772 | const result = analyzeSession(scanned, weights, llmIndex) | |
| 773 | if (result) { | |
| 774 | sessions.push(result) | |
| 775 | // Cache if file is older than 60s (not the active session) | |
| 776 | if (Date.now() - fileMtime > 60000) { | |
| 777 | try { fs.writeFileSync(cacheFile, JSON.stringify(result)) } catch {} | |
| 778 | } | |
| 779 | } | |
| 780 | cacheMisses++ | |
| 781 | } | |
| 782 | ||
| 783 | console.log(`Sessions: ${sessions.length} (${cacheHits} cached, ${cacheMisses} scanned, ${skipped} skipped)`) | |
| 784 | ||
| 785 | // Sort by date | |
| 786 | sessions.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')) | |
| 787 | ||
| 788 | // Cross-session analysis | |
| 789 | const restarts = detectSessionRestarts(sessions) | |
| 790 | ||
| 791 | // Weekly aggregation | |
| 792 | const weekly = computeWeeklyScores(sessions) | |
| 793 | ||
| 794 | // ─── Output ─── | |
| 795 | const report = { | |
| 796 | generated: new Date().toISOString(), | |
| 797 | totalSessions: sessions.length, | |
| 798 | skippedFiles: skipped, | |
| 799 | sensorWeights: weights, | |
| 800 | weekly, | |
| 801 | sessionRestarts: restarts, | |
| 802 | sessions: sessions.map(s => ({ | |
| 803 | sessionId: s.sessionId, | |
| 804 | date: s.date, | |
| 805 | gitBranch: s.gitBranch, | |
| 806 | cwd: s.cwd, | |
| 807 | turns: s.turns, | |
| 808 | score: s.score, | |
| 809 | corrections: s.corrections, | |
| 810 | approvals: s.approvals, | |
| 811 | totalToolCalls: s.totalToolCalls, | |
| 812 | trajectory: s.trajectory, | |
| 813 | sensorAverages: s.sensorAverages, | |
| 814 | // Omit per-turn details in the output to keep file size down | |
| 815 | // Use --verbose to see them in console | |
| 816 | })), | |
| 817 | } | |
| 818 | ||
| 819 | fs.writeFileSync(OUTPUT_FILE, JSON.stringify(report, null, 2)) | |
| 820 | ||
| 821 | // Write detailed per-turn sensor data for meta-ratchet weight learning | |
| 822 | const detailData = { | |
| 823 | generated: new Date().toISOString(), | |
| 824 | sensorWeights: weights, | |
| 825 | sessions: sessions.map(s => ({ | |
| 826 | sessionId: s.sessionId, | |
| 827 | date: s.date, | |
| 828 | cwd: s.cwd, | |
| 829 | gitBranch: s.gitBranch, | |
| 830 | turns: (s.turnScores || []).map(t => ({ | |
| 831 | score: t.score, | |
| 832 | isCorrection: t.isCorrection, | |
| 833 | isApproval: t.isApproval, | |
| 834 | text: t.textSnippet?.slice(0, 200), | |
| 835 | toolCalls: t.toolCalls, | |
| 836 | editFiles: t.editFiles, | |
| 837 | sensors: t.sensors, | |
| 838 | })), | |
| 839 | })), | |
| 840 | } | |
| 841 | fs.writeFileSync(DETAIL_FILE, JSON.stringify(detailData)) | |
| 842 | ||
| 843 | console.log(`\nWrote ${OUTPUT_FILE}`) | |
| 844 | console.log(`Wrote ${DETAIL_FILE} (${detailData.sessions.reduce((s, sess) => s + sess.turns.length, 0)} turns)`) | |
| 845 | ||
| 846 | // ─── Console Report ─── | |
| 847 | console.log(`\n${'═'.repeat(60)}`) | |
| 848 | console.log(' SENSOR FUSION SCORECARD') | |
| 849 | console.log('═'.repeat(60)) | |
| 850 | console.log(` Sessions analyzed: ${sessions.length}`) | |
| 851 | if (skipped > 0) console.log(` Skipped (outside date range): ${skipped}`) | |
| 852 | if (restarts.length > 0) console.log(` Session restarts detected: ${restarts.length}`) | |
| 853 | ||
| 854 | // Weekly trend | |
| 855 | console.log('\n WEEKLY TREND') | |
| 856 | console.log(' ' + '-'.repeat(56)) | |
| 857 | for (const [week, data] of Object.entries(weekly).sort()) { | |
| 858 | const bar = scoreBar(data.avgScore) | |
| 859 | console.log(` ${week} ${bar} ${data.avgScore.toFixed(3)} (${data.sessions} sessions, ${data.corrections}✗ ${data.approvals}✓)`) | |
| 860 | } | |
| 861 | ||
| 862 | // Worst sessions | |
| 863 | const worst = [...sessions].sort((a, b) => a.score - b.score).slice(0, 5) | |
| 864 | if (worst.length > 0 && worst[0].score < 0.6) { | |
| 865 | console.log('\n WORST SESSIONS') | |
| 866 | console.log(' ' + '-'.repeat(56)) | |
| 867 | for (const s of worst) { | |
| 868 | console.log(` ${s.date || '?'} [${s.score.toFixed(3)}] ${s.turns} turns, ${s.corrections}✗ ${s.approvals}✓ ${s.gitBranch || ''}`) | |
| 869 | // Show which sensors dragged the score down | |
| 870 | const lowSensors = Object.entries(s.sensorAverages) | |
| 871 | .filter(([, v]) => v < 0.5) | |
| 872 | .sort((a, b) => a[1] - b[1]) | |
| 873 | .slice(0, 3) | |
| 874 | if (lowSensors.length > 0) { | |
| 875 | console.log(` low: ${lowSensors.map(([k, v]) => `${k}=${v.toFixed(2)}`).join(', ')}`) | |
| 876 | } | |
| 877 | } | |
| 878 | } | |
| 879 | ||
| 880 | // Best sessions | |
| 881 | const best = [...sessions].sort((a, b) => b.score - a.score).slice(0, 3) | |
| 882 | if (best.length > 0) { | |
| 883 | console.log('\n BEST SESSIONS') | |
| 884 | console.log(' ' + '-'.repeat(56)) | |
| 885 | for (const s of best) { | |
| 886 | console.log(` ${s.date || '?'} [${s.score.toFixed(3)}] ${s.turns} turns, ${s.corrections}✗ ${s.approvals}✓ ${s.gitBranch || ''}`) | |
| 887 | } | |
| 888 | } | |
| 889 | ||
| 890 | // Sensor health — which sensors are consistently low across sessions? | |
| 891 | if (sessions.length >= 3) { | |
| 892 | const sensorNames = Object.keys(sessions[0]?.sensorAverages || {}) | |
| 893 | const globalSensorAvgs = {} | |
| 894 | for (const name of sensorNames) { | |
| 895 | const values = sessions.map(s => s.sensorAverages[name]).filter(v => v !== undefined) | |
| 896 | globalSensorAvgs[name] = values.reduce((a, b) => a + b, 0) / values.length | |
| 897 | } | |
| 898 | ||
| 899 | const weakSensors = Object.entries(globalSensorAvgs) | |
| 900 | .filter(([, v]) => v < 0.6) | |
| 901 | .sort((a, b) => a[1] - b[1]) | |
| 902 | ||
| 903 | if (weakSensors.length > 0) { | |
| 904 | console.log('\n WEAK SENSORS (avg < 0.6 across all sessions)') | |
| 905 | console.log(' ' + '-'.repeat(56)) | |
| 906 | for (const [name, avg] of weakSensors) { | |
| 907 | console.log(` ${scoreBar(avg)} ${avg.toFixed(3)} ${name}`) | |
| 908 | } | |
| 909 | } | |
| 910 | } | |
| 911 | ||
| 912 | // Verbose: per-turn details for each session | |
| 913 | if (verbose) { | |
| 914 | console.log('\n PER-TURN DETAILS') | |
| 915 | console.log(' ' + '-'.repeat(56)) | |
| 916 | for (const s of sessions) { | |
| 917 | console.log(`\n Session: ${s.sessionId?.slice(0, 8)} (${s.date})`) | |
| 918 | for (let i = 0; i < (s.turnScores || []).length; i++) { | |
| 919 | const t = s.turnScores[i] | |
| 920 | const flags = [] | |
| 921 | if (t.isCorrection) flags.push('✗') | |
| 922 | if (t.isApproval) flags.push('✓') | |
| 923 | console.log(` [${i}] ${t.score.toFixed(3)} ${flags.join('')} tools=${t.toolCalls} "${t.textSnippet?.slice(0, 80)}"`) | |
| 924 | } | |
| 925 | } | |
| 926 | } | |
| 927 | ||
| 928 | console.log(`\n${'═'.repeat(60)}`) | |
| 929 | } | |
| 930 | ||
| 931 | function scoreBar (score) { | |
| 932 | const filled = Math.round(score * 10) | |
| 933 | const empty = 10 - filled | |
| 934 | return '█'.repeat(filled) + '░'.repeat(empty) | |
| 935 | } | |
| 936 | ||
| 937 | main().catch(err => { | |
| 938 | console.error('Fusion analysis failed:', err.message) | |
| 939 | process.exit(1) | |
| 940 | }) |
| … | … | @@ -0,0 +1,131 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Loop Briefing — Knowledge Ratchet Phase 2 | |
| 4 | // | |
| 5 | // Runs on SessionStart. Checks the working directory and loads | |
| 6 | // relevant past loops as a briefing so Claude enters the session | |
| 7 | // aware of past failure patterns. | |
| 8 | // | |
| 9 | // Output goes to stderr so Claude sees it as context. | |
| 10 | ||
| 11 | const fs = require('fs') | |
| 12 | const path = require('path') | |
| 13 | ||
| 14 | const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json') | |
| 15 | const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json') | |
| 16 | ||
| 17 | function classifyProject (cwd) { | |
| 18 | const lower = cwd.toLowerCase() | |
| 19 | if (lower.includes('pear-git')) return 'pear-git' | |
| 20 | if (lower.includes('dc-terrain') || lower.includes('dc_terrain')) return 'dc-terrain' | |
| 21 | if (lower.includes('hyper-tps')) return 'hyper-tps' | |
| 22 | if (lower.includes('hypernote')) return 'hypernote' | |
| 23 | if (lower.includes('blender-bridge') || lower.includes('blender_bridge')) return 'blender-bridge' | |
| 24 | if (lower.includes('proc-nodes')) return 'proc-nodes' | |
| 25 | if (lower.includes('hyper-overlay')) return 'hyper-overlay' | |
| 26 | if (lower.includes('hyper-social')) return 'hyper-social' | |
| 27 | if (lower.includes('nvidia') || lower.includes('rtx')) return 'nvidia-rtx' | |
| 28 | if (lower.includes('nubis') || lower.includes('clouds')) return 'nubis' | |
| 29 | if (lower.includes('godot')) return 'godot' | |
| 30 | return null | |
| 31 | } | |
| 32 | ||
| 33 | function main () { | |
| 34 | const cwd = process.cwd() | |
| 35 | const project = classifyProject(cwd) | |
| 36 | if (!project) return // not a known project — no briefing needed | |
| 37 | ||
| 38 | if (!fs.existsSync(LOOPS_FILE)) return | |
| 39 | ||
| 40 | let loopsData | |
| 41 | try { | |
| 42 | loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8')) | |
| 43 | } catch { return } | |
| 44 | ||
| 45 | const projectLoops = (loopsData.loops || []).filter(l => l.project === project) | |
| 46 | if (projectLoops.length === 0) return | |
| 47 | ||
| 48 | // Recent loops (last 10, sorted by date desc) | |
| 49 | const recent = [...projectLoops] | |
| 50 | .sort((a, b) => (b.date || '').localeCompare(a.date || '')) | |
| 51 | .slice(0, 10) | |
| 52 | ||
| 53 | // Most common failure types | |
| 54 | const byType = {} | |
| 55 | for (const l of projectLoops) { | |
| 56 | byType[l.type] = (byType[l.type] || 0) + 1 | |
| 57 | } | |
| 58 | const topTypes = Object.entries(byType).sort((a, b) => b[1] - a[1]).slice(0, 3) | |
| 59 | ||
| 60 | // Unresolved loops (still-open problems) | |
| 61 | const unresolved = projectLoops.filter(l => !l.resolved) | |
| 62 | ||
| 63 | // Danger files (most often edited during loops) | |
| 64 | const dangerFiles = {} | |
| 65 | for (const l of projectLoops) { | |
| 66 | for (const f of (l.filesTried || [])) { | |
| 67 | const base = path.basename(f) | |
| 68 | dangerFiles[base] = (dangerFiles[base] || 0) + 1 | |
| 69 | } | |
| 70 | } | |
| 71 | const topDangerFiles = Object.entries(dangerFiles).sort((a, b) => b[1] - a[1]).slice(0, 5) | |
| 72 | ||
| 73 | // Build briefing | |
| 74 | const lines = [ | |
| 75 | '', | |
| 76 | `LOOP BRIEFING — ${project} (${projectLoops.length} past loops)`, | |
| 77 | '═'.repeat(50), | |
| 78 | '', | |
| 79 | `Top failure types: ${topTypes.map(([t, c]) => `${t} (${c})`).join(', ')}`, | |
| 80 | `Danger files: ${topDangerFiles.map(([f, c]) => `${f} (${c}x)`).join(', ')}`, | |
| 81 | ] | |
| 82 | ||
| 83 | if (unresolved.length > 0) { | |
| 84 | lines.push(``) | |
| 85 | lines.push(`UNRESOLVED (${unresolved.length}):`) | |
| 86 | for (const l of unresolved.slice(0, 3)) { | |
| 87 | lines.push(` ✗ ${l.date} [${l.type}] "${l.corrections[0]?.slice(0, 80)}"`) | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | lines.push('') | |
| 92 | lines.push('RECENT LOOPS:') | |
| 93 | for (const l of recent.slice(0, 5)) { | |
| 94 | const status = l.resolved ? '✓' : '✗' | |
| 95 | lines.push(` ${status} ${l.date} [${l.type}] ${l.duration} turns, ${l.correctionCount} corr`) | |
| 96 | lines.push(` "${l.corrections[0]?.slice(0, 90)}"`) | |
| 97 | if (l.resolved && l.resolution.files.length > 0) { | |
| 98 | lines.push(` fix: ${l.resolution.files.map(f => path.basename(f)).slice(0, 3).join(', ')}`) | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | // Load learned rules for this project | |
| 103 | if (fs.existsSync(RULES_FILE)) { | |
| 104 | try { | |
| 105 | const rulesData = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8')) | |
| 106 | const projectRules = (rulesData.rules || []) | |
| 107 | .filter(r => r.trigger.project === project) | |
| 108 | .sort((a, b) => b.source.duration - a.source.duration) | |
| 109 | ||
| 110 | if (projectRules.length > 0) { | |
| 111 | lines.push('') | |
| 112 | lines.push(`LEARNED RULES (${projectRules.length} from past loops):`) | |
| 113 | for (const r of projectRules.slice(0, 5)) { | |
| 114 | lines.push(` When: ${r.trigger.symptom}`) | |
| 115 | lines.push(` Do: ${r.action.description.slice(0, 100)}`) | |
| 116 | lines.push(` Don't: ${r.antipattern.description.slice(0, 100)}`) | |
| 117 | } | |
| 118 | } | |
| 119 | } catch {} | |
| 120 | } | |
| 121 | ||
| 122 | lines.push('') | |
| 123 | lines.push('BEFORE EDITING: Read the target file fully. Check loop history.') | |
| 124 | lines.push('If you feel like fumbling, STOP and enter planning mode.') | |
| 125 | lines.push('═'.repeat(50)) | |
| 126 | lines.push('') | |
| 127 | ||
| 128 | process.stderr.write(lines.join('\n')) | |
| 129 | } | |
| 130 | ||
| 131 | main() |
| … | … | @@ -0,0 +1,362 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Loop Detector — Knowledge Ratchet Phase 2 | |
| 4 | // | |
| 5 | // Scans session turn data for correction loops: | |
| 6 | // A loop is: correction → fumble → correction → ... → resolution | |
| 7 | // | |
| 8 | // A loop STARTS when a correction appears. | |
| 9 | // A loop CONTINUES while: more corrections within N turns, high tool calls, low scores. | |
| 10 | // A loop BREAKS when: approval comes, or 3+ clean turns (no corrections, decent score). | |
| 11 | // | |
| 12 | // For each loop, captures: | |
| 13 | // - What went wrong (correction texts) | |
| 14 | // - What was tried (edit files, tool calls) | |
| 15 | // - What fixed it (the resolution — the turns that broke the loop) | |
| 16 | // - Context (project, files, branch) | |
| 17 | // | |
| 18 | // Usage: | |
| 19 | // node loop-detector.js # extract loops, save to data/loops.json | |
| 20 | // node loop-detector.js --verbose # show per-loop detail | |
| 21 | // node loop-detector.js --match "current context" # find similar past loops | |
| 22 | ||
| 23 | const fs = require('fs') | |
| 24 | const path = require('path') | |
| 25 | ||
| 26 | const DATA_DIR = path.join(__dirname, 'data') | |
| 27 | const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json') | |
| 28 | const LOOPS_FILE = path.join(DATA_DIR, 'loops.json') | |
| 29 | ||
| 30 | // ─── Loop Detection Parameters ─── | |
| 31 | const MAX_GAP = 5 // max turns between corrections to still be "same loop" | |
| 32 | const MIN_CORRECTIONS = 2 // need at least 2 corrections to be a loop (1 correction = normal) | |
| 33 | const CLEAN_STREAK = 3 // 3 turns without correction = loop is over | |
| 34 | ||
| 35 | function loadDetail () { | |
| 36 | if (!fs.existsSync(DETAIL_FILE)) { | |
| 37 | console.error('No fused-turns-detail.json. Run fuse-signals.js first.') | |
| 38 | process.exit(1) | |
| 39 | } | |
| 40 | return JSON.parse(fs.readFileSync(DETAIL_FILE, 'utf8')) | |
| 41 | } | |
| 42 | ||
| 43 | // ─── Loop Extraction ─── | |
| 44 | ||
| 45 | function extractLoops (session) { | |
| 46 | const turns = session.turns | |
| 47 | if (turns.length < 3) return [] | |
| 48 | ||
| 49 | const loops = [] | |
| 50 | let currentLoop = null | |
| 51 | let cleanCount = 0 // consecutive non-correction turns | |
| 52 | ||
| 53 | for (let i = 0; i < turns.length; i++) { | |
| 54 | const turn = turns[i] | |
| 55 | ||
| 56 | if (turn.isCorrection) { | |
| 57 | if (!currentLoop) { | |
| 58 | // Start a new loop | |
| 59 | currentLoop = { | |
| 60 | startTurn: i, | |
| 61 | endTurn: i, | |
| 62 | corrections: [], | |
| 63 | allTurns: [], | |
| 64 | editFiles: new Set(), | |
| 65 | totalToolCalls: 0, | |
| 66 | } | |
| 67 | } | |
| 68 | currentLoop.corrections.push({ | |
| 69 | turn: i, | |
| 70 | text: turn.text || '', | |
| 71 | score: turn.score, | |
| 72 | }) | |
| 73 | currentLoop.endTurn = i | |
| 74 | cleanCount = 0 | |
| 75 | } else { | |
| 76 | cleanCount++ | |
| 77 | } | |
| 78 | ||
| 79 | // Add turn to current loop if we're in one | |
| 80 | if (currentLoop) { | |
| 81 | currentLoop.allTurns.push({ | |
| 82 | turn: i, | |
| 83 | score: turn.score, | |
| 84 | isCorrection: turn.isCorrection, | |
| 85 | isApproval: turn.isApproval, | |
| 86 | text: turn.text || '', | |
| 87 | toolCalls: turn.toolCalls || 0, | |
| 88 | editFiles: turn.editFiles || [], | |
| 89 | }) | |
| 90 | currentLoop.totalToolCalls += turn.toolCalls || 0 | |
| 91 | for (const f of (turn.editFiles || [])) currentLoop.editFiles.add(f) | |
| 92 | } | |
| 93 | ||
| 94 | // Check if loop is over | |
| 95 | if (currentLoop && cleanCount >= CLEAN_STREAK) { | |
| 96 | // Loop broke — finalize it | |
| 97 | if (currentLoop.corrections.length >= MIN_CORRECTIONS) { | |
| 98 | loops.push(finalizeLoop(currentLoop, session)) | |
| 99 | } | |
| 100 | currentLoop = null | |
| 101 | cleanCount = 0 | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // Handle loop at end of session (never resolved) | |
| 106 | if (currentLoop && currentLoop.corrections.length >= MIN_CORRECTIONS) { | |
| 107 | const loop = finalizeLoop(currentLoop, session) | |
| 108 | loop.resolved = false | |
| 109 | loops.push(loop) | |
| 110 | } | |
| 111 | ||
| 112 | return loops | |
| 113 | } | |
| 114 | ||
| 115 | function finalizeLoop (raw, session) { | |
| 116 | const lastCorrIdx = raw.corrections[raw.corrections.length - 1].turn | |
| 117 | const turnsAfterLastCorr = raw.allTurns.filter(t => t.turn > lastCorrIdx) | |
| 118 | ||
| 119 | // Resolution: the turns after the last correction that have good scores | |
| 120 | // These contain the fix that broke the loop | |
| 121 | const resolutionTurns = turnsAfterLastCorr.filter(t => !t.isCorrection) | |
| 122 | const resolutionFiles = [...new Set(resolutionTurns.flatMap(t => t.editFiles))] | |
| 123 | const resolutionApproval = resolutionTurns.find(t => t.isApproval) | |
| 124 | ||
| 125 | // Classify the loop by what files were touched | |
| 126 | const allFiles = [...raw.editFiles] | |
| 127 | const project = classifyProject(session.cwd || '', allFiles) | |
| 128 | const loopType = classifyType(raw.corrections) | |
| 129 | ||
| 130 | return { | |
| 131 | sessionId: session.sessionId, | |
| 132 | date: session.date, | |
| 133 | project, | |
| 134 | type: loopType, | |
| 135 | cwd: session.cwd, | |
| 136 | gitBranch: session.gitBranch, | |
| 137 | startTurn: raw.startTurn, | |
| 138 | endTurn: raw.allTurns[raw.allTurns.length - 1]?.turn || raw.endTurn, | |
| 139 | duration: (raw.allTurns[raw.allTurns.length - 1]?.turn || raw.endTurn) - raw.startTurn + 1, | |
| 140 | correctionCount: raw.corrections.length, | |
| 141 | totalToolCalls: raw.totalToolCalls, | |
| 142 | resolved: resolutionApproval ? true : resolutionTurns.length >= CLEAN_STREAK, | |
| 143 | ||
| 144 | // The problem: what corrections said | |
| 145 | corrections: raw.corrections.map(c => c.text.slice(0, 200)), | |
| 146 | ||
| 147 | // What was tried: files edited during the loop | |
| 148 | filesTried: allFiles.slice(0, 20), | |
| 149 | ||
| 150 | // The fix: what broke the loop | |
| 151 | resolution: { | |
| 152 | files: resolutionFiles.slice(0, 10), | |
| 153 | approval: resolutionApproval?.text?.slice(0, 200) || null, | |
| 154 | turnCount: resolutionTurns.length, | |
| 155 | }, | |
| 156 | ||
| 157 | // Fingerprint for matching | |
| 158 | fingerprint: buildFingerprint(project, loopType, allFiles), | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | // ─── Classification ─── | |
| 163 | ||
| 164 | function classifyProject (cwd, files) { | |
| 165 | const all = (cwd + ' ' + files.join(' ')).toLowerCase() | |
| 166 | if (all.includes('dc-terrain') || all.includes('dc_terrain')) return 'dc-terrain' | |
| 167 | if (all.includes('hyper-tps')) return 'hyper-tps' | |
| 168 | if (all.includes('hypernote')) return 'hypernote' | |
| 169 | if (all.includes('pear-git')) return 'pear-git' | |
| 170 | if (all.includes('blender-bridge') || all.includes('blender_bridge')) return 'blender-bridge' | |
| 171 | if (all.includes('proc-nodes') || all.includes('proc_nodes')) return 'proc-nodes' | |
| 172 | if (all.includes('hyper-overlay')) return 'hyper-overlay' | |
| 173 | if (all.includes('hyper-social')) return 'hyper-social' | |
| 174 | if (all.includes('nvidia') || all.includes('rtx')) return 'nvidia-rtx' | |
| 175 | if (all.includes('nubis') || all.includes('clouds')) return 'nubis' | |
| 176 | if (all.includes('godot')) return 'godot' | |
| 177 | return 'unknown' | |
| 178 | } | |
| 179 | ||
| 180 | function classifyType (corrections) { | |
| 181 | const allText = corrections.map(c => c.text).join(' ').toLowerCase() | |
| 182 | ||
| 183 | if (/invisible|not (visible|showing|rendering)|can.t see|don.t see|flat|blank/.test(allText)) return 'not-rendering' | |
| 184 | if (/crash|error|fail|exception|panic/.test(allText)) return 'runtime-error' | |
| 185 | if (/same|still|worse|no change|didn.t (work|help|fix)/.test(allText)) return 'fix-failed' | |
| 186 | if (/wrong|reversed|flipped|incorrect|cube/.test(allText)) return 'wrong-output' | |
| 187 | if (/why (are|is|did)|approach|design|should we/.test(allText)) return 'wrong-approach' | |
| 188 | if (/audit|planning|think deeper|step back/.test(allText)) return 'needs-rethink' | |
| 189 | if (/hmm|hmmm/.test(allText)) return 'quality-doubt' | |
| 190 | return 'other' | |
| 191 | } | |
| 192 | ||
| 193 | function buildFingerprint (project, type, files) { | |
| 194 | // Extract unique file stems for matching | |
| 195 | const stems = [...new Set(files.map(f => { | |
| 196 | const base = path.basename(f) | |
| 197 | return base.replace(/\.(js|cpp|h|gd|glsl|gdshader|tscn|tres)$/, '') | |
| 198 | }))] | |
| 199 | return `${project}:${type}:${stems.slice(0, 5).sort().join(',')}` | |
| 200 | } | |
| 201 | ||
| 202 | // ─── Loop Matching ─── | |
| 203 | ||
| 204 | function findSimilarLoops (currentFingerprint, pastLoops) { | |
| 205 | const [proj, type, filesStr] = currentFingerprint.split(':') | |
| 206 | const currentFiles = new Set(filesStr.split(',').filter(Boolean)) | |
| 207 | ||
| 208 | const scored = pastLoops | |
| 209 | .filter(l => l.resolved) // only match against resolved loops | |
| 210 | .map(loop => { | |
| 211 | const [lProj, lType, lFilesStr] = loop.fingerprint.split(':') | |
| 212 | const loopFiles = new Set(lFilesStr.split(',').filter(Boolean)) | |
| 213 | ||
| 214 | let score = 0 | |
| 215 | if (lProj === proj) score += 3 // same project = strong signal | |
| 216 | if (lType === type) score += 2 // same error type | |
| 217 | // File overlap | |
| 218 | const overlap = [...currentFiles].filter(f => loopFiles.has(f)).length | |
| 219 | score += overlap * 2 | |
| 220 | ||
| 221 | return { loop, score } | |
| 222 | }) | |
| 223 | .filter(s => s.score >= 3) // minimum relevance | |
| 224 | .sort((a, b) => b.score - a.score) | |
| 225 | ||
| 226 | return scored.slice(0, 3).map(s => ({ | |
| 227 | score: s.score, | |
| 228 | fingerprint: s.loop.fingerprint, | |
| 229 | corrections: s.loop.corrections.slice(0, 3), | |
| 230 | resolution: s.loop.resolution, | |
| 231 | date: s.loop.date, | |
| 232 | duration: s.loop.duration, | |
| 233 | })) | |
| 234 | } | |
| 235 | ||
| 236 | // ─── Main ─── | |
| 237 | ||
| 238 | function main () { | |
| 239 | const verbose = process.argv.includes('--verbose') | |
| 240 | const matchArg = process.argv.find(a => a.startsWith('--match=')) | |
| 241 | ||
| 242 | const data = loadDetail() | |
| 243 | ||
| 244 | // Extract loops from all sessions | |
| 245 | const allLoops = [] | |
| 246 | for (const session of data.sessions) { | |
| 247 | const loops = extractLoops(session) | |
| 248 | allLoops.push(...loops) | |
| 249 | } | |
| 250 | ||
| 251 | // Sort by date | |
| 252 | allLoops.sort((a, b) => (a.date || '').localeCompare(b.date || '')) | |
| 253 | ||
| 254 | // ─── Report ─── | |
| 255 | console.log('═'.repeat(60)) | |
| 256 | console.log(' LOOP DETECTOR') | |
| 257 | console.log('═'.repeat(60)) | |
| 258 | console.log(` Sessions analyzed: ${data.sessions.length}`) | |
| 259 | console.log(` Loops found: ${allLoops.length}`) | |
| 260 | console.log(` Resolved: ${allLoops.filter(l => l.resolved).length}`) | |
| 261 | console.log(` Unresolved: ${allLoops.filter(l => !l.resolved).length}`) | |
| 262 | ||
| 263 | // Stats | |
| 264 | if (allLoops.length > 0) { | |
| 265 | const avgDuration = allLoops.reduce((s, l) => s + l.duration, 0) / allLoops.length | |
| 266 | const avgCorr = allLoops.reduce((s, l) => s + l.correctionCount, 0) / allLoops.length | |
| 267 | const avgTools = allLoops.reduce((s, l) => s + l.totalToolCalls, 0) / allLoops.length | |
| 268 | console.log(` Avg loop duration: ${avgDuration.toFixed(1)} turns`) | |
| 269 | console.log(` Avg corrections per loop: ${avgCorr.toFixed(1)}`) | |
| 270 | console.log(` Avg tool calls per loop: ${avgTools.toFixed(0)}`) | |
| 271 | } | |
| 272 | ||
| 273 | // By project | |
| 274 | const byProject = {} | |
| 275 | for (const loop of allLoops) { | |
| 276 | if (!byProject[loop.project]) byProject[loop.project] = { count: 0, resolved: 0, avgDuration: 0 } | |
| 277 | byProject[loop.project].count++ | |
| 278 | if (loop.resolved) byProject[loop.project].resolved++ | |
| 279 | byProject[loop.project].avgDuration += loop.duration | |
| 280 | } | |
| 281 | ||
| 282 | console.log('\n BY PROJECT') | |
| 283 | console.log(' ' + '-'.repeat(56)) | |
| 284 | for (const [proj, stats] of Object.entries(byProject).sort((a, b) => b[1].count - a[1].count)) { | |
| 285 | stats.avgDuration = (stats.avgDuration / stats.count).toFixed(1) | |
| 286 | console.log(` ${proj.padEnd(20)} ${String(stats.count).padStart(3)} loops ${stats.resolved} resolved avg ${stats.avgDuration} turns`) | |
| 287 | } | |
| 288 | ||
| 289 | // By type | |
| 290 | const byType = {} | |
| 291 | for (const loop of allLoops) { | |
| 292 | byType[loop.type] = (byType[loop.type] || 0) + 1 | |
| 293 | } | |
| 294 | ||
| 295 | console.log('\n BY TYPE') | |
| 296 | console.log(' ' + '-'.repeat(56)) | |
| 297 | for (const [type, count] of Object.entries(byType).sort((a, b) => b - a)) { | |
| 298 | const bar = '█'.repeat(Math.round(count / allLoops.length * 20)) | |
| 299 | console.log(` ${bar.padEnd(10)} ${type.padEnd(20)} ${count}`) | |
| 300 | } | |
| 301 | ||
| 302 | // Show individual loops | |
| 303 | if (verbose) { | |
| 304 | console.log('\n ALL LOOPS') | |
| 305 | console.log(' ' + '-'.repeat(56)) | |
| 306 | for (const loop of allLoops) { | |
| 307 | const status = loop.resolved ? '✓' : '✗' | |
| 308 | console.log(`\n ${status} ${loop.date} [${loop.project}/${loop.type}] ${loop.duration} turns, ${loop.correctionCount} corrections`) | |
| 309 | console.log(` fingerprint: ${loop.fingerprint}`) | |
| 310 | for (const c of loop.corrections.slice(0, 3)) { | |
| 311 | console.log(` ✗ "${c.slice(0, 100)}"`) | |
| 312 | } | |
| 313 | if (loop.resolved && loop.resolution.files.length > 0) { | |
| 314 | console.log(` fix: ${loop.resolution.files.slice(0, 3).map(f => path.basename(f)).join(', ')}`) | |
| 315 | } | |
| 316 | if (loop.resolved && loop.resolution.approval) { | |
| 317 | console.log(` ✓ "${loop.resolution.approval.slice(0, 100)}"`) | |
| 318 | } | |
| 319 | } | |
| 320 | } | |
| 321 | ||
| 322 | // Match mode | |
| 323 | if (matchArg) { | |
| 324 | const query = matchArg.split('=')[1] | |
| 325 | console.log(`\n MATCHING: "${query}"`) | |
| 326 | console.log(' ' + '-'.repeat(56)) | |
| 327 | // Build a fingerprint from the query (project:type:files) | |
| 328 | const matches = findSimilarLoops(query, allLoops) | |
| 329 | if (matches.length === 0) { | |
| 330 | console.log(' No similar past loops found.') | |
| 331 | } else { | |
| 332 | for (const m of matches) { | |
| 333 | console.log(`\n Score: ${m.score} Date: ${m.date} Duration: ${m.duration} turns`) | |
| 334 | console.log(` Fingerprint: ${m.fingerprint}`) | |
| 335 | console.log(` Corrections: ${m.corrections.slice(0, 2).map(c => `"${c.slice(0, 80)}"`).join(', ')}`) | |
| 336 | if (m.resolution.files.length > 0) { | |
| 337 | console.log(` Fix files: ${m.resolution.files.map(f => path.basename(f)).join(', ')}`) | |
| 338 | } | |
| 339 | if (m.resolution.approval) { | |
| 340 | console.log(` Approval: "${m.resolution.approval.slice(0, 100)}"`) | |
| 341 | } | |
| 342 | } | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | // Save | |
| 347 | const report = { | |
| 348 | generated: new Date().toISOString(), | |
| 349 | totalLoops: allLoops.length, | |
| 350 | resolved: allLoops.filter(l => l.resolved).length, | |
| 351 | byProject, | |
| 352 | byType, | |
| 353 | loops: allLoops, | |
| 354 | } | |
| 355 | fs.writeFileSync(LOOPS_FILE, JSON.stringify(report, null, 2)) | |
| 356 | console.log(`\nSaved: ${LOOPS_FILE}`) | |
| 357 | console.log('═'.repeat(60)) | |
| 358 | } | |
| 359 | ||
| 360 | module.exports = { extractLoops, findSimilarLoops } | |
| 361 | ||
| 362 | if (require.main === module) main() |
| … | … | @@ -0,0 +1,163 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Loop Intervention Hook — Blocking Detour | |
| 4 | // Knowledge Ratchet Phase 2 | |
| 5 | // | |
| 6 | // Runs on UserPromptSubmit (synchronous, blocking). | |
| 7 | // Checks if a correction loop is active. If yes, BLOCKS and outputs | |
| 8 | // learned rules + past resolutions as a directive. | |
| 9 | // | |
| 10 | // Claude must read and acknowledge the rules before proceeding. | |
| 11 | // If no loop is active, exits immediately (zero delay). | |
| 12 | // | |
| 13 | // Exit codes: | |
| 14 | // 0 = no loop, allow prompt | |
| 15 | // 2 = loop detected, block with rules (Claude sees stderr) | |
| 16 | ||
| 17 | const fs = require('fs') | |
| 18 | const path = require('path') | |
| 19 | ||
| 20 | const STATE_FILE = '/tmp/claude-session-score.json' | |
| 21 | const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json') | |
| 22 | const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json') | |
| 23 | ||
| 24 | const CORRECTION_PATTERNS = [ | |
| 25 | /\b(no[,.]|wrong|not that|that's not|incorrect|stop doing)\b/i, | |
| 26 | /\b(i said|i meant|i asked for|that's wrong)\b/i, | |
| 27 | /\b(again|already told you|how many times|we discussed this)\b/i, | |
| 28 | /\b(don't ever|never do that|stop|please don't|do not)\b/i, | |
| 29 | /\b(why did you|why are you|that broke|you broke|you just)\b/i, | |
| 30 | /\b(revert|undo|go back|roll back|put it back)\b/i, | |
| 31 | /\b(let me (just |)do it|i'll (just |)do it|forget it|never\s*mind)\b/i, | |
| 32 | // Tuned patterns | |
| 33 | /\b(it is the same|it's the same|still the same|same (problem|issue|bug|error|result))\b/i, | |
| 34 | /\b(still (flat|broken|wrong|missing|empty|invisible|not))\b/i, | |
| 35 | /\b(don't see|can't see|cannot see|not visible|invisible|not showing|not rendering)\b/i, | |
| 36 | /\b(crash|crashes|crashing|crashed)\b/i, | |
| 37 | /\b(not (working|rendering|updating|showing|displaying))\b/i, | |
| 38 | /\b(why (is|are|does|did) (it|the|this))\b/i, | |
| 39 | /\b(hmm+)\b/i, | |
| 40 | ] | |
| 41 | ||
| 42 | async function main () { | |
| 43 | // Read the incoming prompt to check if it's a correction | |
| 44 | let input = '' | |
| 45 | for await (const chunk of process.stdin) { input += chunk } | |
| 46 | let userText = '' | |
| 47 | try { userText = JSON.parse(input).prompt || '' } catch { process.exit(0) } | |
| 48 | ||
| 49 | // Quick check: is a loop already active? | |
| 50 | if (!fs.existsSync(STATE_FILE)) process.exit(0) | |
| 51 | ||
| 52 | let state | |
| 53 | try { state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) } | |
| 54 | catch { process.exit(0) } | |
| 55 | ||
| 56 | // Check if THIS message is a correction | |
| 57 | const isCorrection = CORRECTION_PATTERNS.some(p => p.test(userText)) | |
| 58 | ||
| 59 | // If the user is NOT correcting — they're giving instructions, saying "continue", etc. | |
| 60 | // Let it through and cool down the loop state. | |
| 61 | if (!isCorrection) { | |
| 62 | if (state._loopActive && state._recentCorrections?.length > 0) { | |
| 63 | // Non-correction message cools the loop — remove oldest correction | |
| 64 | state._recentCorrections.shift() | |
| 65 | if (state._recentCorrections.length < 2) { | |
| 66 | state._loopActive = false | |
| 67 | } | |
| 68 | fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) | |
| 69 | } | |
| 70 | process.exit(0) // allow — this is not a correction | |
| 71 | } | |
| 72 | ||
| 73 | // This IS a correction — update loop state | |
| 74 | if (!state._recentCorrections) state._recentCorrections = [] | |
| 75 | state._recentCorrections.push(userText.slice(0, 200)) | |
| 76 | if (state._recentCorrections.length > 5) state._recentCorrections.shift() | |
| 77 | state._loopActive = state._recentCorrections.length >= 2 | |
| 78 | fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) | |
| 79 | ||
| 80 | // Only intervene if the loop has 2+ corrections | |
| 81 | const recentCorr = state._recentCorrections | |
| 82 | if (recentCorr.length < 2) process.exit(0) | |
| 83 | ||
| 84 | // Only intervene once per loop escalation (on 2nd, 4th, 6th correction) | |
| 85 | // Not every single correction — that's too aggressive | |
| 86 | const corrCount = recentCorr.length | |
| 87 | if (corrCount > 2 && corrCount % 2 !== 0) process.exit(0) | |
| 88 | ||
| 89 | // Build the intervention message | |
| 90 | const lines = [ | |
| 91 | '', | |
| 92 | '⚠ LOOP INTERVENTION — You are in a correction loop', | |
| 93 | '═'.repeat(50), | |
| 94 | ` ${corrCount} corrections in this sequence.`, | |
| 95 | '', | |
| 96 | ' Recent corrections:', | |
| 97 | ] | |
| 98 | ||
| 99 | for (const c of recentCorr.slice(-3)) { | |
| 100 | lines.push(` ✗ "${c.slice(0, 100)}"`) | |
| 101 | } | |
| 102 | ||
| 103 | // Load learned rules for this context | |
| 104 | const cwd = state._cwd || process.cwd() | |
| 105 | const editedFiles = state.editedFiles || [] | |
| 106 | ||
| 107 | if (fs.existsSync(RULES_FILE)) { | |
| 108 | try { | |
| 109 | const rulesData = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8')) | |
| 110 | ||
| 111 | // Find rules that match current context | |
| 112 | const matchedRules = (rulesData.rules || []).filter(r => { | |
| 113 | // Match by project (from cwd) | |
| 114 | if (cwd.toLowerCase().includes(r.trigger.project)) return true | |
| 115 | // Match by files | |
| 116 | if (r.trigger.files?.some(f => editedFiles.some(ef => ef.includes(f)))) return true | |
| 117 | return false | |
| 118 | }).sort((a, b) => b.source.duration - a.source.duration) | |
| 119 | ||
| 120 | if (matchedRules.length > 0) { | |
| 121 | lines.push('') | |
| 122 | lines.push(' LEARNED RULES for this context:') | |
| 123 | for (const r of matchedRules.slice(0, 3)) { | |
| 124 | lines.push(` When: ${r.trigger.symptom}`) | |
| 125 | lines.push(` Do: ${r.action.description.slice(0, 120)}`) | |
| 126 | lines.push(` Don't: ${r.antipattern.description.slice(0, 120)}`) | |
| 127 | } | |
| 128 | } | |
| 129 | } catch {} | |
| 130 | } | |
| 131 | ||
| 132 | // Load similar past loops | |
| 133 | if (fs.existsSync(LOOPS_FILE) && state._loopMatches?.matches?.length > 0) { | |
| 134 | lines.push('') | |
| 135 | lines.push(' PAST RESOLUTION:') | |
| 136 | for (const m of state._loopMatches.matches.slice(0, 1)) { | |
| 137 | if (m.resolutionFiles?.length > 0) { | |
| 138 | lines.push(` Fix was in: ${m.resolutionFiles.join(', ')}`) | |
| 139 | } | |
| 140 | if (m.approval) { | |
| 141 | lines.push(` What worked: "${m.approval.slice(0, 120)}"`) | |
| 142 | } | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | lines.push('') | |
| 147 | lines.push(' REQUIRED ACTIONS:') | |
| 148 | lines.push(' 1. STOP what you are doing') | |
| 149 | lines.push(' 2. Read the rules above') | |
| 150 | lines.push(' 3. Enter planning mode if the loop has >3 corrections') | |
| 151 | lines.push(' 4. Read the target files FULLY before making changes') | |
| 152 | lines.push(' 5. Do NOT repeat the same approach that already failed') | |
| 153 | lines.push('') | |
| 154 | lines.push('═'.repeat(50)) | |
| 155 | ||
| 156 | // Output as additionalContext — Claude sees the rules alongside the user's message | |
| 157 | // The prompt goes through, but Claude reads the intervention first | |
| 158 | const output = { additionalContext: lines.join('\n') } | |
| 159 | process.stdout.write(JSON.stringify(output)) | |
| 160 | process.exit(0) // allow prompt through, but inject context | |
| 161 | } | |
| 162 | ||
| 163 | main().catch(() => process.exit(0)) |
| … | … | @@ -0,0 +1,341 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Meta-Ratchet — Self-Adjusting Sensor Weights | |
| 4 | // Knowledge Ratchet Phase 2, Item #4 | |
| 5 | // | |
| 6 | // Analyzes per-turn sensor data to determine which sensors best predict | |
| 7 | // future corrections. Adjusts weights so predictive sensors have more | |
| 8 | // influence on the fused score. | |
| 9 | // | |
| 10 | // Algorithm: | |
| 11 | // For each correction at turn T: | |
| 12 | // Look back at turns T-1, T-2, T-3 | |
| 13 | // For each sensor, if the value was LOW (< threshold), it predicted the correction | |
| 14 | // That sensor's predictive_hits++ | |
| 15 | // | |
| 16 | // For each approval at turn T (after 3+ non-correction turns): | |
| 17 | // Look back at turns T-1, T-2, T-3 | |
| 18 | // For each sensor, if the value was HIGH (> threshold), it correctly indicated quality | |
| 19 | // That sensor's approval_hits++ | |
| 20 | // | |
| 21 | // Weight adjustment: | |
| 22 | // predictive_rate = predictive_hits / total_corrections | |
| 23 | // If predictive_rate > 0.5 → sensor is useful, increase weight | |
| 24 | // If predictive_rate < 0.2 → sensor is noise, decrease weight | |
| 25 | // | |
| 26 | // Usage: | |
| 27 | // node meta-ratchet.js # analyze and adjust weights | |
| 28 | // node meta-ratchet.js --dry-run # analyze without saving | |
| 29 | // node meta-ratchet.js --verbose # show per-sensor details | |
| 30 | ||
| 31 | const fs = require('fs') | |
| 32 | const path = require('path') | |
| 33 | ||
| 34 | const DATA_DIR = path.join(__dirname, 'data') | |
| 35 | const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json') | |
| 36 | const WEIGHTS_FILE = path.join(DATA_DIR, 'sensor-weights.json') | |
| 37 | const HISTORY_FILE = path.join(DATA_DIR, 'weight-history.json') | |
| 38 | ||
| 39 | // How far back to look for predictive signals | |
| 40 | const LOOKBACK = 3 | |
| 41 | ||
| 42 | // Sensor thresholds — below this value, the sensor is "signaling" | |
| 43 | // (i.e., it detected something potentially wrong) | |
| 44 | const SIGNAL_THRESHOLD = 0.6 | |
| 45 | ||
| 46 | // Sensors that are inverted (high = bad) or not meaningful for prediction | |
| 47 | const EXCLUDED_SENSORS = new Set([ | |
| 48 | 'cacheHitRate', // informational only, not predictive | |
| 49 | 'timeBetweenTurns', // user behavior, not Claude quality | |
| 50 | ]) | |
| 51 | ||
| 52 | // Weight adjustment bounds | |
| 53 | const MIN_WEIGHT = 0.05 | |
| 54 | const MAX_WEIGHT = 2.0 | |
| 55 | const ADJUST_STEP = 0.05 // how much to adjust per run | |
| 56 | ||
| 57 | function loadDetail () { | |
| 58 | if (!fs.existsSync(DETAIL_FILE)) { | |
| 59 | console.error(`Detail file not found: ${DETAIL_FILE}`) | |
| 60 | console.error('Run fuse-signals.js first.') | |
| 61 | process.exit(1) | |
| 62 | } | |
| 63 | return JSON.parse(fs.readFileSync(DETAIL_FILE, 'utf8')) | |
| 64 | } | |
| 65 | ||
| 66 | function loadWeightHistory () { | |
| 67 | if (!fs.existsSync(HISTORY_FILE)) return [] | |
| 68 | try { return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')) } | |
| 69 | catch { return [] } | |
| 70 | } | |
| 71 | ||
| 72 | function analyze (data) { | |
| 73 | const currentWeights = data.sensorWeights || {} | |
| 74 | ||
| 75 | // Get all sensor names from first turn with data | |
| 76 | let sensorNames = [] | |
| 77 | for (const sess of data.sessions) { | |
| 78 | for (const turn of sess.turns) { | |
| 79 | if (turn.sensors) { | |
| 80 | sensorNames = Object.keys(turn.sensors).filter(s => !EXCLUDED_SENSORS.has(s)) | |
| 81 | break | |
| 82 | } | |
| 83 | } | |
| 84 | if (sensorNames.length > 0) break | |
| 85 | } | |
| 86 | ||
| 87 | // Initialize tracking per sensor | |
| 88 | const stats = {} | |
| 89 | for (const name of sensorNames) { | |
| 90 | stats[name] = { | |
| 91 | // How many corrections had this sensor signaling in the previous turns? | |
| 92 | predictiveHits: 0, | |
| 93 | // How many approvals had this sensor healthy in the previous turns? | |
| 94 | approvalHits: 0, | |
| 95 | // Total corrections/approvals we checked | |
| 96 | totalCorrections: 0, | |
| 97 | totalApprovals: 0, | |
| 98 | // Raw sensor values at correction/approval points (for distribution analysis) | |
| 99 | valuesAtCorrection: [], | |
| 100 | valuesBeforeCorrection: [], | |
| 101 | valuesBeforeApproval: [], | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | let totalCorrections = 0 | |
| 106 | let totalApprovals = 0 | |
| 107 | ||
| 108 | // Analyze each session's turns | |
| 109 | for (const sess of data.sessions) { | |
| 110 | const turns = sess.turns | |
| 111 | if (turns.length < LOOKBACK + 1) continue | |
| 112 | ||
| 113 | for (let i = LOOKBACK; i < turns.length; i++) { | |
| 114 | const turn = turns[i] | |
| 115 | if (!turn.sensors) continue | |
| 116 | ||
| 117 | if (turn.isCorrection) { | |
| 118 | totalCorrections++ | |
| 119 | ||
| 120 | // Look back at previous turns | |
| 121 | for (const name of sensorNames) { | |
| 122 | stats[name].totalCorrections++ | |
| 123 | ||
| 124 | // Collect sensor values from lookback window | |
| 125 | let signaled = false | |
| 126 | for (let j = i - LOOKBACK; j < i; j++) { | |
| 127 | if (j < 0 || !turns[j]?.sensors) continue | |
| 128 | const value = turns[j].sensors[name] | |
| 129 | if (value !== undefined) { | |
| 130 | stats[name].valuesBeforeCorrection.push(value) | |
| 131 | if (value < SIGNAL_THRESHOLD) { | |
| 132 | signaled = true | |
| 133 | } | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | // Also check the correction turn itself | |
| 138 | const currentValue = turn.sensors[name] | |
| 139 | if (currentValue !== undefined) { | |
| 140 | stats[name].valuesAtCorrection.push(currentValue) | |
| 141 | } | |
| 142 | ||
| 143 | if (signaled) { | |
| 144 | stats[name].predictiveHits++ | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | if (turn.isApproval) { | |
| 150 | // Only count "strong" approvals — ones after at least 3 non-correction turns | |
| 151 | const recentTurns = turns.slice(Math.max(0, i - 3), i) | |
| 152 | const hasRecentCorrection = recentTurns.some(t => t.isCorrection) | |
| 153 | if (hasRecentCorrection) continue // approval after correction = recovery, not quality signal | |
| 154 | ||
| 155 | totalApprovals++ | |
| 156 | ||
| 157 | for (const name of sensorNames) { | |
| 158 | stats[name].totalApprovals++ | |
| 159 | ||
| 160 | // Check if sensors were healthy in lookback window | |
| 161 | let healthy = true | |
| 162 | for (let j = i - LOOKBACK; j < i; j++) { | |
| 163 | if (j < 0 || !turns[j]?.sensors) continue | |
| 164 | const value = turns[j].sensors[name] | |
| 165 | if (value !== undefined) { | |
| 166 | stats[name].valuesBeforeApproval.push(value) | |
| 167 | if (value < SIGNAL_THRESHOLD) { | |
| 168 | healthy = false | |
| 169 | } | |
| 170 | } | |
| 171 | } | |
| 172 | ||
| 173 | if (healthy) { | |
| 174 | stats[name].approvalHits++ | |
| 175 | } | |
| 176 | } | |
| 177 | } | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | // Compute predictive rates and new weights | |
| 182 | const results = {} | |
| 183 | for (const name of sensorNames) { | |
| 184 | const s = stats[name] | |
| 185 | const predictiveRate = s.totalCorrections > 0 | |
| 186 | ? s.predictiveHits / s.totalCorrections | |
| 187 | : 0 | |
| 188 | const approvalRate = s.totalApprovals > 0 | |
| 189 | ? s.approvalHits / s.totalApprovals | |
| 190 | : 0 | |
| 191 | ||
| 192 | // Distribution stats | |
| 193 | const avgBeforeCorr = s.valuesBeforeCorrection.length > 0 | |
| 194 | ? s.valuesBeforeCorrection.reduce((a, b) => a + b, 0) / s.valuesBeforeCorrection.length | |
| 195 | : null | |
| 196 | const avgBeforeAppr = s.valuesBeforeApproval.length > 0 | |
| 197 | ? s.valuesBeforeApproval.reduce((a, b) => a + b, 0) / s.valuesBeforeApproval.length | |
| 198 | : null | |
| 199 | ||
| 200 | // Separation: how different is the sensor before corrections vs approvals? | |
| 201 | // High separation = sensor discriminates well between good and bad turns | |
| 202 | const separation = (avgBeforeCorr !== null && avgBeforeAppr !== null) | |
| 203 | ? avgBeforeAppr - avgBeforeCorr | |
| 204 | : 0 | |
| 205 | ||
| 206 | // Weight adjustment logic | |
| 207 | const currentWeight = currentWeights[name] || 0.3 | |
| 208 | let newWeight = currentWeight | |
| 209 | let reason = 'stable' | |
| 210 | ||
| 211 | if (s.totalCorrections >= 5) { // need enough data to judge | |
| 212 | if (predictiveRate > 0.5 && separation > 0.05) { | |
| 213 | // Sensor predicts corrections well AND differentiates — increase weight | |
| 214 | newWeight = Math.min(MAX_WEIGHT, currentWeight + ADJUST_STEP) | |
| 215 | reason = 'predictive (↑)' | |
| 216 | } else if (predictiveRate < 0.2 && separation < 0.02) { | |
| 217 | // Sensor rarely signals before corrections AND doesn't differentiate — decrease weight | |
| 218 | newWeight = Math.max(MIN_WEIGHT, currentWeight - ADJUST_STEP) | |
| 219 | reason = 'noise (↓)' | |
| 220 | } else if (predictiveRate > 0.3 && separation > 0.03) { | |
| 221 | // Moderately predictive — keep or slight increase | |
| 222 | newWeight = Math.min(MAX_WEIGHT, currentWeight + ADJUST_STEP * 0.5) | |
| 223 | reason = 'moderate (↗)' | |
| 224 | } | |
| 225 | } else { | |
| 226 | reason = 'insufficient data' | |
| 227 | } | |
| 228 | ||
| 229 | results[name] = { | |
| 230 | currentWeight: Math.round(currentWeight * 1000) / 1000, | |
| 231 | newWeight: Math.round(newWeight * 1000) / 1000, | |
| 232 | predictiveRate: Math.round(predictiveRate * 1000) / 1000, | |
| 233 | approvalRate: Math.round(approvalRate * 1000) / 1000, | |
| 234 | separation: Math.round(separation * 1000) / 1000, | |
| 235 | avgBeforeCorrection: avgBeforeCorr !== null ? Math.round(avgBeforeCorr * 1000) / 1000 : null, | |
| 236 | avgBeforeApproval: avgBeforeAppr !== null ? Math.round(avgBeforeAppr * 1000) / 1000 : null, | |
| 237 | predictiveHits: s.predictiveHits, | |
| 238 | totalCorrections: s.totalCorrections, | |
| 239 | approvalHits: s.approvalHits, | |
| 240 | totalApprovals: s.totalApprovals, | |
| 241 | reason, | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | return { results, totalCorrections, totalApprovals, sensorNames } | |
| 246 | } | |
| 247 | ||
| 248 | function main () { | |
| 249 | const dryRun = process.argv.includes('--dry-run') | |
| 250 | const verbose = process.argv.includes('--verbose') | |
| 251 | ||
| 252 | const data = loadDetail() | |
| 253 | const { results, totalCorrections, totalApprovals, sensorNames } = analyze(data) | |
| 254 | ||
| 255 | // ─── Console Report ─── | |
| 256 | console.log('═'.repeat(60)) | |
| 257 | console.log(' META-RATCHET — Sensor Weight Analysis') | |
| 258 | console.log('═'.repeat(60)) | |
| 259 | console.log(` Turns analyzed: ${data.sessions.reduce((s, sess) => s + sess.turns.length, 0)}`) | |
| 260 | console.log(` Corrections: ${totalCorrections}`) | |
| 261 | console.log(` Strong approvals: ${totalApprovals}`) | |
| 262 | console.log(` Lookback window: ${LOOKBACK} turns`) | |
| 263 | console.log(` Signal threshold: < ${SIGNAL_THRESHOLD}`) | |
| 264 | ||
| 265 | // Sort by predictive rate (most predictive first) | |
| 266 | const sorted = Object.entries(results) | |
| 267 | .sort((a, b) => b[1].predictiveRate - a[1].predictiveRate) | |
| 268 | ||
| 269 | console.log('\n SENSOR PREDICTIVENESS') | |
| 270 | console.log(' ' + '-'.repeat(56)) | |
| 271 | console.log(' sensor pred% sep weight → new reason') | |
| 272 | console.log(' ' + '-'.repeat(56)) | |
| 273 | ||
| 274 | for (const [name, r] of sorted) { | |
| 275 | const predBar = '█'.repeat(Math.round(r.predictiveRate * 10)) + '░'.repeat(10 - Math.round(r.predictiveRate * 10)) | |
| 276 | const arrow = r.newWeight > r.currentWeight ? '↑' | |
| 277 | : r.newWeight < r.currentWeight ? '↓' | |
| 278 | : '=' | |
| 279 | console.log(` ${predBar} ${name.padEnd(22)} ${(r.predictiveRate * 100).toFixed(0).padStart(3)}% ${r.separation.toFixed(3).padStart(6)} ${r.currentWeight.toFixed(2).padStart(5)} ${arrow} ${r.newWeight.toFixed(2).padStart(5)} ${r.reason}`) | |
| 280 | } | |
| 281 | ||
| 282 | if (verbose) { | |
| 283 | console.log('\n DETAILED SENSOR DISTRIBUTIONS') | |
| 284 | console.log(' ' + '-'.repeat(56)) | |
| 285 | for (const [name, r] of sorted) { | |
| 286 | console.log(` ${name}:`) | |
| 287 | console.log(` avg before correction: ${r.avgBeforeCorrection?.toFixed(3) || 'n/a'}`) | |
| 288 | console.log(` avg before approval: ${r.avgBeforeApproval?.toFixed(3) || 'n/a'}`) | |
| 289 | console.log(` predictive hits: ${r.predictiveHits}/${r.totalCorrections}`) | |
| 290 | console.log(` approval hits: ${r.approvalHits}/${r.totalApprovals}`) | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | // Weight changes summary | |
| 295 | const changed = sorted.filter(([, r]) => r.newWeight !== r.currentWeight) | |
| 296 | if (changed.length > 0) { | |
| 297 | console.log('\n WEIGHT ADJUSTMENTS') | |
| 298 | console.log(' ' + '-'.repeat(56)) | |
| 299 | for (const [name, r] of changed) { | |
| 300 | const delta = r.newWeight - r.currentWeight | |
| 301 | console.log(` ${name}: ${r.currentWeight.toFixed(3)} → ${r.newWeight.toFixed(3)} (${delta >= 0 ? '+' : ''}${delta.toFixed(3)}) — ${r.reason}`) | |
| 302 | } | |
| 303 | } else { | |
| 304 | console.log('\n No weight adjustments needed.') | |
| 305 | } | |
| 306 | ||
| 307 | // Save new weights | |
| 308 | if (!dryRun && changed.length > 0) { | |
| 309 | const newWeights = {} | |
| 310 | for (const [name, r] of Object.entries(results)) { | |
| 311 | newWeights[name] = r.newWeight | |
| 312 | } | |
| 313 | fs.writeFileSync(WEIGHTS_FILE, JSON.stringify(newWeights, null, 2)) | |
| 314 | console.log(`\n Saved: ${WEIGHTS_FILE}`) | |
| 315 | ||
| 316 | // Append to weight history for trend tracking | |
| 317 | const history = loadWeightHistory() | |
| 318 | history.push({ | |
| 319 | timestamp: new Date().toISOString(), | |
| 320 | corrections: totalCorrections, | |
| 321 | approvals: totalApprovals, | |
| 322 | weights: newWeights, | |
| 323 | changes: changed.map(([name, r]) => ({ | |
| 324 | sensor: name, | |
| 325 | from: r.currentWeight, | |
| 326 | to: r.newWeight, | |
| 327 | reason: r.reason, | |
| 328 | })), | |
| 329 | }) | |
| 330 | // Keep last 100 history entries | |
| 331 | while (history.length > 100) history.shift() | |
| 332 | fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2)) | |
| 333 | console.log(` Saved: ${HISTORY_FILE} (${history.length} entries)`) | |
| 334 | } else if (dryRun) { | |
| 335 | console.log('\n (dry run — weights not saved)') | |
| 336 | } | |
| 337 | ||
| 338 | console.log(`\n${'═'.repeat(60)}`) | |
| 339 | } | |
| 340 | ||
| 341 | main() |
| … | … | @@ -0,0 +1,74 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Merged Pipeline — Single-Pass Analysis | |
| 4 | // Knowledge Ratchet Phase 2 | |
| 5 | // | |
| 6 | // Replaces running fuse-signals.js + analyze-transcripts.js separately. | |
| 7 | // Reads all JSONL files once via scan-sessions.js, then runs both | |
| 8 | // scoring systems on the shared data. | |
| 9 | // | |
| 10 | // Usage: | |
| 11 | // node pipeline.js # full pipeline (scan + fuse + sentiment) | |
| 12 | // node pipeline.js --recent=7 # only last 7 days | |
| 13 | // node pipeline.js --session=UUID # single session | |
| 14 | // | |
| 15 | // This is the fast pipeline for end-of-session runs. | |
| 16 | // Does NOT include: LLM analyzer, regex tuner, ratchet pipeline (those need batch mode). | |
| 17 | ||
| 18 | const { execSync } = require('child_process') | |
| 19 | const path = require('path') | |
| 20 | ||
| 21 | const DIR = __dirname | |
| 22 | ||
| 23 | function run (label, cmd) { | |
| 24 | const start = Date.now() | |
| 25 | try { | |
| 26 | execSync(cmd, { cwd: DIR, encoding: 'utf8', stdio: 'inherit', timeout: 300000 }) | |
| 27 | } catch (e) { | |
| 28 | console.error(` ${label} failed: ${e.message.slice(0, 200)}`) | |
| 29 | } | |
| 30 | return Date.now() - start | |
| 31 | } | |
| 32 | ||
| 33 | async function main () { | |
| 34 | const args = process.argv.slice(2).join(' ') | |
| 35 | const start = Date.now() | |
| 36 | ||
| 37 | console.log(`Knowledge Ratchet — Fast Pipeline`) | |
| 38 | console.log(`Started: ${new Date().toISOString()}`) | |
| 39 | ||
| 40 | // Step 1: Sensor fusion (includes single-pass JSONL scan) | |
| 41 | const t1 = run('Sensor Fusion', `node ${path.join(DIR, 'fuse-signals.js')} ${args}`) | |
| 42 | ||
| 43 | // Step 2: Meta-ratchet (reads fused-turns-detail.json, <0.1s) | |
| 44 | const t2 = run('Meta-Ratchet', `node ${path.join(DIR, 'meta-ratchet.js')}`) | |
| 45 | ||
| 46 | // Step 3: Coverage map (reads corrections.log, ~1s) | |
| 47 | const t3 = run('Coverage Map', `node ${path.join(DIR, 'coverage-map.js')} --summary`) | |
| 48 | ||
| 49 | // Step 4: Regression check (reads corrections.log + fused scores, <0.1s) | |
| 50 | const t4 = run('Regression', `node ${path.join(DIR, 'regress.js')}`) | |
| 51 | ||
| 52 | // Step 5: Loop detection (reads fused-turns-detail.json, <0.5s) | |
| 53 | const t5 = run('Loop Detector', `node ${path.join(DIR, 'loop-detector.js')}`) | |
| 54 | ||
| 55 | // Step 6: Rule extractor (auto-generates rules from resolved loops) | |
| 56 | run('Rule Extractor', `node ${path.join(DIR, 'rule-extractor.js')}`) | |
| 57 | ||
| 58 | // Step 7: Project mapper | |
| 59 | run('Project Mapper', `node ${path.join(DIR, 'project-mapper.js')}`) | |
| 60 | ||
| 61 | // Step 8: Benchmarks → history | |
| 62 | run('Benchmarks', `node ${path.join(DIR, 'benchmark-ratchet.js')}`) | |
| 63 | ||
| 64 | // Step 9: Dashboard | |
| 65 | run('Dashboard', `node ${path.join(DIR, 'dashboard.js')} --no-open`) | |
| 66 | ||
| 67 | const total = Date.now() - start | |
| 68 | console.log(`\nFast pipeline: ${(total / 1000).toFixed(1)}s`) | |
| 69 | } | |
| 70 | ||
| 71 | main().catch(err => { | |
| 72 | console.error('Pipeline failed:', err.message) | |
| 73 | process.exit(1) | |
| 74 | }) |
| … | … | @@ -11,6 +11,7 @@ const path = require('path') |
| 11 | 11 | const STATE_FILE = '/tmp/claude-preflight-state.json' |
| 12 | 12 | const PRINCIPLES_FILE = path.join(__dirname, 'active-principles.md') |
| 13 | 13 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') |
| 14 | const PROJECT_MAPS_FILE = path.join(__dirname, 'data', 'project-maps.json') | |
| 14 | 15 | |
| 15 | 16 | // Shared modules and their downstream consumers |
| 16 | 17 | // This is the map of files where edits need pre-flight review |
| … | … | @@ -61,6 +62,28 @@ const SHARED_MODULE_MAP = { |
| 61 | 62 | 'hyper-fleeky/pear-git/lib/replication.js', |
| 62 | 63 | 'hyper-fleeky/hyper-overlay/lib/replication.js' |
| 63 | 64 | ] |
| 65 | }, | |
| 66 | // pear-git danger files — 58 loops, most corrections of any project | |
| 67 | 'pear-git/pear-git-server.js': { | |
| 68 | description: 'DANGER: Main pear-git server — 12 fix cycles. Autobase init, repo management, HTTP routes. Read ENTIRE file before editing.', | |
| 69 | downstream: ['production server on tabertron + stortron'], | |
| 70 | loopHistory: '58 loops, top failures: audit-demanded (68), stop-commanded (34), same-error-repeated (28)' | |
| 71 | }, | |
| 72 | 'pear-git/lib/repos.js': { | |
| 73 | description: 'DANGER: Repo registry and bare repo management. Claude has repeatedly broken repo creation flow.', | |
| 74 | downstream: ['pear-git-server.js', 'repo-registry.js'] | |
| 75 | }, | |
| 76 | 'pear-git/lib/repo-registry.js': { | |
| 77 | description: 'DANGER: Autobase-backed repo registry. Past corruption from wrong writer keys.', | |
| 78 | downstream: ['pear-git-server.js'] | |
| 79 | }, | |
| 80 | 'pear-git/lib/autobase/': { | |
| 81 | description: 'DANGER: Autobase apply functions. 20 autobase corruption incidents. NEVER pass serverKey to addWriter.', | |
| 82 | downstream: ['pear-git-server.js', 'identity system'] | |
| 83 | }, | |
| 84 | 'pear-git/lib/identity': { | |
| 85 | description: 'DANGER: Identity + crypto. 13 identity/crypto loop corrections. Key format assumptions have been wrong repeatedly.', | |
| 86 | downstream: ['pear-git-server.js', 'auth system'] | |
| 64 | 87 | } |
| 65 | 88 | } |
| 66 | 89 | |
| … | … | @@ -84,11 +107,40 @@ function loadRecentCorrections (filePath) { |
| 84 | 107 | |
| 85 | 108 | // Check which shared module pattern matches this file |
| 86 | 109 | function findSharedModule (filePath) { |
| 110 | // First check hardcoded map (manually curated danger files) | |
| 87 | 111 | for (const [pattern, info] of Object.entries(SHARED_MODULE_MAP)) { |
| 88 | 112 | if (filePath.includes(pattern)) { |
| 89 | 113 | return { pattern, ...info } |
| 90 | 114 | } |
| 91 | 115 | } |
| 116 | ||
| 117 | // Fallback: check autodiscovered project map for high-danger subsystems | |
| 118 | if (fs.existsSync(PROJECT_MAPS_FILE)) { | |
| 119 | try { | |
| 120 | const maps = JSON.parse(fs.readFileSync(PROJECT_MAPS_FILE, 'utf8')) | |
| 121 | for (const [projectName, projectMap] of Object.entries(maps.projects || {})) { | |
| 122 | if (!filePath.includes(projectName) && !filePath.startsWith(projectMap.root)) continue | |
| 123 | ||
| 124 | // Find which subsystem this file belongs to | |
| 125 | for (const [subName, subData] of Object.entries(projectMap.subsystems || {})) { | |
| 126 | const matchesFile = (subData.files || []).some(f => | |
| 127 | filePath.endsWith(f) || filePath.endsWith('/' + f) | |
| 128 | ) | |
| 129 | if (matchesFile && subData.dangerLevel !== 'low') { | |
| 130 | return { | |
| 131 | pattern: `${projectName}/${subName}`, | |
| 132 | description: `Auto-detected: ${projectName}/${subName} subsystem (${subData.fileCount} files, ${subData.loopCount} past loops, danger=${subData.dangerLevel})`, | |
| 133 | downstream: subData.dependedBy || [], | |
| 134 | loopHistory: subData.loopCount > 0 | |
| 135 | ? `${subData.loopCount} loops, types: ${Object.entries(subData.loopTypes || {}).map(([t, c]) => t + '(' + c + ')').join(', ')}` | |
| 136 | : null, | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | } | |
| 141 | } catch {} | |
| 142 | } | |
| 143 | ||
| 92 | 144 | return null |
| 93 | 145 | } |
| 94 | 146 |
| … | … | @@ -0,0 +1,359 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Project Mapper — Autodiscover Subsystems from Code | |
| 4 | // Knowledge Ratchet Phase 2 | |
| 5 | // | |
| 6 | // Walks project directory trees, parses require/import for dependency graphs, | |
| 7 | // clusters files into subsystems, and cross-references with loop history. | |
| 8 | // | |
| 9 | // Outputs data/project-maps.json — a machine-readable map of every known | |
| 10 | // project's subsystem structure, used by preflight-review, loop-detector, | |
| 11 | // loop-briefing instead of hardcoded maps. | |
| 12 | // | |
| 13 | // Usage: | |
| 14 | // node project-mapper.js # scan all known projects | |
| 15 | // node project-mapper.js --project=pear-git # scan one project | |
| 16 | // node project-mapper.js --verbose # show dependency details | |
| 17 | ||
| 18 | const fs = require('fs') | |
| 19 | const path = require('path') | |
| 20 | ||
| 21 | const DATA_DIR = path.join(__dirname, 'data') | |
| 22 | const OUTPUT_FILE = path.join(DATA_DIR, 'project-maps.json') | |
| 23 | const LOOPS_FILE = path.join(DATA_DIR, 'loops.json') | |
| 24 | ||
| 25 | // Known project roots — autodiscovered from hyper-fleeky + godot dirs | |
| 26 | const PROJECT_ROOTS = { | |
| 27 | 'pear-git': '/storage/projects/code/hypercore-kb/hyper-fleeky/pear-git', | |
| 28 | 'hypernote': '/storage/projects/code/hypercore-kb/hyper-fleeky/hypernote', | |
| 29 | 'hyper-overlay': '/storage/projects/code/hypercore-kb/hyper-fleeky/hyper-overlay', | |
| 30 | 'hyper-social': '/storage/projects/code/hypercore-kb/hyper-social/hyper-social-web', | |
| 31 | 'dc-terrain': '/storage/projects/code/godot/dc-terrain', | |
| 32 | 'hyper-tps': '/storage/projects/code/godot/hyper-tps', | |
| 33 | 'blender-bridge': '/storage/projects/code/godot/blender-bridge', | |
| 34 | 'proc-nodes': '/storage/projects/code/godot/proc-nodes', | |
| 35 | } | |
| 36 | ||
| 37 | const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', '.pear', '.claude']) | |
| 38 | const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.cpp', '.h', '.gd', '.glsl', '.gdshader']) | |
| 39 | ||
| 40 | // ─── File Tree Scanner ─── | |
| 41 | ||
| 42 | function scanDir (dir, prefix = '') { | |
| 43 | const files = [] | |
| 44 | let entries | |
| 45 | try { entries = fs.readdirSync(dir, { withFileTypes: true }) } | |
| 46 | catch { return files } | |
| 47 | ||
| 48 | for (const entry of entries) { | |
| 49 | if (SKIP_DIRS.has(entry.name)) continue | |
| 50 | if (entry.name.startsWith('.')) continue | |
| 51 | ||
| 52 | const fullPath = path.join(dir, entry.name) | |
| 53 | const relPath = prefix ? `${prefix}/${entry.name}` : entry.name | |
| 54 | ||
| 55 | if (entry.isDirectory()) { | |
| 56 | files.push(...scanDir(fullPath, relPath)) | |
| 57 | } else if (CODE_EXTS.has(path.extname(entry.name))) { | |
| 58 | files.push({ path: fullPath, rel: relPath, name: entry.name }) | |
| 59 | } | |
| 60 | } | |
| 61 | return files | |
| 62 | } | |
| 63 | ||
| 64 | // ─── Dependency Parser ─── | |
| 65 | // Extract require() and import calls from JS files | |
| 66 | ||
| 67 | function parseDeps (filePath) { | |
| 68 | const deps = [] | |
| 69 | let content | |
| 70 | try { content = fs.readFileSync(filePath, 'utf8') } | |
| 71 | catch { return deps } | |
| 72 | ||
| 73 | // require('./foo') or require('../bar') | |
| 74 | const requireRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g | |
| 75 | // import ... from './foo' | |
| 76 | const importRe = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g | |
| 77 | ||
| 78 | for (const re of [requireRe, importRe]) { | |
| 79 | let match | |
| 80 | while ((match = re.exec(content)) !== null) { | |
| 81 | const dep = match[1] | |
| 82 | // Only track local deps (not npm packages) | |
| 83 | if (dep.startsWith('.') || dep.startsWith('/')) { | |
| 84 | deps.push(dep) | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | return deps | |
| 90 | } | |
| 91 | ||
| 92 | // ─── Subsystem Clustering ─── | |
| 93 | // Group files by their top-level directory under lib/src | |
| 94 | ||
| 95 | function clusterSubsystems (files, projectRoot) { | |
| 96 | const subsystems = {} | |
| 97 | ||
| 98 | for (const file of files) { | |
| 99 | const rel = file.rel | |
| 100 | const parts = rel.split('/') | |
| 101 | ||
| 102 | // Determine subsystem from directory structure | |
| 103 | let subsystem = 'root' | |
| 104 | ||
| 105 | if (parts.length === 1) { | |
| 106 | // Top-level file (e.g., pear-git-server.js, index.js) | |
| 107 | subsystem = 'server-core' | |
| 108 | } else if (parts[0] === 'lib' || parts[0] === 'src') { | |
| 109 | if (parts.length === 2) { | |
| 110 | // lib/foo.js → classify by name | |
| 111 | subsystem = classifyByName(parts[1]) | |
| 112 | } else { | |
| 113 | // lib/subsystem/file.js → use directory name | |
| 114 | subsystem = parts[1] | |
| 115 | } | |
| 116 | } else if (parts[0] === 'test' || parts[0] === 'tests') { | |
| 117 | subsystem = 'tests' | |
| 118 | } else if (parts[0] === 'bin' || parts[0] === 'scripts') { | |
| 119 | subsystem = 'cli' | |
| 120 | } else { | |
| 121 | subsystem = parts[0] | |
| 122 | } | |
| 123 | ||
| 124 | if (!subsystems[subsystem]) { | |
| 125 | subsystems[subsystem] = { files: [], deps: new Set(), dependedBy: new Set() } | |
| 126 | } | |
| 127 | subsystems[subsystem].files.push(file.rel) | |
| 128 | } | |
| 129 | ||
| 130 | return subsystems | |
| 131 | } | |
| 132 | ||
| 133 | function classifyByName (filename) { | |
| 134 | const base = filename.replace(/\.(js|mjs|cjs)$/, '').toLowerCase() | |
| 135 | if (base.includes('auth') || base.includes('identity')) return 'identity' | |
| 136 | if (base.includes('repo') || base.includes('registry')) return 'repo-management' | |
| 137 | if (base.includes('route') || base.includes('middleware')) return 'http-routes' | |
| 138 | if (base.includes('sync') || base.includes('federation') || base.includes('discovery')) return 'p2p-sync' | |
| 139 | if (base.includes('deploy') || base.includes('ci')) return 'ci-deploy' | |
| 140 | if (base.includes('-ui') || base.includes('nav') || base.includes('layout')) return 'frontend-ui' | |
| 141 | if (base.includes('git') || base.includes('diff')) return 'git-layer' | |
| 142 | if (base.includes('issue')) return 'issues' | |
| 143 | if (base.includes('namespace')) return 'namespaces' | |
| 144 | if (base.includes('error') || base.includes('response')) return 'shared-utils' | |
| 145 | if (base.includes('storage') || base.includes('backend')) return 'storage' | |
| 146 | return 'server-core' | |
| 147 | } | |
| 148 | ||
| 149 | // ─── Dependency Graph Between Subsystems ─── | |
| 150 | ||
| 151 | function buildDependencyGraph (files, subsystems, projectRoot) { | |
| 152 | // Map file paths to their subsystem | |
| 153 | const fileToSubsystem = {} | |
| 154 | for (const [sub, data] of Object.entries(subsystems)) { | |
| 155 | for (const f of data.files) { | |
| 156 | fileToSubsystem[f] = sub | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | // Parse deps and map to subsystem-level edges | |
| 161 | for (const file of files) { | |
| 162 | if (!file.path.endsWith('.js')) continue | |
| 163 | const fromSub = fileToSubsystem[file.rel] | |
| 164 | if (!fromSub) continue | |
| 165 | ||
| 166 | const deps = parseDeps(file.path) | |
| 167 | for (const dep of deps) { | |
| 168 | // Resolve relative path | |
| 169 | const resolved = path.resolve(path.dirname(file.path), dep) | |
| 170 | const relResolved = path.relative(projectRoot, resolved).replace(/\\/g, '/') | |
| 171 | ||
| 172 | // Find which subsystem this dep belongs to | |
| 173 | // Try with and without .js extension | |
| 174 | for (const candidate of [relResolved, relResolved + '.js', relResolved + '/index.js']) { | |
| 175 | const toSub = fileToSubsystem[candidate] | |
| 176 | if (toSub && toSub !== fromSub) { | |
| 177 | subsystems[fromSub].deps.add(toSub) | |
| 178 | subsystems[toSub].dependedBy.add(fromSub) | |
| 179 | break | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | // ─── Loop History Cross-Reference ─── | |
| 187 | ||
| 188 | function crossReferenceLoops (subsystems, projectName) { | |
| 189 | if (!fs.existsSync(LOOPS_FILE)) return | |
| 190 | ||
| 191 | let loopsData | |
| 192 | try { loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8')) } | |
| 193 | catch { return } | |
| 194 | ||
| 195 | const projectLoops = (loopsData.loops || []).filter(l => l.project === projectName) | |
| 196 | ||
| 197 | for (const loop of projectLoops) { | |
| 198 | for (const f of (loop.filesTried || [])) { | |
| 199 | const base = path.basename(f) | |
| 200 | // Find which subsystem this file belongs to | |
| 201 | for (const [sub, data] of Object.entries(subsystems)) { | |
| 202 | if (data.files.some(sf => sf.endsWith(base) || sf.endsWith('/' + base))) { | |
| 203 | if (!data.loopCount) data.loopCount = 0 | |
| 204 | if (!data.correctionCount) data.correctionCount = 0 | |
| 205 | if (!data.loopTypes) data.loopTypes = {} | |
| 206 | data.loopCount++ | |
| 207 | data.correctionCount += loop.correctionCount | |
| 208 | data.loopTypes[loop.type] = (data.loopTypes[loop.type] || 0) + 1 | |
| 209 | break | |
| 210 | } | |
| 211 | } | |
| 212 | } | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | // ─── Staleness Detection ─── | |
| 217 | ||
| 218 | function detectChanges (subsystems, projectRoot, previousMap) { | |
| 219 | if (!previousMap) return null | |
| 220 | ||
| 221 | const prevFiles = new Set() | |
| 222 | for (const data of Object.values(previousMap.subsystems || {})) { | |
| 223 | for (const f of (data.files || [])) prevFiles.add(f) | |
| 224 | } | |
| 225 | ||
| 226 | const currentFiles = new Set() | |
| 227 | for (const data of Object.values(subsystems)) { | |
| 228 | for (const f of data.files) currentFiles.add(f) | |
| 229 | } | |
| 230 | ||
| 231 | const added = [...currentFiles].filter(f => !prevFiles.has(f)) | |
| 232 | const removed = [...prevFiles].filter(f => !currentFiles.has(f)) | |
| 233 | ||
| 234 | // Check modification times for existing files | |
| 235 | const modified = [] | |
| 236 | if (previousMap.scannedAt) { | |
| 237 | const lastScan = new Date(previousMap.scannedAt).getTime() | |
| 238 | for (const f of currentFiles) { | |
| 239 | if (prevFiles.has(f)) { | |
| 240 | try { | |
| 241 | const stat = fs.statSync(path.join(projectRoot, f)) | |
| 242 | if (stat.mtimeMs > lastScan) modified.push(f) | |
| 243 | } catch {} | |
| 244 | } | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | if (added.length === 0 && removed.length === 0 && modified.length === 0) return null | |
| 249 | ||
| 250 | return { added, removed, modified } | |
| 251 | } | |
| 252 | ||
| 253 | // ─── Main ─── | |
| 254 | ||
| 255 | function mapProject (projectName, projectRoot, previousMaps) { | |
| 256 | if (!fs.existsSync(projectRoot)) return null | |
| 257 | ||
| 258 | const files = scanDir(projectRoot) | |
| 259 | const subsystems = clusterSubsystems(files, projectRoot) | |
| 260 | buildDependencyGraph(files, subsystems, projectRoot) | |
| 261 | crossReferenceLoops(subsystems, projectName) | |
| 262 | ||
| 263 | // Convert Sets to arrays for JSON | |
| 264 | const serialized = {} | |
| 265 | for (const [name, data] of Object.entries(subsystems)) { | |
| 266 | serialized[name] = { | |
| 267 | files: data.files, | |
| 268 | fileCount: data.files.length, | |
| 269 | deps: [...data.deps], | |
| 270 | dependedBy: [...data.dependedBy], | |
| 271 | loopCount: data.loopCount || 0, | |
| 272 | correctionCount: data.correctionCount || 0, | |
| 273 | loopTypes: data.loopTypes || {}, | |
| 274 | dangerLevel: data.loopCount > 10 ? 'high' : data.loopCount > 3 ? 'medium' : 'low', | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | const changes = detectChanges(subsystems, projectRoot, previousMaps?.[projectName]) | |
| 279 | ||
| 280 | return { | |
| 281 | root: projectRoot, | |
| 282 | scannedAt: new Date().toISOString(), | |
| 283 | totalFiles: files.length, | |
| 284 | subsystems: serialized, | |
| 285 | changes, | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | function main () { | |
| 290 | if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) | |
| 291 | ||
| 292 | const verbose = process.argv.includes('--verbose') | |
| 293 | const projectArg = process.argv.find(a => a.startsWith('--project=')) | |
| 294 | const targetProject = projectArg ? projectArg.split('=')[1] : null | |
| 295 | ||
| 296 | // Load previous maps for change detection | |
| 297 | let previousMaps = {} | |
| 298 | if (fs.existsSync(OUTPUT_FILE)) { | |
| 299 | try { previousMaps = JSON.parse(fs.readFileSync(OUTPUT_FILE, 'utf8')).projects || {} } | |
| 300 | catch {} | |
| 301 | } | |
| 302 | ||
| 303 | const projects = targetProject | |
| 304 | ? { [targetProject]: PROJECT_ROOTS[targetProject] } | |
| 305 | : PROJECT_ROOTS | |
| 306 | ||
| 307 | console.log('═'.repeat(60)) | |
| 308 | console.log(' PROJECT MAPPER — Subsystem Autodiscovery') | |
| 309 | console.log('═'.repeat(60)) | |
| 310 | ||
| 311 | const allMaps = {} | |
| 312 | ||
| 313 | for (const [name, root] of Object.entries(projects)) { | |
| 314 | if (!root) { console.log(` Unknown project: ${name}`); continue } | |
| 315 | ||
| 316 | const map = mapProject(name, root, previousMaps) | |
| 317 | if (!map) { console.log(` ${name}: directory not found`); continue } | |
| 318 | ||
| 319 | allMaps[name] = map | |
| 320 | ||
| 321 | const subs = Object.entries(map.subsystems).sort((a, b) => b[1].fileCount - a[1].fileCount) | |
| 322 | ||
| 323 | console.log(`\n ${name} (${map.totalFiles} files, ${subs.length} subsystems)`) | |
| 324 | console.log(' ' + '-'.repeat(56)) | |
| 325 | ||
| 326 | for (const [sub, data] of subs) { | |
| 327 | const danger = data.dangerLevel === 'high' ? '⚠' : data.dangerLevel === 'medium' ? '~' : ' ' | |
| 328 | const loops = data.loopCount > 0 ? ` ${data.loopCount} loops` : '' | |
| 329 | const deps = data.deps.length > 0 ? ` → ${data.deps.join(', ')}` : '' | |
| 330 | console.log(` ${danger} ${sub.padEnd(22)} ${String(data.fileCount).padStart(3)} files${loops}${verbose ? deps : ''}`) | |
| 331 | } | |
| 332 | ||
| 333 | if (map.changes) { | |
| 334 | console.log(`\n CHANGES since last scan:`) | |
| 335 | if (map.changes.added.length > 0) console.log(` + ${map.changes.added.length} new files`) | |
| 336 | if (map.changes.removed.length > 0) console.log(` - ${map.changes.removed.length} removed files`) | |
| 337 | if (map.changes.modified.length > 0) console.log(` ~ ${map.changes.modified.length} modified files`) | |
| 338 | if (verbose) { | |
| 339 | for (const f of map.changes.added.slice(0, 5)) console.log(` + ${f}`) | |
| 340 | for (const f of map.changes.removed.slice(0, 5)) console.log(` - ${f}`) | |
| 341 | for (const f of map.changes.modified.slice(0, 5)) console.log(` ~ ${f}`) | |
| 342 | } | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | // Save | |
| 347 | const report = { | |
| 348 | generated: new Date().toISOString(), | |
| 349 | projectCount: Object.keys(allMaps).length, | |
| 350 | projects: allMaps, | |
| 351 | } | |
| 352 | fs.writeFileSync(OUTPUT_FILE, JSON.stringify(report, null, 2)) | |
| 353 | console.log(`\nSaved: ${OUTPUT_FILE}`) | |
| 354 | console.log('═'.repeat(60)) | |
| 355 | } | |
| 356 | ||
| 357 | module.exports = { mapProject, PROJECT_ROOTS } | |
| 358 | ||
| 359 | if (require.main === module) main() |
| … | … | @@ -0,0 +1,504 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Realtime Session Scorer — Knowledge Ratchet Phase 2 | |
| 4 | // | |
| 5 | // Maintains a live quality score during Claude Code sessions. | |
| 6 | // Called from hooks (UserPromptSubmit, PostToolUse, Stop) and status line. | |
| 7 | // | |
| 8 | // Usage: | |
| 9 | // echo '{"prompt":"..."}' | node realtime-score.js prompt # UserPromptSubmit hook | |
| 10 | // echo '{"tool_name":"..."}' | node realtime-score.js tool # PostToolUse hook | |
| 11 | // node realtime-score.js stop # Stop hook (session end) | |
| 12 | // node realtime-score.js status # Print current score (for statusline) | |
| 13 | // node realtime-score.js reset # Reset state (SessionStart) | |
| 14 | ||
| 15 | const fs = require('fs') | |
| 16 | const path = require('path') | |
| 17 | ||
| 18 | const STATE_FILE = '/tmp/claude-session-score.json' | |
| 19 | const DATA_DIR = path.join(__dirname, 'data') | |
| 20 | const TUNED_PATTERNS_FILE = path.join(DATA_DIR, 'tuned-patterns.json') | |
| 21 | const LOOPS_FILE = path.join(DATA_DIR, 'loops.json') | |
| 22 | ||
| 23 | // ─── Correction/Approval Detection ─── | |
| 24 | // Loads tuned patterns from tune-regex.js if available | |
| 25 | ||
| 26 | function loadTunedPatterns () { | |
| 27 | if (fs.existsSync(TUNED_PATTERNS_FILE)) { | |
| 28 | try { | |
| 29 | const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8')) | |
| 30 | const corrPatterns = [] | |
| 31 | for (const tier of ['high', 'medium']) { | |
| 32 | for (const p of (tuned.correction?.[tier] || [])) { | |
| 33 | corrPatterns.push(new RegExp(p.pattern, p.flags || 'i')) | |
| 34 | } | |
| 35 | } | |
| 36 | const apprPatterns = [] | |
| 37 | for (const p of (tuned.approval?.high || [])) { | |
| 38 | apprPatterns.push(new RegExp(p.pattern, p.flags || 'i')) | |
| 39 | } | |
| 40 | if (corrPatterns.length > 0) return { correction: corrPatterns, approval: apprPatterns } | |
| 41 | } catch {} | |
| 42 | } | |
| 43 | return null | |
| 44 | } | |
| 45 | ||
| 46 | const _tuned = loadTunedPatterns() | |
| 47 | ||
| 48 | const CORRECTION_PATTERNS = _tuned?.correction || [ | |
| 49 | /\b(no[,.]|wrong|not that|that's not|incorrect|stop doing)\b/i, | |
| 50 | /\b(i said|i meant|i asked for|that's wrong)\b/i, | |
| 51 | /\b(again|already told you|how many times|we discussed this)\b/i, | |
| 52 | /\b(don't ever|never do that|stop|please don't|do not)\b/i, | |
| 53 | /\b(why did you|why are you|that broke|you broke|you just)\b/i, | |
| 54 | /\b(revert|undo|go back|roll back|put it back)\b/i, | |
| 55 | /\b(let me (just |)do it|i'll (just |)do it|forget it|never\s*mind)\b/i, | |
| 56 | ] | |
| 57 | ||
| 58 | const APPROVAL_PATTERNS = _tuned?.approval || [ | |
| 59 | /\b(perfect|exactly|great|lgtm|looks good|nice work|nailed it)\b/i, | |
| 60 | /\b(that works|that's right|correct|good job|well done)\b/i, | |
| 61 | /\b(ship it|merge it|push it|deploy it|commit it)\b/i, | |
| 62 | /\b(thanks|thank you)\b/i, | |
| 63 | ] | |
| 64 | ||
| 65 | const SKIP_PATTERNS = [ | |
| 66 | /^!/, | |
| 67 | /^<command-name>/, | |
| 68 | /^<system-reminder>/, | |
| 69 | /^<task-notification>/, | |
| 70 | /^<local-command/, | |
| 71 | /^Base directory for this skill/, | |
| 72 | /^Implement the following plan/, | |
| 73 | /^This session is being continued/, | |
| 74 | /^Service Unavailable/, | |
| 75 | /^fleeky@\w+ /, | |
| 76 | /tool_use_id/, | |
| 77 | /tool_result/, | |
| 78 | /^Godot Engine v/, | |
| 79 | /^Vulkan \d/, | |
| 80 | ] | |
| 81 | ||
| 82 | const INTERRUPTION_PATTERN = /^\[Request interrupted/ | |
| 83 | ||
| 84 | // ─── State Management ─── | |
| 85 | ||
| 86 | function loadState () { | |
| 87 | if (!fs.existsSync(STATE_FILE)) return createFreshState() | |
| 88 | try { | |
| 89 | return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) | |
| 90 | } catch { | |
| 91 | return createFreshState() | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | function saveState (state) { | |
| 96 | state.lastUpdate = new Date().toISOString() | |
| 97 | fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)) | |
| 98 | } | |
| 99 | ||
| 100 | function createFreshState () { | |
| 101 | return { | |
| 102 | turns: 0, | |
| 103 | score: 0.75, // start neutral-positive | |
| 104 | corrections: 0, | |
| 105 | approvals: 0, | |
| 106 | toolCalls: 0, | |
| 107 | buildErrors: 0, | |
| 108 | interruptions: 0, | |
| 109 | editedFiles: [], | |
| 110 | revertedEdits: 0, | |
| 111 | turnScores: [], | |
| 112 | trajectory: 'stable', | |
| 113 | lastUpdate: new Date().toISOString(), | |
| 114 | // Track last turn's edits for survival check | |
| 115 | _lastTurnEdits: [], | |
| 116 | _lastTurnHadCorrection: false, | |
| 117 | // Loop detection | |
| 118 | _recentCorrections: [], // last 5 correction texts | |
| 119 | _loopActive: false, | |
| 120 | _loopMatches: null, | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | // ─── Loop Matching ─── | |
| 125 | ||
| 126 | function checkForLoop (state) { | |
| 127 | // A loop is forming when 2+ corrections in the last 5 turns | |
| 128 | const recentCorr = state._recentCorrections || [] | |
| 129 | if (recentCorr.length < 2) return null | |
| 130 | ||
| 131 | // Build fingerprint from current context | |
| 132 | const cwd = state._cwd || process.cwd() | |
| 133 | const files = (state.editedFiles || []).slice(-10) | |
| 134 | ||
| 135 | // Classify project from cwd/files | |
| 136 | const all = (cwd + ' ' + files.join(' ')).toLowerCase() | |
| 137 | let project = 'unknown' | |
| 138 | if (all.includes('dc-terrain') || all.includes('dc_terrain')) project = 'dc-terrain' | |
| 139 | else if (all.includes('hyper-tps')) project = 'hyper-tps' | |
| 140 | else if (all.includes('hypernote')) project = 'hypernote' | |
| 141 | else if (all.includes('pear-git')) project = 'pear-git' | |
| 142 | else if (all.includes('blender-bridge') || all.includes('blender_bridge')) project = 'blender-bridge' | |
| 143 | else if (all.includes('proc-nodes')) project = 'proc-nodes' | |
| 144 | else if (all.includes('hyper-overlay')) project = 'hyper-overlay' | |
| 145 | else if (all.includes('godot')) project = 'godot' | |
| 146 | ||
| 147 | // Classify type from correction texts | |
| 148 | const corrText = recentCorr.join(' ').toLowerCase() | |
| 149 | let type = 'other' | |
| 150 | if (/invisible|not (visible|showing|rendering)|can.t see|flat/.test(corrText)) type = 'not-rendering' | |
| 151 | else if (/crash|error|fail|exception/.test(corrText)) type = 'runtime-error' | |
| 152 | else if (/same|still|worse|didn.t (work|help|fix)/.test(corrText)) type = 'fix-failed' | |
| 153 | else if (/wrong|reversed|flipped|incorrect/.test(corrText)) type = 'wrong-output' | |
| 154 | else if (/why (are|is)|approach|design/.test(corrText)) type = 'wrong-approach' | |
| 155 | else if (/audit|planning|think deeper/.test(corrText)) type = 'needs-rethink' | |
| 156 | ||
| 157 | // Build fingerprint | |
| 158 | const path_ = require('path') | |
| 159 | const stems = [...new Set(files.map(f => { | |
| 160 | const base = path_.basename(f) | |
| 161 | return base.replace(/\.(js|cpp|h|gd|glsl|gdshader|tscn|tres)$/, '') | |
| 162 | }))].slice(0, 5).sort() | |
| 163 | const fingerprint = `${project}:${type}:${stems.join(',')}` | |
| 164 | ||
| 165 | // Query past loops | |
| 166 | if (!fs.existsSync(LOOPS_FILE)) return { fingerprint, matches: [] } | |
| 167 | ||
| 168 | try { | |
| 169 | const loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8')) | |
| 170 | const pastLoops = (loopsData.loops || []).filter(l => l.resolved) | |
| 171 | ||
| 172 | const [proj, typ, filesStr] = fingerprint.split(':') | |
| 173 | const currentFiles = new Set(filesStr.split(',').filter(Boolean)) | |
| 174 | ||
| 175 | const matches = pastLoops | |
| 176 | .map(loop => { | |
| 177 | const [lProj, lType, lFilesStr] = (loop.fingerprint || '').split(':') | |
| 178 | const loopFiles = new Set((lFilesStr || '').split(',').filter(Boolean)) | |
| 179 | let score = 0 | |
| 180 | if (lProj === proj) score += 3 | |
| 181 | if (lType === typ) score += 2 | |
| 182 | const overlap = [...currentFiles].filter(f => loopFiles.has(f)).length | |
| 183 | score += overlap * 2 | |
| 184 | return { loop, score } | |
| 185 | }) | |
| 186 | .filter(s => s.score >= 3) | |
| 187 | .sort((a, b) => b.score - a.score) | |
| 188 | .slice(0, 2) | |
| 189 | .map(s => ({ | |
| 190 | score: s.score, | |
| 191 | corrections: (s.loop.corrections || []).slice(0, 2), | |
| 192 | resolutionFiles: (s.loop.resolution?.files || []).map(f => path_.basename(f)), | |
| 193 | approval: s.loop.resolution?.approval?.slice(0, 150), | |
| 194 | date: s.loop.date, | |
| 195 | })) | |
| 196 | ||
| 197 | return { fingerprint, matches } | |
| 198 | } catch { | |
| 199 | return { fingerprint, matches: [] } | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | // ─── Score Computation ─── | |
| 204 | ||
| 205 | function computeScore (state) { | |
| 206 | if (state.turns === 0) return 0.75 | |
| 207 | ||
| 208 | // Session-level signals (same approach as fuse-signals.js) | |
| 209 | const signals = {} | |
| 210 | ||
| 211 | // Correction rate — 0% = 1.0, 5% = 0.75, 10% = 0.5, 20%+ = 0.1 | |
| 212 | const correctionRate = state.corrections / Math.max(1, state.turns) | |
| 213 | signals.correctionRate = Math.max(0.1, 1.0 - correctionRate * 5) | |
| 214 | ||
| 215 | // Approval rate | |
| 216 | const approvalRate = state.approvals / Math.max(1, state.turns) | |
| 217 | signals.approvalRate = Math.min(1.0, 0.3 + approvalRate * 3) | |
| 218 | ||
| 219 | // Correction-to-approval ratio | |
| 220 | const totalSignals = state.corrections + state.approvals | |
| 221 | signals.signalRatio = totalSignals > 0 | |
| 222 | ? state.approvals / totalSignals | |
| 223 | : 0.5 | |
| 224 | ||
| 225 | // Tool call density — avg tool calls per turn | |
| 226 | const avgToolCalls = state.toolCalls / Math.max(1, state.turns) | |
| 227 | signals.toolEfficiency = avgToolCalls <= 3 ? 1.0 | |
| 228 | : avgToolCalls <= 6 ? 0.8 | |
| 229 | : avgToolCalls <= 10 ? 0.6 | |
| 230 | : 0.4 | |
| 231 | ||
| 232 | // Build error rate | |
| 233 | const errorRate = state.buildErrors / Math.max(1, state.toolCalls) | |
| 234 | signals.buildQuality = Math.max(0.2, 1.0 - errorRate * 5) | |
| 235 | ||
| 236 | // Edit survival | |
| 237 | const totalEdits = state.editedFiles.length | |
| 238 | if (totalEdits > 0) { | |
| 239 | signals.editSurvival = Math.max(0.2, 1.0 - (state.revertedEdits / totalEdits)) | |
| 240 | } else { | |
| 241 | signals.editSurvival = 0.7 | |
| 242 | } | |
| 243 | ||
| 244 | // Turn score trajectory — requires both improving turns AND decent overall score | |
| 245 | if (state.turnScores.length >= 4) { | |
| 246 | const recent = state.turnScores.slice(-3) | |
| 247 | const earlier = state.turnScores.slice(-6, -3) | |
| 248 | if (earlier.length > 0) { | |
| 249 | const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length | |
| 250 | const earlierAvg = earlier.reduce((a, b) => a + b, 0) / earlier.length | |
| 251 | const delta = recentAvg - earlierAvg | |
| 252 | if (delta > 0.05 && state.score > 0.5 && state.approvals > 0) state.trajectory = 'improving' | |
| 253 | else if (delta < -0.05 || (state.corrections > 2 && state.approvals === 0)) state.trajectory = 'declining' | |
| 254 | else state.trajectory = 'stable' | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | // Weighted average of signals | |
| 259 | const weights = { | |
| 260 | correctionRate: 1.0, | |
| 261 | approvalRate: 0.6, | |
| 262 | signalRatio: 0.8, | |
| 263 | toolEfficiency: 0.3, | |
| 264 | buildQuality: 0.5, | |
| 265 | editSurvival: 0.4, | |
| 266 | } | |
| 267 | ||
| 268 | let weightedSum = 0 | |
| 269 | let totalWeight = 0 | |
| 270 | for (const [key, value] of Object.entries(signals)) { | |
| 271 | const w = weights[key] || 0.3 | |
| 272 | weightedSum += value * w | |
| 273 | totalWeight += w | |
| 274 | } | |
| 275 | ||
| 276 | return totalWeight > 0 ? Math.round((weightedSum / totalWeight) * 1000) / 1000 : 0.5 | |
| 277 | } | |
| 278 | ||
| 279 | // ─── Hook Handlers ─── | |
| 280 | ||
| 281 | async function handlePrompt () { | |
| 282 | let input = '' | |
| 283 | for await (const chunk of process.stdin) { | |
| 284 | input += chunk | |
| 285 | } | |
| 286 | ||
| 287 | let userText = '' | |
| 288 | try { | |
| 289 | const data = JSON.parse(input) | |
| 290 | userText = data.prompt || data.user_prompt || data.message || '' | |
| 291 | } catch { | |
| 292 | return // can't parse, skip | |
| 293 | } | |
| 294 | ||
| 295 | if (!userText || userText.length < 3) return | |
| 296 | if (SKIP_PATTERNS.some(p => p.test(userText))) return | |
| 297 | ||
| 298 | const state = loadState() | |
| 299 | ||
| 300 | // Check for correction/approval | |
| 301 | const isCorrection = CORRECTION_PATTERNS.some(p => p.test(userText)) | |
| 302 | const isApproval = APPROVAL_PATTERNS.some(p => p.test(userText)) | |
| 303 | const isInterruption = INTERRUPTION_PATTERN.test(userText) | |
| 304 | ||
| 305 | if (isCorrection) state.corrections++ | |
| 306 | if (isApproval) state.approvals++ | |
| 307 | if (isInterruption) state.interruptions++ | |
| 308 | ||
| 309 | // Check edit survival — if this turn is a correction and last turn had edits, | |
| 310 | // those edits likely didn't survive | |
| 311 | if (isCorrection && state._lastTurnEdits.length > 0) { | |
| 312 | state.revertedEdits += state._lastTurnEdits.length | |
| 313 | } | |
| 314 | ||
| 315 | // ─── Loop Detection ─── | |
| 316 | if (!state._recentCorrections) state._recentCorrections = [] | |
| 317 | ||
| 318 | if (isCorrection) { | |
| 319 | state._recentCorrections.push(userText.slice(0, 200)) | |
| 320 | if (state._recentCorrections.length > 5) state._recentCorrections.shift() | |
| 321 | ||
| 322 | // Check if a loop is forming (2+ corrections in recent history) | |
| 323 | if (state._recentCorrections.length >= 2) { | |
| 324 | const loopInfo = checkForLoop(state) | |
| 325 | if (loopInfo) { | |
| 326 | state._loopActive = true | |
| 327 | state._loopMatches = loopInfo.matches.length > 0 ? loopInfo : null | |
| 328 | ||
| 329 | // Output loop warning to stderr (Claude sees this) | |
| 330 | if (loopInfo.matches.length > 0) { | |
| 331 | const match = loopInfo.matches[0] | |
| 332 | const msg = [ | |
| 333 | '', | |
| 334 | '⚠ LOOP DETECTED — you\'ve been here before', | |
| 335 | ` Pattern: ${loopInfo.fingerprint}`, | |
| 336 | ` Past match (${match.date}): "${match.corrections[0]?.slice(0, 80)}"`, | |
| 337 | ] | |
| 338 | if (match.resolutionFiles.length > 0) { | |
| 339 | msg.push(` What fixed it: ${match.resolutionFiles.join(', ')}`) | |
| 340 | } | |
| 341 | if (match.approval) { | |
| 342 | msg.push(` Resolution: "${match.approval.slice(0, 100)}"`) | |
| 343 | } | |
| 344 | msg.push('') | |
| 345 | process.stderr.write(msg.join('\n')) | |
| 346 | } | |
| 347 | } | |
| 348 | } | |
| 349 | } else if (isApproval) { | |
| 350 | // Approval breaks the loop | |
| 351 | state._recentCorrections = [] | |
| 352 | state._loopActive = false | |
| 353 | state._loopMatches = null | |
| 354 | } | |
| 355 | ||
| 356 | // Compute per-turn score | |
| 357 | let turnScore = 0.7 // neutral | |
| 358 | if (isCorrection) turnScore = 0.2 | |
| 359 | else if (isApproval) turnScore = 1.0 | |
| 360 | else if (isInterruption) turnScore = 0.1 | |
| 361 | ||
| 362 | state.turns++ | |
| 363 | state.turnScores.push(turnScore) | |
| 364 | // Keep only last 50 turn scores to bound memory | |
| 365 | if (state.turnScores.length > 50) state.turnScores = state.turnScores.slice(-50) | |
| 366 | ||
| 367 | // Reset per-turn tracking | |
| 368 | state._lastTurnEdits = [] | |
| 369 | state._lastTurnHadCorrection = isCorrection | |
| 370 | state._cwd = process.cwd() | |
| 371 | ||
| 372 | // Recompute session score | |
| 373 | state.score = computeScore(state) | |
| 374 | saveState(state) | |
| 375 | } | |
| 376 | ||
| 377 | async function handleTool () { | |
| 378 | let input = '' | |
| 379 | for await (const chunk of process.stdin) { | |
| 380 | input += chunk | |
| 381 | } | |
| 382 | ||
| 383 | let data | |
| 384 | try { | |
| 385 | data = JSON.parse(input) | |
| 386 | } catch { | |
| 387 | return | |
| 388 | } | |
| 389 | ||
| 390 | const state = loadState() | |
| 391 | state.toolCalls++ | |
| 392 | ||
| 393 | const toolName = data.tool_name || '' | |
| 394 | const toolInput = data.tool_input || {} | |
| 395 | const toolResult = data.tool_result || '' | |
| 396 | ||
| 397 | // Track edited files for survival analysis | |
| 398 | if (toolName === 'Edit' || toolName === 'Write') { | |
| 399 | const fp = toolInput.file_path || '' | |
| 400 | if (fp) { | |
| 401 | state.editedFiles.push(fp) | |
| 402 | state._lastTurnEdits.push(fp) | |
| 403 | } | |
| 404 | } | |
| 405 | ||
| 406 | // Detect build/test errors in tool results | |
| 407 | const resultText = typeof toolResult === 'string' ? toolResult | |
| 408 | : typeof toolResult === 'object' ? JSON.stringify(toolResult) | |
| 409 | : '' | |
| 410 | ||
| 411 | if (toolName === 'Bash' && resultText.length > 0) { | |
| 412 | if (/error|Error|ERROR|FAIL|fatal|panic|exception|TypeError|ReferenceError|SyntaxError/i.test(resultText)) { | |
| 413 | // Check it's actually an error, not just the word "error" in normal output | |
| 414 | if (!/no errors?|error.*(free|safe|handling|message|log)|fix.*error/i.test(resultText)) { | |
| 415 | state.buildErrors++ | |
| 416 | } | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | // Recompute score periodically (every 5 tool calls to stay fast) | |
| 421 | if (state.toolCalls % 5 === 0) { | |
| 422 | state.score = computeScore(state) | |
| 423 | } | |
| 424 | ||
| 425 | saveState(state) | |
| 426 | } | |
| 427 | ||
| 428 | function handleStop () { | |
| 429 | const state = loadState() | |
| 430 | state.score = computeScore(state) | |
| 431 | state.endTime = new Date().toISOString() | |
| 432 | saveState(state) | |
| 433 | ||
| 434 | // Write session summary to data dir for historical tracking | |
| 435 | const summaryDir = path.join(__dirname, 'data', 'session-summaries') | |
| 436 | if (!fs.existsSync(summaryDir)) fs.mkdirSync(summaryDir, { recursive: true }) | |
| 437 | ||
| 438 | const summary = { | |
| 439 | score: state.score, | |
| 440 | turns: state.turns, | |
| 441 | corrections: state.corrections, | |
| 442 | approvals: state.approvals, | |
| 443 | toolCalls: state.toolCalls, | |
| 444 | buildErrors: state.buildErrors, | |
| 445 | interruptions: state.interruptions, | |
| 446 | revertedEdits: state.revertedEdits, | |
| 447 | trajectory: state.trajectory, | |
| 448 | endTime: state.endTime, | |
| 449 | } | |
| 450 | ||
| 451 | const summaryFile = path.join(summaryDir, `${new Date().toISOString().slice(0, 10)}-${Date.now()}.json`) | |
| 452 | fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2)) | |
| 453 | } | |
| 454 | ||
| 455 | function handleStatus () { | |
| 456 | const state = loadState() | |
| 457 | ||
| 458 | // Trajectory arrow | |
| 459 | const arrow = state.trajectory === 'improving' ? '▲' | |
| 460 | : state.trajectory === 'declining' ? '▼' | |
| 461 | : '▬' | |
| 462 | ||
| 463 | // Format: ▬ 0.72 │ 5 approved 2 corrected │ 34 tools │ ⟳LOOP | |
| 464 | const parts = [ | |
| 465 | `${arrow} ${state.score.toFixed(2)}`, | |
| 466 | `${state.approvals} approved ${state.corrections} corrected`, | |
| 467 | `${state.toolCalls} tools`, | |
| 468 | ] | |
| 469 | ||
| 470 | if (state._loopActive) { | |
| 471 | parts.push('⟳LOOP') | |
| 472 | } | |
| 473 | ||
| 474 | process.stdout.write(parts.join(' │ ')) | |
| 475 | } | |
| 476 | ||
| 477 | function handleReset () { | |
| 478 | saveState(createFreshState()) | |
| 479 | } | |
| 480 | ||
| 481 | // ─── CLI ─── | |
| 482 | ||
| 483 | const command = process.argv[2] || 'status' | |
| 484 | ||
| 485 | switch (command) { | |
| 486 | case 'prompt': | |
| 487 | handlePrompt().catch(() => process.exit(0)) | |
| 488 | break | |
| 489 | case 'tool': | |
| 490 | handleTool().catch(() => process.exit(0)) | |
| 491 | break | |
| 492 | case 'stop': | |
| 493 | handleStop() | |
| 494 | break | |
| 495 | case 'status': | |
| 496 | handleStatus() | |
| 497 | break | |
| 498 | case 'reset': | |
| 499 | handleReset() | |
| 500 | break | |
| 501 | default: | |
| 502 | console.error(`Usage: realtime-score.js [prompt|tool|stop|status|reset]`) | |
| 503 | process.exit(1) | |
| 504 | } |
| … | … | @@ -11,6 +11,7 @@ const path = require('path') |
| 11 | 11 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') |
| 12 | 12 | const PRINCIPLES_DATA = path.join(__dirname, 'data', 'principles.json') |
| 13 | 13 | const OVERRIDES_FILE = path.join(__dirname, 'data', 'overrides.json') |
| 14 | const FUSED_SCORES_FILE = path.join(__dirname, 'data', 'fused-scores.json') | |
| 14 | 15 | const SCORECARD_FILE = path.join(__dirname, 'data', 'scorecard.tsv') |
| 15 | 16 | const REPORT_FILE = path.join(__dirname, 'data', 'regression-report.json') |
| 16 | 17 | const QUESTIONS_DIR = path.join(__dirname, 'questions') |
| … | … | @@ -103,16 +104,23 @@ function scorePrinciples (principles, corrections, overrides) { |
| 103 | 104 | return scores.sort((a, b) => a.score - b.score) |
| 104 | 105 | } |
| 105 | 106 | |
| 106 | function writeScorecard (scores, weeks) { | |
| 107 | function loadFusedScores () { | |
| 108 | if (!fs.existsSync(FUSED_SCORES_FILE)) return null | |
| 109 | try { return JSON.parse(fs.readFileSync(FUSED_SCORES_FILE, 'utf8')) } | |
| 110 | catch { return null } | |
| 111 | } | |
| 112 | ||
| 113 | function writeScorecard (scores, weeks, fusedWeekly) { | |
| 107 | 114 | const lines = ['principle\tcorrections\tapprovals\toverrides\tscore\tstatus'] |
| 108 | 115 | for (const s of scores) { |
| 109 | 116 | lines.push(`${s.principle.slice(0, 60)}\t${s.corrections}\t${s.approvals}\t${s.overrides}\t${s.score.toFixed(2)}\t${s.status}`) |
| 110 | 117 | } |
| 111 | 118 | lines.push('') |
| 112 | lines.push('# Weekly trend') | |
| 113 | lines.push('week\tcorrections\tapprovals') | |
| 119 | lines.push('# Weekly trend (corrections + fused score)') | |
| 120 | lines.push('week\tcorrections\tapprovals\tfused_score') | |
| 114 | 121 | for (const [week, data] of Object.entries(weeks).sort()) { |
| 115 | lines.push(`${week}\t${data.corrections}\t${data.approvals}`) | |
| 122 | const fused = fusedWeekly?.[week]?.avgScore | |
| 123 | lines.push(`${week}\t${data.corrections}\t${data.approvals}\t${fused != null ? fused.toFixed(3) : 'n/a'}`) | |
| 116 | 124 | } |
| 117 | 125 | fs.writeFileSync(SCORECARD_FILE, lines.join('\n') + '\n') |
| 118 | 126 | } |
| … | … | @@ -124,51 +132,64 @@ function writeQuestion (id, text) { |
| 124 | 132 | } |
| 125 | 133 | |
| 126 | 134 | function main () { |
| 127 | const corrections = loadCorrections() | |
| 128 | 135 | const principles = loadPrinciples() |
| 129 | 136 | const overrides = loadOverrides() |
| 130 | 137 | |
| 131 | if (corrections.length === 0) { | |
| 132 | console.log('No corrections logged yet. Run some conversations first.') | |
| 133 | console.log(`Log file: ${CORRECTIONS_LOG}`) | |
| 138 | // PRIMARY DATA SOURCE: fused-scores.json (LLM-weighted, per-session) | |
| 139 | const fusedData = loadFusedScores() | |
| 140 | if (!fusedData) { | |
| 141 | console.log('No fused-scores.json found. Run fuse-signals.js first.') | |
| 134 | 142 | return |
| 135 | 143 | } |
| 136 | 144 | |
| 137 | // Filter to only corrections AFTER principles were established | |
| 138 | // Historical corrections (seed data) are training data, not failures | |
| 139 | const principlesEstablished = '2026-04-07' // date principles were first validated | |
| 140 | const recentCorrections = corrections.filter(c => c.timestamp >= principlesEstablished) | |
| 141 | const historicalCorrections = corrections.filter(c => c.timestamp < principlesEstablished) | |
| 145 | const fusedWeekly = fusedData.weekly || {} | |
| 146 | const totalCorr = fusedData.sessions.reduce((s, sess) => s + sess.corrections, 0) | |
| 147 | const totalAppr = fusedData.sessions.reduce((s, sess) => s + sess.approvals, 0) | |
| 142 | 148 | |
| 143 | console.log(`Corrections log: ${corrections.length} entries (${historicalCorrections.length} historical, ${recentCorrections.length} since principles established)`) | |
| 149 | console.log(`Fused scores: ${fusedData.sessions.length} sessions, ${totalCorr} corrections, ${totalAppr} approvals`) | |
| 144 | 150 | console.log(`Active principles: ${principles.filter(p => p.status === 'active').length}`) |
| 145 | 151 | |
| 146 | if (recentCorrections.length === 0) { | |
| 147 | console.log('\nNo corrections since principles were established. System is clean.') | |
| 148 | console.log('Historical corrections are training data — not counted as failures.') | |
| 152 | // Score principles — use fused session data instead of corrections.log | |
| 153 | // Group sessions by week for weekly corrections/approvals | |
| 154 | const weeks = {} | |
| 155 | for (const session of fusedData.sessions) { | |
| 156 | if (!session.date) continue | |
| 157 | const d = new Date(session.date) | |
| 158 | d.setDate(d.getDate() - d.getDay()) | |
| 159 | const weekKey = d.toISOString().slice(0, 10) | |
| 160 | if (!weeks[weekKey]) weeks[weekKey] = { corrections: 0, approvals: 0 } | |
| 161 | weeks[weekKey].corrections += session.corrections | |
| 162 | weeks[weekKey].approvals += session.approvals | |
| 149 | 163 | } |
| 150 | 164 | |
| 151 | // Score principles against RECENT corrections only | |
| 152 | const scores = scorePrinciples(principles, recentCorrections, overrides) | |
| 153 | ||
| 154 | // Weekly trend shows all corrections for context | |
| 155 | const weeks = groupByWeek(corrections) | |
| 165 | // Principle scoring using fused session data | |
| 166 | // Note: without per-correction text, we can't match principles to specific corrections. | |
| 167 | // Instead, report overall session quality trends. Principle-level attribution requires | |
| 168 | // LLM semantic matching (future work). | |
| 169 | const scores = scorePrinciples(principles, [], overrides) // empty corrections — no keyword matching | |
| 156 | 170 | |
| 157 | 171 | // Write scorecard |
| 158 | writeScorecard(scores, weeks) | |
| 172 | writeScorecard(scores, weeks, fusedWeekly) | |
| 159 | 173 | |
| 160 | 174 | // Print results |
| 161 | 175 | console.log('\n═══ PRINCIPLE SCORECARD ═══') |
| 176 | if (scores.length === 0) { | |
| 177 | console.log(' (no active principles)') | |
| 178 | } | |
| 162 | 179 | for (const s of scores) { |
| 163 | 180 | const icon = s.status === 'HEALTHY' ? '✓' : s.status === 'WEAK' ? '~' : '✗' |
| 164 | 181 | console.log(` ${icon} [${s.score.toFixed(2)}] ${s.principle.slice(0, 60)}`) |
| 165 | 182 | console.log(` corrections: ${s.corrections} approvals: ${s.approvals} overrides: ${s.overrides}`) |
| 166 | 183 | } |
| 167 | 184 | |
| 168 | console.log('\n═══ WEEKLY TREND ═══') | |
| 185 | console.log('\n═══ WEEKLY TREND (from fused scores) ═══') | |
| 169 | 186 | for (const [week, data] of Object.entries(weeks).sort()) { |
| 170 | const bar = '█'.repeat(data.corrections) + '░'.repeat(data.approvals) | |
| 171 | console.log(` ${week} ${bar} corrections: ${data.corrections} approvals: ${data.approvals}`) | |
| 187 | const fused = fusedWeekly?.[week]?.avgScore | |
| 188 | const density = (data.corrections + data.approvals) > 0 | |
| 189 | ? (data.corrections / (data.corrections + data.approvals) * 100).toFixed(0) | |
| 190 | : 'n/a' | |
| 191 | const fusedStr = fused != null ? ` fused: ${fused.toFixed(3)}` : '' | |
| 192 | console.log(` ${week} corr: ${String(data.corrections).padStart(4)} appr: ${String(data.approvals).padStart(4)} corr%: ${density}%${fusedStr}`) | |
| 172 | 193 | } |
| 173 | 194 | |
| 174 | 195 | // Flag degraded principles |
| … | … | @@ -190,10 +211,28 @@ function main () { |
| 190 | 211 | } |
| 191 | 212 | } |
| 192 | 213 | |
| 214 | // Fused score summary | |
| 215 | if (fusedWeekly) { | |
| 216 | const fusedWeeks = Object.entries(fusedWeekly).sort() | |
| 217 | if (fusedWeeks.length >= 2) { | |
| 218 | const recent = fusedWeeks[fusedWeeks.length - 1][1] | |
| 219 | const prev = fusedWeeks[fusedWeeks.length - 2][1] | |
| 220 | const delta = recent.avgScore - prev.avgScore | |
| 221 | console.log('\n═══ FUSED QUALITY TREND ═══') | |
| 222 | console.log(` Previous week: ${prev.avgScore.toFixed(3)} (${prev.sessions} sessions)`) | |
| 223 | console.log(` Current week: ${recent.avgScore.toFixed(3)} (${recent.sessions} sessions)`) | |
| 224 | console.log(` Delta: ${delta >= 0 ? '+' : ''}${delta.toFixed(3)}`) | |
| 225 | if (delta < -0.05) console.log(' ⚠ FUSED QUALITY DECLINING') | |
| 226 | else if (delta > 0.05) console.log(' ✓ FUSED QUALITY IMPROVING') | |
| 227 | else console.log(' ~ STABLE') | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 193 | 231 | // Save report |
| 194 | 232 | const report = { |
| 195 | 233 | timestamp: new Date().toISOString(), |
| 196 | 234 | totalCorrections: corrections.length, |
| 235 | fusedWeekly, | |
| 197 | 236 | scores, |
| 198 | 237 | weeks, |
| 199 | 238 | degradedCount: degraded.length |
| … | … | @@ -0,0 +1,239 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Rule Extractor — Auto-Generate Rules from Resolved Loops | |
| 4 | // Knowledge Ratchet Phase 2 | |
| 5 | // | |
| 6 | // Reads loops.json and extracts specific, actionable rules from resolved loops. | |
| 7 | // Each rule has: | |
| 8 | // trigger: { project, subsystem, symptom (from corrections) } | |
| 9 | // action: what fixed it (from resolution files + approval text) | |
| 10 | // antipattern: what was tried and failed (from loop corrections) | |
| 11 | // source: which loop taught this | |
| 12 | // | |
| 13 | // Rules accumulate in data/learned-rules.json. | |
| 14 | // Loaded by loop-briefing.js and preflight-review.js as directives. | |
| 15 | // | |
| 16 | // Usage: | |
| 17 | // node rule-extractor.js # extract rules from all resolved loops | |
| 18 | // node rule-extractor.js --verbose # show rule details | |
| 19 | ||
| 20 | const fs = require('fs') | |
| 21 | const path = require('path') | |
| 22 | ||
| 23 | const DATA_DIR = path.join(__dirname, 'data') | |
| 24 | const LOOPS_FILE = path.join(DATA_DIR, 'loops.json') | |
| 25 | const RULES_FILE = path.join(DATA_DIR, 'learned-rules.json') | |
| 26 | ||
| 27 | function loadJSON (file) { | |
| 28 | if (!fs.existsSync(file)) return null | |
| 29 | try { return JSON.parse(fs.readFileSync(file, 'utf8')) } | |
| 30 | catch { return null } | |
| 31 | } | |
| 32 | ||
| 33 | // ─── Symptom Extraction ─── | |
| 34 | // Extract the core symptom from correction text | |
| 35 | ||
| 36 | function extractSymptom (corrections) { | |
| 37 | if (corrections.length === 0) return 'unknown' | |
| 38 | ||
| 39 | // Use the first correction as the primary symptom | |
| 40 | const text = corrections[0].toLowerCase() | |
| 41 | ||
| 42 | // Try to extract a concise symptom | |
| 43 | if (/invisible|not (visible|showing|rendering)|can.t see|don.t see/.test(text)) return 'not rendering/visible' | |
| 44 | if (/flat|just flat|totally flat/.test(text)) return 'output is flat/empty' | |
| 45 | if (/crash|exception|error|fail/.test(text)) return 'runtime crash/error' | |
| 46 | if (/same|still|no change|didn.t (work|help|fix)/.test(text)) return 'fix did not work' | |
| 47 | if (/worse|regression|broke/.test(text)) return 'change made it worse' | |
| 48 | if (/wrong|reversed|flipped|incorrect/.test(text)) return 'output is wrong/reversed' | |
| 49 | if (/why (are|is|did)|approach/.test(text)) return 'wrong approach/design' | |
| 50 | if (/audit|planning|think deeper/.test(text)) return 'needs deeper analysis' | |
| 51 | if (/stop/.test(text)) return 'going in wrong direction' | |
| 52 | if (/corrupt/.test(text)) return 'data corruption' | |
| 53 | ||
| 54 | // Fallback: first 60 chars of first correction | |
| 55 | return corrections[0].slice(0, 60).toLowerCase() | |
| 56 | } | |
| 57 | ||
| 58 | // ─── Rule Generation ─── | |
| 59 | ||
| 60 | function extractRules (loops) { | |
| 61 | const rules = [] | |
| 62 | const seen = new Set() // dedup by fingerprint | |
| 63 | ||
| 64 | for (const loop of loops) { | |
| 65 | if (!loop.resolved) continue | |
| 66 | if (!loop.corrections || loop.corrections.length === 0) continue | |
| 67 | if (!loop.resolution) continue | |
| 68 | ||
| 69 | // Dedup: same fingerprint = same rule | |
| 70 | if (seen.has(loop.fingerprint)) continue | |
| 71 | seen.add(loop.fingerprint) | |
| 72 | ||
| 73 | const symptom = extractSymptom(loop.corrections) | |
| 74 | const fixFiles = (loop.resolution.files || []).map(f => path.basename(f)) | |
| 75 | const approval = loop.resolution.approval || null | |
| 76 | ||
| 77 | // Build the rule | |
| 78 | const rule = { | |
| 79 | id: `rule-${loop.project}-${loop.type}-${rules.length}`, | |
| 80 | trigger: { | |
| 81 | project: loop.project, | |
| 82 | type: loop.type, | |
| 83 | symptom, | |
| 84 | files: (loop.filesTried || []).slice(0, 5).map(f => path.basename(f)), | |
| 85 | }, | |
| 86 | action: { | |
| 87 | fixFiles, | |
| 88 | description: buildActionDescription(symptom, fixFiles, approval, loop), | |
| 89 | }, | |
| 90 | antipattern: { | |
| 91 | description: buildAntipattern(loop.corrections), | |
| 92 | corrections: loop.corrections.slice(0, 3), | |
| 93 | }, | |
| 94 | source: { | |
| 95 | loopFingerprint: loop.fingerprint, | |
| 96 | date: loop.date, | |
| 97 | duration: loop.duration, | |
| 98 | correctionCount: loop.correctionCount, | |
| 99 | }, | |
| 100 | } | |
| 101 | ||
| 102 | rules.push(rule) | |
| 103 | } | |
| 104 | ||
| 105 | return rules | |
| 106 | } | |
| 107 | ||
| 108 | function buildActionDescription (symptom, fixFiles, approval, loop) { | |
| 109 | const parts = [] | |
| 110 | ||
| 111 | if (fixFiles.length > 0) { | |
| 112 | parts.push(`Fix was in: ${fixFiles.join(', ')}`) | |
| 113 | } | |
| 114 | ||
| 115 | if (approval) { | |
| 116 | parts.push(`Resolution: "${approval.slice(0, 120)}"`) | |
| 117 | } | |
| 118 | ||
| 119 | if (loop.duration > 10) { | |
| 120 | parts.push(`This took ${loop.duration} turns to resolve — enter planning mode early`) | |
| 121 | } | |
| 122 | ||
| 123 | if (parts.length === 0) { | |
| 124 | parts.push('Read the target files completely before making changes') | |
| 125 | } | |
| 126 | ||
| 127 | return parts.join('. ') | |
| 128 | } | |
| 129 | ||
| 130 | function buildAntipattern (corrections) { | |
| 131 | // Extract what NOT to do from the correction sequence | |
| 132 | const patterns = [] | |
| 133 | ||
| 134 | if (corrections.some(c => /stop/i.test(c))) { | |
| 135 | patterns.push('Do not continue without reading the code first') | |
| 136 | } | |
| 137 | if (corrections.some(c => /audit|planning/i.test(c))) { | |
| 138 | patterns.push('Do not implement without planning — enter planning mode') | |
| 139 | } | |
| 140 | if (corrections.some(c => /same|still/i.test(c))) { | |
| 141 | patterns.push('Do not apply the same approach again if it already failed') | |
| 142 | } | |
| 143 | if (corrections.some(c => /worse|regression|broke/i.test(c))) { | |
| 144 | patterns.push('Verify the fix does not break existing functionality') | |
| 145 | } | |
| 146 | if (corrections.some(c => /corrupt/i.test(c))) { | |
| 147 | patterns.push('Check for data corruption before and after changes') | |
| 148 | } | |
| 149 | ||
| 150 | return patterns.length > 0 ? patterns.join('. ') : 'Slow down and verify each step' | |
| 151 | } | |
| 152 | ||
| 153 | // ─── Merge with existing rules ─── | |
| 154 | ||
| 155 | function mergeRules (newRules, existingRules) { | |
| 156 | const existingIds = new Set(existingRules.map(r => r.id)) | |
| 157 | const merged = [...existingRules] | |
| 158 | ||
| 159 | let added = 0 | |
| 160 | for (const rule of newRules) { | |
| 161 | if (!existingIds.has(rule.id)) { | |
| 162 | merged.push(rule) | |
| 163 | added++ | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | return { merged, added } | |
| 168 | } | |
| 169 | ||
| 170 | // ─── Main ─── | |
| 171 | ||
| 172 | function main () { | |
| 173 | const verbose = process.argv.includes('--verbose') | |
| 174 | ||
| 175 | const loopsData = loadJSON(LOOPS_FILE) | |
| 176 | if (!loopsData) { | |
| 177 | console.log('No loops.json found. Run loop-detector.js first.') | |
| 178 | return | |
| 179 | } | |
| 180 | ||
| 181 | const existingRules = loadJSON(RULES_FILE)?.rules || [] | |
| 182 | const newRules = extractRules(loopsData.loops || []) | |
| 183 | const { merged, added } = mergeRules(newRules, existingRules) | |
| 184 | ||
| 185 | // Group by project | |
| 186 | const byProject = {} | |
| 187 | for (const rule of merged) { | |
| 188 | const proj = rule.trigger.project | |
| 189 | if (!byProject[proj]) byProject[proj] = [] | |
| 190 | byProject[proj].push(rule) | |
| 191 | } | |
| 192 | ||
| 193 | console.log('═'.repeat(60)) | |
| 194 | console.log(' RULE EXTRACTOR — Learned Rules from Loops') | |
| 195 | console.log('═'.repeat(60)) | |
| 196 | console.log(` Resolved loops analyzed: ${loopsData.loops.filter(l => l.resolved).length}`) | |
| 197 | console.log(` Rules extracted: ${newRules.length}`) | |
| 198 | console.log(` New rules added: ${added}`) | |
| 199 | console.log(` Total rules: ${merged.length}`) | |
| 200 | ||
| 201 | console.log('\n BY PROJECT') | |
| 202 | console.log(' ' + '-'.repeat(56)) | |
| 203 | for (const [proj, rules] of Object.entries(byProject).sort((a, b) => b[1].length - a[1].length)) { | |
| 204 | console.log(` ${proj}: ${rules.length} rules`) | |
| 205 | if (verbose) { | |
| 206 | for (const r of rules.slice(0, 5)) { | |
| 207 | console.log(` [${r.trigger.type}] "${r.trigger.symptom}"`) | |
| 208 | console.log(` → ${r.action.description.slice(0, 100)}`) | |
| 209 | if (r.antipattern.description) { | |
| 210 | console.log(` ✗ ${r.antipattern.description.slice(0, 100)}`) | |
| 211 | } | |
| 212 | } | |
| 213 | } | |
| 214 | } | |
| 215 | ||
| 216 | // Show top rules by severity (longest loops) | |
| 217 | const bySeverity = [...merged].sort((a, b) => b.source.duration - a.source.duration) | |
| 218 | ||
| 219 | console.log('\n HIGHEST SEVERITY RULES (longest loops)') | |
| 220 | console.log(' ' + '-'.repeat(56)) | |
| 221 | for (const r of bySeverity.slice(0, 10)) { | |
| 222 | console.log(` [${r.source.duration} turns] ${r.trigger.project}/${r.trigger.type}: "${r.trigger.symptom}"`) | |
| 223 | console.log(` → ${r.action.description.slice(0, 120)}`) | |
| 224 | } | |
| 225 | ||
| 226 | // Save | |
| 227 | const report = { | |
| 228 | generated: new Date().toISOString(), | |
| 229 | totalRules: merged.length, | |
| 230 | rules: merged, | |
| 231 | } | |
| 232 | fs.writeFileSync(RULES_FILE, JSON.stringify(report, null, 2)) | |
| 233 | console.log(`\nSaved: ${RULES_FILE}`) | |
| 234 | console.log('═'.repeat(60)) | |
| 235 | } | |
| 236 | ||
| 237 | module.exports = { extractRules, mergeRules } | |
| 238 | ||
| 239 | if (require.main === module) main() |
| … | … | @@ -0,0 +1,258 @@ |
| 1 | // Single-Pass Session Scanner — Knowledge Ratchet Phase 2 | |
| 2 | // | |
| 3 | // Reads JSONL transcript files and extracts structured session data. | |
| 4 | // Uses an mtime-based cache so unchanged files are loaded from disk | |
| 5 | // cache instead of re-parsed. Typical speedup: 10s → <1s. | |
| 6 | // | |
| 7 | // This is a library — not a standalone script. Used by: | |
| 8 | // - fuse-signals.js (sensor fusion scoring) | |
| 9 | // - analyze-transcripts.js (deep sentiment analysis) | |
| 10 | // - pipeline.js (merged pipeline) | |
| 11 | ||
| 12 | const fs = require('fs') | |
| 13 | const path = require('path') | |
| 14 | const readline = require('readline') | |
| 15 | ||
| 16 | const SESSIONS_DIRS = [ | |
| 17 | path.join(process.env.HOME, '.claude/projects/-storage-projects-code-hypercore-kb'), | |
| 18 | path.join(process.env.HOME, '.claude/projects/-storage-projects-code-hypercore-kb-hyper-fleeky'), | |
| 19 | path.join(process.env.HOME, '.claude/projects/-storage-projects-code-hypercore-kb-hyper-fleeky-pear-git') | |
| 20 | ] | |
| 21 | ||
| 22 | const CACHE_DIR = path.join(__dirname, 'data', 'scan-cache') | |
| 23 | ||
| 24 | // ─── Entry Extraction ─── | |
| 25 | ||
| 26 | function extractEntry (entry) { | |
| 27 | const base = { | |
| 28 | type: entry.type, | |
| 29 | timestamp: entry.timestamp, | |
| 30 | sessionId: entry.sessionId, | |
| 31 | } | |
| 32 | ||
| 33 | if (entry.type === 'user') { | |
| 34 | const content = entry.message?.content | |
| 35 | let text = '' | |
| 36 | if (typeof content === 'string') text = content | |
| 37 | else if (Array.isArray(content)) { | |
| 38 | text = content.filter(c => c.type === 'text').map(c => c.text).join(' ') | |
| 39 | } | |
| 40 | ||
| 41 | return { | |
| 42 | ...base, | |
| 43 | text, | |
| 44 | permissionMode: entry.permissionMode, | |
| 45 | cwd: entry.cwd, | |
| 46 | gitBranch: entry.gitBranch, | |
| 47 | isSidechain: entry.isSidechain || false, | |
| 48 | isMeta: entry.isMeta || false, | |
| 49 | toolUseResult: entry.toolUseResult, | |
| 50 | sourceToolAssistantUUID: entry.sourceToolAssistantUUID, | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | if (entry.type === 'assistant') { | |
| 55 | const content = entry.message?.content | |
| 56 | let textChars = 0 | |
| 57 | let thinkingChars = 0 | |
| 58 | let toolUseCount = 0 | |
| 59 | let toolUseChars = 0 | |
| 60 | const toolNames = [] | |
| 61 | const editFiles = [] | |
| 62 | ||
| 63 | if (Array.isArray(content)) { | |
| 64 | for (const c of content) { | |
| 65 | if (c.type === 'text' && c.text) textChars += c.text.length | |
| 66 | if (c.type === 'thinking' && c.thinking) thinkingChars += c.thinking.length | |
| 67 | if (c.type === 'tool_use') { | |
| 68 | toolUseCount++ | |
| 69 | toolNames.push(c.name) | |
| 70 | if (c.input) toolUseChars += JSON.stringify(c.input).length | |
| 71 | if ((c.name === 'Edit' || c.name === 'Write') && c.input?.file_path) { | |
| 72 | editFiles.push(c.input.file_path) | |
| 73 | } | |
| 74 | } | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | const usage = entry.message?.usage | |
| 79 | return { | |
| 80 | ...base, | |
| 81 | textChars, | |
| 82 | thinkingChars, | |
| 83 | toolUseCount, | |
| 84 | toolUseChars, | |
| 85 | toolNames, | |
| 86 | editFiles, | |
| 87 | outputTokens: usage?.output_tokens || 0, | |
| 88 | cacheRead: usage?.cache_read_input_tokens || 0, | |
| 89 | cacheCreate: usage?.cache_creation_input_tokens || 0, | |
| 90 | inputTokens: usage?.input_tokens || 0, | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | if (entry.type === 'system') { | |
| 95 | return { | |
| 96 | ...base, | |
| 97 | subtype: entry.subtype, | |
| 98 | durationMs: entry.durationMs, | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | if (entry.type === 'queue-operation') { | |
| 103 | return { | |
| 104 | ...base, | |
| 105 | operation: entry.operation, | |
| 106 | content: entry.content, | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | return base | |
| 111 | } | |
| 112 | ||
| 113 | // ─── Cache ─── | |
| 114 | ||
| 115 | function cacheKey (filePath) { | |
| 116 | // Use filename as cache key (UUIDs are unique) | |
| 117 | return path.basename(filePath, '.jsonl') + '.json' | |
| 118 | } | |
| 119 | ||
| 120 | function loadFromCache (filePath, fileMtime) { | |
| 121 | const key = cacheKey(filePath) | |
| 122 | const cachePath = path.join(CACHE_DIR, key) | |
| 123 | ||
| 124 | if (!fs.existsSync(cachePath)) return null | |
| 125 | ||
| 126 | try { | |
| 127 | // Check if cache is newer than source file | |
| 128 | const cacheStat = fs.statSync(cachePath) | |
| 129 | if (cacheStat.mtimeMs < fileMtime) return null // source changed, cache stale | |
| 130 | ||
| 131 | return JSON.parse(fs.readFileSync(cachePath, 'utf8')) | |
| 132 | } catch { | |
| 133 | return null | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | function saveToCache (filePath, session) { | |
| 138 | if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true }) | |
| 139 | ||
| 140 | const key = cacheKey(filePath) | |
| 141 | const cachePath = path.join(CACHE_DIR, key) | |
| 142 | ||
| 143 | try { | |
| 144 | // Strip large fields that consumers don't need for most operations. | |
| 145 | // Keep entries lean — consumers that need full text can re-scan. | |
| 146 | const lean = { | |
| 147 | sessionId: session.sessionId, | |
| 148 | sessionDate: session.sessionDate, | |
| 149 | filePath: session.filePath, | |
| 150 | entries: session.entries.map(e => { | |
| 151 | if (e.type === 'user') { | |
| 152 | // Keep full text for correction detection, but cap at 500 chars | |
| 153 | return { ...e, text: (e.text || '').slice(0, 500) } | |
| 154 | } | |
| 155 | if (e.type === 'assistant') { | |
| 156 | // Drop toolNames array (redundant with toolUseCount), keep everything else | |
| 157 | const { toolNames, ...rest } = e | |
| 158 | return rest | |
| 159 | } | |
| 160 | return e | |
| 161 | }), | |
| 162 | } | |
| 163 | fs.writeFileSync(cachePath, JSON.stringify(lean)) | |
| 164 | } catch {} // non-fatal | |
| 165 | } | |
| 166 | ||
| 167 | // ─── Session Scanner ─── | |
| 168 | ||
| 169 | async function scanSession (filePath) { | |
| 170 | const stream = fs.createReadStream(filePath) | |
| 171 | const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }) | |
| 172 | ||
| 173 | const entries = [] | |
| 174 | let sessionId = null | |
| 175 | let sessionDate = null | |
| 176 | ||
| 177 | for await (const line of rl) { | |
| 178 | try { | |
| 179 | const raw = JSON.parse(line) | |
| 180 | const entry = extractEntry(raw) | |
| 181 | entries.push(entry) | |
| 182 | if (!sessionId && entry.sessionId) sessionId = entry.sessionId | |
| 183 | if (!sessionDate && entry.timestamp) sessionDate = entry.timestamp | |
| 184 | } catch {} | |
| 185 | } | |
| 186 | ||
| 187 | return { entries, sessionId, sessionDate, filePath } | |
| 188 | } | |
| 189 | ||
| 190 | async function scanAll (opts = {}) { | |
| 191 | const { recentDays, targetSession, noCache } = opts | |
| 192 | ||
| 193 | const cutoffDate = recentDays | |
| 194 | ? new Date(Date.now() - recentDays * 86400000).toISOString() | |
| 195 | : null | |
| 196 | ||
| 197 | // Collect all transcript files | |
| 198 | const allFiles = [] | |
| 199 | for (const dir of SESSIONS_DIRS) { | |
| 200 | if (!fs.existsSync(dir)) continue | |
| 201 | const files = fs.readdirSync(dir) | |
| 202 | .filter(f => f.endsWith('.jsonl') && !f.includes('subagent')) | |
| 203 | .map(f => path.join(dir, f)) | |
| 204 | allFiles.push(...files) | |
| 205 | } | |
| 206 | ||
| 207 | if (targetSession) { | |
| 208 | const match = allFiles.find(f => path.basename(f).includes(targetSession)) | |
| 209 | if (!match) return { sessions: [], skipped: 0, total: 0, cacheHits: 0 } | |
| 210 | allFiles.length = 0 | |
| 211 | allFiles.push(match) | |
| 212 | } | |
| 213 | ||
| 214 | const sessions = [] | |
| 215 | let skipped = 0 | |
| 216 | let cacheHits = 0 | |
| 217 | let cacheMisses = 0 | |
| 218 | ||
| 219 | for (const file of allFiles) { | |
| 220 | let fileMtime | |
| 221 | try { | |
| 222 | const stat = fs.statSync(file) | |
| 223 | fileMtime = stat.mtimeMs | |
| 224 | ||
| 225 | if (cutoffDate && stat.mtime.toISOString() < cutoffDate) { | |
| 226 | skipped++ | |
| 227 | continue | |
| 228 | } | |
| 229 | } catch { continue } | |
| 230 | ||
| 231 | // Try cache first | |
| 232 | if (!noCache) { | |
| 233 | const cached = loadFromCache(file, fileMtime) | |
| 234 | if (cached) { | |
| 235 | sessions.push(cached) | |
| 236 | cacheHits++ | |
| 237 | continue | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | // Cache miss — full scan | |
| 242 | const session = await scanSession(file) | |
| 243 | if (session.entries.length > 0) { | |
| 244 | sessions.push(session) | |
| 245 | // Cache the result (don't cache the current session — it's still being written) | |
| 246 | // Heuristic: if the file hasn't been modified in the last 60s, it's safe to cache | |
| 247 | const ageMs = Date.now() - fileMtime | |
| 248 | if (ageMs > 60000) { | |
| 249 | saveToCache(file, session) | |
| 250 | } | |
| 251 | cacheMisses++ | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | return { sessions, skipped, total: allFiles.length, cacheHits, cacheMisses } | |
| 256 | } | |
| 257 | ||
| 258 | module.exports = { scanAll, scanSession, SESSIONS_DIRS } |
| … | … | @@ -0,0 +1,350 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | // Regex Tuner — LLM-Driven Pattern Discovery | |
| 4 | // Knowledge Ratchet Phase 2 | |
| 5 | // | |
| 6 | // Reads LLM-classified corrections and approvals from corrections.log, | |
| 7 | // discovers new high-precision patterns that the regex missed, | |
| 8 | // measures per-pattern precision, and outputs a tiered pattern config. | |
| 9 | // | |
| 10 | // The feedback loop: | |
| 11 | // regex detects (fast) → LLM validates (accurate) → tuner learns → regex improves | |
| 12 | // | |
| 13 | // Usage: | |
| 14 | // node tune-regex.js # analyze and output tuned patterns | |
| 15 | // node tune-regex.js --dry-run # analyze without saving | |
| 16 | // node tune-regex.js --verbose # show all candidate patterns | |
| 17 | ||
| 18 | const fs = require('fs') | |
| 19 | const path = require('path') | |
| 20 | ||
| 21 | const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log') | |
| 22 | const DATA_DIR = path.join(__dirname, 'data') | |
| 23 | const OUTPUT_FILE = path.join(DATA_DIR, 'tuned-patterns.json') | |
| 24 | ||
| 25 | // Current regex patterns (for precision measurement) | |
| 26 | const CORRECTION_PATTERNS = [ | |
| 27 | { re: /\b(no[,.]|wrong|not that|that's not|incorrect|stop doing)\b/i, id: 'explicit-no' }, | |
| 28 | { re: /\b(i said|i meant|i asked for|that's wrong)\b/i, id: 'repeating-self' }, | |
| 29 | { re: /\b(again|already told you|how many times|we discussed this)\b/i, id: 'frustration-repeat' }, | |
| 30 | { re: /\b(don't ever|never do that|stop|please don't|do not)\b/i, id: 'prohibition' }, | |
| 31 | { re: /\b(why did you|why are you|that broke|you broke|you just)\b/i, id: 'blame-question' }, | |
| 32 | { re: /\b(revert|undo|go back|roll back|put it back)\b/i, id: 'revert-request' }, | |
| 33 | { re: /\b(let me (just |)do it|i'll (just |)do it|forget it|never\s*mind)\b/i, id: 'giving-up' }, | |
| 34 | ] | |
| 35 | ||
| 36 | const APPROVAL_PATTERNS = [ | |
| 37 | { re: /\b(perfect|exactly|great|lgtm|looks good|nice work|nailed it)\b/i, id: 'strong-approval' }, | |
| 38 | { re: /\b(that works|that's right|correct|good job|well done)\b/i, id: 'confirmation' }, | |
| 39 | { re: /\b(ship it|merge it|push it|deploy it|commit it)\b/i, id: 'action-approval' }, | |
| 40 | { re: /\b(thanks|thank you)\b/i, id: 'thanks' }, | |
| 41 | ] | |
| 42 | ||
| 43 | // ─── Data Loading ─── | |
| 44 | ||
| 45 | function loadEntries () { | |
| 46 | if (!fs.existsSync(CORRECTIONS_LOG)) return { corrections: [], approvals: [] } | |
| 47 | ||
| 48 | const raw = fs.readFileSync(CORRECTIONS_LOG, 'utf8') | |
| 49 | const corrections = [] | |
| 50 | const approvals = [] | |
| 51 | ||
| 52 | for (const entry of raw.split('\n---\n')) { | |
| 53 | if (!entry.trim()) continue | |
| 54 | const lines = entry.trim().split('\n') | |
| 55 | const firstLine = lines[0] | |
| 56 | ||
| 57 | const tsMatch = firstLine.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+)/) | |
| 58 | const typeMatch = firstLine.match(/\[(correction|approval)\]/) | |
| 59 | if (!tsMatch || !typeMatch) continue | |
| 60 | ||
| 61 | const text = lines.slice(1) | |
| 62 | .filter(l => !l.startsWith('cwd:') && !l.startsWith('session:') && !l.startsWith('reason:')) | |
| 63 | .join(' ').trim() | |
| 64 | ||
| 65 | if (!text || text.length < 3) continue | |
| 66 | ||
| 67 | const isLLM = firstLine.includes('(llm,') | |
| 68 | const confMatch = firstLine.match(/confidence: ([\d.]+)/) | |
| 69 | const confidence = confMatch ? parseFloat(confMatch[1]) : (isLLM ? 0.7 : 0.5) | |
| 70 | ||
| 71 | const record = { text, source: isLLM ? 'llm' : 'regex', confidence, timestamp: tsMatch[1] } | |
| 72 | ||
| 73 | if (typeMatch[1] === 'correction') corrections.push(record) | |
| 74 | else approvals.push(record) | |
| 75 | } | |
| 76 | ||
| 77 | return { corrections, approvals } | |
| 78 | } | |
| 79 | ||
| 80 | // ─── Pattern Precision Measurement ─── | |
| 81 | ||
| 82 | function measurePatternPrecision (corrections, approvals) { | |
| 83 | const stats = {} | |
| 84 | ||
| 85 | for (const pat of CORRECTION_PATTERNS) { | |
| 86 | stats[pat.id] = { fires: 0, llmAgrees: 0, llmDisagrees: 0, texts: [] } | |
| 87 | } | |
| 88 | ||
| 89 | // Check LLM-classified entries against each pattern | |
| 90 | const llmEntries = [...corrections.filter(c => c.source === 'llm'), ...approvals.filter(a => a.source === 'llm')] | |
| 91 | ||
| 92 | for (const entry of llmEntries) { | |
| 93 | for (const pat of CORRECTION_PATTERNS) { | |
| 94 | if (pat.re.test(entry.text)) { | |
| 95 | stats[pat.id].fires++ | |
| 96 | const isActualCorrection = corrections.some(c => c === entry) | |
| 97 | if (isActualCorrection) stats[pat.id].llmAgrees++ | |
| 98 | else { | |
| 99 | stats[pat.id].llmDisagrees++ | |
| 100 | stats[pat.id].texts.push(entry.text.slice(0, 100)) | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | return stats | |
| 107 | } | |
| 108 | ||
| 109 | // ─── New Pattern Discovery ─── | |
| 110 | ||
| 111 | function discoverPatterns (corrections, approvals) { | |
| 112 | // LLM-confirmed corrections that no existing regex catches | |
| 113 | const llmCorrections = corrections.filter(c => c.source === 'llm' && c.confidence >= 0.7) | |
| 114 | const llmApprovals = approvals.filter(a => a.source === 'llm' && a.confidence >= 0.7) | |
| 115 | ||
| 116 | const missed = llmCorrections.filter(c => { | |
| 117 | return !CORRECTION_PATTERNS.some(p => p.re.test(c.text)) | |
| 118 | }) | |
| 119 | ||
| 120 | // Extract common n-grams from missed corrections | |
| 121 | const bigramCounts = {} | |
| 122 | const trigramCounts = {} | |
| 123 | const phraseCounts = {} | |
| 124 | ||
| 125 | for (const entry of missed) { | |
| 126 | const words = entry.text.toLowerCase() | |
| 127 | .replace(/[^a-z0-9\s']/g, ' ') | |
| 128 | .split(/\s+/) | |
| 129 | .filter(w => w.length > 1) | |
| 130 | ||
| 131 | // Bigrams | |
| 132 | for (let i = 0; i < words.length - 1; i++) { | |
| 133 | const bg = words[i] + ' ' + words[i + 1] | |
| 134 | bigramCounts[bg] = (bigramCounts[bg] || 0) + 1 | |
| 135 | } | |
| 136 | ||
| 137 | // Trigrams | |
| 138 | for (let i = 0; i < words.length - 2; i++) { | |
| 139 | const tg = words[i] + ' ' + words[i + 1] + ' ' + words[i + 2] | |
| 140 | trigramCounts[tg] = (trigramCounts[tg] || 0) + 1 | |
| 141 | } | |
| 142 | ||
| 143 | // Known correction phrases (manually seeded from analysis) | |
| 144 | const knownPhrases = [ | |
| 145 | /\b(it is the same|it's the same|still the same)\b/i, | |
| 146 | /\b(still (flat|broken|wrong|missing|empty|invisible))\b/i, | |
| 147 | /\b(i think the .+ is|i think .+ are)\b/i, | |
| 148 | /\b(don't see any|can't see|cannot see|not visible|invisible)\b/i, | |
| 149 | /\b(you flipped|you swapped|you mixed up|you confused)\b/i, | |
| 150 | /\b(just (flat|empty|blank|black|white|nothing))\b/i, | |
| 151 | /\b(crash|crashes|crashing|crashed)\b/i, | |
| 152 | /\b(artifact|artifacts|glitch|glitches)\b/i, | |
| 153 | /\b(not (working|rendering|updating|showing|displaying))\b/i, | |
| 154 | /\b(check (the )?(screenshot|image|output|result|log))\b/i, | |
| 155 | /\b(hmm|hmmm)\b/i, | |
| 156 | /\b(that didn't|that does not|that doesn't) (work|help|fix|change)\b/i, | |
| 157 | /\b(same (problem|issue|bug|error|result))\b/i, | |
| 158 | /\b(why (is|are|does|did) (it|the|this))\b/i, | |
| 159 | /\b(audit|think deeper|planning mode)\b/i, | |
| 160 | ] | |
| 161 | ||
| 162 | for (const phrase of knownPhrases) { | |
| 163 | const key = phrase.source | |
| 164 | if (phrase.test(entry.text)) { | |
| 165 | phraseCounts[key] = (phraseCounts[key] || 0) + 1 | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | // Score each candidate pattern by: | |
| 171 | // hits: how many LLM corrections it catches | |
| 172 | // falsePositives: how many LLM approvals it also catches | |
| 173 | // precision: hits / (hits + falsePositives) | |
| 174 | const candidates = [ | |
| 175 | { id: 'same-result', re: /\b(it is the same|it's the same|still the same|same (problem|issue|bug|error|result))\b/i }, | |
| 176 | { id: 'still-broken', re: /\b(still (flat|broken|wrong|missing|empty|invisible|not))\b/i }, | |
| 177 | { id: 'not-visible', re: /\b(don't see|can't see|cannot see|not visible|invisible|not showing|not rendering)\b/i }, | |
| 178 | { id: 'wrong-action', re: /\b(you flipped|you swapped|you mixed up|you confused)\b/i }, | |
| 179 | { id: 'runtime-issue', re: /\b(crash|crashes|crashing|crashed)\b/i }, | |
| 180 | { id: 'visual-artifact', re: /\b(artifact|artifacts|glitch|glitches)\b/i }, | |
| 181 | { id: 'not-working', re: /\b(not (working|rendering|updating|showing|displaying))\b/i }, | |
| 182 | { id: 'check-output', re: /\b(check (the )?(screenshot|image|output|result))\b/i }, | |
| 183 | { id: 'didnt-work', re: /\b(that didn't|that does not|that doesn't) (work|help|fix|change)\b/i }, | |
| 184 | { id: 'questioning-why', re: /\b(why (is|are|does|did) (it|the|this))\b/i }, | |
| 185 | { id: 'hesitation', re: /\b(hmm+)\b/i }, | |
| 186 | { id: 'quality-pushback', re: /\b(audit|think deeper|planning mode)\b/i }, | |
| 187 | { id: 'just-empty', re: /\b(just (flat|empty|blank|black|white|nothing))\b/i }, | |
| 188 | ] | |
| 189 | ||
| 190 | const candidateStats = [] | |
| 191 | for (const cand of candidates) { | |
| 192 | const hits = llmCorrections.filter(c => cand.re.test(c.text)).length | |
| 193 | const falsePos = llmApprovals.filter(a => cand.re.test(a.text)).length | |
| 194 | const total = hits + falsePos | |
| 195 | const precision = total > 0 ? hits / total : 0 | |
| 196 | ||
| 197 | candidateStats.push({ | |
| 198 | id: cand.id, | |
| 199 | pattern: cand.re.source, | |
| 200 | flags: cand.re.flags, | |
| 201 | hits, | |
| 202 | falsePositives: falsePos, | |
| 203 | precision: Math.round(precision * 1000) / 1000, | |
| 204 | recall: llmCorrections.length > 0 ? Math.round((hits / llmCorrections.length) * 1000) / 1000 : 0, | |
| 205 | }) | |
| 206 | } | |
| 207 | ||
| 208 | return { | |
| 209 | missed: missed.length, | |
| 210 | totalLLMCorrections: llmCorrections.length, | |
| 211 | totalLLMApprovals: llmApprovals.length, | |
| 212 | topBigrams: Object.entries(bigramCounts).sort((a, b) => b[1] - a[1]).slice(0, 15), | |
| 213 | candidates: candidateStats.sort((a, b) => b.hits - a.hits), | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | // ─── Output Tuned Patterns ─── | |
| 218 | ||
| 219 | function buildTunedPatterns (patternPrecision, discovery) { | |
| 220 | const tuned = { | |
| 221 | generated: new Date().toISOString(), | |
| 222 | correction: { high: [], medium: [], low: [] }, | |
| 223 | approval: { high: [], medium: [], low: [] }, | |
| 224 | } | |
| 225 | ||
| 226 | // Existing correction patterns — tier by precision | |
| 227 | for (const pat of CORRECTION_PATTERNS) { | |
| 228 | const stats = patternPrecision[pat.id] | |
| 229 | const precision = stats.fires > 0 ? stats.llmAgrees / stats.fires : 1.0 | |
| 230 | ||
| 231 | const entry = { id: pat.id, pattern: pat.re.source, flags: pat.re.flags, precision, fires: stats.fires } | |
| 232 | ||
| 233 | if (precision >= 0.8 || stats.fires < 3) { | |
| 234 | tuned.correction.high.push(entry) // high precision or insufficient data to judge | |
| 235 | } else if (precision >= 0.5) { | |
| 236 | tuned.correction.medium.push(entry) | |
| 237 | } else { | |
| 238 | tuned.correction.low.push(entry) | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | // New discovered patterns — only promote if precision > 70% and hits > 2 | |
| 243 | for (const cand of discovery.candidates) { | |
| 244 | if (cand.hits < 2) continue | |
| 245 | ||
| 246 | const entry = { id: cand.id, pattern: cand.pattern, flags: cand.flags, precision: cand.precision, hits: cand.hits, source: 'discovered' } | |
| 247 | ||
| 248 | if (cand.precision >= 0.8) { | |
| 249 | tuned.correction.high.push(entry) | |
| 250 | } else if (cand.precision >= 0.5) { | |
| 251 | tuned.correction.medium.push(entry) | |
| 252 | } | |
| 253 | // Don't add low-precision discovered patterns — not worth the noise | |
| 254 | } | |
| 255 | ||
| 256 | // Existing approval patterns (all high tier for now — no LLM data to judge) | |
| 257 | for (const pat of APPROVAL_PATTERNS) { | |
| 258 | tuned.approval.high.push({ id: pat.id, pattern: pat.re.source, flags: pat.re.flags }) | |
| 259 | } | |
| 260 | ||
| 261 | return tuned | |
| 262 | } | |
| 263 | ||
| 264 | // ─── Main ─── | |
| 265 | ||
| 266 | function main () { | |
| 267 | const dryRun = process.argv.includes('--dry-run') | |
| 268 | const verbose = process.argv.includes('--verbose') | |
| 269 | ||
| 270 | const { corrections, approvals } = loadEntries() | |
| 271 | const llmCorrections = corrections.filter(c => c.source === 'llm') | |
| 272 | const llmApprovals = approvals.filter(a => a.source === 'llm') | |
| 273 | ||
| 274 | console.log('═'.repeat(60)) | |
| 275 | console.log(' REGEX TUNER — LLM-Driven Pattern Discovery') | |
| 276 | console.log('═'.repeat(60)) | |
| 277 | console.log(` Corrections in log: ${corrections.length} (${llmCorrections.length} LLM, ${corrections.length - llmCorrections.length} regex)`) | |
| 278 | console.log(` Approvals in log: ${approvals.length} (${llmApprovals.length} LLM, ${approvals.length - llmApprovals.length} regex)`) | |
| 279 | ||
| 280 | // 1. Measure existing pattern precision | |
| 281 | const patternPrecision = measurePatternPrecision(corrections, approvals) | |
| 282 | ||
| 283 | console.log('\n EXISTING PATTERN PRECISION (vs LLM ground truth)') | |
| 284 | console.log(' ' + '-'.repeat(56)) | |
| 285 | for (const [id, stats] of Object.entries(patternPrecision)) { | |
| 286 | const precision = stats.fires > 0 ? (stats.llmAgrees / stats.fires * 100).toFixed(0) : 'n/a' | |
| 287 | const status = stats.fires === 0 ? '?' : stats.fires > 0 && stats.llmDisagrees === 0 ? '✓' : '~' | |
| 288 | console.log(` ${status} ${id.padEnd(22)} fires=${String(stats.fires).padStart(3)} agree=${String(stats.llmAgrees).padStart(3)} disagree=${String(stats.llmDisagrees).padStart(2)} precision=${precision}%`) | |
| 289 | if (verbose && stats.texts.length > 0) { | |
| 290 | for (const t of stats.texts.slice(0, 2)) { | |
| 291 | console.log(` false+: "${t}"`) | |
| 292 | } | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | // 2. Discover new patterns | |
| 297 | const discovery = discoverPatterns(corrections, approvals) | |
| 298 | ||
| 299 | console.log(`\n PATTERN DISCOVERY`) | |
| 300 | console.log(' ' + '-'.repeat(56)) | |
| 301 | console.log(` LLM corrections missed by regex: ${discovery.missed}/${discovery.totalLLMCorrections} (${(discovery.missed / Math.max(1, discovery.totalLLMCorrections) * 100).toFixed(0)}%)`) | |
| 302 | ||
| 303 | if (verbose) { | |
| 304 | console.log('\n Top bigrams in missed corrections:') | |
| 305 | for (const [bg, count] of discovery.topBigrams) { | |
| 306 | console.log(` ${String(count).padStart(3)}x "${bg}"`) | |
| 307 | } | |
| 308 | } | |
| 309 | ||
| 310 | console.log('\n CANDIDATE PATTERNS') | |
| 311 | console.log(' ' + '-'.repeat(56)) | |
| 312 | console.log(' id hits false+ prec% recall% tier') | |
| 313 | console.log(' ' + '-'.repeat(56)) | |
| 314 | for (const c of discovery.candidates) { | |
| 315 | const tier = c.precision >= 0.8 && c.hits >= 2 ? 'HIGH' | |
| 316 | : c.precision >= 0.5 && c.hits >= 2 ? 'medium' | |
| 317 | : c.hits < 2 ? 'skip (low n)' | |
| 318 | : 'skip (low prec)' | |
| 319 | console.log(` ${c.id.padEnd(25)} ${String(c.hits).padStart(4)} ${String(c.falsePositives).padStart(5)} ${(c.precision * 100).toFixed(0).padStart(4)}% ${(c.recall * 100).toFixed(1).padStart(6)}% ${tier}`) | |
| 320 | } | |
| 321 | ||
| 322 | // 3. Build tuned patterns | |
| 323 | const tuned = buildTunedPatterns(patternPrecision, discovery) | |
| 324 | ||
| 325 | console.log(`\n TUNED PATTERN SUMMARY`) | |
| 326 | console.log(' ' + '-'.repeat(56)) | |
| 327 | console.log(` Correction high: ${tuned.correction.high.length} patterns`) | |
| 328 | console.log(` Correction medium: ${tuned.correction.medium.length} patterns`) | |
| 329 | console.log(` Correction low: ${tuned.correction.low.length} patterns`) | |
| 330 | console.log(` Approval high: ${tuned.approval.high.length} patterns`) | |
| 331 | ||
| 332 | if (tuned.correction.high.filter(p => p.source === 'discovered').length > 0) { | |
| 333 | console.log('\n NEW patterns promoted to high tier:') | |
| 334 | for (const p of tuned.correction.high.filter(p => p.source === 'discovered')) { | |
| 335 | console.log(` + ${p.id} (precision=${(p.precision * 100).toFixed(0)}%, hits=${p.hits})`) | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | // Save | |
| 340 | if (!dryRun) { | |
| 341 | fs.writeFileSync(OUTPUT_FILE, JSON.stringify(tuned, null, 2)) | |
| 342 | console.log(`\n Saved: ${OUTPUT_FILE}`) | |
| 343 | } else { | |
| 344 | console.log('\n (dry run — not saved)') | |
| 345 | } | |
| 346 | ||
| 347 | console.log(`\n${'═'.repeat(60)}`) | |
| 348 | } | |
| 349 | ||
| 350 | main() |