Spec terminology aligned with codebase: recommended_skill → recommended_node (classification-map хранит Tooling IDs `#NN`, не имена skill'ов). Test runner — vitest (npm run test:tools), не node --test. Missed-activations filter тоже поднимается до >=2. 5 atomic TDD commits: hook-resolver, recommended-node, parser+smoke, analyzer factor-axis, brain-retro template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 KiB
Observer parser — skill/hook expand (schema v3) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Расширить observer-парсер двумя полями для дисциплинарного анализа: имена хук-скриптов (reverse-lookup .claude/settings.json) и recommended_node для direct-эпизодов (из classification-map). Forward-only schema v2 → v3.
Architecture: Два новых pure-модуля (observer-hook-resolver.mjs, observer-recommended-node.mjs) + ~15 LoC delta в observer-transcript-parser.mjs + минимальная правка brain-retro-analyzer.mjs (фильтр >= 2, +1 factor-ось) + missed-activations.mjs (фильтр < 2) + новая секция в brain-retro aggregation-template.
Tech Stack: Node.js ES modules (.mjs), pure (no exec, no fs side-effects per Security Guidance #40), vitest для тестов через npm run test:tools (config app/vitest.config.tools.mjs), Node node:crypto для SHA-fallback.
Spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
Pre-flight (обязательно перед стартом, Pravila §15.2)
- Pre-flight sync
git fetch && git log HEAD..origin/main --oneline
Expected: пусто, либо ясный понятный список коммитов параллельной сессии. Если в списке есть docs/observer/, tools/observer-*, tools/brain-retro-*, tools/missed-activations*, .claude/skills/brain-retro/ — СТОП, мерджить/ребейзить сначала.
- Branch + worktree note
Текущая ветка проверяется заказчиком. План рассчитан на ту же ветку, в которой коммитнут spec (feat/supplier-group-sync-fix или последующая). Каждый Task = один atomic commit, не push'им внутри плана.
File Structure
- Create:
tools/observer-hook-resolver.mjs(~80 LoC) — pure resolver matcher → script names. - Create:
tools/observer-hook-resolver.test.mjs— 8 vitest cases. - Create:
tools/observer-recommended-node.mjs(~30 LoC) — pure: classification → first live node ID. - Create:
tools/observer-recommended-node.test.mjs— 5 vitest cases. - Modify:
tools/observer-transcript-parser.mjs— ~15 LoC delta (import + extractProcessEvents расширение + parseTranscript primary_rationalerecommended_node+ bumpschema_version: 2 → 3). - Modify:
tools/observer-transcript-parser.test.mjs— +3 case (hook scripts, direct recommended, skill no-recommended). - Modify:
tools/brain-retro-analyzer.mjs— строка 202 фильтр=== 2 → >= 2; добавитьrecommended_node_for_directвFACTOR_FNS. - Modify:
tools/brain-retro-analyzer.test.mjs— +1 case (mix v2 + v3). - Modify:
tools/missed-activations.mjs— строка 22 фильтр!== 2 → < 2(чтобы v3 тоже попадал). - Modify:
tools/missed-activations.test.mjs— +1 case (v3 episode). - Modify:
.claude/skills/brain-retro/references/aggregation-template.md— +Hook script breakdown section + Missed Activations note проrecommended_node. - Modify:
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md— добавить cross-ref note внизу: «schema v3 → 2026-05-23-observer-parser-skill-hook-expand-design.md».
Task 1: observer-hook-resolver.mjs + tests
Files:
-
Create:
tools/observer-hook-resolver.mjs -
Create:
tools/observer-hook-resolver.test.mjs -
Step 1.1: Создать failing test file
Create tools/observer-hook-resolver.test.mjs:
import { describe, it, expect } from 'vitest';
import { buildHookMap, resolveScriptCounts, extractScriptName } from './observer-hook-resolver.mjs';
describe('extractScriptName', () => {
it('extracts tools/X.mjs from "node tools/observer-stop-hook.mjs"', () => {
expect(extractScriptName('node tools/observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs');
});
it('extracts tools/X.mjs from quoted path with cwd', () => {
expect(extractScriptName('node "C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs"'))
.toBe('tools/subagent-prompt-prefix.mjs');
});
it('extracts npx package name', () => {
expect(extractScriptName('npx -y markdownlint-cli2 --fix file.md')).toBe('markdownlint-cli2');
});
it('falls back to inline:<sha-16> for node -e inline scripts', () => {
const result = extractScriptName('node -e "const f=process.env.X; if(f) process.stderr.write(\'warn\');"');
expect(result).toMatch(/^inline:[0-9a-f]{16}$/);
});
it('inline fallback is stable across whitespace formatting', () => {
const a = extractScriptName('node -e "const f = 1;\n\nif(f) process.exit(0);"');
const b = extractScriptName('node -e "const f = 1; if(f) process.exit(0);"');
expect(a).toBe(b);
});
it('inline fallback differs for different commands', () => {
const a = extractScriptName('node -e "process.exit(0);"');
const b = extractScriptName('node -e "process.exit(1);"');
expect(a).not.toBe(b);
});
});
describe('buildHookMap', () => {
it('returns empty Map for empty settings', () => {
expect(buildHookMap({}).size).toBe(0);
});
it('handles missing hooks key', () => {
expect(buildHookMap({ permissions: {} }).size).toBe(0);
});
it('builds matcher → [scripts] for single-matcher single-script', () => {
const settings = {
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/foo.mjs' }] },
],
},
};
const map = buildHookMap(settings);
expect(map.get('PreToolUse:Bash')).toEqual(['tools/foo.mjs']);
});
it('aggregates multiple scripts per matcher', () => {
const settings = {
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [
{ type: 'command', command: 'node tools/foo.mjs' },
{ type: 'command', command: 'node tools/bar.mjs' },
]},
],
},
};
expect(buildHookMap(settings).get('PreToolUse:Bash')).toEqual(['tools/foo.mjs', 'tools/bar.mjs']);
});
it('uses event name without matcher for UserPromptSubmit-style hooks', () => {
const settings = {
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: 'node tools/economy.mjs' }] },
],
},
};
expect(buildHookMap(settings).get('UserPromptSubmit')).toEqual(['tools/economy.mjs']);
});
it('merges project + user settings (project takes precedence on dup matcher)', () => {
const project = {
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/a.mjs' }] }] },
};
const user = {
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/b.mjs' }] }] },
};
const map = buildHookMap(project, user);
// both contribute; project listed first
expect(map.get('PreToolUse:Bash')).toEqual(['tools/a.mjs', 'tools/b.mjs']);
});
});
describe('resolveScriptCounts', () => {
it('returns {} for empty matcherCounts', () => {
expect(resolveScriptCounts({}, new Map())).toEqual({});
});
it('returns {} when matcher not in map', () => {
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, new Map())).toEqual({});
});
it('duplicates count for each script on the matcher', () => {
const map = new Map([['PreToolUse:Bash', ['tools/a.mjs', 'tools/b.mjs']]]);
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, map)).toEqual({
'tools/a.mjs': 5,
'tools/b.mjs': 5,
});
});
it('sums across multiple matchers that share a script', () => {
const map = new Map([
['PreToolUse:Bash', ['tools/x.mjs']],
['PostToolUse:Bash', ['tools/x.mjs']],
]);
expect(resolveScriptCounts({ 'PreToolUse:Bash': 3, 'PostToolUse:Bash': 2 }, map))
.toEqual({ 'tools/x.mjs': 5 });
});
});
- Step 1.2: Run test — verify it fails
npm run test:tools -- observer-hook-resolver
Expected: FAIL — "Failed to load url ./observer-hook-resolver.mjs" or similar.
- Step 1.3: Write implementation
Create tools/observer-hook-resolver.mjs:
#!/usr/bin/env node
/**
* Hook resolver for the brain governance observer.
* Reverse-lookup .claude/settings.json (+ ~/.claude/settings.json):
* matcher (event:tool) → list of hook-script names.
*
* Pure — no exec, no fs side-effects (Security Guidance #40).
* Caller is responsible for reading the JSON; this module operates on
* already-parsed settings objects.
*
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
*/
import { createHash } from 'node:crypto';
const TOOL_SCRIPT_RE = /(?:^|[\s"'])(tools\/[\w-]+\.(?:mjs|py|sh))/;
const NPX_RE = /(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)/;
/**
* Normalize a command string for stable hashing:
* - strip surrounding whitespace
* - collapse internal whitespace runs to single space
* No lowercase (script names are case-sensitive in Windows-aware contexts).
*/
function normalizeCommand(s) {
return String(s || '').trim().replace(/\s+/g, ' ');
}
/**
* Extract a stable, human-readable identifier from a hook command string.
* Priority: tools/X.{mjs,py,sh} → npx <pkg> → inline:<sha-16>.
*/
export function extractScriptName(command) {
const cmd = String(command || '');
const toolMatch = cmd.match(TOOL_SCRIPT_RE);
if (toolMatch) return toolMatch[1];
const npxMatch = cmd.match(NPX_RE);
if (npxMatch) return npxMatch[1];
const sha = createHash('sha256').update(normalizeCommand(cmd)).digest('hex').slice(0, 16);
return `inline:${sha}`;
}
/**
* Build matcher → [scriptName, ...] from one or two settings objects.
* Matcher key format:
* - "<event>:<tool>" when entry has `matcher` (e.g. "PreToolUse:Bash")
* - "<event>" when entry has no `matcher` (UserPromptSubmit, SessionStart)
*
* Project settings listed before user settings on shared matchers.
*/
export function buildHookMap(projectSettings = {}, userSettings = {}) {
const map = new Map();
for (const settings of [projectSettings, userSettings]) {
const hooks = settings && settings.hooks;
if (!hooks || typeof hooks !== 'object') continue;
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const matcher = entry.matcher ? `${event}:${entry.matcher}` : event;
const scripts = Array.isArray(entry.hooks) ? entry.hooks : [];
const existing = map.get(matcher) || [];
for (const h of scripts) {
if (!h || h.type !== 'command') continue;
existing.push(extractScriptName(h.command));
}
map.set(matcher, existing);
}
}
}
return map;
}
/**
* Given matcher counts (from parser hook_fired.counts) and a hook map,
* return per-script counts. Each script's count = sum over matchers that
* include it of matcherCounts[matcher]. Matchers not in map are skipped
* silently (their counts remain reflected in the original `counts` field).
*/
export function resolveScriptCounts(matcherCounts, hookMap) {
const result = {};
for (const [matcher, count] of Object.entries(matcherCounts || {})) {
const scripts = hookMap.get(matcher);
if (!scripts || scripts.length === 0) continue;
for (const script of scripts) {
result[script] = (result[script] || 0) + count;
}
}
return result;
}
- Step 1.4: Run test — verify it passes
npm run test:tools -- observer-hook-resolver
Expected: PASS — all describe/it green.
- Step 1.5: Commit
git add tools/observer-hook-resolver.mjs tools/observer-hook-resolver.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): hook-resolver — matcher → script names (schema v3 prep)
Pure module. buildHookMap(project, user) reverse-lookup settings.json,
resolveScriptCounts duplicates counts per script. No exec.
EOF
)"
Task 2: observer-recommended-node.mjs + tests
Files:
-
Create:
tools/observer-recommended-node.mjs -
Create:
tools/observer-recommended-node.test.mjs -
Step 2.1: Создать failing test file
Create tools/observer-recommended-node.test.mjs:
import { describe, it, expect } from 'vitest';
import { recommendNode } from './observer-recommended-node.mjs';
const MAP = {
feature: ['#19'],
refactor: ['#11', '#12', '#43'],
question: [],
other: [],
};
describe('recommendNode', () => {
it('returns first live node ID for a known classification', () => {
expect(recommendNode('feature', MAP, { '#19': false })).toBe('#19');
});
it('skips dormant first node, returns next live', () => {
expect(recommendNode('refactor', MAP, { '#11': true, '#12': false, '#43': false })).toBe('#12');
});
it('returns null when all recommended nodes are dormant', () => {
expect(recommendNode('refactor', MAP, { '#11': true, '#12': true, '#43': true })).toBeNull();
});
it('returns null for classification absent from map', () => {
expect(recommendNode('nonexistent', MAP, {})).toBeNull();
});
it('returns null for empty-array classification (question/memory-sync)', () => {
expect(recommendNode('question', MAP, {})).toBeNull();
expect(recommendNode('other', MAP, {})).toBeNull();
});
it('treats missing dormancy entry as live (defensive, parity with missed-activations)', () => {
// missed-activations uses dormancy[id] === false; recommendNode mirrors:
// unknown/missing → not live (paranoid — only positive false counts as live).
expect(recommendNode('feature', MAP, {})).toBeNull();
});
it('handles null/undefined inputs without throwing', () => {
expect(recommendNode(null, MAP, {})).toBeNull();
expect(recommendNode('feature', null, {})).toBeNull();
expect(recommendNode('feature', MAP, null)).toBeNull();
});
});
- Step 2.2: Run test — verify it fails
npm run test:tools -- observer-recommended-node
Expected: FAIL — module not found.
- Step 2.3: Write implementation
Create tools/observer-recommended-node.mjs:
#!/usr/bin/env node
/**
* Recommended-node resolver for direct episodes.
* Pure — read-only, no exec, no fs (Security Guidance #40).
*
* For an episode classified as `taskClassification` with node_chosen='direct',
* return the first live (non-dormant) recommended node ID from the
* classification map. Mirrors missed-activations.mjs dormancy logic:
* dormancy[id] === false strictly (missing/true → not live).
*
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
*/
export function recommendNode(taskClassification, classificationMap, dormancy) {
if (!taskClassification || !classificationMap || !dormancy) return null;
const recommended = classificationMap[taskClassification];
if (!Array.isArray(recommended) || recommended.length === 0) return null;
for (const id of recommended) {
if (dormancy[id] === false) return id;
}
return null;
}
- Step 2.4: Run test — verify it passes
npm run test:tools -- observer-recommended-node
Expected: PASS.
- Step 2.5: Commit
git add tools/observer-recommended-node.mjs tools/observer-recommended-node.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): recommended-node resolver for direct episodes
Mirrors missed-activations dormancy logic (id === false strict).
First live recommended node from classification-map, else null.
EOF
)"
Task 3: parser extension + smoke
Files:
-
Modify:
tools/observer-transcript-parser.mjs -
Modify:
tools/observer-transcript-parser.test.mjs -
Step 3.1: Прочитать существующий test-файл и понять стиль фикстур
head -100 tools/observer-transcript-parser.test.mjs
Идентифицировать существующие фабрики (makeUserPrompt, makeAssistantMsg, или подобные) — переиспользовать.
- Step 3.2: Добавить 3 failing tests
В tools/observer-transcript-parser.test.mjs (append к существующему describe('parseTranscript', ...) блоку, или новый describe block):
import fs from 'node:fs';
import path from 'node:path';
import { tmpdir } from 'node:os';
describe('parseTranscript v3 fields', () => {
// helper: minimal valid transcript with one user prompt + one assistant + tool_use Skill
// Adapt to existing fixture pattern in this file — fallback below if no helper exists.
function transcriptWithSkill(skillName) {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь endpoint /api/foo' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', id: 't-1', name: 'Skill', input: { skill: skillName } },
],
},
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
].join('\n');
}
function transcriptDirectFeature() {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'добавь новый endpoint /api/foo' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
].join('\n');
}
function transcriptWithHookAttachment() {
return [
JSON.stringify({
type: 'user',
message: { role: 'user', content: 'ls' },
timestamp: '2026-05-23T10:00:00Z',
uuid: 'u-1',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 't-1', name: 'Bash', input: { command: 'ls' } }],
},
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-2',
sessionId: 'sess-1',
}),
JSON.stringify({
type: 'attachment',
attachment: { type: 'hook_success', hookName: 'PreToolUse:Bash', hookEvent: 'PreToolUse' },
timestamp: '2026-05-23T10:00:01Z',
uuid: 'u-3',
sessionId: 'sess-1',
}),
].join('\n');
}
it('emits schema_version: 3', () => {
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
expect(ep.schema_version).toBe(3);
});
it('sets recommended_node for direct feature-classified episode', () => {
// Inject a tiny classification map + dormancy via module mock or by
// relying on the real files. Simpler: read real files; expect '#19'.
// (If parser uses dependency injection, prefer that.)
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
expect(ep.primary_rationale.recommended_node).toBe('#19');
});
it('recommended_node is null when a skill was invoked', () => {
const ep = parseTranscript(transcriptWithSkill('superpowers:writing-plans'), 'sess-1');
expect(ep.primary_rationale.recommended_node).toBeNull();
});
it('hook_fired event includes both counts and scripts keys', () => {
const ep = parseTranscript(transcriptWithHookAttachment(), 'sess-1');
const hookEvent = ep.events.find((e) => e.kind === 'hook_fired');
expect(hookEvent).toBeDefined();
expect(hookEvent.counts).toBeDefined();
expect(hookEvent.scripts).toBeDefined();
expect(typeof hookEvent.scripts).toBe('object');
});
});
NB: если существующий test-файл уже импортирует parseTranscript — переиспользовать. Иначе добавить import { parseTranscript } from './observer-transcript-parser.mjs';.
- Step 3.3: Run tests — verify they fail
npm run test:tools -- observer-transcript-parser
Expected: 4 FAIL — schema_version === 3, recommended_node field absent, hookEvent.scripts absent.
- Step 3.4: Modify parser
В tools/observer-transcript-parser.mjs:
Patch 1 (top, after existing imports):
import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs';
import { recommendNode } from './observer-recommended-node.mjs';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
let HOOK_MAP = null;
function getHookMap() {
if (HOOK_MAP) return HOOK_MAP;
const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; } };
HOOK_MAP = buildHookMap(read('.claude/settings.json'), read(join(homedir(), '.claude/settings.json')));
return HOOK_MAP;
}
let CLASSIFICATION_MAP = null;
function getClassificationMap() {
if (CLASSIFICATION_MAP) return CLASSIFICATION_MAP;
try {
CLASSIFICATION_MAP = JSON.parse(readFileSync('tools/observer-classification-map.json', 'utf-8')).map || {};
} catch { CLASSIFICATION_MAP = {}; }
return CLASSIFICATION_MAP;
}
let DORMANCY = null;
function getDormancy() {
if (DORMANCY) return DORMANCY;
try { DORMANCY = JSON.parse(readFileSync('tools/.node-dormancy.json', 'utf-8')); }
catch { DORMANCY = {}; }
return DORMANCY;
}
Patch 2 (extractProcessEvents — replace the hook_fired emit block):
Locate:
if (Object.keys(hookCounts).length > 0) {
events.push({ kind: 'hook_fired', counts: hookCounts, errors: hookErrors });
}
Replace with:
if (Object.keys(hookCounts).length > 0) {
const scripts = resolveScriptCounts(hookCounts, getHookMap());
events.push({ kind: 'hook_fired', counts: hookCounts, scripts, errors: hookErrors });
}
Patch 3 (parseTranscript — bump schema_version + add recommended_node):
Locate schema_version: 2, in the returned object — change to schema_version: 3,.
Locate the primary_rationale IIFE return object. Inside that object, after task_classification: classifyTask(prompt), add:
recommended_node:
skills.length === 0
? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy())
: null,
- Step 3.5: Run tests — verify they pass
npm run test:tools -- observer-transcript-parser
Expected: PASS — все 4 новых case + все существующие.
- Step 3.6: Smoke на живом JSONL
node -e "import('./tools/observer-transcript-parser.mjs').then(m => { const c = require('fs').readFileSync('docs/observer/episodes-2026-05.jsonl', 'utf-8'); /* just ensure parser loads and exports are intact */ console.log('parser loaded, parseTranscript=', typeof m.parseTranscript); })"
Expected: parser loaded, parseTranscript= function — без throw.
Note: парсер потребляет transcript-формат (~/.claude/projects/.../*.jsonl), не output-формат (docs/observer/episodes-*.jsonl). Smoke лишь проверяет, что модуль грузится с новыми импортами. Полная end-to-end проверка — следующий Stop-хук на реальной сессии.
- Step 3.7: Run full tools test suite — regression check
npm run test:tools
Expected: all green, including observer-of-observer, observer-coverage-checker, missed-activations, и т.д. (missed-activations пока ещё фильтрует !== 2 — отдельная задача 4, регрессия не ожидается).
- Step 3.8: Commit
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): parser v3 — hook_fired.scripts + recommended_node
schema_version 2 → 3. hook_fired event now carries `scripts` map
(reverse-lookup .claude/settings.json + user). primary_rationale gets
`recommended_node` (Tooling node ID) for direct episodes via
classification-map + dormancy. Existing `counts`/skill paths unchanged
— backward-compat preserved.
EOF
)"
Task 4: analyzer >=2 + factor axis + missed-activations <2
Files:
-
Modify:
tools/brain-retro-analyzer.mjs -
Modify:
tools/brain-retro-analyzer.test.mjs -
Modify:
tools/missed-activations.mjs -
Modify:
tools/missed-activations.test.mjs -
Step 4.1: Добавить failing tests
В tools/brain-retro-analyzer.test.mjs (append):
describe('analyze: schema_version filter', () => {
it('accepts both v2 and v3 episodes', () => {
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
const v3 = { ...v2, schema_version: 3, primary_rationale: { ...v2.primary_rationale, recommended_node: '#19' } };
const result = analyze([v2, v3]);
expect(result.episodeCount).toBe(2);
});
it('factorMatrix has recommended_node_for_direct axis', () => {
const v3 = { schema_version: 3, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
const result = analyze([v3]);
expect(result.factorMatrix.recommended_node_for_direct).toBeDefined();
expect(result.factorMatrix.recommended_node_for_direct['#19']).toBeDefined();
});
it('v2 episode bucket=none in recommended_node_for_direct', () => {
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
const result = analyze([v2]);
expect(result.factorMatrix.recommended_node_for_direct.none).toBeDefined();
});
});
В tools/missed-activations.test.mjs (append):
it('detects missed activation on v3 episode', () => {
const v3 = { schema_version: 3, primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' } };
const result = detectMissedActivations([v3], { feature: ['#19'] }, { '#19': false });
expect(result.totalMissed).toBe(1);
});
- Step 4.2: Run tests — verify they fail
npm run test:tools -- brain-retro-analyzer missed-activations
Expected: FAIL — recommended_node_for_direct missing; v3 not counted.
- Step 4.3: Modify analyzer
В tools/brain-retro-analyzer.mjs строка 202:
const normal = allNormal.filter((e) => e.schema_version === 2);
→
const normal = allNormal.filter((e) => e.schema_version >= 2);
В FACTOR_FNS (object literal): добавить запись после task_classification:
recommended_node_for_direct: (e) => (e.primary_rationale || {}).recommended_node || 'none',
- Step 4.4: Modify missed-activations
В tools/missed-activations.mjs строка 22:
if (e.schema_version !== 2) continue;
→
if (typeof e.schema_version !== 'number' || e.schema_version < 2) continue;
Update doc-комментарий выше (строки 7-12), пункт 1:
* 1. schema_version >= 2 (v1 lacks factor data)
- Step 4.5: Run tests — verify they pass
npm run test:tools -- brain-retro-analyzer missed-activations
Expected: PASS.
- Step 4.6: Full regression
npm run test:tools
Expected: all green.
- Step 4.7: Smoke на живом JSONL
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | head -40
Expected: JSON output, ненулевой episodeCount, factorMatrix.recommended_node_for_direct присутствует (даже если только 'none' bucket — все эпизоды v2).
- Step 4.8: Commit
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs \
tools/missed-activations.mjs tools/missed-activations.test.mjs
git commit -m "$(cat <<'EOF'
feat(observer): analyzer >=2 + recommended_node_for_direct factor axis
brain-retro-analyzer accepts schema_version >= 2 (v2+v3 mix).
FACTOR_FNS +recommended_node_for_direct ('none' bucket for v2).
missed-activations also raised to >= 2.
EOF
)"
Task 5: brain-retro template + spec cross-ref
Files:
-
Modify:
.claude/skills/brain-retro/references/aggregation-template.md -
Modify:
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md -
Step 5.1: Расширить aggregation-template.md
В .claude/skills/brain-retro/references/aggregation-template.md после секции «Top nodes used (from skill_invoked events)» добавить:
## Hook script breakdown (from `hook_fired.scripts`, schema v3+)
Per-script counts across the period. Surfaces which discipline-enforcing hooks fired (and which silently failed to fire). Aggregate from `events[].hook_fired.scripts` of v3 episodes — v2 episodes have only matcher-level `counts` and contribute nothing here.
| script | times fired | notes |
|---|---|---|
| `tools/observer-stop-hook.mjs` | N | should fire once per turn — gaps = observer drop |
| `tools/subagent-prompt-prefix.mjs` | N | once per Task-tool call |
| `inline:<sha-16>` | N | inline `node -e "..."` — see settings.json for body |
**Discipline highlights:**
- `tools/observer-stop-hook.mjs` count < turn count → observer skipped turns; cross-check `observerErrorCount` and STATUS.md C5.
- `tools/subagent-prompt-prefix.mjs` count vs `Agent` tool_use count — mismatch = missing pre-flight injection.
- Inline `claude-md`/`schema.sql` guards — fired iff someone touched those files.
## Recommended-node candidates (from `primary_rationale.recommended_node`, schema v3+)
Distinct from `missedActivations` (which aggregates): this is the per-episode signal embedded in each direct episode.
| recommended_node | times direct | top classifications |
|---|---|---|
| #19 | N | feature, planning |
| none (v2 or no recommendation) | N | — |
Cross-reference with `factorMatrix.recommended_node_for_direct` and `missedActivations.byNode`. A persistent (#NN, count > threshold) — strong missed-activation pattern, candidate for retro discussion.
- Step 5.2: Расширить Missed Activations section в template
В существующей секции «Missed Activations (Pravila §16.4 v1.36)», в конце добавить:
**Schema v3 NB:** since 2026-05-23, each direct episode carries `primary_rationale.recommended_node` directly. The analyzer's `missedActivations` aggregates these into `byNode`/`byClassification`. For per-episode forensics (which prompt, which session), grep episodes-*.jsonl on `"recommended_node":"#NN"`.
- Step 5.3: Cross-ref note в factor-analysis spec
В конце docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md добавить (если нет секции «Amendments» — создать):
## Amendments
### 2026-05-23 — schema v3 (parser skill/hook expand)
Spec extension: forward-only bump `schema_version` 2 → 3. Two new fields:
- `events[].hook_fired.scripts: { script_name: count, ... }` — reverse-lookup `.claude/settings.json` → имена хук-скриптов. Old `counts` (matcher level) preserved для backward-compat.
- `primary_rationale.recommended_node: "#NN" | null` — для direct-эпизодов derived из `classification-map` + dormancy. null при использованном skill / отсутствии рекомендации / всех dormant.
Analyzer фильтр `schema_version === 2` → `>= 2`; `missed-activations` фильтр `!== 2` → `< 2`. FACTOR_FNS +recommended_node_for_direct.
Полный spec: `docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md`.
- Step 5.4: Markdownlint + cspell
npx markdownlint-cli2 --fix \
.claude/skills/brain-retro/references/aggregation-template.md \
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
Если cspell ругнётся на новые термины — добавить в cspell-words.txt.
- Step 5.5: Commit
git add .claude/skills/brain-retro/references/aggregation-template.md \
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md \
cspell-words.txt
git commit -m "$(cat <<'EOF'
docs(observer): brain-retro template +hook breakdown + recommended_node
aggregation-template.md gets two new sections (Hook script breakdown,
Recommended-node candidates). factor-analysis spec gets a v3 amendment
cross-ref to the 2026-05-23 spec.
EOF
)"
Self-Review checklist (после Task 5, перед handoff)
- Spec coverage: Каждая секция spec'а покрыта?
- hook-resolver → Task 1 ✓
- recommended-node → Task 2 ✓
- parser extension + schema v3 + smoke → Task 3 ✓
- analyzer >=2 + factor axis + missed-activations <2 → Task 4 ✓
- template + cross-ref → Task 5 ✓
- Pravila §15.2 Pre-flight sync — Task 0 ✓
- Security Guidance #40 — no exec/execSync — все 3 модуля + parser delta используют только readFileSync + JSON.parse + regex ✓
- Type consistency:
recommendNode(camelCase) везде;recommended_node(snake_case) в episode/spec — паттернrecommendNode → recommended_nodeconsistent ✓
Risks during execution
| Risk | Mitigation |
|---|---|
| Existing parser test использует custom helper, не описанный здесь | Step 3.1 — прочитать существующий тест-файл, переиспользовать helper. Fallback transcript-фикстуры в Step 3.2 — самодостаточные. |
recommendNode через DI vs file-read — тест из Step 3.2 ожидает реальный classification-map |
Тест использует реальный tools/observer-classification-map.json (он стабилен и commited). feature: ['#19'] — это факт, проверено в Step 0 exploration. Dormancy .node-dormancy.json — #19 non-dormant. |
lefthook pre-commit может ругнуться на .mjs (eslint-vue ignorePaths) |
tools/*.mjs уже исключены в lefthook конфиге (прецедент: 32 существующих .mjs скрипта). Если ругнётся — проверить lefthook конфиг до коммита. |
Изменение missed-activations фильтра ломает существующие missed-activation тесты |
Тесты в missed-activations.test.mjs используют schema_version: 2 явно — >= 2 для них тоже true. Backward-compat preserved. |
| Параллельная Claude-сессия трогает те же файлы | Pre-flight Task 0; если detected — STOP, ребейз/мердж. |
Execution
Plan complete and saved to docs/superpowers/plans/2026-05-23-observer-parser-skill-hook-expand.md. Two execution options:
1. Subagent-Driven (recommended) — controller (этот session) dispatches Sonnet/Opus subagent per Task (Pravila §15.1), reviews commit between Tasks; fast iteration, isolated context per subagent.
2. Inline Execution — Tasks 1-5 в этой же session через executing-plans skill; batch с checkpoint между Tasks.
Какой подход?