#!/usr/bin/env node
// Realtime Session Scorer — Knowledge Ratchet Phase 2
//
// Maintains a live quality score during Claude Code sessions.
// Called from hooks (UserPromptSubmit, PostToolUse, Stop) and status line.
//
// Usage:
// echo '{"prompt":"..."}' | node realtime-score.js prompt # UserPromptSubmit hook
// echo '{"tool_name":"..."}' | node realtime-score.js tool # PostToolUse hook
// node realtime-score.js stop # Stop hook (session end)
// node realtime-score.js status # Print current score (for statusline)
// node realtime-score.js reset # Reset state (SessionStart)
const fs = require('fs')
const path = require('path')
const STATE_FILE = '/tmp/claude-session-score.json'
const DATA_DIR = path.join(__dirname, 'data')
const TUNED_PATTERNS_FILE = path.join(DATA_DIR, 'tuned-patterns.json')
const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')
// ─── Correction/Approval Detection ───
// Loads tuned patterns from tune-regex.js if available
function loadTunedPatterns () {
if (fs.existsSync(TUNED_PATTERNS_FILE)) {
try {
const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8'))
const corrPatterns = []
for (const tier of ['high', 'medium']) {
for (const p of (tuned.correction?.[tier] || [])) {
corrPatterns.push(new RegExp(p.pattern, p.flags || 'i'))
}
}
const apprPatterns = []
for (const p of (tuned.approval?.high || [])) {
apprPatterns.push(new RegExp(p.pattern, p.flags || 'i'))
}
if (corrPatterns.length > 0) return { correction: corrPatterns, approval: apprPatterns }
} catch {}
}
return null
}
const _tuned = loadTunedPatterns()
const CORRECTION_PATTERNS = _tuned?.correction || [
/\b(no[,.]|wrong|not that|that's not|incorrect|stop doing)\b/i,
/\b(i said|i meant|i asked for|that's wrong)\b/i,
/\b(again|already told you|how many times|we discussed this)\b/i,
/\b(don't ever|never do that|stop|please don't|do not)\b/i,
/\b(why did you|why are you|that broke|you broke|you just)\b/i,
/\b(revert|undo|go back|roll back|put it back)\b/i,
/\b(let me (just |)do it|i'll (just |)do it|forget it|never\s*mind)\b/i,
]
const APPROVAL_PATTERNS = _tuned?.approval || [
/\b(perfect|exactly|great|lgtm|looks good|nice work|nailed it)\b/i,
/\b(that works|that's right|correct|good job|well done)\b/i,
/\b(ship it|merge it|push it|deploy it|commit it)\b/i,
/\b(thanks|thank you)\b/i,
]
// Weak-positive patterns — not full approval but proceed signal.
const PROCEED_PATTERNS = [
/\b(continue|proceed|go ahead|go on|keep going|next)\b/i,
/^\s*(yes|yep|yeah|sure|ok|okay)\b/i,
/\b(do it|do that|try it|let'?s try)\b/i
]
// Direction / new-problem signals — user giving a new task or pivoting.
// These reset the stuck-loop counter because we're starting fresh.
const NEW_PROBLEM_PATTERNS = [
// Directives that start new work
/^\s*(now|next|also|and|then)\b/i,
/\b(let'?s|lets)\s+(add|build|make|create|try|do|check|write|fix|update|run|test|deploy|restart)\b/i,
// Imperative commands (usually building on prior state)
/^\s*(add|build|make|create|fix|update|run|test|deploy|restart|remove|delete|show|tell)\b/i,
// Explicit pivots
/\b(actually|instead|wait|hold on)\b.*\b(let'?s|try|do)\b/i,
/^\s*i (want|need|would like) (you|to|us)\b/i
]
// Simple commands — one-word or short imperative that's not a correction
const COMMAND_PATTERNS = [
/^\s*(restart|run|test|build|commit|push|deploy|check|show|list|help)\s*\??\s*$/i
]
/**
* Classify a user prompt semantically.
* Returns: 'correction' | 'approval' | 'proceed' | 'new-problem' | 'command' | 'question' | 'neutral'
*
* Categories affect state differently:
* correction → corrections++, keeps problem-turns running
* approval → full completion, resets problem, increments problemCount
* proceed → weak reset on stuck-loop if stuck, else continues problem
* new-problem → full reset (new work starting)
* command → weak reset (using prior state for a new action)
* question → neutral (info-seeking, doesn't advance or block)
* neutral → no signal
*/
function classifyPrompt (text) {
if (!text) return 'neutral'
const t = text.trim()
// Order matters — check corrections first (strongest negative)
if (CORRECTION_PATTERNS.some(p => p.test(t))) return 'correction'
if (APPROVAL_PATTERNS.some(p => p.test(t))) return 'approval'
if (COMMAND_PATTERNS.some(p => p.test(t))) return 'command'
if (NEW_PROBLEM_PATTERNS.some(p => p.test(t))) return 'new-problem'
if (PROCEED_PATTERNS.some(p => p.test(t))) return 'proceed'
// Question heuristic
if (/\?/.test(t) && /\b(why|how|what|when|where|which|can you|do you|is there)\b/i.test(t)) return 'question'
return 'neutral'
}
// Load skip patterns from tuned config, fall back to defaults
const _skipFromConfig = (() => {
if (fs.existsSync(TUNED_PATTERNS_FILE)) {
try {
const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8'))
if (tuned.skip?.length > 0) return tuned.skip.map(p => new RegExp(p, 'i'))
} catch {}
}
return null
})()
const SKIP_PATTERNS = _skipFromConfig || [
/^!/,
/^<command-name>/,
/^<system-reminder>/,
/^<task-notification>/,
/^<local-command/,
/^Base directory for this skill/,
/^Implement the following plan/,
/^This session is being continued/,
/^Service Unavailable/,
/^fleeky@\w+/,
/tool_use_id/,
/tool_result/,
/^Godot Engine v/,
/^Vulkan \d/,
]
const INTERRUPTION_PATTERN = /^\[Request interrupted/
// ─── State Management ───
function loadState () {
if (!fs.existsSync(STATE_FILE)) return createFreshState()
try {
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
// Migrate old state: ensure new problem-tracking fields exist
// Seed from existing counters so context-saturation etc work immediately
if (state._problemStartTurn == null) state._problemStartTurn = 0
if (state._problemTurns == null) state._problemTurns = state.turns || 0
if (state._problemCount == null) state._problemCount = (state.approvals || 0) + 1
if (state._lastApprovalTurn == null) state._lastApprovalTurn = 0
if (state._lastProceedTurn == null) state._lastProceedTurn = 0
return state
} catch {
return createFreshState()
}
}
function saveState (state) {
state.lastUpdate = new Date().toISOString()
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}
function createFreshState () {
return {
turns: 0,
score: 0.75, // start neutral-positive
corrections: 0,
approvals: 0,
toolCalls: 0,
buildErrors: 0,
interruptions: 0,
editedFiles: [],
revertedEdits: 0,
turnScores: [],
trajectory: 'stable',
lastUpdate: new Date().toISOString(),
// Track last turn's edits for survival check
_lastTurnEdits: [],
_lastTurnHadCorrection: false,
// Loop detection
_recentCorrections: [], // last 5 correction texts
_loopActive: false,
_loopMatches: null,
// Problem-boundary tracking (NEW)
// "Problem turns" = turns since last approval/proceed (or session start).
// Resets on approval, proceed signal, or topic shift.
_problemStartTurn: 0, // turn # when current problem started
_problemTurns: 0, // turns spent on current problem
_problemCount: 1, // # of problems tackled this session
_lastApprovalTurn: 0, // last turn with explicit approval
_lastProceedTurn: 0 // last turn with weak-positive signal
}
}
// ─── Loop Matching ───
function checkForLoop (state) {
// A loop is forming when 2+ corrections in the last 5 turns
const recentCorr = state._recentCorrections || []
if (recentCorr.length < 2) return null
// Build fingerprint from current context
const cwd = state._cwd || process.cwd()
const files = (state.editedFiles || []).slice(-10)
// Classify project from cwd/files
const all = (cwd + ' ' + files.join(' ')).toLowerCase()
let project = 'unknown'
if (all.includes('dc-terrain') || all.includes('dc_terrain')) project = 'dc-terrain'
else if (all.includes('hyper-tps')) project = 'hyper-tps'
else if (all.includes('hypernote')) project = 'hypernote'
else if (all.includes('pear-git')) project = 'pear-git'
else if (all.includes('blender-bridge') || all.includes('blender_bridge')) project = 'blender-bridge'
else if (all.includes('proc-nodes')) project = 'proc-nodes'
else if (all.includes('hyper-overlay')) project = 'hyper-overlay'
else if (all.includes('godot')) project = 'godot'
// Classify type from correction texts
const corrText = recentCorr.join(' ').toLowerCase()
let type = 'other'
if (/invisible|not (visible|showing|rendering)|can.t see|flat/.test(corrText)) type = 'not-rendering'
else if (/crash|error|fail|exception/.test(corrText)) type = 'runtime-error'
else if (/same|still|worse|didn.t (work|help|fix)/.test(corrText)) type = 'fix-failed'
else if (/wrong|reversed|flipped|incorrect/.test(corrText)) type = 'wrong-output'
else if (/why (are|is)|approach|design/.test(corrText)) type = 'wrong-approach'
else if (/audit|planning|think deeper/.test(corrText)) type = 'needs-rethink'
// Build fingerprint
const path_ = require('path')
const stems = [...new Set(files.map(f => {
const base = path_.basename(f)
return base.replace(/\.(js|cpp|h|gd|glsl|gdshader|tscn|tres)$/, '')
}))].slice(0, 5).sort()
const fingerprint = `${project}:${type}:${stems.join(',')}`
// Query past loops
if (!fs.existsSync(LOOPS_FILE)) return { fingerprint, matches: [] }
try {
const loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
const pastLoops = (loopsData.loops || []).filter(l => l.resolved)
const [proj, typ, filesStr] = fingerprint.split(':')
const currentFiles = new Set(filesStr.split(',').filter(Boolean))
const matches = pastLoops
.map(loop => {
const [lProj, lType, lFilesStr] = (loop.fingerprint || '').split(':')
const loopFiles = new Set((lFilesStr || '').split(',').filter(Boolean))
let score = 0
if (lProj === proj) score += 3
if (lType === typ) score += 2
const overlap = [...currentFiles].filter(f => loopFiles.has(f)).length
score += overlap * 2
return { loop, score }
})
.filter(s => s.score >= 3)
.sort((a, b) => b.score - a.score)
.slice(0, 2)
.map(s => ({
score: s.score,
corrections: (s.loop.corrections || []).slice(0, 2),
resolutionFiles: (s.loop.resolution?.files || []).map(f => path_.basename(f)),
approval: s.loop.resolution?.approval?.slice(0, 150),
date: s.loop.date,
}))
return { fingerprint, matches }
} catch {
return { fingerprint, matches: [] }
}
}
// ─── Score Computation ───
function computeScore (state) {
if (state.turns === 0) return 0.75
// Session-level signals (same approach as fuse-signals.js)
const signals = {}
// Correction rate — 0% = 1.0, 5% = 0.75, 10% = 0.5, 20%+ = 0.1
const correctionRate = state.corrections / Math.max(1, state.turns)
signals.correctionRate = Math.max(0.1, 1.0 - correctionRate * 5)
// Approval rate
const approvalRate = state.approvals / Math.max(1, state.turns)
signals.approvalRate = Math.min(1.0, 0.3 + approvalRate * 3)
// Correction-to-approval ratio
const totalSignals = state.corrections + state.approvals
signals.signalRatio = totalSignals > 0
? state.approvals / totalSignals
: 0.5
// Tool call density — avg tool calls per turn
const avgToolCalls = state.toolCalls / Math.max(1, state.turns)
signals.toolEfficiency = avgToolCalls <= 3 ? 1.0
: avgToolCalls <= 6 ? 0.8
: avgToolCalls <= 10 ? 0.6
: 0.4
// Build error rate
const errorRate = state.buildErrors / Math.max(1, state.toolCalls)
signals.buildQuality = Math.max(0.2, 1.0 - errorRate * 5)
// Edit survival
const totalEdits = state.editedFiles.length
if (totalEdits > 0) {
signals.editSurvival = Math.max(0.2, 1.0 - (state.revertedEdits / totalEdits))
} else {
signals.editSurvival = 0.7
}
// Turn score trajectory — requires both improving turns AND decent overall score
if (state.turnScores.length >= 4) {
const recent = state.turnScores.slice(-3)
const earlier = state.turnScores.slice(-6, -3)
if (earlier.length > 0) {
const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length
const earlierAvg = earlier.reduce((a, b) => a + b, 0) / earlier.length
const delta = recentAvg - earlierAvg
if (delta > 0.05 && state.score > 0.5 && state.approvals > 0) state.trajectory = 'improving'
else if (delta < -0.05 || (state.corrections > 2 && state.approvals === 0)) state.trajectory = 'declining'
else state.trajectory = 'stable'
}
}
// Weighted average of signals
const weights = {
correctionRate: 1.0,
approvalRate: 0.6,
signalRatio: 0.8,
toolEfficiency: 0.3,
buildQuality: 0.5,
editSurvival: 0.4,
}
let weightedSum = 0
let totalWeight = 0
for (const [key, value] of Object.entries(signals)) {
const w = weights[key] || 0.3
weightedSum += value * w
totalWeight += w
}
return totalWeight > 0 ? Math.round((weightedSum / totalWeight) * 1000) / 1000 : 0.5
}
// ─── Hook Handlers ───
async function handlePrompt () {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let userText = ''
try {
const data = JSON.parse(input)
userText = data.prompt || data.user_prompt || data.message || ''
} catch {
return // can't parse, skip
}
if (!userText || userText.length < 3) return
if (SKIP_PATTERNS.some(p => p.test(userText))) return
const state = loadState()
// Semantic prompt classification
const category = classifyPrompt(userText)
const isCorrection = category === 'correction'
const isApproval = category === 'approval'
const isInterruption = INTERRUPTION_PATTERN.test(userText)
if (isCorrection) state.corrections++
if (isApproval) state.approvals++
if (isInterruption) state.interruptions++
// Problem-boundary tracking based on semantic category
// Each category has a distinct effect on stuck-loop counter:
switch (category) {
case 'approval':
// Full completion — new problem begins
state._lastApprovalTurn = state.turns
state._problemStartTurn = state.turns
state._problemTurns = 0
state._problemCount = (state._problemCount || 1) + 1
break
case 'new-problem':
case 'command':
// Fresh work starting — reset problem-turns, count as weak completion
state._problemStartTurn = state.turns
state._problemTurns = 0
state._problemCount = (state._problemCount || 1) + 1
state._lastProceedTurn = state.turns
break
case 'proceed':
// Weak reset — resets stuck-loop if currently stuck, else continues problem
state._lastProceedTurn = state.turns
if (state._problemTurns > 4) {
state._problemStartTurn = state.turns
state._problemTurns = 0
}
break
case 'correction':
// Stays in current problem; counter advances as normal
break
case 'question':
case 'neutral':
// No categorical effect — fall through to counter update
break
}
// Always update problem-turns (resets above take effect)
state._problemTurns = state.turns - (state._problemStartTurn || 0)
state._lastPromptCategory = category
// Check edit survival — if this turn is a correction and last turn had edits,
// those edits likely didn't survive
if (isCorrection && state._lastTurnEdits.length > 0) {
state.revertedEdits += state._lastTurnEdits.length
}
// ─── Loop Detection ───
if (!state._recentCorrections) state._recentCorrections = []
if (isCorrection) {
state._recentCorrections.push(userText.slice(0, 200))
if (state._recentCorrections.length > 5) state._recentCorrections.shift()
// Check if a loop is forming (2+ corrections in recent history)
if (state._recentCorrections.length >= 2) {
const loopInfo = checkForLoop(state)
if (loopInfo) {
state._loopActive = true
state._loopMatches = loopInfo.matches.length > 0 ? loopInfo : null
// Output loop warning to stderr (Claude sees this)
if (loopInfo.matches.length > 0) {
const match = loopInfo.matches[0]
const msg = [
'',
'⚠ LOOP DETECTED — you\'ve been here before',
` Pattern: ${loopInfo.fingerprint}`,
` Past match (${match.date}): "${match.corrections[0]?.slice(0, 80)}"`,
]
if (match.resolutionFiles.length > 0) {
msg.push(` What fixed it: ${match.resolutionFiles.join(', ')}`)
}
if (match.approval) {
msg.push(` Resolution: "${match.approval.slice(0, 100)}"`)
}
msg.push('')
process.stderr.write(msg.join('\n'))
}
}
}
} else if (isApproval) {
// Approval breaks the loop
state._recentCorrections = []
state._loopActive = false
state._loopMatches = null
}
// Compute per-turn score
let turnScore = 0.7 // neutral
if (isCorrection) turnScore = 0.2
else if (isApproval) turnScore = 1.0
else if (isInterruption) turnScore = 0.1
state.turns++
state.turnScores.push(turnScore)
// Keep only last 50 turn scores to bound memory
if (state.turnScores.length > 50) state.turnScores = state.turnScores.slice(-50)
// Reset per-turn tracking
state._lastTurnEdits = []
state._lastTurnHadCorrection = isCorrection
state._cwd = process.cwd()
// Recompute session score
state.score = computeScore(state)
saveState(state)
}
async function handleTool () {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let data
try {
data = JSON.parse(input)
} catch {
return
}
const state = loadState()
state.toolCalls++
const toolName = data.tool_name || ''
const toolInput = data.tool_input || {}
const toolResult = data.tool_result || ''
// Track edited files for survival analysis
if (toolName === 'Edit' || toolName === 'Write') {
const fp = toolInput.file_path || ''
if (fp) {
state.editedFiles.push(fp)
state._lastTurnEdits.push(fp)
}
}
// Detect build/test errors in tool results
const resultText = typeof toolResult === 'string' ? toolResult
: typeof toolResult === 'object' ? JSON.stringify(toolResult)
: ''
if (toolName === 'Bash' && resultText.length > 0) {
if (/error|Error|ERROR|FAIL|fatal|panic|exception|TypeError|ReferenceError|SyntaxError/i.test(resultText)) {
// Check it's actually an error, not just the word "error" in normal output
if (!/no errors?|error.*(free|safe|handling|message|log)|fix.*error/i.test(resultText)) {
state.buildErrors++
}
}
}
// Recompute score periodically (every 5 tool calls to stay fast)
if (state.toolCalls % 5 === 0) {
state.score = computeScore(state)
}
saveState(state)
}
function handleStop () {
const state = loadState()
state.score = computeScore(state)
state.endTime = new Date().toISOString()
saveState(state)
// Write session summary to data dir for historical tracking
const summaryDir = path.join(__dirname, 'data', 'session-summaries')
if (!fs.existsSync(summaryDir)) fs.mkdirSync(summaryDir, { recursive: true })
const summary = {
score: state.score,
turns: state.turns,
corrections: state.corrections,
approvals: state.approvals,
toolCalls: state.toolCalls,
buildErrors: state.buildErrors,
interruptions: state.interruptions,
revertedEdits: state.revertedEdits,
trajectory: state.trajectory,
endTime: state.endTime,
}
const summaryFile = path.join(summaryDir, `${new Date().toISOString().slice(0, 10)}-${Date.now()}.json`)
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2))
}
function handleStatus () {
const state = loadState()
// Trajectory arrow
const arrow = state.trajectory === 'improving' ? '▲'
: state.trajectory === 'declining' ? '▼'
: '▬'
// Compute learning rate: what % of correction BURSTS led to clean turns?
// A burst = consecutive corrections collapsed into one event.
// Learned = burst followed by 3+ non-correction turns.
// Failed = burst followed by another burst within 3 turns.
const scores = state.turnScores || []
let corrLearned = 0
let corrFailed = 0
let i = 0
while (i < scores.length) {
if (scores[i] === 0.2) {
// Found a correction burst — skip consecutive corrections
while (i < scores.length && scores[i] === 0.2) i++
// Now check what follows
const after = scores.slice(i, i + 3)
if (after.includes(0.2)) corrFailed++
else corrLearned++
} else {
i++
}
}
const totalCorr = corrLearned + corrFailed
const learningRate = totalCorr > 0 ? Math.round(corrLearned / totalCorr * 100) : null
// Format: ▬ 0.72 │ 5ok 2fix │ 34T │ 75% learning │ ⟳LOOP
const parts = [
`${arrow} ${state.score.toFixed(2)}`,
`${state.approvals}ok ${state.corrections}fix`,
`${state.toolCalls}T`,
]
if (learningRate !== null) {
parts.push(`${learningRate}% learning`)
}
if (state._loopActive) {
parts.push('⟳LOOP')
}
process.stdout.write(parts.join(' │ '))
}
function handleReset () {
saveState(createFreshState())
}
// ─── CLI ───
const command = process.argv[2] || 'status'
switch (command) {
case 'prompt':
handlePrompt().catch(() => process.exit(0))
break
case 'tool':
handleTool().catch(() => process.exit(0))
break
case 'stop':
handleStop()
break
case 'status':
handleStatus()
break
case 'reset':
handleReset()
break
default:
console.error(`Usage: realtime-score.js [prompt|tool|stop|status|reset]`)
process.exit(1)
}