feat(brain): node attribution — episode signals to graph nodes

This commit is contained in:
Дмитрий
2026-05-19 15:44:39 +03:00
parent 7fed5bc18b
commit c3392bef13
2 changed files with 101 additions and 1 deletions
+68
View File
@@ -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;
+33 -1
View File
@@ -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
});
});