abf2060328
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
20 KiB
JavaScript
280 lines
20 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, 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();
|