docs(router-stage3): план — 3 follow-up фикса с TDD-шагами

Декомпозиция: Task 1 (UTF-8 helper + 3 хука), Task 2 (state-enricher),
Task 3 (parser enrichment), Task 4 (smoke + continuity + push).

Subagent-driven последовательно: Task 1-3 Sonnet, Task 4 controller Opus.
Worktree от свежего origin/main + junction'ы. Финал — push на main FF.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-24 15:20:23 +03:00
parent 36ada767f4
commit 136bad4db2
@@ -0,0 +1,809 @@
# 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'ами на ревью.
**Какой подход?**