Files
brain/tools/enforce-gate3-loop.mjs
T
Дмитрий 2b5e265c3e Merge branch 'track-c-2b-gate3card'
# Conflicts:
#	tools/enforce-gate3-loop.mjs
2026-06-17 19:10:52 +03:00

186 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 } 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 }) {
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds });
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 };
}
/** Видимость 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, grants, consumed, now } = deps;
const marker = readLoopOpen({ runtimeDir, sess, key });
if (!marker) return { block: false };
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 };
let verdict = cache.verdict;
let noGoCount = cache.noGoCount || 0;
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 } });
// Видимость «всё в лоб»: свежий 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 });
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0 } }); }
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} (продолжать).`;
} 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 { loadFloorEscapes, 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';
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 r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, 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: внутренняя ошибка хука НЕ кирпичит конец хода
}
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();