Files
portal/docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
T
Дмитрий 7c3a246759 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

13 KiB
Raw Blame History

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)

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)

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 === 2e.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).