/**
* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
});
}