// 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 }; } 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 }; }