// 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, }; } // 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____ → 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 }; } // 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; } // 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; }); } // 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 }; } // 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, }; } 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 }; }