Files
brain/tools/enforce-gate3-loop.mjs
Дмитрий abf2060328 feat standby: штатный режим - флаг, управляющий хук, сброс, страж в 12 хуков
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:04 +03:00

280 lines
20 KiB
JavaScript
Raw Permalink 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, 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 { 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';
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 };
};
const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, callCardJudge, 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();