#!/usr/bin/env node
/**
 * enforce-supreme-gate — Верховный хук (Машина 2): default-deny на ВСЕ действия,
 * пропуск только при шаге замороженного плана. Матчер = '*' (регистрация в
 * settings.json — owner-шаг). Семена D12/D13 пропускаются всегда (иначе стену
 * нельзя загрузить). fail-CLOSED (сбой → стоп; рубильник у владельца).
 */
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
import { verifyFrozenPlan, verifyFrozenArtifact, actionMatchesStep, nextStep, refResolves, treeLeafAt, validatePlanTree, treeLeaves } from './plan-lock.mjs';
import { advanceOverTree, serializePointer, deserializePointer, normalizeToLeaf } from './step-pointer.mjs';
// W2 (C2, нах.F3): дисциплина чтения ДР-1 (модуль D) + нормализация путей для продюсера SE5.
import { decideReadEvent } from './reading-discipline.mjs';
import { pathNormalize } from './path-normalization.mjs';
import { classifyBashCommand, READING_CMDS } from './enforce-router-gate.mjs';
import { tokenizeBash } from './bash-tokenizer.mjs';
import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
import { assertSafeSessionId } from './action-journal.mjs';
import { floorDecide } from './floor-decide.mjs';
import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs';

// Фаза 5 Task 5.2 (Вариант А): зарезервированная canonical-метка finish-гранта владельца
// «план завершён досрочно». НЕ совпадает ни с одним реальным действием (canonicalAction даёт
// write:/bash:/skill:/mcp:/powershell:/unknown:) → срабатывает только на намеренный finish-грант.
export const PLAN_FINISH_ACTION = 'plan-done';
import { logGuardBlock } from './guard-block-log.mjs';
import { existsSync } from 'node:fs';

// M7 Ф8 (bootstrap): авторская запись НОВОГО артефакта/плана (Write нового .md в
// docs/superpowers/{specs,plans}/) — это КАНАЛ печати, не реализация. В разговорном режиме
// пропускаем: судья оценит содержимое и опечатает ТОЛЬКО на GO. Перезапись существующего
// файла НЕ разрешаем (existsSync). До печати запись власти не даёт — стена закрыта для правок.
const AUTHORING_PATH_RE = /(^|[/\\])docs[/\\]superpowers[/\\](?:specs|plans)[/\\][^/\\]+\.md$/i;
export function isAuthoringWrite(toolUse, { existsImpl = existsSync } = {}) {
  if (!toolUse || toolUse.name !== 'Write') return false;
  const fp = String(toolUse.input?.file_path || '');
  if (!AUTHORING_PATH_RE.test(fp)) return false;
  try { return !existsImpl(fp); } catch { return false; }
}

// Узкий технический allowlist загрузки (НЕ «карта критического») — без него
// нельзя создать первый план: writing-plans пишет план, AskUser/EnterPlanMode
// открывают одобрение. Обоснование — D12/D13.
// M7 Фаза 3 (SE-K): +реактивные дисциплинарные навыки — их зовут на внезапный баг/ревью
// без предварительного плана; стена не должна рубить их «вне плана» (иначе дисциплину
// нельзя вызвать). Это Skill-вызовы (не мутируют мир) → seed-allow безопасен.
export const SEED_SKILLS = ['writing-plans', 'brainstorming', 'discovery-interview',
  'systematic-debugging', 'test-driven-development', 'requesting-code-review', 'verification-before-completion'];
export const SEED_TOOLS = new Set(['EnterPlanMode', 'AskUserQuestion']);

function skillSuffix(name) { const s = String(name || '').toLowerCase(); return s.includes(':') ? s.split(':').pop() : s; }

export function isSeed(toolUse) {
  if (!toolUse) return false;
  if (SEED_TOOLS.has(toolUse.name)) return true;
  if (toolUse.name === 'Skill') return SEED_SKILLS.includes(skillSuffix(toolUse.input?.skill));
  return false;
}

// Навык, ОБЪЯВЛЕННЫЙ в опломбированном плане (frozenPlan.skills — подписанное поле, Вариант 1),
// разрешён к вызову как обеспечение исполнения плана (снимает дедлок «объявил, но не вызвать»).
// Матч по суффиксу (как seed). Указатель шага НЕ двигает — это не шаг плана.
export function isPlanDeclaredSkill(toolUse, frozenPlan) {
  if (!toolUse || toolUse.name !== 'Skill' || !frozenPlan) return false;
  const invoked = String(toolUse.input?.skill || '').toLowerCase();
  if (!invoked) return false;
  const declared = Array.isArray(frozenPlan.skills) ? frozenPlan.skills : [];
  // Объявлено может быть имя плагина ('claude-md-management') или конкретный навык
  // ('superpowers:test-driven-development'). Матч: точное равенство ИЛИ вызванный начинается
  // с 'объявленный:' (плагин-префикс) ИЛИ совпадение суффиксов (бэр-имена навыка).
  return declared.some((d) => {
    const dd = String(d || '').toLowerCase();
    if (!dd) return false;
    return invoked === dd || invoked.startsWith(dd + ':') || skillSuffix(invoked) === skillSuffix(dd);
  });
}

// Зелёный проход по СПОСОБНОСТИ = «нет долговременного И нет исходящего эффекта».
const OBSERVE_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']);   // локальные «смотрят»
const EPHEMERAL_META_TOOLS = new Set(['TodoWrite']);            // меняют только черновик сессии, не мир
export function isObserveOnly(toolUse, { classifyBash = classifyBashCommand } = {}) {
  if (!toolUse) return false;
  if (OBSERVE_ONLY_TOOLS.has(toolUse.name)) return true;
  if (EPHEMERAL_META_TOOLS.has(toolUse.name)) return true;
  // Условие А: read-only Bash по ЭФФЕКТУ; classifyBash консервативен (редирект/писатель → НЕ readonly).
  if (toolUse.name === 'Bash') {
    const cmd = toolUse.input?.command || '';
    const c = classifyBash(cmd);
    if (!c || c.result !== 'allow' || !/readonly|reading/.test(c.reason || '')) return false;
    // F-A (аудит 2026-06-07): reason «whitelisted reading command(s)» схлопывается,
    // если в цепочке есть ХОТЬ ОДИН читатель (anyReading) — тогда whitelisted-мутатор
    // (composer pint/rector → переписать исходники, php artisan migrate:fresh → дроп БД,
    // pest/npm test → запись, node <script> → произвольный JS), стоящий за `cat x &&`,
    // проскакивал зелёным проходом. Дополнительно требуем, чтобы КАЖДЫЙ сегмент был
    // настоящим читателем (READING_CMDS — единый источник из router-gate), либо команда
    // была одиночным readonly-git (reason это уже подтвердил). Не доверяем схлопнутому reason.
    const tok = tokenizeBash(cmd);
    if (!tok.ok || tok.hasSubshell || !Array.isArray(tok.segments) || tok.segments.length === 0) return false;
    if (tok.segments.length === 1 && tok.segments[0].tokens[0] === 'git') return true;
    // Owner-authorized 2026-06-18: строго read-only удалённый доступ к боевому порталу
    // (ssh liderra-prod '<read>' / gh GET). Достижимо ТОЛЬКО если classifyBash выше вернул
    // allow+reading — а для ssh/gh это даёт исключительно ветка remote-read-allow.mjs
    // (валидный разбор удалённой read-команды; мутации/чужой хост/пайпы → block там же).
    // Локальные reading-команды имеют tokens[0] ∈ READING_CMDS (не ssh/gh) → строка ниже.
    if (tok.segments.length === 1 && (tok.segments[0].tokens[0] === 'ssh' || tok.segments[0].tokens[0] === 'gh')) return true;
    return tok.segments.every((s) => READING_CMDS.has(s.tokens[0]));
  }
  // Исходящие (WebFetch/WebSearch/внешний MCP) и мутирующие — НЕ зелёный проход (пол/судья/стена).
  return false;
}

/** Привести событие инструмента к {op, object} для матча по шагу. */
export function actionOf(toolUse) {
  const op = toolUse?.name;
  const i = toolUse?.input || {};
  let object = '';
  if (op === 'Bash') object = i.command || '';
  else if (op === 'Skill') object = i.skill || '';
  else if (op === 'Task') object = i.subagent_type || '';
  // F1 (2026-06-05): поля выровнены с extractPath (B4) — MCP-писатели несут путь под разными именами.
  else object = i.file_path || i.notebook_path || i.path || i.target_file || i.filename || i.destination || i.dest || i.output_path || i.uri || '';
  return { op, object };
}

/**
 * R-27: указатель шага привязан к plan_id. Чужой план / легаси (голое число) /
 * нет plan_id / нет текущего плана → 0 — перепечать плана НЕ отматывает старый
 * указатель на новый план (иначе шаги нового плана мис-индексируются).
 */
export function resolveStepPtr(stored, currentPlanId, verify = null) {
  if (!currentPlanId || !stored || typeof stored !== 'object') return 0;
  if (stored.plan_id !== currentPlanId) return 0;
  if (verify && !verify(stored)) return 0; // R-19: битая/отсутствующая подпись → сброс
  // R-08 (SE-3 secure default): целое (легаси/depth-1) ИЛИ массив-индексов (дерево); иначе → корень.
  const ptr = stored.ptr;
  if (Number.isInteger(ptr) && ptr >= 0) return ptr;
  if (Array.isArray(ptr) && ptr.length > 0 && ptr.every((n) => Number.isInteger(n) && n >= 0)) return ptr;
  return 0;
}

/**
 * R-19: подпись состояния указателя шага (домен step-ptr). Подмена ptr/plan_id без
 * ключа ломает подпись → resolveStepPtr с verify-колбэком сбросит в 0. Поверх «пола»
 * (runtime-write-deny) — защита-в-глубину; настоящий анти-откат — K6/Машина 4.
 */
export function signStepState(planId, ptr, key) {
  return { plan_id: planId, ptr, sig: signPayload({ plan_id: planId, ptr }, key, RECEIPT_DOMAINS.STEP_PTR) };
}
export function verifyStepState(stored, key) {
  if (!stored || typeof stored !== 'object') return false;
  return verifyReceipt({ plan_id: stored.plan_id, ptr: stored.ptr, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR);
}

/**
 * R-28: номер сессии берётся из stdin-события (канон Claude Code: event.session_id),
 * НЕ из process.env (CLAUDE_SESSION_ID хуку не выставляется → все сессии слипались
 * бы в файлах «-unknown»). Фолбэк: env → 'unknown'.
 */
export function resolveSessionId(event, env = process.env) {
  return (event && event.session_id) || env.CLAUDE_SESSION_ID || 'unknown';
}

/**
 * N3-shared (аудит M1-M4): путь файла указателя шага строится из sessionId. Вынесен
 * в экспортируемый guarded-строитель (как planPath/artifactPath), чтобы guard формы
 * не зависел от порядка вызовов в main() и был тестируем. Бракованный sessionId → throw
 * (в main ловится внешним try/catch → fail-CLOSED).
 */
export function stepStatePath(runtimeDir, sessionId) {
  assertSafeSessionId(sessionId);
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return `${runtimeDir}${sep}plan-step-${sessionId}`;
}

/**
 * W2 (C2, SE5/Д-С2-1): продюсер planAuthorizesPath для гейта ДР-1. Op-АГНОСТИЧНЫЙ
 * матч по нормализованному object листьев плана (любой лист с этим путём авторизует
 * чтение — вид-4 «harness-обязательное»: Read обязателен перед запланированным Edit;
 * строгий op+object матч сломал бы harness-цепочку). Bash-шаги (object=команда) пути
 * НЕ авторизуют. fail-safe: битый план/бросок normalize → false (→ сырьё → блок).
 */
export function buildPlanAuthorizesPath(frozenPlan, { stepPtr = 0, normalize = pathNormalize } = {}) {
  return (path) => {
    try {
      const steps = (frozenPlan && frozenPlan.steps) || [];
      const leaves = treeLeaves(steps);
      // F-C2-2 (sharp-edges C2, решение владельца «без дыр в дисциплине»): scope =
      // ТЕКУЩИЙ лист + ПРОЙДЕННЫЕ (префикс до stepPtr включительно). Будущие шаги НЕ
      // авторизованы (фронт-ран сырья закрыт); перечитка исполненного не ломается.
      // Указатель не резолвится в лист → fail-closed (ничего не авторизовано).
      const cur = treeLeafAt(steps, stepPtr);
      const idx = cur ? leaves.indexOf(cur) : -1;
      if (idx < 0) return false;
      const target = normalize(String(path));
      return leaves.slice(0, idx + 1).some((s) => {
        if (!s || String(s.op) === 'Bash') return false;
        const obj = String(s.object ?? '').trim();
        if (!obj) return false;
        try { return normalize(obj) === target; } catch { return false; }
      });
    } catch { return false; }
  };
}

/** W2: расширение пути для classifyReadingContent (без node:path — зеркало D). */
function extOf(p) {
  const m = String(p || '').match(/\.[^./\\]+$/);
  return m ? m[0].toLowerCase() : '';
}

/**
 * Решение верховного хука. allow только если: семя / зелёный проход ИЛИ действие
 * совпадает с текущим шагом валидного замороженного плана, ссылка шага резолвится
 * в опечатанном артефакте той же версии (закрытая дверь C-5). Иначе block.
 * W2 (нах.F3): в impl-режиме (frozenPlan) Read/Grep проходят гейт ДР-1 (модуль D)
 * ДО observe-only — авторское сырьё вне шага плана блокируется; граф-карта /
 * авторизованный-планом путь — свободны. isCriticProbe в стене всегда false
 * (mentor-derived сигнал сюда не доставляется — Д-С2-2); разговорный режим не
 * затронут (decide зовётся только из impl-ветки decideMode).
 * @returns {{decision:'allow'|'block', reason:string, advanceTo?:number}}
 */
export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0, key, verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
  if (isSeed(toolUse)) return { decision: 'allow', reason: 'seed (bootstrap D12/D13)' };
  if (isPlanDeclaredSkill(toolUse, frozenPlan)) return { decision: 'allow', reason: 'навык объявлен в опломбированном плане (вызов разрешён, указатель не двигается)' };
  // M7 Ф8 (re-plan): авторская запись НОВОГО плана/спеки разрешена и В РЕЖИМЕ РЕАЛИЗАЦИИ —
  // иначе «по ходу работы возникло препятствие, нужно переписать план» не сработает (стена
  // держит старый план, новый не записать). Безопасно: новый план так же судят наставник +
  // судья + freeze-gate; запись черновика власти не даёт до печати (как в разговорном режиме).
  if (isAuthoringWrite(toolUse)) return { decision: 'allow', reason: 'authoring write (re-plan, build-loop) — impl-режим' };
  if (frozenPlan && (toolUse?.name === 'Read' || toolUse?.name === 'Grep')) {
    const p = actionOf(toolUse).object;
    const ev = decideReadEvent({
      ext: extOf(p), path: p, frozenPlan: true,
      planAuthorizesPath: buildPlanAuthorizesPath(frozenPlan, { stepPtr, normalize }),
    });
    if (ev.gate.block) {
      const t = ev.content && ev.content.contentType ? ` [тип: ${ev.content.contentType}]` : '';
      return { decision: 'block', reason: `${ev.gate.reason}${t} (W2)` };
    }
  }
  if (isObserveOnly(toolUse)) return { decision: 'allow', reason: 'observe-only (зелёный проход по способности)' };
  if (!frozenPlan) return { decision: 'block', reason: 'нет замороженного плана — действие вне плана запрещено (default-deny)' };
  if (!verifyImpl(frozenPlan, key)) return { decision: 'block', reason: 'печать плана невалидна (seal/signature) — требуется заново одобрить план' };
  // R-08: структурная валидация дерева плана ДО доверия (fail-CLOSED, SE-2/SE-4)
  if (!validatePlanTree(frozenPlan.steps)) return { decision: 'block', reason: 'структура плана-дерева невалидна (fail-CLOSED)' };
  // R-08: текущий лист по указателю (целое=depth-1 / массив=дерево); спуск через контейнеры, лист только из sealed-дерева (I2)
  const step = treeLeafAt(frozenPlan.steps, stepPtr);
  if (!step) return { decision: 'block', reason: 'план исчерпан / указатель не резолвится в лист' };
  if (!actionMatchesStep(step, actionOf(toolUse), { normalize }))
    return { decision: 'block', reason: `действие не в плане (ожидался шаг ${step.n}: ${step.op} ${step.object})` };
  // Привязка к версии артефакта — только если план опирается на artifact_id:
  if (frozenPlan.artifact_id) {
    if (!frozenArtifact || frozenArtifact.artifact_id !== frozenPlan.artifact_id)
      return { decision: 'block', reason: `артефакт не той версии, под которую печатали план (ждём ${frozenPlan.artifact_id}) — пере-печать` };
    // F2 (2026-06-05): decide самодостаточно проверяет ПЕЧАТЬ артефакта (не только id),
    // чтобы вызов decide() в обход обёртки decideMode нельзя было обмануть подложным артефактом.
    if (!verifyArtifactImpl(frozenArtifact, key))
      return { decision: 'block', reason: 'печать артефакта невалидна (seal) — пере-печать артефакта' };
  }
  // Закрытая дверь (C-5) — F-C (аудит 2026-06-07): шаг с ref ОБЯЗАН резолвиться в
  // опечатанном артефакте НЕЗАВИСИМО от artifact_id плана. Иначе план, замороженный
  // без artifact_id, выключал бы дверь целиком (ref не проверялся). ref без опечатанного
  // артефакта = некогерентная ссылка → блок.
  if (step.ref) {
    if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key))
      return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} требует опечатанного артефакта — вернись в разговор (закрытая дверь C-5)` };
    if (!refResolves(step, frozenArtifact))
      return { decision: 'block', reason: `шаг ${step.n}: ссылка ${step.ref} на опечатанное решение не резолвится — вернись в разговор и пере-печатай (закрытая дверь C-5)` };
  }
  // Δ7 (Машина 5 Пакет 2.5): defense-in-depth М2. Даже валидный шаг плана, который
  // САМ ПО СЕБЕ необратим (force-push/migrate:fresh/reset --hard/rm -rf — classify-destructive
  // floor-набор), стена НЕ благословляет: указатель не двигается, нужна дверь владельца
  // (floor-хук). При незарегистрированном floor стена всё равно не двигает указатель на снос.
  // Δ7+ (floor-desync fix 2026-06-14): стена НЕ двигает указатель на ЛЮБОЕ действие, которое
  // заблокировал бы ПОЛ — не только classify-destructive floor-набор (rm-rf/force-push/migrate),
  // но и content-block правило 8 (node -e/curl/eval — matchBashHardBlacklist), PS, запись в
  // runtime/секрет. Раньше Δ7 смотрел только .floor → content-block-шаг проскакивал со сдвигом
  // указателя, пол рубил исполнение → шаг терялся (desync, потеря safety-шага). Предикат —
  // floorDecide с ПУСТЫМ escape (escape владельца обрабатывается в decideMode ДО decide);
  // order-independent (не зависит от порядка регистрации floor/supreme в settings.json).
  if (floorDecide({ toolUse, escapeGrants: [], escapeConsumed: [], now: 0 }).block) {
    return { decision: 'block', reason: `шаг ${step.n}: пол наложил бы вето на это действие — стена не двигает указатель, нужна дверь владельца (escape) (Δ7+)` };
  }
  // R-08: следующая позиция = depth-first ход по дереву, сериализованный (целое для depth-1 / массив).
  // SE-1: явный флаг advance (runGate гейтит по нему, не по typeof advanceTo).
  let advanceTo;
  try {
    const cur = normalizeToLeaf(frozenPlan.steps, deserializePointer(stepPtr, frozenPlan.steps));
    advanceTo = serializePointer(advanceOverTree(frozenPlan.steps, cur));
  } catch { return { decision: 'block', reason: 'навигация по дереву превысила предел (fail-CLOSED)' }; }
  // Фаза 5 (чистое завершение): этот шаг — последний, если следующий указатель уже не
  // резолвится в лист. runGate тогда сам снимет печать → следующее действие в разговорном
  // (вместо вечного «план исчерпан»). Сбой проверки → не complete (печать держится, безопасно).
  let planComplete = false;
  try { planComplete = !treeLeafAt(frozenPlan.steps, advanceTo); } catch { planComplete = false; }
  return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo, planComplete };
}

/** ✅O18: рассинхрон печатей judge_mode — РОВНО одна 'live-block' (XOR). Обе одинаковы → false. */
export function judgeModeMismatch(planMode, artifactMode) {
  return (planMode === 'live-block') !== (artifactMode === 'live-block');
}

/**
 * Выбор режима стены (C-7). Нет замороженного плана-стройки → разговорный режим:
 * пропускаем только семена/зелёный проход, остальное block. Есть план →
 * реализационный режим, НО бэкстоп: без валидного замороженного артефакта строить нельзя (C-10).
 */
export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key,
  escapeGrants = [], escapeConsumed = [], now = Date.now(),
  verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) {
  // G-1 (α): сквозной аварийный выход владельца — раньше всех плановых проверок.
  // allow БЕЗ advanceTo (указатель не двигается; escape — out-of-band, не шаг плана).
  if (escapeGrantOpen(canonicalAction(toolUse?.name, toolUse?.input || {}), escapeGrants, escapeConsumed, now)) {
    return { decision: 'allow', mode: 'escape', reason: 'разрешено аварийным выходом владельца (floor_escape) — указатель не двигается' };
  }
  // Фаза 5 Task 5.2 (Вариант А): досрочное завершение плана владельцем. Открыт finish-грант
  // (floor_escape с зарезервированной меткой PLAN_FINISH_ACTION — её НЕ порождает ни одно
  // реальное действие) И есть запечатанный план → план завершается: печать снимается
  // (runGate зовёт removeFrozenPlan), возврат в разговорный. Не требует валидного артефакта.
  if (frozenPlan && escapeGrantOpen(PLAN_FINISH_ACTION, escapeGrants, escapeConsumed, now)) {
    return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' };
  }
  if (!frozenPlan) {
    if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (разговорный режим)' };
    return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' };
  }
  if (!frozenArtifact || !verifyArtifactImpl(frozenArtifact, key)) {
    // F-B (аудит 2026-06-07): observe-only (Read/Grep/Glob/readonly-Bash/TodoWrite) пускаем
    // и в этом деградированном состоянии — инвариант finding 9 «смотрящие не душатся» +
    // согласованность с decide() (там observe-only allow безусловно). Бэкстоп держит только мутаторы.
    if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (бэкстоп: артефакт не опечатан)' };
    return { decision: 'block', mode: 'conversational', reason: 'нет опечатанного артефакта разговорной фазы — вернись в разговор (бэкстоп C-10)' };
  }
  // SE-2 (fail-closed whitelist): энфорсмент ТОЛЬКО при live-block на ОБЕИХ печатях.
  // shadow/null/опечатка/любое ≠ 'live-block' → разговорный (мнимая печать не одобряет энфорсмент).
  // Разбор #1 (VA-a/SE-a): не «явный shadow→блок» (fail-OPEN), а whitelist — единственная
  // fail-OPEN сверка в коде убрана; опечатка режима безопасно уходит в разговорный.
  if (frozenPlan.judge_mode !== 'live-block' || frozenArtifact.judge_mode !== 'live-block') {
    // ✅O18: рассинхрон (одна печать live-block, другая нет) — fail-safe направление СОХРАНЕНО
    // (→ разговорный), но ГРОМКО: warn-поле в возврат (owner-резюме гейта-1 покажет, wiring в C).
    const warnFields = judgeModeMismatch(frozenPlan.judge_mode, frozenArtifact.judge_mode)
      ? { warn: true, warnReason: 'judge_mode рассинхрон план≠артефакт — энфорсмент off (O18)' } : {};
    if (isSeed(toolUse) || isObserveOnly(toolUse)) {
      return { decision: 'allow', mode: 'conversational', reason: 'seed/observe (не live-block-печать, SE-2)', ...warnFields };
    }
    return { decision: 'block', mode: 'conversational', reason: 'не live-block-печать (shadow/наблюдение) — не одобрение к энфорсменту (SE-2)', ...warnFields };
  }
  return { ...decide({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize }), mode: 'implementation' };
}

/**
 * Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг.
 * journal/saveStep инъектируются (в main — реальные Node fs).
 */
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
  const toolUse = { name: event.tool_name, input: event.tool_input };
  const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now });
  // FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал
  // (escape:true), указатель НЕ двигается. В отличие от Δ3 для агентских шагов, сбой/отсутствие
  // журнала escape НЕ блокирует (escape санкционирован владельцем — иначе git-заминка снова
  // закирпичила бы дверь). Помеченная escape-запись снимает будущий false-positive реконсилера
  // «action-without-record (обход стены)» для легитимного escape.
  if (r.mode === 'escape') {
    if (typeof journal === 'function') {
      try { journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ }
    }
    return { block: false, message: r.reason };
  }
  // Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать
  // (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем.
  if (r.finishPlan) {
    if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
    return { block: false, message: r.reason };
  }
  // W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается
  // в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме
  // гейта-1 — поведенческая сборка контроллера, owner-activation).
  const withWarn = (msg) => (r.warn ? `${msg} ⚠ ${r.warnReason}` : msg);
  if (r.decision === 'allow' && r.advance === true) {
    // Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения —
    // честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ
    // разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает
    // PostToolUse-реконсилер (8.2). journal-успех = не-false и без исключения (push → length, ок).
    let recorded;
    try {
      recorded = journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null }) !== false;
    } catch { recorded = false; }
    if (!recorded) {
      return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' };
    }
    saveStep(r.advanceTo);
    // Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает
    // печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан»,
    // не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow.
    if (r.planComplete && typeof removeFrozenPlan === 'function') {
      try { removeFrozenPlan(); } catch { /* best-effort */ }
    }
  }
  return { block: r.decision === 'block', message: withWarn(r.reason) };
}

/**
 * Panic-ветка (M7 Фаза 2, правило 7б): сетап main (ключ/план/артефакт/путь-указателя)
 * бросил ДО decideMode → escape владельца всё равно оценён. block:false если открыт
 * escape-грант на действие события; иначе fail-CLOSED block:true.
 */
export function panicEscapeDecision(event, escapeGrants = [], escapeConsumed = [], now = Date.now()) {
  return { block: !escapeAllowsEvent(event, escapeGrants, escapeConsumed, now) };
}

async function main() {
  let event = {}; let escapeGrants = []; let escapeConsumed = [];
  try {
    event = parseEventJson(await readStdin());
    const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
    const { loadFrozenPlan, loadFrozenArtifact, removeFrozenPlan } = await import('./plan-lock.mjs');
    const { journalAppend } = await import('./action-journal.mjs');
    const os = await import('node:os'); const fs = await import('node:fs');
    const runtimeDir = `${os.homedir()}/.claude/runtime`;
    const sess = resolveSessionId(event); // R-28: из stdin-события, не из env
    // M7 Фаза 2 (правило 7б): escape-пропуски грузим РАНО — чтобы panic-ветка в catch
    // имела их, даже если последующий сетап (ключ/план/артефакт/путь) бросит.
    escapeGrants = loadFloorEscapes(sess);   // G-1 α: read-only floor_escape-пропуски
    escapeConsumed = loadConsumed(sess);     // отметки one-shot погашения
    const key = resolveReceiptKey();
    const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir });
    const frozenArtifact = loadFrozenArtifact({ sessionId: sess, runtimeDir });
    const stepPath = stepStatePath(runtimeDir, sess); // N3-shared guard формы sessionId
    let stored = null; try { stored = JSON.parse(fs.readFileSync(stepPath, 'utf8')); } catch {}
    const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, key ? (s) => verifyStepState(s, key) : null); // R-27 привязка + R-19 подпись
    const r = runGate({
      event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed,
      journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }),
      saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано
      removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение
    });
    if (r.block) logGuardBlock(event, 'М2 Стена', r.message);
    exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
  } catch {
    // Panic-ветка (правило 7б): сетап бросил ДО decideMode → escape владельца всё равно оценён.
    const p = panicEscapeDecision(event, escapeGrants, escapeConsumed);
    exitDecision({ block: p.block, message: p.block ? '[supreme-gate] внутренняя ошибка — fail-CLOSED' : undefined });
  }
}

import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
