diff --git a/docs/observer/dashboard.html b/docs/observer/dashboard.html
index ba803556..173c9421 100644
--- a/docs/observer/dashboard.html
+++ b/docs/observer/dashboard.html
@@ -34,7 +34,15 @@
Топология мозга: 124 узла, рёбра, 11 размеченных дизайн-конфликтов. Это нулевое состояние холста — без оверлеев.
-
+
+
+
+
+
+
+
+
+
diff --git a/docs/observer/dashboard.js b/docs/observer/dashboard.js
index 3fcb76dd..0c582739 100644
--- a/docs/observer/dashboard.js
+++ b/docs/observer/dashboard.js
@@ -1,4 +1,4 @@
-import { parseEpisodes } from './dashboard-core.js';
+import { parseEpisodes, filterEpisodes, attributeNodes } from './dashboard-core.js';
const AGD = window.AGD;
let episodes = [];
@@ -61,6 +61,64 @@ views.map = function renderMapView() {
}
};
+views.replay = function renderReplayView() {
+ const filter = {
+ classification: document.getElementById('f-classification').value || undefined,
+ outcome: document.getElementById('f-outcome').value || undefined,
+ withErrors: document.getElementById('f-errors').checked || undefined,
+ };
+ const list = filterEpisodes(episodes, filter);
+ const ul = document.getElementById('replay-episodes');
+ ul.innerHTML = '';
+ list.forEach((ep) => {
+ const li = document.createElement('li');
+ li.textContent = `${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.outcome}`
+ + (ep.errorCount ? ` · ⚠${ep.errorCount}` : '');
+ li.addEventListener('click', () => selectEpisode(ep));
+ ul.appendChild(li);
+ });
+};
+
+function selectEpisode(ep) {
+ const attr = attributeNodes(ep);
+ window.__graph.nodes.update(
+ AGD.NODES.map((n) => ({
+ id: n.id,
+ color: attr.nodeIds.includes(n.id)
+ ? { background: '#268bd2', border: '#93a1a1' }
+ : { background: '#2a2a3a', border: '#444' },
+ }))
+ );
+ const d = document.getElementById('replay-detail');
+ const prov = ep.decisionProvenance;
+ const provLine = prov && prov.kind === 'user_directed_method'
+ ? `перенаправление: выбран ${prov.node || '?'}, автономно был бы ${prov.claude_would_have_chosen || '?'}`
+ : prov ? prov.kind : '—';
+ const env = ep.environment || {};
+ d.innerHTML = `
+ ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}
+ provenance: ${provLine}
+ hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}
+ окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}
+ атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов
+ События
+ ${ep.events.map((e) => `- ${eventLine(e)}
`).join('')}
`;
+}
+
+function eventLine(e) {
+ switch (e.kind) {
+ case 'skill_invoked': return `skill: ${e.skill}`;
+ case 'error': return `error: ${e.message || ''}`;
+ case 'retry': return 'retry';
+ case 'interrupt': return 'interrupt';
+ case 'hook_fired': return `hooks (${Object.keys(e.counts || {}).length} типов, errors ${e.errors || 0})`;
+ case 'tool_summary': return `инструменты: ${Object.entries(e.counts || {}).map(([k, v]) => `${k}×${v}`).join(', ')}`;
+ case 'time_burn': return `time_burn: ${e.duration_ms} ms`;
+ case 'parse_gap': return `parse_gap: ${e.broken}/${e.total}`;
+ default: return e.kind;
+ }
+}
+
function switchView(name) {
activeView = name;
for (const v of ['map', 'replay', 'feed', 'aggregate']) {
@@ -79,6 +137,11 @@ async function boot() {
document.querySelectorAll('#tabbar button').forEach((b) => {
b.addEventListener('click', () => switchView(b.dataset.view));
});
+ ['f-classification', 'f-outcome', 'f-errors'].forEach((id) => {
+ document.getElementById(id).addEventListener('change', () => {
+ if (activeView === 'replay') views.replay();
+ });
+ });
await loadEpisodes();
switchView('map');
}