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:
@@ -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)"}]
|
||||
```
|
||||
@@ -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)"}]
|
||||
```
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user