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

> **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.
> **Примечание этого репозитория:** код строит САМ контроллер inline (субагент не проходит TDD-гейт — урок Машин 2/3). Шаг `commit` — owner-gated: владелец вставляет git-команду в терминал по явному «да» (Claude'у git заблокирован router-гейтом).

**Goal:** Дать роутеру в разговорной фазе вход = сырой промпт + вся история ответов владельца + мой план, роль критика верности и механическое анти-внушение (сперва независимый вывод «что просил владелец», потом сверка с планом), не ломая построенную Машину 3.

**Architecture:** Новый focused-модуль `tools/router-verity.mjs` рядом с `router-engine.mjs` — это разговорное поведение роутера-головы (Машина 3), мечанически отделённое от существующего движка ВЫБОРА скила (`router-engine.mjs` не трогаем → регрессия R5 цела). Рассуждение — у модели (`llmCall` мокается, идиома `router-classifier`); все проверки вокруг — тупо-механические («умный не баран»): валидатор слотов + заземление источников + вывод `faithful` кодом из массива расхождений. DRY: парсер JSON переиспользуется из `router-engine.mjs`.

**Tech Stack:** Node.js ESM (`.mjs`), vitest tools-only. Без project-хардкода и детерминизма в роутере; чистые функции построителя промпта детерминированы (тот же вход → тот же текст).

**Источник:** `docs/superpowers/specs/2026-06-05-router-conversational-input-and-emergent-catch-design.md` §2 (вход + роли + анти-внушение), §3 (каденс — СЛЕДУЮЩИЙ чертёж), §7 R1/R5 (риски). Канон M3 — `2026-06-03-router-mentor-master-roadmap.md` §МАШИНА 3 (ревизия P16-e 2026-06-05).

**Границы этого чертежа (что НЕ здесь — отдельными планами/машинами):**
- Каденс «в» (лёгкий per-ответ проход + полная сверка при пересборке) — следующий чертёж M3.
- Покруговой контроль (вопросы-слоты id, код-существование + мягкий возврат + 3 прохода → владелец) — следующий чертёж M3.
- Выходная лесенка (роутер-верность спеки) — отдельный чертёж M3 (шаги «существование $0» и «судья» — Машина 4).
- #1 честные контракты / #2 граф решений / #3 дисциплина доменного навыка — отдельные чертежи.
- Live-wiring (`callAnthropicAPI` вместо мока, регистрация в settings.json), K4-активация — шаг владельца после Машины 4 (режим D28).

---

## File Structure

| Файл | Ответственность | Действие |
|---|---|---|
| `tools/router-verity.mjs` | Разговорный критик верности: слоты `owner_asks`/`discrepancies`, валидатор формы, заземление источников, построитель промпта (анти-внушение), оркестратор `runVerityCheck`. | **Create** |
| `tools/router-verity.test.mjs` | TDD-покрытие всех функций модуля; `llmCall` мокается. | **Create** |
| `tools/router-engine.mjs` | Существующий движок ВЫБОРА скила. Экспортирует `parseRouterResponse` — переиспользуется (DRY). | **Read-only (не меняем)** |

Решение о новом файле (а не правке `router-engine.mjs`): критик-верность — отдельная ответственность от выбора-скила (другие слоты, другой промпт, другой оркестратор). Focused-файл → надёжнее правки + регрессия `router-engine` гарантированно цела (R5). На уровне подсистемы это «вход/выход роутера» Машины 3, как требует дизайн §6.

---

## Task 1: Слоты + механический валидатор формы трассы верности

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

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

```js
import { describe, it, expect } from 'vitest';
import {
  VERITY_SLOTS, DISCREPANCY_KINDS, validateVerityTrace,
} from './router-verity.mjs';

const OK_TRACE = {
  owner_asks: [
    { ask: 'начать с входа роутера', source: 'prompt' },
    { ask: 'без субагентов, inline', source: 0 },
  ],
  discrepancies: [
    { kind: 'missing', detail: 'inline-режим в плане не отражён', source: 0 },
  ],
};

describe('VERITY_SLOTS / DISCREPANCY_KINDS — закрытые наборы (§2.4 / §6.2)', () => {
  it('обязательные слоты анти-внушения', () => {
    expect(VERITY_SLOTS).toContain('owner_asks');
    expect(VERITY_SLOTS).toContain('discrepancies');
  });
  it('виды расхождения — закрытый список', () => {
    expect(DISCREPANCY_KINDS).toEqual(['missing', 'distorted']);
  });
});

describe('validateVerityTrace (механический валидатор формы; пустой слот = флаг)', () => {
  it('полная корректная трасса → ok', () => {
    expect(validateVerityTrace(OK_TRACE)).toEqual({ ok: true, missingSlots: [], badItems: [] });
  });
  it('пустой owner_asks → невалидно (анти-внушение: независимый вывод обязателен)', () => {
    const r = validateVerityTrace({ ...OK_TRACE, owner_asks: [] });
    expect(r.ok).toBe(false); expect(r.missingSlots).toContain('owner_asks');
  });
  it('discrepancies отсутствует (не массив) → невалидно', () => {
    const r = validateVerityTrace({ owner_asks: OK_TRACE.owner_asks });
    expect(r.ok).toBe(false); expect(r.missingSlots).toContain('discrepancies');
  });
  it('пустой discrepancies (массив) → ВАЛИДНО (план верен — расхождений нет)', () => {
    expect(validateVerityTrace({ owner_asks: OK_TRACE.owner_asks, discrepancies: [] }).ok).toBe(true);
  });
  it('пункт owner_asks без source → badItems', () => {
    const r = validateVerityTrace({ ...OK_TRACE, owner_asks: [{ ask: 'x' }] });
    expect(r.ok).toBe(false); expect(r.badItems).toContain('owner_asks[0]');
  });
  it('пункт discrepancies со свободным kind → badItems (только закрытый список)', () => {
    const r = validateVerityTrace({ ...OK_TRACE, discrepancies: [{ kind: 'maybe', detail: 'd', source: 0 }] });
    expect(r.ok).toBe(false); expect(r.badItems).toContain('discrepancies[0]');
  });
  it('не объект → невалидно, все слоты missing', () => {
    expect(validateVerityTrace(null).ok).toBe(false);
    expect(validateVerityTrace('x').missingSlots).toEqual([...VERITY_SLOTS]);
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-verity`
Expected: FAIL — `Failed to resolve import './router-verity.mjs'` (модуля ещё нет).

- [ ] **Step 3: Минимальная реализация (создать модуль с шапкой, импортом, константами, валидатором)**

```js
#!/usr/bin/env node
/**
 * router-verity — роутер в роли КРИТИКА ВЕРНОСТИ для разговорной фазы (1а).
 * Поправка P16-e (spec 2026-06-05): роли НЕ меняются — контроллер ПРОИЗВОДИТ
 * разговорный план, роутер КРИТИКУЕТ его против СЫРЫХ слов владельца (промпт +
 * вся история ответов по порядку), НЕ против пересказа Claude. Анти-внушение:
 * роутер сперва САМ выводит «что просил владелец» (owner_asks), ПОТОМ сверяет с
 * планом (discrepancies) — оба слота обязательны по форме (пустой слот = красный
 * флаг, как validateTrace). РАССУЖДЕНИЕ — у модели (llmCall мокается); все
 * ПРОВЕРКИ вокруг — тупо-механические («умный не баран»).
 */
import { parseRouterResponse } from './router-engine.mjs';

// Обязательные структурные слоты трассы верности (анти-внушение §2.4).
export const VERITY_SLOTS = Object.freeze(['owner_asks', 'discrepancies']);

// Закрытый список видов расхождения (свободный текст в kind отклоняется).
export const DISCREPANCY_KINDS = Object.freeze(['missing', 'distorted']);

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

/**
 * Механический валидатор трассы верности (ОТДЕЛЬНЫЙ код, не сам роутер — §6.2 B,
 * аналог A0). ФОРМА: owner_asks — НЕПУСТОЙ массив пунктов {ask, source};
 * discrepancies — массив (может быть пуст = план верен) пунктов
 * {kind∈DISCREPANCY_KINDS, detail, source}. Пустой/неверный слот = красный флаг.
 * НЕ судит «правильность мысли» и НЕ резолвит номер источника (groundVerityTrace).
 */
export function validateVerityTrace(trace) {
  const missingSlots = [];
  const badItems = [];
  if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...VERITY_SLOTS], badItems };

  if (!Array.isArray(trace.owner_asks) || trace.owner_asks.length === 0) {
    missingSlots.push('owner_asks');
  } else {
    trace.owner_asks.forEach((it, i) => {
      if (!it || typeof it !== 'object' || !isNonEmptyString(it.ask) || !isSourceRef(it.source))
        badItems.push(`owner_asks[${i}]`);
    });
  }

  if (!Array.isArray(trace.discrepancies)) {
    missingSlots.push('discrepancies');
  } else {
    trace.discrepancies.forEach((it, i) => {
      const kindOk = it && typeof it === 'object' && DISCREPANCY_KINDS.includes(it.kind);
      if (!kindOk || !isNonEmptyString(it.detail) || !isSourceRef(it.source))
        badItems.push(`discrepancies[${i}]`);
    });
  }

  return { ok: missingSlots.length === 0 && badItems.length === 0, missingSlots, badItems };
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-verity`
Expected: PASS (оба describe Task 1).

- [ ] **Step 5: Commit** *(owner-gated — владелец вставляет в терминал по «да»)*

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

---

## Task 2: Заземление источников (вся история по порядку, не потерять ранний ответ)

**Files:**
- Modify: `tools/router-verity.mjs`
- Test: `tools/router-verity.test.mjs`

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

```js
// добавить groundVerityTrace в существующий import из './router-verity.mjs'

describe('groundVerityTrace (§2.2 — source резолвится: prompt | индекс ответа в диапазоне)', () => {
  const trace = {
    owner_asks: [{ ask: 'a', source: 'prompt' }, { ask: 'b', source: 1 }],
    discrepancies: [{ kind: 'missing', detail: 'd', source: 0 }],
  };
  it('все источники в диапазоне → grounded', () => {
    expect(groundVerityTrace(trace, { answersCount: 2 })).toEqual({ grounded: true, dangling: [] });
  });
  it('ссылка на несуществующий ответ → dangling (роутер сослался мимо истории)', () => {
    const r = groundVerityTrace(trace, { answersCount: 1 }); // индекс 1 вне [0,1)
    expect(r.grounded).toBe(false);
    expect(r.dangling.some((d) => d.includes('owner_asks[1]'))).toBe(true);
  });
  it('источник из discrepancies вне диапазона → dangling', () => {
    const t = { owner_asks: [{ ask: 'a', source: 'prompt' }], discrepancies: [{ kind: 'missing', detail: 'd', source: 5 }] };
    expect(groundVerityTrace(t, { answersCount: 2 }).grounded).toBe(false);
  });
  it('prompt всегда валиден даже при нулевой истории', () => {
    const t = { owner_asks: [{ ask: 'a', source: 'prompt' }], discrepancies: [] };
    expect(groundVerityTrace(t, { answersCount: 0 }).grounded).toBe(true);
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

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

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

```js
/**
 * Заземление источников (§2.2 — «вся история по порядку, не потерять ранний
 * ответ»): каждый source в owner_asks/discrepancies обязан резолвиться в реальный
 * источник — 'prompt' ИЛИ целочисленный индекс ответа в [0, answersCount).
 * Ссылка вне диапазона → dangling (роутер сослался на несуществующий ответ).
 */
export function groundVerityTrace(trace, { answersCount = 0 } = {}) {
  const dangling = [];
  const check = (src, where) => {
    if (src === 'prompt') return;
    if (typeof src === 'number' && Number.isInteger(src) && src >= 0 && src < answersCount) return;
    dangling.push(`${where}:${JSON.stringify(src)}`);
  };
  (trace?.owner_asks || []).forEach((it, i) => check(it?.source, `owner_asks[${i}]`));
  (trace?.discrepancies || []).forEach((it, i) => check(it?.source, `discrepancies[${i}]`));
  return { grounded: dangling.length === 0, dangling };
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-verity`
Expected: PASS (Task 1 + Task 2).

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

```bash
git -C ".claude/worktrees/brainrepo" add tools/router-verity.mjs tools/router-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): groundVerityTrace — заземление источников трассы верности"
```

---

## Task 3: Построитель промпта (анти-внушение зашито порядком + физикой)

**Files:**
- Modify: `tools/router-verity.mjs`
- Test: `tools/router-verity.test.mjs`

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

```js
// добавить buildVerityPrompt в import

describe('buildVerityPrompt (чистая {system,user}; анти-внушение §2.4 зашит порядком+физикой)', () => {
  const base = { rawPrompt: 'начни с входа роутера', answers: ['inline без субагентов', 'граф решений отдельно'], plan: 'план: правлю router-engine' };
  it('детерминирована: тот же вход → тот же текст', () => {
    const a = buildVerityPrompt(base);
    const b = buildVerityPrompt(base);
    expect(a.system).toBe(b.system);
    expect(a.user).toBe(b.user);
  });
  it('system: инструкция порядка — сперва owner_asks из сырых слов, ПОТОМ discrepancies', () => {
    const { system } = buildVerityPrompt(base);
    const iAsks = system.indexOf('owner_asks');
    const iDisc = system.indexOf('discrepancies');
    expect(iAsks).toBeGreaterThanOrEqual(0);
    expect(iAsks).toBeLessThan(iDisc); // порядок инструкций
    expect(system).toMatch(/анти-внушени|не поддакивай/i);
  });
  it('user: сырые слова владельца ФИЗИЧЕСКИ перед планом', () => {
    const { user } = buildVerityPrompt(base);
    const iRaw = user.indexOf('СЫРОЙ ПРОМПТ');
    const iPlan = user.indexOf('ПЛАН КОНТРОЛЛЕРА');
    expect(iRaw).toBeGreaterThanOrEqual(0);
    expect(iRaw).toBeLessThan(iPlan);
  });
  it('user: ВСЯ история ответов нумеруется по порядку (0..N-1)', () => {
    const { user } = buildVerityPrompt(base);
    expect(user).toMatch(/Ответ 0: inline без субагентов/);
    expect(user).toMatch(/Ответ 1: граф решений отдельно/);
  });
  it('пустая история → «(ответов пока нет)», план может отсутствовать', () => {
    const { user } = buildVerityPrompt({ rawPrompt: 'старт', answers: [], plan: null });
    expect(user).toMatch(/ответов пока нет/);
    expect(user).toMatch(/плана пока нет/);
  });
  it('закрытый список kind в инструкции вывода', () => {
    expect(buildVerityPrompt(base).system).toMatch(/missing.*distorted|distorted.*missing/);
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

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

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

```js
/**
 * Построитель промпта верности (чистый, {system,user}). Анти-внушение §2.4 зашито
 * ДВАЖДЫ: (1) инструкция порядка в system (сперва owner_asks из сырых слов, потом
 * discrepancies против плана); (2) физически — сырые слова в user ПЕРЕД планом.
 * Вся история ответов нумеруется (0-based, под groundVerityTrace).
 */
export function buildVerityPrompt({ rawPrompt = '', answers = [], plan = null } = {}) {
  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 = plan == null ? '(плана пока нет)' : (typeof plan === 'string' ? plan : JSON.stringify(plan));
  const system = [
    'Ты — РОУТЕР-НАСТАВНИК в роли КРИТИКА ВЕРНОСТИ (разговорная фаза). Роли НЕ меняются: план производит контроллер, ты его КРИТИКУЕШЬ против СЫРЫХ слов владельца, а не против пересказа.',
    'ПОРЯДОК (обязателен, анти-внушение): СНАЧАЛА, читая ТОЛЬКО сырые слова владельца (промпт + ответы), независимо выпиши, что владелец РЕАЛЬНО просил → заполни owner_asks. Сделай это ДО того, как смотреть план.',
    'ПОТОМ сверь план с owner_asks и выпиши discrepancies. Не поддакивай плану. Если ранний ответ владельца в плане НЕ отражён или искажён — это расхождение, не пропусти.',
    'Каждый пункт owner_asks ОБЯЗАН ссылаться на источник: source = "prompt" ИЛИ номер ответа (целое, как в нумерации ниже).',
    'discrepancies: каждый пункт {kind: "missing"|"distorted", detail, source}. Пустой массив discrepancies = план ВЕРЕН (расхождений нет) — это допустимо.',
    'ВЫВОД — строго JSON: {"owner_asks":[{"ask":"…","source":"prompt"|N}], "discrepancies":[{"kind":"missing"|"distorted","detail":"…","source":"prompt"|N}]}. Пустой owner_asks недопустим.',
  ].join('\n');
  const user = [
    '--- СЫРОЙ ПРОМПТ ВЛАДЕЛЬЦА (дословно) ---',
    rawPrompt,
    '--- СЫРЫЕ ОТВЕТЫ ВЛАДЕЛЬЦА ПО ПОРЯДКУ (полная история) ---',
    answersText,
    '--- ПЛАН КОНТРОЛЛЕРА (разговорный, читать ПОСЛЕ owner_asks) ---',
    planText,
  ].join('\n');
  return { system, user };
}
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-verity`
Expected: PASS (Task 1+2+3).

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

```bash
git -C ".claude/worktrees/brainrepo" add tools/router-verity.mjs tools/router-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): buildVerityPrompt — анти-внушение порядком чтения + физикой"
```

---

## Task 4: Оркестратор `runVerityCheck` (faithful считает код, не роутер)

**Files:**
- Modify: `tools/router-verity.mjs`
- Test: `tools/router-verity.test.mjs`

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

```js
// добавить runVerityCheck в import

describe('runVerityCheck (оркестратор; llmCall мокается; faithful — кодом из discrepancies)', () => {
  const okTrace = {
    owner_asks: [{ ask: 'вход роутера первым', source: 'prompt' }],
    discrepancies: [],
  };
  const base = { rawPrompt: 'вход роутера первым', answers: [], plan: 'план' };

  it('валидная заземлённая трасса без расхождений → ok + faithful=true', async () => {
    const r = await runVerityCheck({ ...base, llmCall: async () => okTrace });
    expect(r.ok).toBe(true); expect(r.faithful).toBe(true); expect(r.discrepancies).toEqual([]);
  });
  it('есть расхождение → ok + faithful=false (код считает, не роутер)', async () => {
    const trace = { owner_asks: okTrace.owner_asks, discrepancies: [{ kind: 'missing', detail: 'inline не отражён', source: 0 }] };
    const r = await runVerityCheck({ rawPrompt: 'x', answers: ['inline'], plan: 'p', llmCall: async () => trace });
    expect(r.ok).toBe(true); expect(r.faithful).toBe(false); expect(r.discrepancies.length).toBe(1);
  });
  it('пустой owner_asks (поддакнул не выводя) → ok=false, не результат', async () => {
    const r = await runVerityCheck({ ...base, llmCall: async () => ({ owner_asks: [], discrepancies: [] }) });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|owner_asks|невалид/i);
  });
  it('ссылка на несуществующий ответ → ok=false (заземление)', async () => {
    const trace = { owner_asks: [{ ask: 'a', source: 3 }], discrepancies: [] };
    const r = await runVerityCheck({ rawPrompt: 'x', answers: ['one'], plan: 'p', llmCall: async () => trace });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/висящ|источник|dangling/i);
  });
  it('llmCall вернул строку с JSON-fence → парсится (DRY parseRouterResponse)', async () => {
    const r = await runVerityCheck({ ...base, llmCall: async () => '```json\n{"owner_asks":[{"ask":"a","source":"prompt"}],"discrepancies":[]}\n```' });
    expect(r.ok).toBe(true); expect(r.faithful).toBe(true);
  });
  it('llmCall вернул мусор → ok=false (не падает)', async () => {
    const r = await runVerityCheck({ ...base, llmCall: async () => null });
    expect(r.ok).toBe(false);
  });
  it('llmCall кинул исключение → ok=false, reason про сбой', async () => {
    const r = await runVerityCheck({ ...base, llmCall: async () => { throw new Error('network'); } });
    expect(r.ok).toBe(false); expect(r.reason).toMatch(/сбой|модел/i);
  });
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

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

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

```js
/**
 * Оркестратор верности: построить промпт → llmCall (инъектируется; в проде
 * callAnthropicAPI) → распарсить → МЕХАНИЧЕСКИ провалидировать (слоты+форма) →
 * заземлить источники → вывести faithful = (нет расхождений). faithful считает
 * КОД из массива discrepancies, не со слов роутера (не доверяем самоотчёту).
 */
export async function runVerityCheck({ rawPrompt = '', answers = [], plan = null, llmCall }) {
  const ans = Array.isArray(answers) ? answers : [];
  let trace;
  try { trace = await llmCall({ buildPrompt: () => buildVerityPrompt({ rawPrompt, answers: ans, 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 = validateVerityTrace(trace);
  if (!v.ok) return { ok: false, reason: `невалидная трасса верности: слоты [${v.missingSlots.join(', ')}] пункты [${v.badItems.join(', ')}]`, trace };
  const g = groundVerityTrace(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: Запустить — убедиться, что проходит**

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs" router-verity`
Expected: PASS (все 4 таска).

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

```bash
git -C ".claude/worktrees/brainrepo" add tools/router-verity.mjs tools/router-verity.test.mjs
git -C ".claude/worktrees/brainrepo" commit -m "feat(m3): runVerityCheck — оркестратор верности, faithful кодом"
```

---

## Task 5: Регрессия tools-only (R5 — построенная Машина 3 не сломана)

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

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

Run: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config "vitest.config.tools.mjs"` *(с `dangerouslyDisableSandbox=true`)*
Expected: PASS — прежний базлайн (~2241/2242 GREEN) + новые тесты `router-verity` (Tasks 1–4). `router-engine.test.mjs` без изменений (модуль не трогали). Ноль RED.

- [ ] **Step 2: Доложить владельцу строку результата** (число тестов GREEN, что новый модуль `router-verity` добавлен, регрессия цела). Push — отдельно, по «да».

---

## Self-Review (против спеки §2)

| Требование спеки §2 | Где реализовано |
|---|---|
| §2.2 вход: сырой промпт + ВСЯ история ответов по порядку + мой план | `buildVerityPrompt` (Task 3) — `rawPrompt` + нумерованные `answers` + `plan` |
| §2.2 «не только последний — иначе потеряется ранний ответ» | нумерация всей истории + `groundVerityTrace` ловит ссылку вне диапазона (Task 2) |
| §2.3 роль = сверка верности, возврат расхождений «владелец сказал X (ответ №N)» | `discrepancies[{kind,detail,source}]` (Task 1/4) |
| §2.4 анти-внушение: сперва независимый вывод, потом сверка | порядок инструкций в `system` + физика user (Task 3); оба слота обязательны (Task 1) |
| §2.4 пустой слот → невалидно (механика, не «честное слово») | `validateVerityTrace` (Task 1) |
| §2.5 якорь = сырые слова, не пересказ; интент P16-e цел | вход берёт `rawPrompt`+`answers`, не пересказ контроллера |
| §7 R1 (остаточный риск) / R5 (регрессия) | задокументировано: валидатор ловит только пустоту слота (R1); Task 5 регрессия (R5) |

**Placeholder-скан:** код полный в каждом шаге, путей-заглушек нет. **Type-consistency:** `owner_asks`/`discrepancies`/`source`/`kind`/`faithful`/`ok`/`reason`/`trace` — единые имена во всех задачах; `parseRouterResponse` — реальный экспорт `router-engine.mjs`. **Scope:** один subsystem (критик верности), работает и тестируется сам по себе с мокнутым `llmCall`.
