a5d30f38a3
Этап 2c эпика роутер-реестр: оживление машины охвата как замена цепочкам L.
- registry-initial-inputs.mjs: токены-данности (category:given) для initialInputs.
- registry-graph-health.test.mjs: граф ацикличен, рёбра producer-consumer.
- coverage-wiring.mjs: мост recommended skills -> readinessChecklist -> {cards, ready, holes}; ready=нет-дыр.
- enforce-judge-gate.mjs: coverageCardsFor/coverageGate — карточки + стоп при дыре (инъекция-выкл).
- замок словаря (vocabTokens) на живом пути; гайд по стене: автономность + уроки сессии.
Регрессия: 4375 passed (канонический свод владельца).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
640 lines
45 KiB
JavaScript
640 lines
45 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 { classifyJudgeOutcome } from './verdict-outcome-line.mjs';
|
||
import { pushVerdict } from './verdict-surface-store.mjs';
|
||
import { judgeGateMode, judgeActive, resolveJudgeLlmKey } from './judge-gate-config.mjs';
|
||
import { finalGate, logVerdict } from './judge-orchestrator.mjs';
|
||
import { runJudge, requiredLensesFor, buildJudgePrompt } from './judge-engine.mjs';
|
||
import { callAnthropicAPI, classifyLLMError } from './router-classifier.mjs';
|
||
import { CLASSIFIER_MODEL, HEAVY_LLM_TIMEOUT_MS } 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, decideSeal, ownerSealAction, ownerSealActionForContent } from './seal-orchestration.mjs';
|
||
// SP3-b owner-seal: escape-грант владельца над owner-seal:<хеш тела> (читается в sealTurnProd, sync).
|
||
import { escapeGrantOpen, loadConsumed, loadTerminalGrants } from './escape-grant.mjs';
|
||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||
import { loadFrozenArtifact, saveFrozenArtifact, saveFrozenPlan, planId } from './plan-lock.mjs';
|
||
import { logGuardBlock } from './guard-block-log.mjs';
|
||
// T6 «зубы» наставника (решение владельца 2026-06-12): freeze-gate перед печатью плана.
|
||
// Подключается ТОЛЬКО при mentorSeamActive() (sealTurnProd) — иначе печать как раньше.
|
||
import { mentorSeamActive, repoRootOf } from './mentor-gate-config.mjs';
|
||
import { loadMentorVerdict } from './mentor-journal-store.mjs';
|
||
import { parseVerifiedContext } from './plan-verified-context.mjs';
|
||
import { freezeGate } from './freeze-gate.mjs';
|
||
import { artifactHasUnresolvedExtracted } from './context-verity.mjs';
|
||
import { resolve as pathResolve } from 'node:path';
|
||
// Волна 6 (двухуровневые переговоры §6): эскалация судьи → карточка арбитража.
|
||
import { buildArbitrationCard } from './arbitration-card.mjs';
|
||
import { formatJudgeObjection } from './objection-format.mjs';
|
||
import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs';
|
||
import { parseNegotiationSection, arbitrationRequested } from './negotiation-section.mjs';
|
||
// M7 наблюдаемость печати (ремонт «провал печати нигде не логируется»).
|
||
import { buildSealEntry, logSealAttempt } from './seal-log.mjs';
|
||
// Способ B (Фаза 2): судья сам печатает план в Post при валидном mentor-GO (fail-safe).
|
||
import { loadMentorGo, mentorGoValidFor } from './mentor-go-store.mjs';
|
||
// Фаза 4: счётчик судьи на стэк (спека+план) — task-id (наставник его уже сохранил в Post-до).
|
||
import { loadTaskId } from './router-task-id.mjs';
|
||
// Этап 2c (роутер-реестр): живой охват — мост «рекомендованные навыки → карточки + вердикт
|
||
// готовности». Подключается ТОЛЬКО при инъекции coverageImpl (main); без инъекции — no-op.
|
||
import { buildCoverageInput } from './coverage-wiring.mjs';
|
||
import { parsePlanSkills } from './plan-skills.mjs';
|
||
|
||
/**
|
||
* Волна 6 (§6): сообщение арбитража при 3 NO-GO судьи — дословное замечание судьи +
|
||
* позиция контроллера (раздел «Переговоры» плана) + 3 выбора + аффорданс. Чистая.
|
||
*/
|
||
export function buildJudgeArbitrationMessage(verdict, planContent, n) {
|
||
const neg = parseNegotiationSection(planContent);
|
||
const position = neg.length ? neg[neg.length - 1].position : '(позиция не указана в разделе «Переговоры» плана)';
|
||
const card = buildArbitrationCard({
|
||
side: 'judge', level: 'L2', round: n,
|
||
objectionVerbatim: formatJudgeObjection(verdict) || '(судья не дал текста возражения)',
|
||
controllerPositionVerbatim: position,
|
||
// SP3-c: owner-seal-метка (тот же хеш, что sealTurnProd) → владельцу есть откуда взять escape.
|
||
sealAction: ownerSealActionForContent(planContent),
|
||
});
|
||
const opts = card.options.map((o) => `• ${o.label}: ${o.whatChanges}`).join('\n');
|
||
return [
|
||
`[judge-gate] ${card.title}`,
|
||
`Замечание судьи:\n${card.objection}`,
|
||
`Позиция контроллера:\n${card.position}`,
|
||
`Что меняет выбор:\n${opts}`,
|
||
'Можно сказать «объясни подробнее». Решение — через escape/вейвер владельца (не со слов Claude).',
|
||
].join('\n\n');
|
||
}
|
||
|
||
|
||
/**
|
||
* Чистое решение обёртки. 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 });
|
||
if (gate === 'allow') return { block: false, reason: 'live-block: судья GO + пол чист' };
|
||
// Фаза 1 (Р2): на NO-GO контроллеру доходит ПОЛНЫЙ дословный текст возражения судьи
|
||
// через рабочий exit-2 канал. Нет текста (degraded/пол) → скупой fallback с диагностикой.
|
||
const objText = formatJudgeObjection(verdict && verdict.verdict);
|
||
const message = objText
|
||
? buildObjectionFeedback({ side: 'judge', text: objText })
|
||
: `[judge-gate] live-block: судья=${decision}, пол=${floorBlocked} → блок`;
|
||
return { block: true, message };
|
||
}
|
||
|
||
/**
|
||
* Этап 2c (D4): врезка живого охвата. coverageCardsFor — извлекает рекомендованные навыки из
|
||
* тела записи (блок skills-json) и зовёт мост (coverageImpl=buildCoverageInput, инъекция).
|
||
* Нет навыков → пустые карточки + ready:true (backward-compat, без over-block). Сбой моста →
|
||
* degraded (печать не кирпичится). Чистая (coverageImpl инъектируется).
|
||
*/
|
||
export function coverageCardsFor({ content, coverageImpl } = {}) {
|
||
let skills = [];
|
||
try { skills = parsePlanSkills(String(content ?? '')) || []; } catch { skills = []; }
|
||
if (!Array.isArray(skills) || skills.length === 0) return { cards: [], ready: true, holes: [] };
|
||
if (typeof coverageImpl !== 'function') return { cards: [], ready: true, holes: [] };
|
||
try {
|
||
const r = coverageImpl({ recommendedSkills: skills }) || {};
|
||
return { cards: r.cards || [], ready: r.ready !== false, holes: r.holes || [] };
|
||
} catch {
|
||
return { cards: [], ready: true, holes: [], degraded: true };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Этап 2c (D4): механический гейт охвата — дыра покрытия (ready:false) → твёрдый стоп печати
|
||
* (через floorBlocked в decide). degraded (сбой охвата) НЕ блокирует (деградация не кирпичит).
|
||
* Чистая.
|
||
*/
|
||
export function coverageGate({ ready, holes = [], degraded = false } = {}) {
|
||
if (degraded) return { block: false, reason: 'coverage degraded — печать не стопорится' };
|
||
if (ready === false) {
|
||
const list = (holes || []).map((h) => h && h.need).filter(Boolean).join(', ');
|
||
return { block: true, reason: `дыра охвата (нужды не покрыты): ${list}` };
|
||
}
|
||
return { block: false };
|
||
}
|
||
|
||
/**
|
||
* Причина судьи для ПОКАЗА вердикта (SP1 visibility-fix): reason/recommendation, а при их
|
||
* отсутствии (типично для NO-GO судьи — суть в objections, а не в recommendation) — дословные
|
||
* возражения судьи (formatJudgeObjection). Раньше показ брал только reason||recommendation → на
|
||
* NO-GO выходило пусто, и контроллер видел голое «NO-GO» без претензий. Тотально (try) → ''.
|
||
*/
|
||
export function judgeSurfaceReason(verdict) {
|
||
const base = (verdict && (verdict.reason || (verdict.verdict && verdict.verdict.recommendation))) || '';
|
||
if (base) return base;
|
||
try { return formatJudgeObjection(verdict && verdict.verdict) || ''; } catch { return ''; }
|
||
}
|
||
|
||
/**
|
||
* Шов судьи (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, skip: 'inactive' };
|
||
// Гейт-1 (спека) приоритетно, иначе Гейт-2 (план). Пути непересекаемы (specs/ vs plans/).
|
||
const g1 = extractGate1Product(event);
|
||
const g = g1.shouldJudge ? g1 : extractGate2Product(event);
|
||
if (!g.shouldJudge) return { decision: 'GO', wired: false, skip: 'not_plan' };
|
||
const functionName = g.functionName; // 'gate1' | 'gate2'
|
||
// Способ B fail-safe (Фаза 2): судья судит/печатает ТОЛЬКО при одобрении наставника.
|
||
// mentorApproved инъектируется прод-сборкой лишь при mentorSeamActive(); нет одобрения →
|
||
// судья молчит ($0, без LLM-вызова). undefined → нет гейта (backward-compat, наставник off).
|
||
if (typeof deps.mentorApproved === 'function' && !deps.mentorApproved(event, functionName)) {
|
||
return { decision: 'GO', wired: false, skip: 'no_mentor_go' };
|
||
}
|
||
// «Оба строго» (2026-06-12): СВОЙ ключ судьи ROUTER_JUDGE_LLM_KEY, общий не фолбэк.
|
||
const apiKey = deps.apiKey !== undefined ? deps.apiKey : resolveJudgeLlmKey();
|
||
const requiredLenses = requiredLensesFor(functionName);
|
||
// SP2c-2: память кругов J-side (свои judge-замечания + J-доводы + diff; судья холодный —
|
||
// без замечания-при-возврате). roundMemoryImpl грузит из стора (await — годится sync-тест и
|
||
// async-прод); нет инъекции → круг слеп ({}). buildJudgePrompt уже рендерит roundMemory.
|
||
const stage = functionName === 'gate1' ? 'spec' : 'plan';
|
||
const rmContent = String((event && event.tool_input && event.tool_input.content) ?? '');
|
||
let roundMemory = {};
|
||
if (typeof deps.roundMemoryImpl === 'function') {
|
||
try { roundMemory = (await deps.roundMemoryImpl({ stage, content: rmContent })) || {}; } catch { roundMemory = {}; }
|
||
}
|
||
let delivery = null;
|
||
if (functionName === 'gate2') { try { delivery = sealablePlan(rmContent).delivery; } catch { delivery = 'internal'; } }
|
||
// Этап 2c (D4): живой охват — карточки рекомендованных навыков + вердикт готовности.
|
||
// Только при инъекции coverageImpl (main); без неё coverage=no-op (cards как раньше = g.cards).
|
||
let coverage = null;
|
||
if (typeof deps.coverageImpl === 'function') {
|
||
coverage = coverageCardsFor({ content: rmContent, coverageImpl: deps.coverageImpl });
|
||
}
|
||
const cards = coverage && Array.isArray(coverage.cards) && coverage.cards.length ? coverage.cards : g.cards;
|
||
const promptArgs = { product: g.product, goal: g.goal, cards, roundMemory, delivery };
|
||
const raw = await callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model: deps.model, transport: deps.transport });
|
||
if (raw && raw.unavailable) {
|
||
// M7: причина недоступности протекает в вердикт → лог-WARN + seal-запись её фиксируют.
|
||
return { decision: 'GO', wired: false, unavailable: true, cause: raw.cause ?? null, errorType: raw.errorType ?? null };
|
||
}
|
||
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 }, coverage };
|
||
}
|
||
|
||
/**
|
||
* Парс ответа модели в форму {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 {};
|
||
}
|
||
}
|
||
|
||
// T3 активации наставника (решение владельца 2026-06-12): export — единый источник
|
||
// «что есть план» для судьи И наставника (enforce-mentor-on-plan-write). Regex не менялся.
|
||
export 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, perAttemptTimeoutMs = HEAVY_LLM_TIMEOUT_MS }) {
|
||
// M7 (2026-06-13): различаем причину недоступности. no_key — мгновенно, $0 (вероятно,
|
||
// env не унаследован/не провижинен); transport_error:<тип> — реальный сбой захода
|
||
// (timeout/http_5xx/http_4xx/econnreset). Тип берём из classifyLLMError (тот же, что у классификатора).
|
||
if (!apiKey) return { unavailable: true, cause: 'no_key' };
|
||
const base = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
|
||
const prompt = { system: base.system + '\n' + JSON_DIRECTIVE, user: base.user };
|
||
try {
|
||
// perAttemptTimeoutMs: тяжёлый судья ~25-32с — дефолт 30с давал таймаут→degraded→печать
|
||
// не вставала (systematic-debugging 2026-06-14). 300с укладывает латентность deepseek-v4-pro.
|
||
const text = await transport(prompt, { apiKey, model, perAttemptTimeoutMs });
|
||
return parseJudgeResponse(text);
|
||
} catch (err) {
|
||
return { unavailable: true, cause: 'transport_error', errorType: classifyLLMError(err) };
|
||
}
|
||
}
|
||
|
||
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_JUDGE_LLM_KEY / транспорт упал) → WARN-строка, НЕ verdict-запись.
|
||
* M7 (2026-06-13): пишем cause (no_key / transport_error) + error_type (<classifyLLMError>) + at,
|
||
* чтобы degraded был диагностируем и сверяем с seal-attempts по времени.
|
||
*/
|
||
export function warnJudgeUnavailable(_event, { cause = null, errorType = null, nowMs = null, fsImpl = fsDefault, dir = runtimeDir() } = {}) {
|
||
try {
|
||
fsImpl.mkdirSync(dir, { recursive: true });
|
||
const note = cause === 'no_key'
|
||
? 'нет ROUTER_JUDGE_LLM_KEY (строгий ключ судьи, 2026-06-12) — env не провижинен/не унаследован'
|
||
: cause === 'transport_error'
|
||
? `транспорт судьи недоступен (${errorType || 'unknown'})`
|
||
: 'судья недоступен (причина не классифицирована)';
|
||
fsImpl.appendFileSync(join(dir, VERDICT_LOG),
|
||
JSON.stringify({ kind: 'judge_unavailable', at: nowMs, cause, error_type: errorType, note }) + '\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, sealLogImpl = logSealAttempt, nowMs, ...deps } = {}) {
|
||
// M7 наблюдаемость: судится ли это (запись плана/спеки) — только тогда пишем seal-log.
|
||
const judged = extractGate1Product(event).shouldJudge || extractGate2Product(event).shouldJudge;
|
||
const seal = (fields) => { if (judged) { try { sealLogImpl(buildSealEntry({ ...fields, nowMs })); } catch { /* best-effort */ } } };
|
||
if (mode === 'inert') { seal({ judgeActive: false }); return { block: false }; }
|
||
let verdict;
|
||
// Фикс silent-swallow (зеркало cd831b8): throw в производстве вердикта (runJudgeGate) раньше
|
||
// молча возвращал { block } без записи стадии и без причины — в снимке «упало» неотличимо от
|
||
// «ещё считает». Теперь throw → ВИДИМЫЙ degraded (wired:false, unavailable, cause): идёт общим
|
||
// degraded-путём ниже (warnImpl + снимок judge=degraded + degraded-блок с причиной в live-block).
|
||
try { verdict = await runJudgeGate(event, deps); }
|
||
catch (e) { verdict = { decision: 'GO', wired: false, unavailable: true, cause: `судья сорвался: ${String((e && e.message) || e).slice(0, 200)}` }; }
|
||
let sealResult = null;
|
||
if (verdict && verdict.wired) {
|
||
try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ }
|
||
} else if (verdict && verdict.unavailable) {
|
||
// M7: причина+тип+время → WARN-лог (diagnose degraded без догадок).
|
||
try { warnImpl(event, { cause: verdict.cause, errorType: verdict.errorType, nowMs }); } catch { /* best-effort */ }
|
||
}
|
||
// SP3-b (ownerseal-wiring-bug): печать пытается встать на КАЖДОЙ записи спеки/плана (judged) —
|
||
// НЕ только на wired. sealTurnProd внутри решает (decideSeal): wired-GO обычным путём ЛИБО
|
||
// owner-seal перебивает NO-GO/degraded. Раньше вызов сидел под if(verdict.wired) → при NO-GO
|
||
// наставника (wired:false) печать пропускалась и owner-seal был мёртвой проводкой. Best-effort,
|
||
// НЕ влияет на block-решение (печать = одобрение). onWiredSeal инъектируется в main() — тесты hermetic.
|
||
if (judged && deps.onWiredSeal) { try { sealResult = deps.onWiredSeal(event, verdict, mode); } catch { /* best-effort */ } }
|
||
// M7 наблюдаемость: исход судьи+печати для записи плана/спеки → seal-attempts.jsonl.
|
||
seal({
|
||
functionName: verdict && verdict.verdict && verdict.verdict.functionName,
|
||
judgeActive: !(verdict && verdict.skip === 'inactive'),
|
||
wired: verdict && verdict.wired,
|
||
decision: verdict && verdict.decision,
|
||
cause: verdict && verdict.cause,
|
||
errorType: verdict && verdict.errorType,
|
||
sealResult,
|
||
});
|
||
// SP1: громкая видимость вердикта судьи (best-effort, fail-quiet).
|
||
if (judged) {
|
||
const sessJ = (event && event.session_id) || 'unknown';
|
||
const judgeReason = judgeSurfaceReason(verdict);
|
||
try {
|
||
pushVerdict(sessJ, {
|
||
outcome: classifyJudgeOutcome(verdict),
|
||
gate: 'judge',
|
||
round: (verdict && verdict.round) ?? null,
|
||
version: (verdict && verdict.version) ?? null,
|
||
reason: judgeReason,
|
||
});
|
||
} catch { /* fail-quiet */ }
|
||
// Видимость «всё в лоб»: судья + печать → персистентный снимок (по этапу/hash, как наставник).
|
||
try {
|
||
const { writeStage } = await import('./verdict-surface-store.mjs');
|
||
const fpJ = String((event && event.tool_input && event.tool_input.file_path) || '');
|
||
const contentJ = String((event && event.tool_input && event.tool_input.content) ?? '');
|
||
const isSpecJ = SPEC_PATH_RE.test(fpJ);
|
||
let hashJ = null;
|
||
try { hashJ = isSpecJ ? judgedHashOf(sealableArtifact(contentJ)) : planId(sealablePlan(contentJ).steps); } catch { hashJ = null; }
|
||
writeStage(sessJ, { stage: isSpecJ ? 'judge:spec' : 'judge:plan', hash: hashJ, status: classifyJudgeOutcome(verdict), reason: judgeReason, ts: Date.now() });
|
||
if (sealResult && sealResult.sealed) {
|
||
writeStage(sessJ, { stage: 'seal', hash: hashJ, status: 'sealed', reason: `печать: ${sealResult.kind || ''}`, ts: Date.now() });
|
||
}
|
||
} catch { /* fail-quiet */ }
|
||
}
|
||
if (mode === 'shadow') return { block: false }; // D28: логирует, не блокирует
|
||
// Способ B + §9 (degraded): live-block, судья не дозвонился (нет ключа/таймаут) → контроллер
|
||
// ИНФОРМИРУЕТСЯ «судья не дозвонился» (не тихо), печати нет. Это НЕ NO-GO (escalation не растёт).
|
||
if (verdict && verdict.unavailable) {
|
||
const reason = verdict.cause === 'no_key' ? 'нет ключа судьи'
|
||
: (verdict.errorType || verdict.cause || 'транспорт недоступен');
|
||
return { block: true, degraded: true, message: buildDegradedFeedback({ side: 'judge', reason }) };
|
||
}
|
||
// M7: escape-валве владельца (эскалация). Судья чтит escape-грант, как стена/пол: упёрся
|
||
// NO-GO → владелец кликает «разрешаю» → судья пропускает. «Не смог договориться → вышел на владельца».
|
||
try {
|
||
const eg = await import('./escape-grant.mjs');
|
||
const sess = event && event.session_id;
|
||
const action = eg.canonicalAction(event && event.tool_name, (event && event.tool_input) || {});
|
||
if (eg.escapeGrantOpen(action, eg.loadFloorEscapes(sess), eg.loadConsumed(sess))) {
|
||
return { block: false, message: 'judge: разрешено аварийным выходом владельца (escape)' };
|
||
}
|
||
} catch { /* escape недоступен → обычное решение судьи */ }
|
||
// Этап 2c (D4): дыра охвата (ready:false по мосту) → твёрдый стоп через floorBlocked в decide.
|
||
// Только при инъекции coverageImpl (verdict.coverage есть); degraded охвата не блокирует.
|
||
const covBlocked = !!(verdict && verdict.coverage && coverageGate(verdict.coverage).block);
|
||
const d = decide({ mode, verdict, floorBlocked: covBlocked });
|
||
return { block: d.block, message: d.message, verdict };
|
||
}
|
||
|
||
/**
|
||
* 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, ownerSealOpen = false, deps = {} }) {
|
||
// SP3-b: печать на обычном GO ИЛИ owner-seal (перевешивает NO-GO/degraded). decideSeal —
|
||
// единый «мозг» решения; ни GO, ни owner-seal → нет печати (как раньше при !wired-GO).
|
||
if (!decideSeal({ verdict, ownerSealOpen }).seal) 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, ownerSealOpen });
|
||
if (r && r.sealed && deps.persistArtifact) deps.persistArtifact(r.seal);
|
||
return { sealed: !!(r && r.sealed), kind: 'artifact' };
|
||
}
|
||
if (PLAN_PATH_RE.test(fp)) {
|
||
// T6 «зубы» наставника (решение владельца 2026-06-12): freeze-gate ПЕРЕД печатью
|
||
// плана. mentorGate инъектируется прод-сборкой ТОЛЬКО при mentorSeamActive() —
|
||
// выключен рубильник → undefined → печать как раньше. Бросок гейта → fail-CLOSE.
|
||
// SP3-b: на owner-seal mentor-gate ПРОПУСКАЕТСЯ (владелец перевешивает И судью, И наставника).
|
||
if (!ownerSealOpen && typeof deps.mentorGate === 'function') {
|
||
let g;
|
||
try { g = deps.mentorGate({ content }); } catch { g = { pass: false, reason: 'mentor freeze-gate бросил (fail-CLOSE)' }; }
|
||
if (!g || g.pass !== true) return { sealed: false, kind: 'plan', reason: `mentor freeze-gate: ${(g && g.reason) || 'нет pass'}` };
|
||
}
|
||
const cur = deps.loadCurrentArtifact ? deps.loadCurrentArtifact() : null;
|
||
const r = deps.sealPlan({ md: content, currentArtifact: cur, verdict, key, judgeMode, ownerSealOpen });
|
||
if (r && r.sealed && deps.persistPlan) deps.persistPlan(r.seal);
|
||
return { sealed: !!(r && r.sealed), kind: 'plan' };
|
||
}
|
||
return { sealed: false };
|
||
}
|
||
|
||
/**
|
||
* Фаза 3: какой binding-хеш судья сверяет с mentor-GO. gate1 (спека) → хеш артефакта
|
||
* (judgedHashOf(sealableArtifact) — тот же, чем судья печатает gate1); gate2 (план) →
|
||
* plan_hash из steps. Бросок (нет steps и т.п.) ловит вызывающий → не одобрено (fail-safe).
|
||
*/
|
||
export function bindingHashForJudge({ content, functionName } = {}) {
|
||
const c = String(content ?? '');
|
||
return functionName === 'gate1'
|
||
? judgedHashOf(sealableArtifact(c))
|
||
: planId(sealablePlan(c).steps);
|
||
}
|
||
|
||
/**
|
||
* Открыт ли owner-seal для записи спеки/плана: хеш тела (как judged_hash) → спросить грант.
|
||
* Поза 1 (#B): источник — ТОЛЬКО терминальные гранты владельца (grantsLoader=loadTerminalGrants
|
||
* в проде), не chat floor_escape. escapeGrantOpen хранит окно 5мин + one-shot consume. Тотально
|
||
* (try) → false при сбое (печать как раньше — без owner-seal). grantsLoader/consumedLoader инъекция.
|
||
*/
|
||
export function ownerSealOpenForEvent({ event, sessionId, grantsLoader, consumedLoader, now = Date.now() }) {
|
||
try {
|
||
const fp = String((event && event.tool_input && event.tool_input.file_path) || '');
|
||
const content = String((event && event.tool_input && event.tool_input.content) ?? '');
|
||
const hash = SPEC_PATH_RE.test(fp) ? judgedHashOf(sealableArtifact(content))
|
||
: PLAN_PATH_RE.test(fp) ? planId(sealablePlan(content).steps)
|
||
: null;
|
||
if (!hash) return false;
|
||
return escapeGrantOpen(ownerSealAction(hash), grantsLoader(sessionId), consumedLoader(sessionId), now);
|
||
} catch { return false; }
|
||
}
|
||
|
||
/** Прод-сборка seal-deps (Node fs + keychain). Зовётся только из main() (не в юнит-тестах). */
|
||
function sealTurnProd(event, verdict, mode) {
|
||
const sessionId = (event && event.session_id) || 'unknown';
|
||
const dir = runtimeDir();
|
||
// SP3-b owner-seal: владелец подписал owner-seal:<хеш тела>. Поза 1 (#B): источник — ТОЛЬКО
|
||
// терминальный грант владельца (loadTerminalGrants), не chat floor_escape. Хеш над РЕАЛЬНЫМ
|
||
// телом записи (как judged_hash) → работает на degraded/NO-GO. Вычисление — в ownerSealOpenForEvent.
|
||
const ownerSealOpen = ownerSealOpenForEvent({
|
||
event, sessionId, grantsLoader: loadTerminalGrants, consumedLoader: loadConsumed,
|
||
});
|
||
// Способ B (Фаза 2): судья — хук ПОСЛЕ наставника, поэтому вердикт наставника уже свежий
|
||
// (mentor-GO + персист вердикта для plan_hash). Судья САМ печатает план здесь, через
|
||
// sealOnWiredGo + freeze-gate (verity/VA-9). Прежний «фикс дедлока» (судья сохранял judge-GO,
|
||
// наставник печатал в Post) снят — порядок теперь правильный.
|
||
return sealOnWiredGo({
|
||
event, verdict, judgeMode: mode, ownerSealOpen,
|
||
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 }),
|
||
// T6 «зубы» (контракты W7 + C-3): гейт печати ТОЛЬКО при активном наставнике —
|
||
// выключен рубильник → undefined → печать как раньше (не кирпич). Binding:
|
||
// planId(sealablePlan(content).steps) — ТО ЖЕ извлечение, что у producer-обёртки.
|
||
mentorGate: !mentorSeamActive() ? undefined : ({ content }) => {
|
||
const rec = loadMentorVerdict({ sessionId, runtimeDir: dir });
|
||
const steps = sealablePlan(content).steps;
|
||
return freezeGate({
|
||
mentorVerdict: rec && rec.verdict,
|
||
mentorWired: !!(rec && rec.wired),
|
||
planHash: planId(steps),
|
||
verifiedContextArtifact: parseVerifiedContext(content),
|
||
// W-2 (sharp-edges): корень репо — из события хука (repoRootOf), не слепо cwd.
|
||
hasUnresolvedExtractedImpl: (art) =>
|
||
artifactHasUnresolvedExtracted(art, (f) => fsDefault.readFileSync(pathResolve(repoRootOf(event), f), 'utf8')), // C-3
|
||
});
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const JUDGE_ESCALATE_AFTER = 3;
|
||
/**
|
||
* M7 эскалация (round-control C-12): счётчик ПОДРЯД идущих NO-GO судьи в сессии.
|
||
* blocked=true → +1; blocked=false (allow) → сброс 0. Возвращает новый счёт.
|
||
* fsImpl/dir инъектируемы для тестов. Best-effort — ошибка I/O не ломает судью.
|
||
*/
|
||
export function bumpJudgeNoGo({ taskId, sessionId, stage, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) {
|
||
// SP2c-3: счётчик на КАЖДУЮ стадию ОТДЕЛЬНО — ключ (task-id + stage), по дизайну §0/§6.
|
||
// stage отсутствует → 'all' (backward-compat). sessionId — fallback к task-id.
|
||
const safe = String(taskId || sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||
const stageKey = String(stage || 'all').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||
const path = join(dir, `judge-nogo-${safe}-${stageKey}.json`);
|
||
let count = 0;
|
||
try { count = (JSON.parse(fsImpl.readFileSync(path, 'utf8')).count) || 0; } catch { count = 0; }
|
||
const next = blocked ? count + 1 : 0;
|
||
try { fsImpl.mkdirSync(dir, { recursive: true }); fsImpl.writeFileSync(path, JSON.stringify({ count: next })); } catch { /* best-effort */ }
|
||
return next;
|
||
}
|
||
|
||
async function main() {
|
||
let event, mode;
|
||
try {
|
||
event = parseEventJson(await readStdin());
|
||
mode = judgeGateMode();
|
||
} catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe allow ($0)
|
||
// Способ B fail-safe (Фазы 2-3): судья судит/печатает ТОЛЬКО при валидном mentor-GO
|
||
// (наставник одобрил ЭТОТ артефакт). Активно лишь при mentorSeamActive(); иначе undefined →
|
||
// нет гейта (backward-compat). Гейтим и план (gate2), и спеку (gate1) — один сценарий.
|
||
const mentorApproved = !mentorSeamActive() ? undefined : (ev, fn) => {
|
||
try {
|
||
const content = String((ev && ev.tool_input && ev.tool_input.content) ?? '');
|
||
const bindingHash = bindingHashForJudge({ content, functionName: fn });
|
||
const rec = loadMentorGo({ sessionId: (ev && ev.session_id) || 'unknown', runtimeDir: runtimeDir() });
|
||
return mentorGoValidFor(rec, { planHash: bindingHash, key: resolveReceiptKey() });
|
||
} catch { return false; } // не смогли проверить → не одобрено → судья молчит (fail-safe)
|
||
};
|
||
let result;
|
||
try {
|
||
result = await runJudgeTurn(event, {
|
||
mode, nowMs: Date.now(), onWiredSeal: sealTurnProd, mentorApproved,
|
||
// Этап 2c (D4): живой охват — мост buildCoverageInput. Дыра покрытия → твёрдый стоп печати.
|
||
// Инъекция здесь (не в юнит-тестах) — рекомендованные навыки берутся из тела плана (skills-json).
|
||
coverageImpl: ({ recommendedSkills }) => buildCoverageInput({ recommendedSkills }),
|
||
// SP2c-2: реальный загрузчик памяти кругов J-side из стора (taskId — тот же, что
|
||
// сохранил наставник до судьи; side='judge' холодный). Динамический импорт, fail-quiet внутри.
|
||
roundMemoryImpl: async ({ stage, content }) => {
|
||
let taskId = null;
|
||
try { taskId = loadTaskId({ sessionId: (event && event.session_id) || 'unknown', runtimeDir: runtimeDir(), fsImpl: fsDefault }); } catch { taskId = null; }
|
||
const { buildRoundMemory } = await import('./round-memory-store.mjs');
|
||
return buildRoundMemory({ taskId, stage, side: 'judge', currentContent: content, baseDir: runtimeDir() });
|
||
},
|
||
}); // inert/shadow/live-block внутри; nowMs → at в seal/verdict/warn (M7)
|
||
} catch { exitDecision({ block: mode === 'live-block' }); return; } // fail-CLOSE только в live-block
|
||
// M7 эскалация (round-control C-12): подряд идущие NO-GO судьи. allow → сброс. После 3-го подряд —
|
||
// сообщение «ЭСКАЛАЦИЯ ВЛАДЕЛЬЦУ» (судья сам выходит на владельца; продавить — escape, который судья
|
||
// уже чтит). Счётчик best-effort, на сам факт block не влияет.
|
||
try {
|
||
const judgedPlan = extractGate1Product(event).shouldJudge || extractGate2Product(event).shouldJudge;
|
||
if (judgedPlan) {
|
||
// degraded (судья не дозвонился) — НЕ NO-GO: счётчик эскалации не растёт, карточки нет.
|
||
const isNoGo = !!result.block && !result.degraded;
|
||
// Фаза 4: ключ счётчика — task-id (наставник сохранил его в Post-до судьи); sess — fallback.
|
||
let taskId = null;
|
||
try { taskId = loadTaskId({ sessionId: (event && event.session_id) || 'unknown', runtimeDir: runtimeDir(), fsImpl: fsDefault }); } catch { taskId = null; }
|
||
// SP2c-3: потолок per-стадия — stage из функции судьи (gate1=спека, gate2=план).
|
||
const stageNoGo = extractGate1Product(event).shouldJudge ? 'spec' : 'plan';
|
||
const n = bumpJudgeNoGo({ taskId, sessionId: (event && event.session_id) || 'unknown', stage: stageNoGo, blocked: isNoGo });
|
||
// SP2c-1: дословное замечание судьи на NO-GO в дорожку judge. Best-effort, fail-quiet.
|
||
if (isNoGo) {
|
||
try {
|
||
const fp = String((event && event.tool_input && event.tool_input.file_path) || '');
|
||
const stage = SPEC_PATH_RE.test(fp) ? 'spec' : (PLAN_PATH_RE.test(fp) ? 'plan' : null);
|
||
if (stage) {
|
||
const rm = await import('./round-memory-record.mjs');
|
||
rm.recordSideObjection(taskId, stage, 'judge', formatJudgeObjection(result.verdict));
|
||
}
|
||
} catch { /* fail-quiet */ }
|
||
}
|
||
// SP2d: карточка на 3-м круге (потолок) ИЛИ при маркере `**Арбитраж:**` (любой круг, §7).
|
||
if (isNoGo && (n >= JUDGE_ESCALATE_AFTER || arbitrationRequested(String((event && event.tool_input && event.tool_input.content) ?? '')))) {
|
||
const planContent = String((event && event.tool_input && event.tool_input.content) ?? '');
|
||
result = { ...result, message: buildJudgeArbitrationMessage(result.verdict, planContent, n) };
|
||
}
|
||
}
|
||
} catch { /* счётчик best-effort */ }
|
||
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();
|