feat: gate-3 owner acceptance live wiring Phase 2d finale

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 05:28:49 +03:00
parent c50ac7c915
commit 2380e82002
4 changed files with 412 additions and 13 deletions
@@ -0,0 +1,115 @@
# gate-3 пользовательская приёмка — оживление в живой петле (Фаза 2d), план r3
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:test-driven-development. Шаги — чекбоксы.
**Goal:** оживить готовое ядро пользовательской приёмки владельца в живом Stop-хуке
`enforce-gate3-loop.mjs`: на завершении плана с пользовательской доставкой и исправном коде собрать
карточку, сверить её судьёй карточки, показать владельцу и ждать его подписанного решения.
**Delivery:** user-result
**Architecture:** надстройка на существующий хук без новых production-файлов. Добавляются тонкие
чистые функции деривации/рендера, расширяется распорядитель зубов входами доставки и вердикта
карточки, оживляется оркестрация хода и проводится судья карточки в обвязке.
**Tech Stack:** Node ESM, vitest, чистые экспортируемые функции (тест без модели/IO).
## Цель
Завершить пользовательскую приёмку владельца на gate-3: когда план доводит цель до готового
пользовательского результата и судья по коду дал положительный вердикт, хук собирает понятную
владельцу карточку (что сделано + как проверить самому + честная граница), сверяет её отдельным
судьёй карточки на отсутствие приукрашивания, показывает владельцу с подписанным выбором «принять /
вернуть в работу», и закрывает внешнюю петлю только при подписанном согласии владельца. Деградация
судьи карточки не вешает конец хода: карточка эскалируется владельцу с явным предупреждением.
Результат этого плана — рабочая приёмка, которую владелец видит и принимает (пользовательский
результат, не внутренний этап).
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op": "Edit", "object": "tools/enforce-gate3-loop.test.mjs", "ref": "c6"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t buildOwnerCardFromMarker", "ref": "c6"},
{"op": "Edit", "object": "tools/enforce-gate3-loop.mjs", "ref": "c2"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t produceCardVerdict", "ref": "c2"},
{"op": "Edit", "object": "tools/enforce-gate3-loop.mjs", "ref": "c3"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t buildOwnerCardFromMarker", "ref": "c6"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t produceCardVerdict", "ref": "c2"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t buildCardJudgeArgs", "ref": "c2"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t renderOwnerCardMessage", "ref": "c1"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism -t decideStopTeeth", "ref": "c3"},
{"op": "Edit", "object": "tools/enforce-gate3-loop.mjs", "ref": "c1"},
{"op": "Bash", "object": "npx vitest run enforce-gate3-loop --config vitest.config.tools.mjs --no-file-parallelism", "ref": "c1"},
{"op": "Edit", "object": "tools/enforce-gate3-loop.mjs", "ref": "c1"},
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "c6"}
]
```
```verified-context-json
[
{"id": "vk1", "kind": "EXTRACTED", "ref": "tools/cost-pricing.mjs", "anchor": "export const PRICING = Object.freeze("},
{"id": "vk2", "kind": "EXTRACTED", "ref": "tools/enforce-gate3-loop.mjs", "anchor": "export function decideStopTeeth"}
]
```
---
### Task 1: Тесты новых чистых функций (RED)
**Files:** Test `tools/enforce-gate3-loop.test.mjs`
- [ ] **Step 1 (Edit):** дописать в конец тест-файла `describe`-блоки: `decideStopTeeth` с
`delivery`+`cardVerdict` (internal→closed; user-result+карточка NO-GO→await-card; +карточка
GO→await-owner unverified:false; +карточка degraded→await-owner unverified:true; +нет
карточки→await-card; +подписанный accept→closed), `buildOwnerCardFromMarker` (цель из секций,
verifySteps из GREEN, честные заглушки change/boundary, machinery, honestyChecked:false→warning),
`produceCardVerdict` (нет ключа→degraded; заход бросил→degraded+cause; вердикт→проброс),
`buildCardJudgeArgs` (факты+карточка+цель), `renderOwnerCardMessage` (accept/continue по
отпечатку; unverified→предупреждение).
- [ ] **Step 2 (Bash):** `... -t buildOwnerCardFromMarker` — FAIL (функции не объявлены).
### Task 2: Импорт сборщика карточки
**Files:** Modify `tools/enforce-gate3-loop.mjs` (строка импорта из `./loop-termination.mjs`)
- [ ] **Step 3 (Edit):** добавить `buildOwnerCard` в импорт из `./loop-termination.mjs`.
- [ ] **Step 4 (Bash):** `... -t produceCardVerdict` (RED — производитель ещё не объявлен).
### Task 3: Чистые функции + расширение decideStopTeeth
**Files:** Modify `tools/enforce-gate3-loop.mjs` (функция `decideStopTeeth` + вставка ниже неё)
- [ ] **Step 5 (Edit):** расширить `decideStopTeeth` входами `delivery`/`cardVerdict` (прокинуть в
`decideGate3Closure`, добавить `unverified` в блок-ветку) и вставить ниже четыре чистые функции:
`buildOwnerCardFromMarker`, `produceCardVerdict`, `buildCardJudgeArgs`, `renderOwnerCardMessage`.
- [ ] **Step 6 (Bash):** `... -t buildOwnerCardFromMarker` — GREEN (подтверждение прохождения).
- [ ] **Step 7 (Bash):** `... -t produceCardVerdict` — GREEN.
- [ ] **Step 8 (Bash):** `... -t buildCardJudgeArgs` — GREEN.
- [ ] **Step 9 (Bash):** `... -t renderOwnerCardMessage` — GREEN.
- [ ] **Step 10 (Bash):** `... -t decideStopTeeth` — GREEN (новые ветки delivery/cardVerdict).
### Task 4: Оживление runGate3Stop
**Files:** Modify `tools/enforce-gate3-loop.mjs` (функция `runGate3Stop`)
- [ ] **Step 11 (Edit):** на код-GO + пользовательская доставка собрать карточку
(`buildOwnerCardFromMarker`), сверить судьёй карточки (`produceCardVerdict` через инъектируемый
`callCardJudge`), при GO пометить `honestyChecked:true`, записать видимость стадии
`judge:gate3card`, прокинуть `delivery`+`cardVerdict` в `decideStopTeeth`, кэшировать
`cardVerdict`+`card`, рендерить показ владельцу (`await-owner`) и доработку (`await-card`).
Локальную переменную карточки арбитража переименовать, чтобы не затеняла внешнюю карточку.
- [ ] **Step 12 (Bash):** `npx vitest run enforce-gate3-loop ... --no-file-parallelism` — весь
файл GREEN (парсится + все чистые тесты проходят).
### Task 5: Проводка судьи карточки в main()
**Files:** Modify `tools/enforce-gate3-loop.mjs` (функция `main`)
- [ ] **Step 13 (Edit):** добавить `callCardJudge` (functionName `gate3card`, линзы
`requiredLensesFor('gate3card')`, тот же `callJudgeModel`/`runJudge`) и передать его в deps
`runGate3Stop`.
- [ ] **Step 14 (Bash):** `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
— полная регрессия, ноль регрессий.
@@ -0,0 +1,100 @@
# gate-3 пользовательская приёмка — оживление в живой петле (Фаза 2d)
**Дата:** 2026-06-17
**Эпик:** роутер-наставник, E-S1 (приёмка владельца), завершающая фаза.
**Предшественники:** ядро замыкания, сборщик карточки и линзы судьи карточки уже готовы; здесь они
прокидываются в живую оркестрацию Stop-хука.
## Цель
Завершить пользовательскую приёмку владельца на gate-3. Когда план доводит цель до готового
пользовательского результата и судья по коду подтвердил исправность, живой Stop-хук должен собрать
понятную владельцу карточку (что сделано + как проверить самому + честная граница), сверить её
отдельным судьёй карточки на отсутствие приукрашивания, показать владельцу с подписанным выбором
«принять / вернуть в работу» и закрыть внешнюю петлю только при подписанном согласии владельца.
Деградация судьи карточки не вешает конец хода: карточка эскалируется владельцу с явным
предупреждением «сверка недоступна». Внутренние планы закрываются по судье кода и владельца не
беспокоят.
## Контракт живой оркестрации {#c1}
В живом Stop-хуке после получения вердикта судьи по коду:
- режим карточки определяется пометкой плана из подписанной метки: пользовательский результат →
ветка карточки; внутренний → закрытие по судье кода без карточки;
- ветка карточки активируется только при исправном коде (положительный вердикт судьи по коду);
при отрицательном/недоступном судье кода работает прежняя логика переговоров/арбитража;
- решение о блокировке конца хода принимает чистый детерминированный распорядитель, получающий
пометку доставки и вердикт карточки дополнительными входами;
- показ карточки владельцу несёт подписанный выбор по отпечатку (принять / вернуть в работу);
закрытие петли — только по подписанному согласию владельца, распорядитель сам не закрывает.
## Сборка и сверка карточки {#c2}
- **Сборка** — чистая деривация полей карточки из подписанной метки, опечатанного артефакта и
по-критерийных подтверждений: цель — из секций артефакта; шаги проверки — реальные воспроизводимые
по-критерию подтверждения (владелец перепроверяет сам); поля, для которых структурного источника
нет, заполняются честными заглушками, а не выдумкой. Путь машинерии (без экранного UI).
- **Сверка** — отдельный звонок судьи карточки набором линз «карточка соответствует продукту /
нет заявлений сверх подтверждённого / шаги проверки реальны». Производитель вердикта зеркалит
производителя вердикта судьи кода: отсутствие ключа или срыв захода дают видимую деградацию с
причиной, а не молчаливый проброс наверх.
- Положительная сверка помечает карточку проверенной (снимает предупреждение); отрицательная
содержательная сверка отправляет карточку на доработку без вызова владельца; деградация сверки
эскалирует карточку владельцу с предупреждением «честность не подтверждена автоматически».
## Замыкание и состояния {#c3}
Распорядитель замыкания (уже существующий) при пользовательской доставке и исправном коде даёт:
- карточка содержательно не пройдена → состояние «дорабатывать карточку», владельца не зовём, блок;
- карточка проверена, подписи владельца нет → «ждать владельца», показываем проверенную карточку, блок;
- карточка недоступна для сверки (деградация), подписи нет → «ждать владельца» с под-флагом
«непроверено», показываем карточку с предупреждением, блок (живучесть сохранена);
- подписанное согласие владельца → закрытие (приоритетно);
- внутренняя доставка + исправный код → закрытие по судье кода.
Закрытие достижимо только через распорядитель завершения; ни деградация судьи кода, ни деградация
судьи карточки сами петлю не закрывают.
## Крайние случаи {#c4}
- Пустые источники полей карточки → честные заглушки, не выдумки; карточка с непроверенной
честностью всегда несёт видимое предупреждение.
- Повторный конец хода с тем же отпечатком переиспользует закэшированный вердикт карточки (без
повторного звонка судье); смена отпечатка пересобирает карточку и вердикт заново.
- Локальная переменная карточки арбитража не должна затенять внешнюю переменную карточки приёмки.
- Видимость стадии сверки карточки пишется отдельным каналом снимка/баннера (fail-quiet, на зубы
петли не влияет).
## Конвенция {#c5}
Чистые экспортируемые функции (деривация карточки, производитель вердикта карточки, сборка
аргументов судье, рендер показа владельцу, расширение распорядителя зубов) тестируемы в изоляции
без модели и без ввода-вывода. Без новых внешних зависимостей и без новых production-файлов:
надстройка укладывается в существующий хук. Пометка доставки — строковое поле из фиксированного
множества; неизвестное значение трактуется как внутренняя доставка.
## Критерий приёмки {#c6}
TDD-тесты:
- распорядитель зубов с входами доставки и вердикта карточки даёт корректные состояния
(внутренняя+код-GO → закрыть; пользовательская+код-GO+карточка-NO-GO → дорабатывать без владельца;
+карточка-GO → ждать владельца; +карточка-деградация → ждать владельца с флагом «непроверено»;
+нет карточки → дорабатывать; +подписанное согласие → закрыть);
- деривация карточки: цель из секций, шаги проверки из подтверждений, честные заглушки пустых полей,
путь машинерии, непроверенная честность → предупреждение;
- производитель вердикта карточки: нет ключа → деградация; срыв захода → видимая деградация с
причиной; вердикт → проброс без искажения;
- сборка аргументов судье: подтверждённые факты продукта + сериализованная карточка + цель;
- рендер показа владельцу: подписанный выбор принять/вернуть по отпечатку; флаг «непроверено» →
видимое предупреждение;
- полная регрессия свода зелёная, ноль регрессий в переиспользуемых модулях.
```verified-context-json
[
{"id": "ek1", "kind": "EXTRACTED", "ref": "tools/loop-termination.mjs", "anchor": "decideGate3Closure"},
{"id": "ek2", "kind": "EXTRACTED", "ref": "tools/cost-pricing.mjs", "anchor": "export const PRICING = Object.freeze("}
]
```
+106 -13
View File
@@ -9,7 +9,7 @@ import fsDefault from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';
import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs';
import { buildGate3Product, decideGate3Closure, buildOwnerCard } from './loop-termination.mjs';
import { escapeGrantOpen } from './escape-grant.mjs';
import { parseNegotiationSection } from './negotiation-section.mjs';
import { buildArbitrationCard } from './arbitration-card.mjs';
@@ -62,11 +62,74 @@ export function resolveOwnerArbitration({ fingerprint, grants, consumed, now })
return null;
}
export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = null, maxRounds = ESCALATE_AFTER }) {
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds });
export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = null, maxRounds = ESCALATE_AFTER, delivery = 'internal', cardVerdict = null }) {
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds, delivery, cardVerdict });
if (d.state === 'closed') return { block: false, clear: true, state: d.state, reason: d.reason };
if (d.state === 'open') return { block: false, clear: false, state: d.state, reason: d.reason };
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
return { block: true, clear: false, state: d.state, card: !!d.card, unverified: !!d.unverified, reason: d.reason };
}
/**
* E-S1 Фаза 2d §c2: сборка пользовательской карточки из подписанной метки + опечатанного артефакта
* + по-критерийных GREEN. Честная деривация: цель из секций артефакта; verifySteps — реальные
* по-критерию GREEN (владелец воспроизводит сам); change/boundary — честные заглушки (поведенческое
* изменение машинерии структурно не извлекается; не выдумываем). Путь machinery (claude-brain без UI).
* honestyChecked=false до GO судьи карточки. Чистая.
*/
export function buildOwnerCardFromMarker({ marker, frozenArtifact, greens } = {}) {
const sections = (frozenArtifact && frozenArtifact.sections) || {};
const goal = Object.keys(sections).sort().map((k) => `[${k}] ${sections[k]}`).join('\n');
const verifySteps = (Array.isArray(greens) ? greens : []).filter((g) => g && g.green).map((g) => `критерий ${g.criterion_id}: GREEN (воспроизводимо прогоном свода)`);
return buildOwnerCard({ goal, change: [], verifySteps, boundary: '', kind: 'machinery', honestyChecked: false });
}
/**
* E-S1 Фаза 2d §c2: производитель вердикта судьи карточки. Зеркало produceGate3Verdict — нет
* ключа/захода → degraded (wired:false); срыв захода → ВИДИМЫЙ degraded с cause (не молчит, не
* fail-open наверх). Чистая (buildArgs инъектируется).
*/
export async function produceCardVerdict({ judgeKey, callCardJudge, buildArgs } = {}) {
if (!(judgeKey && callCardJudge)) return { wired: false, decision: 'GO', unavailable: true };
try {
return await callCardJudge(buildArgs());
} catch (e) {
return { wired: false, decision: 'GO', unavailable: true, cause: `судья карточки сорвался: ${String((e && e.message) || e).slice(0, 200)}` };
}
}
/**
* E-S1 Фаза 2d §c2: аргументы судье карточки — подтверждённые факты продукта + сериализованная
* карточка простого языка. Судья сверяет card_matches_product/no_overstatement/verify_steps_real. Чистая.
*/
export function buildCardJudgeArgs({ card, gate3Product } = {}) {
const facts = (gate3Product && gate3Product.product) || '';
const goal = (gate3Product && gate3Product.goal) || '';
const c = card || {};
const cardText = [
`цель: ${c.goal || ''}`,
`что изменилось: ${(Array.isArray(c.change) ? c.change : []).join(' | ')}`,
`как проверить: ${(Array.isArray(c.verifySteps) ? c.verifySteps : []).join(' | ')}`,
`граница: ${c.boundary || ''}`,
].join('\n');
return { product: `ПОДТВЕРЖДЁННЫЕ ФАКТЫ ПРОДУКТА:\n${facts}\n\nКАРТОЧКА ВЛАДЕЛЬЦУ:\n${cardText}`, goal, cards: [] };
}
/**
* E-S1 Фаза 2d §c1: рендер показа карточки владельцу на конце хода с подписанным выбором
* accept/continue по отпечатку. unverified (degraded судья карточки) → видимое предупреждение. Чистая.
*/
export function renderOwnerCardMessage({ card, fingerprint, unverified = false } = {}) {
const c = card || {};
const lines = ['Приёмка результата владельцем — цель доведена до пользовательского результата.'];
if (unverified || c.honestyChecked !== true) {
lines.push(`${c.warning || 'автоматическая сверка честности недоступна — проверь по шагам сам'}`);
}
lines.push(`Цель: ${c.goal || '(не указана)'}`);
lines.push(`Что изменилось: ${(Array.isArray(c.change) ? c.change : []).join('; ') || '(не указано)'}`);
lines.push(`Как проверить самому: ${(Array.isArray(c.verifySteps) ? c.verifySteps : []).join('; ') || '(не указано)'}`);
lines.push(`Чего НЕ делает: ${c.boundary || '(не указано)'}`);
lines.push(`Выбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять как достигнутое) либо gate3-arb:continue:${fingerprint} (вернуть в работу).`);
return lines.join('\n');
}
/** Видимость gate3: вердикт судьи цели → запись стадии снимка/баннера. Чистая (без import). */
@@ -105,25 +168,43 @@ export function gate3CardSurfaceRecord({ verdict, hash } = {}) {
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
export async function runGate3Stop(event, deps) {
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps;
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, callCardJudge, grants, consumed, now } = deps;
const marker = readLoopOpen({ runtimeDir, sess, key });
if (!marker) return { block: false };
const delivery = marker.delivery === 'user-result' ? 'user-result' : 'internal';
const greens = (loadGreens && loadGreens()) || [];
const greenIds = greens.filter((g) => g && g.green).map((g) => g.criterion_id);
const frozenArtifact = (loadArtifact && loadArtifact()) || null;
const negotiationText = parseNegotiationSection((frozenArtifact && frozenArtifact._md) || '').map((r) => r.position).join('\n');
const fingerprint = computeFingerprint({ planId: marker.planId, greenIds, negotiationText });
const cache = loadCache({ runtimeDir, sess }) || { fingerprint: null, verdict: null, noGoCount: 0 };
const cache = loadCache({ runtimeDir, sess }) || { fingerprint: null, verdict: null, noGoCount: 0, cardVerdict: null, card: null };
let verdict = cache.verdict;
let noGoCount = cache.noGoCount || 0;
let cardVerdict = cache.cardVerdict || null;
let card = cache.card || null;
if (cache.fingerprint !== fingerprint) {
// Срыв построения продукта/захода → видимый degraded (не немой fail-open в main()).
verdict = await produceGate3Verdict({ judgeKey, callJudge, buildProduct: () => buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) });
const isContentNoGo = !!verdict && verdict.wired === true && verdict.decision !== 'GO';
const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO';
noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount);
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
// §c1/§c2: код исправен + план доводит до пользовательского результата → собрать карточку и сверить судьёй карточки.
cardVerdict = null; card = null;
const codeGo = !!verdict && verdict.decision === 'GO' && verdict.wired !== false;
if (codeGo && delivery === 'user-result') {
card = buildOwnerCardFromMarker({ marker, frozenArtifact, greens });
cardVerdict = await produceCardVerdict({ judgeKey, callCardJudge, buildArgs: () => buildCardJudgeArgs({ card, gate3Product: buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) }) });
if (cardVerdict && cardVerdict.decision === 'GO' && cardVerdict.wired !== false) { const { warning, ...rest } = card; card = { ...rest, honestyChecked: true }; }
// Видимость судьи карточки → снимок + баннер (fail-quiet, на зубы не влияет).
try {
const { writeStage, pushVerdict } = await import('./verdict-surface-store.mjs');
const rec = gate3CardSurfaceRecord({ verdict: cardVerdict, hash: marker.planId });
writeStage(sess, { ...rec, ts: Date.now() }, runtimeDir);
pushVerdict(sess, { outcome: rec.status, gate: 'judge:gate3card', round: null, version: null, reason: rec.reason }, runtimeDir);
} catch { /* fail-quiet */ }
}
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount, cardVerdict, card } });
// Видимость «всё в лоб»: свежий gate3-вердикт → снимок + баннер (fail-quiet, не влияет на зубы).
try {
const { writeStage, pushVerdict } = await import('./verdict-surface-store.mjs');
@@ -134,14 +215,18 @@ export async function runGate3Stop(event, deps) {
}
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
const t = decideStopTeeth({ verdict, noGoCount, ownerArbitration });
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0 } }); }
const t = decideStopTeeth({ verdict, noGoCount, ownerArbitration, delivery, cardVerdict });
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0, cardVerdict: null, card: null } }); }
if (!t.block) return { block: false };
let message;
if (t.state === 'arbitrate') {
const card = buildArbitrationCard({ side: 'judge', level: 'L2', round: noGoCount, objectionVerbatim: t.reason || '(возражение судьи)', controllerPositionVerbatim: negotiationText || '(позиция не указана)', sealAction: `gate3-arb:accept:${fingerprint}` });
message = `[gate3-loop] ${card.title}\nЦель не подтверждена. Замечание: ${card.objection}\nВыбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять) / gate3-arb:continue:${fingerprint} (продолжать).`;
if (t.state === 'await-owner') {
message = renderOwnerCardMessage({ card, fingerprint, unverified: !!t.unverified });
} else if (t.state === 'await-card') {
message = buildObjectionFeedback({ side: 'judge', text: t.reason || 'карточка приукрашена/неточна — доработай карточку; владельца не зовём' });
} else if (t.state === 'arbitrate') {
const cardArb = buildArbitrationCard({ side: 'judge', level: 'L2', round: noGoCount, objectionVerbatim: t.reason || '(возражение судьи)', controllerPositionVerbatim: negotiationText || '(позиция не указана)', sealAction: `gate3-arb:accept:${fingerprint}` });
message = `[gate3-loop] ${cardArb.title}\nЦель не подтверждена. Замечание: ${cardArb.objection}\nВыбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять) / gate3-arb:continue:${fingerprint} (продолжать).`;
} else if (verdict && verdict.wired === false) {
message = buildDegradedFeedback({ side: 'judge', reason: 'судья gate-3 недоступен — петля не закрыта; выход: escape владельца ИЛИ plan-done' });
} else {
@@ -173,7 +258,15 @@ async function main() {
const v = runJudge({ functionName: 'gate3', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
return { wired: true, decision: v.decision, verdict: v };
};
const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now() });
const callCardJudge = async (args) => {
const requiredLenses = requiredLensesFor('gate3card');
const promptArgs = { ...args, roundMemory: {} };
const raw = await callJudgeModel({ functionName: 'gate3card', requiredLenses, promptArgs, apiKey: judgeKey });
if (raw && raw.unavailable) return { wired: false, decision: 'GO', unavailable: true };
const v = runJudge({ functionName: 'gate3card', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
return { wired: true, decision: v.decision, verdict: v };
};
const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, callCardJudge, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now() });
exitDecision({ block: !!r.block, message: r.block ? `[gate3-loop] ${r.message || 'петля открыта — цель не подтверждена'}` : undefined });
} catch {
exitDecision({ block: false }); // Stop fail-OPEN: внутренняя ошибка хука НЕ кирпичит конец хода
+91
View File
@@ -170,3 +170,94 @@ describe('produceGate3Verdict (видимость срыва gate3 — фикс
expect(r).toEqual(v);
});
});
import { buildOwnerCardFromMarker, produceCardVerdict, buildCardJudgeArgs, renderOwnerCardMessage } from './enforce-gate3-loop.mjs';
describe('decideStopTeeth — delivery + cardVerdict (Фаза 2d приёмка)', () => {
const go = { wired: true, decision: 'GO' };
const cardGo = { wired: true, decision: 'GO' };
const cardNoGo = { wired: true, decision: 'NO-GO', reason: 'приукрашено' };
const cardDegraded = { wired: false, decision: 'GO' };
it('internal + код-GO → allow + clear (closed)', () => {
const r = decideStopTeeth({ verdict: go, delivery: 'internal' });
expect(r.block).toBe(false); expect(r.clear).toBe(true);
});
it('user-result + код-GO + карточка NO-GO → block await-card (владельца не зовём)', () => {
const r = decideStopTeeth({ verdict: go, delivery: 'user-result', cardVerdict: cardNoGo });
expect(r.block).toBe(true); expect(r.state).toBe('await-card');
});
it('user-result + код-GO + карточка GO → block await-owner, unverified false', () => {
const r = decideStopTeeth({ verdict: go, delivery: 'user-result', cardVerdict: cardGo });
expect(r.block).toBe(true); expect(r.state).toBe('await-owner'); expect(r.unverified).toBe(false);
});
it('user-result + код-GO + карточка degraded → block await-owner unverified:true', () => {
const r = decideStopTeeth({ verdict: go, delivery: 'user-result', cardVerdict: cardDegraded });
expect(r.block).toBe(true); expect(r.state).toBe('await-owner'); expect(r.unverified).toBe(true);
});
it('user-result + код-GO + карточка ещё не сверена → await-card', () => {
const r = decideStopTeeth({ verdict: go, delivery: 'user-result', cardVerdict: null });
expect(r.block).toBe(true); expect(r.state).toBe('await-card');
});
it('user-result + подписанный accept → allow + clear (closed)', () => {
const r = decideStopTeeth({ verdict: go, delivery: 'user-result', cardVerdict: cardGo, ownerArbitration: 'accept' });
expect(r.block).toBe(false); expect(r.clear).toBe(true);
});
});
describe('buildOwnerCardFromMarker (Фаза 2d сборка карточки)', () => {
it('цель из секций + verifySteps из GREEN + честные заглушки + machinery + warning', () => {
const marker = { steps: [], planId: 'p', delivery: 'user-result' };
const frozenArtifact = { sections: { s1: 'дать владельцу приёмку' } };
const card = buildOwnerCardFromMarker({ marker, frozenArtifact, greens: [{ criterion_id: 'c1', green: true }, { criterion_id: 'c2', green: false }] });
expect(card.goal).toContain('дать владельцу приёмку');
expect(card.verifySteps.join(' ')).toContain('c1');
expect(card.verifySteps.join(' ')).not.toContain('c2');
expect(card.kind).toBe('machinery');
expect(card.honestyChecked).toBe(false);
expect(card.warning).toBeTruthy();
});
it('пустые входы → честные заглушки, не выдумки', () => {
const card = buildOwnerCardFromMarker({ marker: { steps: [] }, frozenArtifact: null, greens: [] });
expect(card.goal).toBe('(цель не указана)');
expect(card.change).toEqual(['(что изменилось — не указано)']);
});
});
describe('produceCardVerdict (видимость срыва судьи карточки)', () => {
it('нет ключа → degraded', async () => {
const r = await produceCardVerdict({ judgeKey: null, callCardJudge: async () => ({ wired: true, decision: 'GO' }), buildArgs: () => ({}) });
expect(r.wired).toBe(false); expect(r.unavailable).toBe(true);
});
it('заход бросил → видимый degraded с cause', async () => {
const r = await produceCardVerdict({ judgeKey: 'K', callCardJudge: async () => { throw new Error('boom'); }, buildArgs: () => ({}) });
expect(r.wired).toBe(false); expect(typeof r.cause).toBe('string'); expect(r.cause.length).toBeGreaterThan(0);
});
it('вердикт → проброс', async () => {
const v = { wired: true, decision: 'NO-GO', reason: 'overstatement' };
const r = await produceCardVerdict({ judgeKey: 'K', callCardJudge: async () => v, buildArgs: () => ({}) });
expect(r).toEqual(v);
});
});
describe('buildCardJudgeArgs', () => {
it('факты продукта + сериализованная карточка + цель', () => {
const args = buildCardJudgeArgs({ card: { goal: 'G', change: ['c'], verifySteps: ['v'], boundary: 'b' }, gate3Product: { product: 'ФАКТЫ', goal: 'G' } });
expect(args.product).toContain('ФАКТЫ');
expect(args.product).toContain('как проверить');
expect(args.product).toContain('v');
expect(args.goal).toBe('G');
});
});
describe('renderOwnerCardMessage', () => {
it('содержит accept/continue по отпечатку + поля карточки', () => {
const msg = renderOwnerCardMessage({ card: { goal: 'G', change: ['ch'], verifySteps: ['st'], boundary: 'bd', honestyChecked: true }, fingerprint: 'FP' });
expect(msg).toContain('gate3-arb:accept:FP');
expect(msg).toContain('gate3-arb:continue:FP');
expect(msg).toContain('st');
});
it('unverified → видимое предупреждение', () => {
const msg = renderOwnerCardMessage({ card: { goal: 'G', warning: 'не проверено' }, fingerprint: 'FP', unverified: true });
expect(msg).toContain('⚠');
});
});