BID · Console
Baseline · Intelligence · Decision
scripts/render-report.ts 33,199 bytes · typescript
/**
 * Renders a run-*.json audit trail into a single self-contained HTML
 * report (no external assets) under the same name.
 *
 * Interactive: trace replay (play/pause/step/scrub), filters by agent
 * and Std, click-to-expand record details with raw source snippet.
 *
 * Usage:
 *   tsx scripts/render-report.ts                          # latest run
 *   tsx scripts/render-report.ts output/run-...json       # specific run
 */

import { readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import type { OrchestrationResult } from '../src/orchestrator.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT_DIR = path.resolve(__dirname, '..', 'output');

async function pickLatest(): Promise<string | null> {
  const entries = await readdir(OUTPUT_DIR);
  const runs = entries
    .filter(f => (f.startsWith('run-') || f.startsWith('ask-')) && f.endsWith('.json'))
    .sort();
  const last = runs.at(-1);
  return last ? path.join(OUTPUT_DIR, last) : null;
}

function esc(s: unknown): string {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function tierColor(tier: string): string {
  if (tier === 'high') return '#137333';
  if (tier === 'medium') return '#b06000';
  return '#a50e0e';
}
function statusColor(status: string): string {
  if (status === 'passed') return '#137333';
  if (status === 'flagged') return '#b06000';
  return '#a50e0e';
}

function renderPipeline(result: OrchestrationResult): string {
  const executedAgents = new Set(result.repositorySnapshot.records.map(r => r.agent));
  return result.pipeline
    .map(step => {
      if (step.kind === 'checkpoint') {
        return `<div class="step checkpoint">⏸ ${esc(step.name)}</div>`;
      }
      const fqn = `${step.pillar}.${step.agent}`;
      const executed = executedAgents.has(fqn);
      const cls = executed ? 'step done' : 'step pending';
      return `<div class="${cls}" data-agent="${esc(fqn)}"><span class="pill">${esc(step.pillar)}</span>${esc(step.agent)}</div>`;
    })
    .join('<div class="arrow">→</div>');
}

function renderStandards(result: OrchestrationResult): string {
  return result.standards
    .map(
      s => `
      <div class="std" data-std="${s.n}" title="Click to filter trace by Std ${s.n}">
        <div class="std-n">Std ${s.n}</div>
        <div class="std-body"><div class="std-name">${esc(s.name)}</div><div class="std-gist">${esc(s.gist)}</div></div>
      </div>`,
    )
    .join('');
}

function renderAgents(result: OrchestrationResult): string {
  const records = result.repositorySnapshot.records;

  // Build a map: agent name → indices in the global trace.
  const traceIndicesByAgent = new Map<string, number[]>();
  result.trace.forEach((t, i) => {
    if (!traceIndicesByAgent.has(t.agent)) traceIndicesByAgent.set(t.agent, []);
    traceIndicesByAgent.get(t.agent)!.push(i);
  });

  return records
    .map(rec => {
      const indices = traceIndicesByAgent.get(rec.agent) ?? [];
      const conf = rec.confidence;
      return `
      <section class="agent" id="agent-${esc(rec.agent)}">
        <header class="agent-head" data-agent="${esc(rec.agent)}" title="Click to filter trace by this agent">
          <h3>${esc(rec.agent)} <span class="muted">v${esc(rec.agentVersion)}</span></h3>
          <div class="badges">
            <span class="badge" style="background:${statusColor(rec.validationStatus)}">${esc(rec.validationStatus)}</span>
            <span class="badge" style="background:${tierColor(conf.tier)}">conf ${esc(conf.tier)} · ${conf.value.toFixed(2)}</span>
          </div>
        </header>
        <div class="agent-body">
          <div class="rationale"><strong>Confidence rationale.</strong> ${esc(conf.rationale)}</div>
          <ol class="trace">
            ${indices
              .map(i => {
                const t = result.trace[i]!;
                return `<li data-trace-idx="${i}" data-agent="${esc(t.agent)}" data-std="${t.standard}">
                  <span class="std-tag" data-std="${t.standard}">Std ${t.standard}</span>
                  <span class="step-name">${esc(t.step)}</span>
                  <span class="step-detail">— ${esc(t.detail)}</span>
                </li>`;
              })
              .join('')}
          </ol>
        </div>
      </section>`;
    })
    .join('');
}

interface FinalRecord {
  canonicalEntity: string;
  canonicalMetric: string;
  period: string;
  value: number | null;
  unit: string;
  rawEntity: string;
  rawLabel: string;
  rawValue: number | string | null;
  rawUnit: string | null;
  sourceUrl: string;
  confidence: number;
  resolutionAction?: string;
  resolutionNotes?: string[];
  flags: string[];
  appliedRules: string[];
}

function renderRecords(result: OrchestrationResult): string {
  const payload = result.finalHandoff?.payload as { records?: FinalRecord[] } | undefined;
  const records = payload?.records ?? [];
  if (records.length === 0) return '<p class="muted">No records produced.</p>';
  return `
    <table class="records">
      <thead><tr>
        <th>Entity</th><th>Metric</th><th>Period</th>
        <th>Canonical</th><th>Raw</th><th>Action</th>
        <th>Conf.</th><th>Source</th>
      </tr></thead>
      <tbody>
        ${records
          .map((r, i) => {
            const ruleStr = (r.appliedRules ?? []).map(a => esc(a)).join('<br>');
            return `
          <tr data-record-idx="${i}" title="Click for full details">
            <td><strong>${esc(r.canonicalEntity)}</strong><div class="muted small">(raw: ${esc(r.rawEntity)})</div></td>
            <td>${esc(r.canonicalMetric)}</td>
            <td>${esc(r.period)}</td>
            <td class="num">${r.value === null ? '—' : r.value.toLocaleString()} <span class="muted">${esc(r.unit)}</span></td>
            <td class="num">${r.rawValue === null ? '—' : esc(r.rawValue)} <span class="muted">${esc(r.rawUnit ?? '')}</span><div class="muted small">"${esc(r.rawLabel)}"</div></td>
            <td><span class="action a-${esc(r.resolutionAction ?? 'na')}">${esc(r.resolutionAction ?? '—')}</span>
                ${(r.resolutionNotes ?? []).map(n => `<div class="muted small">${esc(n)}</div>`).join('')}
                ${r.flags.length ? `<div class="flags">${r.flags.map(f => `<span class="flag">${esc(f)}</span>`).join('')}</div>` : ''}
            </td>
            <td class="num"><span class="conf" style="color:${tierColor(r.confidence >= 0.8 ? 'high' : r.confidence >= 0.6 ? 'medium' : 'low')}">${r.confidence.toFixed(2)}</span>
                <div class="muted small">${ruleStr}</div></td>
            <td><a href="${esc(r.sourceUrl)}" target="_blank" rel="noopener">${esc(new URL(r.sourceUrl).hostname)}</a></td>
          </tr>`;
          })
          .join('')}
      </tbody>
    </table>`;
}

function renderRepo(result: OrchestrationResult): string {
  const s = result.repositorySnapshot;
  const section = (title: string, count: number, body: string) =>
    `<details ${count > 0 ? 'open' : ''}><summary>${esc(title)} <span class="count">${count}</span></summary>${body}</details>`;
  const list = (items: { label: string; sub?: string }[]) =>
    items.length === 0
      ? '<p class="muted">none</p>'
      : `<ul class="kv">${items
          .map(i => `<li><span>${esc(i.label)}</span>${i.sub ? `<span class="muted small">${esc(i.sub)}</span>` : ''}</li>`)
          .join('')}</ul>`;

  return `
    ${section('Persisted handoffs', s.records.length, list(s.records.map(r => ({ label: r.agent, sub: `${r.validationStatus} · conf ${r.confidence.value.toFixed(2)}` }))))}
    ${section('Exception log', s.exceptions.length, list(s.exceptions.map(e => ({ label: `${e.agent}: ${e.category}`, sub: e.detail }))))}
    ${section('Learned rules', s.learnedRules.length, list(s.learnedRules.map(r => ({ label: `${r.agent}: ${r.ruleKey}`, sub: String(r.ruleValue) }))))}
    ${section('Escalations (HITL)', s.escalations.length, list(s.escalations.map(e => ({ label: `${e.agent}: ${e.reason} → ${e.recommendedReviewer}`, sub: e.failureContext }))))}
    ${section('Failures', s.failures.length, list(s.failures.map(f => ({ label: `${f.failure.agent}: ${f.failure.category}`, sub: f.failure.reason }))))}
    ${section('Human overrides', s.overrides.length, list(s.overrides.map(o => ({ label: `${o.agent}: ${o.field}`, sub: `by ${o.overriddenBy}` }))))}
  `;
}

/** Build option lists for the agent + std filters. */
function renderFilters(result: OrchestrationResult): string {
  const agents = Array.from(new Set(result.trace.map(t => t.agent)));
  const stds = Array.from(new Set(result.trace.map(t => t.standard))).sort((a, b) => a - b);
  return `
    <select id="filter-agent">
      <option value="all">all agents</option>
      ${agents.map(a => `<option value="${esc(a)}">${esc(a)}</option>`).join('')}
    </select>
    <select id="filter-std">
      <option value="all">all standards</option>
      ${stds.map(s => `<option value="${s}">Std ${s}</option>`).join('')}
    </select>
    <button id="filter-clear" type="button">clear</button>
  `;
}

function renderReplayBar(result: OrchestrationResult): string {
  const max = Math.max(0, result.trace.length - 1);
  return `
    <div class="replay">
      <button id="replay-reset" type="button" title="Reset">⏮</button>
      <button id="replay-step-back" type="button" title="Step back">◀</button>
      <button id="replay-play" type="button" title="Play / pause" class="primary">▶ Play</button>
      <button id="replay-step-fwd" type="button" title="Step forward">▶</button>
      <input id="replay-slider" type="range" min="-1" max="${max}" value="-1" step="1" />
      <label class="speed">speed
        <select id="replay-speed">
          <option value="0.5">0.5×</option>
          <option value="1" selected>1×</option>
          <option value="2">2×</option>
          <option value="4">4×</option>
        </select>
      </label>
      <span id="replay-status" class="status">Idle — ${result.trace.length} trace step(s)</span>
      <span class="spacer"></span>
      <span class="filter-wrap">${renderFilters(result)}</span>
    </div>
  `;
}

/** Embed the run JSON safely inside a <script type="application/json">. */
function embedJson(result: OrchestrationResult): string {
  return JSON.stringify(result).replace(/<\/script/gi, '<\\/script');
}

function render(result: OrchestrationResult, sourceFile: string): string {
  const conf = result.finalHandoff?.confidence;
  const validation = result.finalHandoff?.validation.status ?? (result.ok ? 'passed' : 'review');
  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BID run · ${esc(result.analysisId)}</title>
<style>
  :root {
    --fg:#1f2328; --muted:#656d76; --bg:#fff; --soft:#f6f8fa; --border:#d0d7de;
    --blue:#0969da; --accent:#8250df; --hi:#fff8c5; --hi-border:#d4a72c;
  }
  * { box-sizing: border-box; }
  body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color:var(--fg); background:var(--soft); margin:0; padding:0 24px 48px; }
  header.top { padding:24px 0 12px; }
  h1 { font-size:22px; margin:0 0 4px; }
  h2 { font-size:16px; margin:24px 0 8px; }
  h3 { font-size:15px; margin:0; }
  .muted { color:var(--muted); }
  .small { font-size:12px; }
  .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:16px; margin-bottom:16px; }
  .badges { display:flex; gap:6px; }
  .badge { display:inline-block; color:#fff; padding:2px 8px; border-radius:10px; font-size:12px; font-weight:500; }
  /* sticky replay bar */
  .replay-sticky { position:sticky; top:0; z-index:20; background:var(--soft); padding-top:8px; margin:0 -24px 12px; padding-left:24px; padding-right:24px; border-bottom:1px solid var(--border); padding-bottom:8px; }
  .replay { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
  .replay button { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font:inherit; cursor:pointer; }
  .replay button:hover { background:var(--soft); }
  .replay button.primary { background:var(--accent); color:#fff; border-color:var(--accent); font-weight:500; }
  .replay button.primary:hover { filter:brightness(1.05); background:var(--accent); }
  .replay input[type=range] { flex:1; min-width:200px; }
  .replay .status { font-variant-numeric:tabular-nums; color:var(--muted); font-size:12px; }
  .replay .spacer { flex:1; }
  .replay select { font:inherit; padding:4px 6px; border:1px solid var(--border); border-radius:6px; background:var(--bg); }
  .filter-wrap { display:flex; gap:6px; align-items:center; }
  /* pipeline strip */
  .pipeline { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
  .step { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:8px 12px; font-weight:500; transition: box-shadow 0.2s, border-color 0.2s, background 0.2s; }
  .step.done { border-color:#137333; box-shadow:0 0 0 1px #13733322 inset; }
  .step.pending { color:var(--muted); border-style:dashed; }
  .step.checkpoint { background:#fff8c5; border-color:#d4a72c; }
  .step.active { background:#dafbe1; border-color:#137333; box-shadow:0 0 0 3px #13733344 inset; transform:translateY(-1px); }
  .pill { display:inline-block; background:#ddf4ff; color:#0969da; padding:1px 6px; border-radius:8px; font-size:11px; margin-right:6px; text-transform:uppercase; letter-spacing:.04em; }
  .arrow { color:var(--muted); font-size:18px; }
  /* standards */
  .standards { display:grid; grid-template-columns:repeat(3, 1fr); gap:8px; }
  .std { display:flex; gap:8px; padding:8px; border:1px solid var(--border); border-radius:6px; background:var(--bg); cursor:pointer; transition:border-color 0.15s, box-shadow 0.15s; }
  .std:hover { border-color:var(--accent); box-shadow:0 0 0 1px #8250df33 inset; }
  .std.active { border-color:var(--accent); box-shadow:0 0 0 2px #8250df44 inset; }
  .std-n { background:var(--accent); color:#fff; border-radius:4px; padding:2px 6px; height:fit-content; font-size:11px; font-weight:600; }
  .std-name { font-weight:600; }
  .std-gist { color:var(--muted); font-size:12px; }
  /* agent cards */
  .agent { background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:12px; transition:box-shadow 0.2s, border-color 0.2s; }
  .agent.active { border-color:var(--accent); box-shadow:0 0 0 2px #8250df33; }
  .agent-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid var(--border); cursor:pointer; }
  .agent-head:hover { background:var(--soft); }
  .agent-body { padding:12px 16px; }
  .rationale { color:var(--muted); margin-bottom:8px; }
  ol.trace { list-style:none; padding:0; margin:0; }
  ol.trace li { padding:6px 0; border-bottom:1px dashed var(--border); display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; transition:background 0.2s, padding-left 0.2s; padding-left:6px; border-left:3px solid transparent; }
  ol.trace li:last-child { border-bottom:none; }
  ol.trace li.current { background:var(--hi); border-left-color:var(--hi-border); padding-left:10px; }
  .std-tag { background:#eaeefc; color:var(--accent); padding:1px 6px; border-radius:4px; font-size:11px; font-weight:600; cursor:pointer; }
  .std-tag:hover { background:#d4dafa; }
  .step-name { font-weight:500; }
  .step-detail { color:var(--muted); }
  /* records table */
  table.records { width:100%; border-collapse:collapse; background:var(--bg); }
  table.records th, table.records td { padding:8px 10px; border-bottom:1px solid var(--border); text-align:left; vertical-align:top; }
  table.records th { background:var(--soft); font-weight:600; }
  table.records td.num { font-variant-numeric:tabular-nums; }
  table.records tbody tr { cursor:pointer; transition:background 0.15s; }
  table.records tbody tr:hover { background:#eaeefc55; }
  table.records tbody tr.active { background:#eaeefc; }
  .action { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; font-weight:500; }
  .a-pass-through { background:#dafbe1; color:#137333; }
  .a-rule-applied { background:#ddf4ff; color:#0969da; }
  .a-duplicate-merged { background:#fff8c5; color:#9a6700; }
  .a-contradiction-resolved { background:#ffd8b5; color:#b35a00; }
  .a-escalated { background:#ffd8d3; color:#a40e26; }
  .flags { margin-top:4px; }
  .flag { background:#ffefc6; color:#9a6700; padding:1px 6px; border-radius:4px; font-size:11px; margin-right:4px; }
  details { background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:8px; }
  details summary { padding:8px 12px; cursor:pointer; font-weight:500; user-select:none; }
  details > *:not(summary) { padding:0 12px 12px; }
  .count { background:var(--soft); border:1px solid var(--border); border-radius:10px; padding:1px 7px; font-size:11px; color:var(--muted); margin-left:4px; }
  ul.kv { list-style:none; padding:0; margin:0; }
  ul.kv li { padding:4px 0; border-bottom:1px dashed var(--border); display:flex; justify-content:space-between; gap:12px; }
  ul.kv li:last-child { border-bottom:none; }
  a { color:var(--blue); text-decoration:none; }
  a:hover { text-decoration:underline; }
  code { background:var(--soft); padding:1px 4px; border-radius:3px; }
  pre.snippet { background:var(--soft); padding:10px; border-radius:6px; border:1px solid var(--border); white-space:pre-wrap; word-break:break-word; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px; max-height:200px; overflow:auto; }
  .file { color:var(--muted); font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px; }
  /* side panel */
  .panel { position:fixed; top:0; right:0; width:min(520px, 90vw); height:100vh; background:var(--bg); border-left:1px solid var(--border); box-shadow:-4px 0 16px #00000022; transform:translateX(110%); transition:transform 0.25s ease-out; z-index:30; display:flex; flex-direction:column; }
  .panel.open { transform:translateX(0); }
  .panel-head { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--border); }
  .panel-head h3 { font-size:15px; }
  .panel-body { padding:16px; overflow:auto; flex:1; }
  .panel-close { background:transparent; border:none; font-size:22px; cursor:pointer; color:var(--muted); padding:0 8px; }
  .panel-close:hover { color:var(--fg); }
  .kvgrid { display:grid; grid-template-columns:120px 1fr; gap:6px 12px; margin-bottom:12px; }
  .kvgrid dt { color:var(--muted); font-size:12px; }
  .kvgrid dd { margin:0; font-variant-numeric:tabular-nums; }
  .chain { display:grid; grid-template-columns:1fr; gap:6px; }
  .chain .row { display:flex; gap:8px; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--soft); align-items:baseline; }
  .chain .lbl { font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); flex:0 0 90px; }
  .chain .val { flex:1; word-break:break-word; }
</style>
</head>
<body>
  <header class="top">
    <h1>BID Baseline Pillar — Run Audit</h1>
    <div class="muted">analysis <code>${esc(result.analysisId)}</code> · source <span class="file">${esc(path.basename(sourceFile))}</span> · click any record / Std / agent to drill in</div>
  </header>

  <div class="replay-sticky">${renderReplayBar(result)}</div>

  <section class="card">
    <div style="display:flex; align-items:center; justify-content:space-between; gap:16px;">
      <div>
        <h2 style="margin-top:0">Outcome</h2>
        <div><strong>${result.ok ? '✅ Run succeeded' : '⚠️ Run did not complete'}</strong></div>
        ${conf ? `<div class="muted">final confidence <strong style="color:${tierColor(conf.tier)}">${esc(conf.tier)}</strong> · ${conf.value.toFixed(2)} — ${esc(conf.rationale)}</div>` : ''}
      </div>
      <div class="badges">
        <span class="badge" style="background:${statusColor(validation)}">validation: ${esc(validation)}</span>
        <span class="badge" style="background:#0969da">escalations: ${result.escalations.length}</span>
        <span class="badge" style="background:#8250df">trace steps: ${result.trace.length}</span>
      </div>
    </div>
  </section>

  <section class="card">
    <h2 style="margin-top:0">Pipeline</h2>
    <div class="pipeline">${renderPipeline(result)}</div>
  </section>

  <section>
    <h2>Final dataset (analytics-ready for Intelligence pillar)</h2>
    <div class="card" style="padding:0; overflow:hidden;">${renderRecords(result)}</div>
  </section>

  <section id="agents-section">
    <h2>Per-agent execution (Standards in action)</h2>
    ${renderAgents(result)}
  </section>

  <section>
    <h2>Repository write-back</h2>
    ${renderRepo(result)}
  </section>

  <section class="card">
    <h2 style="margin-top:0">The 12 universal standards <span class="muted small">— click any to filter the trace</span></h2>
    <div class="standards">${renderStandards(result)}</div>
  </section>

  <aside class="panel" id="panel" aria-hidden="true">
    <div class="panel-head">
      <h3 id="panel-title">Record</h3>
      <button class="panel-close" id="panel-close" aria-label="Close">×</button>
    </div>
    <div class="panel-body" id="panel-body"></div>
  </aside>

  <script type="application/json" id="run-data">${embedJson(result)}</script>
  <script>
  (function () {
    const data = JSON.parse(document.getElementById('run-data').textContent);
    const trace = data.trace || [];
    const finalRecords = (data.finalHandoff && data.finalHandoff.payload && data.finalHandoff.payload.records) || [];

    // Build a (rawEntity, rawLabel) → extraction snippet lookup from the
    // source-extraction handoff so the side panel can show the actual
    // source bytes that the parser matched.
    const snippetByKey = new Map();
    const extractionRecord = (data.repositorySnapshot.records || []).find(r => r.agent === 'baseline.source-extraction');
    if (extractionRecord && extractionRecord.payload && Array.isArray(extractionRecord.payload.values)) {
      for (const v of extractionRecord.payload.values) {
        snippetByKey.set(v.entity + '::' + v.rawLabel, v);
      }
    }

    /* ---------- Replay ---------- */
    let cur = -1;
    let playing = false;
    let timer = null;
    const playBtn = document.getElementById('replay-play');
    const slider = document.getElementById('replay-slider');
    const status = document.getElementById('replay-status');
    const speedSel = document.getElementById('replay-speed');

    function clearActives() {
      document.querySelectorAll('ol.trace li.current').forEach(el => el.classList.remove('current'));
      document.querySelectorAll('.step.active').forEach(el => el.classList.remove('active'));
      document.querySelectorAll('.agent.active').forEach(el => el.classList.remove('active'));
    }
    function showStep(idx) {
      clearActives();
      slider.value = String(idx);
      if (idx < 0 || idx >= trace.length) {
        status.textContent = 'Idle — ' + trace.length + ' trace step(s)';
        return;
      }
      const t = trace[idx];
      const li = document.querySelector('[data-trace-idx="' + idx + '"]');
      if (li) {
        li.classList.add('current');
        li.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }
      document.querySelectorAll('.step[data-agent="' + t.agent + '"]').forEach(s => s.classList.add('active'));
      const card = document.getElementById('agent-' + t.agent);
      if (card) card.classList.add('active');
      status.textContent = 'Step ' + (idx + 1) + '/' + trace.length + ' · ' + t.agent + ' · Std ' + t.standard + ' · ' + t.step;
    }
    function setPlaying(v) {
      playing = v;
      playBtn.textContent = v ? '⏸ Pause' : '▶ Play';
      if (!v && timer) { clearTimeout(timer); timer = null; }
    }
    function tick() {
      if (!playing) return;
      cur = Math.min(trace.length - 1, cur + 1);
      showStep(cur);
      if (cur >= trace.length - 1) { setPlaying(false); return; }
      const speed = Number(speedSel.value) || 1;
      timer = setTimeout(tick, 700 / speed);
    }
    playBtn.addEventListener('click', () => {
      if (playing) { setPlaying(false); return; }
      if (cur >= trace.length - 1) cur = -1;
      setPlaying(true);
      tick();
    });
    document.getElementById('replay-step-fwd').addEventListener('click', () => {
      setPlaying(false);
      cur = Math.min(trace.length - 1, cur + 1);
      showStep(cur);
    });
    document.getElementById('replay-step-back').addEventListener('click', () => {
      setPlaying(false);
      cur = Math.max(-1, cur - 1);
      showStep(cur);
    });
    document.getElementById('replay-reset').addEventListener('click', () => {
      setPlaying(false);
      cur = -1;
      showStep(cur);
    });
    slider.addEventListener('input', e => {
      setPlaying(false);
      cur = Number(e.target.value);
      showStep(cur);
    });

    /* ---------- Filters ---------- */
    const agentFilter = document.getElementById('filter-agent');
    const stdFilter = document.getElementById('filter-std');
    function applyFilters() {
      const a = agentFilter.value;
      const s = stdFilter.value;
      let visible = 0;
      document.querySelectorAll('[data-trace-idx]').forEach(li => {
        const ok = (a === 'all' || li.dataset.agent === a) && (s === 'all' || li.dataset.std === s);
        li.style.display = ok ? '' : 'none';
        if (ok) visible++;
      });
      document.querySelectorAll('.std').forEach(el => el.classList.toggle('active', s !== 'all' && el.dataset.std === s));
      if (a !== 'all' || s !== 'all') {
        status.textContent = 'Filtered: ' + visible + '/' + trace.length + ' step(s)' +
          (a !== 'all' ? ' · agent=' + a : '') + (s !== 'all' ? ' · Std ' + s : '');
      } else if (cur < 0) {
        status.textContent = 'Idle — ' + trace.length + ' trace step(s)';
      }
    }
    agentFilter.addEventListener('change', applyFilters);
    stdFilter.addEventListener('change', applyFilters);
    document.getElementById('filter-clear').addEventListener('click', () => {
      agentFilter.value = 'all';
      stdFilter.value = 'all';
      applyFilters();
    });

    // Click a Std card → filter the trace.
    document.querySelectorAll('.std').forEach(el => {
      el.addEventListener('click', () => {
        stdFilter.value = el.dataset.std;
        applyFilters();
        document.getElementById('agents-section').scrollIntoView({ behavior: 'smooth', block: 'start' });
      });
    });
    // Click a Std tag inside trace → same.
    document.querySelectorAll('.std-tag').forEach(el => {
      el.addEventListener('click', e => {
        e.stopPropagation();
        stdFilter.value = el.dataset.std;
        applyFilters();
      });
    });
    // Click an agent header → filter trace.
    document.querySelectorAll('.agent-head').forEach(el => {
      el.addEventListener('click', () => {
        agentFilter.value = el.dataset.agent;
        applyFilters();
      });
    });

    /* ---------- Side panel ---------- */
    const panel = document.getElementById('panel');
    const panelTitle = document.getElementById('panel-title');
    const panelBody = document.getElementById('panel-body');
    function escHtml(s) {
      return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
    }
    function tierForNum(n) { return n >= 0.8 ? 'high' : n >= 0.6 ? 'medium' : 'low'; }
    function openRecord(idx) {
      const r = finalRecords[idx];
      if (!r) return;
      const snip = snippetByKey.get(r.rawEntity + '::' + r.rawLabel);
      panelTitle.textContent = r.canonicalEntity + ' · ' + r.canonicalMetric + ' · ' + r.period;
      const chainRows = [
        ['Raw entity', escHtml(r.rawEntity)],
        ['Canonical entity', '<strong>' + escHtml(r.canonicalEntity) + '</strong>'],
        ['Raw label', '"' + escHtml(r.rawLabel) + '"'],
        ['Canonical metric', '<strong>' + escHtml(r.canonicalMetric) + '</strong>'],
        ['Raw value', (r.rawValue === null ? '—' : escHtml(r.rawValue)) + ' <span class="muted">' + escHtml(r.rawUnit || '') + '</span>'],
        ['Canonical value', '<strong>' + (r.value === null ? '—' : Number(r.value).toLocaleString()) + '</strong> <span class="muted">' + escHtml(r.unit) + '</span>'],
      ];
      const chain = '<div class="chain">' + chainRows.map(([l, v]) => '<div class="row"><div class="lbl">' + l + '</div><div class="val">' + v + '</div></div>').join('') + '</div>';
      const snipHtml = snip
        ? '<h4 style="margin:16px 0 6px;">Raw source snippet <span class="muted small">(shape: ' + escHtml(snip.shape) + ')</span></h4>' +
          '<pre class="snippet">' + escHtml(snip.snippet || '') + '</pre>' +
          '<div class="muted small">Captured from <a href="' + escHtml(snip.sourceUrl) + '" target="_blank" rel="noopener">' + escHtml(snip.sourceUrl) + '</a></div>'
        : '<p class="muted small">No raw snippet available for this record.</p>';
      const flagsHtml = (r.flags && r.flags.length)
        ? '<div class="flags">' + r.flags.map(f => '<span class="flag">' + escHtml(f) + '</span>').join('') + '</div>'
        : '<p class="muted small">No flags.</p>';
      const rulesHtml = (r.appliedRules && r.appliedRules.length)
        ? '<ul>' + r.appliedRules.map(a => '<li><code>' + escHtml(a) + '</code></li>').join('') + '</ul>'
        : '<p class="muted small">No rules applied.</p>';
      const notesHtml = (r.resolutionNotes && r.resolutionNotes.length)
        ? '<ul>' + r.resolutionNotes.map(n => '<li>' + escHtml(n) + '</li>').join('') + '</ul>'
        : '<p class="muted small">No resolution notes (pass-through).</p>';
      panelBody.innerHTML =
        '<dl class="kvgrid">' +
          '<dt>Action</dt><dd><span class="action a-' + escHtml(r.resolutionAction || 'na') + '">' + escHtml(r.resolutionAction || '—') + '</span></dd>' +
          '<dt>Confidence</dt><dd style="color:' + (tierForNum(r.confidence) === 'high' ? '#137333' : tierForNum(r.confidence) === 'medium' ? '#b06000' : '#a50e0e') + '">' + Number(r.confidence).toFixed(2) + ' (' + tierForNum(r.confidence) + ')</dd>' +
          '<dt>Source</dt><dd><a href="' + escHtml(r.sourceUrl) + '" target="_blank" rel="noopener">' + escHtml(r.sourceUrl) + '</a></dd>' +
        '</dl>' +
        '<h4 style="margin:8px 0 6px;">Lineage chain (raw → canonical)</h4>' +
        chain +
        '<h4 style="margin:16px 0 6px;">Applied rules</h4>' +
        rulesHtml +
        '<h4 style="margin:16px 0 6px;">Flags</h4>' +
        flagsHtml +
        '<h4 style="margin:16px 0 6px;">Resolution notes</h4>' +
        notesHtml +
        snipHtml;
      panel.classList.add('open');
      panel.setAttribute('aria-hidden', 'false');
      document.querySelectorAll('tr[data-record-idx]').forEach(tr => tr.classList.toggle('active', Number(tr.dataset.recordIdx) === idx));
    }
    document.querySelectorAll('tr[data-record-idx]').forEach(tr => {
      tr.addEventListener('click', () => openRecord(Number(tr.dataset.recordIdx)));
    });
    document.getElementById('panel-close').addEventListener('click', () => {
      panel.classList.remove('open');
      panel.setAttribute('aria-hidden', 'true');
      document.querySelectorAll('tr[data-record-idx]').forEach(tr => tr.classList.remove('active'));
    });
    // Esc closes panel; Space toggles play; arrows step.
    window.addEventListener('keydown', e => {
      if (e.key === 'Escape') { panel.classList.remove('open'); panel.setAttribute('aria-hidden','true'); }
      if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA')) return;
      if (e.key === ' ') { e.preventDefault(); playBtn.click(); }
      else if (e.key === 'ArrowRight') { document.getElementById('replay-step-fwd').click(); }
      else if (e.key === 'ArrowLeft') { document.getElementById('replay-step-back').click(); }
    });
  })();
  </script>
</body>
</html>`;
}

export async function renderReport(jsonPath?: string): Promise<string> {
  const file = jsonPath ?? (await pickLatest());
  if (!file) throw new Error('No run JSON found in output/.');
  const raw = await readFile(file, 'utf8');
  const result = JSON.parse(raw) as OrchestrationResult;
  const html = render(result, file);
  const outPath = file.replace(/\.json$/, '.html');
  await writeFile(outPath, html, 'utf8');
  return outPath;
}

// Run directly when invoked from the CLI.
const invokedDirectly = (() => {
  try {
    return import.meta.url === `file://${process.argv[1]}`;
  } catch {
    return false;
  }
})();

if (invokedDirectly) {
  const arg = process.argv[2];
  renderReport(arg)
    .then(p => {
      // eslint-disable-next-line no-console
      console.log(`Wrote HTML report: ${path.relative(process.cwd(), p)}`);
    })
    .catch(err => {
      // eslint-disable-next-line no-console
      console.error(err);
      process.exit(1);
    });
}