diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index a44fb01c..7e0560c1 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -53,6 +53,18 @@ export function actionOf(toolUse) { return { op, object }; } +/** + * R-27: указатель шага привязан к plan_id. Чужой план / легаси (голое число) / + * нет plan_id / нет текущего плана → 0 — перепечать плана НЕ отматывает старый + * указатель на новый план (иначе шаги нового плана мис-индексируются). + */ +export function resolveStepPtr(stored, currentPlanId) { + if (!currentPlanId || !stored || typeof stored !== 'object') return 0; + if (stored.plan_id !== currentPlanId) return 0; + const n = Number(stored.ptr); + return Number.isInteger(n) && n >= 0 ? n : 0; +} + /** * Решение верховного хука. allow только если: семя / зелёный проход ИЛИ действие * совпадает с текущим шагом валидного замороженного плана, ссылка шага резолвится @@ -126,11 +138,12 @@ async function main() { const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir }); const frozenArtifact = loadFrozenArtifact({ sessionId: sess, runtimeDir }); const stepPath = `${runtimeDir}/plan-step-${sess}`; - let stepPtr = 0; try { stepPtr = parseInt(fs.readFileSync(stepPath, 'utf8'), 10) || 0; } catch {} + let stored = null; try { stored = JSON.parse(fs.readFileSync(stepPath, 'utf8')); } catch {} + const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id); // R-27: привязка к плану const r = runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }), - saveStep: (n) => fs.writeFileSync(stepPath, String(n)), + saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify({ plan_id: frozenPlan?.plan_id ?? null, ptr: n })), }); exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined }); } catch { diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 0d1bbd47..95f4b065 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -5,6 +5,24 @@ import { decide, actionOf } from './enforce-supreme-gate.mjs'; import { runGate } from './enforce-supreme-gate.mjs'; import { decideMode } from './enforce-supreme-gate.mjs'; import { isObserveOnly } from './enforce-supreme-gate.mjs'; +import { resolveStepPtr } from './enforce-supreme-gate.mjs'; + +describe('resolveStepPtr — указатель шага привязан к plan_id (R-27)', () => { + it('тот же план → возвращает сохранённый ptr', () => { + expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P1')).toBe(3); + }); + it('другой план → сброс в 0 (перепечать не отматывает чужой указатель)', () => { + expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, 'P2')).toBe(0); + }); + it('легаси (голое число) / null / без plan_id → 0', () => { + expect(resolveStepPtr(5, 'P1')).toBe(0); + expect(resolveStepPtr(null, 'P1')).toBe(0); + expect(resolveStepPtr({ ptr: 9 }, 'P1')).toBe(0); + }); + it('нет текущего плана → 0', () => { + expect(resolveStepPtr({ plan_id: 'P1', ptr: 3 }, undefined)).toBe(0); + }); +}); describe('isSeed (D12/D13 bootstrap exemption)', () => { it('EnterPlanMode and AskUserQuestion are seeds', () => {