2026-06-17 08:54:54 +03:00
#!/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' ;
2026-06-18 05:28:49 +03:00
import { buildGate3Product , decideGate3Closure , buildOwnerCard } from './loop-termination.mjs' ;
2026-06-17 08:54:54 +03:00
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 ` ) ; }
2026-06-17 10:53:25 +03:00
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 ) ;
2026-06-17 08:54:54 +03:00
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 ;
}
2026-06-18 05:28:49 +03:00
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 } ) ;
2026-06-17 08:54:54 +03:00
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 } ;
2026-06-18 05:28:49 +03:00
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' ) ;
2026-06-17 08:54:54 +03:00
}
2026-06-17 13:06:07 +03:00
/** Видимость 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 ) || '' } ;
}
2026-06-17 17:16:18 +03:00
/**
* Производитель 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 ) } ` } ;
}
}
2026-06-17 16:44:55 +03:00
/** Видимость судьи карточки (Фаза 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 ) || '' } ;
}
2026-06-17 08:54:54 +03:00
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
export async function runGate3Stop ( event , deps ) {
2026-06-18 05:28:49 +03:00
const { runtimeDir , sess , key , judgeKey , loadGreens , loadArtifact , callJudge , callCardJudge , grants , consumed , now } = deps ;
2026-06-17 08:54:54 +03:00
const marker = readLoopOpen ( { runtimeDir , sess , key } ) ;
if ( ! marker ) return { block : false } ;
2026-06-18 05:28:49 +03:00
const delivery = marker . delivery === 'user-result' ? 'user-result' : 'internal' ;
2026-06-17 08:54:54 +03:00
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 } ) ;
2026-06-18 05:28:49 +03:00
const cache = loadCache ( { runtimeDir , sess } ) || { fingerprint : null , verdict : null , noGoCount : 0 , cardVerdict : null , card : null } ;
2026-06-17 08:54:54 +03:00
let verdict = cache . verdict ;
let noGoCount = cache . noGoCount || 0 ;
2026-06-18 05:28:49 +03:00
let cardVerdict = cache . cardVerdict || null ;
let card = cache . card || null ;
2026-06-17 08:54:54 +03:00
if ( cache . fingerprint !== fingerprint ) {
2026-06-17 17:16:18 +03:00
// Срыв построения продукта/захода → видимый degraded (не немой fail-open в main()).
verdict = await produceGate3Verdict ( { judgeKey , callJudge , buildProduct : ( ) => buildGate3ProductFromMarker ( { marker , frozenArtifact , greens } ) } ) ;
2026-06-17 08:54:54 +03:00
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 ) ;
2026-06-18 05:28:49 +03:00
// §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 } } ) ;
2026-06-17 13:06:07 +03:00
// Видимость «всё в лоб»: свежий 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 */ }
2026-06-17 08:54:54 +03:00
}
const ownerArbitration = resolveOwnerArbitration ( { fingerprint , grants , consumed , now } ) ;
2026-06-18 05:28:49 +03:00
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 } } ) ; }
2026-06-17 08:54:54 +03:00
if ( ! t . block ) return { block : false } ;
let message ;
2026-06-18 05:28:49 +03:00
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 } (продолжать). ` ;
2026-06-17 08:54:54 +03:00
} 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' ) ;
2026-06-18 19:03:10 +03:00
const { loadTerminalGrants , loadConsumed } = await import ( './escape-grant.mjs' ) ;
2026-06-17 08:54:54 +03:00
try {
const event = parseEventJson ( await readStdin ( ) ) ;
const dir = runtimeDir ( ) ;
const sess = ( event && event . session _id ) || process . env . CLAUDE _SESSION _ID || 'unknown' ;
2026-06-18 10:07:04 +03:00
if ( ( await import ( './enforce-hook-helpers.mjs' ) ) . standbyActive ( sess ) ) { exitDecision ( { block : false } ) ; return ; }
2026-06-17 08:54:54 +03:00
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 } ;
} ;
2026-06-18 05:28:49 +03:00
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 } ;
} ;
2026-06-18 19:03:10 +03:00
// Поза 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 ( ) } ) ;
2026-06-17 08:54:54 +03:00
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 ( ) ;