feat: E-S1 gate-3 safe core buildGate3Product and decideGate3Closure

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 06:55:43 +03:00
parent 4f5c928796
commit dfa5ef180f
4 changed files with 449 additions and 0 deletions
@@ -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"}]
```
@@ -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-<sess>.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"}]
```
+50
View File
@@ -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 };
}
+60
View File
@@ -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);
});
});