#!/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, 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 } from './seal-orchestration.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 } 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';
import { ownerSealAction } from './seal-override.mjs';
import { escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.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,
  });
  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 };
}

/**
 * Шов судьи (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);
  let priorObjections = []; let controllerResponses = [];
  try {
    const sess0 = (event && event.session_id) || 'unknown';
    const taskId0 = loadTaskId({ sessionId: sess0, runtimeDir: runtimeDir() });
    const rm = await import('./round-memory-store.mjs');
    priorObjections = rm.readRoundObjections({ taskId: taskId0, side: 'judge' });
    const content0 = String((event && event.tool_input && event.tool_input.content) ?? '');
    controllerResponses = parseNegotiationSection(content0).map((r) => ({ round: r.round, position: r.position }));
  } catch { priorObjections = []; controllerResponses = []; }
  const promptArgs = { product: g.product, goal: g.goal, cards: g.cards, priorObjections, controllerResponses };
  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 });
  try {
    if (verdict && verdict.decision === 'NO-GO') {
      const s1 = (event && event.session_id) || 'unknown';
      const t1 = loadTaskId({ sessionId: s1, runtimeDir: runtimeDir() });
      const text = formatJudgeObjection(verdict);
      if (text) { const rm2 = await import('./round-memory-store.mjs'); rm2.appendRoundObjection({ taskId: t1, side: 'judge', objectionVerbatim: text, nowMs: Date.now() }); }
    }
  } catch { /* best-effort */ }
  // 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 {};
  }
}

// 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). 90с укладывает с большим запасом.
    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;
  try { verdict = await runJudgeGate(event, deps); }
  catch { seal({ wired: false, decision: null }); return { block: mode === 'live-block' }; }
  let sealResult = null;
  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 { sealResult = deps.onWiredSeal(event, verdict, mode); } 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 */ }
  }
  // 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,
  });
  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 недоступен → обычное решение судьи */ }
  const d = decide({ mode, verdict, floorBlocked: false });
  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 = {} }) {
  // Fix3: seal on wired-GO OR owner-seal.
  if (!(ownerSealOpen || (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)) {
    // T6 «зубы» наставника (решение владельца 2026-06-12): freeze-gate ПЕРЕД печатью
    // плана. mentorGate инъектируется прод-сборкой ТОЛЬКО при mentorSeamActive() —
    // выключен рубильник → undefined → печать как раньше. Бросок гейта → fail-CLOSE.
    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 });
    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);
}

/** Прод-сборка seal-deps (Node fs + keychain). Зовётся только из main() (не в юнит-тестах). */
function sealTurnProd(event, verdict, mode) {
  const sessionId = (event && event.session_id) || 'unknown';
  const dir = runtimeDir();
  let ownerSealOpen = false;
  try {
    const c = String((event && event.tool_input && event.tool_input.content) ?? '');
    const fp = String((event && event.tool_input && event.tool_input.file_path) || '');
    const hash = SPEC_PATH_RE.test(fp) ? judgedHashOf(sealableArtifact(c)) : planId(sealablePlan(c).steps);
    ownerSealOpen = hash ? escapeGrantOpen(ownerSealAction(hash), loadFloorEscapes(sessionId), loadConsumed(sessionId)) : false;
  } catch { ownerSealOpen = false; }
  // Способ 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, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) {
  // Фаза 4: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sessionId — fallback).
  const safe = String(taskId || sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_');
  const path = join(dir, `judge-nogo-${safe}.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 }); // 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; }
      const n = bumpJudgeNoGo({ taskId, sessionId: (event && event.session_id) || 'unknown', blocked: isNoGo });
      if (isNoGo && n >= JUDGE_ESCALATE_AFTER) {
        const planContent = String((event && event.tool_input && event.tool_input.content) ?? '');
        result = { ...result, message: buildJudgeArbitrationMessage(result.verdict, planContent, n) };
      }
    }
  } catch { /* счётчик best-effort */ }
  try {
    const g1 = extractGate1Product(event).shouldJudge;
    const judged = g1 || extractGate2Product(event).shouldJudge;
    if (judged) {
      const m1 = await import('./verdict-surface-store.mjs');
      const det = await import('./verdict-surface-detail.mjs');
      const outcome = classifyJudgeOutcome(result, result.verdict);
      const line = det.buildJudgeSurface(outcome, { gate: g1 ? 'gate1' : 'gate2' });
      if (line) m1.appendVerdictOutcome({ sessionId: (event && event.session_id) || 'unknown', line, nowMs: Date.now() });
    }
  } 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();


/** Fix1: judge outcome -> visibility line. */
export function classifyJudgeOutcome(result, verdict) {
  if (verdict && verdict.skip) return { kind: 'skip', reason: String(verdict.skip) };
  if (result && result.degraded) {
    const reason = verdict && verdict.cause === 'no_key' ? 'нет ключа судьи'
      : (verdict && (verdict.errorType || verdict.cause)) || 'транспорт недоступен';
    return { kind: 'degraded', reason };
  }
  if (result && result.block) return { kind: 'nogo', text: String((result.message || '')).trim() };
  if (verdict && verdict.wired === true && verdict.decision === 'GO') return { kind: 'go' };
  return { kind: 'skip', reason: 'не судится' };
}
