Files
brain/tools/enforce-gate3-loop.mjs
T

164 lines
11 KiB
JavaScript

#!/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) || '' };
}
/** Чистая оркестрация хода 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) {
if (judgeKey && callJudge) {
verdict = await callJudge(buildGate3ProductFromMarker({ marker, frozenArtifact, greens }));
} else {
verdict = { wired: false, decision: 'GO', unavailable: true };
}
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();