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:
@@ -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'ами на ревью.
|
||||
|
||||
**Какой подход?**
|
||||
Reference in New Issue
Block a user