84231a1470
Доска «кто на посту» (STATUS.md §7) теперь показывает реальные недавние escape владельца и блоки машин М1–М6 вместо хардкода []/[]. - new tools/guard-block-log.mjs: logGuardBlock (best-effort, fail-quiet, Node fs append в guard-blocks-<sess>.jsonl) + loadRecentBlocks/ loadRecentEscapes (скан session-файлов runtime, окно 24ч + cap 10, ts→ISO). - проводка logGuardBlock в block-ветку main() 9 машинных хуков (floor / supreme-gate / judge-gate / snapshot / read-path-deny / mcp-classification / normative-content-rules / verify-gate / criterion-gate). Логгер вызывается ПОСЛЕ решения, не влияет на block; decide() pure не тронут. - status-md-generator CLI: recentEscapes/recentBlocks из читателей вместо []/[]. До флипа Фазы 8 доска показывает 0/0 (хуки не зарегистрированы — данных нет); реальная польза — пост-флип наблюдаемость. TDD: guard-block-log.test (6) + 9 структурных wiring-тестов + 1 board-тест. Гейт закрытия: sharp-edges (промежуточный по 9 хукам + читатели) + variant-analysis (все block-ветки покрыты, иных источников нет). Регрессия tools-only 3465 passed / 2 skipped / 0 failed (было 3449+2skip). 0 регрессий. Plan: docs/superpowers/plans/2026-06-10-guard-board-live-source.md
285 lines
18 KiB
JavaScript
285 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* enforce-judge-gate — тонкая хук-обёртка судьи (Машина 4). НЕ движок (движок —
|
||
* judge-orchestrator + judge-engine + пол судьи 4-A..4-F). Гейтит расход рубильником
|
||
* (judgeActive: флаг ROUTER_MENTOR_JUDGE_ENABLED + ключ) → $0 пока выключен. Режим
|
||
* (judgeGateMode): inert ($0) / shadow (active, логирует, НЕ блокирует — D28) / live-block
|
||
* (active + MODE=block, блокирует на NO-GO; пол перевешивает через finalGate).
|
||
*
|
||
* Регистрировать в settings.json нужно ИМЕННО эту обёртку (не движок) — у движка нет
|
||
* рубильника $0; обёртка гейтит флагом+ключом+режимом (паттерн llm-judge v4).
|
||
*
|
||
* §8 (шаг ВЛАДЕЛЬЦА, последняя фаза): реальный llmCall-транспорт + извлечение «продукта на
|
||
* суд» подключаются в runJudgeGate владельцем. До подключения — нейтральный GO (flip mode=block
|
||
* без транспорта НЕ кирпичит сессию). Рекомендация §11: обкатка в shadow перед block.
|
||
*/
|
||
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
|
||
import { judgeGateMode, judgeActive } from './judge-gate-config.mjs';
|
||
import { finalGate, logVerdict } from './judge-orchestrator.mjs';
|
||
import { runJudge, requiredLensesFor, buildJudgePrompt } from './judge-engine.mjs';
|
||
import { callAnthropicAPI } from './router-classifier.mjs';
|
||
import { CLASSIFIER_MODEL } from './router-config.mjs';
|
||
import fsDefault from 'node:fs';
|
||
import { join } from 'node:path';
|
||
// Task 5 (sealed-plan production): печать на реальном wired GO.
|
||
import { sealArtifact, sealPlan, sealablePlan, sealableArtifact, judgedHashOf } from './seal-orchestration.mjs';
|
||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||
import { loadFrozenArtifact, saveFrozenArtifact, saveFrozenPlan } from './plan-lock.mjs';
|
||
import { logGuardBlock } from './guard-block-log.mjs';
|
||
|
||
/**
|
||
* Чистое решение обёртки. inert/shadow → allow. live-block → finalGate(вердикт, пол):
|
||
* судья GO И пол чист → allow; иначе block. Битый/пустой вердикт → NO-GO (сомнение→блок).
|
||
*/
|
||
export function decide({ mode, verdict, floorBlocked = false } = {}) {
|
||
if (mode !== 'live-block') {
|
||
return { block: false, reason: mode === 'inert' ? 'judge inert ($0)' : 'shadow (D28) — судья логирует, не блокирует' };
|
||
}
|
||
const decision = verdict && verdict.decision === 'GO' ? 'GO' : 'NO-GO';
|
||
const gate = finalGate({ judgeDecision: decision, floorBlocked });
|
||
return gate === 'allow'
|
||
? { block: false, reason: 'live-block: судья GO + пол чист' }
|
||
: { block: true, message: `[judge-gate] live-block: судья=${decision}, пол=${floorBlocked} → блок` };
|
||
}
|
||
|
||
/**
|
||
* Шов судьи (async, §8 + Δ-C): рубильник → детект плана (Write-only) → префетч живого вердикта.
|
||
* 1) не активен (нет флага/HMAC-ключа судьи) → нейтральный GO, wired:false, $0.
|
||
* 2) активен, но не «запись плана» → нейтральный GO, wired:false, $0 (защита от over-block).
|
||
* 3) активен + план + судья НЕДОСТУПЕН (нет ROUTER_LLM_KEY / транспорт бросил) → degraded ALLOW
|
||
* (decision:'GO', wired:false, unavailable:true), $0 — не фабрикуем NO-GO (VA-FIX-1, зеркало v4).
|
||
* 4) активен + план + судья ответил → judge-engine.runJudge (движок М4 не трогаем; subRunsRequired:[]).
|
||
* functionName='gate2' приклеивается к вердикту для лога.
|
||
* apiKey/transport/judgeActiveImpl/model инъектируются для тестов; apiKey прод = ROUTER_LLM_KEY.
|
||
*/
|
||
export async function runJudgeGate(event, deps = {}) {
|
||
const judgeActiveImpl = deps.judgeActiveImpl || judgeActive;
|
||
if (!judgeActiveImpl()) return { decision: 'GO', wired: false };
|
||
// Гейт-1 (спека) приоритетно, иначе Гейт-2 (план). Пути непересекаемы (specs/ vs plans/).
|
||
const g1 = extractGate1Product(event);
|
||
const g = g1.shouldJudge ? g1 : extractGate2Product(event);
|
||
if (!g.shouldJudge) return { decision: 'GO', wired: false };
|
||
const functionName = g.functionName; // 'gate1' | 'gate2'
|
||
const apiKey = deps.apiKey !== undefined ? deps.apiKey : process.env.ROUTER_LLM_KEY;
|
||
const requiredLenses = requiredLensesFor(functionName);
|
||
const promptArgs = { product: g.product, goal: g.goal, cards: g.cards };
|
||
const raw = await callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model: deps.model, transport: deps.transport });
|
||
if (raw && raw.unavailable) {
|
||
return { decision: 'GO', wired: false, unavailable: true };
|
||
}
|
||
const verdict = runJudge({ functionName, requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
|
||
// C5/SD-1: judged_hash от СЫРОГО content (как печать sealOnWiredGo), не от g.product (trimmed).
|
||
// gate1 → артефакт (source_sha байт-чувствителен!), gate2 → план. buildArtifact не бросает;
|
||
// sealablePlan бросает без steps-json блока → judged_hash undefined (печать fail-CLOSE).
|
||
const rawContent = String((event && event.tool_input && event.tool_input.content) ?? '');
|
||
let judged_hash;
|
||
try {
|
||
judged_hash = functionName === 'gate1'
|
||
? judgedHashOf(sealableArtifact(rawContent))
|
||
: judgedHashOf(sealablePlan(rawContent));
|
||
} catch { judged_hash = undefined; }
|
||
return { decision: verdict.decision, wired: true, judged_hash, verdict: { ...verdict, functionName } };
|
||
}
|
||
|
||
/**
|
||
* Парс ответа модели в форму {decision?, slots, objections}. Fail-closed:
|
||
* не-JSON / не-объект / массив / строка → {} (движок runJudge отвергнет по слотам → NO-GO).
|
||
* objections нормализуется в массив. decision судьёй не используется (runJudge выводит сам),
|
||
* сохраняется лишь для лога.
|
||
*/
|
||
export function parseJudgeResponse(text) {
|
||
try {
|
||
let s = String(text == null ? '' : text).trim();
|
||
s = s.replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim();
|
||
if (!s) return {};
|
||
const o = JSON.parse(s);
|
||
if (!o || typeof o !== 'object' || Array.isArray(o)) return {};
|
||
return {
|
||
decision: o.decision,
|
||
slots: o.slots && typeof o.slots === 'object' ? o.slots : {},
|
||
objections: Array.isArray(o.objections) ? o.objections : [],
|
||
};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
const PLAN_PATH_RE = /(^|[/\\])docs[/\\]superpowers[/\\]plans[/\\][^/\\]+\.md$/i;
|
||
// Task 5 (C3): запись решения-спеки → печать артефакта (печать №1). Гейт-1 (судить
|
||
// спеки) НЕ строится здесь — extractGate2Product остаётся Гейт-2 (планы); sealOnWiredGo
|
||
// печатает артефакт только когда вердикт wired GO приходит на spec-Write (Фаза 8 / Гейт-1).
|
||
export const SPEC_PATH_RE = /(^|[/\\])docs[/\\]superpowers[/\\]specs[/\\][^/\\]+\.md$/i;
|
||
// Δ-A (SE-FIX-2): только Write несёт полный content плана. Edit/MultiEdit — фрагмент,
|
||
// не весь план → судить нельзя (ложные NO-GO). writing-plans создаёт план через Write.
|
||
const PLAN_TOOLS = new Set(['Write']);
|
||
|
||
/** Цель плана: секция ## Цель / ## Goal (до след. заголовка) или первый непустой абзац. */
|
||
function extractGoal(text) {
|
||
const m = String(text).match(/^##\s*(?:Цель|Goal)[^\n]*\n([\s\S]*?)(?:\n##\s|$)/im);
|
||
if (m && m[1].trim()) return m[1].trim();
|
||
const para = String(text).split(/\n\s*\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('#'));
|
||
return para || '';
|
||
}
|
||
|
||
/**
|
||
* Детект «запись плана реализации» (Гейт-2) + извлечение продукта на суд.
|
||
* Срабатывает ТОЛЬКО для Write по пути docs/superpowers/plans/*.md (полный content).
|
||
* Иначе {shouldJudge:false} → судья не зовётся ($0, защита от over-block).
|
||
*/
|
||
export function extractGate2Product(event) {
|
||
const tool = event && event.tool_name;
|
||
const input = (event && event.tool_input) || {};
|
||
const filePath = String(input.file_path || '');
|
||
if (!PLAN_TOOLS.has(tool) || !PLAN_PATH_RE.test(filePath)) return { shouldJudge: false };
|
||
const product = String(input.content ?? '').trim();
|
||
return { shouldJudge: true, functionName: 'gate2', product, goal: extractGoal(product), cards: [] };
|
||
}
|
||
|
||
/**
|
||
* Детект «запись РЕШЕНИЯ-спеки» (Гейт-1, C3) + извлечение продукта на суд.
|
||
* Срабатывает ТОЛЬКО для Write по пути docs/superpowers/specs/*.md (полный content).
|
||
* Зеркало extractGate2Product, но SPEC_PATH_RE + functionName='gate1' (линзы gate1 в движке).
|
||
*/
|
||
export function extractGate1Product(event) {
|
||
const tool = event && event.tool_name;
|
||
const input = (event && event.tool_input) || {};
|
||
const filePath = String(input.file_path || '');
|
||
if (!PLAN_TOOLS.has(tool) || !SPEC_PATH_RE.test(filePath)) return { shouldJudge: false };
|
||
const product = String(input.content ?? '').trim();
|
||
return { shouldJudge: true, functionName: 'gate1', product, goal: extractGoal(product), cards: [] };
|
||
}
|
||
|
||
const JSON_DIRECTIVE = [
|
||
'Ответь СТРОГО валидным JSON без пояснений и без markdown-забора:',
|
||
'{"slots":{"<линза>":"<непустая строка ≥8 симв>"},',
|
||
' "objections":[{"verdict":"NO","anchor":{"kind":"spec_section|card_need|test_name|failed_criterion|observation","ref":"<конкретика>"},"severity":"fatal|heavy|light","reversible":true|false}]}',
|
||
'Слот на КАЖДУЮ линзу обязателен. Возражение без якоря станет советом (не блок).',
|
||
].join('\n');
|
||
|
||
/**
|
||
* Префетч вердикта судьи (async): строит промпт, зовёт транспорт, парсит fail-closed.
|
||
* Δ-B (VA-FIX-1): различаем «не смог запуститься» от «запустился, но мусор»:
|
||
* - нет apiKey → {unavailable:true} (транспорт НЕ зовётся, $0) — судья недоступен, НЕ NO-GO;
|
||
* - транспорт бросил → {unavailable:true} — судья недоступен (сеть/таймаут), НЕ NO-GO;
|
||
* - транспорт вернул текст → parseJudgeResponse: валидный → {slots,...}; мусор → {} (движок→NO-GO).
|
||
* transport инъектируется (тест — мок; прод — callAnthropicAPI). apiKey = ROUTER_LLM_KEY (как классификатор).
|
||
*/
|
||
export async function callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI }) {
|
||
if (!apiKey) return { unavailable: true };
|
||
const base = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
|
||
const prompt = { system: base.system + '\n' + JSON_DIRECTIVE, user: base.user };
|
||
try {
|
||
const text = await transport(prompt, { apiKey, model });
|
||
return parseJudgeResponse(text);
|
||
} catch {
|
||
return { unavailable: true };
|
||
}
|
||
}
|
||
|
||
const VERDICT_LOG = 'judge-verdicts.jsonl';
|
||
|
||
/** Построить запись вердикта (J8) через orchestrator.logVerdict (журнал не пишем — entry-only). */
|
||
export function buildVerdictEntry(judgeResult, nowMs) {
|
||
return logVerdict({ verdict: judgeResult.verdict || judgeResult, nowMs });
|
||
}
|
||
|
||
/** Best-effort append-only лог вердиктов в ~/.claude/runtime/judge-verdicts.jsonl (Node fs, не Write-tool). */
|
||
export function logVerdictLine(entry, { fsImpl = fsDefault, dir = runtimeDir() } = {}) {
|
||
try {
|
||
fsImpl.mkdirSync(dir, { recursive: true });
|
||
fsImpl.appendFileSync(join(dir, VERDICT_LOG), JSON.stringify(entry) + '\n');
|
||
} catch { /* shadow-лог best-effort */ }
|
||
}
|
||
|
||
/** Δ-D: судья недоступен (нет ROUTER_LLM_KEY / транспорт упал) → WARN-строка, НЕ verdict-запись. */
|
||
export function warnJudgeUnavailable(_event, { fsImpl = fsDefault, dir = runtimeDir() } = {}) {
|
||
try {
|
||
fsImpl.mkdirSync(dir, { recursive: true });
|
||
fsImpl.appendFileSync(join(dir, VERDICT_LOG),
|
||
JSON.stringify({ kind: 'judge_unavailable', at: null, note: 'нет ROUTER_LLM_KEY или транспорт недоступен' }) + '\n');
|
||
} catch { /* best-effort */ }
|
||
}
|
||
|
||
/**
|
||
* Δ-D: чистая режим-логика хода (без stdin/exit). inert → allow ($0); shadow → прогон + лог-real +
|
||
* allow (D28, не блокирует); live-block → прогон + лог-real + decide (degraded-allow→allow, реальный
|
||
* NO-GO→block, пол перевешивает). Истинный throw в прогоне → live-block fail-CLOSE (block), shadow
|
||
* allow. logImpl/warnImpl/nowMs/deps (judgeActiveImpl/apiKey/transport/model) инъектируются для тестов.
|
||
*/
|
||
export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warnImpl = warnJudgeUnavailable, nowMs, ...deps } = {}) {
|
||
if (mode === 'inert') return { block: false };
|
||
let verdict;
|
||
try { verdict = await runJudgeGate(event, deps); }
|
||
catch { return { block: mode === 'live-block' }; }
|
||
if (verdict && verdict.wired) {
|
||
try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ }
|
||
// Task 5: печать на реальном wired GO. Best-effort, НЕ влияет на block-решение
|
||
// (печать = одобрение, не энфорсмент). Инъектируется в main() — юнит-тесты hermetic.
|
||
if (deps.onWiredSeal) { try { deps.onWiredSeal(event, verdict, mode); } catch { /* best-effort */ } }
|
||
} else if (verdict && verdict.unavailable) {
|
||
try { warnImpl(event); } catch { /* best-effort */ }
|
||
}
|
||
if (mode === 'shadow') return { block: false }; // D28: логирует, не блокирует
|
||
const d = decide({ mode, verdict, floorBlocked: false });
|
||
return { block: d.block, message: d.message };
|
||
}
|
||
|
||
/**
|
||
* Task 5 (C3/C4/C6): печать на реальном wired GO. spec-Write → sealArtifact;
|
||
* plan-Write → sealPlan (currentArtifact из загрузки). Persist атомарный (артефакт ДО плана,
|
||
* VA-3). judgeMode = режим гейта (shadow/live-block) → запечатан в печать (VA-2). Чистая
|
||
* (роутинг по пути), реальные deps впрыснуты — для теста переиспользуется без I/O.
|
||
*/
|
||
export function sealOnWiredGo({ event, verdict, judgeMode, deps = {} }) {
|
||
if (!(verdict && verdict.wired === true && verdict.decision === 'GO')) return { sealed: false };
|
||
const fp = String((event && event.tool_input && event.tool_input.file_path) || '');
|
||
const content = String((event && event.tool_input && event.tool_input.content) ?? '');
|
||
const key = deps.key !== undefined ? deps.key : (deps.resolveReceiptKey ? deps.resolveReceiptKey() : null);
|
||
if (SPEC_PATH_RE.test(fp)) {
|
||
const r = deps.sealArtifact({ md: content, verdict, key, judgeMode });
|
||
if (r && r.sealed && deps.persistArtifact) deps.persistArtifact(r.seal);
|
||
return { sealed: !!(r && r.sealed), kind: 'artifact' };
|
||
}
|
||
if (PLAN_PATH_RE.test(fp)) {
|
||
const cur = deps.loadCurrentArtifact ? deps.loadCurrentArtifact() : null;
|
||
const r = deps.sealPlan({ md: content, currentArtifact: cur, verdict, key, judgeMode });
|
||
if (r && r.sealed && deps.persistPlan) deps.persistPlan(r.seal);
|
||
return { sealed: !!(r && r.sealed), kind: 'plan' };
|
||
}
|
||
return { sealed: false };
|
||
}
|
||
|
||
/** Прод-сборка seal-deps (Node fs + keychain). Зовётся только из main() (не в юнит-тестах). */
|
||
function sealTurnProd(event, verdict, mode) {
|
||
const sessionId = (event && event.session_id) || 'unknown';
|
||
const dir = runtimeDir();
|
||
return sealOnWiredGo({
|
||
event, verdict, judgeMode: mode,
|
||
deps: {
|
||
resolveReceiptKey: () => resolveReceiptKey(),
|
||
sealArtifact, sealPlan,
|
||
loadCurrentArtifact: () => loadFrozenArtifact({ sessionId, runtimeDir: dir }),
|
||
persistArtifact: (seal) => saveFrozenArtifact({ artifact: seal, sessionId, runtimeDir: dir }),
|
||
persistPlan: (seal) => saveFrozenPlan({ plan: seal, sessionId, runtimeDir: dir }),
|
||
},
|
||
});
|
||
}
|
||
|
||
async function main() {
|
||
let event, mode;
|
||
try {
|
||
event = parseEventJson(await readStdin());
|
||
mode = judgeGateMode();
|
||
} catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe allow ($0)
|
||
let result;
|
||
try {
|
||
result = await runJudgeTurn(event, { mode, onWiredSeal: sealTurnProd }); // inert/shadow/live-block внутри
|
||
} catch { exitDecision({ block: mode === 'live-block' }); return; } // fail-CLOSE только в live-block
|
||
if (result.block) { logGuardBlock(event, 'М4 Судья', result.message); exitDecision({ block: true, message: result.message || '[judge-gate] block' }); }
|
||
else exitDecision({ block: false });
|
||
}
|
||
|
||
import { fileURLToPath } from 'node:url';
|
||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||
if (isCli) main();
|