# 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 | 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` (add `scripts.brain:dashboard`)

- [ ] **Step 1: Write failing tests for the server helpers**

Create `tools/brain-dashboard-server.test.mjs`:

```js
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`:

```js
// 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):

```json
    "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**

```bash
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 222–227)
2. `const NODES = [ … ];` (≈ lines 229–389)
3. `const CONFLICT_TYPES = { … };` (≈ lines 394–398)
4. `const E = (…) => ({ … });` (≈ lines 399–405)
5. `const CONFLICT = (…) => ({ … });` (≈ lines 406–416)
6. `const EDGES = [ … ];` (≈ lines 418–615)
7. `const CATEGORY_LABELS = { … };` (≈ lines 620–625)
8. `const SECTION_BUCKETS = [ … ];` (≈ lines 2105–2111)
9. `const SECTIONS = [ … ];` (≈ lines 2112–2153)
10. `const NODE_SECTION = { … };` (≈ lines 2155–2212)
11. `const NODE_SECTION_SECONDARY = { … };` (≈ lines 2216–2228)
12. `const GROUPS = { … };` (≈ lines 2268–2279)

Then append, as the last lines of the file:

```js
// 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:

```html
  <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 `const`s 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**

```bash
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`:

```js
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`:

```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**

```bash
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`:

```js
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`:

```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**

```bash
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`:

```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**

```bash
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:

```js
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**

```bash
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`:

```js
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`:

```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:

```js
import { parseEpisodes, filterEpisodes, attributeNodes } from './dashboard-core.js';
```

Then add:

```js
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:

```js
  ['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**

```bash
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`:

```js
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`:

```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`:

```js
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession } from './dashboard-core.js';
```

Then add:

```js
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**

```bash
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`):

```js
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:

```js
  if (name === 'feed') startPolling(); else stopPolling();
```

In `boot`, after wiring the filter listeners, add the pause-button handler:

```js
  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**

```bash
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`:

```js
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`:

```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**

```bash
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`:

```js
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate } from './dashboard-core.js';
```

Then add:

```js
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**

```bash
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`:

```js
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`:

```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`:

```js
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate, inferConflicts } from './dashboard-core.js';
```

At the end of `views.aggregate` add:

```js
  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**

```bash
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:

```css
: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:

```markdown
- `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**

```bash
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.
