# Observer parser — раскрытие skill/hook полей (schema v3)

**Дата:** 2026-05-23
**Связано:** ADR-011 (brain governance), spec `2026-05-19-observer-factor-analysis-design.md`, Pravila §16, [[feedback_feature_via_writing_plans]]
**Schema bump:** v2 → v3 (forward-only, прошлые v2 не правятся)

## Проблема

В `/brain-retro` факторном анализе самые интересные для дисциплины поля сейчас не раскрываются:

1. **Какой именно хук-скрипт сработал** — в `events[].hook_fired.counts` лежат только matcher-имена (`PreToolUse:Bash:8`). Имя файла-скрипта (`tools/subagent-prompt-prefix.mjs`, `tools/observer-stop-hook.mjs`, inline-хуки) не записывается, потому что Claude Code в transcript пишет `attachment.hookName = "PreToolUse:Bash"` (matcher), не имя файла. Один matcher может запускать несколько скриптов — все они невидимы по отдельности.

2. **Какой узел был бы рекомендован для `direct`-эпизода** — `node_chosen: 'direct'` для большинства эпизодов; читателю retro-сводки приходится отдельно сверяться с `missedActivations`, чтобы понять, был ли промах роутинга. В самом эпизоде явного сигнала нет.

Заказчик: «допили парсер — это отдельная задача» (23.05.2026, после A1/A2/B1/D1 retro-кандидатов).

## Решение

Три pure-модуля + минимальное расширение парсера. Forward-only — прошлые v2 эпизоды остаются как есть; analyzer фильтр расширяется до `schema_version >= 2`.

### Компоненты

**`tools/observer-hook-resolver.mjs`** (новый, ~80 LoC, pure)

```js
export function buildHookMap({ projectSettings, userSettings } = {})
  // Map<matcher, string[]>
  // matcher: "PreToolUse:Bash" | "UserPromptSubmit" | "SessionStart:startup" | ...
  // value:   ["tools/observer-stop-hook.mjs", "inline:claude-md-guard-7f3a", ...]

export function resolveScriptCounts(matcherCounts, hookMap)
  // { "tools/observer-stop-hook.mjs": 1, "inline:claude-md-guard-7f3a": 8, ... }
```

Чтение `.claude/settings.json` (project) + `~/.claude/settings.json` (user) через `readFileSync` + `JSON.parse` в try/catch. Битый/отсутствующий файл → пустой map (parser fallback на matcher-only). Кэш в module-scope (per-process).

**Извлечение имени скрипта из `command` string** (приоритет сверху):

1. Regex `(?:^|[\s"'])(tools\/[\w-]+\.(?:mjs|py|sh))` → имя файла.
2. Regex `(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)` → имя npm-пакета.
3. Fallback `inline:<sha256(normalize(command)).slice(0,16)>` — стабильный для inline-хуков. `normalize(s)` = strip surrounding whitespace + collapse internal whitespace runs до одного пробела. Без lowercase (имена скриптов case-sensitive в Windows-окружении).

Если на один matcher навешано N скриптов — все возвращаются. Counts удваиваются: matcher `PreToolUse:Bash:8` с двумя скриптами → каждый скрипт получает счёт 8 (фактическое поведение — каждый скрипт исполняется на каждый matcher hit).

**`tools/observer-recommended-node.mjs`** (новый, ~30 LoC, pure)

```js
export function recommendNode(taskClassification, classificationMap, dormancy = {})
  // → "#19" | null   (Tooling Прил.Н node ID, точно как в classification-map)
```

Источник правил — существующий `tools/observer-classification-map.json` (уже используется в `missed-activations.mjs`). Возвращает первый live (non-dormant) узел из `map[classification]` — формат **точно как в map** (`"#19"`, `"#43"`, и т.д., Tooling ID). Dormancy фильтрует DEFERRED через `dormancy[id] === false` (буквально false — паттерн из `missed-activations.mjs`, `tools/.node-dormancy.json` нормализуется `extract-node-dormancy.mjs`).

**`tools/observer-transcript-parser.mjs`** (расширение, ~15 LoC delta)

```js
// extractProcessEvents — после построения hookCounts:
const scriptCounts = resolveScriptCounts(hookCounts, getHookMap());
events.push({
  kind: 'hook_fired',
  counts: hookCounts,      // ← старое поле, сохраняется для backward-compat
  scripts: scriptCounts,   // ← new
  errors: hookErrors,
});

// parseTranscript — primary_rationale:
recommended_node: skills.length === 0
  ? recommendNode(classifyTask(prompt), classificationMap, dormancy)
  : null,
```

Stop-хук (`observer-stop-hook.mjs`) не трогаем — он только вызывает parser.

### Schema v3

```jsonc
{
  "schema_version": 3,
  ...
  "primary_rationale": {
    "step": 1,
    "node_chosen": "direct",
    "recommended_node": "#19",  // ← new, Tooling node ID или null (если skill использован / нет рекомендации / все рекомендованные dormant)
    ...
  },
  "events": [
    {
      "kind": "hook_fired",
      "counts":  { "PreToolUse:Bash": 8, "PostToolUse:Bash": 4, ... },
      "scripts": { "tools/subagent-prompt-prefix.mjs": 1, "inline:claude-md-guard-7f3a": 8, ... },
      "errors": 0
    }
  ]
}
```

### Analyzer

**`tools/brain-retro-analyzer.mjs`:**

- Фильтр строка 202: `e.schema_version === 2` → `e.schema_version >= 2`. v3 без `recommended_node` ≡ v2.
- `FACTOR_FNS` +1 ось: `recommended_node_for_direct: (e) => e.primary_rationale?.recommended_node ?? 'none'`. Эпизоды v2 автоматом попадают в bucket `'none'` — корректно.

NB: `missed-activations.mjs` сейчас фильтрует `e.schema_version !== 2` (строка 22) — поднять до `< 2`, чтобы v3 тоже попадал в детектор.

### Brain-retro template

**`.claude/skills/brain-retro/references/aggregation-template.md`:**

- Новая секция «Hook script breakdown» — топ-10 скриптов с count'ами + выделение discipline-enforcing (skill-discipline / economy-mode / subagent-prefix / claude-md-guard).
- Расширить «Missed Activations» — теперь рядом с агрегированным `missedActivations.byNode` сводка из самих эпизодов: `direct + recommended_node != null` → явный сигнал в каждом таком эпизоде.

## Data flow

```
Stop-hook → parser.parseTranscript(transcriptText)
            ├─ collectToolUse → skills[], counts, errors
            ├─ extractProcessEvents
            │    ├─ hookCounts (matcher) ← из attachment.hookName
            │    ├─ resolveScriptCounts(hookCounts, hookResolver.buildHookMap())
            │    └─ event hook_fired = { counts, scripts, errors }
            └─ primary_rationale
                 └─ recommended_node = skills.length === 0
                      ? recommendNode(classifyTask(prompt), classificationMap, dormancy)
                      : null
            → episode v3 → append docs/observer/episodes-YYYY-MM.jsonl

/brain-retro → analyzer.analyze(episodes, { classificationMap, dormancy })
               ├─ filter schema_version >= 2
               ├─ buildFactorMatrix + recommended_node_for_direct axis
               └─ → aggregation-template renders Hook script breakdown
```

## Error handling

| Случай | Поведение |
|---|---|
| `.claude/settings.json` отсутствует / битый JSON | resolver возвращает пустой map, parser fallback на matcher-only counts, `scripts: {}` |
| Regex не зацепил имя скрипта в command | fallback `inline:<sha-16>`, стабильный к форматированию |
| `classification-map.json` отсутствует / пустой массив для классификации | `recommendNode` возвращает null; parser fallback `recommended_node: null` |
| Classification не в map (`other`, `question`, `memory-sync`) или пустой массив | `recommendNode` возвращает null — корректное «нет рекомендации» |
| Все рекомендованные узлы dormant | `recommendNode` возвращает null |
| v2 эпизод без `scripts`/`recommended_node` | analyzer считает как v2 (bucket `'none'` для новой оси) — backward-compat сохранён |

## Security Guidance #40

- Pure parsing, no `exec`/`execSync` (consistent с `observer-transcript-parser.mjs:14` shebang).
- Resolver читает только `.claude/settings.json` (+ `~/.claude/settings.json`) — known paths.
- Регекс на `command` string — без eval.
- SHA-fallback — `node:crypto` `createHash('sha256')`, no shell.

## Testing (TDD)

Прецедент: все `observer-*-detector.mjs` имеют парный `.test.mjs` через `vitest` (config `app/vitest.config.tools.mjs`, скрипт `npm run test:tools`).

**`observer-hook-resolver.test.mjs`:**

- parse project-only / user-only / merged settings.json
- matcher с одним хуком / с несколькими (counts удваиваются)
- command как `node tools/X.mjs` / `node -e "..."` / `npx -y pkg` / неизвестный → fallback
- битый/отсутствующий settings.json → пустой map, без throw
- `resolveScriptCounts({}, map)` → `{}`

**`observer-recommended-node.test.mjs`:**

- классификация в map → первый live ID (например `"#19"` для `feature`)
- классификация в map, первый узел dormant → следующий live или null
- классификация не в map (`other`) → null
- классификация в map с пустым массивом (`question`, `memory-sync`) → null

**`observer-transcript-parser.test.mjs` (+3 case):**

- turn с hook-attachments → `hook_fired.scripts` непустой, `hook_fired.counts` сохранён
- direct-эпизод с feature-prompt → `recommended_node === '#19'`
- skill-эпизод → `recommended_node === null`

**`brain-retro-analyzer.test.mjs` (+1 case):**

- mix v2 + v3 эпизодов → оба считаются

**Regression smoke:**

- Прогнать parser на живом `docs/observer/episodes-2026-05.jsonl` через CLI — 0 throw, все эпизоды parse OK.

## Риски и mitigation

| Риск | Mitigation |
|---|---|
| settings.json меняется между эпизодами → resolver-кэш стейл | Кэш per-process; parser/Stop-хук однопроцессны на эпизод — стейл невозможен. |
| `inline:<sha>` шумит при частом изменении inline-хука | sha от нормализованной (strip whitespace) команды — стабильна к форматированию. |
| Conflict с параллельной правкой `brain-retro-analyzer.mjs` (Pravila §15) | Pre-flight `git fetch && git log HEAD..origin/main` перед началом плана. |
| `recommended_skill` воспринимается как hard-rule | В spec явно: рекомендация ≠ обязанность; missedActivations остаются сигналом, не блоком (Pravila §16.4 v1.36 условное правило). |

## Объём (≈5 TDD commits)

1. `observer-hook-resolver.mjs` + tests
2. `observer-recommended-node.mjs` + tests
3. parser extension + tests + smoke на живом JSONL
4. analyzer filter `>= 2` + новая factor-ось + missed-activations filter `< 2` + tests
5. brain-retro template + retro-skill SKILL.md note + cross-ref note в factor-analysis spec

## Не делаем (out of scope)

- Retrofill прошлых v2 эпизодов (заказчик: forward-only).
- Полный per-script timing/duration (Claude Code stdout его не пишет).
- Explicit discipline-hook booleans (`economy_parser: true` и т.д.) — derived от `scripts` для read, дублирование не нужно.
- Правка Stop-хука / observer-of-observer / coverage-checker — никаких изменений infrastructure.
- Bump Pravila/CLAUDE.md/PSR_v1/Tooling — это инструментальное расширение в `tools/`, нормативка не меняется. Только бамп `schema_version` в spec'е factor-analysis (cross-ref).
