a8489a22c7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
11 KiB
JavaScript
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();
|