# 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](../specs/2026-05-23-observer-parser-skill-hook-expand-design.md) --- ## Pre-flight (обязательно перед стартом, Pravila §15.2) - [ ] **Pre-flight sync** ```bash 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_rationale `recommended_node` + bump `schema_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`: ```js 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: 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** ```bash 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`: ```js #!/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 → inline:. */ 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: * - ":" when entry has `matcher` (e.g. "PreToolUse:Bash") * - "" 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** ```bash npm run test:tools -- observer-hook-resolver ``` Expected: PASS — all describe/it green. - [ ] **Step 1.5: Commit** ```bash 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`: ```js 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** ```bash npm run test:tools -- observer-recommended-node ``` Expected: FAIL — module not found. - [ ] **Step 2.3: Write implementation** Create `tools/observer-recommended-node.mjs`: ```js #!/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** ```bash npm run test:tools -- observer-recommended-node ``` Expected: PASS. - [ ] **Step 2.5: Commit** ```bash 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-файл и понять стиль фикстур** ```bash 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): ```js 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** ```bash 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):** ```js 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: ```js if (Object.keys(hookCounts).length > 0) { events.push({ kind: 'hook_fired', counts: hookCounts, errors: hookErrors }); } ``` Replace with: ```js 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: ```js recommended_node: skills.length === 0 ? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy()) : null, ``` - [ ] **Step 3.5: Run tests — verify they pass** ```bash npm run test:tools -- observer-transcript-parser ``` Expected: PASS — все 4 новых case + все существующие. - [ ] **Step 3.6: Smoke на живом JSONL** ```bash 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** ```bash npm run test:tools ``` Expected: all green, including `observer-of-observer`, `observer-coverage-checker`, `missed-activations`, и т.д. (missed-activations пока ещё фильтрует `!== 2` — отдельная задача 4, регрессия не ожидается). - [ ] **Step 3.8: Commit** ```bash 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): ```js 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): ```js 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** ```bash 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: ```js const normal = allNormal.filter((e) => e.schema_version === 2); ``` → ```js const normal = allNormal.filter((e) => e.schema_version >= 2); ``` В `FACTOR_FNS` (object literal): добавить запись после `task_classification`: ```js recommended_node_for_direct: (e) => (e.primary_rationale || {}).recommended_node || 'none', ``` - [ ] **Step 4.4: Modify missed-activations** В `tools/missed-activations.mjs` строка 22: ```js if (e.schema_version !== 2) continue; ``` → ```js if (typeof e.schema_version !== 'number' || e.schema_version < 2) continue; ``` Update doc-комментарий выше (строки 7-12), пункт 1: ```js * 1. schema_version >= 2 (v1 lacks factor data) ``` - [ ] **Step 4.5: Run tests — verify they pass** ```bash npm run test:tools -- brain-retro-analyzer missed-activations ``` Expected: PASS. - [ ] **Step 4.6: Full regression** ```bash npm run test:tools ``` Expected: all green. - [ ] **Step 4.7: Smoke на живом JSONL** ```bash 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** ```bash 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)» добавить: ```markdown ## 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:` | 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)», в конце добавить: ```markdown **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» — создать): ```markdown ## 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** ```bash 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** ```bash 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_node` consistent ✓ --- ## 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. **Какой подход?**