# 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:<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**

```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 <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**

```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:<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)», в конце добавить:

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

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