Compare commits

...

10 Commits

Author SHA1 Message Date
Дмитрий 54b1de78b8 chore(observer): retrofill chain_ref on existing committed May episodes 2026-05-21 06:06:29 +03:00
Дмитрий ee5bc56f2d docs(brain-retro): fill L1-L13+ hit rate template section 2026-05-21 06:06:28 +03:00
Дмитрий df2d091174 feat(status-md): surface C6 chain-map sync row 2026-05-21 06:06:28 +03:00
Дмитрий 4c9a1e9ccb feat(brain-retro): aggregate chain_ref into factorMatrix (multi-chain axis) 2026-05-21 06:06:27 +03:00
Дмитрий 65c2c5e471 feat(observer): one-shot chain_ref retrofill script (idempotent, atomic) 2026-05-21 06:06:27 +03:00
Дмитрий f6ba9bc1e7 chore(lefthook): wire C6 observer-chain-map-checker (job 16, blocking) 2026-05-21 06:06:26 +03:00
Дмитрий 05076c4f1d feat(observer): C6 chain-map-checker (JSON vs routing-off-phase.md sync) + L14 coverage 2026-05-21 06:06:26 +03:00
Дмитрий f943b229c0 feat(observer): emit chain_ref in primary_rationale 2026-05-21 06:06:25 +03:00
Дмитрий 28671cb012 feat(observer): chain-map JSON + chainsFor detector (L1-L13 attribution) 2026-05-21 06:06:25 +03:00
Дмитрий d86d375ce4 docs(observer): chain attribution L1-L13 spec + plan + brain-retro #2
Brain-retro #2 (весь май) → кандидат: атрибуция canonical chains L1-L13.
Spec + 9-task TDD plan (chain_ref в primary_rationale, C6 sync-контролёр,
ретрофилл). Исполнение разблокировано — epic observer-instrument-expansion
влит в main. +cspell словарь.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 06:06:24 +03:00
21 changed files with 1713 additions and 16 deletions
@@ -70,10 +70,14 @@ For each factor below, render a table: factor value × outcome counts
- `observerErrorCount` from the analyzer — observer_error markers in the period.
Non-zero = the observer failed silently somewhere; investigate.
## Canonical chains L1L12 hit rate
## Canonical chains L1L13+ hit rate (from analyzer `factorMatrix.chain_ref`)
| chain | times | notes |
|---|---|---|
| chain | times | outcome split | notes |
|---|---|---|---|
Each node may belong to several L (a multi-chain episode is counted in each).
`null` = episodes outside any chain (`direct` + nodes not in L1L13+) — **not a
problem** per `memory/feedback_brain_unused_tools_not_problem`.
## Improvised chains (path_type=improvised, repeated ≥2)
+7
View File
@@ -1572,3 +1572,10 @@ lemed
# Сквозной чек-лист портала + 6 фиксов (2026-05-21)
захардкоженным
смердженных
# Observer chain attribution L1-L13 (2026-05-20)
инвокированный
межэпизодные
побочек
диффы
ретрофилл
+3 -2
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-05-21T01:18:52.154Z
Last updated: 2026-05-21T01:53:48.034Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -9,10 +9,11 @@ Last updated: 2026-05-21T01:18:52.154Z
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ⚠️ | 16 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 16 episodes this month, 0 observer_error markers, 3 PII matches before filter
- Observer evidence: 16 episodes this month, 0 observer_error markers, 0 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 5
- Last /brain-retro: 2 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
+11 -11
View File
@@ -3,14 +3,14 @@
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:10:13.713Z","ended_at":"2026-05-19T06:16:11.406Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Write":1,"Bash":2,"Edit":3,"TodoWrite":1}},{"kind":"error","message":"tool_result reported is_error"}]}
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:20:40.404Z","ended_at":"2026-05-19T06:23:08.962Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":2,"Read":1,"Edit":2}}]}
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:32:15.034Z","ended_at":"2026-05-19T06:57:02.675Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix"},"events":[{"kind":"tool_summary","counts":{"Read":17,"ToolSearch":1,"Glob":5,"TodoWrite":4,"Grep":14,"Write":1}}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:06:30.059Z","ended_at":"2026-05-19T08:10:43.437Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":82,"parallel_session":true},"task_size":{"tool_calls":12,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix"},"events":[{"kind":"tool_summary","counts":{"Edit":5,"Read":1,"Bash":4,"TodoWrite":2}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Bash":8,"PostToolUse:Bash":4,"PreToolUse:TodoWrite":2,"PostToolUse:TodoWrite":2},"errors":0},{"kind":"retry"}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:10:44.073Z","ended_at":"2026-05-19T08:13:14.644Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":83,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:13:37.924Z","ended_at":"2026-05-19T08:15:57.442Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":84,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":2,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Read":2,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Read":2,"PostToolUse:Read":2,"PreToolUse:Edit":3,"PostToolUse:Edit":3},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:21:19.146Z","ended_at":"2026-05-19T08:25:57.307Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":86,"parallel_session":false},"task_size":{"tool_calls":1,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"refactor"},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":1}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":1,"PostToolUse:AskUserQuestion":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:25:58.145Z","ended_at":"2026-05-19T08:28:19.676Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"brainstorming"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":87,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:29:06.419Z","ended_at":"2026-05-19T08:30:06.086Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":88,"parallel_session":false},"task_size":{"tool_calls":2,"files_touched":1,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Edit":1}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Edit":1,"PostToolUse:Edit":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:34:18.924Z","ended_at":"2026-05-19T08:40:38.461Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":132,"parallel_session":true},"task_size":{"tool_calls":2,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":2}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":2,"PostToolUse:AskUserQuestion":2},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:43:39.664Z","ended_at":"2026-05-19T08:46:16.416Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"approval","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":133,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\superpowers\\specs\\2026-05-19-observer-factor-analysis-design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Read":1,"Edit":4,"Grep":1}},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Grep":1,"PostToolUse:Grep":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T09:21:50.135Z","ended_at":"2026-05-19T09:27:09.498Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":139,"parallel_session":true},"task_size":{"tool_calls":11,"files_touched":3,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\episodes-2026-05.jsonl","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[{"kind":"tool_summary","counts":{"Bash":3,"Read":4,"Edit":4}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Bash":6,"PostToolUse:Bash":2,"PreToolUse:Read":4,"PostToolUse:Read":3,"PreToolUse:Edit":8,"PostToolUse:Edit":4},"errors":0},{"kind":"retry"},{"kind":"retry"}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:11:19.381Z","ended_at":"2026-05-19T10:12:06.880Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":140,"parallel_session":true},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"question"},"events":[{"kind":"hook_fired","counts":{"Stop":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:13:02.977Z","ended_at":"2026-05-19T10:24:02.234Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":91,"parallel_session":true},"task_size":{"tool_calls":19,"files_touched":4,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\553717ec-bf55-43dc-8b9c-b9812711023a.jsonl","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.test.mjs","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.mjs","c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"superpowers:systematic-debugging","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"other"},"events":[{"kind":"skill_invoked","skill":"superpowers:systematic-debugging"},{"kind":"skill_invoked","skill":"claude-md-management:claude-md-improver"},{"kind":"tool_summary","counts":{"Skill":2,"Grep":2,"Read":5,"Bash":7,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Skill":2,"PostToolUse:Skill":2,"PreToolUse:Grep":2,"PostToolUse:Grep":2,"PreToolUse:Read":5,"PostToolUse:Read":5,"PreToolUse:Bash":14,"PostToolUse:Bash":7,"PreToolUse:Edit":6,"PostToolUse:Edit":3},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:06:30.059Z","ended_at":"2026-05-19T08:10:43.437Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":82,"parallel_session":true},"task_size":{"tool_calls":12,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"Edit":5,"Read":1,"Bash":4,"TodoWrite":2}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Bash":8,"PostToolUse:Bash":4,"PreToolUse:TodoWrite":2,"PostToolUse:TodoWrite":2},"errors":0},{"kind":"retry"}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:10:44.073Z","ended_at":"2026-05-19T08:13:14.644Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":83,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature","chain_ref":null},"events":[]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:13:37.924Z","ended_at":"2026-05-19T08:15:57.442Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":84,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":2,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Read":2,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Read":2,"PostToolUse:Read":2,"PreToolUse:Edit":3,"PostToolUse:Edit":3},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:21:19.146Z","ended_at":"2026-05-19T08:25:57.307Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":86,"parallel_session":false},"task_size":{"tool_calls":1,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"refactor","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":1}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":1,"PostToolUse:AskUserQuestion":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:25:58.145Z","ended_at":"2026-05-19T08:28:19.676Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"brainstorming"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":87,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature","chain_ref":null},"events":[]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:29:06.419Z","ended_at":"2026-05-19T08:30:06.086Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":88,"parallel_session":false},"task_size":{"tool_calls":2,"files_touched":1,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Edit":1}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Edit":1,"PostToolUse:Edit":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:34:18.924Z","ended_at":"2026-05-19T08:40:38.461Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":132,"parallel_session":true},"task_size":{"tool_calls":2,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":2}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":2,"PostToolUse:AskUserQuestion":2},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:43:39.664Z","ended_at":"2026-05-19T08:46:16.416Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"approval","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":133,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\superpowers\\specs\\2026-05-19-observer-factor-analysis-design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"Read":1,"Edit":4,"Grep":1}},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Grep":1,"PostToolUse:Grep":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T09:21:50.135Z","ended_at":"2026-05-19T09:27:09.498Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":139,"parallel_session":true},"task_size":{"tool_calls":11,"files_touched":3,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\episodes-2026-05.jsonl","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature","chain_ref":null},"events":[{"kind":"tool_summary","counts":{"Bash":3,"Read":4,"Edit":4}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Bash":6,"PostToolUse:Bash":2,"PreToolUse:Read":4,"PostToolUse:Read":3,"PreToolUse:Edit":8,"PostToolUse:Edit":4},"errors":0},{"kind":"retry"},{"kind":"retry"}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:11:19.381Z","ended_at":"2026-05-19T10:12:06.880Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":140,"parallel_session":true},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"question","chain_ref":null},"events":[{"kind":"hook_fired","counts":{"Stop":1},"errors":0}]}
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:13:02.977Z","ended_at":"2026-05-19T10:24:02.234Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":91,"parallel_session":true},"task_size":{"tool_calls":19,"files_touched":4,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\553717ec-bf55-43dc-8b9c-b9812711023a.jsonl","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.test.mjs","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.mjs","c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"superpowers:systematic-debugging","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"other","chain_ref":["L8"]},"events":[{"kind":"skill_invoked","skill":"superpowers:systematic-debugging"},{"kind":"skill_invoked","skill":"claude-md-management:claude-md-improver"},{"kind":"tool_summary","counts":{"Skill":2,"Grep":2,"Read":5,"Bash":7,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Skill":2,"PostToolUse:Skill":2,"PreToolUse:Grep":2,"PostToolUse:Grep":2,"PreToolUse:Read":5,"PostToolUse:Read":5,"PreToolUse:Bash":14,"PostToolUse:Bash":7,"PreToolUse:Edit":6,"PostToolUse:Edit":3},"errors":0}]}
@@ -0,0 +1,219 @@
# Brain-retro #2 — весь май 2026 (полный срез)
**Дата:** 2026-05-20 (вечер, ~17:55 MSK)
**Период:** весь май 2026 — 2026-05-19T05:18Z .. 2026-05-20T08:58Z (28 строк JSONL; 23 v2-эпизода + 5 v1 пропущено).
**Источник:** `docs/observer/episodes-2026-05.jsonl` (28 строк) + `docs/observer/.read-counter.json`.
**Анализатор:** `node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl`.
**Отношение к предыдущему ретро:** надстройка над [2026-05-20-brain-retro.md](2026-05-20-brain-retro.md) (то — 17 v2-эпизодов, 12:25 MSK); здесь — те же 17 + дельта в 6 новых.
**Уровень анализа:** верхнеуровневый по запросу заказчика; экономия 100%.
> Анализатор: `episodeCount=23`, `v1SkippedCount=5`, `observerErrorCount=0`. Все цифры по 23 v2-эпизодам, если не отмечено иное.
---
## Period
2026-05-19T05:18:16Z .. 2026-05-20T08:58:44Z. **7 уникальных task_id (сессий)**, 23 v2-анализируемых эпизода.
Дельта vs прошлое ретро (6 новых эпизодов после 2026-05-20T08:12:29Z):
| task_id | turn | start..end (Z) | path_type | provenance | node_chosen | econ | tool_calls | files | events примечательное | inferred outcome |
|---|---|---|---|---|---|---|---|---|---|---|
| `98298ec2` | 5 | 08:13..08:19 | improvised | autonomous | direct | 100 | 19 | 4 | 2× error tool_result + 2× retry | success (continuation) |
| `35fc31da` | 1 | 08:16..08:24 | improvised | autonomous | **brain-retro** | 0 | 20 | 6 | skill_invoked brain-retro | **rework** (следующий ход — correction) |
| `35fc31da` | 2 | 08:30..08:36 | improvised | autonomous | direct | 5 | 17 | 4 | 1× error + retry; prompt_signal=**correction** | success |
| `35fc31da` | 3 | 08:36..08:37 | improvised | user_directed_method (`claude_would_have_chosen=brain-retro`) | direct | null | 0 | 0 | — | unknown (no-op ход) |
| `35fc31da` | 4 | 08:39..08:46 | improvised | autonomous | direct | 0 | 15 | 14 | 14× Read mass | success |
| `286dd904` | 2 | 07:52..08:58 | **regulated** | **user_chose_from_options** ("На Plan 3 (экспорт)") | **superpowers:verification-before-completion** | 5 | 133 | 25 | skill_invoked verification; 1× error; **time_burn 66 мин** | unknown (нет следующего эпизода) |
---
## Path-type distribution (v2, n=23)
| path_type | count | % |
|---|---|---|
| improvised | 20 | 87.0% |
| regulated | 3 | 13.0% |
| alternative | 0 | 0% |
| mixed | 0 | 0% |
Доля regulated на 4.6 п.п. ниже прошлого ретро (17.6% → 13.0%) — три новых improvised-эпизода без skill в дельте сдвинули долю.
## Outcome distribution
| outcome | count | % |
|---|---|---|
| success (inferred) | 9 | 39.1% |
| rework (inferred) | 1 | 4.3% |
| unknown (последние/нет следующего) | 13 | 56.5% |
«Unknown» здесь — это эпизоды, после которых нет хода с positive/correction-сигналом (хвост сессий) — не provenance-bug.
## Skill invocations (events `skill_invoked`, n=6)
| skill | times | sessions |
|---|---|---|
| superpowers:verification-before-completion | 2 | `553717ec`, `286dd904` |
| superpowers:systematic-debugging | 1 | `553717ec` |
| superpowers:test-driven-development | 1 | `553717ec` |
| claude-md-management:claude-md-improver | 1 | `553717ec` |
| brain-retro | 1 | `35fc31da` |
## Factor analysis matrix (analyzer `factorMatrix`)
### decision_provenance — «rework мой или роутера?»
| provenance | success | rework | unknown |
|---|---|---|---|
| autonomous | 6 | 1 | 10 |
| user_directed_method | 2 | 0 | 2 |
| user_chose_from_options | 0 | 0 | 2 |
Единственный rework (`brain-retro` turn 1) — autonomous-выбор узла brain-retro заказчиком (это сам skill, инвокированный по `/brain-retro`); коррекция — про точность аналитики прошлого ретро, не про routing. **«Rework мой, не роутера.»**
### economy_level
| economy_level | success | rework | unknown |
|---|---|---|---|
| null | 3 | 0 | 2 |
| 0 | 0 | 1 | 2 |
| 5 | 4 | 0 | 6 |
| 100 | 1 | 0 | 4 |
Слишком маленькая выборка для выводов; единственный rework на 0% — это brain-retro turn 1 (для самого ретро economy=0% это норма, заказчик так попросил).
### model · post_compaction · task_size
| factor value | success | rework | unknown |
|---|---|---|---|
| model: claude-opus-4-7 | 8 | 1 | 14 |
| post_compaction=true | 6 | 0 | 5 |
| post_compaction=false | 2 | 1 | 9 |
| session_turn late (≥10) | 6 | 0 | 5 |
| session_turn early (<10) | 2 | 1 | 9 |
| task_size small | 8 | 0 | 11 |
| task_size medium | 0 | 1 | 2 |
| task_size large | 0 | 0 | 1 |
Все эпизоды на одной модели → строка про model — не сигнал. Post_compaction=true и late session_turn — это одна и та же длинная brain-governance сессия `553717ec` (turn 82+); концентрация success там — артефакт сессии, не закономерность.
### node_chosen · task_classification
| node_chosen | success | rework | unknown |
|---|---|---|---|
| direct | 8 | 0 | 11 |
| superpowers:verification-before-completion | 0 | 0 | 1 |
| superpowers:systematic-debugging | 0 | 0 | 1 |
| superpowers:test-driven-development | 0 | 0 | 1 |
| brain-retro | 0 | 1 | 0 |
| task_classification | success | rework | unknown |
|---|---|---|---|
| bugfix | 2 | 0 | 1 |
| feature | 2 | 0 | 2 |
| other | 3 | 0 | 9 |
| refactor | 1 | 0 | 0 |
| question | 0 | 1 | 2 |
«direct» — 8/0/11 — основная масса задач без skill-маршрутизации, всё работает. superpowers-узлы (3 эпизода, все unknown) сидят в хвостах своих сессий — нет следующего хода с явным signal.
## Episodes → tasks (analyzer `tasks`, 15 task-групп)
| task_ref | episodes | rework turns |
|---|---|---|
| `553717ec#1..#10` | 10 | turn 82 (rework — improvised CLAUDE.md edit, retry-recovered) |
| `24acfa10#1` | 1 | — |
| `a42e4ba5#1` | 1 | — |
| `dd905ea0#1` | 1 | — |
| `98298ec2#1..#3` | 3 (continuation across 5 turns) | — |
| `35fc31da#1..#4` | 4 | turn 1 (brain-retro) — correction в turn 2 |
| `286dd904#1` | 1 (66-min verification) | — |
## Causal-chain candidates (analyzer `causalChains`)
| from | to | shared files |
|---|---|---|
| — | — | — |
Анализатор не нашёл «errored episode → fix episode на тех же файлах». 7 событий error (`tool_result reported is_error`) — это transient-сбои тулов внутри одного эпизода с retry-recovery, не межэпизодные.
## Observer health
- `observerErrorCount = 0` — за весь май **ни одного** `observer_error`-маркера. Парсер ни разу не сломался тихо.
- `interrupts = 0` — заказчик ни разу не прерывал ход.
- `errors = 7` (внутри 5 эпизодов) — все transient, retry-recovered.
- `retries = 6` — корреспондируют ошибкам один-в-один (один retry бесплатный после restart-tooling).
- `time_burn_total = 86 мин` — из них 66 мин — один эпизод `286dd904#2` (длинная verification-сессия Plan 2 экспорт).
## Canonical chains L1L12 hit rate
Не считаем за май — нет атрибуции `chain_ref`; routing-таблица `docs/routing-off-phase.md` v1.2 ещё не интегрирована в primary_rationale. Кандидат на доработку парсера — см. ниже.
## Improvised chains (повторённые ≥2)
| node-set | times | candidate L13+? |
|---|---|---|
| direct → direct (continuation в одной сессии) | 14 | нет — это норма, не цепочка |
Других повторов нет.
## chain_divergence cases
Нет атрибуции — пропуск.
## Top error classes
| error class | count | recovery pattern |
|---|---|---|
| `tool_result reported is_error` (transient) | 7 | retry в том же эпизоде, без user-intervention |
## confusion_marker hot-spots
Нет таких маркеров в схеме v2 — пропуск.
---
## Candidates for owner review
### Candidate 1: уточнить analyzer для дельта-сравнения с предыдущим ретро
- **Type:** doc/skill enhancement, не нормативная правка.
- **Evidence:** прошлое ретро (35fc31da#1) → `prompt_signal=correction` следующего хода (35fc31da#2). Анализатор корректно пометил `outcome=rework`, но в выводе нет указателя на номер прошлого ретро или diff vs предыдущий.
- **Suggested action:** в `tools/brain-retro-analyzer.mjs` добавить опциональный аргумент `--since <ISO>` (срез по `started_at >= since`), чтобы можно было дёшево считать только дельту между ретро. Альтернатива: в шаблоне `.claude/skills/brain-retro/references/aggregation-template.md` добавить секцию «Delta vs prior retro» с явным diff'ом.
- **Cost / risk:** низкий; чистый node-скрипт без побочек на JSONL. Сейчас процесс ручной (этот ретро diff делался руками).
- **Rejection option:** заказчик может сказать «всегда срез — полный месяц», и тогда диффы не нужны.
### Candidate 2: атрибуция canonical chains L1L12 в primary_rationale
- **Type:** observer schema extension (потребует amend ADR-011 / spec factor-analysis).
- **Evidence:** ни один из 23 эпизодов не несёт ссылки на L1–L12 chain из `docs/routing-off-phase.md` v1.2 (а с finance-tooling — там уже L1L13). «Canonical chains hit rate» — пустая таблица.
- **Suggested action:** в `tools/observer-routing-detector.mjs` (или новый детектор) маппить выбранные узлы в L-цепочку и писать `primary_rationale.chain_ref: "L7"` (например). Только тогда можно отслеживать чистоту следования цепочкам.
- **Cost / risk:** средний — нужен маппинг «node_chosen → L-chain», который сейчас живёт только в человеческом тексте routing-off-phase.md. Риск: дрейф маппинга между парсером и документом.
- **Rejection option:** оставить L1–L13 как нормативное «чтение для человека», не пытаться формализовать.
### Candidate 3: проверить корректность аналитики прошлого ретро
- **Type:** ad-hoc review (не нормативка).
- **Evidence:** rework-flag на 35fc31da#1 — единственный rework в выборке. Я (текущее ретро) дельту посчитал и нашёл, что предыдущее ретро отчиталось «17 эпизодов, 5 v1 пропущено» — это совпадает с записанным; коррекция была про что-то другое (содержание ретро, не структура). Без доступа к самой формулировке коррекции (`35fc31da#2` body) можно только сказать: коррекция структурно не была про observability, а про текст ретро.
- **Suggested action:** заказчик при желании может прокомментировать «что именно правил после ретро 12:25», и записать урок в notes. **Не блокирующее.**
- **Rejection option:** игнорировать — мелкая коррекция текста, не системный сигнал.
(Других кандидатов нет. Никаких removals/zombie nodes per memory `feedback_brain_unused_tools_not_problem`.)
---
## Informational metrics (NOT alerts)
- Узлов, использованных хотя бы раз за период (явно через `skill_invoked`): **5 / 63+** (superpowers TDD/debug/verify, claude-md-improver, brain-retro). Узел `direct` (=прямое исполнение) — отдельная категория, 19 эпизодов.
- Узлов, ни разу не использованных с начала наблюдения: **большинство (≥55 из 63+)****не проблема** per [feedback_brain_unused_tools_not_problem](../../../../C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/feedback_brain_unused_tools_not_problem.md). Capability-readiness — осознанная стратегия заказчика.
- Параллельные сессии: 12 эпизодов из 23 (52%) с `parallel_session=true` — норма для текущего рабочего режима с §15 Pravila.
- Длинные эпизоды (`time_burn` событие): 1 — `286dd904#2` 66 мин (Plan 2 экспорт verification). Все остальные — без time_burn-маркера (под 60 мин).
---
## Дельта vs прошлое ретро 2026-05-20 12:25 — итог
- Картина устойчива: improvised-доминанта, opus-4-7-only, 0 observer_error, rework на autonomous-выборах единичный.
- Новый класс события: **`prompt_signal=correction` после skill-инвокации** (35fc31da#1#2). Раньше correction наблюдался только на autonomous direct-выборах; теперь видно, что brain-retro skill тоже не неприкосновенен — это здоровый сигнал.
- Новый успешный кейс гибридной модели: **`user_chose_from_options` → regulated path** (286dd904#2, 66 мин, 133 tool_calls, через superpowers:verification-before-completion). Это первое подтверждение, что collaborative-choice + skill-маршрутизация даёт длинные продуктивные эпизоды без interruptions.
- Никаких рекомендаций править Pravila / PSR_v1 / Tooling / CLAUDE.md — выборка (23 эпизода / 2 дня) слишком мала.
@@ -0,0 +1,820 @@
# Observer Canonical Chain Attribution (L1L13) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Добавить опциональное поле `chain_ref` в эпизоды наблюдателя, связывающее `node_chosen` с каноническими цепочками L1–L13, чтобы `/brain-retro` мог считать «hit rate цепочек».
**Architecture:** Один новый слой над наблюдателем — статический JSON-маппинг `node_chosen → [LN]`, чистая функция-детектор, врезка в парсер транскрипта, контролёр C6 сверки JSON↔`routing-off-phase.md` (в lefthook pre-commit + Vitest), одноразовый ретрофилл существующих эпизодов, агрегация в анализаторе. Всё детерминированно — 0 LLM-вызовов.
**Tech Stack:** Node 20+ ESM, Vitest 4.1.5, pure fs/regex (Security Guidance #40 — никаких shell-вызовов в parser/hook). Раннер: `npm run test:tools` (`cd app && npx vitest run --config vitest.config.tools.mjs`). Тесты лежат рядом с модулями: `tools/<name>.test.mjs`.
**Spec:** [docs/superpowers/specs/2026-05-20-observer-chain-attribution-design.md](../specs/2026-05-20-observer-chain-attribution-design.md).
---
## ⚠️ Внешняя зависимость и порядок (читать ПЕРВЫМ)
**Этот план НЕ исполняется немедленно.** Жёсткая зависимость от epic-плана [2026-05-20-observer-instrument-expansion.md](2026-05-20-observer-instrument-expansion.md) (20 task), который правит тот же `tools/observer-transcript-parser.mjs`.
**Процедура старта (Task 0 ниже):**
1. Дождаться сообщения «epic 20-task закрыт и push'нут на origin/main».
2. `git fetch origin && git log HEAD..origin/main --oneline` — убедиться, что 20 task влиты (Pravila §15.2 pre-flight).
3. Создать свежий worktree off `origin/main` (Task 0).
4. Исполнять Tasks 1–9 по порядку.
---
## Уточнения к spec (раскрыты при детализации writing-plans)
1. **Расположение тестов.** Spec §5/§8 называл `tests/observer-chain-*.test.mjs`. Реальный паттерн репозитория — тест рядом с модулем: `tools/observer-chain-detector.test.mjs`. План использует реальный паттерн.
2. **Семантика контролёра C6 — по L-номерам, не по именам узлов.** Имена в таблице `routing-off-phase.md` — человеческие display-names (`Boost MCP`, `Trail of Bits`, `Semgrep MCP`), а `node_chosen` в эпизодах — технические skill-id (`superpowers:test-driven-development`, `claude-md-management:claude-md-improver`, `direct`). Прямая построчная сверка имён хрупкая. Поэтому C6 v1 сверяет **множества L-номеров**:
- каждый `LN`, упомянутый в JSON, существует в `.md` (нет ссылок на несуществующую цепочку);
- каждый `LN` из таблицы `.md` присутствует хотя бы в одной записи JSON (цепочка не «потеряна» при добавлении новой L в .md).
Это ловит главный класс дрейфа («добавили L14 в .md — JSON про неё не знает»). Точечная сверка «узел X в L7» через display-name-алиасы — out of scope v1 (можно как future-слой).
3. **Точка врезки в parser.** На `origin/main` это `return { … node_chosen: skills.length > 0 ? skills[0] : 'direct', … }` (≈строка 696). После epic-плана номер строки сдвинется — **искать по grep-маркеру** `node_chosen: skills.length > 0 ? skills[0] : 'direct'`, не по номеру.
---
## File Structure
| Файл | Тип | Ответственность |
|---|---|---|
| `tools/observer-chain-map.json` | новый (data) | Маппинг `node_chosen` (реальное значение) → массив `["LN"]`. Только узлы, входящие в L1–L13 |
| `tools/observer-chain-detector.mjs` | новый | `loadChainMap(path)` + чистая `chainsFor(node, map)` → массив \| `null` |
| `tools/observer-chain-detector.test.mjs` | новый | Юнит-тесты `chainsFor` |
| `tools/observer-transcript-parser.mjs` | edit | Врезка `chain_ref` в `primary_rationale` |
| `tools/observer-transcript-parser.test.mjs` | edit | +тест что эпизод несёт `chain_ref` |
| `tools/observer-chain-map-checker.mjs` | новый | C6: `parseChainsFromMd()` + `checkSync()` + CLI |
| `tools/observer-chain-map-checker.test.mjs` | новый | Тесты парсера .md + sync-сверки |
| `lefthook.yml` | edit | Job 16 `observer-chain-map-checker` в pre-commit |
| `tools/observer-retrofill-chain-ref.mjs` | новый | Одноразовый ретрофилл `chain_ref` в JSONL |
| `tools/observer-retrofill-chain-ref.test.mjs` | новый | Тесты идемпотентности + dry-run |
| `tools/brain-retro-analyzer.mjs` | edit | `factorMatrix.chain_ref` + `chainHitRate` |
| `tools/brain-retro-analyzer.test.mjs` | edit | +тест агрегации chain_ref |
| `tools/status-md-generator.mjs` | edit | Строка «C6 Chain map sync» |
| `tools/status-md-generator.test.mjs` | edit | +тест строки C6 |
| `.claude/skills/brain-retro/references/aggregation-template.md` | edit | Заполнить секцию «L1–L13 hit rate» |
---
## Task 0: Pre-flight + worktree (организационный, не код)
**Files:** нет правок кода.
- [ ] **Step 1: Убедиться, что epic-план влит**
Run:
```bash
git fetch origin
git log --oneline -5 origin/main
git log HEAD..origin/main --oneline | grep -i "observer-instrument-expansion\|Task 21\|Task 20" || echo "epic не найден — НЕ СТАРТОВАТЬ"
```
Expected: видны коммиты закрытия epic 20-task на origin/main. Если нет — остановиться, сообщить владельцу.
- [ ] **Step 2: Создать worktree off origin/main**
Использовать `superpowers:using-git-worktrees`. Целевая база — `origin/main` (свежий, после epic). Ветка `feat/observer-chain-attribution`.
- [ ] **Step 3: Verify базовая регрессия зелёная**
Run: `npm run test:tools`
Expected: PASS (≥ baseline после epic, например 350+/350+). Записать число baseline для финальной сверки.
---
## Task 1: chain-map JSON + детектор `chainsFor`
**Files:**
- Create: `tools/observer-chain-map.json`
- Create: `tools/observer-chain-detector.mjs`
- Test: `tools/observer-chain-detector.test.mjs`
- [ ] **Step 1: Создать JSON-маппинг**
`tools/observer-chain-map.json` — только узлы, входящие в L1–L13. Имена ключей = реальные значения `node_chosen` (skill-id). NB: значения `node_chosen` берутся из первого `skill_invoked` (`skills[0]`); для skill-узлов это `plugin:skill` или `skill`. MCP-узлы (Boost/Sentry/Redis) в `node_chosen` не появляются (они не skill_invoked) — но включены в маппинг на будущее, если детектор узлов расширится.
```json
{
"_note": "node_chosen -> L-цепочки. Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, прочее) НЕ включаются -> chainsFor вернёт null. Имена ключей = реальные значения primary_rationale.node_chosen. Синхронизируется с docs/routing-off-phase.md через контролёр C6 (tools/observer-chain-map-checker.mjs).",
"discovery-interview": ["L1", "L2"],
"superpowers:brainstorming": ["L1"],
"superpowers:writing-plans": ["L1"],
"superpowers:subagent-driven-development": ["L1"],
"audit-portal": ["L2"],
"process-analysis": ["L3"],
"process-modeling": ["L3", "L4"],
"mermaid": ["L4"],
"adr-kit:adr": ["L4", "L5"],
"adr-kit:judge": ["L5"],
"operations": ["L4"],
"architecture-patterns:architecture-patterns": ["L5"],
"deptrac": ["L5"],
"security-review": ["L6"],
"superpowers:systematic-debugging": ["L8"],
"ccpm": ["L9"],
"product-management:brainstorm": ["L9"],
"promptfoo": ["L10"],
"data-scientist": ["L10"],
"claude-api": ["L10"],
"skill-creator:skill-creator": ["L11"],
"hookify:hookify": ["L11"],
"plugin-dev:create-plugin": ["L11"],
"claude-md-management:claude-md-improver": ["L12"],
"claude-md-management:revise-claude-md": ["L12"],
"billing-audit": ["L13"],
"ru-tax-accounting": ["L13"]
}
```
- [ ] **Step 2: Написать failing-тест детектора**
`tools/observer-chain-detector.test.mjs`:
```javascript
import { describe, it, expect } from 'vitest';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
const map = loadChainMap();
describe('chainsFor', () => {
it('returns chain array for a single-chain node', () => {
expect(chainsFor('billing-audit', map)).toEqual(['L13']);
});
it('returns all chains for a multi-chain node', () => {
expect(chainsFor('discovery-interview', map)).toEqual(['L1', 'L2']);
});
it('returns null for direct', () => {
expect(chainsFor('direct', map)).toBeNull();
});
it('returns null for an unknown node', () => {
expect(chainsFor('totally-unknown-xyz', map)).toBeNull();
});
it('returns null for empty/null/undefined', () => {
expect(chainsFor('', map)).toBeNull();
expect(chainsFor(null, map)).toBeNull();
expect(chainsFor(undefined, map)).toBeNull();
});
it('ignores the _note metadata key', () => {
expect(chainsFor('_note', map)).toBeNull();
});
});
```
- [ ] **Step 3: Запустить тест — убедиться, что падает**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-detector.test.mjs`
Expected: FAIL — `loadChainMap is not a function` / module not found.
- [ ] **Step 4: Реализовать детектор**
`tools/observer-chain-detector.mjs`:
```javascript
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_MAP_PATH = join(__dirname, 'observer-chain-map.json');
/** Load the node->chains map. Throws on missing/invalid JSON (caller handles). */
export function loadChainMap(path = DEFAULT_MAP_PATH) {
const raw = JSON.parse(readFileSync(path, 'utf8'));
const map = new Map();
for (const [node, chains] of Object.entries(raw)) {
if (node === '_note') continue;
if (Array.isArray(chains) && chains.length > 0) map.set(node, chains);
}
return map;
}
/** node_chosen -> array of L-chains, or null if not in any chain. */
export function chainsFor(node, map) {
if (!node || typeof node !== 'string') return null;
const chains = map.get(node);
return chains && chains.length > 0 ? chains : null;
}
```
- [ ] **Step 5: Запустить тест — убедиться, что проходит**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-detector.test.mjs`
Expected: PASS (6 tests).
- [ ] **Step 6: Commit**
```bash
git add tools/observer-chain-map.json tools/observer-chain-detector.mjs tools/observer-chain-detector.test.mjs
git commit -m "feat(observer): chain-map JSON + chainsFor detector (L1-L13 attribution)"
```
---
## Task 2: Врезка `chain_ref` в парсер транскрипта
**Files:**
- Modify: `tools/observer-transcript-parser.mjs` (grep-маркер `node_chosen: skills.length > 0 ? skills[0] : 'direct'`)
- Test: `tools/observer-transcript-parser.test.mjs`
- [ ] **Step 1: Написать failing-тест**
Добавить в `tools/observer-transcript-parser.test.mjs` (в подходящий describe для primary_rationale; адаптировать фабрику транскрипта под существующие хелперы файла):
```javascript
it('attaches chain_ref for a node that belongs to a chain', () => {
// транскрипт с skill_invoked = 'billing-audit' (адаптировать под фабрику файла)
const episode = parseTranscript(transcriptWithSkill('billing-audit'));
expect(episode.primary_rationale.chain_ref).toEqual(['L13']);
});
it('sets chain_ref null for a direct episode', () => {
const episode = parseTranscript(transcriptWithNoSkill());
expect(episode.primary_rationale.chain_ref).toBeNull();
});
```
NB: точные имена хелперов (`parseTranscript` / фабрики) взять из существующего теста — не выдумывать. Если фабрики нет — собрать минимальный transcript-объект вручную по образцу соседних тестов.
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs -t chain_ref`
Expected: FAIL — `chain_ref` undefined.
- [ ] **Step 3: Реализовать врезку**
В `tools/observer-transcript-parser.mjs`:
1. Вверху файла добавить импорт:
```javascript
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
```
1. Один раз модульно загрузить карту с защитой от битого JSON:
```javascript
let CHAIN_MAP = null;
try {
CHAIN_MAP = loadChainMap();
} catch {
CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает
}
```
1. Найти grep-маркер `node_chosen: skills.length > 0 ? skills[0] : 'direct'` и добавить рядом строку. Должно получиться:
```javascript
node_chosen: skills.length > 0 ? skills[0] : 'direct',
chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP),
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs`
Expected: PASS (включая существующие тесты файла — ни один не сломан).
- [ ] **Step 5: Commit**
```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): emit chain_ref in primary_rationale"
```
---
## Task 3: Контролёр C6 — sync JSON ↔ routing-off-phase.md
**Files:**
- Create: `tools/observer-chain-map-checker.mjs`
- Test: `tools/observer-chain-map-checker.test.mjs`
- [ ] **Step 1: Написать failing-тест**
`tools/observer-chain-map-checker.test.mjs`:
```javascript
import { describe, it, expect } from 'vitest';
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';
const SAMPLE_MD = [
'| # | Цепочка | Зачем |',
'|---|---|---|',
'| L1 | `discovery-interview` (FEATURE) → `brainstorming` | text |',
'| L2 | `audit-portal` | text |',
'| L13 | `billing-audit` (#62) + `Pest` | text |',
].join('\n');
describe('parseChainsFromMd', () => {
it('extracts the set of L-numbers from the table', () => {
expect(parseChainsFromMd(SAMPLE_MD)).toEqual(new Set(['L1', 'L2', 'L13']));
});
});
describe('checkSync', () => {
it('passes when JSON L-numbers subset of md and md subset of json-union', () => {
const mdSet = new Set(['L1', 'L2', 'L13']);
const jsonMap = { a: ['L1'], b: ['L2'], c: ['L13'] };
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
});
it('fails when JSON references a chain absent from md', () => {
const mdSet = new Set(['L1', 'L2']);
const jsonMap = { a: ['L1'], b: ['L99'] };
const res = checkSync(jsonMap, mdSet);
expect(res.ok).toBe(false);
expect(res.jsonOnly).toContain('L99');
});
it('fails when md has a chain not covered by any JSON entry', () => {
const mdSet = new Set(['L1', 'L2', 'L14']);
const jsonMap = { a: ['L1'], b: ['L2'] };
const res = checkSync(jsonMap, mdSet);
expect(res.ok).toBe(false);
expect(res.mdOnly).toContain('L14');
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-map-checker.test.mjs`
Expected: FAIL — module not found.
- [ ] **Step 3: Реализовать чекер**
`tools/observer-chain-map-checker.mjs`:
```javascript
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MD_PATH = join(__dirname, '..', 'docs', 'routing-off-phase.md');
const JSON_PATH = join(__dirname, 'observer-chain-map.json');
/** Extract the set of L-numbers ("L1".."L13") from the routing-off-phase.md table. */
export function parseChainsFromMd(md) {
const set = new Set();
for (const line of md.split(/\r?\n/)) {
const m = /^\|\s*(L\d+)\s*\|/.exec(line.trim());
if (m) set.add(m[1]);
}
return set;
}
/** Compare JSON L-numbers against the md set, both directions. */
export function checkSync(jsonMap, mdSet) {
const jsonSet = new Set();
for (const [node, chains] of Object.entries(jsonMap)) {
if (node === '_note') continue;
if (Array.isArray(chains)) for (const c of chains) jsonSet.add(c);
}
const jsonOnly = [...jsonSet].filter((c) => !mdSet.has(c)); // ссылки на несуществующие L
const mdOnly = [...mdSet].filter((c) => !jsonSet.has(c)); // потерянные цепочки
return { ok: jsonOnly.length === 0 && mdOnly.length === 0, jsonOnly, mdOnly };
}
/** CLI entry — exit 1 on drift with a human-readable message. */
function main() {
const md = readFileSync(MD_PATH, 'utf8');
const jsonMap = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
const mdSet = parseChainsFromMd(md);
if (mdSet.size === 0) {
console.error('[chain-map-checker] не нашёл ни одной L-строки в routing-off-phase.md — формат таблицы изменился?');
process.exit(1);
}
const res = checkSync(jsonMap, mdSet);
if (res.ok) {
console.log(`[chain-map-checker] OK — ${mdSet.size} chains in sync`);
process.exit(0);
}
console.error('[chain-map-checker] дрейф маппинга chain-map <-> routing-off-phase.md:');
if (res.jsonOnly.length) console.error(` JSON ссылается на отсутствующие в .md цепочки: ${res.jsonOnly.join(', ')}`);
if (res.mdOnly.length) console.error(` В .md есть цепочки без записи в JSON: ${res.mdOnly.join(', ')} — добавьте узлы в tools/observer-chain-map.json`);
process.exit(1);
}
if (process.argv[1]?.endsWith('observer-chain-map-checker.mjs')) {
main();
}
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-chain-map-checker.test.mjs`
Expected: PASS (4 tests).
- [ ] **Step 5: Smoke CLI на реальных файлах**
Run: `node tools/observer-chain-map-checker.mjs`
Expected: `[chain-map-checker] OK — 13 chains in sync` (exit 0). Если FAIL — привести JSON (Task 1) в соответствие с реальной таблицей `.md` (это раскроет реальные расхождения первого маппинга).
- [ ] **Step 6: Commit**
```bash
git add tools/observer-chain-map-checker.mjs tools/observer-chain-map-checker.test.mjs
git commit -m "feat(observer): C6 chain-map-checker (JSON vs routing-off-phase.md sync)"
```
---
## Task 4: lefthook job 16 + red-green smoke
**Files:**
- Modify: `lefthook.yml` (после job 15 observer-coverage-checker)
- [ ] **Step 1: Добавить job**
В `lefthook.yml`, в секцию pre-commit после job 15:
```yaml
# 16. observer-chain-map-checker — brain governance C6 (chain attribution).
# Сверяет tools/observer-chain-map.json с таблицей L1-L13 в
# docs/routing-off-phase.md. Падает при дрейфе (несуществующая L в JSON
# или потерянная цепочка из .md).
- name: observer-chain-map-checker
run: node tools/observer-chain-map-checker.mjs
fail_text: |
observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md.
Обновите tools/observer-chain-map.json под таблицу L1-L13.
```
NB: точный синтаксис (`fail_text` vs `interactive` vs `|| true`) скопировать с соседних observer-job'ов (1115) — формат должен совпасть.
- [ ] **Step 2: Red-green smoke**
Run (намеренная рассинхронизация):
```bash
# временно добавить несуществующую цепочку в JSON
node -e "const f='tools/observer-chain-map.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f,'utf8'));j['__test_drift__']=['L99'];fs.writeFileSync(f,JSON.stringify(j,null,2));"
node tools/observer-chain-map-checker.mjs; echo "exit=$?"
```
Expected: exit=1, сообщение про `L99`.
```bash
# откатить
git checkout tools/observer-chain-map.json
node tools/observer-chain-map-checker.mjs; echo "exit=$?"
```
Expected: exit=0, `OK — 13 chains in sync`.
- [ ] **Step 3: Commit**
```bash
git add lefthook.yml
git commit -m "chore(lefthook): wire C6 observer-chain-map-checker (job 16)"
```
---
## Task 5: Ретрофилл существующих эпизодов
**Files:**
- Create: `tools/observer-retrofill-chain-ref.mjs`
- Test: `tools/observer-retrofill-chain-ref.test.mjs`
- [ ] **Step 1: Написать failing-тест**
`tools/observer-retrofill-chain-ref.test.mjs`:
```javascript
import { describe, it, expect } from 'vitest';
import { retrofillLine } from './observer-retrofill-chain-ref.mjs';
import { loadChainMap } from './observer-chain-detector.mjs';
const map = loadChainMap();
describe('retrofillLine', () => {
it('adds chain_ref to a v2 episode with a known node', () => {
const ep = { schema_version: 2, primary_rationale: { node_chosen: 'billing-audit' } };
const out = retrofillLine(ep, map);
expect(out.primary_rationale.chain_ref).toEqual(['L13']);
});
it('sets chain_ref null for a direct v2 episode', () => {
const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct' } };
expect(retrofillLine(ep, map).primary_rationale.chain_ref).toBeNull();
});
it('is idempotent — does not overwrite existing chain_ref', () => {
const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: ['L1'] } };
expect(retrofillLine(ep, map).primary_rationale.chain_ref).toEqual(['L1']);
});
it('skips v1 episodes (no schema_version 2)', () => {
const ep = { foo: 'bar' };
expect(retrofillLine(ep, map)).toEqual({ foo: 'bar' });
});
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-retrofill-chain-ref.test.mjs`
Expected: FAIL — module not found.
- [ ] **Step 3: Реализовать**
`tools/observer-retrofill-chain-ref.mjs`:
```javascript
import { readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OBS_DIR = join(__dirname, '..', 'docs', 'observer');
/** Add chain_ref to a single parsed episode object (pure). Idempotent. */
export function retrofillLine(ep, map) {
if (!ep || ep.schema_version !== 2 || !ep.primary_rationale) return ep;
if ('chain_ref' in ep.primary_rationale) return ep; // idempotent
ep.primary_rationale.chain_ref = chainsFor(ep.primary_rationale.node_chosen, map);
return ep;
}
/** Process one JSONL file atomically (tmp + rename). Returns {changed, total}. */
export function retrofillFile(path, map, { dryRun = false } = {}) {
const lines = readFileSync(path, 'utf8').split(/\r?\n/);
let changed = 0, total = 0;
const out = lines.map((line) => {
if (!line.trim()) return line;
total++;
const ep = JSON.parse(line);
const before = ep.primary_rationale && 'chain_ref' in ep.primary_rationale;
const next = retrofillLine(ep, map);
const after = next.primary_rationale && 'chain_ref' in next.primary_rationale;
if (!before && after) changed++;
return JSON.stringify(next);
});
if (!dryRun && changed > 0) {
const tmp = `${path}.tmp`;
writeFileSync(tmp, out.join('\n'), 'utf8');
renameSync(tmp, path);
}
return { changed, total };
}
function main() {
const dryRun = process.argv.includes('--dry-run');
const map = loadChainMap();
const files = readdirSync(OBS_DIR).filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f));
for (const f of files) {
const { changed, total } = retrofillFile(join(OBS_DIR, f), map, { dryRun });
console.log(`${dryRun ? '[dry-run] ' : ''}${f}: ${changed}/${total} lines would get chain_ref`);
}
}
if (process.argv[1]?.endsWith('observer-retrofill-chain-ref.mjs')) main();
```
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-retrofill-chain-ref.test.mjs`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add tools/observer-retrofill-chain-ref.mjs tools/observer-retrofill-chain-ref.test.mjs
git commit -m "feat(observer): one-shot chain_ref retrofill script (idempotent, atomic)"
```
---
## Task 6: Агрегация в brain-retro-analyzer
**Files:**
- Modify: `tools/brain-retro-analyzer.mjs` (в формирование `factorMatrix`)
- Test: `tools/brain-retro-analyzer.test.mjs`
- [ ] **Step 1: Написать failing-тест**
Добавить в `tools/brain-retro-analyzer.test.mjs`:
```javascript
it('aggregates chain_ref into factorMatrix (multi-chain counted in each)', () => {
const episodes = [
{ schema_version: 2, primary_rationale: { node_chosen: 'discovery-interview', chain_ref: ['L1','L2'] } /* + поля, нужные analyzer */ },
{ schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: null } },
];
const result = analyze(episodes); // имя функции взять из существующего теста
expect(result.factorMatrix.chain_ref.L1).toBeDefined();
expect(result.factorMatrix.chain_ref.L2).toBeDefined();
expect(result.factorMatrix.chain_ref.null).toBeDefined();
});
```
NB: имена `analyze` / shape входа подогнать под существующий тест файла (там уже есть фикстуры эпизодов — переиспользовать форму).
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/brain-retro-analyzer.test.mjs -t chain_ref`
Expected: FAIL — `factorMatrix.chain_ref` undefined.
- [ ] **Step 3: Реализовать**
В `tools/brain-retro-analyzer.mjs`, где строится `factorMatrix`, добавить ось `chain_ref`. Multi-chain эпизод инкрементит каждую L; `null` → ключ `"null"`:
```javascript
// внутри построения factorMatrix, по аналогии с другими осями:
matrix.chain_ref = {};
for (const ep of v2Episodes) {
const cr = ep.primary_rationale?.chain_ref;
const outcome = ep._inferredOutcome ?? 'unknown';
const keys = Array.isArray(cr) && cr.length ? cr : ['null'];
for (const k of keys) {
matrix.chain_ref[k] = matrix.chain_ref[k] || {};
matrix.chain_ref[k][outcome] = (matrix.chain_ref[k][outcome] || 0) + 1;
}
}
```
NB: точные имена переменных (`matrix`, `v2Episodes`, поле inferred outcome) взять из реального кода — он уже строит другие оси, скопировать паттерн.
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/brain-retro-analyzer.test.mjs`
Expected: PASS (включая существующие тесты).
- [ ] **Step 5: Commit**
```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(brain-retro): aggregate chain_ref into factorMatrix"
```
---
## Task 7: STATUS.md строка C6
**Files:**
- Modify: `tools/status-md-generator.mjs`
- Test: `tools/status-md-generator.test.mjs`
- [ ] **Step 1: Написать failing-тест**
Добавить в `tools/status-md-generator.test.mjs`:
```javascript
it('includes a C6 chain-map row', () => {
const md = generateStatus(/* фикстура как в существующих тестах */);
expect(md).toMatch(/C6 Chain map sync/);
});
```
NB: имя `generateStatus` и форму входа взять из существующего теста.
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/status-md-generator.test.mjs -t C6`
Expected: FAIL.
- [ ] **Step 3: Реализовать**
В `tools/status-md-generator.mjs`, в таблицу контролёров добавить строку C6 (по аналогии с C5). Поскольку чекер запускается в lefthook, статус в STATUS.md — информационный: вызвать `checkSync` через импорт и отразить ok/drift:
```javascript
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';
// ... при сборке таблицы контролёров:
let c6 = '✅';
let c6detail = '[chain-map-checker] OK';
try {
const md = readFileSync(join(OBS_ROOT, '..', 'routing-off-phase.md'), 'utf8'); // путь привести к реальному
const jsonMap = JSON.parse(readFileSync(CHAIN_MAP_PATH, 'utf8'));
const res = checkSync(jsonMap, parseChainsFromMd(md));
if (!res.ok) { c6 = '🔴'; c6detail = `drift: ${[...res.jsonOnly, ...res.mdOnly].join(', ')}`; }
} catch (e) { c6 = '⚠️'; c6detail = `checker error: ${e.message}`; }
// добавить строку в таблицу: | C6 Chain map sync | ${c6} | ${c6detail} |
```
NB: пути (`OBS_ROOT`, `CHAIN_MAP_PATH`) и формат строки таблицы взять из реального кода генератора.
- [ ] **Step 4: Запустить — убедиться, что проходит**
Run: `cd app && npx vitest run --config vitest.config.tools.mjs ../tools/status-md-generator.test.mjs`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
git commit -m "feat(status-md): surface C6 chain-map sync row"
```
---
## Task 8: aggregation-template секция hit rate
**Files:**
- Modify: `.claude/skills/brain-retro/references/aggregation-template.md`
- [ ] **Step 1: Заменить пустую секцию**
Найти `## Canonical chains L1L12 hit rate` и заменить на:
```markdown
## Canonical chains L1L13 hit rate (from analyzer `factorMatrix.chain_ref`)
| chain | times | outcome split | notes |
|---|---|---|---|
Каждый узел может входить в несколько L (multi-chain эпизод засчитан в каждую).
`null` = эпизоды вне цепочек (direct + узлы вне L1-L13) — **не проблема** per
`memory/feedback_brain_unused_tools_not_problem`.
```
- [ ] **Step 2: Commit**
```bash
git add .claude/skills/brain-retro/references/aggregation-template.md
git commit -m "docs(brain-retro): fill L1-L13 hit rate template section"
```
---
## Task 9: Финальная регрессия + ретрофилл + verification
**Files:** нет правок кода.
- [ ] **Step 1: Полная регрессия tools**
Run: `npm run test:tools`
Expected: PASS, число ≥ baseline (Task 0 Step 3) + новые тесты (≈ +16). 0 сломанных существующих.
- [ ] **Step 2: Ретрофилл dry-run**
Run: `node tools/observer-retrofill-chain-ref.mjs --dry-run`
Expected: для `episodes-2026-05.jsonl` — N/M строк получат chain_ref (N = число v2-эпизодов с known node).
- [ ] **Step 3: Ретрофилл реальный + идемпотентность**
Run:
```bash
node tools/observer-retrofill-chain-ref.mjs
node tools/observer-retrofill-chain-ref.mjs
```
Expected: первый — `changed > 0`; второй — `0/M` (идемпотентно).
- [ ] **Step 4: Commit ретрофилла данных**
```bash
git add docs/observer/episodes-2026-05.jsonl
git commit -m "chore(observer): retrofill chain_ref on existing May episodes"
```
- [ ] **Step 5: Verification-before-completion**
Использовать `superpowers:verification-before-completion`. Проверить acceptance criteria spec §13:
- 6 новых файлов созданы, тесты зелёные;
- lefthook job 16 red-green работает (Task 4);
- ретрофилл идемпотентен (Step 3);
- `node tools/observer-chain-map-checker.mjs` → OK;
- STATUS.md содержит строку C6.
- [ ] **Step 6: Финальный push**
```bash
git push origin feat/observer-chain-attribution:main
```
NB: gitleaks pre-push + полная регрессия по политике §15; push-паттерн `<ветка>:main` (FF).
---
## Self-Review (выполнено при написании плана)
**Spec coverage:** §4 архитектура → Tasks 1–8; §5 компоненты 1-8 → Tasks 1–8 (по одному); §6 потоки A/B/C/D → Tasks 2/4/6/5; §7 ошибки → defensive try/catch в Task 2 Step 3 (битый JSON) + Task 3 (формат .md) + Task 5 (идемпотентность); §8 тесты → Tasks 1/3/5/6/7; §10 порядок → Task 0; §13 acceptance → Task 9 Step 5. Все секции покрыты.
**Placeholder scan:** код приведён во всех code-степах. Места «NB: имя взять из существующего теста» — намеренные адаптационные точки (фабрики транскриптов и имена функций analyzer/generateStatus зависят от финальной базы после epic-плана и не могут быть зафиксированы заранее), не placeholder-логика.
**Type consistency:** `loadChainMap()`/`chainsFor(node, map)` — единые сигнатуры в Tasks 1/2/5/6. `parseChainsFromMd()`/`checkSync(jsonMap, mdSet)``{ ok, jsonOnly, mdOnly }` — единые в Tasks 3/4/7. `retrofillLine(ep, map)`/`retrofillFile(path, map, opts)` — Task 5. `chain_ref` форма (массив \| null) консистентна везде.
**Раскрытые при детализации уточнения** (в разделе «Уточнения к spec» вверху): тесты в `tools/` не `tests/`; C6 по L-номерам не по именам узлов; врезка по grep-маркеру.
@@ -0,0 +1,274 @@
# Observer Canonical Chain Attribution (L1L13) — Design Spec
**Дата:** 2026-05-20
**Автор:** controller Opus 4.7 (через `superpowers:brainstorming` skill, ответы заказчика → AskUserQuestion)
**Базовая ветка:** `feat/project-migration-redesign` (формально) → реальное исполнение в свежем worktree off `origin/main` ПОСЛЕ закрытия epic-плана `2026-05-20-observer-instrument-expansion v1.1` (20 task).
**Триггер:** [docs/observer/notes/2026-05-20-brain-retro-v2.md](../../observer/notes/2026-05-20-brain-retro-v2.md) — Candidate №2 «атрибуция canonical chains L1L13 в primary_rationale».
**Cross-refs:** [docs/routing-off-phase.md](../../routing-off-phase.md) v1.2 (L1–L13 таблица, строки 84–96); [docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md](2026-05-19-observer-factor-analysis-design.md) v1.2 (Layer 2 heuristic capture, `triggers_matched` уже извлекает routing-off-phase LN — но как trigger, не как attribute узла); ADR-011 (Brain governance anchor).
**Schema impact:** `schema_version` остаётся `2``chain_ref` это **опциональное** поле. Backward-compatible с v1/v2-эпизодами без него.
---
## 1. Проблема
В эпизодах наблюдателя (`docs/observer/episodes-YYYY-MM.jsonl`, schema v2) поле `primary_rationale.node_chosen` фиксирует **какой узел** Claude выбрал (например, `"superpowers:verification-before-completion"` или `"laravel-boost"` или `"direct"`). Но **нет ссылки на канонические цепочки L1–L13** из [docs/routing-off-phase.md](../../routing-off-phase.md) v1.2.
Из-за этого `/brain-retro` не может ответить на вопрос: «насколько часто Claude действительно ходит по правильным маршрутам цепочек?». В ретро-ноте 2026-05-20 секция «Canonical chains L1L12 hit rate» — **пустая**: «нет атрибуции `chain_ref`».
`extractTriggers` (Layer 2 heuristic из factor-analysis v1.2) **уже** извлекает упоминания `routing-off-phase LN` в `assistant.text` — но это маркер **триггера** (что Claude упомянул в обосновании), а не **атрибуция узла**: узел Boost #10 живёт в L7 + L13 независимо от того, упомянул ли Claude routing-таблицу в тексте.
## 2. Цель
Добавить **атрибут** `chain_ref` рядом с `node_chosen`, который связывает выбранный узел с одной или несколькими каноническими цепочками L1–L13. Это позволит `/brain-retro` строить гистограмму «L1: N раз, L2: M раз, …, вне цепочек: K раз» по любому периоду.
**Граница цели:** наблюдатель *регистрирует* атрибут, *не диктует* поведение. Если Claude выбрал узел вне L-таблицы — никаких блокировок (Pravila §16.4 «не использован ≠ проблема»).
## 3. Решения по архитектуре (приняты заказчиком через AskUserQuestion)
| # | Развилка | Выбор | Обоснование |
|---|---|---|---|
| 1 | Что для `direct`-эпизодов (узел не в L-таблице) | `chain_ref: null` | Честно: L1L13 — routing-цепочки для сложных задач; простые правки в них не входят. |
| 2 | Узел в нескольких L (Boost в L7+L13, Sentry в L8+L13, adr-kit в L4+L5) | Массив всех цепочек `["L7","L13"]` | Узел действительно в обеих; агрегатор считает обе. |
| 3 | Где живёт маппинг узел → цепочки | Отдельный JSON-файл + **тест сверки с `routing-off-phase.md`, врезанный в lefthook pre-commit** (Вариант Б, не А) | Дрейф ловится агрессивно: коммит, расходящийся с .md, блокируется. Pre-commit + Vitest используют одну логику. |
| 4 | Ретроактивность для 23 v2-эпизодов мая 2026 | Однократный ретрофилл-скрипт | ~30 строк, идемпотентный, даёт честную статистику с 19.05. |
## 4. Архитектура высокого уровня
К наблюдателю добавляется **один слой** — «chain attribution». Работает в трёх местах:
1. **При записи эпизода** (Stop-хук → парсер транскрипта): после выбора `node_chosen` парсер дополнительно вычисляет `chain_ref` через чистую функцию `chainsFor(node)`. Запись в JSONL — атомарная append-line, как и сейчас.
2. **При коммите** (lefthook pre-commit): контролёр **C6** сверяет JSON-маппинг с таблицей L1–L13 в `routing-off-phase.md`. Расхождение → коммит блокируется с человеко-читаемым diff'ом.
3. **При ретро** (`/brain-retro`): `brain-retro-analyzer.mjs` агрегирует гистограмму `factorMatrix.chain_ref` + `chainHitRate`. Шаблон `aggregation-template.md` заполняет секцию «Canonical chains L1L13 hit rate».
Существующие парсер/Stop-хук/routing-detector/choice-detector — **нетронуты**. Схема `schema_version: 2` сохраняется: `chain_ref` опционален; старые эпизоды без него по-прежнему валидны.
## 5. Компоненты
| # | Файл | Роль | Тип | Объём |
|---|---|---|---|---|
| 1 | `tools/observer-chain-map.json` | Таблица узел → массив цепочек, например `{"laravel-boost":["L7","L13"], "superpowers:verification-before-completion":[]}`. Человеко-читаемый | новый | ~60 строк |
| 2 | `tools/observer-chain-detector.mjs` | Чистая функция `chainsFor(nodeChosen)` → массив или `null`. Грузит JSON один раз, кэширует в Map | новый | ~40 строк |
| 3 | `tools/observer-transcript-parser.mjs` | **Точка врезки:** в формировании `primary_rationale` (текущая строка 450) — добавить `chain_ref: chainsFor(node_chosen)` | edit existing | +2 строки |
| 4 | `tools/observer-chain-map-checker.mjs` | Контролёр **C6**: парсит таблицу L1–L13 из `routing-off-phase.md`, сверяет с JSON-маппингом. Возвращает diff или OK | новый | ~80 строк |
| 5 | `lefthook.yml` | Новый job `chain-map-sync` в pre-commit запускает контролёр C6. Блокирует коммит при расхождении | edit existing | +5 строк |
| 6 | `tools/observer-retrofill-chain-ref.mjs` | Одноразовый скрипт: добавляет `chain_ref` к v2-эпизодам в `episodes-*.jsonl`. Атомарный (tmp + rename), идемпотентный | новый | ~30 строк |
| 7 | `tools/brain-retro-analyzer.mjs` | Дополняется `factorMatrix.chain_ref` (гистограмма) и `chainHitRate` (массив с процентами) | edit existing | +20 строк |
| 8 | `.claude/skills/brain-retro/references/aggregation-template.md` | Раздел «Canonical chains L1L13 hit rate» заполняется реальными данными из аналитика | edit existing | +5 строк |
**Тесты (отдельно, 2 файла):**
- `tests/observer-chain-detector.test.mjs` — юнит-тесты `chainsFor`: известный узел / multi-chain узел / неизвестный узел / `direct` / `null` / `undefined` / пустая строка. ~6 тестов, ~50 строк.
- `tests/observer-chain-map-sync.test.mjs` — интеграционный тест: запускает тот же C6-чекер, парсит `routing-off-phase.md`, сверяет с JSON. Та же логика что в pre-commit, один источник правды.
**Итого:** 4 новых файла в `tools/`, 2 новых теста, 4 точки правок в existing, +1 в `lefthook.yml`, +1 в SKILL-template.
## 6. Поток данных
### Поток A — запись эпизода (runtime, при каждом Stop-хуке)
```
Stop-хук → observer-transcript-parser.mjs (existing)
формирует primary_rationale (node_chosen, triggers_matched, …)
вызывает chainsFor(node_chosen) ← из observer-chain-detector.mjs (NEW)
↓ читает observer-chain-map.json (cached)
primary_rationale.chain_ref = ["L7","L13"] | null
append-line в docs/observer/episodes-YYYY-MM.jsonl
```
Дополнительная задержка — ~1–2 мс (lookup в `Map<string, string[]>` в памяти). JSON загружается один раз при первом вызове.
### Поток B — pre-commit сверка (раз в коммит)
```
git commit → lefthook → job chain-map-sync (NEW)
node tools/observer-chain-map-checker.mjs
parse routing-off-phase.md (таблица L1L13, строки 8496)
load observer-chain-map.json
diff: (узлы в .md без записи в JSON) ∪ (узлы в JSON без записи в .md) ∪ (L-список расходится)
exit 0 (синхронно) | exit 1 (расхождение + human-readable сообщение с подсказкой)
```
### Поток C — агрегация в /brain-retro (раз в спринт)
```
node tools/brain-retro-analyzer.mjs episodes-*.jsonl
читает все эпизоды (v2)
группирует по chain_ref (multi-chain эпизоды засчитываются в каждую L)
factorMatrix.chain_ref: {"L1":0, "L7":3, "L13":1, "null":19, …}
chainHitRate: [{chain:"L7", times:3, percent:"13.0%"}, …]
aggregation-template заполняет секцию «L1–L13 hit rate»
```
### Поток D — однократный ретрофилл
```
node tools/observer-retrofill-chain-ref.mjs [--dry-run]
для каждого episodes-*.jsonl:
↓ для каждой строки v2:
если chain_ref уже есть → skip (idempotent)
иначе: добавить chain_ref: chainsFor(node_chosen)
атомарная перезапись (write tmp → rename)
```
Запускается один раз вручную после внедрения. Повторный запуск — 0 changes.
**Что НЕ меняется:**
- `schema_version: 2``chain_ref` опциональное поле.
- 5 v1-эпизодов мая 2026 — пропускаются (нет `schema_version: 2` и нет `primary_rationale`).
- Stop-хук, parser-логика выбора `node_chosen`, routing-detector, choice-detector — не трогаем.
- Никаких новых hard-blockers для Claude. Pre-commit блокирует только дрейф маппинга.
## 7. Обработка ошибок
| # | Сценарий | Поведение |
|---|---|---|
| 1 | Узел не найден в маппинге (`chainsFor("foo-bar-baz")`) | Возвращает `null`. Эпизод пишется. Нормальная ситуация для direct/новых skills/строкового шума |
| 2 | `observer-chain-map.json` отсутствует или битый | Парсер ловит исключение, пишет `observer_error` маркер + `chain_ref: null`. Эпизод **всё равно записывается** — наблюдатель не падает. Контролёр C5 (observer-coverage-checker) увидит маркер и подсветит в STATUS.md |
| 3 | `.md` правят, JSON не обновили (дрейф) | Pre-commit `chain-map-sync` падает с понятным diff'ом: «в .md есть `ru-tax-accounting → L13`, в JSON нет — добавьте». Узлы из новых L-цепочек до добавления в JSON не атрибутируются (см. п. 1) |
| 4 | Формат таблицы в `.md` изменён (новая колонка, переименование L) | Парсер `chain-map-checker.mjs` падает с указанием строки: «не могу распарсить L7». Vitest даёт ту же ошибку до коммита |
| 5 | Retrofill-скрипт прерван на середине | Запись атомарная (tmp + rename) — файл всегда консистентен. Идемпотентность — повторный запуск пропускает уже обработанные строки |
| 6 | JSON ссылается на несуществующую L (например `L99`) | C6 ловит: «JSON ссылается на L99, в `.md` нет». exit 1 |
| 7 | v1-эпизоды (5 строк мая) | Пропускаются — у них нет `schema_version: 2` и `primary_rationale` |
## 8. Тестирование
**Юнит-тесты (`tests/observer-chain-detector.test.mjs`):**
- `chainsFor("laravel-boost")``["L7","L13"]`
- `chainsFor("superpowers:verification-before-completion")``null` (узел НЕ входит ни в одну L1–L13 → его нет в JSON-маппинге → `null`, как и для `direct`)
- `chainsFor("direct")``null`
- `chainsFor("unknown-node")``null`
- `chainsFor("")`, `chainsFor(null)`, `chainsFor(undefined)``null`
- ~6 тестов, ~50 строк.
**Единообразие формы (решено в self-review):** только две формы результата — непустой массив `["LN", …]` (узел есть в JSON) или `null` (узла нет в JSON: direct / узел вне цепочек / неизвестный / шум). Пустой массив `[]` НЕ используется — нет узлов «в маппинге, но без цепочек».
**Интеграционный тест синхронизации (`tests/observer-chain-map-sync.test.mjs`):**
- Использует тот же `chain-map-checker.mjs`, что и pre-commit.
- Падает, если кто-то добавил строку в `.md` без обновления JSON.
- Сообщение теста = сообщение pre-commit job.
**Сценарный тест парсера (опционально, если бюджет позволяет):**
- Подаёт фиктивный транскрипт с известным skill (`Boost`) → проверяет, что в результирующем эпизоде есть `chain_ref: ["L7","L13"]`.
- Подаёт транскрипт без skill (direct) → `chain_ref: null`.
- Подаёт с битым JSON → пишется `observer_error` + `chain_ref: null`.
**Smoke ретрофилла (ручной шаг после реализации):**
- `node tools/observer-retrofill-chain-ref.mjs --dry-run` → видим планируемые изменения.
- Если OK — без `--dry-run`. Идемпотентность проверяется повторным запуском (должно быть 0 changes).
**Что НЕ тестируем:**
- Скорость (1–2 мс заведомо некритично для Stop-хука).
- Все 60+ узлов руками — JSON + sync-тест уже это покрывают.
## 9. Initial JSON-маппинг (на момент дизайна, routing-off-phase.md v1.2)
Источник истины — таблица L1–L13 в [docs/routing-off-phase.md строки 8496](../../routing-off-phase.md#L84). Извлечение узлов из ячейки «Цепочка» — однократное руками при первой реализации, дальше контролёр C6 синхронизирует.
Примерный shape (демонстрация формата; финальный JSON генерируется при реализации):
```json
{
"_note": "Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, verification-before-completion, прочие skills вне L) НЕ включаются — chainsFor вернёт null.",
"discovery-interview": ["L1","L2"],
"superpowers:brainstorming": ["L1"],
"superpowers:writing-plans": ["L1"],
"superpowers:subagent-driven-development": ["L1"],
"audit-portal": ["L2"],
"process-analysis": ["L3"],
"process-modeling": ["L3","L4"],
"mermaid": ["L4"],
"adr-kit": ["L4","L5"],
"operations": ["L4"],
"architecture-patterns": ["L5"],
"deptrac": ["L5"],
"trail-of-bits": ["L6"],
"semgrep-mcp": ["L6"],
"security-guidance": ["L6"],
"security-review": ["L6"],
"openapi-mcp-server": ["L7"],
"api-docs": ["L7"],
"laravel-boost": ["L7","L13"],
"superpowers:systematic-debugging": ["L8"],
"sentry-mcp": ["L8","L13"],
"redis-mcp": ["L8","L13"],
"ccpm": ["L9"],
"product-management": ["L9"],
"github-mcp": ["L9"],
"promptfoo": ["L10"],
"data-scientist": ["L10"],
"claude-api": ["L10"],
"skill-creator": ["L11"],
"hookify": ["L11"],
"plugin-dev": ["L11"],
"claude-md-management": ["L12"],
"billing-audit": ["L13"],
"pest": ["L13"],
"ru-tax-accounting": ["L13"]
}
```
**Note для реализации:** имена узлов в JSON должны точно соответствовать значениям, которые парсер записывает в `node_chosen`. Если узел в `node_chosen` — это `"superpowers:verification-before-completion"`, то в JSON ключ такой же. Это валидируется C6 + sync-тестом.
## 10. Внешние зависимости и порядок исполнения
**Жёсткая зависимость:** этот spec исполняется **ПОСЛЕ** закрытия и push'а в `origin/main` эпик-плана [docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md](../plans/2026-05-20-observer-instrument-expansion.md) v1.1 (20 атомарных коммитов).
**Причина:** epic 20-task правит `observer-transcript-parser.mjs` в Tasks #1, #2, #4, #6, #7, #8, #9, #12, #13. Моя врезка `chain_ref` тоже в этом файле. Параллельное исполнение → merge-конфликт почти гарантирован.
**Процедура запуска работы по этому spec:**
1. Дождаться push'а epic 20-task на `origin/main` (контролёр или владелец сообщает «epic закрыт, push сделан»).
2. `git fetch origin && git log HEAD..origin/main --oneline` — убедиться, что 20 task реально влиты.
3. Создать свежий worktree `.claude/worktrees/observer-chain-attribution/` off `origin/main` (новая ветка `feat/observer-chain-attribution`).
4. Перейти к `superpowers:writing-plans` — детальный TDD-план по компонентам §5.
5. Исполнить через `superpowers:subagent-driven-development` (Sonnet/Opus only per Pravila §15.1).
6. Финальный push: `git push origin feat/observer-chain-attribution:main` (FF-merge).
## 11. Влияние на нормативку
**Не требует** правок Pravila / PSR_v1 / Tooling / CLAUDE.md / ADR.
- `chain_ref` — опциональное расширение schema v2, не нормативный сдвиг (как `task_cost` в epic-плане v1.1 — там тоже без правок Pravila).
- L1–L13 уже формализованы в `routing-off-phase.md` v1.2 (правлено 20.05 при finance-tooling).
- Контролёр C6 — добавление нового lefthook job, по аналогии с C1/C2/C3/C4/C5; в STATUS.md появится строка «C6 Chain map sync». Это организационная правка, не нормативная.
**Опционально:** при желании заказчика — micro-правка в Pravila §16.2 «Схема эпизода v2» с упоминанием `chain_ref` как опционального атрибута. Делается одним коммитом через `claude-md-management`. Не блокирует основную работу.
## 12. Из scope исключено (NOT this spec)
- **`chain_divergence` event** — заявлен в factor-analysis v1.2 §10 как phase-2 / agent-based (нужен LLM-судья «правильная ли цепочка»). Не в этом spec'е. `chain_ref` — это атрибутирование, а не суждение.
- **`triggers_matched: routing-off-phase L7` heuristic** — уже реализуется в epic-плане v1.1 Task #6 (reasoning capture). `chain_ref` — отдельный атрибут параллельно, не дубль.
- **Real-time блокировка «ушёл вне цепочки»** — спорная idea, противоречит Pravila §16.4 «не использован ≠ проблема». NOT this spec.
- **Авто-правки нормативки по результатам hit rate** — фантазия v3+. NOT this spec.
## 13. Acceptance criteria
Spec считается реализованным когда:
1. Все 6 новых файлов из §5 созданы, тесты проходят локально и в CI.
2. Pre-commit job `chain-map-sync` врезан в `lefthook.yml`, smoke-проверка red-green работает (намеренная рассинхронизация JSON ↔ .md → коммит блокируется; правильная синхронизация → проходит).
3. `node tools/observer-retrofill-chain-ref.mjs --dry-run` показывает планируемые изменения для всех v2-эпизодов в `episodes-2026-05.jsonl`; запуск без `--dry-run` добавляет `chain_ref`; повторный запуск → 0 changes.
4. `/brain-retro` следующего spring печатает непустую секцию «Canonical chains L1L13 hit rate» с реальными цифрами.
5. STATUS.md добавлена строка «C6 Chain map sync: ✅ — last sync OK».
6. Финальная регрессия `npm run test:tools` ≥ 331 + N (где N — число новых тестов из §8) GREEN.
+10
View File
@@ -196,6 +196,16 @@ pre-commit:
observer-coverage-checker reports a gap (coverage or registration).
See docs/observer/STATUS.md C5 row for details.
# 16. observer-chain-map-checker — brain governance C6 (chain attribution).
# Сверяет tools/observer-chain-map.json с таблицей L1-L13 в
# docs/routing-off-phase.md по множествам L-номеров (обе стороны). Блокирует
# коммит при дрейфе: несуществующая L в JSON или потерянная цепочка из .md.
- name: observer-chain-map-checker
run: node tools/observer-chain-map-checker.mjs
fail_text: |
observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md.
Обновите tools/observer-chain-map.json под таблицу L1-LN.
# Post-commit: regenerate STATUS.md dashboard (informational, not gate)
post-commit:
parallel: false
+12
View File
@@ -177,6 +177,18 @@ export function buildFactorMatrix(episodesWithOutcome) {
matrix[fname][val][outcome] = (matrix[fname][val][outcome] || 0) + 1;
}
}
// chain_ref is multi-value: a multi-chain episode counts once per chain;
// null/absent → key "null". Handled outside FACTOR_FNS (single-value loop).
matrix.chain_ref = {};
for (const e of episodesWithOutcome) {
const cr = (e.primary_rationale || {}).chain_ref;
const outcome = e._inferredOutcome || 'unknown';
const keys = Array.isArray(cr) && cr.length ? cr : ['null'];
for (const k of keys) {
matrix.chain_ref[k] = matrix.chain_ref[k] || {};
matrix.chain_ref[k][outcome] = (matrix.chain_ref[k][outcome] || 0) + 1;
}
}
return matrix;
}
+17
View File
@@ -230,6 +230,23 @@ describe('buildFactorMatrix — session_segment_turn axis rename (Task 14)', ()
});
});
describe('buildFactorMatrix — chain_ref axis (multi-chain)', () => {
it('counts a multi-chain episode in each chain and null for direct', () => {
const m = buildFactorMatrix([
{ _inferredOutcome: 'success', primary_rationale: { node_chosen: 'discovery-interview', chain_ref: ['L1', 'L2'] } },
{ _inferredOutcome: 'unknown', primary_rationale: { node_chosen: 'direct', chain_ref: null } },
]);
expect(m.chain_ref.L1).toEqual({ success: 1 });
expect(m.chain_ref.L2).toEqual({ success: 1 });
expect(m.chain_ref.null).toEqual({ unknown: 1 });
});
it('chain_ref axis present via analyze()', () => {
const result = analyze([ep({ primary_rationale: { node_chosen: 'billing-audit', chain_ref: ['L13'], task_classification: 'other' } })]);
expect(result.factorMatrix).toHaveProperty('chain_ref');
});
});
describe('inferOutcome — neutral → soft_success (Task 16)', () => {
it('returns soft_success when next prompt is neutral', () => {
const a = { events: [] };
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_MAP_PATH = join(__dirname, 'observer-chain-map.json');
/** Load the node->chains map. Throws on missing/invalid JSON (caller handles). */
export function loadChainMap(path = DEFAULT_MAP_PATH) {
const raw = JSON.parse(readFileSync(path, 'utf8'));
const map = new Map();
for (const [node, chains] of Object.entries(raw)) {
if (node === '_note') continue;
if (Array.isArray(chains) && chains.length > 0) map.set(node, chains);
}
return map;
}
/** node_chosen -> array of L-chains, or null if not in any chain. */
export function chainsFor(node, map) {
if (!node || typeof node !== 'string') return null;
const chains = map.get(node);
return chains && chains.length > 0 ? chains : null;
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
const map = loadChainMap();
describe('chainsFor', () => {
it('returns chain array for a single-chain node', () => {
expect(chainsFor('billing-audit', map)).toEqual(['L13']);
});
it('returns all chains for a multi-chain node', () => {
expect(chainsFor('discovery-interview', map)).toEqual(['L1', 'L2']);
});
it('returns null for direct', () => {
expect(chainsFor('direct', map)).toBeNull();
});
it('returns null for an unknown node', () => {
expect(chainsFor('totally-unknown-xyz', map)).toBeNull();
});
it('returns null for empty/null/undefined', () => {
expect(chainsFor('', map)).toBeNull();
expect(chainsFor(null, map)).toBeNull();
expect(chainsFor(undefined, map)).toBeNull();
});
it('ignores the _note metadata key', () => {
expect(chainsFor('_note', map)).toBeNull();
});
});
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Brain governance controller C6 — chain-map sync checker.
* Verifies tools/observer-chain-map.json against the L1-L13 table in
* docs/routing-off-phase.md. Sync is checked by L-number sets (both
* directions), not by node names — node_chosen values (skill-id) differ
* from the human display names in the .md table. Pure fs/regex, no LLM.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MD_PATH = join(__dirname, '..', 'docs', 'routing-off-phase.md');
const JSON_PATH = join(__dirname, 'observer-chain-map.json');
/** Extract the set of L-numbers ("L1".."L13") from the routing-off-phase.md table. */
export function parseChainsFromMd(md) {
const set = new Set();
for (const line of md.split(/\r?\n/)) {
const m = /^\|\s*(L\d+)\s*\|/.exec(line.trim());
if (m) set.add(m[1]);
}
return set;
}
/** Compare JSON L-numbers against the md set, both directions. */
export function checkSync(jsonMap, mdSet) {
const jsonSet = new Set();
for (const [node, chains] of Object.entries(jsonMap)) {
if (node === '_note') continue;
if (Array.isArray(chains)) for (const c of chains) jsonSet.add(c);
}
const jsonOnly = [...jsonSet].filter((c) => !mdSet.has(c)); // ссылки на несуществующие L
const mdOnly = [...mdSet].filter((c) => !jsonSet.has(c)); // потерянные цепочки
return { ok: jsonOnly.length === 0 && mdOnly.length === 0, jsonOnly, mdOnly };
}
/** CLI entry — exit 1 on drift with a human-readable message. */
function main() {
const md = readFileSync(MD_PATH, 'utf8');
const jsonMap = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
const mdSet = parseChainsFromMd(md);
if (mdSet.size === 0) {
console.error(
'[chain-map-checker] не нашёл ни одной L-строки в routing-off-phase.md — формат таблицы изменился?'
);
process.exit(1);
}
const res = checkSync(jsonMap, mdSet);
if (res.ok) {
console.log(`[chain-map-checker] OK — ${mdSet.size} chains in sync`);
process.exit(0);
}
console.error('[chain-map-checker] дрейф маппинга chain-map <-> routing-off-phase.md:');
if (res.jsonOnly.length)
console.error(` JSON ссылается на отсутствующие в .md цепочки: ${res.jsonOnly.join(', ')}`);
if (res.mdOnly.length)
console.error(
` В .md есть цепочки без записи в JSON: ${res.mdOnly.join(', ')} — добавьте узлы в tools/observer-chain-map.json`
);
process.exit(1);
}
if (process.argv[1]?.endsWith('observer-chain-map-checker.mjs')) {
main();
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';
const SAMPLE_MD = [
'| # | Цепочка | Зачем |',
'|---|---|---|',
'| L1 | `discovery-interview` (FEATURE) → `brainstorming` | text |',
'| L2 | `audit-portal` | text |',
'| L13 | `billing-audit` (#62) + `Pest` | text |',
].join('\n');
describe('parseChainsFromMd', () => {
it('extracts the set of L-numbers from the table', () => {
expect(parseChainsFromMd(SAMPLE_MD)).toEqual(new Set(['L1', 'L2', 'L13']));
});
});
describe('checkSync', () => {
it('passes when JSON L-numbers subset of md and md subset of json-union', () => {
const mdSet = new Set(['L1', 'L2', 'L13']);
const jsonMap = { a: ['L1'], b: ['L2'], c: ['L13'] };
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
});
it('fails when JSON references a chain absent from md', () => {
const mdSet = new Set(['L1', 'L2']);
const jsonMap = { a: ['L1'], b: ['L99'] };
const res = checkSync(jsonMap, mdSet);
expect(res.ok).toBe(false);
expect(res.jsonOnly).toContain('L99');
});
it('fails when md has a chain not covered by any JSON entry', () => {
const mdSet = new Set(['L1', 'L2', 'L14']);
const jsonMap = { a: ['L1'], b: ['L2'] };
const res = checkSync(jsonMap, mdSet);
expect(res.ok).toBe(false);
expect(res.mdOnly).toContain('L14');
});
it('ignores the _note metadata key in the JSON map', () => {
const mdSet = new Set(['L1']);
const jsonMap = { _note: 'meta', a: ['L1'] };
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
});
});
+41
View File
@@ -0,0 +1,41 @@
{
"_note": "node_chosen -> L-цепочки. Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, прочее) НЕ включаются -> chainsFor вернёт null. Имена ключей = реальные значения primary_rationale.node_chosen (skill-id из skill_invoked). MCP/agent-узлы (laravel-boost, openapi-mcp-server, api-docs, sentry-mcp, redis-mcp, pest, github-mcp) в node_chosen не появляются, но включены для полноты покрытия цепочек L1-L13 (контролёр C6 требует, чтобы каждая L из routing-off-phase.md была покрыта). Синхронизируется с docs/routing-off-phase.md через tools/observer-chain-map-checker.mjs.",
"discovery-interview": ["L1", "L2"],
"superpowers:brainstorming": ["L1"],
"superpowers:writing-plans": ["L1"],
"superpowers:subagent-driven-development": ["L1"],
"audit-portal": ["L2"],
"process-analysis": ["L3"],
"process-modeling": ["L3", "L4"],
"mermaid": ["L4"],
"adr-kit:adr": ["L4", "L5"],
"adr-kit:judge": ["L5"],
"operations": ["L4"],
"architecture-patterns:architecture-patterns": ["L5"],
"deptrac": ["L5", "L14"],
"rector": ["L14"],
"php-insights": ["L14"],
"larastan": ["L14"],
"laravel-backend-patterns": ["L14"],
"security-review": ["L6"],
"openapi-mcp-server": ["L7"],
"api-docs": ["L7"],
"laravel-boost": ["L7", "L13"],
"superpowers:systematic-debugging": ["L8"],
"sentry-mcp": ["L8", "L13"],
"redis-mcp": ["L8", "L13"],
"ccpm": ["L9"],
"product-management:brainstorm": ["L9"],
"github-mcp": ["L9"],
"promptfoo": ["L10"],
"data-scientist": ["L10"],
"claude-api": ["L10"],
"skill-creator:skill-creator": ["L11"],
"hookify:hookify": ["L11"],
"plugin-dev:create-plugin": ["L11"],
"claude-md-management:claude-md-improver": ["L12"],
"claude-md-management:revise-claude-md": ["L12"],
"billing-audit": ["L13"],
"pest": ["L13"],
"ru-tax-accounting": ["L13"]
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* One-shot retrofill: add primary_rationale.chain_ref to existing v2 episodes
* in docs/observer/episodes-*.jsonl. Idempotent (skips lines that already have
* chain_ref), atomic per file (tmp + rename). Pure fs, no LLM.
*
* Usage: node tools/observer-retrofill-chain-ref.mjs [--dry-run]
*/
import { readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OBS_DIR = join(__dirname, '..', 'docs', 'observer');
/** Add chain_ref to a single parsed episode object (pure). Idempotent. */
export function retrofillLine(ep, map) {
if (!ep || ep.schema_version !== 2 || !ep.primary_rationale) return ep;
if ('chain_ref' in ep.primary_rationale) return ep; // idempotent
ep.primary_rationale.chain_ref = chainsFor(ep.primary_rationale.node_chosen, map);
return ep;
}
/** Process one JSONL file atomically (tmp + rename). Returns {changed, total}. */
export function retrofillFile(path, map, { dryRun = false } = {}) {
const lines = readFileSync(path, 'utf8').split(/\r?\n/);
let changed = 0;
let total = 0;
const out = lines.map((line) => {
if (!line.trim()) return line;
total++;
const ep = JSON.parse(line);
const before = ep.primary_rationale && 'chain_ref' in ep.primary_rationale;
const next = retrofillLine(ep, map);
const after = next.primary_rationale && 'chain_ref' in next.primary_rationale;
if (!before && after) changed++;
return JSON.stringify(next);
});
if (!dryRun && changed > 0) {
const tmp = `${path}.tmp`;
writeFileSync(tmp, out.join('\n'), 'utf8');
renameSync(tmp, path);
}
return { changed, total };
}
function main() {
const dryRun = process.argv.includes('--dry-run');
const map = loadChainMap();
const files = readdirSync(OBS_DIR).filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f));
for (const f of files) {
const { changed, total } = retrofillFile(join(OBS_DIR, f), map, { dryRun });
console.log(`${dryRun ? '[dry-run] ' : ''}${f}: ${changed}/${total} lines get chain_ref`);
}
}
if (process.argv[1]?.endsWith('observer-retrofill-chain-ref.mjs')) main();
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { retrofillLine } from './observer-retrofill-chain-ref.mjs';
import { loadChainMap } from './observer-chain-detector.mjs';
const map = loadChainMap();
describe('retrofillLine', () => {
it('adds chain_ref to a v2 episode with a known node', () => {
const ep = { schema_version: 2, primary_rationale: { node_chosen: 'billing-audit' } };
const out = retrofillLine(ep, map);
expect(out.primary_rationale.chain_ref).toEqual(['L13']);
});
it('sets chain_ref null for a direct v2 episode', () => {
const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct' } };
expect(retrofillLine(ep, map).primary_rationale.chain_ref).toBeNull();
});
it('is idempotent — does not overwrite existing chain_ref', () => {
const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: ['L1'] } };
expect(retrofillLine(ep, map).primary_rationale.chain_ref).toEqual(['L1']);
});
it('skips v1 episodes (no schema_version 2)', () => {
const ep = { foo: 'bar' };
expect(retrofillLine(ep, map)).toEqual({ foo: 'bar' });
});
});
+9
View File
@@ -16,6 +16,14 @@
*/
import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
let CHAIN_MAP = null;
try {
CHAIN_MAP = loadChainMap();
} catch {
CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает
}
const SUPERPOWERS_PREFIX = 'superpowers:';
@@ -694,6 +702,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) {
return {
step: 1,
node_chosen: skills.length > 0 ? skills[0] : 'direct',
chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP),
triggers_matched: merge(extractTriggers(turn), tag ? tag.triggers : []),
candidates_considered: merge(extractCandidates(turn), tag ? tag.candidates : []),
boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []),
+19
View File
@@ -106,6 +106,25 @@ describe('parseTranscript', () => {
expect(parseTranscript(t).primary_rationale.node_chosen).toBe('direct');
});
it('attaches chain_ref for a node that belongs to a chain', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn(
[{ type: 'tool_use', id: 't1', name: 'Skill', input: { skill: 'billing-audit' } }],
'2026-05-19T10:01:00Z'
),
]);
expect(parseTranscript(t).primary_rationale.chain_ref).toEqual(['L13']);
});
it('sets chain_ref null for a direct episode', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
assistantTurn([{ type: 'tool_use', id: 't1', name: 'Read', input: {} }], '2026-05-19T10:01:00Z'),
]);
expect(parseTranscript(t).primary_rationale.chain_ref).toBeNull();
});
it('hard_floor invoked when a superpowers skill is used', () => {
const t = jsonl([
userPrompt('go', '2026-05-19T10:00:00Z'),
+3
View File
@@ -10,6 +10,7 @@ function iconFor(status) {
export function renderStatus(inputs) {
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo } = inputs;
const c6 = inputs.c6 || { status: 'ok', detail: '—' };
const retroLine = (lastRetroDaysAgo === null || lastRetroDaysAgo === undefined)
? 'never'
: `${lastRetroDaysAgo} day(s) ago`;
@@ -24,6 +25,7 @@ Last updated: ${now}
| C3 Observer-of-observer | ${iconFor(c3.status)} | ${c3.detail || '—'} |
| C4 Сигнальный статус | | This file (self-reference) |
| C5 Observer-coverage | ${iconFor(c5.status)} | ${c5.detail || '—'} |
| C6 Chain map sync | ${iconFor(c6.status)} | ${c6.detail || '—'} |
## Метрики (информационные, не алерты)
@@ -114,6 +116,7 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-
status: c5ok ? 'ok' : 'warn',
detail: [cov.coverage.detail, cov.registration.detail].join(' · '),
},
c6: runControllerNode(['tools/observer-chain-map-checker.mjs']),
observer: {
episodeCount: countEpisodes(),
observerErrors: countObserverErrors(),
+6
View File
@@ -7,6 +7,7 @@ const baseInputs = (overrides = {}) => ({
c2: { status: 'ok', detail: '0 version drift' },
c3: { status: 'ok', detail: 'last read today' },
c5: { status: 'ok', detail: 'coverage OK · registration OK' },
c6: { status: 'ok', detail: '14 chains in sync' },
observer: { episodeCount: 12, observerErrors: 0, piiMatches: 0 },
...overrides,
});
@@ -23,6 +24,11 @@ describe('renderStatus', () => {
expect(md).toContain('12 episodes');
});
it('includes a C6 chain-map row', () => {
const md = renderStatus(baseInputs());
expect(md).toContain('| C6 Chain map sync | ✅');
});
it('shows a warn status for the coverage controller', () => {
const md = renderStatus(baseInputs({ c5: { status: 'warn', detail: '3 commits, 0 episodes' } }));
expect(md).toContain('| C5 Observer-coverage | ⚠️');