# Машина 3 — Контроль разговорной фазы 2a (механический фундамент) — TDD-план

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

**Goal:** Построить чистый ($0, без ИИ) механический фундамент контроля разговорной фазы: вопросы-слоты с id (R7), каденс-диспетчер + размер-страховка (§3), код-проверка круга и решение круга 3-прохода→владелец (§3А реш.3/2).

**Architecture:** Три focused-модуля чистых функций в `tools/`, иммутабельные операции, инъекция фактов (никакого project-хардкода, никакого I/O — журнал читается при live-wiring позже). Это нижний слой; LLM-части (лёгкий per-ответ проход «всплывшая нужда» + оркестратор круга) — отдельный под-план 2b, надстраивается сверху.

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

**Источник:** `docs/superpowers/specs/2026-06-05-router-conversational-input-and-emergent-catch-design.md` §3 (каденс + размер-страховка), §3А реш.2 (3 прохода → владелец) / реш.3 (покруговой контроль: код существование + мягкий возврат), §7 R7 (вопросы как слоты-id — условие). Принцип «умный не баран» (master-roadmap): защита/проверки — тупо-механические.

**Границы 2a (НЕ здесь):**
- Лёгкий per-ответ проход «всплывшая нужда» (router-emerge, ИИ) — под-план 2b.
- Оркестратор круга (связка код-проверка + роутер-верность смысла через `runVerityCheck` + эскалация) — под-план 2b.
- Полная верность (`runVerityCheck`) — построена (чертёж №1). Бесплатный сверщик полноты — `coverage-machine.readinessChecklist` (построен 3-C). 2a только перечисляет их id в каденсе, не переписывает.
- Чтение журнала М1 (извлечение `invokedSkills`) — live-wiring; 2a принимает факты инъекцией.

---

## File Structure

| Файл | Ответственность | Действие |
|---|---|---|
| `tools/question-slots.mjs` | Вопросы как слоты {id,text,answered,answer}; add/answer/hanging/allAnswered. Иммутабельно. | **Create** |
| `tools/question-slots.test.mjs` | TDD-покрытие. | **Create** |
| `tools/conversation-cadence.mjs` | `CADENCE` (событие→проверки), `checksForEvent`, `oversizeSignal` (размер-страховка). | **Create** |
| `tools/conversation-cadence.test.mjs` | TDD-покрытие. | **Create** |
| `tools/round-control.mjs` | Чистые части: `codeRoundCheck` ($0 код), `ROUND_OUTCOMES`, `decideRoundOutcome` (3 прохода→владелец). | **Create** |
| `tools/round-control.test.mjs` | TDD-покрытие. | **Create** |

---

## Task 1: `question-slots.mjs` — вопросы как слоты с id (R7)

**Files:**
- Create: `tools/question-slots.mjs`
- Test: `tools/question-slots.test.mjs`

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

```js
import { describe, it, expect } from 'vitest';
import { addQuestion, answerQuestion, hangingQuestions, allAnswered } from './question-slots.mjs';

describe('addQuestion (новый слот answered:false; id непустой уникальный)', () => {
  it('добавляет слот', () => {
    const s = addQuestion([], { id: 'q1', text: 'inline или субагенты?' });
    expect(s).toEqual([{ id: 'q1', text: 'inline или субагенты?', answered: false, answer: null }]);
  });
  it('иммутабельно — исходный массив не меняется', () => {
    const base = [];
    addQuestion(base, { id: 'q1', text: 't' });
    expect(base).toEqual([]);
  });
  it('пустой id → ошибка', () => {
    expect(() => addQuestion([], { id: '', text: 't' })).toThrow();
  });
  it('дубль id → ошибка', () => {
    const s = addQuestion([], { id: 'q1', text: 't' });
    expect(() => addQuestion(s, { id: 'q1', text: 'другой' })).toThrow(/дубл/i);
  });
});

describe('answerQuestion (пометка отвеченным; непустой ответ)', () => {
  it('помечает answered + хранит ответ', () => {
    let s = addQuestion([], { id: 'q1', text: 't' });
    s = answerQuestion(s, 'q1', 'inline');
    expect(s[0]).toMatchObject({ id: 'q1', answered: true, answer: 'inline' });
  });
  it('неизвестный id → ошибка', () => {
    expect(() => answerQuestion([], 'qX', 'a')).toThrow(/неизвестн/i);
  });
  it('пустой ответ → ошибка', () => {
    const s = addQuestion([], { id: 'q1', text: 't' });
    expect(() => answerQuestion(s, 'q1', '  ')).toThrow();
  });
});

describe('hangingQuestions / allAnswered (нет повисших)', () => {
  it('повисшие = заданные без ответа', () => {
    let s = addQuestion([], { id: 'q1', text: 't1' });
    s = addQuestion(s, { id: 'q2', text: 't2' });
    s = answerQuestion(s, 'q1', 'a');
    expect(hangingQuestions(s).map((x) => x.id)).toEqual(['q2']);
    expect(allAnswered(s)).toBe(false);
  });
  it('все отвечены → allAnswered true', () => {
    let s = addQuestion([], { id: 'q1', text: 't' });
    s = answerQuestion(s, 'q1', 'a');
    expect(allAnswered(s)).toBe(true);
  });
  it('пустой список → allAnswered true (вопросов нет — ничего не повисло)', () => {
    expect(allAnswered([])).toBe(true);
  });
});
```

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

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

- [ ] **Step 3: Реализация (создать модуль)**

```js
#!/usr/bin/env node
/**
 * question-slots — трекинг заданных вопросов разговорной фазы как СЛОТОВ с id
 * (условие R7 spec 2026-06-05): механическая проверка «нет повисших вопросов»
 * работает только если вопрос помечается слотом (задан/отвечен), а не вольным
 * текстом. Чистый модуль, иммутабельные операции ($0, без LLM).
 */

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

/** Добавить заданный вопрос (новый слот answered:false). id — непустой уникальный. */
export function addQuestion(slots, { id, text } = {}) {
  const list = Array.isArray(slots) ? slots : [];
  if (!isNonEmptyString(id)) throw new Error('question id: непустая строка обязательна');
  if (!isNonEmptyString(text)) throw new Error('question text: непустая строка обязательна');
  if (list.some((s) => s.id === id)) throw new Error(`question id дублируется: ${id}`);
  return [...list, { id, text, answered: false, answer: null }];
}

/** Пометить вопрос отвеченным (непустой ответ). Неизвестный id → ошибка. */
export function answerQuestion(slots, id, answer) {
  const list = Array.isArray(slots) ? slots : [];
  if (!list.some((s) => s.id === id)) throw new Error(`неизвестный question id: ${id}`);
  if (!isNonEmptyString(answer)) throw new Error('answer: непустая строка обязательна');
  return list.map((s) => (s.id === id ? { ...s, answered: true, answer } : s));
}

/** Повисшие вопросы — заданные, но не отвеченные. */
export function hangingQuestions(slots) {
  return (Array.isArray(slots) ? slots : []).filter((s) => !s.answered);
}

/** Ни один заданный вопрос не повис (пустой список → true, вопросов нет). */
export function allAnswered(slots) {
  return hangingQuestions(slots).length === 0;
}
```

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

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

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

```bash
git -C ".claude/worktrees/brainrepo" add tools/question-slots.mjs tools/question-slots.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): question-slots — вопросы-слоты с id (R7)"
```

---

## Task 2: `conversation-cadence.mjs` — каденс «в» + размер-страховка

**Files:**
- Create: `tools/conversation-cadence.mjs`
- Test: `tools/conversation-cadence.test.mjs`

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

```js
import { describe, it, expect } from 'vitest';
import { CADENCE, checksForEvent, oversizeSignal } from './conversation-cadence.mjs';

describe('checksForEvent (§3 — какие проверки когда)', () => {
  it('на каждый ответ — лёгкий проход всплывшей нужды + полнота', () => {
    expect(checksForEvent('answer')).toEqual(['emerge-light', 'coverage']);
  });
  it('при пересборке плана — полная верность + полнота', () => {
    expect(checksForEvent('replan')).toEqual(['verity-full', 'coverage']);
  });
  it('неизвестное событие → []', () => {
    expect(checksForEvent('xyz')).toEqual([]);
  });
  it('CADENCE заморожен (нельзя мутировать политику)', () => {
    expect(Object.isFrozen(CADENCE)).toBe(true);
  });
});

describe('oversizeSignal (§3 — план распух → дроби, не блок)', () => {
  it('мало вопросов/тем → не oversize', () => {
    expect(oversizeSignal({ questionCount: 3, themeCount: 1 })).toEqual({ oversize: false, reasons: [] });
  });
  it('десятки вопросов → oversize (дроби на волны)', () => {
    const r = oversizeSignal({ questionCount: 15, themeCount: 1 });
    expect(r.oversize).toBe(true);
    expect(r.reasons.some((x) => /волн/i.test(x))).toBe(true);
  });
  it('несколько независимых тем → oversize (дроби на под-разговоры)', () => {
    const r = oversizeSignal({ questionCount: 2, themeCount: 4 });
    expect(r.oversize).toBe(true);
    expect(r.reasons.some((x) => /под-разговор/i.test(x))).toBe(true);
  });
  it('пороги — ручки (инъекция)', () => {
    expect(oversizeSignal({ questionCount: 5 }, { questionThreshold: 4 }).oversize).toBe(true);
  });
});
```

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

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

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

```js
#!/usr/bin/env node
/**
 * conversation-cadence — каденс «в» (spec 2026-06-05 §3): КАКИЕ проверки гоняются
 * КОГДА. На каждый ответ — лёгкий проход «всплывшая нужда» + бесплатный сверщик
 * полноты; при сборке/пересборке плана — полная сверка верности + полнота.
 * Плюс размер-страховка: план распух → сигнал «дроби на волны». Чистый модуль.
 */

// Каденс: событие → набор проверок (id проверок-исполнителей).
export const CADENCE = Object.freeze({
  answer: Object.freeze(['emerge-light', 'coverage']),
  replan: Object.freeze(['verity-full', 'coverage']),
});

/** Какие проверки гонять на событие; неизвестное событие → []. */
export function checksForEvent(event) {
  return CADENCE[event] ? [...CADENCE[event]] : [];
}

/**
 * Размер-страховка (§3): план распух (десятки вопросов / несколько независимых
 * тем) → сигнал «дроби на волны / под-разговоры». Пороги — ручки (портативно).
 * Не блок — сигнал.
 */
export function oversizeSignal({ questionCount = 0, themeCount = 0 } = {}, { questionThreshold = 12, themeThreshold = 3 } = {}) {
  const reasons = [];
  if (questionCount >= questionThreshold) reasons.push(`вопросов много (${questionCount}≥${questionThreshold}) — дроби на волны`);
  if (themeCount >= themeThreshold) reasons.push(`независимых тем несколько (${themeCount}≥${themeThreshold}) — дроби на под-разговоры`);
  return { oversize: reasons.length > 0, reasons };
}
```

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

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

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

```bash
git -C ".claude/worktrees/brainrepo" add tools/conversation-cadence.mjs tools/conversation-cadence.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): conversation-cadence — каденс в + размер-страховка"
```

---

## Task 3: `round-control.mjs` (чистые части) — код-проверка круга + решение 3-прохода

**Files:**
- Create: `tools/round-control.mjs`
- Test: `tools/round-control.test.mjs`

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

```js
import { describe, it, expect } from 'vitest';
import { codeRoundCheck, ROUND_OUTCOMES, decideRoundOutcome } 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']);
  });
});
```

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

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

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

```js
#!/usr/bin/env node
/**
 * round-control — покруговой контроль разговорной фазы (spec 2026-06-05 §3А реш.3):
 * новый план не рождается, пока текущий круг не сошёлся. Здесь — ЧИСТЫЕ ($0)
 * части: код-проверка круга (скил реально вызван по журналу + ни один вопрос не
 * повис) + решение круга (3 прохода диалога → эскалация владельцу). LLM-проверка
 * смысла (роутер-верность) и оркестратор — под-план 2b.
 */
import { hangingQuestions } from './question-slots.mjs';

/**
 * Код-проверка круга ($0, НЕ ИИ, §3А): (1) ни один заданный вопрос не повис
 * (question-slots); (2) каждый требуемый скил реально вызван (по журналу М1 —
 * invokedSkills; принцип K2: факт вызова, не текст/слово). requiredSkills/
 * invokedSkills — инъекция (журнал читается при live-wiring).
 */
export function codeRoundCheck({ slots = [], invokedSkills = [], requiredSkills = [] } = {}) {
  const reasons = [];
  const hanging = hangingQuestions(slots);
  if (hanging.length > 0) reasons.push(`повисшие вопросы: [${hanging.map((s) => s.id).join(', ')}]`);
  const invoked = new Set(invokedSkills);
  const missing = requiredSkills.filter((s) => !invoked.has(s));
  if (missing.length > 0) reasons.push(`скил не вызван (журнал): [${missing.join(', ')}]`);
  return { ok: reasons.length === 0, reasons };
}

// Исходы круга (закрытый список).
export const ROUND_OUTCOMES = Object.freeze(['proceed', 'soft-return', 'escalate-owner']);

/**
 * Решение круга (§3А реш.2/3): сошёлся (код+смысл) → proceed; пробел и проходов
 * < maxRounds → soft-return (дозадай/перезадай нужным скилом); пробел и проходы
 * исчерпаны → escalate-owner (та же тройка, что апелляция C-12). Двойной
 * терминатор: раунды→владелец (тут) + Гейт-1 (в конце). Вечного круга нет.
 */
export function decideRoundOutcome({ codeOk, routerFaithful, roundCount = 0, maxRounds = 3 } = {}) {
  const gap = !codeOk || !routerFaithful;
  if (!gap) return { outcome: 'proceed', reason: 'круг сошёлся: код + смысл' };
  if (roundCount < maxRounds) return { outcome: 'soft-return', reason: `пробел (код:${codeOk} смысл:${routerFaithful}); проход ${roundCount}/${maxRounds} — мягкий возврат` };
  return { outcome: 'escalate-owner', reason: `пробел не закрыт за ${maxRounds} прохода — эскалация владельцу (C-12)` };
}
```

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

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

- [ ] **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): round-control чистые части — код-проверка круга + решение 3-прохода"
```

---

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

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

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

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs"` *(`dangerouslyDisableSandbox=true`)*
Expected: PASS — прежний базлайн (2267 после чертежа №1) + новые тесты question-slots/conversation-cadence/round-control. Ноль RED.

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

---

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

| Требование | Где |
|---|---|
| R7 вопросы как слоты-id, «нет повисших» механически | `question-slots` (Task 1); `codeRoundCheck` (Task 3) |
| §3 каденс: per-ответ (emerge+coverage) vs replan (verity+coverage) | `checksForEvent` / `CADENCE` (Task 2) |
| §3 размер-страховка: распух → дроби (сигнал, не блок) | `oversizeSignal` (Task 2) |
| §3А реш.3 код-проверка круга: скил вызван (журнал, K2) + вопрос не повис | `codeRoundCheck` (Task 3) |
| §3А реш.2 граница 3 прохода → владелец; вечного круга нет | `decideRoundOutcome` + `ROUND_OUTCOMES` (Task 3) |

**Placeholder-скан:** код полный. **Type-consistency:** `slots`/`invokedSkills`/`requiredSkills`/`outcome`/`reasons`/`oversize` — единые имена; `hangingQuestions` импортируется из `question-slots` (DRY, без дубля). **Scope:** чистый механический слой, тестируется сам по себе; LLM-надстройка (emerge + оркестратор) — под-план 2b, опирается на эти функции.
