The config's include `../tools/*.test.mjs` resolves relative to its own dir (app/), not cwd. Baseline verified 2026-05-19 from app/: 11 files, 169 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
56 KiB
Brain Dashboard Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a standalone HTML dashboard that visualises the observer episode log (docs/observer/episodes-*.jsonl) over the automation-graph topology — four views (Карта / Разбор / Лента / Агрегат).
Architecture: Zero-build static HTML served by a ~20-line node:http static server. Topology data is extracted from automation-graph.html into a shared automation-graph-data.js. Pure logic (parse / normalize / attribute / aggregate) lives in an ES-module dashboard-core.js, unit-tested with Vitest; UI and vis.js rendering in dashboard.js. Layout: graph banner on top, view work-area below.
Tech Stack: Vanilla ES modules, vis-network 9.1.9 (CDN, same as the existing map), node:http, Vitest 4.1 (config app/vitest.config.tools.mjs).
Spec: docs/superpowers/specs/2026-05-19-brain-dashboard-design.md
Conventions:
- Run tools tests from
app/(the config's../toolsglob resolves relative to it):cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run. The config globs../tools/*.test.mjs, so newtools/brain-dashboard-*.test.mjsare auto-included. Verified baseline 2026-05-19 — 11 files, 169 tests passing. dashboard-core.jsis browser-safe (nonode:APIs) so it loads both in the browser and under Vitest'snodeenvironment.- Commit messages:
feat(brain): …/refactor(brain): …/test(brain): …. - Forest palette / Inter / JetBrains Mono polish is applied in Task 13 (final), not earlier — earlier tasks use neutral styling.
File Structure
| File | Responsibility | Tasks |
|---|---|---|
tools/brain-dashboard-server.mjs |
Static file server (repo root) + GET /api/episodes endpoint |
1 |
tools/brain-dashboard-server.test.mjs |
Vitest tests for server helpers | 1 |
docs/automation-graph-data.js |
Shared topology constants (NODES/EDGES/SECTIONS/…) — single source of truth, exposed as window.AGD |
2 |
docs/automation-graph.html |
Existing map — refactored to <script src> the data file (behaviour unchanged) |
2 |
docs/observer/dashboard-core.js |
Pure logic: parse JSONL, normalize v1/v2, attribute nodes, aggregate, infer conflicts. ES module, no DOM | 3, 4, 10, 12 |
docs/observer/dashboard.html |
Dashboard shell — layout, tab bar, graph banner, work-area, inline CSS | 5 |
docs/observer/dashboard.js |
UI controller: fetch episodes, render graph + 4 views, polling | 5–9, 11, 12 |
tools/brain-dashboard-core.test.mjs |
Vitest tests for dashboard-core.js |
3, 4, 10, 12 |
package.json |
brain:dashboard npm script |
1 |
Phase 1 — Foundation (server, topology extraction, shell, Карта + Разбор)
Task 1: Static server + /api/episodes + npm script
Files:
-
Create:
tools/brain-dashboard-server.mjs -
Create:
tools/brain-dashboard-server.test.mjs -
Modify:
package.json(addscripts.brain:dashboard) -
Step 1: Write failing tests for the server helpers
Create tools/brain-dashboard-server.test.mjs:
import { describe, it, expect } from 'vitest';
import { listEpisodeFiles, resolveStaticPath, contentType } from './brain-dashboard-server.mjs';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
describe('listEpisodeFiles', () => {
it('returns episodes-*.jsonl filenames sorted, ignores other files', () => {
const root = mkdtempSync(join(tmpdir(), 'bd-'));
const obs = join(root, 'docs', 'observer');
mkdirSync(obs, { recursive: true });
writeFileSync(join(obs, 'episodes-2026-05.jsonl'), '');
writeFileSync(join(obs, 'episodes-2026-04.jsonl'), '');
writeFileSync(join(obs, 'STATUS.md'), '');
expect(listEpisodeFiles(root)).toEqual(['episodes-2026-04.jsonl', 'episodes-2026-05.jsonl']);
});
it('returns [] when the observer dir is missing', () => {
const root = mkdtempSync(join(tmpdir(), 'bd-'));
expect(listEpisodeFiles(root)).toEqual([]);
});
});
describe('resolveStaticPath', () => {
it('resolves a path inside root', () => {
const root = '/srv/app';
expect(resolveStaticPath('/docs/observer/dashboard.html', root))
.toBe(join(root, 'docs', 'observer', 'dashboard.html'));
});
it('rejects path traversal with null', () => {
expect(resolveStaticPath('/../../etc/passwd', '/srv/app')).toBeNull();
expect(resolveStaticPath('/docs/../../secret', '/srv/app')).toBeNull();
});
});
describe('contentType', () => {
it('maps known extensions', () => {
expect(contentType('.html')).toBe('text/html; charset=utf-8');
expect(contentType('.js')).toBe('text/javascript; charset=utf-8');
expect(contentType('.jsonl')).toBe('application/x-ndjson; charset=utf-8');
});
it('falls back to octet-stream', () => {
expect(contentType('.xyz')).toBe('application/octet-stream');
});
});
- Step 2: Run tests to verify they fail
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-server
Expected: FAIL — Failed to resolve import "./brain-dashboard-server.mjs".
- Step 3: Implement the server
Create tools/brain-dashboard-server.mjs:
// Static file server for the Brain Dashboard. Serves the repo root over
// localhost so dashboard.html can fetch() episodes-*.jsonl (file:// cannot).
// Run: node tools/brain-dashboard-server.mjs (npm run brain:dashboard)
import { createServer as httpCreateServer } from 'node:http';
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
import { join, resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
const PORT = Number(process.env.BRAIN_DASHBOARD_PORT) || 7700;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.jsonl': 'application/x-ndjson; charset=utf-8',
'.svg': 'image/svg+xml',
};
export function contentType(ext) {
return MIME[ext] || 'application/octet-stream';
}
export function listEpisodeFiles(root) {
const dir = join(root, 'docs', 'observer');
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f))
.sort();
}
// Resolve a URL path to an absolute path inside root; null if it escapes root.
export function resolveStaticPath(urlPath, root) {
const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, '');
const abs = resolve(root, clean);
if (abs !== root && !abs.startsWith(root + sep)) return null;
return abs;
}
export function createServer(root = REPO_ROOT) {
return httpCreateServer((req, res) => {
const url = req.url || '/';
if (url.split('?')[0] === '/api/episodes') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(listEpisodeFiles(root)));
return;
}
let path = url.split('?')[0];
if (path === '/') path = '/docs/observer/dashboard.html';
const abs = resolveStaticPath(path, root);
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('404');
return;
}
res.writeHead(200, { 'Content-Type': contentType(extname(abs)) });
res.end(readFileSync(abs));
});
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
createServer().listen(PORT, '127.0.0.1', () => {
console.log(`Brain Dashboard: http://localhost:${PORT}/ (Ctrl+C to stop)`);
});
}
- Step 4: Run tests to verify they pass
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-server
Expected: PASS — 6 tests.
- Step 5: Add npm script
In package.json, add to the scripts object after "eval:llm" (add a comma to the previous line):
"brain:dashboard": "node tools/brain-dashboard-server.mjs"
- Step 6: Smoke-test the server manually
Run: node tools/brain-dashboard-server.mjs (in a separate terminal), then in another:
curl -s http://localhost:7700/api/episodes
Expected: a JSON array containing "episodes-2026-05.jsonl". Stop the server with Ctrl+C.
- Step 7: Commit
git add tools/brain-dashboard-server.mjs tools/brain-dashboard-server.test.mjs package.json
git commit -m "feat(brain): static server + /api/episodes for the dashboard"
Task 2: Extract topology to automation-graph-data.js
The dashboard needs the same node/edge topology as the map, but must render its own controllable vis.js graph. Extract the topology constants into a shared file; the existing map keeps working by loading it.
Files:
-
Create:
docs/automation-graph-data.js -
Modify:
docs/automation-graph.html -
Step 1: Read the full current map file
Run: Read docs/automation-graph.html (entire file). Confirm the constant blocks below still exist; line numbers below are as of 2026-05-19 and may have drifted — locate blocks by their const NAME anchors, not by line number alone.
- Step 2: Create the shared data file
Create docs/automation-graph-data.js. Move these constant blocks verbatim from the inline <script> of automation-graph.html, in this exact order:
RADIIandfunction pos(...)(≈ lines 222–227)const NODES = [ … ];(≈ lines 229–389)const CONFLICT_TYPES = { … };(≈ lines 394–398)const E = (…) => ({ … });(≈ lines 399–405)const CONFLICT = (…) => ({ … });(≈ lines 406–416)const EDGES = [ … ];(≈ lines 418–615)const CATEGORY_LABELS = { … };(≈ lines 620–625)const SECTION_BUCKETS = [ … ];(≈ lines 2105–2111)const SECTIONS = [ … ];(≈ lines 2112–2153)const NODE_SECTION = { … };(≈ lines 2155–2212)const NODE_SECTION_SECONDARY = { … };(≈ lines 2216–2228)const GROUPS = { … };(≈ lines 2268–2279)
Then append, as the last lines of the file:
// Expose for ES-module consumers (the dashboard). The map's classic inline
// script reads the bare consts directly via the shared global lexical scope.
window.AGD = {
NODES, EDGES, SECTIONS, SECTION_BUCKETS,
NODE_SECTION, NODE_SECTION_SECONDARY,
CONFLICT_TYPES, GROUPS, CATEGORY_LABELS,
};
Do not move nd() / NODE_DETAILS / WISHLIST / SECTION_BY_ID / SECTION_NODES / the network init — they stay in automation-graph.html.
- Step 3: Edit
automation-graph.html
a) After the vis-network <script> tag (line 7), add:
<script src="automation-graph-data.js"></script>
b) Delete the 12 moved blocks from the inline <script>. Delete bottom-up (highest line numbers first) so earlier line numbers do not shift. After deletion the inline script still references NODES, EDGES, SECTIONS, NODE_SECTION, etc. — these resolve to the shared global-lexical consts defined by automation-graph-data.js (classic scripts share the top-level lexical scope).
- Step 4: Visual smoke-test the map
Open docs/automation-graph.html in a browser (or http://localhost:7700/docs/automation-graph.html via the Task 1 server). Verify:
- the graph renders with all nodes and edges, same layout as before;
- conflict edges are dashed with emoji labels;
- clicking a node opens the legend panel;
- the browser console shows no errors.
If anything differs, the extraction dropped or reordered a block — fix before continuing.
- Step 5: Commit
git add docs/automation-graph-data.js docs/automation-graph.html
git commit -m "refactor(brain): extract automation-graph topology to a shared data file"
Task 3: Episode parser + v1/v2 normalizer
Files:
-
Create:
docs/observer/dashboard-core.js -
Create:
tools/brain-dashboard-core.test.mjs -
Step 1: Write failing tests
Create tools/brain-dashboard-core.test.mjs:
import { describe, it, expect } from 'vitest';
import { parseEpisodes, normalizeEpisode } from '../docs/observer/dashboard-core.js';
const v1 = {
task_id: 'a', timestamps: { started_at: '2026-05-19T05:18:16.342Z', ended_at: '2026-05-19T06:05:55.439Z' },
path_type: 'improvised', outcome: 'success',
primary_rationale: { node_chosen: 'direct', hard_floor: { invoked: false, rules: [] }, task_classification: 'refactor' },
events: [{ kind: 'tool_summary', counts: { TodoWrite: 2, AskUserQuestion: 5 } }],
};
const v2 = {
schema_version: 2, task_id: 'b', task_ref: 'b',
timestamps: { started_at: '2026-05-19T08:06:30.059Z', ended_at: '2026-05-19T08:10:43.437Z' },
path_type: 'improvised', outcome: 'unknown', prompt_signal: 'new_task',
decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null },
environment: { economy_level: 5, model: 'claude-opus-4-7', post_compaction: true, session_turn: 82, parallel_session: true },
task_size: { tool_calls: 12, files_touched: 1, files: ['x'] },
primary_rationale: { node_chosen: 'direct', hard_floor: { invoked: false, rules: [] }, task_classification: 'bugfix' },
events: [{ kind: 'tool_summary', counts: { Edit: 5 } }, { kind: 'error', message: 'e' }, { kind: 'retry' }],
};
describe('parseEpisodes', () => {
it('parses valid JSONL lines', () => {
const text = [JSON.stringify(v1), JSON.stringify(v2)].join('\n');
const r = parseEpisodes(text);
expect(r.episodes).toHaveLength(2);
expect(r.skipped).toBe(0);
});
it('skips broken lines and counts them', () => {
const text = [JSON.stringify(v1), '{ broken', '', JSON.stringify(v2)].join('\n');
const r = parseEpisodes(text);
expect(r.episodes).toHaveLength(2);
expect(r.skipped).toBe(1);
});
it('skips observer_error marker lines', () => {
const text = [JSON.stringify({ observer_error: 'hook failed' }), JSON.stringify(v1)].join('\n');
const r = parseEpisodes(text);
expect(r.episodes).toHaveLength(1);
expect(r.skipped).toBe(1);
});
});
describe('normalizeEpisode', () => {
it('normalizes a v1 episode — v2-only fields are null', () => {
const e = normalizeEpisode(v1);
expect(e.schemaVersion).toBe(1);
expect(e.outcome).toBe('success');
expect(e.environment).toBeNull();
expect(e.decisionProvenance).toBeNull();
expect(e.taskSize).toBeNull();
expect(e.durationMs).toBe(Date.parse(v1.timestamps.ended_at) - Date.parse(v1.timestamps.started_at));
expect(e.tools).toEqual({ TodoWrite: 2, AskUserQuestion: 5 });
});
it('normalizes a v2 episode with all fields', () => {
const e = normalizeEpisode(v2);
expect(e.schemaVersion).toBe(2);
expect(e.environment.economy_level).toBe(5);
expect(e.errorCount).toBe(1);
expect(e.retryCount).toBe(1);
expect(e.taskClassification).toBe('bugfix');
});
it('merges tool_summary counts across multiple events', () => {
const e = normalizeEpisode({
...v1,
events: [{ kind: 'tool_summary', counts: { Read: 2 } }, { kind: 'tool_summary', counts: { Read: 3, Bash: 1 } }],
});
expect(e.tools).toEqual({ Read: 5, Bash: 1 });
});
it('collects skill_invoked skills in order', () => {
const e = normalizeEpisode({
...v1,
events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }, { kind: 'skill_invoked', skill: 'superpowers:test-driven-development' }],
});
expect(e.skills).toEqual(['superpowers:writing-plans', 'superpowers:test-driven-development']);
});
});
- Step 2: Run tests to verify they fail
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core
Expected: FAIL — cannot resolve ../docs/observer/dashboard-core.js.
- Step 3: Implement parser + normalizer
Create docs/observer/dashboard-core.js:
// Pure logic for the Brain Dashboard. Browser-safe ES module (no node: APIs)
// so it loads both in the browser and under Vitest's node environment.
export function normalizeEpisode(raw) {
const v2 = raw.schema_version === 2;
const pr = raw.primary_rationale || {};
const events = Array.isArray(raw.events) ? raw.events : [];
const tools = {};
for (const ev of events) {
if (ev.kind === 'tool_summary' && ev.counts) {
for (const [k, n] of Object.entries(ev.counts)) tools[k] = (tools[k] || 0) + n;
}
}
const started = raw.timestamps?.started_at || null;
const ended = raw.timestamps?.ended_at || null;
return {
schemaVersion: v2 ? 2 : 1,
taskId: raw.task_id || null,
taskRef: raw.task_ref || raw.task_id || null,
startedAt: started,
endedAt: ended,
durationMs: started && ended ? Date.parse(ended) - Date.parse(started) : null,
pathType: raw.path_type || null,
outcome: raw.outcome || 'unknown',
promptSignal: v2 ? raw.prompt_signal || null : null,
decisionProvenance: v2 ? raw.decision_provenance || null : null,
environment: v2 ? raw.environment || null : null,
taskSize: v2 ? raw.task_size || null : null,
taskClassification: pr.task_classification || null,
nodeChosen: pr.node_chosen || null,
hardFloor: pr.hard_floor || { invoked: false, rules: [] },
skills: events.filter((e) => e.kind === 'skill_invoked').map((e) => e.skill),
tools,
errorCount: events.filter((e) => e.kind === 'error').length,
retryCount: events.filter((e) => e.kind === 'retry').length,
interruptCount: events.filter((e) => e.kind === 'interrupt').length,
events,
raw,
};
}
export function parseEpisodes(text) {
const episodes = [];
let skipped = 0;
for (const line of String(text).split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
let raw;
try {
raw = JSON.parse(trimmed);
} catch {
skipped++;
continue;
}
if (!raw || typeof raw !== 'object' || raw.observer_error) {
skipped++;
continue;
}
episodes.push(normalizeEpisode(raw));
}
return { episodes, skipped };
}
- Step 4: Run tests to verify they pass
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core
Expected: PASS — 8 tests.
- Step 5: Commit
git add docs/observer/dashboard-core.js tools/brain-dashboard-core.test.mjs
git commit -m "feat(brain): episode JSONL parser + v1/v2 normalizer"
Task 4: Node attribution (episode signal → graph node id)
Files:
-
Modify:
docs/observer/dashboard-core.js(append) -
Modify:
tools/brain-dashboard-core.test.mjs(append) -
Step 1: Write failing tests
Append to tools/brain-dashboard-core.test.mjs:
import { attributeNodes } from '../docs/observer/dashboard-core.js';
describe('attributeNodes', () => {
const ep = (over) => normalizeEpisode({ ...v1, ...over });
it('maps node_chosen skill id to a graph node', () => {
const r = attributeNodes(ep({ primary_rationale: { node_chosen: 'superpowers:systematic-debugging', hard_floor: {} } }));
expect(r.nodeIds).toContain('sk_debug');
});
it('ignores node_chosen === "direct"', () => {
const r = attributeNodes(ep({ primary_rationale: { node_chosen: 'direct', hard_floor: {} } }));
expect(r.nodeIds).toEqual([]);
});
it('maps skill_invoked events to graph nodes', () => {
const r = attributeNodes(ep({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }));
expect(r.nodeIds).toContain('sk_wplans');
});
it('maps mcp__<server>__ tool names to MCP graph nodes', () => {
const r = attributeNodes(ep({ events: [{ kind: 'tool_summary', counts: { 'mcp__github__get_issue': 2, 'mcp__laravel-boost__database-query': 1, Read: 4 } }] }));
expect(r.nodeIds).toContain('mcp_gh');
expect(r.nodeIds).toContain('mcp_boost');
});
it('counts signals vs attributed — builtin tools are not signals', () => {
const r = attributeNodes(ep({ events: [{ kind: 'tool_summary', counts: { Read: 1, 'mcp__github__x': 1 } }],
primary_rationale: { node_chosen: 'superpowers:test-driven-development', hard_floor: {} } }));
expect(r.attributed).toBe(2); // tdd skill + github mcp
expect(r.signals).toBe(2); // only the tdd skill and the mcp tool count as signals
});
});
Note: builtin tools (Read, Edit, Bash, …) are intentionally not counted as signals — only skill ids and mcp__* tool names are attribution signals (see spec §4.3).
- Step 2: Run tests to verify they fail
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core
Expected: FAIL — attributeNodes is not exported.
- Step 3: Implement attribution
Append to docs/observer/dashboard-core.js:
// episode skill name → automation-graph node id (see tools/observer-known-nodes.txt
// for the routable vocabulary; only skills that have a graph node are listed).
export const SKILL_TO_NODE = {
brainstorming: 'sk_brainstorm',
'writing-plans': 'sk_wplans',
'executing-plans': 'sk_eplans',
'subagent-driven-development': 'sk_subagent',
'test-driven-development': 'sk_tdd',
'systematic-debugging': 'sk_debug',
'verification-before-completion': 'sk_verify',
'requesting-code-review': 'sk_coderev',
'using-git-worktrees': 'sk_worktree',
'finishing-a-development-branch': 'sk_pr',
'writing-skills': 'sk_wskills',
'discovery-interview': 'discovery_interview',
'audit-portal': 'sk_audit_portal',
regression: 'sk_regression',
'process-modeling': 'process_modeling',
'process-analysis': 'process_analysis',
ccpm: 'ccpm',
'security-review': 'sk_security_review',
'claude-md-management': 'claude_md_mgmt',
};
// mcp__<server>__<tool> → automation-graph node id.
export const MCP_SERVER_TO_NODE = {
github: 'mcp_gh',
playwright: 'mcp_pw',
'laravel-boost': 'mcp_boost',
redis: 'mcp_redis',
sentry: 'mcp_sentry',
semgrep: 'mcp_semgrep',
openapi: 'mcp_openapi',
magic: 'mcp_21st',
'universal-icons': 'mcp_icons',
};
// "superpowers:systematic-debugging" → "systematic-debugging"
function skillBase(name) {
const s = String(name || '');
return s.includes(':') ? s.split(':').pop() : s;
}
// Returns { nodeIds: string[], signals: number, attributed: number }.
// A "signal" is an episode datum that names a routable node (a skill id or an
// mcp__ tool). Builtin Claude tools are not signals.
export function attributeNodes(episode) {
const ids = new Set();
let signals = 0;
let attributed = 0;
const consider = (nodeId) => {
signals++;
if (nodeId) {
ids.add(nodeId);
attributed++;
}
};
if (episode.nodeChosen && episode.nodeChosen !== 'direct') {
consider(SKILL_TO_NODE[skillBase(episode.nodeChosen)]);
}
for (const s of episode.skills) consider(SKILL_TO_NODE[skillBase(s)]);
for (const toolName of Object.keys(episode.tools)) {
const m = /^mcp__(.+?)__/.exec(toolName);
if (m) consider(MCP_SERVER_TO_NODE[m[1]]);
}
return { nodeIds: [...ids], signals, attributed };
}
- Step 4: Run tests to verify they pass
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core
Expected: PASS — 13 tests total.
- Step 5: Commit
git add docs/observer/dashboard-core.js tools/brain-dashboard-core.test.mjs
git commit -m "feat(brain): node attribution — episode signals to graph nodes"
Task 5: Dashboard shell + graph banner
Files:
-
Create:
docs/observer/dashboard.html -
Create:
docs/observer/dashboard.js -
Step 1: Create
dashboard.html
Create docs/observer/dashboard.html with this structure (inline <style>, neutral colours — Forest polish is Task 13):
<head>: charset, viewport,<title>Дашборд мозга — Лидерра</title>.- Three scripts at the end of
<body>, in order:<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script><script src="../automation-graph-data.js"></script>(defineswindow.AGD)<script type="module" src="dashboard.js"></script>
<body>layout (CSS grid, full viewport height,overflow:hidden):<header id="tabbar">— 4 buttons:data-view="map"Карта,data-view="replay"Разбор,data-view="feed"Лента,data-view="aggregate"Агрегат. Plus a right-aligned<span id="status">for "N эпизодов · M пропущено".<section id="graph">— fixed height40vh, contains<div id="network" style="width:100%;height:100%"></div>.<section id="workarea">—flex:1; overflow:auto, contains four<div class="view" id="view-map|view-replay|view-feed|view-aggregate">panels; only the active one hasdisplay:block, othersdisplay:none.
- Inline CSS: neutral dark theme (
background:#1e1e2e; color:#fdf6e3), tab buttons with an.activeclass,#networkbackground#1e1e2e.
Acceptance: file opens via the Task 1 server at / (default route) without console errors once Task 5 Step 2 is done.
- Step 2: Create
dashboard.js— shell controller + graph render
Create docs/observer/dashboard.js:
import { parseEpisodes } from './dashboard-core.js';
const AGD = window.AGD;
let episodes = [];
let skipped = 0;
let network = null;
// ── data loading ──────────────────────────────────────────────
async function loadEpisodes() {
const files = await fetch('/api/episodes').then((r) => r.json());
const all = [];
let skip = 0;
for (const f of files) {
const text = await fetch(f).then((r) => (r.ok ? r.text() : ''));
const r = parseEpisodes(text);
all.push(...r.episodes);
skip += r.skipped;
}
all.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
episodes = all;
skipped = skip;
document.getElementById('status').textContent =
`${episodes.length} эпизодов · ${skipped} пропущено`;
}
// ── graph banner ──────────────────────────────────────────────
function renderGraph() {
const nodes = new vis.DataSet(AGD.NODES);
const edges = new vis.DataSet(AGD.EDGES);
network = new vis.Network(
document.getElementById('network'),
{ nodes, edges },
{
groups: AGD.GROUPS,
nodes: { shape: 'dot', borderWidth: 2, font: { multi: 'html' } },
edges: { smooth: { type: 'continuous', roundness: 0.5 } },
physics: { enabled: false },
interaction: { hover: true, tooltipDelay: 400 },
}
);
network.once('afterDrawing', () => network.fit());
return { nodes, edges };
}
// ── view switching ────────────────────────────────────────────
const views = {}; // viewName -> render function, filled by later tasks
let activeView = 'map';
function switchView(name) {
activeView = name;
for (const v of ['map', 'replay', 'feed', 'aggregate']) {
document.getElementById('view-' + v).style.display = v === name ? 'block' : 'none';
}
document.querySelectorAll('#tabbar button').forEach((b) => {
b.classList.toggle('active', b.dataset.view === name);
});
if (views[name]) views[name]();
}
// ── boot ──────────────────────────────────────────────────────
async function boot() {
const gds = renderGraph();
window.__graph = { network, ...gds }; // shared handle for view modules
document.querySelectorAll('#tabbar button').forEach((b) => {
b.addEventListener('click', () => switchView(b.dataset.view));
});
await loadEpisodes();
switchView('map');
}
export function getEpisodes() { return episodes; }
export { views, switchView };
boot();
- Step 3: Smoke-test the shell
Run node tools/brain-dashboard-server.mjs, open http://localhost:7700/. Verify:
-
the graph banner renders the topology;
-
the 4 tab buttons switch the active class and the visible
.viewpanel; -
#statusshows the episode count (e.g. "18 эпизодов · 0 пропущено"); -
no console errors.
-
Step 4: Commit
git add docs/observer/dashboard.html docs/observer/dashboard.js
git commit -m "feat(brain): dashboard shell + graph banner + view switching"
Task 6: View «Карта» (plain topology + design conflicts)
Files:
-
Modify:
docs/observer/dashboard.js -
Modify:
docs/observer/dashboard.html(#view-mapcontent) -
Step 1: Add the Карта panel markup
In dashboard.html, inside <div class="view" id="view-map"> add:
-
<p>explaining: "Топология мозга: 124 узла, рёбра, 11 размеченных дизайн-конфликтов. Это нулевое состояние холста — без оверлеев." -
<ul id="map-conflicts">— populated by JS with the design conflicts. -
Step 2: Register the
mapview
In dashboard.js, after the views declaration add:
views.map = function renderMapView() {
// Plain mode: clear any overlay coloring applied by other views.
window.__graph.nodes.update(AGD.NODES.map((n) => ({ id: n.id, color: undefined })));
// List the design-time conflict edges (dashed edges carry an emoji label).
const conflicts = AGD.EDGES.filter((e) => e.dashes === true);
const ul = document.getElementById('map-conflicts');
ul.innerHTML = '';
for (const c of conflicts) {
const li = document.createElement('li');
li.textContent = `${c.label || '•'} ${c.from} ↔ ${c.to}: ${c.title || ''}`;
ul.appendChild(li);
}
};
- Step 3: Smoke-test
Open the dashboard, click «Карта». Verify the conflict list shows 11 entries with emoji (🔴/⚫/🟢) and node pairs; graph is in plain colours. No console errors.
- Step 4: Commit
git add docs/observer/dashboard.js docs/observer/dashboard.html
git commit -m "feat(brain): Карта view — plain topology + design conflicts list"
Task 7: View «Разбор задачи» (episode list + filters + trajectory)
Files:
-
Modify:
docs/observer/dashboard-core.js(appendfilterEpisodes) -
Modify:
tools/brain-dashboard-core.test.mjs(append) -
Modify:
docs/observer/dashboard.js,docs/observer/dashboard.html -
Step 1: Write failing test for
filterEpisodes
Append to tools/brain-dashboard-core.test.mjs:
import { filterEpisodes } from '../docs/observer/dashboard-core.js';
describe('filterEpisodes', () => {
const list = [
normalizeEpisode({ ...v1, primary_rationale: { node_chosen: 'direct', hard_floor: {}, task_classification: 'refactor' }, events: [] }),
normalizeEpisode({ ...v2, primary_rationale: { node_chosen: 'direct', hard_floor: {}, task_classification: 'bugfix' }, events: [{ kind: 'error', message: 'e' }] }),
];
it('returns all with an empty filter', () => {
expect(filterEpisodes(list, {})).toHaveLength(2);
});
it('filters by task classification', () => {
expect(filterEpisodes(list, { classification: 'bugfix' })).toHaveLength(1);
});
it('filters to episodes with errors only', () => {
expect(filterEpisodes(list, { withErrors: true })).toHaveLength(1);
});
});
- Step 2: Run test, verify it fails
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core
Expected: FAIL — filterEpisodes not exported.
- Step 3: Implement
filterEpisodes
Append to docs/observer/dashboard-core.js:
// filter: { classification?, outcome?, pathType?, withErrors?, dateFrom?, dateTo? }
export function filterEpisodes(episodes, filter = {}) {
return episodes.filter((e) => {
if (filter.classification && e.taskClassification !== filter.classification) return false;
if (filter.outcome && e.outcome !== filter.outcome) return false;
if (filter.pathType && e.pathType !== filter.pathType) return false;
if (filter.withErrors && e.errorCount === 0 && e.retryCount === 0) return false;
if (filter.dateFrom && String(e.startedAt) < filter.dateFrom) return false;
if (filter.dateTo && String(e.startedAt) > filter.dateTo) return false;
return true;
});
}
- Step 4: Run test, verify it passes
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core
Expected: PASS.
- Step 5: Add the Разбор panel markup
In dashboard.html, inside #view-replay add a two-column layout:
-
left
<div id="replay-list">— a<select id="f-classification">(options: «все», bugfix, feature, refactor, docs, question, other), a<select id="f-outcome">(options: «все», success, unknown, failure), a checkbox#f-errors(«только с ошибками»), and a<ul id="replay-episodes">. -
right
<div id="replay-detail">— empty until an episode is selected. -
Step 6: Implement the
replayview
In dashboard.js, change the first import line to also import the needed functions:
import { parseEpisodes, filterEpisodes, attributeNodes } from './dashboard-core.js';
Then add:
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(getEpisodes(), 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 = `
<h3>${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}</h3>
<p>provenance: ${provLine}</p>
<p>hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}</p>
<p>окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}</p>
<p>атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов</p>
<h4>События</h4>
<ol>${ep.events.map((e) => `<li>${eventLine(e)}</li>`).join('')}</ol>`;
}
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;
}
}
In boot(), after wiring tab buttons, add filter listeners:
['f-classification', 'f-outcome', 'f-errors'].forEach((id) => {
document.getElementById(id).addEventListener('change', () => {
if (activeView === 'replay') views.replay();
});
});
- Step 7: Smoke-test
Open the dashboard → «Разбор». Verify: episode list populates; changing a filter narrows it; clicking an episode highlights nodes on the graph (skill/MCP episodes light up; direct episodes light nothing — expected) and shows the detail panel with the ordered event list. No console errors.
- Step 8: Commit
git add docs/observer/dashboard-core.js tools/brain-dashboard-core.test.mjs docs/observer/dashboard.js docs/observer/dashboard.html
git commit -m "feat(brain): Разбор view — episode list, filters, trajectory highlight"
Phase 2 — Live session feed
Task 8: View «Лента сессии» (episodes grouped by task)
Files:
-
Modify:
docs/observer/dashboard-core.js(appendgroupBySession) -
Modify:
tools/brain-dashboard-core.test.mjs(append) -
Modify:
docs/observer/dashboard.js,docs/observer/dashboard.html -
Step 1: Write failing test for
groupBySession
Append to tools/brain-dashboard-core.test.mjs:
import { groupBySession } from '../docs/observer/dashboard-core.js';
describe('groupBySession', () => {
it('groups episodes by taskRef, newest episode first within a group', () => {
const a1 = normalizeEpisode({ ...v2, task_ref: 'S', timestamps: { started_at: '2026-05-19T08:00:00Z', ended_at: '2026-05-19T08:01:00Z' } });
const a2 = normalizeEpisode({ ...v2, task_ref: 'S', timestamps: { started_at: '2026-05-19T09:00:00Z', ended_at: '2026-05-19T09:01:00Z' } });
const b1 = normalizeEpisode({ ...v2, task_ref: 'T', timestamps: { started_at: '2026-05-19T07:00:00Z', ended_at: '2026-05-19T07:01:00Z' } });
const groups = groupBySession([a1, a2, b1]);
const s = groups.find((g) => g.taskRef === 'S');
expect(s.episodes[0].startedAt).toBe('2026-05-19T09:00:00Z');
expect(groups[0].taskRef).toBe('S'); // group with the newest episode first
});
});
-
Step 2: Run test, verify it fails. Run the
brain-dashboard-coresuite — FAIL (groupBySessionnot exported). -
Step 3: Implement
groupBySession
Append to docs/observer/dashboard-core.js:
// Groups episodes by taskRef. Each group's episodes are sorted newest-first;
// groups are ordered by their newest episode, newest group first.
export function groupBySession(episodes) {
const byRef = new Map();
for (const e of episodes) {
const key = e.taskRef || e.taskId || 'unknown';
if (!byRef.has(key)) byRef.set(key, []);
byRef.get(key).push(e);
}
const groups = [...byRef.entries()].map(([taskRef, eps]) => {
eps.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
return { taskRef, episodes: eps, newest: eps[0]?.startedAt || '' };
});
groups.sort((a, b) => String(b.newest).localeCompare(String(a.newest)));
return groups;
}
-
Step 4: Run test, verify it passes.
-
Step 5: Add the Лента markup
In dashboard.html, inside #view-feed add <button id="feed-pause">Пауза</button>, <span id="feed-poll-state"></span>, and <div id="feed-stream">.
- Step 6: Implement the
feedview
In dashboard.js, change the import to add groupBySession:
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession } from './dashboard-core.js';
Then add:
views.feed = function renderFeedView() {
const groups = groupBySession(getEpisodes());
const root = document.getElementById('feed-stream');
root.innerHTML = groups.map((g) => `
<section class="feed-group">
<h4>сессия ${g.taskRef.slice(0, 8)} · ${g.episodes.length} ходов</h4>
${g.episodes.map(feedCard).join('')}
</section>`).join('');
};
function feedCard(ep) {
const dur = ep.durationMs != null ? Math.round(ep.durationMs / 1000) + 's' : '—';
const redirect = ep.decisionProvenance && ep.decisionProvenance.kind === 'user_directed_method' ? ' ↪' : '';
return `<div class="feed-card">
${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.nodeChosen || '—'}
· ${dur}${ep.errorCount ? ' · ⚠' + ep.errorCount : ''}${ep.retryCount ? ' · ↻' + ep.retryCount : ''}${redirect}
</div>`;
}
-
Step 7: Smoke-test. Open → «Лента». Episodes appear grouped by session, newest first. No console errors.
-
Step 8: Commit
git add docs/observer/dashboard-core.js tools/brain-dashboard-core.test.mjs docs/observer/dashboard.js docs/observer/dashboard.html
git commit -m "feat(brain): Лента view — episodes grouped by session"
Task 9: Auto-poll + pause
Files:
-
Modify:
docs/observer/dashboard.js -
Step 1: Add polling logic
In dashboard.js add (after boot):
const POLL_MS = 5000;
let pollTimer = null;
async function pollTick() {
const before = getEpisodes().length;
await loadEpisodes();
if (getEpisodes().length !== before && activeView === 'feed') views.feed();
}
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(pollTick, POLL_MS);
const el = document.getElementById('feed-poll-state');
if (el) el.textContent = `автоопрос каждые ${POLL_MS / 1000}s`;
}
function stopPolling() {
clearInterval(pollTimer);
pollTimer = null;
const el = document.getElementById('feed-poll-state');
if (el) el.textContent = 'опрос на паузе';
}
- Step 2: Wire pause button and view-driven polling
In switchView, after the if (views[name]) views[name](); line add:
if (name === 'feed') startPolling(); else stopPolling();
In boot, after wiring the filter listeners, add the pause-button handler:
document.getElementById('feed-pause').addEventListener('click', () => {
if (pollTimer) stopPolling(); else startPolling();
});
- Step 3: Smoke-test
Open → «Лента». Confirm #feed-poll-state shows "автоопрос каждые 5s"; click Пауза → "опрос на паузе"; switch to another tab and back → polling resumes only on «Лента». Watch the Network tab — /api/episodes is re-requested every 5s while on «Лента». No console errors.
- Step 4: Commit
git add docs/observer/dashboard.js
git commit -m "feat(brain): Лента auto-poll with pause"
Phase 3 — Aggregate view + heat + conflicts
Task 10: Aggregator
Files:
-
Modify:
docs/observer/dashboard-core.js(appendaggregate) -
Modify:
tools/brain-dashboard-core.test.mjs(append) -
Step 1: Write failing tests
Append to tools/brain-dashboard-core.test.mjs:
import { aggregate } from '../docs/observer/dashboard-core.js';
describe('aggregate', () => {
const mk = (over) => normalizeEpisode({ ...v2, ...over });
it('counts node heat from attributed nodes', () => {
const list = [
mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }),
mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }),
];
expect(aggregate(list).nodeHeat.sk_wplans).toBe(2);
});
it('computes redirect rate', () => {
const list = [
mk({ decision_provenance: { kind: 'user_directed_method', claude_would_have_chosen: 'x' } }),
mk({ decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null } }),
];
expect(aggregate(list).redirectRate).toBe(0.5);
});
it('tallies path_type and outcome distributions', () => {
const list = [mk({ path_type: 'improvised', outcome: 'unknown' }), mk({ path_type: 'regulated', outcome: 'success' })];
const a = aggregate(list);
expect(a.pathType).toEqual({ improvised: 1, regulated: 1 });
expect(a.outcome).toEqual({ unknown: 1, success: 1 });
});
it('reports total error and retry counts', () => {
const list = [mk({ events: [{ kind: 'error', message: 'e' }, { kind: 'retry' }] })];
const a = aggregate(list);
expect(a.totalErrors).toBe(1);
expect(a.totalRetries).toBe(1);
});
});
- Step 2: Run tests, verify they fail.
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run brain-dashboard-core — FAIL (aggregate not exported).
- Step 3: Implement
aggregate
Append to docs/observer/dashboard-core.js:
// Aggregates a list of episodes into dashboard metrics.
export function aggregate(episodes) {
const nodeHeat = {};
const pathType = {};
const outcome = {};
const classification = {};
const economy = {};
let totalErrors = 0;
let totalRetries = 0;
let redirects = 0;
for (const e of episodes) {
for (const id of attributeNodes(e).nodeIds) nodeHeat[id] = (nodeHeat[id] || 0) + 1;
if (e.pathType) pathType[e.pathType] = (pathType[e.pathType] || 0) + 1;
outcome[e.outcome] = (outcome[e.outcome] || 0) + 1;
if (e.taskClassification) classification[e.taskClassification] = (classification[e.taskClassification] || 0) + 1;
const lvl = e.environment ? e.environment.economy_level : null;
const key = lvl == null ? 'n/a' : String(lvl);
economy[key] = (economy[key] || 0) + 1;
totalErrors += e.errorCount;
totalRetries += e.retryCount;
if (e.decisionProvenance && e.decisionProvenance.kind === 'user_directed_method') redirects++;
}
return {
nodeHeat,
pathType,
outcome,
classification,
economy,
totalErrors,
totalRetries,
redirectRate: episodes.length ? redirects / episodes.length : 0,
count: episodes.length,
};
}
-
Step 4: Run tests, verify they pass.
-
Step 5: Commit
git add docs/observer/dashboard-core.js tools/brain-dashboard-core.test.mjs
git commit -m "feat(brain): aggregator — node heat, distributions, redirect rate"
Task 11: View «Агрегат» + heat overlay on the graph
Files:
-
Modify:
docs/observer/dashboard.js,docs/observer/dashboard.html -
Step 1: Add the Агрегат markup
In dashboard.html, inside #view-aggregate add <div id="agg-tiles"> (a CSS grid of metric tiles, filled by JS).
- Step 2: Implement the
aggregateview
In dashboard.js, change the import to add aggregate:
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate } from './dashboard-core.js';
Then add:
views.aggregate = function renderAggregateView() {
const a = aggregate(getEpisodes());
applyHeat(a.nodeHeat);
const dist = (obj) => Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(' · ') || '—';
const topNodes = Object.entries(a.nodeHeat).sort((x, y) => y[1] - x[1]).slice(0, 10);
document.getElementById('agg-tiles').innerHTML = `
<div class="tile"><h4>Эпизодов</h4><p>${a.count}</p></div>
<div class="tile"><h4>Ошибки / ретраи</h4><p>${a.totalErrors} / ${a.totalRetries}</p></div>
<div class="tile"><h4>Доля перенаправлений</h4><p>${(a.redirectRate * 100).toFixed(0)}%</p></div>
<div class="tile"><h4>path_type</h4><p>${dist(a.pathType)}</p></div>
<div class="tile"><h4>outcome</h4><p>${dist(a.outcome)}</p></div>
<div class="tile"><h4>классы задач</h4><p>${dist(a.classification)}</p></div>
<div class="tile"><h4>economy-уровни</h4><p>${dist(a.economy)}</p></div>
<div class="tile"><h4>Топ узлов</h4><p>${topNodes.map(([k, v]) => `${k}×${v}`).join(' · ') || '—'}</p></div>`;
};
function applyHeat(nodeHeat) {
const max = Math.max(1, ...Object.values(nodeHeat));
window.__graph.nodes.update(
AGD.NODES.map((n) => {
const h = nodeHeat[n.id] || 0;
const t = h / max;
return {
id: n.id,
color: h
? { background: `rgba(38,139,210,${0.25 + 0.6 * t})`, border: '#93a1a1' }
: { background: '#2a2a3a', border: '#444' },
};
})
);
}
- Step 3: Smoke-test
Open → «Агрегат». Verify metric tiles fill; the graph banner colours nodes by usage heat (hotter = brighter blue); unused nodes are dim. Switching back to «Карта» clears the heat (Task 6 Step 2 already resets node colours). No console errors.
- Step 4: Commit
git add docs/observer/dashboard.js docs/observer/dashboard.html
git commit -m "feat(brain): Агрегат view — metric tiles + node heat overlay"
Task 12: Conflict three-layer panel
Files:
-
Modify:
docs/observer/dashboard-core.js(appendinferConflicts) -
Modify:
tools/brain-dashboard-core.test.mjs(append) -
Modify:
docs/observer/dashboard.js,docs/observer/dashboard.html -
Step 1: Write failing tests
Append to tools/brain-dashboard-core.test.mjs:
import { inferConflicts } from '../docs/observer/dashboard-core.js';
describe('inferConflicts', () => {
const conflictEdges = [{ from: 'sk_wplans', to: 'sk_debug', dashes: true, label: '⚫', title: 't' }];
it('returns design conflicts from dashed edges', () => {
const r = inferConflicts([], conflictEdges);
expect(r.design).toHaveLength(1);
});
it('reports friction — episodes with errors attributed to nodes', () => {
const ep = normalizeEpisode({ ...v2,
events: [{ kind: 'error', message: 'e' }, { kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] });
const r = inferConflicts([ep], conflictEdges);
expect(r.friction.sk_wplans).toBe(1);
});
it('reports correlation when an errored episode spans a conflict-edge pair', () => {
const ep = normalizeEpisode({ ...v2, events: [
{ kind: 'error', message: 'e' },
{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' },
{ kind: 'skill_invoked', skill: 'superpowers:systematic-debugging' },
] });
const r = inferConflicts([ep], conflictEdges);
expect(r.correlation).toHaveLength(1);
expect(r.correlation[0].pair).toEqual(['sk_wplans', 'sk_debug']);
});
});
-
Step 2: Run tests, verify they fail.
-
Step 3: Implement
inferConflicts
Append to docs/observer/dashboard-core.js:
// Three honest layers (spec §6):
// design — the dashed conflict edges (fact, from topology)
// friction — node id → count of errored/retried episodes attributed to it
// correlation — errored episodes that span both ends of a design-conflict edge
export function inferConflicts(episodes, edges) {
const design = edges.filter((e) => e.dashes === true);
const friction = {};
const correlation = [];
for (const e of episodes) {
if (e.errorCount === 0 && e.retryCount === 0) continue;
const ids = attributeNodes(e).nodeIds;
for (const id of ids) friction[id] = (friction[id] || 0) + 1;
if (e.errorCount > 0) {
for (const edge of design) {
if (ids.includes(edge.from) && ids.includes(edge.to)) {
correlation.push({ episode: e.taskId, pair: [edge.from, edge.to], conflict: edge.title || '' });
}
}
}
}
return { design, friction, correlation };
}
-
Step 4: Run tests, verify they pass.
-
Step 5: Add a conflicts sub-panel to «Агрегат»
In dashboard.html, inside #view-aggregate add <div id="agg-conflicts"> after #agg-tiles.
In dashboard.js, change the import to add inferConflicts:
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate, inferConflicts } from './dashboard-core.js';
At the end of views.aggregate add:
const c = inferConflicts(getEpisodes(), AGD.EDGES);
const top = (obj) => Object.entries(obj).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k}×${v}`).join(' · ') || '—';
document.getElementById('agg-conflicts').innerHTML = `
<h4>Конфликты — три слоя</h4>
<p><b>Дизайн-конфликты (факт):</b> ${c.design.length} размеченных рёбер</p>
<p><b>Трение (инференс):</b> ${top(c.friction)}</p>
<p><b>Корреляция (эвристика):</b> ${c.correlation.length} ходов с ошибкой на паре конфликтующих узлов</p>`;
- Step 6: Smoke-test
Open → «Агрегат». Verify the three-layer conflict block renders below the tiles with the labels «факт» / «инференс» / «эвристика» visible. No console errors.
- Step 7: Commit
git add docs/observer/dashboard-core.js tools/brain-dashboard-core.test.mjs docs/observer/dashboard.js docs/observer/dashboard.html
git commit -m "feat(brain): conflict three-layer panel (design / friction / correlation)"
Task 13: Forest polish + observer README update
Files:
-
Modify:
docs/observer/dashboard.html(CSS) -
Modify:
docs/observer/README.md -
Step 1: Apply Forest palette
In dashboard.html's inline <style>, introduce CSS variables and apply them:
:root {
--bg: #F6F3EC; --ink: #012019; --teal: #0F6E56;
--panel: #ffffff; --line: #d8d2c4;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--sans: 'Inter', system-ui, sans-serif;
}
Apply: page background --bg, text --ink, tab bar / tiles / cards --panel with --line borders, active tab and headings accent --teal, all numeric/timestamp/node-id text uses --mono. Keep #network background dark (#1e1e2e) — vis.js graphs read better on dark; that is intentional contrast, not an oversight.
- Step 2: Visual smoke — all four views
Run the server, open the dashboard, walk every view (Карта / Разбор / Лента / Агрегат). Verify the Forest look is consistent, the graph banner stays dark, text is legible, no layout overflow. No console errors.
- Step 3: Document the dashboard in the observer README
In docs/observer/README.md, under ## Files, add a bullet:
- `dashboard.html` + `dashboard.js` + `dashboard-core.js` — Brain Dashboard: visualises the episode log over the automation-graph topology (4 views). Run `npm run brain:dashboard`, open the printed localhost URL. `dashboard-core.js` is pure logic, unit-tested in `tools/brain-dashboard-core.test.mjs`.
- Step 4: Run the full tools test suite
Run: cd app && node node_modules/vitest/vitest.mjs --config vitest.config.tools.mjs run
Expected: all brain-dashboard-* suites PASS alongside the existing observer suites; 0 failures.
- Step 5: Commit
git add docs/observer/dashboard.html docs/observer/README.md
git commit -m "feat(brain): Forest polish + observer README entry for the dashboard"
Self-Review
Spec coverage:
- §3 tech-model A (HTML + static server) → Task 1. ✓
- §3 layout C (graph banner top) → Task 5. ✓
- §4.1 data layer (server,
/api/episodes, v1/v2 parser) → Tasks 1, 3. ✓ - §4.2 topology extraction → Task 2. ✓
- §4.3 node attribution → Task 4. ✓
- §5.1 Карта → Task 6; §5.2 Разбор → Task 7; §5.3 Лента → Task 8; §5.4 Агрегат → Tasks 10, 11. ✓
- §5.3 auto-poll → Task 9. ✓
- §6 three-layer conflicts → Task 12. ✓
- §10 testing (TDD on pure logic, smoke on UI) → every task. ✓
- §7 Forest polish → Task 13. ✓
- §13 vis.js source — resolved: CDN (same tag as the existing map). §13 outcome-inference reuse — resolved: not reused; the dashboard shows
outcomeas-is includingunknown(YAGNI). Recorded here, no task needed.
Placeholder scan: no TBD/TODO; every code step carries complete code; the topology-move in Task 2 is described by const-name anchors + line ranges (a move, not new code) — acceptable.
Type consistency: dashboard-core.js exports used consistently — parseEpisodes/normalizeEpisode (Task 3), attributeNodes/SKILL_TO_NODE/MCP_SERVER_TO_NODE (Task 4), filterEpisodes (Task 7), groupBySession (Task 8), aggregate (Task 10), inferConflicts (Task 12). Normalized-episode field names (taskRef, nodeChosen, errorCount, decisionProvenance, environment, taskClassification, tools, skills, events, durationMs) defined in Task 3 and reused unchanged in Tasks 4/7/8/10/12. window.AGD keys (NODES/EDGES/GROUPS/…) defined in Task 2, consumed in Tasks 5/6/11/12. window.__graph (network/nodes/edges) defined in Task 5, consumed in Tasks 6/7/11. The dashboard.js import line grows across Tasks 5→7→8→11→12; each task states the full replacement import line. Consistent.