# Router Stage 3 — three follow-up fixes 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:** Починить три дыры в работе сторожа warn-only: UTF-8 в stdin трёх хуков (русский в state-файл и Anthropic API без mojibake) + проброс рекомендации сторожа и прогресса цепочки из state-файла `~/.claude/runtime/router-state-<session>.json` в `primary_rationale` эпизодов наблюдателя.

**Architecture:** Три атомарных фикса. (1) `process.stdin.setEncoding('utf-8')` в трёх хуках. (2) Новый pure helper `readRouterState(sessionId, baseDir?)`. (3) Парсер эпизодов читает state-файл и расширяет `primary_rationale` тремя новыми полями + override существующего `recommended_node`. State-файл сторожа = source of truth для рекомендации (более точный, чем classification-map fallback).

**Tech Stack:** Node.js ESM, Vitest, существующие модули `tools/router-*.mjs` + `tools/observer-transcript-parser.mjs`.

**Spec:** `docs/superpowers/specs/2026-05-24-router-stage3-three-fixes-design.md` (commit `5f9bd07`).

**Прошлое:** Stage 3 Tasks 1-8 ✅ слиты в `origin/main` (commit `d030dbbe`); warn-only mode активен; UTF-8 + recommended_node + chain_progress дыры обнаружены 24.05.2026 при инспекции state.

---

## File Structure

**Создаём:**

- `tools/observer-state-enricher.mjs` — pure helper для чтения `~/.claude/runtime/router-state-<sessionId>.json`. Один экспорт: `readRouterState(sessionId, options?)`. Без fs если не запущен из CLI (DI через `options.baseDir`).
- `tools/observer-state-enricher.test.mjs` — unit-тесты helper'а (tmp-dir fixture).

**Модифицируем:**

- `tools/router-prehook.mjs` — добавить `process.stdin.setEncoding('utf-8')` перед циклом stdin.
- `tools/router-prehook.test.mjs` — +1 тест на UTF-8 stdin (mock с кириллицей).
- `tools/router-stop-gate.mjs` — то же.
- `tools/router-stop-gate.test.mjs` — +1 тест.
- `tools/router-tool-gate.mjs` — то же.
- `tools/router-tool-gate.test.mjs` — +1 тест.
- `tools/observer-transcript-parser.mjs` — внутри `primary_rationale` блока (строка ~809) вызвать `readRouterState`, override `recommended_node` приоритетом state-файла, добавить `recommended_chain` / `chain_progress` / `chain_completed`.
- `tools/observer-transcript-parser.test.mjs` — +1 fixture тест на enrichment из state-файла.

**Не трогаем (out of scope):**

- Нормативка (Pravila / CLAUDE.md / PSR_v1 / Tooling / ADR / docs/router-procedure.md).
- `.claude/settings.json` (хуки уже зарегистрированы).
- `~/.claude/runtime/router-gate-mode.json` (остаётся `warn-only`).
- Старые v1-эпизоды.

---

## Pre-flight

- [ ] **Step 1: Pre-flight sync с origin/main**

```bash
git fetch origin
git log HEAD..origin/main --oneline
```

Spec/plan уже на main (`5f9bd07`). Pre-flight только для информации.

- [ ] **Step 2: Worktree от свежего origin/main**

```powershell
git fetch origin
git worktree add ".claude/worktrees/router-stage3-three-fixes" -b feat/router-stage3-three-fixes origin/main
```

- [ ] **Step 3: Junction зависимостей (Windows quirk #108)**

```powershell
$wt = "c:\моя\проекты\портал crm\Документация\.claude\worktrees\router-stage3-three-fixes"
$main = "c:\моя\проекты\портал crm\Документация"
New-Item -ItemType Junction -Path "$wt\node_modules" -Target "$main\node_modules" -Force
New-Item -ItemType Junction -Path "$wt\app\node_modules" -Target "$main\app\node_modules" -Force
if (Test-Path "$wt\bin") { Remove-Item "$wt\bin" -Recurse -Force }
New-Item -ItemType Junction -Path "$wt\bin" -Target "$main\bin" -Force
```

- [ ] **Step 4: Зелёный baseline регрессии**

```bash
cd "<worktree>/app" && npx vitest run --config vitest.config.tools.mjs 2>&1 | tail -5
```

Expected: baseline GREEN (≥456 tests от Stage 3 Task 8).

---

## Task 1: UTF-8 stdin encoding в трёх хуках

**Files:**

- Modify: `tools/router-prehook.mjs:57` (внутри `main()` перед `for await` циклом).
- Modify: `tools/router-stop-gate.mjs:49` (то же).
- Modify: `tools/router-tool-gate.mjs:1264` (то же — внутри `main()`).
- Test: `tools/router-prehook.test.mjs` (+1 тест).
- Test: `tools/router-stop-gate.test.mjs` (+1 тест).
- Test: `tools/router-tool-gate.test.mjs` (+1 тест).

### Подход к TDD для хуков

Хуки читают stdin внутри `main()`, который не экспортируется. Тестировать корень утечки кодировки напрямую через `main()` сложно. Поэтому проверяем **косвенно** через две seam:

1. Запускаем хук как subprocess из теста (`child_process.spawnSync`) и шлём UTF-8 Buffer в stdin → читаем выходной state-файл → проверяем что в нём кириллица читаемая.
2. Альтернатива: вынести stdin-чтение в маленький экспортируемый helper `readStdinAsUtf8(stdinIterable)`, который принимает iterable стрима. Тестировать его легко.

**Выбираем helper-подход** — он быстрее, читабельнее, и пригодится третьему хуку без дублирования.

### Task 1.1: Helper `readStdinAsUtf8`

- [ ] **Step 1: Write failing test**

Создать `tools/router-stdin-helper.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';

async function* fromBuffers(buffers) {
  for (const b of buffers) yield b;
}

describe('readStdinAsUtf8', () => {
  it('decodes UTF-8 cyrillic correctly across chunk boundaries', async () => {
    const text = 'посмотри сторожа достаточно ему информации?';
    const buf = Buffer.from(text, 'utf-8');
    // Split across multi-byte boundary (UTF-8 cyrillic = 2 bytes per char)
    const mid = 9; // mid-byte split for 'посмо|три...'
    const result = await readStdinAsUtf8(fromBuffers([buf.subarray(0, mid), buf.subarray(mid)]));
    expect(result).toBe(text);
  });

  it('handles ASCII without modification', async () => {
    const text = 'hello world';
    const result = await readStdinAsUtf8(fromBuffers([Buffer.from(text)]));
    expect(result).toBe(text);
  });

  it('returns empty string on empty stream', async () => {
    const result = await readStdinAsUtf8(fromBuffers([]));
    expect(result).toBe('');
  });

  it('does NOT mangle byte-level concatenation (regression guard)', async () => {
    // The bug: `for await (const c of stdin) input += c` interprets Buffer
    // via Buffer.prototype.toString() = 'utf-8' by default in Node, BUT
    // concatenation across chunks at multi-byte boundary fails.
    // Our helper must use a StringDecoder to handle the boundary.
    const cyrillic = 'тест';
    const buf = Buffer.from(cyrillic, 'utf-8');
    // Split exactly in the middle of 'т' (2-byte char)
    const result = await readStdinAsUtf8(fromBuffers([buf.subarray(0, 1), buf.subarray(1)]));
    expect(result).toBe(cyrillic);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-stdin-helper.test.mjs
```

Expected: FAIL `Cannot find module './router-stdin-helper.mjs'`.

- [ ] **Step 3: Implement helper**

Создать `tools/router-stdin-helper.mjs`:

```javascript
#!/usr/bin/env node
/**
 * UTF-8 safe stdin reader for hooks.
 * Fixes Windows Node stdin quirk: default `for await (chunk of stdin)` interprets
 * chunks as Buffer, and `input += chunk` calls .toString() which uses utf-8 BUT
 * fails on chunk boundaries that fall inside multi-byte sequences (e.g. cyrillic
 * 2-byte chars split across chunks).
 *
 * Uses StringDecoder to handle multi-byte chars across chunks correctly.
 */

import { StringDecoder } from 'string_decoder';

export async function readStdinAsUtf8(stdin) {
  const decoder = new StringDecoder('utf-8');
  let out = '';
  for await (const chunk of stdin) {
    out += decoder.write(chunk);
  }
  out += decoder.end();
  return out;
}
```

- [ ] **Step 4: Run test to verify GREEN**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-stdin-helper.test.mjs
```

Expected: 4/4 PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/router-stdin-helper.mjs tools/router-stdin-helper.test.mjs
git commit -m "feat(router): UTF-8 safe stdin helper для трёх хуков

StringDecoder корректно собирает multi-byte chars (кириллица) через границы
chunk'ов stdin. Закрывает Windows Node quirk, при котором русский промпт
превращался в mojibake до отправки в Anthropic API (Layer 2 эскалация).

Stage 3 follow-up fix 1/3 (helper).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

### Task 1.2: Подключить helper к трём хукам

- [ ] **Step 1: Modify `tools/router-prehook.mjs`**

В верх файла рядом с другими imports:

```javascript
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
```

В функции `main()` заменить блок:

```javascript
  let input = '';
  for await (const chunk of process.stdin) input += chunk;
```

на:

```javascript
  const input = await readStdinAsUtf8(process.stdin);
```

- [ ] **Step 2: Modify `tools/router-stop-gate.mjs`**

Аналогично: добавить import, заменить тот же блок в `main()`:

```javascript
  const input = await readStdinAsUtf8(process.stdin);
```

- [ ] **Step 3: Modify `tools/router-tool-gate.mjs`**

Аналогично — нужно найти `for await (const chunk of process.stdin)` в его `main()` и заменить на тот же helper-вызов.

- [ ] **Step 4: Add regression test to each of 3 hook tests**

В `tools/router-prehook.test.mjs` добавить describe-блок:

```javascript
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
  it('preserves cyrillic in prompt through hook end-to-end', async () => {
    // This test verifies the import wiring — actual stdin handling is unit-tested
    // in router-stdin-helper.test.mjs. Here we assert prehook re-exports the import
    // OR (better) construct a fake stdin and verify state file content.
    // Minimal version: import the module and assert helper is wired.
    const mod = await import('./router-prehook.mjs');
    // No direct assertion possible — helper is used internally inside main().
    // Instead, smoke-check that import does NOT throw.
    expect(typeof mod.buildStateFromClassification).toBe('function');
  });
});
```

Аналогично для `router-stop-gate.test.mjs` и `router-tool-gate.test.mjs`.

> **Замечание:** прямой end-to-end тест хука потребовал бы spawn subprocess (медленно, хрупко на Windows). Реальная защита — unit-тесты `router-stdin-helper.test.mjs` (Task 1.1) + live smoke (Task 4). Эти 3 placeholder-теста — для траектории «import работает после правки» и для регрессионного маркера.

- [ ] **Step 5: Run all hook tests**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/router-prehook.test.mjs ../tools/router-stop-gate.test.mjs ../tools/router-tool-gate.test.mjs
```

Expected: all existing tests PASS + 3 new tests PASS.

- [ ] **Step 6: Commit**

```bash
git add tools/router-prehook.mjs tools/router-stop-gate.mjs tools/router-tool-gate.mjs \
        tools/router-prehook.test.mjs tools/router-stop-gate.test.mjs tools/router-tool-gate.test.mjs
git commit -m "feat(router): подключить UTF-8 helper к трём хукам (stage 3 follow-up 1)

router-prehook, router-stop-gate, router-tool-gate теперь читают stdin
через readStdinAsUtf8 (StringDecoder). Русский в промпте корректно
доходит до Anthropic API и в state-файл — никаких mojibake типа
'РїРѕСЃРјРѕС‚СЂРё'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 2: Helper `observer-state-enricher.mjs`

**Files:**

- Create: `tools/observer-state-enricher.mjs`
- Test: `tools/observer-state-enricher.test.mjs`

**Зачем.** Дать парсеру эпизодов pure-функцию для чтения state-файла сторожа. Изоляция: helper не знает про парсер, парсер не знает про детали state-формата. Тестируется с tmp-dir fixture.

- [ ] **Step 1: Write failing tests**

Создать `tools/observer-state-enricher.test.mjs`:

```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { readRouterState } from './observer-state-enricher.mjs';

describe('readRouterState', () => {
  let baseDir;

  beforeEach(() => {
    baseDir = mkdtempSync(join(tmpdir(), 'router-state-test-'));
  });

  afterEach(() => {
    rmSync(baseDir, { recursive: true, force: true });
  });

  it('returns null when state file does not exist', () => {
    expect(readRouterState('abc-123', { baseDir })).toBeNull();
  });

  it('reads state file when present', () => {
    const state = {
      sessionId: 'abc-123',
      classification: { recommendedNode: '#62', recommendedChain: '#13' },
      chainProgress: ['brainstorming'],
      chainCompleted: false,
    };
    writeFileSync(join(baseDir, 'router-state-abc-123.json'), JSON.stringify(state));
    const result = readRouterState('abc-123', { baseDir });
    expect(result).toEqual(state);
  });

  it('returns null on malformed JSON', () => {
    writeFileSync(join(baseDir, 'router-state-broken.json'), 'not-json');
    expect(readRouterState('broken', { baseDir })).toBeNull();
  });

  it('returns null on missing sessionId', () => {
    expect(readRouterState(null, { baseDir })).toBeNull();
    expect(readRouterState('', { baseDir })).toBeNull();
  });

  it('uses ~/.claude/runtime/ as default baseDir', () => {
    // Smoke-check: default baseDir resolution doesn't throw.
    // Real-file reading covered above with explicit baseDir.
    const result = readRouterState('non-existent-session-xyz');
    // Either null (file doesn't exist there) or object — both fine.
    expect(result === null || typeof result === 'object').toBe(true);
  });
});

describe('extractRouterFields', () => {
  it('extracts the four fields from state, defaulting to null/empty', async () => {
    const { extractRouterFields } = await import('./observer-state-enricher.mjs');
    const state = {
      classification: { recommendedNode: '#62', recommendedChain: '#13' },
      chainProgress: ['brainstorming', 'writing-plans'],
      chainCompleted: false,
    };
    expect(extractRouterFields(state)).toEqual({
      recommended_node: '#62',
      recommended_chain: '#13',
      chain_progress: ['brainstorming', 'writing-plans'],
      chain_completed: false,
    });
  });

  it('returns nulls/empty when state is null', async () => {
    const { extractRouterFields } = await import('./observer-state-enricher.mjs');
    expect(extractRouterFields(null)).toEqual({
      recommended_node: null,
      recommended_chain: null,
      chain_progress: [],
      chain_completed: false,
    });
  });

  it('handles missing classification block', async () => {
    const { extractRouterFields } = await import('./observer-state-enricher.mjs');
    expect(extractRouterFields({ chainProgress: ['x'], chainCompleted: true })).toEqual({
      recommended_node: null,
      recommended_chain: null,
      chain_progress: ['x'],
      chain_completed: true,
    });
  });
});
```

- [ ] **Step 2: Run test — FAIL**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-state-enricher.test.mjs
```

Expected: FAIL `Cannot find module`.

- [ ] **Step 3: Implement helper**

Создать `tools/observer-state-enricher.mjs`:

```javascript
#!/usr/bin/env node
/**
 * Router state enricher for observer episodes.
 * Reads ~/.claude/runtime/router-state-<sessionId>.json and exposes pure
 * extraction helpers for primary_rationale enrichment.
 *
 * Pure-ish — fs is parameterized via options.baseDir for testability.
 *
 * Per spec: docs/superpowers/specs/2026-05-24-router-stage3-three-fixes-design.md
 */

import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

function defaultBaseDir() {
  return join(homedir(), '.claude', 'runtime');
}

export function readRouterState(sessionId, options = {}) {
  if (!sessionId || typeof sessionId !== 'string') return null;
  const baseDir = options.baseDir || defaultBaseDir();
  const path = join(baseDir, `router-state-${sessionId}.json`);
  if (!existsSync(path)) return null;
  try {
    const content = readFileSync(path, 'utf-8');
    return JSON.parse(content);
  } catch {
    return null;
  }
}

export function extractRouterFields(state) {
  if (!state || typeof state !== 'object') {
    return { recommended_node: null, recommended_chain: null, chain_progress: [], chain_completed: false };
  }
  const cls = state.classification || {};
  return {
    recommended_node: cls.recommendedNode ?? null,
    recommended_chain: cls.recommendedChain ?? null,
    chain_progress: Array.isArray(state.chainProgress) ? state.chainProgress : [],
    chain_completed: state.chainCompleted === true,
  };
}
```

- [ ] **Step 4: Run tests — GREEN**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-state-enricher.test.mjs
```

Expected: 8/8 PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/observer-state-enricher.mjs tools/observer-state-enricher.test.mjs
git commit -m "feat(observer): state enricher helper для эпизодов (stage 3 follow-up 2)

readRouterState(sessionId, {baseDir}) — pure read state-файла сторожа.
extractRouterFields(state) — pure извлечение 4 полей для primary_rationale.

Используется парсером эпизодов на следующем шаге.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 3: Парсер — обогащение `primary_rationale` из state-файла

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:809-828` (внутри `primary_rationale` IIFE в `parseTranscript`).
- Test: `tools/observer-transcript-parser.test.mjs` (+1 fixture-тест).

- [ ] **Step 1: Add failing test**

Найти существующий describe-блок в `tools/observer-transcript-parser.test.mjs`. Добавить новый:

```javascript
describe('parseTranscript — router state enrichment (stage 3 fix 2+3)', () => {
  let baseDir;

  beforeEach(() => {
    baseDir = mkdtempSync(join(tmpdir(), 'parser-state-test-'));
  });

  afterEach(() => {
    rmSync(baseDir, { recursive: true, force: true });
  });

  it('enriches primary_rationale with state-file recommendation when available', () => {
    const sessionId = 'test-session-abc';
    const state = {
      classification: { recommendedNode: '#62', recommendedChain: '#13' },
      chainProgress: ['brainstorming'],
      chainCompleted: false,
    };
    writeFileSync(join(baseDir, `router-state-${sessionId}.json`), JSON.stringify(state));

    // Minimal transcript with one user→assistant→Stop turn
    const transcript = [
      JSON.stringify({ type: 'user', sessionId, message: { content: 'почини баланс клиента' } }),
      JSON.stringify({ type: 'assistant', sessionId, message: { content: [{ type: 'text', text: 'ok' }] } }),
      JSON.stringify({ type: 'assistant', sessionId, attachment: { type: 'hook_success', hookEvent: 'Stop', hookName: 'Stop:*', stdout: '' } }),
    ].join('\n');

    const result = parseTranscript(transcript, sessionId, { routerStateBaseDir: baseDir });
    expect(result.episodes).toHaveLength(1);
    const pr = result.episodes[0].primary_rationale;
    expect(pr.recommended_node).toBe('#62');
    expect(pr.recommended_chain).toBe('#13');
    expect(pr.chain_progress).toEqual(['brainstorming']);
    expect(pr.chain_completed).toBe(false);
  });

  it('falls back to classification-map when state file absent', () => {
    const sessionId = 'no-state-session';
    const transcript = [
      JSON.stringify({ type: 'user', sessionId, message: { content: 'давай сделаем новую фичу' } }),
      JSON.stringify({ type: 'assistant', sessionId, message: { content: [{ type: 'text', text: 'ok' }] } }),
      JSON.stringify({ type: 'assistant', sessionId, attachment: { type: 'hook_success', hookEvent: 'Stop', hookName: 'Stop:*', stdout: '' } }),
    ].join('\n');
    const result = parseTranscript(transcript, sessionId, { routerStateBaseDir: baseDir });
    const pr = result.episodes[0].primary_rationale;
    // recommended_node may come from classification-map fallback (feature → #19 if live).
    // recommended_chain / chain_progress / chain_completed default to null/[]/false.
    expect(pr.recommended_chain).toBeNull();
    expect(pr.chain_progress).toEqual([]);
    expect(pr.chain_completed).toBe(false);
  });
});
```

Также наверх файла добавить imports (если ещё не там):

```javascript
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
```

- [ ] **Step 2: Run test — FAIL**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs -t "router state enrichment"
```

Expected: FAIL — `recommended_chain` пока `undefined` (поле не существует в эпизоде).

- [ ] **Step 3: Modify `tools/observer-transcript-parser.mjs`**

Шаг 3.1 — добавить import в верхнюю часть файла рядом с остальными:

```javascript
import { readRouterState, extractRouterFields } from './observer-state-enricher.mjs';
```

Шаг 3.2 — найти сигнатуру `parseTranscript`. Сейчас (строка 754):

```javascript
export function parseTranscript(transcriptText, fallbackSessionId = null) {
```

Расширить:

```javascript
export function parseTranscript(transcriptText, fallbackSessionId = null, options = {}) {
```

Шаг 3.3 — в начале функции (после извлечения sessionId) добавить:

```javascript
  const routerStateBaseDir = options.routerStateBaseDir; // undefined → default ~/.claude/runtime/
  const routerState = readRouterState(sessionId, { baseDir: routerStateBaseDir });
  const routerFields = extractRouterFields(routerState);
```

(Поместить ДО формирования эпизодов, чтобы был доступен в IIFE.)

Шаг 3.4 — заменить блок `primary_rationale` IIFE (строки ~809-828) на:

```javascript
    primary_rationale: (() => {
      const tag = parseReasoningTag(turn);
      const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])];
      const fallbackRecommended = recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy());
      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 : []),
        hard_floor: usedSuperpowers
          ? { invoked: true, rules: ['Pravila §12'] }
          : { invoked: false, rules: [] },
        task_classification: classifyTask(prompt),
        // Stage 3 fix 2+3: router state-файл — source of truth для рекомендации.
        // Fallback на classification-map когда state-файла нет.
        recommended_node:
          routerFields.recommended_node !== null
            ? routerFields.recommended_node
            : (skills.length === 0 ? fallbackRecommended : null),
        recommended_chain: routerFields.recommended_chain,
        chain_progress: routerFields.chain_progress,
        chain_completed: routerFields.chain_completed,
      };
    })(),
```

- [ ] **Step 4: Run test — GREEN**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs ../tools/observer-transcript-parser.test.mjs
```

Expected: все существующие тесты + 2 новых PASS.

- [ ] **Step 5: Full tools suite regression**

```bash
cd app && npx vitest run --config vitest.config.tools.mjs 2>&1 | tail -3
```

Expected: GREEN. Прирост ≥ helper(4) + helper-tests(8) + parser(2) = 14 новых тестов. Если есть фейлы — фиксим до commit.

- [ ] **Step 6: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): обогащение primary_rationale из router-state (stage 3 follow-up 3)

parseTranscript теперь читает ~/.claude/runtime/router-state-<session>.json
(через observer-state-enricher) и заполняет 4 поля primary_rationale:
- recommended_node (state приоритет, fallback classification-map)
- recommended_chain (только из state)
- chain_progress (только из state)
- chain_completed (только из state)

Закрывает дыры 2 и 3 из spec follow-up: brain-retro domainHitRate
и chainCompletionRate теперь имеют данные.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

## Task 4: Smoke + continuity + push (controller, Opus)

**Этот шаг — НЕ для субагента.** Делается контроллером (Opus) после Task 1-3. Включает ручной smoke на живой сессии.

**Files:**

- Modify: `docs/observer/active-projects.md` (mark stage 3 follow-up closed).
- Modify: `docs/observer/STATUS.md` (auto-regenerated).
- Outside-repo: `memory/project_router_overhaul.md` (controller updates).
- Outside-repo: `memory/MEMORY.md` (entry update).

- [ ] **Step 1: Финальная регрессия в worktree**

```bash
cd <worktree>/app && npx vitest run --config vitest.config.tools.mjs 2>&1 | tail -5
```

Expected: baseline + ≥14 новых tests, всё GREEN.

- [ ] **Step 2: gitleaks**

```bash
./bin/gitleaks.exe detect --no-banner --redact 2>&1 | tail -3
```

Expected: `0 leaks`.

- [ ] **Step 3: Update `docs/observer/active-projects.md`**

В разделе про router overhaul изменить статус этапа 3 — поставить пометку «follow-up 3 dырs закрыт <дата>».

Если файла или раздела нет — пропустить (он необязательный); сделать вместо этого commit-pointer в Memory.

- [ ] **Step 4: Regenerate STATUS.md**

```bash
node tools/status-md-generator.mjs
```

- [ ] **Step 5: Commit continuity**

```bash
git add docs/observer/active-projects.md docs/observer/STATUS.md
git commit -m "docs(continuity): stage 3 follow-up закрыт — 3 fixes + STATUS regen

UTF-8 + recommended_node + chain_progress теперь работают.
Финальная регрессия: <X>f / <Y>t GREEN. gitleaks 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

- [ ] **Step 6: Push на main (FF)**

```bash
git push origin feat/router-stage3-three-fixes:main
```

Expected: FF успешно, без force.

Если push отклонён (origin/main ушёл вперёд) — pre-flight, rebase на свежий origin/main, повторить.

- [ ] **Step 7: Memory update (outside-repo, controller-only)**

Обновить `memory/project_router_overhaul.md` (или эквивалент): добавить запись «24.05 follow-up — 3 fixes pushed `<sha>`».
Обновить `MEMORY.md` если строка ссылается на router overhaul.

- [ ] **Step 8: LIVE smoke test (ручной шаг — НЕ для субагента)**

После push'а — рестарт Claude Code; в новой сессии:

1. Прислать русский размытый промпт: «проверь как сторож обрабатывает финансовые задачи клиента».
2. После завершения turn'а — открыть `~/.claude/runtime/router-state-<новая-sessionId>.json`. Проверить: в `classification.reasoning` — читаемая кириллица (если Layer 2 эскалировал), без `РїРѕСЃРјРѕС‚СЂРё`.
3. Открыть последнюю строку `docs/observer/episodes-2026-05.jsonl`. Проверить:
   - `primary_rationale.recommended_node` — не null если сторож порекомендовал.
   - `primary_rationale.recommended_chain` — заполнен или null.
   - `primary_rationale.chain_progress` — массив.
   - `primary_rationale.chain_completed` — bool.

Если что-то из перечисленного не работает — открыть новый план «follow-up to follow-up» (НЕ латать в этом spec).

- [ ] **Step 9: Worktree cleanup**

```bash
cd <main-repo-path>
git worktree remove .claude/worktrees/router-stage3-three-fixes
git branch -D feat/router-stage3-three-fixes  # ветка влита в main, можно удалить
```

---

## Acceptance Criteria (Definition of Done)

- ✅ Helper `router-stdin-helper` создан + 4 теста GREEN.
- ✅ Три хука используют helper.
- ✅ Helper `observer-state-enricher` создан + 8 тестов GREEN.
- ✅ Парсер эпизодов читает state и пишет 4 новых поля в primary_rationale.
- ✅ Полный tools suite GREEN (baseline 456 + ≥14 новых = ≥470).
- ✅ gitleaks 0.
- ✅ Live smoke: русский в state без mojibake; 4 поля в новом эпизоде заполнены.
- ✅ Push на `main` FF.
- ✅ Worktree удалён.

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

---

## Self-Review

### 1. Spec coverage

| Spec требование | Task | Покрыто |
|---|---|---|
| Fix 1 — UTF-8 в trех хуках (§3.1) | Task 1.1 + 1.2 | ✅ |
| Fix 2 — recommended_node в эпизоды (§3.2) | Task 2 + 3 | ✅ |
| Fix 3 — chain_progress / chain_completed / recommended_chain (§3.3) | Task 2 + 3 (single helper покрывает оба) | ✅ |
| Unit-тесты на каждый фикс (§4) | Task 1.1 (helper 4), Task 1.2 (3 placeholders), Task 2 (8), Task 3 (2) | ✅ |
| Live smoke на русском промпте (§4 + §8) | Task 4 Step 8 | ✅ |
| gitleaks 0 (§8) | Task 4 Step 2 | ✅ |
| Откат ≤5 минут (§8) | Каждый task — atomic commit; revert по одному. | ✅ |
| Push на main FF (§7) | Task 4 Step 6 | ✅ |
| Worktree per Pravila §15.1 | Pre-flight Step 2 | ✅ |
| Subagent последовательно (§7) | Tasks 1-3 — Sonnet; Task 4 — controller Opus | ✅ |

### 2. Placeholder scan

- ❌ Никаких «TBD», «TODO», «implement later», «add error handling», «similar to Task N» в коде шагов.
- ✅ Все code blocks показывают конкретный код, который нужно вставить.
- ⚠️ Task 1.2 Step 4 — три regression placeholder-теста минимальны. Это осознанное решение (объяснено: end-to-end stdin тест хука требует spawn subprocess, что хрупко на Windows; реальная защита — helper unit-тесты + live smoke). НЕ placeholder в смысле «дописать позже» — это финальный код этих тестов.

### 3. Type consistency

- `readStdinAsUtf8(stdin)` → `Promise<string>` — везде то же.
- `readRouterState(sessionId, options?)` → `object | null` — везде то же.
- `extractRouterFields(state)` → `{recommended_node, recommended_chain, chain_progress, chain_completed}` — везде то же.
- `parseTranscript(text, fallbackSessionId?, options?)` — третий параметр options обратносовместим (default `{}`).
- Поля `primary_rationale`: `recommended_node` (string|null), `recommended_chain` (string|null), `chain_progress` (array), `chain_completed` (bool) — consistent в spec + helper + parser + тестах.

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — диспетчирую свежего субагента на каждую таску (Task 1, 2, 3 — Sonnet; Task 4 — controller Opus сам), review между тасками, быстрая итерация.

**2. Inline Execution** — выполняю в этой сессии через `superpowers:executing-plans`, batch с checkpoint'ами на ревью.

**Какой подход?**
