diff --git a/docs/superpowers/plans/2026-06-17-sp4-fj-pointer-desync-plan.md b/docs/superpowers/plans/2026-06-17-sp4-fj-pointer-desync-plan.md new file mode 100644 index 0000000..fa4cf17 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-sp4-fj-pointer-desync-plan.md @@ -0,0 +1,308 @@ +# SP4 / F-J — двухтактный сдвиг указателя: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:test-driven-development. + +**Goal:** Лечить рассинхрон указателя шага в `enforce-supreme-gate.mjs`: вместо немедленной +фиксации сдвига в PreToolUse — предварительная пометка с подтверждением на следующем такте. + +**Architecture:** Указатель остаётся на текущем шаге (committed `ptr`); ход кладётся как +подписанная пометка `tentative.toPtr`. Следующее действие реконсилит: совпало со следующим +шагом → commit; повтор того же шага → discard; иначе hold. Последний шаг (`planComplete`) — +ранний сдвиг как сейчас. Только PreToolUse; settings.json не трогаем. + +**Tech Stack:** Node ESM, vitest. Файлы: `tools/enforce-supreme-gate.mjs` + +`tools/enforce-supreme-gate.test.mjs`. + +--- + +## Цель + +См. spec `docs/superpowers/specs/2026-06-17-sp4-fj-pointer-desync-design.md`. Реализовать +контракт {#ac1} и алгоритм {#ac2}; крайние случаи {#ac3}; конвенция {#ac4}; критерий {#ac5}. + +--- + +### Task 1: Тесты F-J (RED) + +**Files:** Test: `tools/enforce-supreme-gate.test.mjs` + +- [ ] **Step 1: Дописать describe-блоки** (Edit, добавить в конец файла перед закрытием): + +```js +describe('F-J helpers: signState tentative round-trip', () => { + const key = 'k-test'; + it('подписывает и проверяет состояние с tentative', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(s.tentative).toEqual({ toPtr: 3 }); + expect(verifyStepState(s, key)).toBe(true); + }); + it('без tentative — формат и подпись как прежде (обратная совместимость)', () => { + const s = signStepState('plan-A', 2, key); + expect('tentative' in s).toBe(false); + expect(verifyStepState(s, key)).toBe(true); + expect(s).toEqual(signStepState('plan-A', 2, key)); // детерминирован + }); + it('подделка tentative ломает подпись', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(verifyStepState({ ...s, tentative: { toPtr: 9 } }, key)).toBe(false); + }); +}); + +describe('F-J helpers: resolveTentative', () => { + const key = 'k-test'; + it('валидная пометка своего плана → toPtr', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(resolveTentative(s, 'plan-A', (x) => verifyStepState(x, key))).toBe(3); + }); + it('чужой план → null', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(resolveTentative(s, 'plan-B', (x) => verifyStepState(x, key))).toBe(null); + }); + it('нет пометки → null', () => { + const s = signStepState('plan-A', 2, key); + expect(resolveTentative(s, 'plan-A', (x) => verifyStepState(x, key))).toBe(null); + }); + it('битая подпись → null', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(resolveTentative({ ...s, tentative: { toPtr: 9 } }, 'plan-A', (x) => verifyStepState(x, key))).toBe(null); + }); +}); + +describe('F-J helpers: computeReconcile', () => { + const plan = { steps: [ + { n: 1, op: 'Bash', object: 'echo a' }, + { n: 2, op: 'Write', object: '/tmp/x.txt' }, + { n: 3, op: 'Bash', object: 'echo b' }, + ] }; + it('нет пометки → none, effPtr=committed', () => { + const r = computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Bash', object: 'echo a' }, committedPtr: 0, tentativeToPtr: null }); + expect(r).toEqual({ state: 'none', effPtr: 0 }); + }); + it('действие = шаг toPtr → commit', () => { + const r = computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/x.txt' }, committedPtr: 0, tentativeToPtr: 1 }); + expect(r).toEqual({ state: 'commit', effPtr: 1 }); + }); + it('повтор шага ptr → discard', () => { + const r = computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Bash', object: 'echo a' }, committedPtr: 0, tentativeToPtr: 1 }); + expect(r).toEqual({ state: 'discard', effPtr: 0 }); + }); + it('ни то ни другое (observe-op) → hold', () => { + const r = computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Read', object: '/tmp/x.txt' }, committedPtr: 0, tentativeToPtr: 1 }); + expect(r).toEqual({ state: 'hold', effPtr: 0 }); + }); +}); + +describe('F-J runGate deferral', () => { + const key = 'k-test'; + const plan = { plan_id: 'P', judge_mode: 'live-block', steps: [ + { n: 1, op: 'Bash', object: 'echo a' }, + { n: 2, op: 'Write', object: '/tmp/x.txt' }, + ] }; + const art = { artifact_id: undefined, judge_mode: 'live-block' }; + const mk = () => { const calls = []; return { saveStep: (p, t) => calls.push([p, t]), calls, journal: () => true, removeFrozenPlan: () => calls.push(['remove']) }; }; + const ev = (name, input) => ({ tool_name: name, tool_input: input }); + it('не последний шаг: saveStep с пометкой, не голым advanceTo', () => { + const io = mk(); + runGate({ event: ev('Bash', { command: 'echo a' }), frozenPlan: plan, frozenArtifact: art, stepPtr: 0, tentativeToPtr: null, key, verifyImpl: () => true, verifyArtifactImpl: () => true, ...io }); + const save = io.calls.find((c) => c[0] === 0); + expect(save).toEqual([0, { toPtr: 1 }]); + }); + it('пометка открыта + действие следующего шага → commit saveStep(toPtr,null)', () => { + const io = mk(); + runGate({ event: ev('Write', { file_path: '/tmp/x.txt' }), frozenPlan: plan, frozenArtifact: art, stepPtr: 0, tentativeToPtr: 1, key, verifyImpl: () => true, verifyArtifactImpl: () => true, ...io }); + expect(io.calls.some((c) => c[0] === 1 && c[1] === null)).toBe(true); + }); + it('пометка открыта + повтор шага → discard saveStep(ptr,null)', () => { + const io = mk(); + const r = runGate({ event: ev('Bash', { command: 'echo a' }), frozenPlan: plan, frozenArtifact: art, stepPtr: 0, tentativeToPtr: 1, key, verifyImpl: () => true, verifyArtifactImpl: () => true, ...io }); + expect(io.calls.some((c) => c[0] === 0 && c[1] === null)).toBe(true); + expect(r.block).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Прогнать тесты F-J — ожидать RED** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs -t "F-J" --config vitest.config.tools.mjs` +Expected: FAIL (resolveTentative/computeReconcile не определены; signStepState 3-арг; runGate не откладывает). + +--- + +### Task 2: Помощники — подпись с пометкой, resolveTentative, computeReconcile + +**Files:** Modify: `tools/enforce-supreme-gate.mjs` (блок `signStepState`/`verifyStepState`) + +- [ ] **Step 3: Расширить подпись + добавить чистые помощники** (Edit, заменить блок + `signStepState`+`verifyStepState`): + +```js +export function signStepState(planId, ptr, key, tentative = null) { + const payload = tentative ? { plan_id: planId, ptr, tentative } : { plan_id: planId, ptr }; + const sig = signPayload(payload, key, RECEIPT_DOMAINS.STEP_PTR); + return tentative ? { plan_id: planId, ptr, tentative, sig } : { plan_id: planId, ptr, sig }; +} +export function verifyStepState(stored, key) { + if (!stored || typeof stored !== 'object') return false; + const payload = stored.tentative + ? { plan_id: stored.plan_id, ptr: stored.ptr, tentative: stored.tentative } + : { plan_id: stored.plan_id, ptr: stored.ptr }; + return verifyReceipt({ ...payload, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR); +} + +/** F-J: указатель ПРЕДВАРИТЕЛЬНОЙ пометки (зеркало resolveStepPtr). Совпадение plan_id + + * валидная подпись + корректный toPtr (целое/массив-индексов ≥0); иначе null. */ +export function resolveTentative(stored, currentPlanId, verify = null) { + if (!currentPlanId || !stored || typeof stored !== 'object') return null; + if (stored.plan_id !== currentPlanId) return null; + if (verify && !verify(stored)) return null; + const t = stored.tentative; + if (!t || typeof t !== 'object') return null; + const toPtr = t.toPtr; + if (Number.isInteger(toPtr) && toPtr >= 0) return toPtr; + if (Array.isArray(toPtr) && toPtr.length > 0 && toPtr.every((n) => Number.isInteger(n) && n >= 0)) return toPtr; + return null; +} + +/** F-J: реконсиляция открытой пометки против входного действия (чистая). + * commit (= шаг toPtr) / discard (= повтор шага committedPtr) / hold / none. */ +export function computeReconcile({ frozenPlan, incomingAction, committedPtr, tentativeToPtr, normalize }) { + if (tentativeToPtr == null) return { state: 'none', effPtr: committedPtr }; + const steps = (frozenPlan && frozenPlan.steps) || []; + const toStep = treeLeafAt(steps, tentativeToPtr); + if (toStep && actionMatchesStep(toStep, incomingAction, { normalize })) { + return { state: 'commit', effPtr: tentativeToPtr }; + } + const fromStep = treeLeafAt(steps, committedPtr); + if (fromStep && actionMatchesStep(fromStep, incomingAction, { normalize })) { + return { state: 'discard', effPtr: committedPtr }; + } + return { state: 'hold', effPtr: committedPtr }; +} +``` + +- [ ] **Step 4: Прогнать помощники — ожидать GREEN** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs -t "F-J helpers" --config vitest.config.tools.mjs` +Expected: PASS (signState/resolveTentative/computeReconcile). + +--- + +### Task 3: runGate — реконсиляция-первой + отложенная пометка + +**Files:** Modify: `tools/enforce-supreme-gate.mjs` (функция `runGate`) + +- [ ] **Step 5: Перестроить runGate** (Edit, заменить тело `runGate`): + +```js +export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { + const toolUse = { name: event.tool_name, input: event.tool_input }; + const incomingAction = actionOf(toolUse); + // F-J: сверить открытую пометку с входным действием ДО решения. + const rec = computeReconcile({ frozenPlan, incomingAction, committedPtr: stepPtr, tentativeToPtr, normalize }); + const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr: rec.effPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); + // escape / finishPlan — out-of-band: пометку и указатель не трогаем. + if (r.mode === 'escape') { + if (typeof journal === 'function') { + try { journal({ op: toolUse.name, object: incomingAction.object, step: rec.effPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ } + } + return { block: false, message: r.reason }; + } + if (r.finishPlan) { + if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } + return { block: false, message: r.reason }; + } + // Применить реконсиляцию (in-band): commit/discard фиксируют новый committed-указатель, + // пометку снимают (saveStep с tentative=null). + if (rec.state === 'commit' || rec.state === 'discard') saveStep(rec.effPtr, null); + const withWarn = (msg) => (r.warn ? `${msg} ⚠ ${r.warnReason}` : msg); + if (r.decision === 'allow' && r.advance === true) { + let recorded; + try { + recorded = journal({ op: toolUse.name, object: incomingAction.object, step: rec.effPtr + 1, at: event.nowMs ?? null }) !== false; + } catch { recorded = false; } + if (!recorded) { + return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' }; + } + if (r.planComplete) { + // Последний шаг: ранний сдвиг + снятие печати (mid-plan-клина нет; блок отдаёт в разговор). + saveStep(r.advanceTo, null); + if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } + } else { + // F-J: ПРЕДВАРИТЕЛЬНАЯ пометка вместо немедленного сдвига. Указатель остаётся на текущем + // шаге; toPtr подтвердится следующим действием (commit) или сбросится (discard). + saveStep(rec.effPtr, { toPtr: r.advanceTo }); + } + } + return { block: r.decision === 'block', message: withWarn(r.reason) }; +} +``` + +- [ ] **Step 6: Прогнать runGate-тесты — ожидать GREEN** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs -t "F-J runGate" --config vitest.config.tools.mjs` +Expected: PASS (deferral / commit / discard). + +--- + +### Task 4: main() — проводка пометки + +**Files:** Modify: `tools/enforce-supreme-gate.mjs` (блок чтения указателя в `main`) + +- [ ] **Step 7: Врезать resolveTentative + 2-арг saveStep в main** (Edit, заменить блок + от `const stepPtr = resolveStepPtr(...)` до закрытия вызова `runGate({...})`): + +```js + const verifyCb = key ? (s) => verifyStepState(s, key) : null; + const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, verifyCb); // R-27 + R-19 + const tentativeToPtr = resolveTentative(stored, frozenPlan?.plan_id, verifyCb); // F-J двухтакт + const r = runGate({ + event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr, key, escapeGrants, escapeConsumed, + journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }), + saveStep: (n, tentative = null) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key, tentative))), // R-19 + F-J + removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), + }); +``` + +- [ ] **Step 8: Прогнать весь файл супрем-гейта — ожидать GREEN** + +Run: `npx vitest run tools/enforce-supreme-gate.test.mjs --config vitest.config.tools.mjs` +Expected: PASS (F-J + существующие, адаптированные под 2-арг saveStep). + +- [ ] **Step 9: Полная регрессия** + +Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Expected: PASS (база 4081 passed + 2 skip + новые F-J). + +--- + +## Self-Review + +- **Покрытие spec:** {#ac1} подпись+пометка → Task 2 Step 3; {#ac2} resolveTentative/ + computeReconcile/runGate/main → Task 2–4; {#ac3} крайние случаи → тесты Task 1 + (commit/discard/hold/observe) + planComplete-ветка runGate; {#ac4} конвенция → экспорт + чистых функций, без новых зависимостей; {#ac5} критерий → Steps 2/4/6/8/9. +- **Плейсхолдеры:** нет — весь код приведён. +- **Согласованность имён:** `resolveTentative`, `computeReconcile`, `signStepState(…,tentative)`, + `verifyStepState`, `saveStep(n, tentative)` — едины во всех тасках. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Edit","object":"tools/enforce-supreme-gate.test.mjs","ref":"ac5"}, + {"op":"Bash","object":"npx vitest run tools/enforce-supreme-gate.test.mjs -t \"F-J\" --config vitest.config.tools.mjs","ref":"ac5"}, + {"op":"Edit","object":"tools/enforce-supreme-gate.mjs","ref":"ac1"}, + {"op":"Bash","object":"npx vitest run tools/enforce-supreme-gate.test.mjs -t \"F-J helpers\" --config vitest.config.tools.mjs","ref":"ac2"}, + {"op":"Edit","object":"tools/enforce-supreme-gate.mjs","ref":"ac2"}, + {"op":"Bash","object":"npx vitest run tools/enforce-supreme-gate.test.mjs -t \"F-J runGate\" --config vitest.config.tools.mjs","ref":"ac2"}, + {"op":"Edit","object":"tools/enforce-supreme-gate.mjs","ref":"ac2"}, + {"op":"Bash","object":"npx vitest run tools/enforce-supreme-gate.test.mjs --config vitest.config.tools.mjs","ref":"ac5"}, + {"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs --no-file-parallelism","ref":"ac5"} +] +``` + +```verified-context-json +[{"id":"pc1","kind":"EXTRACTED","ref":"tools/enforce-supreme-gate.mjs","anchor":"saveStep(r.advanceTo)"}] +``` diff --git a/docs/superpowers/specs/2026-06-17-sp4-fj-pointer-desync-design.md b/docs/superpowers/specs/2026-06-17-sp4-fj-pointer-desync-design.md new file mode 100644 index 0000000..eb77c49 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-sp4-fj-pointer-desync-design.md @@ -0,0 +1,147 @@ +# SP4 / F-J — двухтактный сдвиг указателя шага (лечение рассинхрона) + +**Дата:** 2026-06-17 +**Модуль:** `tools/enforce-supreme-gate.mjs` (Машина 2, PreToolUse). +**Статус:** дизайн под реализацию (TDD). + +## Цель + +Указатель шага опечатанного плана сейчас продвигается в `runGate` синхронно с +разрешением действия — то есть в PreToolUse, **до** фактического исполнения +инструмента (`saveStep(r.advanceTo)`). Если более поздний со-хук в цепочке +(`enforce-domain-skill-discipline`, `enforce-normative-content-rules §6`) или отказ +владельца в permission-промпте роняют действие, инструмент не исполняется, а указатель +уже ушёл вперёд. Возникает рассинхрон: следующий матч ждёт шаг N+1, повтор шага N +блокируется — план встаёт колом. + +Цель — сделать сдвиг указателя **двухтактным внутри одного владельца-хука**: PreToolUse +вычисляет ход как сейчас, но фиксирует его не сразу, а **предварительной пометкой**; +коммит происходит на следующем такте, когда по поведению участника видно, что прошлый +шаг реально исполнился. Решение exit-агностично (красный тест TDD не ломается) и не +зависит ни от порядка хуков, ни от их параллельности — в духе уже принятого в коде +order-independent-предиката пола (Δ7+). + +## Контракт {#ac1} + +Состояние указателя в подписанном файле расширяется опциональным полем `tentative`: + +- Хранимое состояние: `{ plan_id, ptr, sig }` — как сейчас (зафиксированный указатель). +- При открытой предварительной пометке: `{ plan_id, ptr, tentative: { toPtr }, sig }`, + где `ptr` — по-прежнему зафиксированный (committed) указатель текущего шага, а + `toPtr` — сериализованный указатель, КУДА сдвинуться, когда прошлый шаг подтвердится. +- Подпись (`RECEIPT_DOMAINS.STEP_PTR`) покрывает `tentative`: подделать/добавить пометку + без ключа нельзя (битая подпись → сброс в 0 / null, как у `ptr`). +- **Обратная совместимость:** когда `tentative` отсутствует, подписываемый payload и формат + файла байт-в-байт совпадают с текущими (поле в объект не добавляется) — старые хранимые + состояния и существующие тесты подписи не ломаются. + +Реконсиляция пометки на каждом такте даёт одно из трёх: + +- **commit** — входное действие совпадает с шагом по `toPtr` (участник делает следующий + шаг ⇒ прошлый исполнился): зафиксировать указатель в `toPtr`, пометку снять. +- **discard** — входное действие совпадает с шагом по `ptr` (повтор того же шага ⇒ прошлый + был заблокирован, не исполнился): пометку снять, указатель оставить на `ptr`. +- **hold** — ни то, ни другое: пометку сохранить, указатель оставить на `ptr` (стена + заблокирует внеплановое действие против текущего шага; следующий такт реконсилит снова). + +Различимость commit/discard опирается на правило судьи DR-1 (нет двух идентичных шагов +подряд в опечатанном плане): «то же действие снова» однозначно = повтор заблокированного шага. + +## Алгоритм {#ac2} + +Новые чистые функции (экспортируются, тестируются в изоляции): + +- `resolveTentative(stored, currentPlanId, verify)` — зеркало `resolveStepPtr` для пометки: + возвращает `toPtr` (целое или массив-индексов ≥0) при совпадении `plan_id` и валидной + подписи; иначе `null` (нет плана / чужой план / битая подпись / нет пометки). +- `computeReconcile({ frozenPlan, incomingAction, committedPtr, tentativeToPtr, normalize })` + → `{ state: 'none'|'commit'|'discard'|'hold', effPtr }`: + - `tentativeToPtr == null` → `{ state:'none', effPtr: committedPtr }`; + - `treeLeafAt(steps, tentativeToPtr)` совпадает с `incomingAction` (`actionMatchesStep`) → + `{ state:'commit', effPtr: tentativeToPtr }`; + - иначе `treeLeafAt(steps, committedPtr)` совпадает с `incomingAction` → + `{ state:'discard', effPtr: committedPtr }`; + - иначе `{ state:'hold', effPtr: committedPtr }`. + +`signStepState(planId, ptr, key, tentative = null)` и `verifyStepState(stored, key)` +расширяются так, что при `tentative` (truthy) payload = `{ plan_id, ptr, tentative }`, +иначе payload = `{ plan_id, ptr }` (как сейчас). `verifyStepState` строит тот же payload по +наличию `stored.tentative`. + +`runGate` перестраивается (единый владелец указателя, PreToolUse): + +1. `incomingAction = actionOf(toolUse)`; `rec = computeReconcile(...)` по `stepPtr` + (committed) и `tentativeToPtr`. +2. `r = decideMode({ ..., stepPtr: rec.effPtr, ... })` — решение по эффективному указателю. +3. `escape` / `finishPlan` — out-of-band: пометку и указатель НЕ трогаем (как сейчас); + escape best-effort журналит и возвращает allow; finishPlan снимает печать. +4. Иначе применить реконсиляцию: `commit`/`discard` → `saveStep(rec.effPtr, null)` + (зафиксировать новый committed-указатель, пометку снять). +5. На `allow + advance`: пред-запись намерения в журнал (Δ3, без изменений). Затем: + - `planComplete === true` (последний шаг): `saveStep(r.advanceTo, null)` + + `removeFrozenPlan` немедленно — mid-plan-клина на последнем шаге нет (следующего шага + не существует; блок последнего шага со-хуком отдаёт участника в разговорный режим, не + в тупик), поэтому ранний сдвиг безопасен и проще; + - иначе: `saveStep(rec.effPtr, { toPtr: r.advanceTo })` — committed-указатель остаётся на + текущем шаге, пометка `toPtr` ждёт подтверждения следующим действием. + +`main()`: рядом с `stepPtr = resolveStepPtr(...)` вычислить +`tentativeToPtr = resolveTentative(stored, frozenPlan?.plan_id, verifyCb)`; передать в +`runGate`; инъектируемый `saveStep` принимает `(ptr, tentative)` и зовёт +`signStepState(planId, ptr, key, tentative)`. **Регистрация хука не меняется** — только +PreToolUse (PostToolUse не требуется). + +## Крайние случаи {#ac3} + +- **Красный тест TDD** (`vitest` exit≠0, не последний шаг): инструмент исполнился, участник + переходит к следующему шагу (Write реализации между красным и зелёным прогоном) → следующее + действие совпадает с `toPtr` → commit. Красный шаг не теряется (не зависим от PostToolUse). +- **Со-хук блокирует / отказ владельца:** инструмент не исполнился, участник повторяет то же + действие → совпадает с `ptr` → discard, указатель остаётся на месте. Клина нет. +- **Последний шаг:** `planComplete` → ранний сдвиг + снятие печати (как сейчас). Если последний + шаг заблокирован со-хуком — печать снята, участник в разговорном режиме (нормальный блок + «разговорный режим», не тупик). +- **observe-only / seed / authoring между шагами:** `decideMode` пропускает их до шаговой + логики; в `computeReconcile` их op не совпадает ни с `toPtr`-, ни с `ptr`-шагом → `hold`, + пометка сохраняется до следующего настоящего шагового действия. +- **Внеплановое действие (ни next, ни retry):** `hold` — пометка цела, действие блокируется + против текущего шага; самоисцеление на следующем корректном действии. +- **Битая/чужая подпись пометки:** `resolveTentative` → `null` (fail-CLOSED): ведём себя как + без пометки (committed-указатель — единственный источник истины). + +## Конвенция {#ac4} + +ES-модуль `tools/enforce-supreme-gate.mjs` (как есть). Новые чистые функции +(`resolveTentative`, `computeReconcile`) экспортируются и тестируемы в изоляции, как +текущие `decide`/`decideMode`/`runGate`. Инъекция I/O (`journal`/`saveStep`/ +`removeFrozenPlan`) сохраняется; `saveStep` получает второй параметр `tentative`. Без новых +зависимостей и без правки формата подписи при отсутствии пометки. `decide`/`decideMode`/ +`actionOf`/`resolveStepPtr` семантически не меняются. Правка settings.json не требуется. + +## Критерий приёмки {#ac5} + +TDD-тесты (новый describe в `tools/enforce-supreme-gate.test.mjs`): + +- `signStepState`/`verifyStepState` с `tentative`: round-trip подписи; без `tentative` — + формат и подпись идентичны прежним (обратная совместимость). +- `resolveTentative`: валидная пометка → `toPtr`; чужой plan_id / битая подпись / нет + пометки → `null`. +- `computeReconcile`: `none` (нет пометки), `commit` (действие = шаг `toPtr`), + `discard` (действие = шаг `ptr`), `hold` (ни то ни другое / observe-op). +- `runGate` (инъекция fake saveStep/journal): + - не последний шаг, allow+advance → `saveStep` вызван с `(ptr, {toPtr})`, НЕ с голым + advanceTo (указатель не зафиксирован вперёд); + - открытая пометка + действие следующего шага → commit: `saveStep(toPtr, null)`; + - открытая пометка + повтор того же шага → discard: `saveStep(ptr, null)`, блок если + шаг не совпал бы; указатель не ушёл вперёд; + - последний шаг (planComplete) → `saveStep(advanceTo, null)` + `removeFrozenPlan`; + - escape/finishPlan → пометка не трогается. +- Регрессионный сценарий рассинхрона: allow+advance без немедленного коммита advanceTo → + «исполнение не случилось» (повтор) → указатель остался на месте. +- Полная регрессия tools GREEN: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` + (база 4081 passed + 2 skip; новые тесты сверху, существующие адаптируются под второй + параметр `saveStep`). + +```verified-context-json +[{"id":"vc1","kind":"EXTRACTED","ref":"tools/enforce-supreme-gate.mjs","anchor":"saveStep(r.advanceTo)"}] +``` diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index c97412a..f2f7c87 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -136,12 +136,56 @@ export function resolveStepPtr(stored, currentPlanId, verify = null) { * ключа ломает подпись → resolveStepPtr с verify-колбэком сбросит в 0. Поверх «пола» * (runtime-write-deny) — защита-в-глубину; настоящий анти-откат — K6/Машина 4. */ -export function signStepState(planId, ptr, key) { - return { plan_id: planId, ptr, sig: signPayload({ plan_id: planId, ptr }, key, RECEIPT_DOMAINS.STEP_PTR) }; +export function signStepState(planId, ptr, key, tentative = null) { + const payload = tentative ? { plan_id: planId, ptr, tentative } : { plan_id: planId, ptr }; + const sig = signPayload(payload, key, RECEIPT_DOMAINS.STEP_PTR); + return tentative ? { plan_id: planId, ptr, tentative, sig } : { plan_id: planId, ptr, sig }; } export function verifyStepState(stored, key) { if (!stored || typeof stored !== 'object') return false; - return verifyReceipt({ plan_id: stored.plan_id, ptr: stored.ptr, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR); + const payload = stored.tentative + ? { plan_id: stored.plan_id, ptr: stored.ptr, tentative: stored.tentative } + : { plan_id: stored.plan_id, ptr: stored.ptr }; + return verifyReceipt({ ...payload, sig: stored.sig }, key, RECEIPT_DOMAINS.STEP_PTR); +} + +/** + * F-J: указатель ПРЕДВАРИТЕЛЬНОЙ пометки (зеркало resolveStepPtr). Возвращает toPtr + * (целое / массив-индексов ≥0) при совпадении plan_id и валидной подписи; иначе null + * (нет плана / чужой план / битая подпись / нет пометки) — fail-CLOSED. + */ +export function resolveTentative(stored, currentPlanId, verify = null) { + if (!currentPlanId || !stored || typeof stored !== 'object') return null; + if (stored.plan_id !== currentPlanId) return null; + if (verify && !verify(stored)) return null; + const t = stored.tentative; + if (!t || typeof t !== 'object') return null; + const toPtr = t.toPtr; + if (Number.isInteger(toPtr) && toPtr >= 0) return toPtr; + if (Array.isArray(toPtr) && toPtr.length > 0 && toPtr.every((n) => Number.isInteger(n) && n >= 0)) return toPtr; + return null; +} + +/** + * F-J: реконсиляция открытой пометки против входного действия (чистая). Двухтактный сдвиг: + * commit — действие = шаг по toPtr (участник делает следующий шаг ⇒ прошлый исполнился); + * discard — действие = шаг по committedPtr (повтор ⇒ прошлый был заблокирован, не исполнился); + * hold — ни то, ни другое (пометку держим, действие блокируется против текущего шага); + * none — пометки нет. + * Различимость commit/discard опирается на DR-1 (нет двух идентичных шагов подряд). + */ +export function computeReconcile({ frozenPlan, incomingAction, committedPtr, tentativeToPtr, normalize }) { + if (tentativeToPtr == null) return { state: 'none', effPtr: committedPtr }; + const steps = (frozenPlan && frozenPlan.steps) || []; + const toStep = treeLeafAt(steps, tentativeToPtr); + if (toStep && actionMatchesStep(toStep, incomingAction, { normalize })) { + return { state: 'commit', effPtr: tentativeToPtr }; + } + const fromStep = treeLeafAt(steps, committedPtr); + if (fromStep && actionMatchesStep(fromStep, incomingAction, { normalize })) { + return { state: 'discard', effPtr: committedPtr }; + } + return { state: 'hold', effPtr: committedPtr }; } /** @@ -346,48 +390,57 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k * Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг. * journal/saveStep инъектируются (в main — реальные Node fs). */ -export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { +export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { const toolUse = { name: event.tool_name, input: event.tool_input }; - const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); + const incomingAction = actionOf(toolUse); + // F-J: двухтактный сдвиг. Сверить открытую ПРЕДВАРИТЕЛЬНУЮ пометку с входным действием ДО + // решения: commit (= шаг по toPtr → прошлый исполнился) / discard (= повтор шага → прошлый был + // заблокирован, не исполнился) / hold / none. Решение принимается по эффективному указателю. + const rec = computeReconcile({ frozenPlan, incomingAction, committedPtr: stepPtr, tentativeToPtr, normalize }); + const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr: rec.effPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); // FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал - // (escape:true), указатель НЕ двигается. В отличие от Δ3 для агентских шагов, сбой/отсутствие - // журнала escape НЕ блокирует (escape санкционирован владельцем — иначе git-заминка снова - // закирпичила бы дверь). Помеченная escape-запись снимает будущий false-positive реконсилера - // «action-without-record (обход стены)» для легитимного escape. + // (escape:true), указатель И пометку НЕ трогаем (escape — не шаг плана). Сбой журнала escape + // НЕ блокирует (санкционирован владельцем). Помеченная escape-запись снимает будущий + // false-positive реконсилера «action-without-record» для легитимного escape. if (r.mode === 'escape') { if (typeof journal === 'function') { - try { journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ } + try { journal({ op: toolUse.name, object: incomingAction.object, step: rec.effPtr, at: event.nowMs ?? null, escape: true }); } catch { /* best-effort */ } } return { block: false, message: r.reason }; } // Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать - // (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем. + // (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель/пометку не трогаем. if (r.finishPlan) { if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } return { block: false, message: r.reason }; } + // F-J: применить реконсиляцию пометки (только in-band). commit/discard фиксируют новый + // committed-указатель и снимают пометку (saveStep с tentative=null). + if (rec.state === 'commit' || rec.state === 'discard') saveStep(rec.effPtr, null); // W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается - // в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме - // гейта-1 — поведенческая сборка контроллера, owner-activation). + // в message вывода хука (владелец видит «энфорсмент off» громко). const withWarn = (msg) => (r.warn ? `${msg} ⚠ ${r.warnReason}` : msg); if (r.decision === 'allow' && r.advance === true) { // Δ3 (8.1): пред-запись НАМЕРЕНИЯ в журнал ДО allow (PreToolUse не видит факт исполнения — // честный максимум: «нет записи → нет действия»). Журнал вернул false ИЛИ бросил → стена НЕ - // разрешает (block), указатель НЕ двигается. Сверку «произошло ровно записанное» делает - // PostToolUse-реконсилер (8.2). journal-успех = не-false и без исключения (push → length, ок). + // разрешает (block), сдвига/пометки нет. Сверку делает PostToolUse-реконсилер (8.2). let recorded; try { - recorded = journal({ op: toolUse.name, object: actionOf(toolUse).object, step: stepPtr + 1, at: event.nowMs ?? null }) !== false; + recorded = journal({ op: toolUse.name, object: incomingAction.object, step: rec.effPtr + 1, at: event.nowMs ?? null }) !== false; } catch { recorded = false; } if (!recorded) { return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' }; } - saveStep(r.advanceTo); - // Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает - // печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан», - // не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow. - if (r.planComplete && typeof removeFrozenPlan === 'function') { - try { removeFrozenPlan(); } catch { /* best-effort */ } + if (r.planComplete) { + // Последний шаг: ранний сдвиг + снятие печати немедленно (mid-plan-клина нет — следующего + // шага не существует; блок последнего шага со-хуком отдаёт участника в разговорный режим, + // не в тупик). Best-effort: сбой снятия НЕ ломает allow. + saveStep(r.advanceTo, null); + if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } + } else { + // F-J: ПРЕДВАРИТЕЛЬНАЯ пометка вместо немедленного сдвига. Committed-указатель остаётся на + // текущем шаге; toPtr подтвердится следующим действием (commit) либо сбросится (discard). + saveStep(rec.effPtr, { toPtr: r.advanceTo }); } } return { block: r.decision === 'block', message: withWarn(r.reason) }; @@ -421,11 +474,13 @@ async function main() { const frozenArtifact = loadFrozenArtifact({ sessionId: sess, runtimeDir }); const stepPath = stepStatePath(runtimeDir, sess); // N3-shared guard формы sessionId let stored = null; try { stored = JSON.parse(fs.readFileSync(stepPath, 'utf8')); } catch {} - const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, key ? (s) => verifyStepState(s, key) : null); // R-27 привязка + R-19 подпись + const verifyCb = key ? (s) => verifyStepState(s, key) : null; + const stepPtr = resolveStepPtr(stored, frozenPlan?.plan_id, verifyCb); // R-27 привязка + R-19 подпись + const tentativeToPtr = resolveTentative(stored, frozenPlan?.plan_id, verifyCb); // F-J двухтактный сдвиг const r = runGate({ - event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed, + event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr, key, escapeGrants, escapeConsumed, journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }), - saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано + saveStep: (n, tentative = null) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key, tentative))), // R-19 + F-J removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение }); if (r.block) logGuardBlock(event, 'М2 Стена', r.message); diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 1c932cf..012a4ef 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -802,3 +802,98 @@ describe('Фаза 5 — чистое завершение плана (стен expect(r.block).toBe(false); }); }); + +// ── F-J: двухтактный сдвиг указателя (предварительная пометка + подтверждение) ── +import { resolveTentative, computeReconcile } from './enforce-supreme-gate.mjs'; + +describe('F-J helpers: signState tentative round-trip', () => { + const key = 'k-fj'; + it('подписывает и проверяет состояние с tentative', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(s.tentative).toEqual({ toPtr: 3 }); + expect(verifyStepState(s, key)).toBe(true); + }); + it('без tentative — формат и подпись как прежде (обратная совместимость)', () => { + const s = signStepState('plan-A', 2, key); + expect('tentative' in s).toBe(false); + expect(verifyStepState(s, key)).toBe(true); + expect(s).toEqual(signStepState('plan-A', 2, key)); + }); + it('подделка tentative ломает подпись', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(verifyStepState({ ...s, tentative: { toPtr: 9 } }, key)).toBe(false); + }); +}); + +describe('F-J helpers: resolveTentative', () => { + const key = 'k-fj'; + const v = (x) => verifyStepState(x, key); + it('валидная пометка своего плана → toPtr', () => { + expect(resolveTentative(signStepState('plan-A', 2, key, { toPtr: 3 }), 'plan-A', v)).toBe(3); + }); + it('чужой план → null', () => { + expect(resolveTentative(signStepState('plan-A', 2, key, { toPtr: 3 }), 'plan-B', v)).toBe(null); + }); + it('нет пометки → null', () => { + expect(resolveTentative(signStepState('plan-A', 2, key), 'plan-A', v)).toBe(null); + }); + it('битая подпись → null', () => { + const s = signStepState('plan-A', 2, key, { toPtr: 3 }); + expect(resolveTentative({ ...s, tentative: { toPtr: 9 } }, 'plan-A', v)).toBe(null); + }); +}); + +describe('F-J helpers: computeReconcile', () => { + const norm = (p) => p.toLowerCase(); + const plan = { steps: [ + { n: 1, op: 'Write', object: '/tmp/a.txt' }, + { n: 2, op: 'Write', object: '/tmp/b.txt' }, + { n: 3, op: 'Write', object: '/tmp/c.txt' }, + ] }; + it('нет пометки → none, effPtr=committed', () => { + expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/a.txt' }, committedPtr: 0, tentativeToPtr: null, normalize: norm })) + .toEqual({ state: 'none', effPtr: 0 }); + }); + it('действие = шаг toPtr → commit', () => { + expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/b.txt' }, committedPtr: 0, tentativeToPtr: 1, normalize: norm })) + .toEqual({ state: 'commit', effPtr: 1 }); + }); + it('повтор шага ptr → discard', () => { + expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Write', object: '/tmp/a.txt' }, committedPtr: 0, tentativeToPtr: 1, normalize: norm })) + .toEqual({ state: 'discard', effPtr: 0 }); + }); + it('ни то ни другое (observe-op) → hold', () => { + expect(computeReconcile({ frozenPlan: plan, incomingAction: { op: 'Read', object: '/tmp/b.txt' }, committedPtr: 0, tentativeToPtr: 1, normalize: norm })) + .toEqual({ state: 'hold', effPtr: 0 }); + }); +}); + +describe('F-J runGate deferral', () => { + const key = 'k-fj'; + const lc = (p) => p.toLowerCase(); + const plan = { plan_id: 'P', judge_mode: 'live-block', steps: [ + { n: 1, op: 'Write', object: '/tmp/a.txt' }, + { n: 2, op: 'Write', object: '/tmp/b.txt' }, + ] }; + const art = { judge_mode: 'live-block' }; + const mk = () => { const calls = []; return { calls, saveStep: (p, t) => calls.push([p, t]), journal: () => true, removeFrozenPlan: () => calls.push(['remove']) }; }; + const ev = (name, input) => ({ tool_name: name, tool_input: input }); + const base = (io) => ({ frozenPlan: plan, frozenArtifact: art, key, verifyImpl: () => true, verifyArtifactImpl: () => true, normalize: lc, ...io }); + + it('не последний шаг: saveStep с пометкой, не голым advanceTo', () => { + const io = mk(); + runGate({ event: ev('Write', { file_path: '/tmp/a.txt' }), stepPtr: 0, tentativeToPtr: null, ...base(io) }); + expect(io.calls).toContainEqual([0, { toPtr: 1 }]); + }); + it('пометка открыта + действие следующего шага → commit saveStep(toPtr,null)', () => { + const io = mk(); + runGate({ event: ev('Write', { file_path: '/tmp/b.txt' }), stepPtr: 0, tentativeToPtr: 1, ...base(io) }); + expect(io.calls.some((c) => c[0] === 1 && c[1] === null)).toBe(true); + }); + it('пометка открыта + повтор шага → discard saveStep(ptr,null), не уходит вперёд', () => { + const io = mk(); + const r = runGate({ event: ev('Write', { file_path: '/tmp/a.txt' }), stepPtr: 0, tentativeToPtr: 1, ...base(io) }); + expect(io.calls.some((c) => c[0] === 0 && c[1] === null)).toBe(true); + expect(r.block).toBe(false); + }); +});