diff --git a/docs/observer/dashboard-core.js b/docs/observer/dashboard-core.js index cf9cc9f0..305428d9 100644 --- a/docs/observer/dashboard-core.js +++ b/docs/observer/dashboard-core.js @@ -39,6 +39,74 @@ export function normalizeEpisode(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; diff --git a/tools/brain-dashboard-core.test.mjs b/tools/brain-dashboard-core.test.mjs index ab44977f..ceea5150 100644 --- a/tools/brain-dashboard-core.test.mjs +++ b/tools/brain-dashboard-core.test.mjs @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseEpisodes, normalizeEpisode } from '../docs/observer/dashboard-core.js'; +import { parseEpisodes, normalizeEpisode, attributeNodes } 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' }, @@ -78,3 +78,35 @@ describe('normalizeEpisode', () => { expect(e.skills).toEqual(['superpowers:writing-plans', 'superpowers:test-driven-development']); }); }); + +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____ 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 + }); +});