feat(brain): node attribution — episode signals to graph nodes
This commit is contained in:
@@ -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__<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 };
|
||||
}
|
||||
|
||||
export function parseEpisodes(text) {
|
||||
const episodes = [];
|
||||
let skipped = 0;
|
||||
|
||||
@@ -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__<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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user