test(m3-d): router-engine invariants on real graph + plan + questions log

Машина 3-D «Движок роутера» собрана (TDD): router-engine.mjs (detectHighRisk 6.1
детерминированный / validateLevelSkip 6.2 / cheaperOf / validateTrace 5.1 /
groundTrace ОВ-Д2 / buildRouterPrompt+parse+runRouter, llmCall мокается как
router-classifier) + step-pointer.mjs (дерево-указатель волн D6/OQ1, стендово).
35 новых тестов, регрессия tools-only 2193 GREEN. K4-поправка к стене + live-wiring
+ перенос волн в живой main — ОТЛОЖЕНО до Машины 4 (журнал вопросов).
This commit is contained in:
Дмитрий
2026-06-04 19:46:09 +03:00
parent 3b39f624cc
commit da4831a3b6
3 changed files with 581 additions and 1 deletions
@@ -8,5 +8,23 @@
- Принцип: механика — чистый TDD (контроллер строит сам); рассуждение роутера — LLM, вызов мокается, проверки вокруг механические («умный не баран»). Портативность — без project-хардкода.
## Журнал
(заполняется по ходу)
### 3-A (контракты скилов) — СОБРАНО
- `[ВОПРОС]` Гард-рейлы G1–G3/G5–G6 в спеке поимённо НЕ перечислены (назван только G4 — хеш-страж дрейфа). Реализован G4 + механические валидаторы формы. Остальные G — уточнить при сборке 3-C/3-D или у владельца.
- `[ДОПУЩЕНИЕ]` `PREVIEW_FORMS` = `none/outline/mockup/sample/dry-run/diagram` — выведен из духа L1, дословно в спеке не зафиксирован. Расширяемый.
- `[ДОПУЩЕНИЕ]` Контракты лежат `docs/registry/contracts/*.contract.json` рядом с `nodes.yaml`. Массовое наполнение контрактов для всех ~86 узлов — отдельная задача данных, НЕ код 3-A (собраны 2 образца: own writing-plans + external operations:process-doc).
- `[СТЫК]` Само-ослабление контракта (убрал нужду/критерий → блок + да владельца) — Машина 5, НЕ 3-A (только схема здесь).
### 3-B (граф узлов) — СОБРАНО
- `[ДОПУЩЕНИЕ]` Явных рёбер «близнецы/конфликты» в схеме реестра НЕТ. Близнецы выведены = общий `subcategory`; связи-подсказки = со-членство в `chains`; конфликты = опциональное `attributes.conflicts_with` (пусто, если не задано). Free-text `boundaries` НЕ парсим (ненадёжно). Явные конфликт-рёбра — наполнение позже.
- `[ДОПУЩЕНИЕ]` Члены цепочек вида `superpowers:brainstorming` могут не резолвиться в отдельный узел (суб-скилы superpowers не отдельные узлы) → в связях-подсказках пропускаются. Граф корректен для зарегистрированных узлов.
### 3-C (машина охвата) — СОБРАНО
- `[ДОПУЩЕНИЕ]` Сопоставление need↔produce — по нормализованной строке (lower/trim; равенство или подстрока для просьб). Семантическое сопоставление («spec»≈«требования») — рассуждение роутера (3-D), НЕ механика. Машина охвата честно ловит ТОЧНЫЕ дыры/сироты/циклы.
- `[ДОПУЩЕНИЕ]` Извлечение «просьб цели» из текста (C) — мягкий край, выполняется выше (роутер/владелец); 3-C принимает готовый массив просьб.
### 3-D (движок роутера) — границы автономной сборки (ВАЖНО для владельца)
- `[РЕШЕНИЕ — отложено до Машины 4] K4-поправка к стене (узкое Write-исключение в decideMode)` НЕ активируется в этой сессии. Канон M3 §1 прямо: «ВКЛЮЧАТЬ исключение можно только ПОСЛЕ того, как K1/M4 реальны». Машина 4 (гейт-1/печать) ещё НЕ построена → включишь сейчас = либо инертно, либо дыра (фальшивый артефакт некому отбраковать). Поэтому 3-D строит движок роутера, но `enforce-supreme-gate.decideMode` НЕ трогает. Активация K4 — отдельным шагом после Машины 4.
- `[РЕШЕНИЕ — отложено] Live-wiring роутера` (регистрация в settings.json + реальные вызовы Sonnet вместо моков) — шаг владельца (settings.json Claude'у закрыт) + режим D28 «сперва тихо, не вживую». 3-D строит и тестирует движок с МОКнутым llmCall (как router-classifier); живой вызов через `callAnthropicAPI` готов, но не регистрируется.
- `[РЕШЕНИЕ — отложено] Перенос «волн» (дерево указателей шага)` в живой `enforce-supreme-gate.main()` НЕ делается сейчас (трогает рабочий код Машины 2 + указатель пока линейный достаточен — на прямых планах). Построен СТЕНДОВЫЙ модуль дерева-указателя (мех., оттестирован), готов к замене линейного при живом роутере. Live-замена — отдельный шаг.
@@ -0,0 +1,531 @@
# Машина 3 / под-план 3-D — Движок роутера — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans.
> **NB сборки:** код строит контроллер по TDD (субагент не проходит TDD-гейт).
**Goal:** думающая голова роутера — выбор скила рассуждением по ПОЛНОМУ графу+каталогу (3-B) + контрактам (3-A), с вшитым брейнстормом, видимой трассой 5.1 (структурные слоты), L-ядром (L1/L3/L4/L5/L7), детерминированным риск-фильтром, ценой-разделителем (6.1) — где РАССУЖДЕНИЕ у модели (вызов мокается), а все ПРОВЕРКИ/ЗАЩИТЫ вокруг тупо-механические и покрыты тестами («умный не баран»). Плюс стендовый модуль дерева-указателя (волны D6/OQ1).
**Architecture:** идиома `tools/router-classifier.mjs` (`classify(prompt, registry, {llmCall})` + `callAnthropicAPI({system,user})` ephemeral-cache + `parse*`). `tools/router-engine.mjs`: чистый `buildRouterPrompt`→{system,user} (граф+каталог в system для кэша, 6.2-исключение) + `parseRouterResponse` + механические `validateTrace`/`groundTrace`(реюз 3-B resolveNode)/`validateLevelSkip`/`detectHighRisk`/`cheaperOf` + оркестратор `runRouter({llmCall})` (вызов инъектируется/мокается). `tools/step-pointer.mjs`: дерево указателя шага (волны), стендово.
**Tech Stack:** Node.js ESM, vitest tools-only. Реюз `node-graph` (resolveNode), `coverage-machine` (готовности — стык, не вызываем здесь жёстко).
---
## ⚠️ Контекст исполнения (как 3-A..3-C)
- Git только `git -C "<worktree>"`. Тесты: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>` через Bash, `dangerouslyDisableSandbox=true`, без `cd`.
- Тест-файлы — в `<worktree>\tools\`, ЦЕЛИКОМ через Write. TDD-гейт: Read плана прямыми слэшами + тест + Bash-RED, потом прод.
## Границы 3-D (что НЕ здесь — записано в журнал вопросов)
- **K4-поправка к `enforce-supreme-gate.decideMode`** (узкое Write-исключение артефакта) — **НЕ активируется** (канон §1: только ПОСЛЕ K1/Машины 4). 3-D `decideMode` не трогает.
- **Live-wiring** (регистрация в settings.json + реальные Sonnet-вызовы) — шаг владельца + режим D28 «тихо». Здесь llmCall МОКается; живой `callAnthropicAPI` готов, не регистрируется.
- **Перенос волн в живой `main()` Машины 2** — НЕ делается (трогает рабочий код). Дерево-указатель строится СТЕНДОВО (модуль готов к замене линейного позже).
- **Оценщик постфактум** (правдивость заявленной категории, D31) → Машина 4.
---
## Структура файлов
**Создаём:**
- `tools/router-engine.mjs``detectHighRisk`, `validateLevelSkip`, `cheaperOf` (Task 1); `validateTrace`, `groundTrace` (Task 2); `buildRouterPrompt`, `parseRouterResponse`, `runRouter` (Task 3).
- `tools/step-pointer.mjs` — дерево указателя шага (Task 4).
- Тесты: `tools/router-engine.test.mjs`, `tools/step-pointer.test.mjs`, `tools/m3d-router-invariants.test.mjs`.
**Порядок:** риск/skip/цена (Task 1) → трасса+заземление (Task 2) → промпт+парс+оркестратор-мок (Task 3) → дерево-указатель (Task 4) → инварианты+регрессия (Task 5).
---
## Task 1: Детерминированный риск (6.1) + валидатор пропуска уровня (6.2) + цена-разделитель (6.1)
**Files:** Create `tools/router-engine.mjs`, `tools/router-engine.test.mjs`
- [ ] **Step 1: Падающий тест**`tools/router-engine.test.mjs`
```js
import { describe, it, expect } from 'vitest';
import { detectHighRisk, validateLevelSkip, LEVEL_SKIP_CATEGORIES, cheaperOf } from './router-engine.mjs';
describe('detectHighRisk (6.1 — ДЕТЕРМИНИРОВАННО, LLM-оценка только совет)', () => {
it('разрушительная операция → high', () => {
expect(detectHighRisk({ op: 'Bash', command: 'rm -rf x' }).high).toBe(true);
expect(detectHighRisk({ op: 'Bash', command: 'git push --force' }).high).toBe(true);
});
it('прод-выкат → high', () => {
expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
});
it('многошаговость над порогом → high', () => {
expect(detectHighRisk({ op: 'Skill', stepCount: 9 }).high).toBe(true);
});
it('инъецированный sensitive-флаг → high (портативно, без зашитых путей)', () => {
expect(detectHighRisk({ op: 'Edit', sensitive: true }).high).toBe(true);
});
it('обычное чтение → не high', () => {
expect(detectHighRisk({ op: 'Read' }).high).toBe(false);
});
it('reasons перечисляет причины', () => {
expect(detectHighRisk({ op: 'Bash', command: 'DROP TABLE x' }).reasons.length).toBeGreaterThan(0);
});
});
describe('validateLevelSkip (6.2 — только закрытый список категорий)', () => {
it('категория из закрытого списка → ok', () => {
expect(validateLevelSkip(LEVEL_SKIP_CATEGORIES[0]).ok).toBe(true);
});
it('свободный текст → отклонён', () => {
expect(validateLevelSkip('не хочу заморачиваться').ok).toBe(false);
});
it('пусто → отклонён', () => {
expect(validateLevelSkip('').ok).toBe(false);
expect(validateLevelSkip(null).ok).toBe(false);
});
});
describe('cheaperOf (6.1 — цена РАЗДЕЛИТЕЛЬ равноценных, не глушилка)', () => {
it('при равном качестве берёт дешевле', () => {
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 5 }, { skill: 'B', qualityRank: 1, costRank: 2 }]);
expect(r.skill).toBe('B');
});
it('лучшее качество побеждает несмотря на цену (цена НЕ важнее правильности)', () => {
const r = cheaperOf([{ skill: 'A', qualityRank: 1, costRank: 9 }, { skill: 'B', qualityRank: 2, costRank: 1 }]);
expect(r.skill).toBe('A');
});
it('пусто → null', () => { expect(cheaperOf([])).toBe(null); });
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация**`tools/router-engine.mjs`
```js
#!/usr/bin/env node
/**
* router-engine — думающая голова роутера (3-D). РАССУЖДЕНИЕ — у модели (llmCall
* инъектируется/мокается, идиома router-classifier); все ПРОВЕРКИ/ЗАЩИТЫ вокруг —
* тупо-механические и покрыты тестами («умный не баран»). Группы 1/2/5/6 + look-ahead
* + L-ядро + риск-фильтр W1+W2 + деньги 6.1/6.2. K4-поправку к стене и live-wiring
* НЕ трогает (см. журнал вопросов: только после Машины 4).
*/
import { resolveNode } from './node-graph.mjs';
// Универсальные разрушительные глаголы (портативно — не project-хардкод путей).
const DESTRUCTIVE_RE = /\b(rm|rmdir|drop|delete|truncate|--force|force-push|reset --hard|migrate:fresh|format)\b|push\s+--force/i;
/**
* 6.1 — ДЕТЕРМИНИРОВАННЫЙ высокий риск (по операции/прод-выкату/многошаговости/
* инъецированному sensitive-флагу). LLM-оценка риска — ТОЛЬКО совет, НЕ триггер
* блока (жёсткий блок нельзя вешать на догадку модели). stepThreshold — ручка.
*/
export function detectHighRisk({ op, command = '', prodDeploy = false, stepCount = 0, sensitive = false } = {}, { stepThreshold = 7 } = {}) {
const reasons = [];
if (DESTRUCTIVE_RE.test(command)) reasons.push('разрушительная операция');
if (prodDeploy) reasons.push('прод-выкат');
if (stepCount >= stepThreshold) reasons.push(`многошаговость (${stepCount}${stepThreshold})`);
if (sensitive) reasons.push('чувствительная зона (инъецированный флаг)');
return { high: reasons.length > 0, reasons };
}
// 6.2 — закрытый список категорий пропуска уровня (свободный текст отклоняется).
export const LEVEL_SKIP_CATEGORIES = Object.freeze(['atomic', 'trivial', 'obvious-single-path', 'no-skill-needed']);
/** 6.2 — пропуск уровня только ЯВНОЙ категорией из закрытого списка. */
export function validateLevelSkip(category) {
const ok = typeof category === 'string' && LEVEL_SKIP_CATEGORIES.includes(category.trim());
return { ok, allowed: LEVEL_SKIP_CATEGORIES };
}
/**
* 6.1 — цена как РАЗДЕЛИТЕЛЬ равноценных: сначала лучшее качество (min qualityRank),
* среди равных по качеству — min costRank. Цена НЕ важнее правильности.
*/
export function cheaperOf(candidates) {
if (!Array.isArray(candidates) || candidates.length === 0) return null;
return [...candidates].sort((a, b) =>
(a.qualityRank - b.qualityRank) || (a.costRank - b.costRank))[0];
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" add tools/router-engine.mjs tools/router-engine.test.mjs` + commit `"feat(m3-d): router-engine — deterministic risk (6.1) + level-skip (6.2) + price discriminator"`
---
## Task 2: Видимая трасса 5.1 — validateTrace (слоты, 6.2 A) + groundTrace (ОВ-Д2 C)
**Files:** Modify `tools/router-engine.mjs`, `tools/router-engine.test.mjs`
- [ ] **Step 1: Падающие тесты (добавить)**
```js
import { validateTrace, groundTrace, TRACE_SLOTS } from './router-engine.mjs';
import { buildNodeGraph } from './node-graph.mjs';
const GRAPH = buildNodeGraph({ nodes: [
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', status: 'active' },
{ id: '#55', slug: 'discovery-interview', name: 'discovery-interview', status: 'active' },
], chains: {} });
const FULL_TRACE = {
candidates: ['writing-plans', 'discovery-interview'],
chosen: 'writing-plans',
why_chosen: 'multi-step feature plan',
twins_considered: 'discovery-interview — отброшен: цель не интервью',
confidence: 0.8,
};
describe('validateTrace (5.1 структурные слоты; пустой слот = красный флаг, §6.2 A)', () => {
it('полная трасса → ok', () => {
expect(validateTrace(FULL_TRACE)).toEqual({ ok: true, missingSlots: [] });
});
it('пустой слот → невалидно + перечень', () => {
const r = validateTrace({ ...FULL_TRACE, why_chosen: '' });
expect(r.ok).toBe(false); expect(r.missingSlots).toContain('why_chosen');
});
it('нет кандидатов → невалидно', () => {
expect(validateTrace({ ...FULL_TRACE, candidates: [] }).ok).toBe(false);
});
it('TRACE_SLOTS перечисляет обязательные слоты', () => {
expect(TRACE_SLOTS).toContain('chosen');
});
});
describe('groundTrace (ОВ-Д2 C — все кандидаты в реальные узлы; выдумка → отклонение)', () => {
it('все кандидаты резолвятся → grounded', () => {
expect(groundTrace(FULL_TRACE, GRAPH)).toMatchObject({ grounded: true, invented: [] });
});
it('выдуманный кандидат → not grounded + перечень', () => {
const r = groundTrace({ ...FULL_TRACE, candidates: ['writing-plans', 'elasticsearch-magic'] }, GRAPH);
expect(r.grounded).toBe(false); expect(r.invented).toContain('elasticsearch-magic');
});
it('выдуманный chosen → not grounded', () => {
const r = groundTrace({ ...FULL_TRACE, chosen: 'made-up' }, GRAPH);
expect(r.grounded).toBe(false);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация (дописать)**
```js
// 5.1 — обязательные структурные слоты трассы (схема=метод, §6.2 A).
export const TRACE_SLOTS = Object.freeze(['candidates', 'chosen', 'why_chosen', 'twins_considered', 'confidence']);
/**
* Механический валидатор трассы (ОТДЕЛЬНЫЙ код, не сам роутер — §6.2 B, аналог A0):
* пустой/отсутствующий слот = красный флаг. candidates должен быть непустым массивом,
* confidence — число, остальные — непустые строки.
*/
export function validateTrace(trace) {
const missingSlots = [];
if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...TRACE_SLOTS] };
if (!Array.isArray(trace.candidates) || trace.candidates.length === 0) missingSlots.push('candidates');
for (const slot of ['chosen', 'why_chosen', 'twins_considered']) {
if (typeof trace[slot] !== 'string' || !trace[slot].trim()) missingSlots.push(slot);
}
if (typeof trace.confidence !== 'number') missingSlots.push('confidence');
return { ok: missingSlots.length === 0, missingSlots };
}
/**
* ОВ-Д2 / §6.3 C — заземление: каждый кандидат И выбранный обязаны резолвиться в
* реальный узел графа (3-B resolveNode); выдумка → grounded=false + перечень invented.
*/
export function groundTrace(trace, graph) {
const invented = [];
const check = (ref) => { if (resolveNode(graph, ref) === null) invented.push(ref); };
for (const cand of (trace?.candidates || [])) check(cand);
if (trace?.chosen) check(trace.chosen);
return { grounded: invented.length === 0, invented };
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" commit -am "feat(m3-d): visible trace 5.1 validator + groundTrace (ОВ-Д2)"`
---
## Task 3: Построитель промпта + парсер + оркестратор runRouter (llmCall мокается)
**Files:** Modify `tools/router-engine.mjs`, `tools/router-engine.test.mjs`
- [ ] **Step 1: Падающие тесты (добавить)**
```js
import { buildRouterPrompt, parseRouterResponse, runRouter } from './router-engine.mjs';
const CATALOG = { nodes: [
{ id: '#19', slug: 'writing-plans', name: 'writing-plans', capabilities: 'plans', status: 'active' },
] };
describe('buildRouterPrompt (чистая, {system,user}; граф+каталог в system для кэша 6.2)', () => {
it('детерминирована: тот же вход → тот же текст', () => {
const a = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
const b = buildRouterPrompt({ prompt: 'add feature', graph: GRAPH, catalog: CATALOG });
expect(a.system).toBe(b.system);
expect(a.user).toBe(b.user);
});
it('system несёт вшитый брейнсторм + слоты трассы; user — задачу', () => {
const { system, user } = buildRouterPrompt({ prompt: 'add feature X', graph: GRAPH, catalog: CATALOG });
expect(system).toMatch(/вариант|brainstorm|2.?3/i);
expect(system).toMatch(/candidates/);
expect(user).toMatch(/add feature X/);
});
});
describe('parseRouterResponse', () => {
it('парсит JSON-трассу', () => {
const t = parseRouterResponse('```json\n{"chosen":"writing-plans","candidates":["writing-plans"],"why_chosen":"x","twins_considered":"y","confidence":0.9}\n```');
expect(t.chosen).toBe('writing-plans');
});
it('мусор → null', () => { expect(parseRouterResponse('не json')).toBe(null); });
});
describe('runRouter (оркестратор; llmCall мокается)', () => {
const okTrace = { candidates: ['writing-plans'], chosen: 'writing-plans', why_chosen: 'feature', twins_considered: 'none', confidence: 0.85 };
it('валидная заземлённая трасса с уверенностью → ok-результат', async () => {
const r = await runRouter({ prompt: 'add feature', graph: GRAPH, llmCall: async () => okTrace });
expect(r.ok).toBe(true); expect(r.trace.chosen).toBe('writing-plans'); expect(r.abstain).toBe(false);
});
it('пустой слот трассы → невалидно (мех. флаг, не результат)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, why_chosen: '' }) });
expect(r.ok).toBe(false); expect(r.reason).toMatch(/слот|trace|missing/i);
});
it('выдуманный кандидат → отклонение (заземление)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => ({ ...okTrace, candidates: ['made-up'], chosen: 'made-up' }) });
expect(r.ok).toBe(false); expect(r.reason).toMatch(/выдум|invent|заземл/i);
});
it('низкая уверенность → воздержание (5.2), не угадывание', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, confidenceThreshold: 0.7, llmCall: async () => ({ ...okTrace, confidence: 0.3 }) });
expect(r.abstain).toBe(true);
});
it('llmCall вернул мусор → ok=false (не падает)', async () => {
const r = await runRouter({ prompt: 'x', graph: GRAPH, llmCall: async () => null });
expect(r.ok).toBe(false);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация (дописать)**
```js
/**
* Построитель промпта (чистый, {system,user} — идиома buildClassifierPromptStructured).
* system: статичные инструкции роутера + вшитый брейнсторм (2–3 варианта → спор → выбор)
* + L-ядро (L1 образец / L3 дефолт-на-вето / L4 порядок по влиянию / L5 ограничения-
* пре-мортем / L7 критерии приёмки) + схема слотов трассы 5.1 + ПОЛНЫЙ граф+каталог
* (6.2-исключение: в system для кэша). user: задача (+план/прогресс) — волатильное.
*/
export function buildRouterPrompt({ prompt = '', plan = null, graph = null, catalog = null } = {}) {
const nodesForCtx = (catalog && catalog.nodes) || (graph && graph.nodes) || [];
const catalogText = nodesForCtx.map((n) => `${n.id || ''} ${n.slug || ''}${n.capabilities || n.name || ''}`).join('\n');
const system = [
'Ты — РОУТЕР-НАСТАВНИК. Выбираешь навык по СМЫСЛУ задачи рассуждением (ключевые слова сняты).',
'МЕТОД (вшитый брейнсторм): придумай 2–3 варианта-кандидата с разных углов графа → поспорь с собой (почему не сосед-близнец? а если я неправ?) → выбери лучший с обоснованием.',
'L-ядро: L1 предложи дешёвый образец до полной стройки; L3 давай разумный дефолт-на-вето вместо открытого вопроса; L4 порядок решений по влиянию (фундамент раньше мелочи); L5 ограничения как пре-мортем; L7 объяви критерий приёмки заранее.',
'ВЫВОД — строго JSON-трасса со слотами: candidates (массив реальных узлов графа), chosen, why_chosen, twins_considered (почему не близнец), confidence (0..1). Пустой слот недопустим.',
'Кандидаты ОБЯЗАНЫ быть реальными узлами из каталога ниже — не выдумывай.',
'--- ГРАФ+КАТАЛОГ УЗЛОВ (100%) ---',
catalogText,
].join('\n');
const user = [
`ЗАДАЧА: ${prompt}`,
plan ? `ПЛАН (прогресс): ${typeof plan === 'string' ? plan : JSON.stringify(plan)}` : '',
].filter(Boolean).join('\n');
return { system, user };
}
/** Парсер ответа роутера → трасса-объект | null (терпимый к ```json fence). */
export function parseRouterResponse(text) {
if (typeof text !== 'string') return null;
const m = text.match(/```json\s*([\s\S]*?)```/i) || text.match(/(\{[\s\S]*\})/);
const body = m ? m[1] : text;
try { const o = JSON.parse(body); return (o && typeof o === 'object') ? o : null; }
catch { return null; }
}
/**
* Оркестратор: построить промпт → llmCall (инъектируется; в проде callAnthropicAPI) →
* распарсить → МЕХАНИЧЕСКИ провалидировать трассу (слоты) + заземлить кандидатов (ОВ-Д2) →
* воздержание 5.2 при низкой уверенности. Сам выбор — у модели; всё вокруг — механика.
*/
export async function runRouter({ prompt, plan = null, graph, catalog = null, llmCall, confidenceThreshold = 0.5 }) {
let trace;
try { trace = await llmCall({ buildPrompt: () => buildRouterPrompt({ prompt, plan, graph, catalog }) }); }
catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; }
if (typeof trace === 'string') trace = parseRouterResponse(trace);
if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null };
const v = validateTrace(trace);
if (!v.ok) return { ok: false, reason: `невалидная трасса: пустые слоты [${v.missingSlots.join(', ')}]`, trace };
const g = groundTrace(trace, graph);
if (!g.grounded) return { ok: false, reason: `выдуманные узлы (нет заземления): [${g.invented.join(', ')}]`, trace };
if (typeof trace.confidence === 'number' && trace.confidence < confidenceThreshold)
return { ok: true, abstain: true, reason: 'низкая уверенность — воздержание (5.2): переспросить/эскалация', trace };
return { ok: true, abstain: false, trace };
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" commit -am "feat(m3-d): buildRouterPrompt + parseRouterResponse + runRouter orchestrator (llmCall injected)"`
---
## Task 4: Дерево указателя шага (волны D6/OQ1) — стендовый модуль
**Files:** Create `tools/step-pointer.mjs`, `tools/step-pointer.test.mjs`
- [ ] **Step 1: Падающий тест**`tools/step-pointer.test.mjs`
```js
import { describe, it, expect } from 'vitest';
import { createPointer, advance, enterSubPlan, exitSubPlan, currentPath, isDone } from './step-pointer.mjs';
describe('step-pointer (волны: линейный → дерево указателей, D6/OQ1)', () => {
it('линейное продвижение даёт путь [n]', () => {
let p = createPointer({ length: 3 });
expect(currentPath(p)).toEqual([0]);
p = advance(p); expect(currentPath(p)).toEqual([1]);
});
it('вход в под-план углубляет путь, выход возвращает', () => {
let p = createPointer({ length: 2 });
p = enterSubPlan(p, { length: 2 }); // шаг 0 раскрылся в под-план
expect(currentPath(p)).toEqual([0, 0]);
p = advance(p); expect(currentPath(p)).toEqual([0, 1]);
p = exitSubPlan(p); // под-план исчерпан → назад к родителю
p = advance(p); expect(currentPath(p)).toEqual([1]);
});
it('isDone когда корень исчерпан', () => {
let p = createPointer({ length: 1 });
expect(isDone(p)).toBe(false);
p = advance(p);
expect(isDone(p)).toBe(true);
});
it('линейный режим обратно совместим (один уровень = число indexAtRoot)', () => {
let p = createPointer({ length: 5 });
p = advance(advance(p));
expect(p.indexAtRoot).toBe(2);
});
});
```
- [ ] **Step 2: RED.**
- [ ] **Step 3: Реализация**`tools/step-pointer.mjs`
```js
#!/usr/bin/env node
/**
* step-pointer — указатель шага плана как ДЕРЕВО (волны D6/OQ1). Заменяет линейный
* счётчик Машины 2, когда шаг раскрывается в под-план. СТЕНДОВЫЙ модуль: построен и
* оттестирован, но в живой enforce-supreme-gate.main() НЕ врезается в этой сессии
* (см. журнал вопросов — отдельный шаг). Линейный режим обратно совместим.
*/
/** Указатель = стек уровней [{index, length}]; верхний — текущий под-план. */
export function createPointer({ length }) {
return { stack: [{ index: 0, length }] };
}
/** Текущий путь — индексы по уровням стека. */
export function currentPath(p) { return p.stack.map((l) => l.index); }
/** Индекс на корневом уровне (обратная совместимость с линейным счётчиком). */
function rootIndex(p) { return p.stack[0].index; }
/** Продвинуть указатель на текущем (верхнем) уровне. */
export function advance(p) {
const stack = p.stack.map((l) => ({ ...l }));
stack[stack.length - 1].index += 1;
const np = { stack };
np.indexAtRoot = rootIndex(np);
return np;
}
/** Текущий шаг раскрылся в под-план длины length → углубляемся. */
export function enterSubPlan(p, { length }) {
const stack = p.stack.map((l) => ({ ...l }));
stack.push({ index: 0, length });
const np = { stack };
np.indexAtRoot = rootIndex(np);
return np;
}
/** Под-план исчерпан → возвращаемся к родителю (на его текущем шаге). */
export function exitSubPlan(p) {
if (p.stack.length <= 1) return p;
const stack = p.stack.slice(0, -1).map((l) => ({ ...l }));
const np = { stack };
np.indexAtRoot = rootIndex(np);
return np;
}
/** Готово, когда корневой уровень исчерпан. */
export function isDone(p) {
const root = p.stack[0];
return p.stack.length === 1 && root.index >= root.length;
}
```
- [ ] **Step 4: GREEN.**
- [ ] **Step 5: Commit**`git -C "<worktree>" add tools/step-pointer.mjs tools/step-pointer.test.mjs` + commit `"feat(m3-d): step-pointer tree (waves D6/OQ1) — standalone, not yet wired"`
---
## Task 5: Инварианты 3-D + регрессия
**Files:** Create `tools/m3d-router-invariants.test.mjs`
- [ ] **Step 1: Инвариант-тест** — собирает движок поверх РЕАЛЬНОГО графа узлов (loadRegistry) + проверяет, что выдуманный узел отклоняется, реальный проходит.
```js
import { describe, it, expect } from 'vitest';
import { loadRegistry, clearCache } from './registry-load.mjs';
import { buildNodeGraph } from './node-graph.mjs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runRouter, buildRouterPrompt, detectHighRisk } from './router-engine.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const registryPath = join(here, '..', 'docs', 'registry', 'nodes.yaml');
describe('Машина 3-D — движок на реальном графе узлов', () => {
it('выбор реального узла проходит, выдуманный отклоняется (на реальном графе)', async () => {
clearCache();
const graph = buildNodeGraph(loadRegistry({ registryPath, useCache: false }));
const real = await runRouter({ prompt: 'нужна архитектурная диаграмма', graph,
llmCall: async () => ({ candidates: ['mermaid'], chosen: 'mermaid', why_chosen: 'диаграмма', twins_considered: 'нет', confidence: 0.9 }) });
expect(real.ok).toBe(true);
const fake = await runRouter({ prompt: 'x', graph,
llmCall: async () => ({ candidates: ['totally-invented'], chosen: 'totally-invented', why_chosen: 'y', twins_considered: 'z', confidence: 0.9 }) });
expect(fake.ok).toBe(false);
});
it('промпт несёт каталог реальных узлов', () => {
clearCache();
const graph = buildNodeGraph(loadRegistry({ registryPath, useCache: false }));
const { system } = buildRouterPrompt({ prompt: 'x', graph });
expect(system).toMatch(/mermaid|adr-kit|writing-plans/);
});
it('детерминированный риск на прод-выкате не зависит от мнения модели', () => {
expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
});
});
```
- [ ] **Step 2: GREEN** (m3d invariants).
- [ ] **Step 3: Полная регрессия**`npx vitest run --root ... --config ...` без фильтра → всё зелёное.
- [ ] **Step 4: Commit**`git -C "<worktree>" add tools/m3d-router-invariants.test.mjs docs/superpowers/plans/2026-06-04-router-mentor-3d-router-engine.md` + commit `"test(m3-d): router-engine invariants on real graph + plan"`
---
## Self-Review (против канона §2/§3 3-D + §6)
- **Выбор по смыслу рассуждением (вызов мокается)** → runRouter(llmCall), buildRouterPrompt полный граф+каталог в system (6.2 кэш). ✅
- **Вшитый брейнсторм (2–3 варианта→спор→выбор) + L-ядро** → в system-инструкции. ✅
- **Видимая трасса 5.1 + механический валидатор (пустой слот=флаг, §6.2 A, отдельный код)** → validateTrace. ✅
- **Заземление (ОВ-Д2/§6.3 C, выдумка→отклонение)** → groundTrace (реюз 3-B resolveNode). ✅
- **Воздержание 5.2** → runRouter abstain при низкой уверенности. ✅
- **Риск-фильтр детерминированный (6.1), LLM-оценка только совет** → detectHighRisk (универсальные глаголы/прод/многошаг/инъецированный sensitive, портативно). ✅
- **Деньги 6.1 цена-разделитель** → cheaperOf (качество > цена). 6.2 пропуск уровня закрытым списком → validateLevelSkip. ✅
- **Волны D6/OQ1** → step-pointer (стендово, не врезано). ✅
- **K4-поправка + live-wiring + волны-в-живой-main** → ОТЛОЖЕНО (журнал вопросов; только после Машины 4 / шаг владельца). ✅
- **Нюх 5.3 / интервьюер 4.4 / самопроверка 5.4** — вшиты в system-инструкции брейнсторма (рассуждение модели), мех. часть — валидатор трассы; отдельные мех.-детекторы нюха — мягкий край (LLM), не строим жёстко. **[ДОПУЩЕНИЕ в журнал.]**
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { loadRegistry, clearCache } from './registry-load.mjs';
import { buildNodeGraph } from './node-graph.mjs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runRouter, buildRouterPrompt, detectHighRisk } from './router-engine.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const registryPath = join(here, '..', 'docs', 'registry', 'nodes.yaml');
describe('Машина 3-D — движок на реальном графе узлов', () => {
it('выбор реального узла проходит, выдуманный отклоняется (на реальном графе)', async () => {
clearCache();
const graph = buildNodeGraph(loadRegistry({ registryPath, useCache: false }));
const real = await runRouter({ prompt: 'нужна архитектурная диаграмма', graph,
llmCall: async () => ({ candidates: ['mermaid'], chosen: 'mermaid', why_chosen: 'диаграмма', twins_considered: 'нет', confidence: 0.9 }) });
expect(real.ok).toBe(true);
const fake = await runRouter({ prompt: 'x', graph,
llmCall: async () => ({ candidates: ['totally-invented'], chosen: 'totally-invented', why_chosen: 'y', twins_considered: 'z', confidence: 0.9 }) });
expect(fake.ok).toBe(false);
});
it('промпт несёт каталог реальных узлов', () => {
clearCache();
const graph = buildNodeGraph(loadRegistry({ registryPath, useCache: false }));
const { system } = buildRouterPrompt({ prompt: 'x', graph });
expect(system).toMatch(/mermaid|adr-kit|writing-plans/);
});
it('детерминированный риск на прод-выкате не зависит от мнения модели', () => {
expect(detectHighRisk({ op: 'Bash', command: 'php artisan migrate', prodDeploy: true }).high).toBe(true);
});
});