397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
4.9 KiB
JavaScript
87 lines
4.9 KiB
JavaScript
#!/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();
|