OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / dashboard.js

680 lines · javascript ref:
#!/usr/bin/env node

// Ratchet Dashboard — HTML Visualization
// Generates a self-contained HTML dashboard from all ratchet data.
//
// Usage:
//   node dashboard.js                # generate only
//   node dashboard.js --no-open      # same (alias)
//   node dashboard.js --serve        # serve on port 9077
//   node dashboard.js --serve=PORT   # serve on custom port

const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')

const DATA_DIR = path.join(__dirname, 'data')
const OUTPUT_FILE = path.join(DATA_DIR, 'dashboard.html')

function loadJSON (name) {
  const file = path.join(DATA_DIR, name)
  if (!fs.existsSync(file)) return null
  try { return JSON.parse(fs.readFileSync(file, 'utf8')) }
  catch { return null }
}

function generate () {
  // Live session state
  let liveSession = null
  try {
    if (fs.existsSync('/tmp/claude-session-score.json')) {
      liveSession = JSON.parse(fs.readFileSync('/tmp/claude-session-score.json', 'utf8'))
    }
  } catch {}

  const fused = loadJSON('fused-scores.json')
  const loops = loadJSON('loops.json')
  const projectMaps = loadJSON('project-maps.json')
  const benchmarks = loadJSON('benchmark-results.json')
  const benchmarkHistory = loadJSON('benchmark-history.json') || []
  const weightHistory = loadJSON('weight-history.json')
  const coverage = loadJSON('coverage-map.json')
  const learnedRules = loadJSON('learned-rules.json')

  // ─── Prepare chart data ───

  // Weekly trend: density + fused score
  const weeklyData = []
  if (fused?.weekly) {
    for (const [week, data] of Object.entries(fused.weekly).sort()) {
      const density = data.totalScore != null
        ? null
        : (fused.sessions || [])
          .filter(s => {
            const d = new Date(s.date)
            d.setDate(d.getDate() - d.getDay())
            return d.toISOString().slice(0, 10) === week
          })
          .reduce((acc, s) => {
            acc.turns += s.turns
            acc.corr += s.corrections
            return acc
          }, { turns: 0, corr: 0 })

      // Find last session date in this week for range labels
      const weekSessions = (fused.sessions || []).filter(s => {
        const d = new Date(s.date)
        d.setDate(d.getDate() - d.getDay())
        return d.toISOString().slice(0, 10) === week
      })
      const lastDate = weekSessions.length > 0
        ? weekSessions.sort((a, b) => (a.date || '').localeCompare(b.date || ''))[weekSessions.length - 1].date
        : week
      const weekLabel = lastDate !== week
        ? `${week.slice(5)}…${lastDate.slice(8)}`
        : week.slice(5)

      weeklyData.push({
        week,
        weekLabel,
        score: data.avgScore,
        // Per-turn score: quality independent of session length
        perTurnScore: data.avgPerTurnScore || data.avgScore,
        // Trusted score: top-5 sensors only — PRIMARY quality signal
        trustedScore: data.avgTrustedScore,
        // Correction burst: longest streak of consecutive corrections (lower = healthier)
        avgCorrectionBurst: data.avgCorrectionBurst,
        maxCorrectionBurst: data.maxCorrectionBurst || 0,
        avgTurns: data.avgTurns || 0,
        avgDensity: data.avgDensity || null,
        sessions: data.sessions,
        corrections: data.corrections,
        approvals: data.approvals,
        density: density ? (density.turns > 0 ? density.corr / density.turns : 0) : null,
      })
    }
  }

  // Session scores for scatter plot
  const sessionData = (fused?.sessions || []).map(s => ({
    date: s.date,
    score: s.score,
    turns: s.turns,
    corrections: s.corrections,
    approvals: s.approvals,
    branch: s.gitBranch,
  }))

  // Loops by project
  const loopsByProject = {}
  if (loops?.loops) {
    for (const l of loops.loops) {
      if (!loopsByProject[l.project]) loopsByProject[l.project] = { total: 0, resolved: 0, avgDuration: 0, totalDuration: 0 }
      loopsByProject[l.project].total++
      if (l.resolved) loopsByProject[l.project].resolved++
      loopsByProject[l.project].totalDuration += l.duration
    }
    for (const v of Object.values(loopsByProject)) {
      v.avgDuration = v.total > 0 ? (v.totalDuration / v.total) : 0
    }
  }

  // Loops over time (weekly)
  const loopsWeekly = {}
  if (loops?.loops) {
    for (const l of loops.loops) {
      if (!l.date) continue
      const d = new Date(l.date)
      d.setDate(d.getDate() - d.getDay())
      const week = d.toISOString().slice(0, 10)
      if (!loopsWeekly[week]) loopsWeekly[week] = { total: 0, resolved: 0 }
      loopsWeekly[week].total++
      if (l.resolved) loopsWeekly[week].resolved++
    }
  }

  // Project subsystem danger map
  const dangerMap = {}
  if (projectMaps?.projects) {
    for (const [proj, data] of Object.entries(projectMaps.projects)) {
      dangerMap[proj] = Object.entries(data.subsystems || {})
        .filter(([, s]) => s.loopCount > 0)
        .map(([name, s]) => ({ name, files: s.fileCount, loops: s.loopCount, danger: s.dangerLevel }))
        .sort((a, b) => b.loops - a.loops)
    }
  }

  // Sensor weight evolution
  const weightData = (weightHistory || []).map(h => ({
    date: h.timestamp?.slice(0, 10),
    weights: h.weights,
    changes: h.changes?.length || 0,
  }))

  // Coverage summary
  const coverageSummary = coverage?.summary || {}

  // ─── Pre-compute ratchet status cards ───
  const latest = benchmarkHistory.length > 0 ? benchmarkHistory[benchmarkHistory.length - 1] : null
  const prev = benchmarkHistory.length > 1 ? benchmarkHistory[benchmarkHistory.length - 2] : null

  const novelRate = latest?.novelRate != null ? (latest.novelRate * 100).toFixed(0) : '—'
  const densityPct = latest?.density != null ? (latest.density * 100).toFixed(1) : '—'
  const resolution = latest?.avgResolution != null ? latest.avgResolution.toFixed(1) : '—'
  const rulesCount = latest?.rulesCount ?? learnedRules?.totalRules ?? 0

  function ratchetArrow (delta, invertGood) {
    if (delta == null) return ''
    const good = invertGood ? delta < 0 : delta > 0
    const bad = invertGood ? delta > 0 : delta < 0
    if (good) return '<span class="green"> ▲</span>'
    if (bad) return '<span class="red"> ▼</span>'
    return '<span class="dim"> ▬</span>'
  }
  function ratchetColor (val, goodThresh, warnThresh, invert) {
    if (val === '—') return ''
    const v = parseFloat(val)
    if (invert) return v < goodThresh ? 'green' : v < warnThresh ? 'yellow' : 'red'
    return v > goodThresh ? 'green' : v > warnThresh ? 'yellow' : 'red'
  }

  const novelDelta = prev?.novelRate != null && latest?.novelRate != null ? latest.novelRate - prev.novelRate : null
  const densityDelta = prev?.density != null && latest?.density != null ? latest.density - prev.density : null
  const resDelta = prev?.avgResolution != null && latest?.avgResolution != null ? latest.avgResolution - prev.avgResolution : null

  const novelColor = ratchetColor(novelRate, 90, 80, false)
  const densityColor = ratchetColor(densityPct, 15, 25, true)
  const resColor = ratchetColor(resolution, 6, 10, true)
  const novelArrow = ratchetArrow(novelDelta, false)
  const densityArrow = ratchetArrow(densityDelta, true)
  const resArrow = ratchetArrow(resDelta, true)

  // Live session
  const liveScore = liveSession?.score?.toFixed(2) || '—'
  const liveColor = liveSession?.score > 0.7 ? 'green' : liveSession?.score > 0.5 ? 'yellow' : 'red'
  const liveBorder = liveSession?._loopActive ? '#da3633' : liveSession?.score > 0.7 ? '#238636' : '#9e6a03'
  const liveLabel = liveSession?._loopActive ? '⟳ LIVE — LOOP ACTIVE' : '● LIVE SESSION'
  const liveLabelColor = liveSession?._loopActive ? '#f85149' : '#3fb950'
  const liveTraj = liveSession?.trajectory === 'improving' ? '▲ improving' : liveSession?.trajectory === 'declining' ? '▼ declining' : '▬ stable'
  const liveTrajColor = liveSession?.trajectory === 'improving' ? 'green' : liveSession?.trajectory === 'declining' ? 'red' : 'dim'

  // Live learning rate
  let liveLearningRate = null
  if (liveSession?.turnScores) {
    let learned = 0, failed = 0
    const ts = liveSession.turnScores
    for (let i = 0; i < ts.length; i++) {
      if (ts[i] === 0.2) {
        const after = ts.slice(i + 1, i + 4)
        if (after.includes(0.2)) failed++
        else learned++
      }
    }
    const total = learned + failed
    if (total > 0) liveLearningRate = Math.round(learned / total * 100)
  }
  const liveLearningStr = liveLearningRate !== null ? `${liveLearningRate}% learning` : ''
  const liveLearningColor = liveLearningRate > 70 ? 'green' : liveLearningRate > 40 ? 'yellow' : 'red'

  // ─── Generate HTML ───
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Knowledge Ratchet Dashboard</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: #0d1117; color: #c9d1d9; padding: 20px; }
  h1 { color: #58a6ff; margin-bottom: 8px; font-size: 24px; }
  h2 { color: #8b949e; font-size: 14px; margin-bottom: 20px; }
  h3 { color: #58a6ff; font-size: 16px; margin: 20px 0 10px; border-bottom: 1px solid #21262d; padding-bottom: 5px; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 20px; }
  .card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; }
  .card-title { color: #8b949e; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; }
  .big-number { font-size: 36px; font-weight: bold; }
  .green { color: #3fb950; }
  .red { color: #f85149; }
  .yellow { color: #d29922; }
  .blue { color: #58a6ff; }
  .dim { color: #484f58; }
  svg { width: 100%; }
  .chart-container { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
  th { text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #21262d; font-weight: normal; }
  td { padding: 6px 8px; border-bottom: 1px solid #21262d; }
  .bar { display: inline-block; height: 14px; border-radius: 2px; }
  .bar-green { background: #238636; }
  .bar-red { background: #da3633; }
  .bar-yellow { background: #9e6a03; }
  .danger-high { color: #f85149; font-weight: bold; }
  .danger-medium { color: #d29922; }
  .danger-low { color: #3fb950; }
  .tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; margin: 1px; }
  .tag-high { background: #da363322; color: #f85149; border: 1px solid #da363355; }
  .tag-medium { background: #9e6a0322; color: #d29922; border: 1px solid #9e6a0355; }
  .tag-low { background: #23863622; color: #3fb950; border: 1px solid #23863655; }
  .timestamp { color: #484f58; font-size: 11px; margin-top: 20px; }
</style>
</head>
<body>
<h1>Knowledge Ratchet <a href="graph" style="font-size: 14px; color: #58a6ff; text-decoration: none; margin-left: 16px;">→ AtomSpace Graph</a></h1>
<h2>Self-improving AI quality system — tracks corrections, detects failure loops, auto-generates prevention rules
<br>Data through: ${benchmarkHistory.length > 0 ? benchmarkHistory[benchmarkHistory.length - 1].date : 'n/a'} | ${sessionData.length} sessions</h2>

${liveSession && liveSession.turns > 0 ? `
<div class="card" style="border-color: ${liveBorder}; margin-bottom: 16px;">
  <div class="card-title" style="color: ${liveLabelColor}">${liveLabel}</div>
  <div style="display: flex; gap: 32px; align-items: baseline;">
    <div><span class="big-number ${liveColor}">${liveScore}</span><span class="dim"> score</span></div>
    <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>
    <div><span class="dim">${liveSession.toolCalls} tool calls</span></div>
    <div><span class="${liveTrajColor}">${liveTraj}</span></div>
    ${liveLearningRate !== null ? `<div><span class="${liveLearningColor}" style="font-size:18px">${liveLearningStr}</span></div>` : ''}
  </div>
</div>
` : ''}

<!-- Ratchet Status — Is Claude getting smarter? -->
<div class="grid">
  <div class="card">
    <div class="card-title">Novel Corrections (target: &gt;90%)</div>
    <div class="big-number ${novelColor}">${novelRate}%${novelArrow}</div>
    <div class="dim">% of corrections that are new mistakes, not repeats. Higher = old mistakes stay fixed.</div>
  </div>
  <div class="card">
    <div class="card-title">Correction Density (target: &lt;15%)</div>
    <div class="big-number ${densityColor}">${densityPct}%${densityArrow}</div>
    <div class="dim">How often the user has to correct Claude per turn. 20% = 1 in 5 turns. Lower = fewer mistakes.</div>
  </div>
  <div class="card">
    <div class="card-title">Loop Resolution (target: &lt;6 turns)</div>
    <div class="big-number ${resColor}">${resolution}${resArrow}</div>
    <div class="dim">When Claude gets stuck in a correction loop, how many turns to escape. Lower = learns faster.</div>
  </div>
  <div class="card">
    <div class="card-title">Learned Rules</div>
    <div class="big-number blue">${rulesCount}</div>
    <div class="dim">${sessionData.length} sessions, ${loops?.totalLoops || 0} loops, ${loops?.resolved || 0} resolved</div>
  </div>
</div>

<!-- Daily Ratchet Trends (primary) -->
${benchmarkHistory.length >= 1 ? `
<h3>Daily Ratchet Trends</h3>
<div class="grid">
  <div class="card">
    <div class="card-title">Novel Correction Rate (higher = better)</div>
    ${renderLineChart(
      benchmarkHistory.filter(h => h.novelRate != null).map(h => h.novelRate),
      benchmarkHistory.filter(h => h.novelRate != null).map(h => h.date),
      { height: 120, color: '#3fb950', label: 'Novel Rate', format: 'pct' }
    )}
  </div>
  <div class="card">
    <div class="card-title">Correction Density (lower = better)</div>
    ${renderLineChart(
      benchmarkHistory.filter(h => h.density != null).map(h => h.density),
      benchmarkHistory.filter(h => h.density != null).map(h => h.date),
      { height: 120, color: '#f85149', label: 'Density', format: 'pct' }
    )}
  </div>
  <div class="card">
    <div class="card-title">Loop Resolution Speed (lower = better)</div>
    ${renderLineChart(
      benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.avgResolution),
      benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.date),
      { height: 120, color: '#d29922', label: 'Turns to Resolve', format: 'auto' }
    )}
  </div>
  <div class="card">
    <div class="card-title">Learned Rules (higher = more knowledge)</div>
    ${renderLineChart(
      benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.rulesCount),
      benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.date),
      { height: 120, color: '#58a6ff', label: 'Rules', format: 'int' }
    )}
  </div>
</div>

<div class="card" style="margin-top:16px; margin-bottom:16px">
  <div class="card-title">Ratchet Scorecard</div>
  <table>
    <tr><th>Metric</th><th>Current</th><th>Target</th><th>Status</th></tr>
    <tr>
      <td>Novel correction rate</td>
      <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate != null ? (benchmarkHistory[benchmarkHistory.length-1].novelRate * 100).toFixed(0) + '%' : 'n/a'}</td>
      <td>&gt;90%</td>
      <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate > 0.9 ? '<span class="green">&#10003;</span>' : benchmarkHistory[benchmarkHistory.length-1]?.novelRate > 0.8 ? '<span class="yellow">~</span>' : '<span class="red">&#10007;</span>'}</td>
    </tr>
    <tr>
      <td>Correction density</td>
      <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density != null ? (benchmarkHistory[benchmarkHistory.length-1].density * 100).toFixed(1) + '%' : 'n/a'}</td>
      <td>&lt;15%</td>
      <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density < 0.15 ? '<span class="green">&#10003;</span>' : benchmarkHistory[benchmarkHistory.length-1]?.density < 0.25 ? '<span class="yellow">~</span>' : '<span class="red">&#10007;</span>'}</td>
    </tr>
    <tr>
      <td>Avg loop resolution</td>
      <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution != null ? benchmarkHistory[benchmarkHistory.length-1].avgResolution.toFixed(1) + ' turns' : 'n/a'}</td>
      <td>&lt;6 turns</td>
      <td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution < 6 ? '<span class="green">&#10003;</span>' : benchmarkHistory[benchmarkHistory.length-1]?.avgResolution < 10 ? '<span class="yellow">~</span>' : '<span class="red">&#10007;</span>'}</td>
    </tr>
    <tr>
      <td>Learned rules</td>
      <td>${learnedRules?.totalRules || 0}</td>
      <td>growing</td>
      <td><span class="blue">${learnedRules?.totalRules > 0 ? '&#10003;' : '~'}</span></td>
    </tr>
  </table>
</div>
` : ''}

<!-- Primary quality metric: Trusted score (top-5 sensors, noise-free) -->
<div class="chart-container">
  <div class="card-title">★ Trusted Score (PRIMARY) — top-5 sensors only, ratchet's real quality signal</div>
  ${renderLineChart(weeklyData.map(w => w.trustedScore || 0), weeklyData.map(w => w.weekLabel), { height: 140, color: '#3fb950', label: 'Trusted Score', format: 'auto' })}
</div>

<!-- Secondary metrics for debugging -->
<div class="chart-container">
  <div class="card-title">Per-Turn Score (all 16 sensors) — includes noise; see audit for caveats</div>
  ${renderLineChart(weeklyData.map(w => w.perTurnScore), weeklyData.map(w => w.weekLabel), { height: 100, color: '#8b949e', label: 'Per-Turn Score', format: 'auto' })}
</div>

<div class="chart-container">
  <div class="card-title">Avg Correction Burst — longest run of consecutive corrections per session (lower = healthier; 1 = isolated mistakes, 2+ = stuck patterns)</div>
  ${renderLineChart(weeklyData.map(w => w.avgCorrectionBurst || 0), weeklyData.map(w => w.weekLabel), { height: 100, color: '#f0883e', label: 'Avg Burst', format: 'auto' })}
</div>

<!-- Session Length: ambition, not quality -->
<div class="chart-container">
  <div class="card-title">Avg Turns Per Session — ambition level (not a quality metric)</div>
  ${renderLineChart(weeklyData.map(w => w.avgTurns), weeklyData.map(w => w.weekLabel), { height: 100, color: '#bc8cff', label: 'Avg Turns', format: 'int' })}
</div>

<!-- Weekly Correction Density -->
<div class="chart-container">
  <div class="card-title">Weekly Corrections vs Approvals (green overtaking red = improving)</div>
  ${renderBarChart(weeklyData, { height: 120 })}
</div>

<!-- Loops Over Time -->
<div class="chart-container">
  <div class="card-title">Loops Per Week (lower = better)</div>
  ${renderLineChart(
    Object.entries(loopsWeekly).sort().map(([, v]) => v.total),
    Object.entries(loopsWeekly).sort().map(([k]) => k),
    { height: 120, color: '#f85149', label: 'Loops Per Week', format: 'int' }
  )}
</div>

<!-- Session Scatter -->
<div class="chart-container">
  <div class="card-title">Session Scores — green dots = clean sessions, red = heavy corrections, size = session length. Higher = better.</div>
  ${renderScatter(sessionData, { height: 150 })}
</div>

<!-- Loops by Project -->
<h3>Loops by Project</h3>
<p class="dim" style="margin-bottom:8px">A loop is a correction spiral — Claude gets corrected, tries to fix it, gets corrected again. More loops = more frustration. Higher resolved % = problems eventually get fixed.</p>
<table>
  <tr><th>Project</th><th>Loops</th><th>Resolved</th><th>Avg Duration</th><th></th></tr>
  ${Object.entries(loopsByProject).sort((a, b) => b[1].total - a[1].total).map(([proj, data]) => {
    const pct = data.total > 0 ? (data.resolved / data.total * 100) : 0
    return `<tr>
      <td>${proj}</td>
      <td>${data.total}</td>
      <td>${data.resolved} (${pct.toFixed(0)}%)</td>
      <td>${data.avgDuration.toFixed(1)} turns</td>
      <td><span class="bar bar-red" style="width:${Math.min(data.total, 100)}px"></span></td>
    </tr>`
  }).join('\n  ')}
</table>

<!-- Subsystem Danger Map -->
<h3>Subsystem Danger Map</h3>
<p class="dim" style="margin-bottom:8px">Auto-discovered from code. Red = high loop count (Claude struggles here). Yellow = moderate. Green = stable. L=loops, F=files in subsystem.</p>
${Object.entries(dangerMap).map(([proj, subs]) => {
  if (subs.length === 0) return ''
  return `<div style="margin-bottom:12px">
    <strong>${proj}</strong>
    <div>${subs.map(s =>
      `<span class="tag tag-${s.danger}">${s.name} (${s.loops}L/${s.files}F)</span>`
    ).join(' ')}</div>
  </div>`
}).join('\n')}

<!-- Sensor Weights -->
<h3>Sensor Weights</h3>
<p class="dim" style="margin-bottom:8px">The system uses 16 sensors to score each turn. Weights auto-adjust based on which sensors best predict corrections. Higher weight = more influence on per-turn scoring.</p>
<table>
  <tr><th>Sensor</th><th>Weight</th><th>Bar</th></tr>
  ${fused?.sensorWeights ? Object.entries(fused.sensorWeights).sort((a, b) => b[1] - a[1]).map(([name, weight]) => {
    const w = Math.round(weight * 100)
    return `<tr>
      <td>${name}</td>
      <td>${weight.toFixed(3)}</td>
      <td><span class="bar bar-green" style="width:${w}px"></span></td>
    </tr>`
  }).join('\n  ') : '<tr><td colspan="3" class="dim">No weight data</td></tr>'}
</table>

<!-- Ratchet Intelligence Trends (formerly buried at bottom) -->
<div class="dim" style="margin-bottom: 4px; font-size: 11px;">Remaining sections: sensor weights, loops by project, subsystem danger map are below.</div>

<div class="timestamp">
  Dashboard generated: ${new Date().toISOString()}<br>
  Ratchet active since: 2026-04-07 | ${benchmarkHistory.length} benchmark snapshots
</div>
</body>
</html>`

  fs.writeFileSync(OUTPUT_FILE, html)
  console.log(`Dashboard: ${OUTPUT_FILE}`)

}

function serve (port) {
  const http = require('http')
  const url = require('url')
  generate() // initial generation
  http.createServer(async (req, res) => {
    const parsed = url.parse(req.url, true)
    try {
      // /graph — render the D3 graph visualization page
      if (parsed.pathname === '/graph' || parsed.pathname === '/graph.html') {
        const graphHtml = fs.readFileSync(path.join(__dirname, 'atomspace/graph.html'), 'utf8')
        res.writeHead(200, { 'Content-Type': 'text/html' })
        res.end(graphHtml)
        return
      }

      // /graph.json — live AtomSpace slice as JSON
      if (parsed.pathname === '/graph.json') {
        const { exportGraph } = require('./atomspace/graph-export')
        const opts = {}
        if (parsed.query.module) opts.module = parsed.query.module
        if (parsed.query.minWisdom) opts.minWisdom = parseFloat(parsed.query.minWisdom)
        if (parsed.query.limit) opts.limit = parseInt(parsed.query.limit)
        const data = await exportGraph(opts)
        res.writeHead(200, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify(data))
        return
      }

      // /fires.json — recent rule firings for live dashboard
      if (parsed.pathname === '/fires.json') {
        const { readRecent, readSince } = require('./atomspace/fire-log')
        const since = parsed.query.since ? parseInt(parsed.query.since) : null
        const limit = parsed.query.limit ? parseInt(parsed.query.limit) : 50
        const fires = since ? readSince(since) : readRecent(limit)
        res.writeHead(200, { 'Content-Type': 'application/json' })
        res.end(JSON.stringify({ fires, count: fires.length, at: Date.now() }))
        return
      }

      // Default: serve the regenerated dashboard
      generate() // regenerate on each request — always fresh data
      res.writeHead(200, { 'Content-Type': 'text/html' })
      res.end(fs.readFileSync(OUTPUT_FILE, 'utf8'))
    } catch (err) {
      res.writeHead(500, { 'Content-Type': 'text/plain' })
      res.end('Dashboard error: ' + err.message)
    }
  }).listen(port, () => {
    console.log(`Ratchet dashboard: http://localhost:${port}`)
    console.log(`AtomSpace graph:   http://localhost:${port}/graph`)
  })
}

// ─── CLI ───
const serveArg = process.argv.find(a => a.startsWith('--serve'))
if (serveArg) {
  const port = serveArg.includes('=') ? parseInt(serveArg.split('=')[1]) : 9077
  serve(port)
} else {
  generate()
}

// ─── SVG Chart Renderers ───

function renderLineChart (values, labels, opts = {}) {
  const { height = 120, color = '#58a6ff', label = '', format = 'auto' } = opts
  if (values.length === 0) return '<div class="dim">No data</div>'

  const w = 600
  const h = height
  const padL = 50 // left pad for Y-axis labels
  const padR = 20
  const padT = 25
  const padB = 25
  const chartW = w - padL - padR
  const chartH = h - padT - padB

  const rawMin = Math.min(...values)
  const rawMax = Math.max(...values)
  const margin = (rawMax - rawMin) * 0.15 || 0.05
  const min = rawMin - margin
  const max = rawMax + margin
  const range = max - min || 1

  // Format values for display
  function fmt (v) {
    if (format === 'pct') return (v * 100).toFixed(0) + '%'
    if (format === 'int') return Math.round(v).toString()
    if (v >= 100) return Math.round(v).toString()
    if (v >= 1) return v.toFixed(1)
    return (v * 100).toFixed(0) + '%'
  }

  // Y-axis: 3-4 gridlines with labels
  const gridLines = []
  const steps = 4
  for (let i = 0; i <= steps; i++) {
    const v = min + (range * i / steps)
    const y = h - padB - (i / steps) * chartH
    gridLines.push({ v, y })
  }

  const points = values.map((v, i) => {
    const x = padL + (i / Math.max(1, values.length - 1)) * chartW
    const y = h - padB - ((v - min) / range) * chartH
    return { x, y, v }
  })

  const xLabels = labels.map((l, i) => {
    if (i % Math.max(1, Math.floor(labels.length / 6)) !== 0) return ''
    const x = padL + (i / Math.max(1, values.length - 1)) * chartW
    return `<text x="${x}" y="${h - 3}" fill="#484f58" font-size="10" text-anchor="middle">${l.slice(5)}</text>`
  }).join('')

  return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
    ${gridLines.map(g => `
      <line x1="${padL}" y1="${g.y}" x2="${w - padR}" y2="${g.y}" stroke="#21262d" stroke-width="1"/>
      <text x="${padL - 5}" y="${g.y + 4}" fill="#484f58" font-size="9" text-anchor="end">${fmt(g.v)}</text>
    `).join('')}
    <polyline points="${points.map(p => p.x + ',' + p.y).join(' ')}" fill="none" stroke="${color}" stroke-width="2"/>
    ${points.map(p => `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${color}"><title>${fmt(p.v)}</title></circle>`).join('')}
    ${points.map(p => `<text x="${p.x}" y="${p.y - 8}" fill="${color}" font-size="9" text-anchor="middle">${fmt(p.v)}</text>`).join('')}
    ${xLabels}
    <text x="${padL}" y="14" fill="#8b949e" font-size="11">${label}</text>
  </svg>`
}

function renderBarChart (weeklyData, opts = {}) {
  const { height = 140 } = opts
  if (weeklyData.length === 0) return '<div class="dim">No data</div>'

  const w = 600
  const h = height
  const padL = 50
  const padR = 20
  const padT = 25
  const padB = 25
  const chartW = w - padL - padR
  const chartH = h - padT - padB
  const barW = Math.max(8, chartW / weeklyData.length / 2 - 2)

  const maxVal = Math.max(...weeklyData.map(d => Math.max(d.corrections, d.approvals)), 1)

  // Y-axis gridlines
  const gridLines = []
  const steps = 4
  for (let i = 0; i <= steps; i++) {
    const v = Math.round(maxVal * i / steps)
    const y = h - padB - (i / steps) * chartH
    gridLines.push({ v, y })
  }

  return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
    ${gridLines.map(g => `
      <line x1="${padL}" y1="${g.y}" x2="${w - padR}" y2="${g.y}" stroke="#21262d" stroke-width="1"/>
      <text x="${padL - 5}" y="${g.y + 4}" fill="#484f58" font-size="9" text-anchor="end">${g.v}</text>
    `).join('')}
    ${weeklyData.map((d, i) => {
      const x = padL + (i / Math.max(1, weeklyData.length - 1)) * chartW
      const corrH = (d.corrections / maxVal) * chartH
      const apprH = (d.approvals / maxVal) * chartH
      return `
        <rect x="${x - barW}" y="${h - padB - corrH}" width="${barW}" height="${corrH}" fill="#da3633" opacity="0.7"/>
        <rect x="${x}" y="${h - padB - apprH}" width="${barW}" height="${apprH}" fill="#238636" opacity="0.7"/>
        <text x="${x - barW/2}" y="${h - padB - corrH - 3}" fill="#da3633" font-size="8" text-anchor="middle">${d.corrections}</text>
        <text x="${x + barW/2}" y="${h - padB - apprH - 3}" fill="#238636" font-size="8" text-anchor="middle">${d.approvals}</text>
        <text x="${x}" y="${h - 5}" fill="#484f58" font-size="9" text-anchor="middle">${d.weekLabel || d.week.slice(5)}</text>
      `
    }).join('')}
    <text x="${padL}" y="14" fill="#da3633" font-size="11">■ corrections</text>
    <text x="${padL + 100}" y="14" fill="#238636" font-size="11">■ approvals</text>
  </svg>`
}

function renderScatter (sessions, opts = {}) {
  const { height = 150 } = opts
  if (sessions.length === 0) return '<div class="dim">No data</div>'

  const w = 600
  const h = height
  const pad = 30

  // X axis = date index, Y axis = score
  const sorted = [...sessions].sort((a, b) => (a.date || '').localeCompare(b.date || ''))
  const minScore = 0.4
  const maxScore = 1.0
  const range = maxScore - minScore

  return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
    ${sorted.map((s, i) => {
      const x = pad + (i / Math.max(1, sorted.length - 1)) * (w - pad * 2)
      const y = h - pad - ((s.score - minScore) / range) * (h - pad * 2)
      const r = Math.min(8, Math.max(2, Math.sqrt(s.turns) * 0.5))
      const color = s.corrections === 0 ? '#3fb950' : s.score < 0.6 ? '#f85149' : '#d29922'
      return `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6">
        <title>${s.date} score=${s.score.toFixed(3)} ${s.turns}T ${s.corrections}✗ ${s.approvals}✓</title>
      </circle>`
    }).join('')}
    <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"/>
    <text x="${w - pad + 5}" y="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2) + 4}" fill="#484f58" font-size="9">0.7</text>
  </svg>`
}

// Entry point moved to CLI section above generate()/serve()