Files
portal/tools/plan-lock.mjs
T
2026-06-04 16:48:30 +03:00

116 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/**
* plan-lock — Замок плана (несущий №2, P15-f): заморозка одобренного плана
* хеш-печатью (HMAC из Машины 1). «В плане» — фикция без печати: правка шагов
* меняет sig, что видно владельцу. Детерминированный матч «действие ↔ шаг».
*/
import { createHash } from 'node:crypto';
import fsDefault from 'node:fs';
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';
import { pathNormalize } from './path-normalization.mjs';
/** Стабильный id плана = sha256 канонизированных шагов. */
export function planId(steps) {
return createHash('sha256').update(canonicalJson(steps)).digest('hex');
}
/** Заморозить план: проставить id + версию артефакта + время + подпись-печать.
* artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта). */
export function freezePlan({ steps, artifactId = null, key, nowMs }) {
const id = planId(steps);
const base = { plan_id: id, artifact_id: artifactId, frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps };
return { ...base, sig: signPayload(base, key) };
}
/** Печать цела? (правка шагов/времени/id → false; без ключа/подписи → false). */
export function verifyFrozenPlan(plan, key) {
if (!plan || typeof plan !== 'object') return false;
return verifyReceipt(plan, key);
}
/** Нормализация команды Bash: схлопнуть пробелы, trim. */
function normCommand(c) { return String(c || '').split(/\s+/).filter(Boolean).join(' '); }
/**
* Детерминированный матч действия и шага (P15-a + P15-e): op И object обязаны
* совпасть. Файловые object — через normalize (default pathNormalize); Bash — через
* normCommand. Никакого LLM, никаких зашитых списков.
*/
export function actionMatchesStep(step, action, { normalize = pathNormalize } = {}) {
if (!step || !action) return false;
if (String(step.op) !== String(action.op)) return false;
if (step.op === 'Bash') return normCommand(step.object) === normCommand(action.object);
let a, b;
try { a = normalize(String(step.object)); b = normalize(String(action.object)); }
catch { return false; }
return a === b;
}
/** Шаг по указателю (или null за концом). */
export function nextStep(steps, ptr) {
if (!Array.isArray(steps) || ptr < 0 || ptr >= steps.length) return null;
return steps[ptr];
}
function planPath(runtimeDir, sessionId) {
const sep = runtimeDir.endsWith('/') ? '' : '/';
return `${runtimeDir}${sep}frozen-plan-${sessionId}.json`;
}
export function saveFrozenPlan({ plan, sessionId, runtimeDir, fsImpl = fsDefault }) {
fsImpl.writeFileSync(planPath(runtimeDir, sessionId), JSON.stringify(plan));
}
export function loadFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) {
try { return JSON.parse(fsImpl.readFileSync(planPath(runtimeDir, sessionId), 'utf8')); }
catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
/** Каждое журнальное действие обязано иметь шаг плана; иначе — сирота (пропущен гейт). */
export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) {
const orphans = (journal || []).filter(
(a) => !(steps || []).some((s) => actionMatchesStep(s, a, { normalize }))
);
return { ok: orphans.length === 0, orphans };
}
/** Стабильный id артефакта = sha256 канонизированного содержания. */
export function artifactId(artifact) {
return createHash('sha256').update(canonicalJson(artifact)).digest('hex');
}
/** Заморозить артефакт разговорной фазы (вторая печать). */
export function freezeArtifact({ artifact, key, nowMs }) {
const base = { ...artifact, artifact_id: artifactId(artifact), frozen_at: typeof nowMs === 'number' ? nowMs : Date.now() };
return { ...base, sig: signPayload(base, key) };
}
/** Печать артефакта цела? */
export function verifyFrozenArtifact(artifact, key) {
if (!artifact || typeof artifact !== 'object') return false;
return verifyReceipt(artifact, key);
}
// Персист артефакта (как у плана, Task 3) — нужен рантайму: main() грузит его,
// стена сверяет версию (artifact_id) и резолвит ссылки шагов (закрытая дверь).
function artifactPath(runtimeDir, sessionId) {
const sep = runtimeDir.endsWith('/') ? '' : '/';
return `${runtimeDir}${sep}frozen-artifact-${sessionId}.json`;
}
export function saveFrozenArtifact({ artifact, sessionId, runtimeDir, fsImpl = fsDefault }) {
fsImpl.writeFileSync(artifactPath(runtimeDir, sessionId), JSON.stringify(artifact));
}
export function loadFrozenArtifact({ sessionId, runtimeDir, fsImpl = fsDefault }) {
try { return JSON.parse(fsImpl.readFileSync(artifactPath(runtimeDir, sessionId), 'utf8')); }
catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}
/**
* Закрытая дверь (C-5): если у шага есть ссылка ref на решение артефакта,
* она ОБЯЗАНА резолвиться в опечатанном артефакте. Нет ref → требование снято
* (простой шаг). Есть ref, но раздела нет → false (стена блокирует).
*/
export function refResolves(step, frozenArtifact) {
if (!step || !step.ref) return true;
const sections = (frozenArtifact && frozenArtifact.sections) || {};
return Object.prototype.hasOwnProperty.call(sections, step.ref);
}
export const _internals = { fsDefault };