5222e109e0
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>
209 lines
13 KiB
Markdown
209 lines
13 KiB
Markdown
# 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).
|