# Машина 3 — Выходная верность спеки (роутер-шаг лесенки) — 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:** Построить М3-шаг выходной лесенки: когда контроллер объявил «готово», роутер сверяет ГОТОВУЮ спеку против сырых ответов владельца + одобренного плана и ловит «прошёл движения, а написал своё» (kind `fabricated`). Шаги «существование $0» и «судья» — Машина 4, НЕ здесь.

**Architecture:** Новый focused-модуль `spec-verity.mjs` по образцу `router-verity` (чертёж №1), но с ДВУМЯ якорями (сырые ответы + одобренный план; source `'prompt'|'plan'|номер`) и ТРЕТЬИМ видом расхождения `fabricated` (в спеке есть, но не обещано — «написал своё»). Рассуждение у модели (`llmCall` мокается); слоты+валидатор+заземление+faithful-кодом — механические. 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А «Контроль на ВЫХОДЕ — ТРИ проверки» шаг 2 (верность спеки = М3). §7 R8 (это ИИ: грубое «написал не то» ловит, тонкую смысловую подмену может пропустить → последний рубеж владелец).

**Границы (НЕ здесь):**
- Шаг 1 «существование ($0, код)» + шаг 3 «судья (полнота + пре-мортем K7)» — Машина 4.
- Связка лесенки в живой поток (последовательный GO трёх) — Машина 4 + live-wiring.

---

## File Structure

| Файл | Ответственность | Действие |
|---|---|---|
| `tools/spec-verity.mjs` | Выходная сверка спеки: `SPEC_VERITY_SLOTS`/`SPEC_DISCREPANCY_KINDS`, `validateSpecVerityTrace`, `groundSpecVerityTrace`, `buildSpecVerityPrompt`, `runSpecVerityCheck`. | **Create** |
| `tools/spec-verity.test.mjs` | TDD-покрытие; `llmCall` мокается. | **Create** |
| `tools/router-engine.mjs` | Экспортирует `parseRouterResponse` (DRY). | **Read-only** |

---

## Task 1: Слоты/виды + `validateSpecVerityTrace`

**Files:**
- Create: `tools/spec-verity.mjs`
- Test: `tools/spec-verity.test.mjs`

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

```js
import { describe, it, expect } from 'vitest';
import { SPEC_VERITY_SLOTS, SPEC_DISCREPANCY_KINDS, validateSpecVerityTrace } from './spec-verity.mjs';

const OK = {
  promised: [{ item: 'вход роутера первым', source: 'prompt' }, { item: 'inline без субагентов', source: 0 }],
  discrepancies: [{ kind: 'fabricated', detail: 'спека добавила кэш, которого не просили', source: 'plan' }],
};

describe('SPEC_VERITY_SLOTS / SPEC_DISCREPANCY_KINDS', () => {
  it('слоты promised + discrepancies', () => {
    expect(SPEC_VERITY_SLOTS).toEqual(['promised', 'discrepancies']);
  });
  it('виды расхождения: missing/distorted/fabricated', () => {
    expect(SPEC_DISCREPANCY_KINDS).toEqual(['missing', 'distorted', 'fabricated']);
  });
});

describe('validateSpecVerityTrace (форма; пустой слот = флаг; source prompt|plan|число)', () => {
  it('корректная трасса → ok', () => {
    expect(validateSpecVerityTrace(OK)).toEqual({ ok: true, missingSlots: [], badItems: [] });
  });
  it('пустой promised → невалидно', () => {
    const r = validateSpecVerityTrace({ ...OK, promised: [] });
    expect(r.ok).toBe(false); expect(r.missingSlots).toContain('promised');
  });
  it('discrepancies не массив → невалидно', () => {
    expect(validateSpecVerityTrace({ promised: OK.promised }).ok).toBe(false);
  });
  it('пустой discrepancies → валидно (спека верна)', () => {
    expect(validateSpecVerityTrace({ promised: OK.promised, discrepancies: [] }).ok).toBe(true);
  });
  it('source plan допустим', () => {
    expect(validateSpecVerityTrace({ promised: [{ item: 'x', source: 'plan' }], discrepancies: [] }).ok).toBe(true);
  });
  it('свободный kind → badItems', () => {
    const r = validateSpecVerityTrace({ ...OK, discrepancies: [{ kind: 'whatever', detail: 'd', source: 'plan' }] });
    expect(r.ok).toBe(false); expect(r.badItems).toContain('discrepancies[0]');
  });
  it('не объект → все слоты missing', () => {
    expect(validateSpecVerityTrace(null).missingSlots).toEqual([...SPEC_VERITY_SLOTS]);
  });
});
```

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

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

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

```js
#!/usr/bin/env node
/**
 * spec-verity — роутер-шаг ВЫХОДНОЙ лесенки (spec 2026-06-05 §3А «контроль на
 * выходе», шаг 2): когда контроллер объявил «готово», роутер сверяет ГОТОВУЮ
 * спеку против СЫРЫХ ответов владельца + ОДОБРЕННОГО плана. Ловит «прошёл
 * движения, а написал своё» (kind fabricated — в спеке есть, в ответах/плане нет).
 * Существование ($0) и судья (полнота+пре-мортем) — Машина 4, НЕ здесь.
 * РАССУЖДЕНИЕ — у модели (llmCall мокается); проверки вокруг — механические.
 */
import { parseRouterResponse } from './router-engine.mjs';

export const SPEC_VERITY_SLOTS = Object.freeze(['promised', 'discrepancies']);
export const SPEC_DISCREPANCY_KINDS = Object.freeze(['missing', 'distorted', 'fabricated']);

function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; }
function isSpecSourceRef(v) { return v === 'prompt' || v === 'plan' || (typeof v === 'number' && Number.isInteger(v)); }

/**
 * Валидатор формы (отдельный код): promised — НЕПУСТОЙ массив {item, source};
 * discrepancies — массив (пуст = спека верна) {kind∈SPEC_DISCREPANCY_KINDS,
 * detail, source}. source: 'prompt' | 'plan' | номер ответа.
 */
export function validateSpecVerityTrace(trace) {
  const missingSlots = [];
  const badItems = [];
  if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...SPEC_VERITY_SLOTS], badItems };
  if (!Array.isArray(trace.promised) || trace.promised.length === 0) {
    missingSlots.push('promised');
  } else {
    trace.promised.forEach((it, i) => {
      if (!it || typeof it !== 'object' || !isNonEmptyString(it.item) || !isSpecSourceRef(it.source)) badItems.push(`promised[${i}]`);
    });
  }
  if (!Array.isArray(trace.discrepancies)) {
    missingSlots.push('discrepancies');
  } else {
    trace.discrepancies.forEach((it, i) => {
      const kindOk = it && typeof it === 'object' && SPEC_DISCREPANCY_KINDS.includes(it.kind);
      if (!kindOk || !isNonEmptyString(it.detail) || !isSpecSourceRef(it.source)) badItems.push(`discrepancies[${i}]`);
    });
  }
  return { ok: missingSlots.length === 0 && badItems.length === 0, missingSlots, badItems };
}
```

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

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

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

```bash
git -C ".claude/worktrees/brainrepo" add tools/spec-verity.mjs tools/spec-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): spec-verity слоты + валидатор формы выходной сверки"
```

---

## Task 2: `groundSpecVerityTrace` — заземление (prompt | plan | номер ответа)

**Files:**
- Modify: `tools/spec-verity.mjs`
- Test: `tools/spec-verity.test.mjs` (Write полного файла + новый describe)

- [ ] **Step 1: Написать падающий тест** (импорт +`groundSpecVerityTrace`; добавить блок)

```js
describe('groundSpecVerityTrace (source: prompt | plan | индекс ответа в диапазоне)', () => {
  const trace = { promised: [{ item: 'a', source: 'prompt' }, { item: 'b', source: 'plan' }, { item: 'c', source: 1 }], discrepancies: [] };
  it('все источники валидны → grounded', () => {
    expect(groundSpecVerityTrace(trace, { answersCount: 2 })).toEqual({ grounded: true, dangling: [] });
  });
  it('индекс вне диапазона → dangling', () => {
    expect(groundSpecVerityTrace(trace, { answersCount: 1 }).grounded).toBe(false);
  });
  it('plan валиден при нулевой истории', () => {
    expect(groundSpecVerityTrace({ promised: [{ item: 'a', source: 'plan' }], discrepancies: [] }, { answersCount: 0 }).grounded).toBe(true);
  });
});
```

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

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" spec-verity`
Expected: FAIL — `groundSpecVerityTrace is not a function`.

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

```js
/** Заземление: source резолвится 'prompt' | 'plan' | индекс ответа в [0, answersCount). */
export function groundSpecVerityTrace(trace, { answersCount = 0 } = {}) {
  const dangling = [];
  const check = (src, where) => {
    if (src === 'prompt' || src === 'plan') return;
    if (typeof src === 'number' && Number.isInteger(src) && src >= 0 && src < answersCount) return;
    dangling.push(`${where}:${JSON.stringify(src)}`);
  };
  (trace?.promised || []).forEach((it, i) => check(it?.source, `promised[${i}]`));
  (trace?.discrepancies || []).forEach((it, i) => check(it?.source, `discrepancies[${i}]`));
  return { grounded: dangling.length === 0, dangling };
}
```

- [ ] **Step 4: Запустить — GREEN** — `... spec-verity` → PASS.
- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/spec-verity.mjs tools/spec-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): groundSpecVerityTrace — заземление якорей выходной сверки"
```

---

## Task 3: `buildSpecVerityPrompt` — анти-внушение + ловля «написал своё»

**Files:**
- Modify: `tools/spec-verity.mjs`
- Test: `tools/spec-verity.test.mjs` (Write полного файла + новый describe)

- [ ] **Step 1: Написать падающий тест** (импорт +`buildSpecVerityPrompt`)

```js
describe('buildSpecVerityPrompt (чистая {system,user}; promised из якорей ДО спеки; ловит fabricated)', () => {
  const base = { rawPrompt: 'вход роутера', answers: ['inline'], approvedPlan: 'план A', spec: 'спека текст' };
  it('детерминирована', () => {
    expect(buildSpecVerityPrompt(base).system).toBe(buildSpecVerityPrompt(base).system);
  });
  it('system: сперва promised из якорей, потом сверка спеки; описан fabricated', () => {
    const { system } = buildSpecVerityPrompt(base);
    expect(system.indexOf('promised')).toBeLessThan(system.indexOf('discrepancies'));
    expect(system).toMatch(/fabricated/);
    expect(system).toMatch(/написал сво|не обещано/i);
  });
  it('user: якоря (ответы + одобренный план) ФИЗИЧЕСКИ перед спекой', () => {
    const { user } = buildSpecVerityPrompt(base);
    expect(user.indexOf('ОДОБРЕННЫЙ ПЛАН')).toBeLessThan(user.indexOf('ГОТОВАЯ СПЕКА'));
    expect(user).toMatch(/Ответ 0: inline/);
    expect(user).toMatch(/план A/);
    expect(user).toMatch(/спека текст/);
  });
});
```

- [ ] **Step 2: Запустить — RED** → `buildSpecVerityPrompt is not a function`.

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

```js
/** Построитель промпта (чистый, {system,user}). Анти-внушение: promised из якорей ДО чтения спеки. */
export function buildSpecVerityPrompt({ rawPrompt = '', answers = [], approvedPlan = null, spec = '' } = {}) {
  const ans = Array.isArray(answers) ? answers : [];
  const answersText = ans.length ? ans.map((a, i) => `Ответ ${i}: ${typeof a === 'string' ? a : JSON.stringify(a)}`).join('\n') : '(ответов нет)';
  const planText = approvedPlan == null ? '(одобренного плана нет)' : (typeof approvedPlan === 'string' ? approvedPlan : JSON.stringify(approvedPlan));
  const specText = typeof spec === 'string' ? spec : JSON.stringify(spec);
  const system = [
    'Ты — РОУТЕР-НАСТАВНИК, ВЫХОДНАЯ сверка верности спеки. Контроллер объявил «готово».',
    'ПОРЯДОК (анти-внушение): СНАЧАЛА, читая ТОЛЬКО якоря (сырые ответы владельца + одобренный план), независимо выпиши, что было ОБЕЩАНО → promised (source: "prompt"|"plan"|номер ответа). ДО чтения спеки.',
    'ПОТОМ сверь готовую спеку с promised и выпиши discrepancies: missing (обещано, в спеке нет), distorted (в спеке искажено), fabricated (в спеке есть, но НИ в ответах НИ в плане не обещано — «написал своё»).',
    'Спека верна (расхождений нет) → discrepancies пустой — допустимо.',
    'ВЫВОД — строго JSON: {"promised":[{"item":"…","source":"prompt"|"plan"|N}], "discrepancies":[{"kind":"missing"|"distorted"|"fabricated","detail":"…","source":"prompt"|"plan"|N}]}. Пустой promised недопустим.',
  ].join('\n');
  const user = [
    '--- СЫРОЙ ПРОМПТ ВЛАДЕЛЬЦА ---', rawPrompt,
    '--- СЫРЫЕ ОТВЕТЫ ВЛАДЕЛЬЦА ПО ПОРЯДКУ ---', answersText,
    '--- ОДОБРЕННЫЙ ПЛАН (якорь) ---', planText,
    '--- ГОТОВАЯ СПЕКА (читать ПОСЛЕ promised) ---', specText,
  ].join('\n');
  return { system, user };
}
```

- [ ] **Step 4: Запустить — GREEN** → PASS.
- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/spec-verity.mjs tools/spec-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): buildSpecVerityPrompt — анти-внушение + ловля написал-своё"
```

---

## Task 4: `runSpecVerityCheck` — оркестратор, faithful кодом

**Files:**
- Modify: `tools/spec-verity.mjs`
- Test: `tools/spec-verity.test.mjs` (Write полного файла + новый describe)

- [ ] **Step 1: Написать падающий тест** (импорт +`runSpecVerityCheck`)

```js
describe('runSpecVerityCheck (оркестратор; llmCall мокается; faithful кодом)', () => {
  const base = { rawPrompt: 'вход роутера', answers: ['inline'], approvedPlan: 'план A', spec: 'спека' };
  const okTrace = { promised: [{ item: 'вход роутера', source: 'prompt' }], discrepancies: [] };
  it('нет расхождений → ok + faithful=true', async () => {
    const r = await runSpecVerityCheck({ ...base, llmCall: async () => okTrace });
    expect(r.ok).toBe(true); expect(r.faithful).toBe(true);
  });
  it('fabricated → ok + faithful=false', async () => {
    const trace = { promised: okTrace.promised, discrepancies: [{ kind: 'fabricated', detail: 'лишний кэш', source: 'plan' }] };
    const r = await runSpecVerityCheck({ ...base, llmCall: async () => trace });
    expect(r.ok).toBe(true); expect(r.faithful).toBe(false); expect(r.discrepancies[0].kind).toBe('fabricated');
  });
  it('пустой promised → ok=false', async () => {
    const r = await runSpecVerityCheck({ ...base, llmCall: async () => ({ promised: [], discrepancies: [] }) });
    expect(r.ok).toBe(false);
  });
  it('висящая ссылка (номер вне истории) → ok=false', async () => {
    const r = await runSpecVerityCheck({ ...base, llmCall: async () => ({ promised: [{ item: 'a', source: 5 }], discrepancies: [] }) });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/висящ|dangling|ссыл/i);
  });
  it('строка JSON-fence → парсится', async () => {
    const r = await runSpecVerityCheck({ ...base, llmCall: async () => '```json\n{"promised":[{"item":"a","source":"plan"}],"discrepancies":[]}\n```' });
    expect(r.ok).toBe(true); expect(r.faithful).toBe(true);
  });
  it('исключение → ok=false', async () => {
    const r = await runSpecVerityCheck({ ...base, llmCall: async () => { throw new Error('net'); } });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/сбой|модел/i);
  });
});
```

- [ ] **Step 2: Запустить — RED** → `runSpecVerityCheck is not a function`.

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

```js
/** Оркестратор: построить → llmCall → распарсить → валидировать → заземлить → faithful=код. */
export async function runSpecVerityCheck({ rawPrompt = '', answers = [], approvedPlan = null, spec = '', llmCall }) {
  const ans = Array.isArray(answers) ? answers : [];
  let trace;
  try { trace = await llmCall({ buildPrompt: () => buildSpecVerityPrompt({ rawPrompt, answers: ans, approvedPlan, spec }) }); }
  catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; }
  if (typeof trace === 'string') trace = parseRouterResponse(trace);
  if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null };
  const v = validateSpecVerityTrace(trace);
  if (!v.ok) return { ok: false, reason: `невалидная трасса спеки: слоты [${v.missingSlots.join(', ')}] пункты [${v.badItems.join(', ')}]`, trace };
  const g = groundSpecVerityTrace(trace, { answersCount: ans.length });
  if (!g.grounded) return { ok: false, reason: `висящие ссылки: [${g.dangling.join(', ')}]`, trace };
  const faithful = trace.discrepancies.length === 0;
  return { ok: true, faithful, discrepancies: trace.discrepancies, trace };
}
```

- [ ] **Step 4: Запустить — GREEN** → PASS (все 4 таска).
- [ ] **Step 5: Commit** *(owner-gated)*

```bash
git -C ".claude/worktrees/brainrepo" add tools/spec-verity.mjs tools/spec-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): runSpecVerityCheck — оркестратор выходной сверки спеки"
```

---

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

- [ ] **Step 1:** `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs"` (`dangerouslyDisableSandbox=true`) → базлайн 2308 + spec-verity (~18). Ноль RED.
- [ ] **Step 2:** Доложить строку результата.

---

## Self-Review (§3А выход шаг 2)

| Требование | Где |
|---|---|
| Готовая спека против сырых ответов + одобренного плана | `buildSpecVerityPrompt` (Task 3): rawPrompt+answers+approvedPlan+spec |
| Ловит «прошёл движения, а написал своё» | `fabricated` kind (Task 1/3/4) |
| Анти-внушение: promised из якорей ДО спеки | порядок system + физика user (Task 3); promised non-empty (Task 1) |
| faithful — механически, не со слов роутера | `runSpecVerityCheck` (Task 4) |
| R8 — это ИИ, грубое ловит, тонкое может пропустить → владелец | задокументировано в шапке модуля |

**Placeholder-скан:** код полный. **Type-consistency:** `promised`/`discrepancies`/`fabricated`/`source`/`faithful` единые; source 'prompt'|'plan'|число всюду; `parseRouterResponse` реальный экспорт. **Scope:** один subsystem (выходная сверка), тестируется с моком; шаги существование+судья — Машина 4.
