# Router Stage 3 — three follow-up fixes (design)

**Дата:** 2026-05-24
**Автор:** Claude Opus 4.7 + Дмитрий (controller)
**Контекст:** этап 3 router discipline overhaul (Tasks 1-8) уже на `origin/main` в режиме warn-only. После рестарта Claude Code и подключения `ANTHROPIC_API_KEY` (Layer 2 эскалация) обнаружены три дыры, мешающие осмысленному прогону `/brain-retro` и переключению в `enforce`.
**Связано с:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md` (anchor spec этапа); `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md` (план Tasks 1-10); ADR-011 (brain governance).
**Тип:** bugfix + интеграционное расширение. Размер: ~3 файла кода, ~3 теста, ≤80 строк нетто.

---

## 1. Problem statement

Сторож (классификатор + UserPromptSubmit/PreToolUse/Stop хуки) работает в warn-only с 24.05.2026. Накоплено 16 сессий. При инспекции state-файлов и эпизодов наблюдателя видны три дефекта:

### Дефект 1 — UTF-8 mojibake в state-файле сторожа

В `~/.claude/runtime/router-state-<session>.json` `classification.reasoning` (приходит от Anthropic API) содержит mojibake вместо кириллицы пользовательского промпта. Пример (фактический state 24.05.2026, session `f4c8ef62`):

```
"reasoning": "Ambiguous Russian query 'РїРѕСЃРјРѕС‚СЂРё СЃС‚РѕСЂРѕР¶Р° РґРѕСЃС‚Р°С‚РѕС‡РЅРѕ РµРјСѓ РёРЅС„РѕСЂРјР°С†РёРё?' ..."
```

Симптом — классический cp1251→UTF-8 misinterpret. На Windows Node.js `process.stdin` по умолчанию выдаёт `Buffer`'ы, конкатенация через `+=` к строке без явного `setEncoding('utf-8')` интерпретирует байты как latin-1, теряя multi-byte UTF-8 sequences. Битая строка уезжает в `body.messages[0].content` запроса к Anthropic API, Haiku видит мусор и классифицирует «по шуму».

**Влияние:** Layer 2 эскалация на русских промптах работает по испорченному тексту → рекомендации мимо. Layer 1 (regex по байтам — это всё ещё валидная UTF-8 в `event.prompt`, парсинг JSON через `JSON.parse` сохраняет правильно) работает корректно, но размытые русские задачи всё равно попадают в Layer 2.

### Дефект 2 — `recommended_node` отсутствует в эпизодах наблюдателя

В эпизодах `docs/observer/episodes-2026-05.jsonl` поле `primary_rationale.recommended_node` пустое во всех 5 последних эпизодах (sample 24.05.2026). При этом state-файл сторожа содержит `classification.recommendedNode`. Модуль `tools/observer-recommended-node.mjs` существует (создан в Stage 2 batch), но не подключён к `observer-transcript-parser.mjs` либо подключён без сохранения результата.

**Влияние:** `brain-retro-analyzer.mjs` Stage 3 Task 9 будет считать `domainHitRate` (matched recommendation vs node_chosen) — без поля в эпизоде эта ось показывает 0. Метрика «правильный ли узел я выбрал» не работает.

### Дефект 3 — `chain_progress` отсутствует в эпизодах

Аналогично дефекту 2: state-файл хранит `chainProgress` (массив), `chainCompleted` (bool), `classification.recommendedChain` (строка). `tools/router-stop-gate.mjs` обновляет state на Stop event. `tools/observer-chain-detector.mjs` существует. Но `primary_rationale.chain_progress` / `chain_completed` / `recommended_chain` в эпизодах пусты.

**Влияние:** `chainCompletionRate` (Stage 3 Task 9) тоже показывает 0. Метрика «довёл ли цепочку до конца» не работает.

---

## 2. Goals / Non-goals

### Goals

- G1. Русский язык в пользовательском промпте корректно (без mojibake) попадает в state-файл и в запрос к Anthropic API.
- G2. `primary_rationale.recommended_node` в каждом новом эпизоде наблюдателя = `classification.recommendedNode` из state-файла сторожа (или `null`, если state-файла нет).
- G3. `primary_rationale.chain_progress`, `chain_completed`, `recommended_chain` в каждом новом эпизоде наблюдателя = соответствующие поля из state-файла сторожа.
- G4. Smoke-test на живой сессии после фиксов: один русский размытый промпт → читаемый русский в `reasoning` + три указанных поля заполнены в новом эпизоде.
- G5. Регрессия GREEN: полный прогон `tools/*` (≥456 tests, baseline Stage 3 Task 8 + новые).

### Non-goals

- NG1. Извлечение `triggers_matched` / `candidates_considered` / `boundaries_applied` из reasoning Claude (отдельный кандидат brain-retro #6 от 20.05).
- NG2. Конвертация v1-эпизодов 19.05 (~5 записей) в v3 задним числом.
- NG3. Расширение словаря классификатора (Candidate 7 brain-retro 20.05).
- NG4. Переключение в `enforce` mode (это отдельный шаг — Task 9 плана Stage 3, после 24h warn-only post-fix наблюдения).
- NG5. Изменение `~/.claude/settings.json` (хуки уже зарегистрированы Task 8 ebca54f0).
- NG6. Правка нормативки (Pravila / CLAUDE.md / PSR_v1 / Tooling / ADR / docs/router-procedure.md) — это Stage 4.

---

## 3. Architecture

Три независимых фикса, общий контекст — state-файл сторожа `~/.claude/runtime/router-state-<session>.json` как single source of truth между prehook (запись), tool-gate (чтение), stop-gate (обновление) и observer-parser (чтение и обогащение эпизода).

### 3.1. Fix 1 — UTF-8 stdin encoding в двух хуках

**Файлы:** `tools/router-prehook.mjs`, `tools/router-stop-gate.mjs`.

**Изменение:** перед циклом `for await (const chunk of process.stdin)` вставить:

```javascript
process.stdin.setEncoding('utf-8');
```

Это директива Node.js, переключающая stdin readable stream в text mode с явным UTF-8 декодером. После этого `chunk` приходит как `string`, конкатенация `input += chunk` сохраняет code points без потерь.

**Альтернатива (рассмотрена и отвергнута):** читать как `Buffer.concat([...])` и затем `.toString('utf-8')` явно. Эквивалентно по результату, но менее идиоматично и требует переписать цикл.

**Также трогаем третий хук:** `router-tool-gate.mjs` тоже читает stdin (PreToolUse event). Для текущих полей (`tool_input.command`/`path`) кодировка не критична (ASCII пути/команды), но для единообразия и защиты от будущих расширений (если хук начнёт логировать prompt-фрагменты) — добавляем `setEncoding('utf-8')` и сюда. Стоимость — одна строка + один регрессионный тест.

### 3.2. Fix 2 — recommended_node в эпизод

**Файлы:** `tools/observer-transcript-parser.mjs` (модификация), `tools/observer-recommended-node.mjs` (существует, используем как есть).

**Подход:** в функции построения `primary_rationale` объекта эпизода вызвать helper из `observer-recommended-node.mjs`, который читает state-файл по `sessionId` (из контекста эпизода) и возвращает `recommendedNode`. Сохранить в `primary_rationale.recommended_node`.

Помощник уже умеет:

- Принять `sessionId` и базовый путь `~/.claude/runtime/`.
- Прочитать `router-state-<sessionId>.json`, вернуть `null` если файла нет.
- Извлечь `classification.recommendedNode`.

Если helper не делает что-то из перечисленного — расширим его (TDD).

### 3.3. Fix 3 — chain_progress + chain_completed + recommended_chain в эпизод

**Файлы:** `tools/observer-transcript-parser.mjs` (та же модификация что Fix 2 — добавляем в тот же блок).

**Подход:** ровно как Fix 2, в том же чтении state-файла достаём `chainProgress`, `chainCompleted`, `classification.recommendedChain` и заполняем `primary_rationale.chain_progress`, `chain_completed`, `recommended_chain` соответственно.

Можно вытащить в общий helper «обогатить primary_rationale из state-файла» — единый вызов, три поля. Это **рекомендуемый** вариант (1 read state-файла на эпизод вместо 2 для отдельных helper'ов).

### 3.4. Data flow

```
UserPromptSubmit
   ↓ stdin (русский UTF-8) [Fix 1: setEncoding]
router-prehook.mjs
   ↓ classify() → state.classification.{recommendedNode, recommendedChain, reasoning}
~/.claude/runtime/router-state-<session>.json

Stop event
   ↓ stdin (turn_events) [Fix 1: setEncoding профилактически]
router-stop-gate.mjs
   ↓ updateChainProgress() → state.{chainProgress, chainCompleted, skillInvokedThisTurn}
~/.claude/runtime/router-state-<session>.json (тот же файл)

Observer Stop event (parallel hook)
   ↓ обходит transcript JSONL
observer-transcript-parser.mjs
   ↓ [Fix 2/3: enrichFromState(sessionId)]
   ↓ primary_rationale.{recommended_node, chain_progress, chain_completed, recommended_chain}
docs/observer/episodes-2026-05.jsonl
```

---

## 4. Testing

### Unit tests

| Тест | Что проверяем | Fix |
|---|---|---|
| `router-prehook.test.mjs` +1 | mock stdin: Buffer с кириллицей в UTF-8 → main() → state.classification.reasoning без mojibake | 1 |
| `router-stop-gate.test.mjs` +1 | mock stdin: Buffer с кириллицей в JSON-поле → main() не падает, не портит state | 1 |
| `router-tool-gate.test.mjs` +1 | mock stdin: Buffer с UTF-8 → main() не падает; решение decideDecision не меняется | 1 (защита) |
| `observer-recommended-node.test.mjs` +1-2 | mock state-файл → helper извлекает recommendedNode/recommendedChain/chainProgress/chainCompleted | 2+3 (расширение helper'а) |
| `observer-transcript-parser.test.mjs` +1 | fixture эпизода + mock state-файла → собранный эпизод имеет все 4 поля в primary_rationale | 2+3 integration |

### Smoke test (live, после деплоя)

После рестарта Claude Code:

1. Прислать русский размытый промпт (например, «проверь как сторож обрабатывает сложные клиентские задачи»).
2. Проверить `~/.claude/runtime/router-state-<новая-session>.json`:
   - В `classification.reasoning` — читаемая кириллица (если Layer 2 эскалировал).
3. После Stop текущего хода — посмотреть последний эпизод в `docs/observer/episodes-2026-05.jsonl`:
   - `primary_rationale.recommended_node` ≠ null (или явно `null` если состояние подсказывает).
   - `primary_rationale.recommended_chain` — заполнено.
   - `primary_rationale.chain_progress` — массив (пустой ОК, если скилы не вызывались).

### Regression suite

`cd app && npx vitest run --config vitest.config.tools.mjs` — все тесты GREEN, прирост ≥3 новых tests.

---

## 5. Risks & mitigations

| Риск | Митигация |
|---|---|
| `setEncoding('utf-8')` ломает существующее поведение на не-кириллических промптах | Существующие тесты `router-prehook.test.mjs` сейчас работают на ASCII-фикстурах — добавление setEncoding не должно повлиять. Прогон полного suite до коммита. |
| `observer-recommended-node.mjs` уже имеет тесты, расширение ломает существующие | TDD: новые тесты пишем сначала, существующие зелёные — ок; красные — переписываем helper аккуратно. |
| Корень UTF-8 в другом месте (не в stdin) — фикс не помогает | Smoke-test на живой сессии — если mojibake остался, делаем диагностику отдельно (не в этом spec). Cost: 1 час смок-теста. |
| `enrichFromState` не находит state-файл (для эпизодов в сессиях ДО раскатки сторожа) | Helper возвращает `null`/пустоту — primary_rationale.recommended_node = null. Это поведение для legacy ОК. |
| state-файл устарел / другой сессии (race) | sessionId в эпизоде и в state-файле должен совпадать; mismatch → null. Helper это уже учитывает. |
| Параллельная сессия Claude в этом же чекауте правит те же файлы | Pre-flight перед каждым коммитом + worktree (Pravila §15.1). |

---

## 6. Rollback

Каждый фикс — 1-3 строки кода + тест. Откат:

- Fix 1: `git revert <commit>` или удалить строку `setEncoding`.
- Fix 2/3: `git revert <commit>` или убрать вызов `enrichFromState`. primary_rationale.recommended_node вернётся в `undefined`/`null` (текущее состояние).

Никаких миграций данных, никаких backup'ов. Старые эпизоды не трогаем — они остаются без новых полей (это ожидаемо).

---

## 7. Execution layout

- **Where:** изолированный worktree от свежего `origin/main` (per Pravila §15.1 — git-commit tasks через Sonnet/Opus, не Haiku).
- **Branches:** `feat/router-stage3-three-fixes`.
- **Subagent vs inline:** subagent-driven последовательно — 3 Sonnet субагента, по одному на фикс (фиксы малы, параллельность даст negligible выигрыш + усложнит review). Closure (continuity + STATUS regen + push) — controller (Opus).
- **Commits:** 1 commit per fix + 1 closure commit (continuity + STATUS regen + push).
- **Push target:** `main` (FF merge).

---

## 8. Acceptance criteria

После всех фиксов и smoke:

- ✅ Smoke-русский в state-файле новой сессии без mojibake.
- ✅ Smoke-эпизод имеет 4 новых поля в `primary_rationale` (recommended_node, recommended_chain, chain_progress, chain_completed).
- ✅ Полный tools-suite GREEN (baseline 456 + ≥4 новых).
- ✅ gitleaks 0.
- ✅ Откатываемость ≤5 минут.
- ✅ STATUS.md регенерирован.
- ✅ Push на `main` без force, без skipped hooks.

После этого 24h warn-only наблюдения **с уже починенным сторожем** → `/brain-retro` → решение по enforce (Task 9 плана Stage 3).

---

## 9. References

- Spec этапа 3: `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`
- План этапа 3 Tasks 1-10: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`
- ADR-011 Brain governance.
- Brain-retro первое (20.05.2026): `docs/observer/notes/2026-05-20-brain-retro.md` — кандидаты #2/#5/#6.
- State file format: `tools/router-prehook.mjs:40-50` (`buildStateFromClassification`).
- Helper recommended-node: `tools/observer-recommended-node.mjs`.
- Helper chain-detector: `tools/observer-chain-detector.mjs`.
