#!/usr/bin/env node /** * enforce-gate3-loop (E-S1 триггер) — Stop-хук «зубы петли»: стена на завершении плана пишет * метку «петля открыта»; здесь на конце хода судим «цель достигнута?» (gate-3) и блокируем * завершение, пока петля не закрыта. Закрытие — только реальный GO судьи ИЛИ подписанный выбор * владельца (SE-R7-6). Чистые ядра тестируемы без модели/IO; main() — тонкая обёртка. */ 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, buildOwnerCard } from './loop-termination.mjs'; import { escapeGrantOpen } from './escape-grant.mjs'; import { parseNegotiationSection } from './negotiation-section.mjs'; import { buildArbitrationCard } from './arbitration-card.mjs'; import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs'; const GATE3_LOOP_DOMAIN = 'gate3-loop'; const ESCALATE_AFTER = 3; export function signLoopMarker(payload, key) { return { ...payload, sig: signPayload(payload, key, GATE3_LOOP_DOMAIN) }; } export function verifyLoopMarker(marker, key) { return verifyReceipt(marker, key, GATE3_LOOP_DOMAIN); } export function loopMarkerPath(runtimeDir, sess) { return join(runtimeDir, `gate3-loop-${sess}.json`); } export function cachePath(runtimeDir, sess) { return join(runtimeDir, `gate3-cache-${sess}.json`); } export function writeLoopOpen({ taskId, planId, artifactId, steps, delivery = 'internal', at, key, runtimeDir, sess, fsImpl = fsDefault }) { const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], delivery: delivery || 'internal', at: at || 0 }, key); try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(loopMarkerPath(runtimeDir, sess), JSON.stringify(marker)); } catch { /* best-effort */ } } export function readLoopOpen({ runtimeDir, sess, key, fsImpl = fsDefault }) { let m = null; try { m = JSON.parse(fsImpl.readFileSync(loopMarkerPath(runtimeDir, sess), 'utf8')); } catch { return null; } return verifyLoopMarker(m, key) ? m : null; } export function clearLoopOpen({ runtimeDir, sess, fsImpl = fsDefault }) { try { fsImpl.unlinkSync(loopMarkerPath(runtimeDir, sess)); } catch { /* no-op */ } } export function computeFingerprint({ planId = '', greenIds = [], negotiationText = '' } = {}) { const sorted = [...greenIds].map(String).sort(); return createHash('sha256').update(canonicalJson({ planId: String(planId), greenIds: sorted, negotiationText: String(negotiationText) })).digest('hex'); } export function loadCache({ runtimeDir, sess, fsImpl = fsDefault }) { try { return JSON.parse(fsImpl.readFileSync(cachePath(runtimeDir, sess), 'utf8')); } catch { return null; } } export function saveCache({ runtimeDir, sess, cache, fsImpl = fsDefault }) { try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(cachePath(runtimeDir, sess), JSON.stringify(cache)); } catch { /* best-effort */ } } export function buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) { const sections = (frozenArtifact && frozenArtifact.sections) || {}; const goal = Object.keys(sections).sort().map((k) => `[${k}] ${sections[k]}`).join('\n') || '(цель не извлечена из опечатанного артефакта)'; const greenIds = new Set((Array.isArray(greens) ? greens : []).filter((g) => g && g.green).map((g) => String(g.criterion_id))); const planSteps = (marker && Array.isArray(marker.steps) ? marker.steps : []).map((s) => ({ id: s.criterion_id, op: s.op, object: s.object })); const greenRuns = planSteps.filter((s) => greenIds.has(String(s.id))).map((s) => ({ stepId: s.id, criterion: true })); return buildGate3Product({ goal, planSteps, greenRuns }); } export function resolveOwnerArbitration({ fingerprint, grants, consumed, now }) { if (escapeGrantOpen(`gate3-arb:accept:${fingerprint}`, grants, consumed, now)) return 'accept'; if (escapeGrantOpen(`gate3-arb:continue:${fingerprint}`, grants, consumed, now)) return 'continue'; return null; } 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, 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). */ export function gate3SurfaceRecord({ verdict, hash } = {}) { let status = 'skip'; if (verdict && verdict.wired === false) status = 'degraded'; else if (verdict && verdict.decision === 'GO') status = 'GO'; else if (verdict && verdict.decision === 'NO-GO') status = 'NO-GO'; return { stage: 'judge:gate3', hash: hash || null, status, reason: (verdict && verdict.reason) || '' }; } /** * Производитель gate3-вердикта (зеркало cd831b8 / runMentorVerdict): нет ключа/захода → degraded; * исключение в построении продукта (buildProduct) ИЛИ в заходе судьи (callJudge) → ВИДИМЫЙ degraded * (wired:false, unavailable, cause), а НЕ проброс наверх — там немой fail-OPEN catch main() тихо * разблокировал бы конец хода без записи стадии и без причины. Чистая (без IO): buildProduct инъектируется. */ export async function produceGate3Verdict({ judgeKey, callJudge, buildProduct }) { if (!(judgeKey && callJudge)) return { wired: false, decision: 'GO', unavailable: true }; try { return await callJudge(buildProduct()); } catch (e) { return { wired: false, decision: 'GO', unavailable: true, cause: `судья gate-3 сорвался: ${String((e && e.message) || e).slice(0, 200)}` }; } } /** Видимость судьи карточки (Фаза 2b): вердикт сверки карточки → стадия judge:gate3card. * Зеркало gate3SurfaceRecord, тот же канал снимок+баннер (спека видимости {#deferred}). Чистая. */ export function gate3CardSurfaceRecord({ verdict, hash } = {}) { let status = 'skip'; if (verdict && verdict.wired === false) status = 'degraded'; else if (verdict && verdict.decision === 'GO') status = 'GO'; else if (verdict && verdict.decision === 'NO-GO') status = 'NO-GO'; return { stage: 'judge:gate3card', hash: hash || null, status, reason: (verdict && verdict.reason) || '' }; } /** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */ export async function runGate3Stop(event, 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, 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); // §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'); const rec = gate3SurfaceRecord({ verdict, hash: marker.planId }); writeStage(sess, { ...rec, ts: Date.now() }, runtimeDir); pushVerdict(sess, { outcome: rec.status, gate: 'judge:gate3', round: null, version: null, reason: rec.reason }, runtimeDir); } catch { /* fail-quiet */ } } const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now }); 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 === '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 { message = buildObjectionFeedback({ side: 'judge', text: t.reason || 'цель не достигнута — доработай или докажи' }); } return { block: true, message }; } async function main() { const { readStdin, parseEventJson, runtimeDir, exitDecision } = await import('./enforce-hook-helpers.mjs'); const { resolveReceiptKey } = await import('./receipt-key-config.mjs'); const { resolveJudgeLlmKey } = await import('./judge-gate-config.mjs'); const { callJudgeModel } = await import('./enforce-judge-gate.mjs'); const { requiredLensesFor, runJudge } = await import('./judge-engine.mjs'); const { loadTerminalGrants, loadConsumed } = await import('./escape-grant.mjs'); try { const event = parseEventJson(await readStdin()); const dir = runtimeDir(); const sess = (event && event.session_id) || process.env.CLAUDE_SESSION_ID || 'unknown'; if ((await import('./enforce-hook-helpers.mjs')).standbyActive(sess)) { exitDecision({ block: false }); return; } const key = resolveReceiptKey(); const judgeKey = resolveJudgeLlmKey(); const loadGreens = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `criterion-greens-${sess}.json`), 'utf8')); } catch { return []; } }; const loadArtifact = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `frozen-artifact-${sess}.json`), 'utf8')); } catch { return null; } }; const callJudge = async (product) => { const requiredLenses = requiredLensesFor('gate3'); const promptArgs = { ...product, roundMemory: {} }; const raw = await callJudgeModel({ functionName: 'gate3', requiredLenses, promptArgs, apiKey: judgeKey }); if (raw && raw.unavailable) return { wired: false, decision: 'GO', unavailable: true }; const v = runJudge({ functionName: 'gate3', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs }); return { wired: true, decision: v.decision, verdict: v }; }; 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 }; }; // Поза 1 (#B4): арбитраж gate3 (accept/continue) — ТЯЖЁЛОЕ → ТОЛЬКО терминальный грант владельца. const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, callCardJudge, grants: loadTerminalGrants(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: внутренняя ошибка хука НЕ кирпичит конец хода } } import { fileURLToPath } from 'node:url'; const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();