feat: supreme-gate two-tact step-pointer tentative-advance F-J SP4

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 04:52:00 +03:00
parent 9f28c2cfbc
commit c4774c55fb
4 changed files with 630 additions and 25 deletions
@@ -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 24; {#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)"}]
```
@@ -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)"}]
```
+80 -25
View File
@@ -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);
+95
View File
@@ -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);
});
});