Files
portal/docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
T
Дмитрий 5222e109e0 fix(observer): hook-resolver — split combined matchers (Edit|Write)
Final-review followup. .claude/settings.json uses regex-style combined
matchers like "Edit|Write"; transcript writes per-tool PreToolUse:Edit.
Split on | when building map so per-tool counts resolve. Also sync
spec doc loadHookMap -> buildHookMap (impl name).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:49:42 +03:00

209 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).