# Машина 3 — Контроль разговорной фазы 2b (части с ИИ) — TDD-план

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) или superpowers:executing-plans. Steps use checkbox (`- [ ]`).
> **Этот репозиторий:** код строит САМ контроллер inline. `commit` — owner-gated. Тест-файл расширяем целиком через Write (TDD-гейт отбивает import-only Edit).

**Goal:** Достроить контроль разговорной фазы LLM-частями: лёгкий узкий per-ответ проход «всплывшая нужда» (§3) и оркестратор круга, связывающий код-проверку ($0, 2a) + роутер-верность смысла (`runVerityCheck`, чертёж №1) + решение 3-прохода→владелец (2a).

**Architecture:** Новый focused-модуль `router-emerge.mjs` (лёгкий проход; рассуждение у модели, `llmCall` мокается; проверки механические) + расширение `round-control.mjs` оркестратором `runRoundControl`, который ДЕКУПЛИРОВАН от LLM через инъекцию `verityCall` (предсвязанный `runVerityCheck`). Опирается на 2a (codeRoundCheck/decideRoundOutcome) и чертёж №1 (runVerityCheck). DRY: парсер JSON из `router-engine`.

**Tech Stack:** Node.js ESM (`.mjs`), vitest tools-only.

**Источник:** spec `2026-06-05-router-conversational-input-and-emergent-catch-design.md` §3 (лёгкий per-ответ проход), §3А реш.3 (роутер-верность смысла: ответы легли в план; мягкий возврат). §7 R2 (стоимость лёгкого прохода — мерим в D28).

**Границы 2b (НЕ здесь):**
- Live-wiring (`callAnthropicAPI`, регистрация settings.json) — шаг владельца + D28.
- Выходная лесенка (роутер-верность спеки на стыке разговор→стройка) — отдельный чертёж №3.
- Доставка/STATUS — Машина 7.

---

## File Structure

| Файл | Ответственность | Действие |
|---|---|---|
| `tools/router-emerge.mjs` | Лёгкий узкий проход «всплывшая нужда»: `buildEmergePrompt`, `validateEmergeTrace`, `runEmergeCheck`. | **Create** |
| `tools/router-emerge.test.mjs` | TDD-покрытие; `llmCall` мокается. | **Create** |
| `tools/round-control.mjs` | +`runRoundControl` (оркестратор: код + verityCall + решение). | **Modify** (добавить функцию) |
| `tools/round-control.test.mjs` | +describe на `runRoundControl`; `verityCall` мокается. | **Modify** (Write полного файла) |

---

## Task 1: `router-emerge.mjs` — лёгкий узкий per-ответ проход

**Files:**
- Create: `tools/router-emerge.mjs`
- Test: `tools/router-emerge.test.mjs`

- [ ] **Step 1: Написать падающий тест**

```js
import { describe, it, expect } from 'vitest';
import { buildEmergePrompt, validateEmergeTrace, runEmergeCheck } from './router-emerge.mjs';

describe('buildEmergePrompt (чистая {system,user}; узкий — только последний ответ + план)', () => {
  const base = { lastAnswer: 'и сделай отступ побольше', plan: 'план: правлю роутер' };
  it('детерминирована', () => {
    expect(buildEmergePrompt(base).system).toBe(buildEmergePrompt(base).system);
    expect(buildEmergePrompt(base).user).toBe(buildEmergePrompt(base).user);
  });
  it('system: «только этот ответ», не весь пересчёт; user несёт последний ответ + план', () => {
    const { system, user } = buildEmergePrompt(base);
    expect(system).toMatch(/только.*последн|узк/i);
    expect(system).toMatch(/НЕ пересчитывай|не пересчит/i);
    expect(user).toMatch(/сделай отступ побольше/);
    expect(user).toMatch(/план: правлю роутер/);
  });
});

describe('validateEmergeTrace (форма: emerged массив; пункт {item непустой})', () => {
  it('корректный (с нуждой) → ok', () => {
    expect(validateEmergeTrace({ emerged: [{ item: 'отступ', detail: 'мобильный поедет' }] }).ok).toBe(true);
  });
  it('пустой emerged → ok (ничего нового — норм)', () => {
    expect(validateEmergeTrace({ emerged: [] }).ok).toBe(true);
  });
  it('нет массива emerged → не ok', () => {
    expect(validateEmergeTrace({}).ok).toBe(false);
  });
  it('пункт без item → badItems', () => {
    const r = validateEmergeTrace({ emerged: [{ detail: 'x' }] });
    expect(r.ok).toBe(false); expect(r.badItems).toContain('emerged[0]');
  });
});

describe('runEmergeCheck (оркестратор; llmCall мокается; hasEmerged кодом)', () => {
  const base = { lastAnswer: 'сделай отступ', plan: 'p' };
  it('нашёл нужду → ok + hasEmerged true', async () => {
    const r = await runEmergeCheck({ ...base, llmCall: async () => ({ emerged: [{ item: 'отступ' }] }) });
    expect(r.ok).toBe(true); expect(r.hasEmerged).toBe(true); expect(r.emerged.length).toBe(1);
  });
  it('ничего нового → ok + hasEmerged false', async () => {
    const r = await runEmergeCheck({ ...base, llmCall: async () => ({ emerged: [] }) });
    expect(r.ok).toBe(true); expect(r.hasEmerged).toBe(false);
  });
  it('строка с JSON-fence → парсится (DRY)', async () => {
    const r = await runEmergeCheck({ ...base, llmCall: async () => '```json\n{"emerged":[]}\n```' });
    expect(r.ok).toBe(true); expect(r.hasEmerged).toBe(false);
  });
  it('мусор → ok=false', async () => {
    const r = await runEmergeCheck({ ...base, llmCall: async () => null });
    expect(r.ok).toBe(false);
  });
  it('исключение → ok=false, reason про сбой', async () => {
    const r = await runEmergeCheck({ ...base, llmCall: async () => { throw new Error('net'); } });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/сбой|модел/i);
  });
});
```

- [ ] **Step 2: Запустить — RED**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-emerge`
Expected: FAIL — `Cannot find module './router-emerge.mjs'`.

- [ ] **Step 3: Реализация**

```js
#!/usr/bin/env node
/**
 * router-emerge — лёгкий узкий per-ответ проход «всплывшая нужда» (spec 2026-06-05
 * §3): после КАЖДОГО ответа роутер смотрит ТОЛЬКО последний ответ + план и ищет
 * озвученную нужду, которой в плане нет (не полный пересчёт — дёшево). При батче
 * ответов мелочь тонет; отдельный взгляд на каждый ответ даёт чистый шанс поймать.
 * РАССУЖДЕНИЕ — у модели (llmCall мокается); проверки вокруг — механические.
 */
import { parseRouterResponse } from './router-engine.mjs';

function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; }

/** Построитель промпта лёгкого прохода (чистый, {system,user}). Узкий: только последний ответ + план. */
export function buildEmergePrompt({ lastAnswer = '', plan = null } = {}) {
  const planText = plan == null ? '(плана пока нет)' : (typeof plan === 'string' ? plan : JSON.stringify(plan));
  const system = [
    'Ты — РОУТЕР-НАСТАВНИК, ЛЁГКИЙ УЗКИЙ ПРОХОД (всплывшая нужда). Смотри ТОЛЬКО последний ответ владельца ниже.',
    'Вопрос один: есть ли в ЭТОМ последнем ответе озвученная нужда/просьба (напр. «сделай отступ»), которой НЕТ в плане? НЕ пересчитывай весь план — только этот ответ.',
    'Ничего нового нет → emerged пустой. Это нормально.',
    'ВЫВОД — строго JSON: {"emerged":[{"item":"…","detail":"…"}]}. item — непустой.',
  ].join('\n');
  const user = [
    '--- ПОСЛЕДНИЙ ОТВЕТ ВЛАДЕЛЬЦА ---',
    lastAnswer,
    '--- ТЕКУЩИЙ ПЛАН ---',
    planText,
  ].join('\n');
  return { system, user };
}

/** Механический валидатор: emerged — массив (может быть пуст); пункт {item непустой, detail?}. */
export function validateEmergeTrace(trace) {
  const badItems = [];
  if (!trace || typeof trace !== 'object' || !Array.isArray(trace.emerged))
    return { ok: false, badItems, reason: 'нет массива emerged' };
  trace.emerged.forEach((it, i) => {
    if (!it || typeof it !== 'object' || !isNonEmptyString(it.item)) badItems.push(`emerged[${i}]`);
  });
  return { ok: badItems.length === 0, badItems };
}

/**
 * Оркестратор лёгкого прохода: построить → llmCall (инъекция) → распарсить →
 * провалидировать форму → вернуть emerged. hasEmerged считает КОД из массива.
 */
export async function runEmergeCheck({ lastAnswer = '', plan = null, llmCall }) {
  let trace;
  try { trace = await llmCall({ buildPrompt: () => buildEmergePrompt({ lastAnswer, plan }) }); }
  catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; }
  if (typeof trace === 'string') trace = parseRouterResponse(trace);
  if (!trace) return { ok: false, reason: 'пустой/неразборный ответ', trace: null };
  const v = validateEmergeTrace(trace);
  if (!v.ok) return { ok: false, reason: `невалидный emerge: ${v.reason || ''} пункты [${v.badItems.join(', ')}]`, trace };
  return { ok: true, emerged: trace.emerged, hasEmerged: trace.emerged.length > 0, trace };
}
```

- [ ] **Step 4: Запустить — GREEN**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-emerge`
Expected: PASS.

- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/router-emerge.mjs tools/router-emerge.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): router-emerge — лёгкий per-ответ проход всплывшей нужды"
```

---

## Task 2: `round-control.mjs` — оркестратор круга `runRoundControl`

**Files:**
- Modify: `tools/round-control.mjs` (добавить `runRoundControl` в конец)
- Test: `tools/round-control.test.mjs` (Write полного файла: 2a-describes + новый describe)

- [ ] **Step 1: Написать падающий тест** (Write полного `round-control.test.mjs` — 2a-блоки + новый блок; импорт +`runRoundControl`)

```js
import { describe, it, expect } from 'vitest';
import { codeRoundCheck, ROUND_OUTCOMES, decideRoundOutcome, runRoundControl } from './round-control.mjs';
import { addQuestion, answerQuestion } from './question-slots.mjs';

describe('codeRoundCheck ($0 — скил вызван по журналу + нет повисших вопросов)', () => {
  it('всё закрыто → ok', () => {
    let s = addQuestion([], { id: 'q1', text: 't' });
    s = answerQuestion(s, 'q1', 'a');
    const r = codeRoundCheck({ slots: s, invokedSkills: ['brainstorming'], requiredSkills: ['brainstorming'] });
    expect(r.ok).toBe(true); expect(r.reasons).toEqual([]);
  });
  it('повисший вопрос → not ok + причина', () => {
    const s = addQuestion([], { id: 'q1', text: 't' });
    const r = codeRoundCheck({ slots: s, invokedSkills: [], requiredSkills: [] });
    expect(r.ok).toBe(false); expect(r.reasons.some((x) => /повис/i.test(x))).toBe(true);
  });
  it('требуемый скил не вызван (журнал) → not ok + причина', () => {
    const r = codeRoundCheck({ slots: [], invokedSkills: [], requiredSkills: ['writing-plans'] });
    expect(r.ok).toBe(false); expect(r.reasons.some((x) => /не вызван/i.test(x))).toBe(true);
  });
});

describe('decideRoundOutcome (§3А — 3 прохода → владелец; двойной терминатор)', () => {
  it('код+смысл сошлись → proceed', () => {
    expect(decideRoundOutcome({ codeOk: true, routerFaithful: true }).outcome).toBe('proceed');
  });
  it('пробел и проходы не исчерпаны → soft-return', () => {
    expect(decideRoundOutcome({ codeOk: false, routerFaithful: true, roundCount: 1, maxRounds: 3 }).outcome).toBe('soft-return');
  });
  it('пробел и проходы исчерпаны → escalate-owner', () => {
    expect(decideRoundOutcome({ codeOk: true, routerFaithful: false, roundCount: 3, maxRounds: 3 }).outcome).toBe('escalate-owner');
  });
  it('ROUND_OUTCOMES — закрытый список', () => {
    expect(ROUND_OUTCOMES).toEqual(['proceed', 'soft-return', 'escalate-owner']);
  });
});

describe('runRoundControl (оркестратор: код + роутер-верность смысла + решение)', () => {
  const okSlots = () => { let s = addQuestion([], { id: 'q1', text: 't' }); return answerQuestion(s, 'q1', 'a'); };
  it('код ok + смысл верен → proceed', async () => {
    const r = await runRoundControl({ slots: okSlots(), invokedSkills: ['b'], requiredSkills: ['b'], verityCall: async () => ({ ok: true, faithful: true }) });
    expect(r.outcome).toBe('proceed'); expect(r.routerFaithful).toBe(true);
  });
  it('код ok, смысл искажён (faithful=false) → soft-return (есть проходы)', async () => {
    const r = await runRoundControl({ slots: okSlots(), invokedSkills: ['b'], requiredSkills: ['b'], roundCount: 0, verityCall: async () => ({ ok: true, faithful: false }) });
    expect(r.outcome).toBe('soft-return');
  });
  it('повисший вопрос (код not ok) → soft-return даже при верном смысле', async () => {
    const s = addQuestion([], { id: 'q1', text: 't' });
    const r = await runRoundControl({ slots: s, verityCall: async () => ({ ok: true, faithful: true }) });
    expect(r.outcome).toBe('soft-return'); expect(r.codeCheck.ok).toBe(false);
  });
  it('сбой verityCall → routerFaithful=false (безопасно, пробел)', async () => {
    const r = await runRoundControl({ slots: okSlots(), invokedSkills: ['b'], requiredSkills: ['b'], verityCall: async () => { throw new Error('net'); } });
    expect(r.routerFaithful).toBe(false); expect(r.outcome).not.toBe('proceed');
  });
  it('пробел и проходы исчерпаны → escalate-owner', async () => {
    const r = await runRoundControl({ slots: okSlots(), invokedSkills: ['b'], requiredSkills: ['b'], roundCount: 3, maxRounds: 3, verityCall: async () => ({ ok: true, faithful: false }) });
    expect(r.outcome).toBe('escalate-owner');
  });
});
```

- [ ] **Step 2: Запустить — RED**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" round-control`
Expected: FAIL — `runRoundControl is not a function` (5 новых тестов; прежние 7 проходят).

- [ ] **Step 3: Реализация (добавить функцию в конец `round-control.mjs`)**

```js
/**
 * Оркестратор круга (§3А реш.3): связывает код-проверку ($0) + роутер-верность
 * смысла (verityCall — предсвязанный runVerityCheck: ответы легли в план?) +
 * решение (3 прохода → владелец). verityCall инъецируется (round-control не знает
 * про LLM). Сбой верности → routerFaithful=false (не подтвердили → пробел, безопасно).
 */
export async function runRoundControl({ slots = [], invokedSkills = [], requiredSkills = [], roundCount = 0, maxRounds = 3, verityCall } = {}) {
  const codeCheck = codeRoundCheck({ slots, invokedSkills, requiredSkills });
  let verity;
  try { verity = await verityCall(); } catch { verity = { ok: false, faithful: false, reason: 'сбой верности' }; }
  const routerFaithful = !!(verity && verity.ok && verity.faithful);
  const decision = decideRoundOutcome({ codeOk: codeCheck.ok, routerFaithful, roundCount, maxRounds });
  return { outcome: decision.outcome, reason: decision.reason, codeCheck, verity, routerFaithful };
}
```

- [ ] **Step 4: Запустить — GREEN**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" round-control`
Expected: PASS (7 + 5 = 12).

- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/round-control.mjs tools/round-control.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): runRoundControl — оркестратор круга (код + верность + решение)"
```

---

## Task 3: Регрессия tools-only (R5)

**Files:** нет правок.

- [ ] **Step 1: Полный прогон**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs"` *(`dangerouslyDisableSandbox=true`)*
Expected: PASS — базлайн 2292 (после 2a) + router-emerge (~13) + runRoundControl (+5) = ~2310. Ноль RED.

- [ ] **Step 2: Доложить владельцу строку результата.** Push — отдельно по «да».

---

## Self-Review (против §3 / §3А)

| Требование | Где |
|---|---|
| §3 лёгкий узкий per-ответ проход (только последний ответ + план, не пересчёт) | `buildEmergePrompt` + `runEmergeCheck` (Task 1) |
| §3 «ничего нового → пусто» допустимо | `validateEmergeTrace` пустой emerged ok (Task 1) |
| §3А реш.3 роутер-верность смысла (ответы легли в план) | `runRoundControl` через `verityCall` (Task 2) |
| §3А реш.3 связка код + смысл → мягкий возврат/эскалация | `runRoundControl` → `decideRoundOutcome` (Task 2) |
| безопасность при сбое ИИ | сбой `verityCall` → `routerFaithful=false` (Task 2) |

**Placeholder-скан:** код полный. **Type-consistency:** `emerged`/`hasEmerged`/`item`/`verityCall`/`routerFaithful`/`codeCheck`/`outcome` — единые; `parseRouterResponse` реальный экспорт; `codeRoundCheck`/`decideRoundOutcome` — из 2a (тот же файл). **Scope:** LLM-надстройка на 2a-фундаменте; `verityCall` декуплирует от router-verity (тестируется моком). Live-wiring (реальный `runVerityCheck` через `callAnthropicAPI`) — шаг владельца.
