Files
portal/docs/superpowers/plans/2026-05-19-brain-dashboard.md
T
Дмитрий df5e0ad0c9 fix(brain): correct vitest command in plan — run from app/
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>
2026-05-19 16:23:44 +03:00

56 KiB
Raw Blame History

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 ../tools glob 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 new tools/brain-dashboard-*.test.mjs are auto-included. Verified baseline 2026-05-19 — 11 files, 169 tests passing.
  • dashboard-core.js is browser-safe (no node: APIs) so it loads both in the browser and under Vitest's node environment.
  • 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 59, 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 (add scripts.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:

  1. RADII and function pos(...) (≈ lines 222227)
  2. const NODES = [ … ]; (≈ lines 229389)
  3. const CONFLICT_TYPES = { … }; (≈ lines 394398)
  4. const E = (…) => ({ … }); (≈ lines 399405)
  5. const CONFLICT = (…) => ({ … }); (≈ lines 406416)
  6. const EDGES = [ … ]; (≈ lines 418615)
  7. const CATEGORY_LABELS = { … }; (≈ lines 620625)
  8. const SECTION_BUCKETS = [ … ]; (≈ lines 21052111)
  9. const SECTIONS = [ … ]; (≈ lines 21122153)
  10. const NODE_SECTION = { … }; (≈ lines 21552212)
  11. const NODE_SECTION_SECONDARY = { … }; (≈ lines 22162228)
  12. const GROUPS = { … }; (≈ lines 22682279)

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:
    1. <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
    2. <script src="../automation-graph-data.js"></script> (defines window.AGD)
    3. <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 height 40vh, 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 has display:block, others display:none.
  • Inline CSS: neutral dark theme (background:#1e1e2e; color:#fdf6e3), tab buttons with an .active class, #network background #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 .view panel;

  • #status shows 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-map content)

  • 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 map view

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 (append filterEpisodes)

  • 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 replay view

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 (append groupBySession)

  • 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-core suite — FAIL (groupBySession not 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 feed view

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 (append aggregate)

  • 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 aggregate view

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 (append inferConflicts)

  • 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 outcome as-is including unknown (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.