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:
@@ -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), не строим жёстко. **[ДОПУЩЕНИЕ в журнал.]**
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user