#!/usr/bin/env node /** * enforce-reconcile (Машина 5 Пакет 8, Δ3) — PostToolUse-реконсилер двухтактного reconcile. * * Δ3 честно: PreToolUse-стена (supreme-gate) не видит факт исполнения — она лишь ПРЕД-записывает * намерение в журнал ДО allow (8.1). Достижимый второй такт — PostToolUse-сверка «произошло ровно * записанное»: * reconcileAction — исполненное действие имеет совпадающую пред-запись? нет → action-without-record * (действие проскользнуло мимо пред-записи стены — возможен обход). * findOrphanIntents — пред-записи без соответствующего исполнения → record-without-action. * * WARN-уровень (НЕ блок): PreToolUse-пол уже отработал; расхождение — сигнал владельцу, а не * остановка (PostToolUse не может отменить уже исполненное). Чистые функции + fail-quiet I/O main. */ import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { actionOf } from './enforce-supreme-gate.mjs'; import { loadJournal } from './action-journal.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; function sameAction(entry, action) { return !!entry && !!action && String(entry.op) === String(action.op) && String(entry.object) === String(action.object); } /** Исполненное действие имеет совпадающую пред-запись в журнале намерений? */ export function reconcileAction({ action, journalEntries = [] }) { const matched = (journalEntries || []).some((e) => sameAction(e, action)); if (matched) return { matched: true }; return { matched: false, flag: 'action-without-record', reason: `действие ${action && action.op} «${action && action.object}» исполнено без журнальной пред-записи (возможен обход стены)`, }; } /** Пред-записи (намерения), под которые НЕ было исполнения → сироты (record-without-action). */ export function findOrphanIntents({ journalEntries = [], executedActions = [] }) { const orphans = (journalEntries || []).filter( (e) => !(executedActions || []).some((a) => sameAction(e, a)), ); return { ok: orphans.length === 0, orphans, flag: orphans.length ? 'record-without-action' : null }; } /** * Pure: harness-событие PostToolUse + загруженные журнальные пред-записи (payload'ы намерений) * → WARN-строка (если действие исполнено без пред-записи) или null. Никогда не бросает, не блок. * actionOf реюзится из supreme-gate — единый критерий {op, object}. */ export function reconcileEvent({ event, journalEntries = [] }) { if (!event) return null; const name = event.tool_name || (event.tool_use && event.tool_use.name); if (!name) return null; const action = actionOf({ name, input: event.tool_input || (event.tool_use && event.tool_use.input) || {} }); const r = reconcileAction({ action, journalEntries }); return r.matched ? null : `[reconcile] ⚠️ ${r.reason}`; } // I/O-обёртка PostToolUse: сверяет факт-действие с журналом намерений; расхождение → WARN в stderr, // НИКОГДА не блокирует (exit 0). Fail-quiet. Загрузка журнала — реальная, но реконсилер ИНЕРТЕН до // регистрации хука владельцем (owner-шаг A3); без ключа/файла нет данных = нет сигнала. async function main() { try { let input = ''; for await (const chunk of process.stdin) input += chunk; let event; try { event = JSON.parse(input); } catch { process.exit(0); } const key = resolveReceiptKey(); const sessionId = event.session_id || event.sessionId; let journalEntries = []; if (key && sessionId) { try { const runtimeDir = `${homedir()}/.claude/runtime`; const { entries } = loadJournal({ sessionId, runtimeDir }); journalEntries = entries.map((e) => e && e.payload).filter(Boolean); } catch { journalEntries = []; } } const warn = reconcileEvent({ event, journalEntries }); if (warn) process.stderr.write(warn + '\n'); void findOrphanIntents; } catch { /* fail-quiet: реконсилер — сигнал, не блок */ } process.exit(0); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();