diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-safe-core-plan.md b/docs/superpowers/plans/2026-06-17-es1-gate3-safe-core-plan.md new file mode 100644 index 0000000..8ca702e --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-safe-core-plan.md @@ -0,0 +1,203 @@ +# E-S1 / gate-3 безопасное ядро — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:test-driven-development. + +**Goal:** Чистое ядро третьего гейта «цель достигнута?»: `buildGate3Product` (продукт для судьи) + +`decideGate3Closure` (замыкание петли через `loopTerminationDecision`), без триггера-в-стене. + +**Architecture:** Обе функции — в существующий `tools/loop-termination.mjs` (его естественный дом: +он и есть модуль закрытия петли; `decideGate3Closure` зовёт `loopTerminationDecision` из того же +файла). Чистые, детерминированные, модель/стену НЕ трогают. Спека v2 §g5 называла отдельные +модули — план осознанно консолидирует в loop-termination.mjs по решению владельца (избежать +override на новый production-файл); ВСЕ контракты §t1/§g1/§g2 honored побайтно, имена функций и +поведение те же. + +**Tech Stack:** Node ESM, vitest. Источник истины — spec `2026-06-17-es1-gate3-safe-core-design-v2.md`. + +--- + +## Цель + +Реализовать §g2 (`buildGate3Product`) + §g1 (`decideGate3Closure`) из spec v2, с аутентификацией +входов §t1 (degraded никогда не закрывает; контроллер без подписи закрыть не может). Триггер-в-стене +и «зубы» — вне объёма (§g6, спека #2). + +--- + +### Task 1: Тесты ядра gate-3 (RED) + +**Files:** Test: `tools/loop-termination.test.mjs` + +- [ ] **Step 1: Дописать describe-блоки** (Edit — добавить в конец файла): + +```js +import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs'; + +describe('gate-3 buildGate3Product (E-S1 §g2)', () => { + it('цель+шаги+greens → продукт со сводкой исполнения и green-метками', () => { + const r = buildGate3Product({ + goal: 'добавить хелпер', + planSteps: [{ id: 's1', op: 'Write', object: 'tools/x.mjs' }], + greenRuns: [{ stepId: 's1', criterion: 'x-test' }], + }); + expect(r.goal).toBe('добавить хелпер'); + expect(r.product).toMatch(/Write tools\/x\.mjs/); + expect(r.product).toMatch(/green: x-test/); + expect(r.cards).toEqual([]); + }); + it('нет greens → пометка «нет доказательств исполнения»', () => { + const r = buildGate3Product({ goal: 'g', planSteps: [{ op: 'Bash', object: 'echo a' }], greenRuns: [] }); + expect(r.product).toMatch(/нет доказательств исполнения/); + }); + it('пустой ввод → не бросает, форма {product,goal,cards}', () => { + const r = buildGate3Product({}); + expect(r).toEqual({ product: expect.stringMatching(/нет доказательств/), goal: '', cards: [] }); + }); +}); + +describe('gate-3 decideGate3Closure (E-S1 §g1/§g4/§t1)', () => { + it('degraded судья (wired:false) без accept → negotiate, НЕ closed', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: false, decision: 'GO' } }); + expect(r.state).toBe('negotiate'); + expect(r.terminate).toBe(false); + }); + it('подписанный владелец accept → closed+terminate (приоритет над судьёй)', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: false }, ownerArbitration: 'accept' }); + expect(r.state).toBe('closed'); + expect(r.terminate).toBe(true); + }); + it('реальный GO движка → closed+terminate', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' } }); + expect(r.state).toBe('closed'); + expect(r.terminate).toBe(true); + }); + it('владелец continue → open', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, ownerArbitration: 'continue' }); + expect(r.state).toBe('open'); + expect(r.terminate).toBe(false); + }); + it('NO-GO & круг<3 → negotiate', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, noGoCount: 1 }); + expect(r.state).toBe('negotiate'); + }); + it('NO-GO & круг>=3 → arbitrate + карточка', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, noGoCount: 3 }); + expect(r.state).toBe('arbitrate'); + expect(r.card).toBe(true); + }); + it('контроллер без подписи (нет accept, NO-GO) закрыть не может', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, noGoCount: 0 }); + expect(r.terminate).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Прогнать тесты gate-3 — ожидать RED** + +Run: `npx vitest run tools/loop-termination.test.mjs -t "gate-3" --config vitest.config.tools.mjs` +Expected: FAIL — `buildGate3Product`/`decideGate3Closure` не определены. + +--- + +### Task 2: Реализация ядра (GREEN) + +**Files:** Modify: `tools/loop-termination.mjs` + +- [ ] **Step 3: Добавить обе функции** (Edit — добавить в конец файла, после `loopTerminationDecision`): + +```js + +/** + * E-S1 §g2: продукт судьи gate-3 (цель достигнута?) — детерминированная сводка исполненного: + * цель + шаги + их по-критерию GREEN. Форма совместима с buildJudgePrompt (judge-engine). + */ +export function buildGate3Product({ goal = '', planSteps = [], greenRuns = [] } = {}) { + const greenById = new Map(); + for (const g of Array.isArray(greenRuns) ? greenRuns : []) { + if (g && g.stepId != null) greenById.set(String(g.stepId), g.criterion ?? true); + } + const lines = (Array.isArray(planSteps) ? planSteps : []).map((s, i) => { + const id = s && s.id != null ? String(s.id) : String(i); + const green = greenById.has(id) ? greenById.get(id) : null; + return { text: `${s && s.op} ${s && s.object}`, green }; + }); + const hasEvidence = lines.some((l) => l.green != null); + const product = hasEvidence + ? lines.map((l) => `${l.text}${l.green != null ? ` [green: ${l.green}]` : ''}`).join('\n') + : 'нет доказательств исполнения (шаги не подтверждены по-критерию)'; + return { product, goal: String(goal || ''), cards: [] }; +} + +/** + * E-S1 §g1/§g4: оркестратор замыкания внешней петли. Закрытие ТОЛЬКО через loopTerminationDecision + * (SE-R7-6). Degraded судья (wired:false) НИКОГДА не закрывает/не арбитрит — fail-safe против + * ложного исхода на сбое транспорта. Входы аутентифицирует потребитель (§t1: ownerArbitration — + * только из подписанного канала владельца; gate3Verdict — только выход движка); функция чистая. + */ +export function decideGate3Closure({ gate3Verdict = null, noGoCount = 0, ownerArbitration = null, maxRounds = 3 } = {}) { + const degraded = !!gate3Verdict && gate3Verdict.wired === false; + if (degraded && ownerArbitration !== 'accept') { + return { state: 'negotiate', terminate: false, reason: 'судья gate-3 недоступен — не закрывать, повтор/доработка' }; + } + if (ownerArbitration === 'accept') { + const t = loopTerminationDecision({ ownerDeclaredDone: true }); + return { state: 'closed', terminate: t.terminate, reason: t.reason }; + } + if (gate3Verdict && gate3Verdict.decision === 'GO' && gate3Verdict.wired !== false) { + const t = loopTerminationDecision({ judgeGate3Go: true }); + return { state: 'closed', terminate: t.terminate, reason: t.reason }; + } + if (ownerArbitration === 'continue') { + return { state: 'open', terminate: false, reason: 'владелец выбрал продолжать' }; + } + const rc = (Number.isInteger(noGoCount) && noGoCount >= 0) ? noGoCount : maxRounds; + if (rc < maxRounds) { + return { state: 'negotiate', terminate: false, reason: `gate-3 NO-GO; круг ${rc}/${maxRounds} — переговоры` }; + } + return { state: 'arbitrate', terminate: false, reason: `gate-3 NO-GO ${maxRounds} круга — арбитраж владельцу`, card: true }; +} +``` + +- [ ] **Step 4: Прогнать весь файл — ожидать GREEN** + +Run: `npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs` +Expected: PASS (gate-3 тесты + существующие loopTerminationDecision). + +- [ ] **Step 5: Полная регрессия** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS (база зелёная + новые тесты; существующие модули не трогались → 0 регрессий). + +--- + +## Self-Review + +- **Покрытие spec:** §t1 (аутентификация входов) → decideGate3Closure degraded+accept-приоритет + + тест «контроллер без подписи не закрывает»; §g1 (контракт/порядок проверок) → Task 2 Step 3 + + тесты closure; §g2 (продукт) → buildGate3Product + тесты; §g4 (terminate только из хелпера) → + closed-ветки зовут loopTerminationDecision; §g7 (критерий) → Steps 2/4/5. §g6 (триггер/зубы) — + вне объёма, не реализуется (верно). +- **Плейсхолдеров нет** — весь код приведён. +- **Согласованность имён:** `buildGate3Product({goal,planSteps,greenRuns})→{product,goal,cards}`; + `decideGate3Closure({gate3Verdict,noGoCount,ownerArbitration,maxRounds})→{state,terminate,reason,card?}`; + `loopTerminationDecision` — из того же файла (hoisted, импорт не нужен). Едины во всех тасках. +- **Отступление от спеки (осознанное):** код в `loop-termination.mjs`, не в отдельных gate3-*.mjs + (§g5) — решение владельца ради избежания override на новый production-файл; контракты не изменены. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Edit","object":"tools/loop-termination.test.mjs","ref":"g7"}, + {"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs -t \"gate-3\" --config vitest.config.tools.mjs","ref":"g7"}, + {"op":"Edit","object":"tools/loop-termination.mjs","ref":"g1"}, + {"op":"Bash","object":"npx vitest run tools/loop-termination.test.mjs --config vitest.config.tools.mjs","ref":"g2"}, + {"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs --no-file-parallelism","ref":"g7"} +] +``` + +```verified-context-json +[{"id":"pc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"loopTerminationDecision"}] +``` diff --git a/docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design-v2.md b/docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design-v2.md new file mode 100644 index 0000000..1bc0a07 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-es1-gate3-safe-core-design-v2.md @@ -0,0 +1,136 @@ +# E-S1 / gate-3 безопасное ядро — судья «цель достигнута» + замыкание петли (v2) + +**Дата:** 2026-06-17 +**Эпик:** роутер-наставник, E-S1 (sub-plan C, ядро). **Статус:** дизайн под реализацию (TDD). +**Кодовая фраза:** «роутер-наставник». + +## Цель + +Сейчас машинерия судит спеку (gate1) и план (gate2) ДО работы, но никогда не проверяет ПОСЛЕ — +достиг ли результат заявленной цели. Внешняя петля закрывается фактически по усмотрению контроллера. +Хелпер `loopTerminationDecision` уже задаёт правило «петлю закрывает ТОЛЬКО владелец явно ЛИБО GO +судьи gate-3», но он нигде не потреблён. Эта спека строит **безопасное ядро** третьего гейта: +чистый движок-судью «цель достигнута?», его переговоры+арбитраж (симметрично gate1/gate2) и +оркестратор замыкания петли через `loopTerminationDecision`. Автозапуск gate-3 на завершении плана +(триггер внутри стены) — НЕ здесь (см. «Вне объёма»). Ядро — чистая логика, тестируемая в изоляции; +кода стены НЕ трогает. + +## Доверие входов (анти-обход) {#t1} + +Ядро чистое (принимает параметры), но безопасность держит контракт ИСТОЧНИКА входов — иначе +контроллер подделал бы «владелец принял» и закрыл петлю в обход (класс премортем: abuse/seam): + +- `ownerArbitration` принимается ТОЛЬКО из подписанного канала владельца — запись + `askuser-decisions-.jsonl`, подписанная ключом владельца (`router-mentor-receipts`), + как floor_escape/approve. Потребитель ОБЯЗАН проверить подпись ДО вызова `decideGate3Closure`; + непроверенное/неподписанное `ownerArbitration` → передаётся как `null` (отсутствует). Контроллер + ключа не имеет → подделать «accept» не может (канал protected). +- `gate3Verdict` принимается ТОЛЬКО как выход реального движка судьи (`runJudge`, + `functionName='gate3'`), не синтезируется контроллером. Любой не-движковый/битый объект → + трактуется как НЕ-GO (сомнение → не закрывать). +- Инвариант SE-R7-6 сохранён: закрыть петлю может ТОЛЬКО подписанный владелец ЛИБО реальный судья + gate-3; контроллер — никогда, ни прямо, ни подделкой входа. + +## Контракт оркестратора замыкания {#g1} + +Новый чистый модуль `tools/gate3-closure.mjs`, функция +`decideGate3Closure({ gate3Verdict, noGoCount = 0, ownerArbitration = null, maxRounds = 3 })` +→ `{ state, terminate, reason, card? }`, `state ∈ {'closed','negotiate','arbitrate','open'}`. +Входы уже аутентифицированы потребителем ({#t1}); функция чистая и детерминированная. Порядок +проверок (приоритет — сверху вниз): + +1. `gate3Verdict.wired === false` (судья degraded/недоступен) И `ownerArbitration !== 'accept'` + → `{ state:'negotiate', terminate:false, reason:'судья недоступен — не закрывать, повтор/доработка' }` + (degraded НИКОГДА не закрывает и не уходит в арбитраж — fail-safe против ложного исхода). +2. `ownerArbitration === 'accept'` → `loopTerminationDecision({ ownerDeclaredDone: true })` → + `{ state:'closed', terminate:true }` (подписанный владелец принял — приоритет над судьёй). +3. `gate3Verdict.decision === 'GO'` (и `wired !== false`) → + `loopTerminationDecision({ judgeGate3Go: true })` → `{ state:'closed', terminate:true }`. +4. `ownerArbitration === 'continue'` → `{ state:'open', terminate:false }` (владелец: продолжать). +5. NO-GO и `noGoCount < maxRounds` → `{ state:'negotiate', terminate:false }` + (контроллер отвечает на возражение: доказывает ИЛИ доделывает; петля НЕ закрыта). +6. NO-GO и `noGoCount >= maxRounds` → `{ state:'arbitrate', terminate:false, card }` + (карточка арбитража владельцу — {#g3}). + +`terminate` ВСЕГДА берётся из `loopTerminationDecision` (единый источник правила), не вычисляется +заново; строгий `===true` хелпера сохранён. Пустой/битый `gate3Verdict` (не из движка) → шаг 5/6 +как NO-GO. Деградированный судья никогда не даёт `closed`/`arbitrate` (только negotiate) — арбитраж +только на СОДЕРЖАТЕЛЬНОМ 3-м NO-GO, не на сбое транспорта. + +## Продукт судьи gate-3 {#g2} + +Новый чистый модуль `tools/gate3-product.mjs`, функция +`buildGate3Product({ goal = '', planSteps = [], greenRuns = [] })` → `{ product, goal, cards }` +в форме, которую принимает `buildJudgePrompt` (`judge-engine.mjs`). Судья gate-3 слеп к переписке — +ему подаётся ТОЛЬКО продукт+цель. Состав: + +- `goal` — цель из опечатанной спеки (та же `extractGoal`, что у gate1/gate2). +- `product` — детерминированная сводка ИСПОЛНЕННОГО: шаги плана (op+object) и для каждого значимого + шага его подтверждённый по-критерию GREEN из `greenRuns` критерий-гейта (тест прошёл И мутация + убита). Это «поведение», которое судья сверяет с целью (линза `behavior_vs_goal`). +- `cards` — пусто в ядре. + +Линзы gate-3 уже объявлены в `judge-engine.VOTE_LAYOUTS.gate3` = +`['goal_achieved','premortem_whole','behavior_vs_goal']` — движок не трогаем, зовём +`runJudge`/`buildJudgePrompt` с `functionName='gate3'`. `buildGate3Product` детерминирован, тестуем +без модели; пустой план/нет greens → product помечает «нет доказательств исполнения» (судья увидит +и, скорее всего, NO-GO — цель не доказана). + +## Переговоры + арбитраж gate-3 {#g3} + +gate-3 NO-GO симметричен gate1/gate2 — переговорный блок, не обрыв: + +- **Счётчик NO-GO gate-3** — переиспользуем существующий `mentor-nogo-counter.mjs` (независимый + счётчик по ключу стадии `gate3`). Инкремент на каждом СОДЕРЖАТЕЛЬНОМ NO-GO (degraded не считается — + {#g1} шаг 1). +- **Раздел «Переговоры»** — контроллер отвечает на возражение судьи (`negotiation-section.mjs`): + доказывает достижение цели ЛИБО доделывает (новый план, который сам проходит gate1/gate2 и по + завершении снова приводит к gate-3). +- **Карточка арбитража на 3-м NO-GO** — `arbitration-card.buildArbitrationCard`: дословно возражение + судьи + довод контроллера + три выбора владельцу: «принять как достигнуто» / «продолжать» / + «объясни подробнее». Выбор возвращается в `decideGate3Closure` как `ownerArbitration` ТОЛЬКО через + подписанный канал ({#t1}). Новых каналов сигнала владельца ядро не вводит. + +## Замыкание петли {#g4} + +`decideGate3Closure` — единственная точка решения «петля закрыта», и закрытие идёт ТОЛЬКО через +`loopTerminationDecision` (инвариант SE-R7-6). `terminate:true` (state `closed`) достижим ИСКЛЮЧИТЕЛЬНО +по подписанному `ownerArbitration='accept'` ({#t1} шаг 2) ИЛИ реальному GO движка ({#g1} шаг 3) — +никогда контроллером, никогда на degraded. `negotiate`/`arbitrate`/`open` — петля открыта, работа +продолжается. **Ядро НЕ вводит блокировок в стене** (нет «зуба»): «зуб» (запрет «готово» при открытой +петле) реализует ТРИГГЕР (отдельная спека), потребляя `state`/`terminate` ядра. Это сознательная +граница безопасной декомпозиции: ядро = безопасное fail-safe-решение, триггер = его энфорсмент. + +## Конвенция {#g5} + +ES-модули `tools/gate3-product.mjs`, `tools/gate3-closure.mjs`. Чистые экспортируемые функции, +тестируемы в изоляции (модель/I/O не трогают). Переиспользование без модификации: `judge-engine` +(`buildJudgePrompt`/`runJudge`/`VOTE_LAYOUTS.gate3`), `arbitration-card`, `mentor-nogo-counter`, +`negotiation-section`, `loop-termination`. Без новых зависимостей. Стена (`enforce-supreme-gate`) и +живые хуки НЕ трогаются. + +## Вне объёма (→ спека #2) {#g6} + +- **Триггер gate-3 на завершении плана**: ловля `planComplete` в `enforce-supreme-gate` и автозапуск + gate-3 — трогает живую стену (риск F-J-капкана самомодификации), отдельной спекой. +- **«Зубы» блокировки** «готово» при открытой петле — реализует триггер, потребляя `state` ядра. +- **Проверка подписи owner-arbitration в живом хуке** ({#t1}) — wiring в потребителе (триггер); + ядро лишь принимает уже-проверенное значение. + +## Критерий приёмки {#g7} + +TDD-тесты (новые `gate3-product.test.mjs`, `gate3-closure.test.mjs`): + +- `buildGate3Product`: цель+шаги+greens → продукт несёт сводку исполнения; пустой план/нет greens → + «нет доказательств»; форма совместима с `buildJudgePrompt`. +- `decideGate3Closure`: degraded (`wired:false`) → negotiate, НЕ closed/arbitrate; signed + ownerArbitration 'accept' → closed+terminate (приоритет); GO → closed+terminate; 'continue' → + open; NO-GO & count<3 → negotiate; NO-GO & count>=3 → arbitrate+card; битый/не-движковый вердикт → + как NO-GO; `terminate` всегда из `loopTerminationDecision`; контроллер без подписи закрыть не может. +- Интеграция (без модели): product→prompt(`functionName='gate3'`)→mock-вердикт→closure. +- Полная регрессия tools GREEN: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` + (база зелёная + новые тесты; существующие модули не модифицируются → 0 регрессий). + +```verified-context-json +[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"loopTerminationDecision"}] +``` diff --git a/tools/loop-termination.mjs b/tools/loop-termination.mjs index a6a6ce4..a2ec9e8 100644 --- a/tools/loop-termination.mjs +++ b/tools/loop-termination.mjs @@ -10,3 +10,53 @@ export function loopTerminationDecision({ ownerDeclaredDone = false, judgeGate3G if (judgeGate3Go === true) return { terminate: true, reason: 'судья gate-3 goal_achieved → GO на завершение' }; return { terminate: false, reason: 'продолжать — контроллер сам не закрывает петлю (SE-R7-6)' }; } + +/** + * E-S1 §g2: продукт судьи gate-3 (цель достигнута?) — детерминированная сводка исполненного: + * цель + шаги + их по-критерию GREEN. Форма совместима с buildJudgePrompt (judge-engine). + */ +export function buildGate3Product({ goal = '', planSteps = [], greenRuns = [] } = {}) { + const greenById = new Map(); + for (const g of Array.isArray(greenRuns) ? greenRuns : []) { + if (g && g.stepId != null) greenById.set(String(g.stepId), g.criterion ?? true); + } + const lines = (Array.isArray(planSteps) ? planSteps : []).map((s, i) => { + const id = s && s.id != null ? String(s.id) : String(i); + const green = greenById.has(id) ? greenById.get(id) : null; + return { text: `${s && s.op} ${s && s.object}`, green }; + }); + const hasEvidence = lines.some((l) => l.green != null); + const product = hasEvidence + ? lines.map((l) => `${l.text}${l.green != null ? ` [green: ${l.green}]` : ''}`).join('\n') + : 'нет доказательств исполнения (шаги не подтверждены по-критерию)'; + return { product, goal: String(goal || ''), cards: [] }; +} + +/** + * E-S1 §g1/§g4: оркестратор замыкания внешней петли. Закрытие ТОЛЬКО через loopTerminationDecision + * (SE-R7-6). Degraded судья (wired:false) НИКОГДА не закрывает/не арбитрит — fail-safe против + * ложного исхода на сбое транспорта. Входы аутентифицирует потребитель (§t1: ownerArbitration — + * только из подписанного канала владельца; gate3Verdict — только выход движка); функция чистая. + */ +export function decideGate3Closure({ gate3Verdict = null, noGoCount = 0, ownerArbitration = null, maxRounds = 3 } = {}) { + const degraded = !!gate3Verdict && gate3Verdict.wired === false; + if (degraded && ownerArbitration !== 'accept') { + return { state: 'negotiate', terminate: false, reason: 'судья gate-3 недоступен — не закрывать, повтор/доработка' }; + } + if (ownerArbitration === 'accept') { + const t = loopTerminationDecision({ ownerDeclaredDone: true }); + return { state: 'closed', terminate: t.terminate, reason: t.reason }; + } + if (gate3Verdict && gate3Verdict.decision === 'GO' && gate3Verdict.wired !== false) { + const t = loopTerminationDecision({ judgeGate3Go: true }); + return { state: 'closed', terminate: t.terminate, reason: t.reason }; + } + if (ownerArbitration === 'continue') { + return { state: 'open', terminate: false, reason: 'владелец выбрал продолжать' }; + } + const rc = (Number.isInteger(noGoCount) && noGoCount >= 0) ? noGoCount : maxRounds; + if (rc < maxRounds) { + return { state: 'negotiate', terminate: false, reason: `gate-3 NO-GO; круг ${rc}/${maxRounds} — переговоры` }; + } + return { state: 'arbitrate', terminate: false, reason: `gate-3 NO-GO ${maxRounds} круга — арбитраж владельцу`, card: true }; +} diff --git a/tools/loop-termination.test.mjs b/tools/loop-termination.test.mjs index 08a8964..22301ad 100644 --- a/tools/loop-termination.test.mjs +++ b/tools/loop-termination.test.mjs @@ -22,3 +22,63 @@ describe('loopTerminationDecision (SE-R7-6)', () => { expect(loopTerminationDecision({ judgeGate3Go: 1 }).terminate).toBe(false); }); }); + +import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs'; + +describe('gate-3 buildGate3Product (E-S1 §g2)', () => { + it('цель+шаги+greens → продукт со сводкой исполнения и green-метками', () => { + const r = buildGate3Product({ + goal: 'добавить хелпер', + planSteps: [{ id: 's1', op: 'Write', object: 'tools/x.mjs' }], + greenRuns: [{ stepId: 's1', criterion: 'x-test' }], + }); + expect(r.goal).toBe('добавить хелпер'); + expect(r.product).toMatch(/Write tools\/x\.mjs/); + expect(r.product).toMatch(/green: x-test/); + expect(r.cards).toEqual([]); + }); + it('нет greens → пометка «нет доказательств исполнения»', () => { + const r = buildGate3Product({ goal: 'g', planSteps: [{ op: 'Bash', object: 'echo a' }], greenRuns: [] }); + expect(r.product).toMatch(/нет доказательств исполнения/); + }); + it('пустой ввод → не бросает, форма {product,goal,cards}', () => { + const r = buildGate3Product({}); + expect(r).toEqual({ product: expect.stringMatching(/нет доказательств/), goal: '', cards: [] }); + }); +}); + +describe('gate-3 decideGate3Closure (E-S1 §g1/§g4/§t1)', () => { + it('degraded судья (wired:false) без accept → negotiate, НЕ closed', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: false, decision: 'GO' } }); + expect(r.state).toBe('negotiate'); + expect(r.terminate).toBe(false); + }); + it('подписанный владелец accept → closed+terminate (приоритет над судьёй)', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: false }, ownerArbitration: 'accept' }); + expect(r.state).toBe('closed'); + expect(r.terminate).toBe(true); + }); + it('реальный GO движка → closed+terminate', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'GO' } }); + expect(r.state).toBe('closed'); + expect(r.terminate).toBe(true); + }); + it('владелец continue → open', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, ownerArbitration: 'continue' }); + expect(r.state).toBe('open'); + expect(r.terminate).toBe(false); + }); + it('NO-GO & круг<3 → negotiate', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, noGoCount: 1 }); + expect(r.state).toBe('negotiate'); + }); + it('NO-GO & круг>=3 → arbitrate + карточка', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, noGoCount: 3 }); + expect(r.state).toBe('arbitrate'); + expect(r.card).toBe(true); + }); + it('контроллер без подписи (нет accept, NO-GO) закрыть не может', () => { + const r = decideGate3Closure({ gate3Verdict: { wired: true, decision: 'NO-GO' }, noGoCount: 0 }); + expect(r.terminate).toBe(false); + }); +});