Files
portal/docs/superpowers/plans/2026-05-23-observer-parser-skill-hook-expand.md
T
Дмитрий 705608b5ad docs(plan): observer parser skill/hook expand — 5-task TDD plan
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>
2026-05-23 13:10:06 +03:00

36 KiB
Raw Blame History

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_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:

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
)"

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_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.

Какой подход?