Files
portal/tools/enforce-reconcile.mjs
T
Дмитрий a0a73d8d3a feat(m5): Пакет 8 — Δ3 честный двухтакт reconcile (8.1 пред-запись + 8.2 реконсилер)
Δ3: убрано обещание «атомарно на исполнении» (PreToolUse не видит факт). Достижимый максимум —
два такта:
- 8.1 (runGate): пред-запись НАМЕРЕНИЯ в журнал ДО allow. Журнал вернул false ИЛИ бросил →
  стена НЕ разрешает (block), указатель не двигается («нет записи → нет действия», явно).
  Backward-compat: push → length (truthy) = успех; только явный false/throw → block.
- 8.2 (enforce-reconcile.mjs, новый): PostToolUse-сверка. reconcileAction — исполненное
  действие без пред-записи → action-without-record (возможен обход). findOrphanIntents —
  пред-записи без исполнения → record-without-action. WARN-уровень (не блок: PreToolUse-пол
  уже отработал, PostToolUse не отменяет исполненное). Чистые функции + fail-quiet I/O main.

+2 (supreme-gate runGate) +5 (reconcile) тестов. Полная tools-only регрессия 2788 + 2 skip
(0 регрессий). Машина 5 (Пакеты 5-8) собрана полностью.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:29:48 +03:00

59 lines
3.7 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';
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 };
}
// I/O-обёртка PostToolUse: сверяет факт-действие с журналом намерений; расхождение → WARN в stderr,
// НИКОГДА не блокирует (exit 0). Fail-quiet. Загрузка журнала/событий — owner-активируемая живая часть;
// здесь — минимальный каркас (реальный парс журнала добавляется при регистрации хука владельцем).
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); }
// Реконсиляция требует загруженного журнала намерений сессии; при регистрации владелец
// прокидывает реальный reader. Без него — каркас не кричит (нет данных = нет сигнала).
void event; void reconcileAction; void findOrphanIntents;
} catch { /* fail-quiet: реконсилер — сигнал, не блок */ }
process.exit(0);
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();