diff --git a/docs/superpowers/plans/2026-06-17-gate3-card-live-wiring-phase2d-plan-r3.md b/docs/superpowers/plans/2026-06-17-gate3-card-live-wiring-phase2d-plan-r3.md new file mode 100644 index 0000000..5769af5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-gate3-card-live-wiring-phase2d-plan-r3.md @@ -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` + — полная регрессия, ноль регрессий. diff --git a/docs/superpowers/specs/2026-06-17-gate3-card-live-wiring-phase2d.md b/docs/superpowers/specs/2026-06-17-gate3-card-live-wiring-phase2d.md new file mode 100644 index 0000000..24e8763 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-gate3-card-live-wiring-phase2d.md @@ -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("} +] +``` diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs index d7dc9e6..8f095dd 100644 --- a/tools/enforce-gate3-loop.mjs +++ b/tools/enforce-gate3-loop.mjs @@ -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: внутренняя ошибка хука НЕ кирпичит конец хода diff --git a/tools/enforce-gate3-loop.test.mjs b/tools/enforce-gate3-loop.test.mjs index a8ab223..fb4fa28 100644 --- a/tools/enforce-gate3-loop.test.mjs +++ b/tools/enforce-gate3-loop.test.mjs @@ -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('⚠'); + }); +});