diff --git a/docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md b/docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md new file mode 100644 index 00000000..3d22b5e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md @@ -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-.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-.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 "/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 " +``` + +### 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 " +``` + +--- + +## 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-.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 " +``` + +--- + +## 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-.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 " +``` + +--- + +## 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 /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 теперь работают. +Финальная регрессия: f / t GREEN. gitleaks 0. + +Co-Authored-By: Claude Opus 4.7 " +``` + +- [ ] **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 ``». +Обновить `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 +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` — везде то же. +- `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'ами на ревью. + +**Какой подход?**