From da4831a3b67fcf8e28d74bb8bbad2b95559fa0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 4 Jun 2026 19:46:09 +0300 Subject: [PATCH] test(m3-d): router-engine invariants on real graph + plan + questions log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Машина 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 (журнал вопросов). --- .../2026-06-04-machine-3-build-questions.md | 20 +- ...26-06-04-router-mentor-3d-router-engine.md | 531 ++++++++++++++++++ tools/m3d-router-invariants.test.mjs | 31 + 3 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-06-04-router-mentor-3d-router-engine.md create mode 100644 tools/m3d-router-invariants.test.mjs diff --git a/docs/superpowers/plans/2026-06-04-machine-3-build-questions.md b/docs/superpowers/plans/2026-06-04-machine-3-build-questions.md index 4b493cc7..dbab60f2 100644 --- a/docs/superpowers/plans/2026-06-04-machine-3-build-questions.md +++ b/docs/superpowers/plans/2026-06-04-machine-3-build-questions.md @@ -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-замена — отдельный шаг. diff --git a/docs/superpowers/plans/2026-06-04-router-mentor-3d-router-engine.md b/docs/superpowers/plans/2026-06-04-router-mentor-3d-router-engine.md new file mode 100644 index 00000000..6fb4f979 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-router-mentor-3d-router-engine.md @@ -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 ""`. Тесты: `npx vitest run --root ".claude/worktrees/brainrepo/app" --config vitest.config.tools.mjs <фильтр>` через Bash, `dangerouslyDisableSandbox=true`, без `cd`. +- Тест-файлы — в `\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 "" 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 "" 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 "" 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 "" 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 "" 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), не строим жёстко. **[ДОПУЩЕНИЕ в журнал.]** diff --git a/tools/m3d-router-invariants.test.mjs b/tools/m3d-router-invariants.test.mjs new file mode 100644 index 00000000..ce4de984 --- /dev/null +++ b/tools/m3d-router-invariants.test.mjs @@ -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); + }); +});