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>
13 KiB
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 факторном анализе самые интересные для дисциплины поля сейчас не раскрываются:
-
Какой именно хук-скрипт сработал — в
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 может запускать несколько скриптов — все они невидимы по отдельности. -
Какой узел был бы рекомендован для
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)
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 (приоритет сверху):
- Regex
(?:^|[\s"'])(tools\/[\w-]+\.(?:mjs|py|sh))→ имя файла. - Regex
(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)→ имя npm-пакета. - 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)
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)
// 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
{
"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:14shebang). - Resolver читает только
.claude/settings.json(+~/.claude/settings.json) — known paths. - Регекс на
commandstring — без eval. - SHA-fallback —
node:cryptocreateHash('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)
observer-hook-resolver.mjs+ testsobserver-recommended-node.mjs+ tests- parser extension + tests + smoke на живом JSONL
- analyzer filter
>= 2+ новая factor-ось + missed-activations filter< 2+ tests - 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).