OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet commits / 89915ac

Knowledge Ratchet Phase 2 — sensor fusion, loop detection, auto-learning
14 new scripts, 4 modified. Complete self-improving quality measurement system. Measurement: - fuse-signals.js: 16 per-turn sensors + 5 session signals, LLM-weighted scoring - scan-sessions.js: shared single-pass JSONL reader with results cache (10s → 0.4s) - meta-ratchet.js: auto-adjusts sensor weights from predictive accuracy - tune-regex.js: LLM-driven pattern discovery (9 new 100%-precision patterns) - coverage-map.js: topic coverage with failure-risk formula - benchmark-ratchet.js: R2 novel/repeat, R3 density, R4 resolution speed + history Loop system: - loop-detector.js: extracts 287 correction loops with resolutions from session data - loop-intervention.js: injects learned rules as context when loops form (non-blocking) - loop-briefing.js: session-start briefing with past loops + danger files + rules - rule-extractor.js: auto-generates 221 actionable rules from resolved loops Infrastructure: - realtime-score.js: live session scoring via hooks + loop detection in status line - project-mapper.js: autodiscovers subsystems from code + cross-references with loop history - pipeline.js: 2.7s end-of-session pipeline (fusion → meta-ratchet → loops → rules → map → benchmarks → dashboard) - dashboard.js: HTML visualization on port 9077 with live session + ratchet trend charts Baseline metrics: 87% novel corrections, 20.8% density, 8.4 turn loop resolution, 221 learned rules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
89915ac4c5f88c8b092b3beeef5ce63526aa60d7
by plankatron <fleeky@gmail.com> · 2026-04-07T15:31:54.000Z

Changed files (18) +5502 -41

.gitignore +5 -9
……@@ -1,10 +1,6 @@
1corrections.log
2data/deep-analysis.json
1data/*.json
2data/*.html
3data/scan-cache/
4data/session-summaries/
35data/llm-batches/
4data/llm-cursor.json
5data/migration-state.json
6data/overrides.json
7data/regression-report.json
8data/scorecard.tsv
9questions/
10debug-hook.js
6data/results-cache/
analyze-all.js +30 -6
……@@ -1,12 +1,16 @@
11#!/usr/bin/env node
22
3// Full analysis pipeline — runs all three steps in sequence
3// Full analysis pipeline — runs all seven steps in sequence
44// Usage: node analyze-all.js [--limit=N]
55//
66// Steps:
77// 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
1014
1115const { execSync } = require('child_process')
1216const path = require('path')
……@@ -29,17 +33,37 @@ console.log(`Knowledge Ratchet — Full Analysis`)
2933console.log(`Started: ${new Date().toISOString()}`)
3034
3135run(
32 'Step 1/3: LLM Semantic Analysis',
36 'Step 1/7: LLM Semantic Analysis',
3337 `node ${path.join(DIR, 'llm-analyzer.js')} ${limitArg}`
3438)
3539
3640run(
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
45run(
46 'Step 3/7: Sensor Fusion Scoring',
47 `node ${path.join(DIR, 'fuse-signals.js')}`
48)
49
50run(
51 'Step 4/7: Meta-Ratchet (weight learning)',
52 `node ${path.join(DIR, 'meta-ratchet.js')}`
53)
54
55run(
56 'Step 5/7: Coverage Map (unknown-unknowns)',
57 `node ${path.join(DIR, 'coverage-map.js')} --summary`
58)
59
60run(
61 'Step 6/7: Ratchet Pipeline (extract → validate → regress → migrate)',
3862 `node ${path.join(DIR, 'run.js')} full`
3963)
4064
4165run(
42 'Step 3/3: Deep Sentiment Analysis',
66 'Step 7/7: Deep Sentiment Analysis',
4367 `node ${path.join(DIR, 'analyze-transcripts.js')}`
4468)
4569
benchmark-ratchet.js +716 -0
……@@ -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
25const fs = require('fs')
26const path = require('path')
27
28const DATA_DIR = path.join(__dirname, 'data')
29const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
30
31function 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
39function 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
58function 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
68function 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
186function 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
202function 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
216function 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
316function 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
434function 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
546function 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
637const RATCHET_ACTIVE_SINCE = '2026-04-07' // date the full system went live
638
639function 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
716main()
coverage-map.js +386 -0
……@@ -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
13const fs = require('fs')
14const path = require('path')
15
16const DATA_DIR = path.join(__dirname, 'data')
17const OUTPUT_FILE = path.join(DATA_DIR, 'coverage-map.json')
18const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
19const FUSED_SCORES_FILE = path.join(DATA_DIR, 'fused-scores.json')
20const 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
29const 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
51const 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
73function 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
96function 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
102function 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
129function 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
145function 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
163function 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
277function 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
386main()
dashboard.js +527 -0
……@@ -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
12const fs = require('fs')
13const path = require('path')
14const { execSync } = require('child_process')
15
16const DATA_DIR = path.join(__dirname, 'data')
17const OUTPUT_FILE = path.join(DATA_DIR, 'dashboard.html')
18
19function 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
26function 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: &gt;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: &lt;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: &lt;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>&gt;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>&lt;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>&lt;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
410function 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 ───
426const serveArg = process.argv.find(a => a.startsWith('--serve'))
427if (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
436function 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
471function 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
498function 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()
fuse-signals.js +940 -0
……@@ -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
15const fs = require('fs')
16const path = require('path')
17const crypto = require('crypto')
18const { scanAll, scanSession, SESSIONS_DIRS } = require('./scan-sessions')
19
20const DATA_DIR = path.join(__dirname, 'data')
21const OUTPUT_FILE = path.join(DATA_DIR, 'fused-scores.json')
22const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
23const WEIGHTS_FILE = path.join(DATA_DIR, 'sensor-weights.json')
24const 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.
31const 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
50function 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
64function 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
104const TUNED_PATTERNS_FILE = path.join(DATA_DIR, 'tuned-patterns.json')
105
106function 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
127const _tuned = loadTunedPatterns()
128
129const 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
139const 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
146const 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
154const 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
172const INTERRUPTION_PATTERN = /^\[Request interrupted/
173
174// ─── Text Helpers ───
175
176function 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
186function 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
231function extractToolCallCount (turn) {
232 return turn.assistantMessages.reduce((sum, msg) => sum + (msg.toolUseCount || 0), 0)
233}
234
235function extractEditFiles (turn) {
236 return turn.assistantMessages.flatMap(msg => msg.editFiles || [])
237}
238
239function 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
246function 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
257function 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
266function extractToolUseChars (turn) {
267 return turn.assistantMessages.reduce((sum, msg) => sum + (msg.toolUseChars || 0), 0)
268}
269
270function 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
283function 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
477function 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
512function 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
635function 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
644function 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
675function 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
699async 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
931function scoreBar (score) {
932 const filled = Math.round(score * 10)
933 const empty = 10 - filled
934 return '█'.repeat(filled) + '░'.repeat(empty)
935}
936
937main().catch(err => {
938 console.error('Fusion analysis failed:', err.message)
939 process.exit(1)
940})
loop-briefing.js +131 -0
……@@ -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
11const fs = require('fs')
12const path = require('path')
13
14const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json')
15const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json')
16
17function 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
33function 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
131main()
loop-detector.js +362 -0
……@@ -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
23const fs = require('fs')
24const path = require('path')
25
26const DATA_DIR = path.join(__dirname, 'data')
27const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
28const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
29
30// ─── Loop Detection Parameters ───
31const MAX_GAP = 5 // max turns between corrections to still be "same loop"
32const MIN_CORRECTIONS = 2 // need at least 2 corrections to be a loop (1 correction = normal)
33const CLEAN_STREAK = 3 // 3 turns without correction = loop is over
34
35function 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
45function 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
115function 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
164function 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
180function 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
193function 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
204function 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
238function 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
360module.exports = { extractLoops, findSimilarLoops }
361
362if (require.main === module) main()
loop-intervention.js +163 -0
……@@ -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
17const fs = require('fs')
18const path = require('path')
19
20const STATE_FILE = '/tmp/claude-session-score.json'
21const RULES_FILE = path.join(__dirname, 'data', 'learned-rules.json')
22const LOOPS_FILE = path.join(__dirname, 'data', 'loops.json')
23
24const 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
42async 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
163main().catch(() => process.exit(0))
meta-ratchet.js +341 -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
31const fs = require('fs')
32const path = require('path')
33
34const DATA_DIR = path.join(__dirname, 'data')
35const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
36const WEIGHTS_FILE = path.join(DATA_DIR, 'sensor-weights.json')
37const HISTORY_FILE = path.join(DATA_DIR, 'weight-history.json')
38
39// How far back to look for predictive signals
40const LOOKBACK = 3
41
42// Sensor thresholds — below this value, the sensor is "signaling"
43// (i.e., it detected something potentially wrong)
44const SIGNAL_THRESHOLD = 0.6
45
46// Sensors that are inverted (high = bad) or not meaningful for prediction
47const EXCLUDED_SENSORS = new Set([
48 'cacheHitRate', // informational only, not predictive
49 'timeBetweenTurns', // user behavior, not Claude quality
50])
51
52// Weight adjustment bounds
53const MIN_WEIGHT = 0.05
54const MAX_WEIGHT = 2.0
55const ADJUST_STEP = 0.05 // how much to adjust per run
56
57function 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
66function loadWeightHistory () {
67 if (!fs.existsSync(HISTORY_FILE)) return []
68 try { return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')) }
69 catch { return [] }
70}
71
72function 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
248function 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
341main()
pipeline.js +74 -0
……@@ -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
18const { execSync } = require('child_process')
19const path = require('path')
20
21const DIR = __dirname
22
23function 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
33async 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
71main().catch(err => {
72 console.error('Pipeline failed:', err.message)
73 process.exit(1)
74})
preflight-review.js +52 -0
……@@ -11,6 +11,7 @@ const path = require('path')
1111const STATE_FILE = '/tmp/claude-preflight-state.json'
1212const PRINCIPLES_FILE = path.join(__dirname, 'active-principles.md')
1313const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
14const PROJECT_MAPS_FILE = path.join(__dirname, 'data', 'project-maps.json')
1415
1516// Shared modules and their downstream consumers
1617// This is the map of files where edits need pre-flight review
……@@ -61,6 +62,28 @@ const SHARED_MODULE_MAP = {
6162 'hyper-fleeky/pear-git/lib/replication.js',
6263 'hyper-fleeky/hyper-overlay/lib/replication.js'
6364 ]
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']
6487 }
6588}
6689
……@@ -84,11 +107,40 @@ function loadRecentCorrections (filePath) {
84107
85108// Check which shared module pattern matches this file
86109function findSharedModule (filePath) {
110 // First check hardcoded map (manually curated danger files)
87111 for (const [pattern, info] of Object.entries(SHARED_MODULE_MAP)) {
88112 if (filePath.includes(pattern)) {
89113 return { pattern, ...info }
90114 }
91115 }
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
92144 return null
93145}
94146
project-mapper.js +359 -0
……@@ -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
18const fs = require('fs')
19const path = require('path')
20
21const DATA_DIR = path.join(__dirname, 'data')
22const OUTPUT_FILE = path.join(DATA_DIR, 'project-maps.json')
23const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
24
25// Known project roots — autodiscovered from hyper-fleeky + godot dirs
26const 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
37const SKIP_DIRS = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', '.pear', '.claude'])
38const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.cpp', '.h', '.gd', '.glsl', '.gdshader'])
39
40// ─── File Tree Scanner ───
41
42function 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
67function 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
95function 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
133function 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
151function 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
188function 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
218function 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
255function 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
289function 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
357module.exports = { mapProject, PROJECT_ROOTS }
358
359if (require.main === module) main()
realtime-score.js +504 -0
……@@ -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
15const fs = require('fs')
16const path = require('path')
17
18const STATE_FILE = '/tmp/claude-session-score.json'
19const DATA_DIR = path.join(__dirname, 'data')
20const TUNED_PATTERNS_FILE = path.join(DATA_DIR, 'tuned-patterns.json')
21const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
22
23// ─── Correction/Approval Detection ───
24// Loads tuned patterns from tune-regex.js if available
25
26function 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
46const _tuned = loadTunedPatterns()
47
48const 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
58const 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
65const 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
82const INTERRUPTION_PATTERN = /^\[Request interrupted/
83
84// ─── State Management ───
85
86function 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
95function saveState (state) {
96 state.lastUpdate = new Date().toISOString()
97 fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
98}
99
100function 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
126function 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
205function 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
281async 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
377async 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
428function 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
455function 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
477function handleReset () {
478 saveState(createFreshState())
479}
480
481// ─── CLI ───
482
483const command = process.argv[2] || 'status'
484
485switch (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}
regress.js +65 -26
……@@ -11,6 +11,7 @@ const path = require('path')
1111const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
1212const PRINCIPLES_DATA = path.join(__dirname, 'data', 'principles.json')
1313const OVERRIDES_FILE = path.join(__dirname, 'data', 'overrides.json')
14const FUSED_SCORES_FILE = path.join(__dirname, 'data', 'fused-scores.json')
1415const SCORECARD_FILE = path.join(__dirname, 'data', 'scorecard.tsv')
1516const REPORT_FILE = path.join(__dirname, 'data', 'regression-report.json')
1617const QUESTIONS_DIR = path.join(__dirname, 'questions')
……@@ -103,16 +104,23 @@ function scorePrinciples (principles, corrections, overrides) {
103104 return scores.sort((a, b) => a.score - b.score)
104105}
105106
106function writeScorecard (scores, weeks) {
107function 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
113function writeScorecard (scores, weeks, fusedWeekly) {
107114 const lines = ['principle\tcorrections\tapprovals\toverrides\tscore\tstatus']
108115 for (const s of scores) {
109116 lines.push(`${s.principle.slice(0, 60)}\t${s.corrections}\t${s.approvals}\t${s.overrides}\t${s.score.toFixed(2)}\t${s.status}`)
110117 }
111118 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')
114121 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'}`)
116124 }
117125 fs.writeFileSync(SCORECARD_FILE, lines.join('\n') + '\n')
118126}
……@@ -124,51 +132,64 @@ function writeQuestion (id, text) {
124132}
125133
126134function main () {
127 const corrections = loadCorrections()
128135 const principles = loadPrinciples()
129136 const overrides = loadOverrides()
130137
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.')
134142 return
135143 }
136144
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)
142148
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`)
144150 console.log(`Active principles: ${principles.filter(p => p.status === 'active').length}`)
145151
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
149163 }
150164
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
156170
157171 // Write scorecard
158 writeScorecard(scores, weeks)
172 writeScorecard(scores, weeks, fusedWeekly)
159173
160174 // Print results
161175 console.log('\n═══ PRINCIPLE SCORECARD ═══')
176 if (scores.length === 0) {
177 console.log(' (no active principles)')
178 }
162179 for (const s of scores) {
163180 const icon = s.status === 'HEALTHY' ? '✓' : s.status === 'WEAK' ? '~' : '✗'
164181 console.log(` ${icon} [${s.score.toFixed(2)}] ${s.principle.slice(0, 60)}`)
165182 console.log(` corrections: ${s.corrections} approvals: ${s.approvals} overrides: ${s.overrides}`)
166183 }
167184
168 console.log('\n═══ WEEKLY TREND ═══')
185 console.log('\n═══ WEEKLY TREND (from fused scores) ═══')
169186 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}`)
172193 }
173194
174195 // Flag degraded principles
……@@ -190,10 +211,28 @@ function main () {
190211 }
191212 }
192213
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
193231 // Save report
194232 const report = {
195233 timestamp: new Date().toISOString(),
196234 totalCorrections: corrections.length,
235 fusedWeekly,
197236 scores,
198237 weeks,
199238 degradedCount: degraded.length
rule-extractor.js +239 -0
……@@ -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
20const fs = require('fs')
21const path = require('path')
22
23const DATA_DIR = path.join(__dirname, 'data')
24const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
25const RULES_FILE = path.join(DATA_DIR, 'learned-rules.json')
26
27function 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
36function 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
60function 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
108function 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
130function 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
155function 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
172function 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
237module.exports = { extractRules, mergeRules }
238
239if (require.main === module) main()
scan-sessions.js +258 -0
……@@ -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
12const fs = require('fs')
13const path = require('path')
14const readline = require('readline')
15
16const 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
22const CACHE_DIR = path.join(__dirname, 'data', 'scan-cache')
23
24// ─── Entry Extraction ───
25
26function 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
115function cacheKey (filePath) {
116 // Use filename as cache key (UUIDs are unique)
117 return path.basename(filePath, '.jsonl') + '.json'
118}
119
120function 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
137function 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
169async 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
190async 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
258module.exports = { scanAll, scanSession, SESSIONS_DIRS }
tune-regex.js +350 -0
……@@ -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
18const fs = require('fs')
19const path = require('path')
20
21const CORRECTIONS_LOG = path.join(__dirname, 'corrections.log')
22const DATA_DIR = path.join(__dirname, 'data')
23const OUTPUT_FILE = path.join(DATA_DIR, 'tuned-patterns.json')
24
25// Current regex patterns (for precision measurement)
26const 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
36const 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
45function 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
82function 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
111function 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
219function 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
266function 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
350main()