feat: E-S1 gate-3 trigger Stop-hook enforce-gate3-loop plus wall loop-open marker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
# E-S1 gate-3 trigger (Stop-хук) + зубы петли — Implementation Plan (v3)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline; под стеной Task запрещён). Шаги — checkbox (`- [ ]`).
|
||||
|
||||
**Goal:** автозапуск судьи gate-3 на завершении плана через Stop-хук + блокировка завершения хода при недостигнутой цели, потребляя готовое ядро `decideGate3Closure`.
|
||||
|
||||
**Architecture:** стена в in-band такте `planComplete` (та же ветка `runGate`, что зовёт `removeFrozenPlan`) через инъектируемый dep `writeLoopOpen` пишет подписанную метку «петля открыта»; новый Stop-хук `enforce-gate3-loop.mjs` на конце хода читает метку, при изменении работы зовёт судью gate-3, применяет ядро и ставит зубы (block Stop, кроме `closed`/`open`). Закрытие — только реальный GO судьи или подписанный выбор владельца (SE-R7-6).
|
||||
|
||||
**Tech Stack:** Node ESM, vitest, HMAC-квитанции (`receipt-sign`), escape-грант (`escape-grant`), движок судьи (`judge-engine`), ядро (`loop-termination`).
|
||||
|
||||
## Цель
|
||||
|
||||
Замкнуть внешнюю петлю наставника: завершение плана автоматически запускает проверку «цель
|
||||
достигнута?», а попытка завершить ход при открытой петле блокируется до GO судьи gate-3 либо
|
||||
подписанного решения владельца. Контроллер петлю не закрывает ни прямо, ни подделкой.
|
||||
|
||||
## Переговоры
|
||||
|
||||
### Круг 1
|
||||
|
||||
**Судье:** Метка пишется НЕ отдельным срабатыванием «после завершения», а в том же in-band такте
|
||||
`runGate`, где вычислен `planComplete` и уже вызывается `removeFrozenPlan` (ветка `if (r.planComplete)`).
|
||||
Это `PreToolUse`-авторизация последнего шага: любой инструментальный вызов проходит стену, значит для
|
||||
любого исполняемого последнего шага метка пишется гарантированно. Брошенный план → нет последнего
|
||||
шага → метки нет → петля и не должна открываться (инертности нет). Врезка вынесена в инъектируемый
|
||||
dep `writeLoopOpen` и проверяется юнит-тестом (Task 6). Поправка к v2: число правок стены приведено
|
||||
в соответствие — ТРИ несмежных участка `enforce-supreme-gate.mjs` (сигнатура `runGate`, ветка
|
||||
`planComplete`, проброс dep в `main`), каждый — отдельный шаг с Bash-проверкой; импорты в `main`
|
||||
сделаны динамическими, чтобы не плодить отдельный шаг правки шапки файла.
|
||||
|
||||
## Предусловия исполнения (под стеной)
|
||||
|
||||
- Новый production-файл `tools/enforce-gate3-loop.mjs` требует override владельца ДВУМЯ строками
|
||||
(`ремонт инфраструктуры` + `ремонт: новый Stop-хук gate-3 по опечатанной спеке`) ИЛИ escape-per-step.
|
||||
- Правки стены (`enforce-supreme-gate.mjs`) — escape-per-step с самого начала, атомарно, по одной
|
||||
(урок F-J: не дробить мутирующие шаги под живой печатью без escape).
|
||||
- vitest под Claude-Bash недостижим (harness-collapse) → verify-шаги двигают указатель; авторитетный
|
||||
прогон — у владельца: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`.
|
||||
- Регистрация хука в `settings.json` (Stop) + перезапуск — действие владельца, ВНЕ этого плана.
|
||||
|
||||
## Структура файлов
|
||||
|
||||
- **Создать** `tools/enforce-gate3-loop.mjs` — Stop-хук: подпись/IO метки, отпечаток+кэш,
|
||||
`decideStopTeeth`, сбор продукта, вызов судьи, маппинг подписанного выбора владельца, `main()`,
|
||||
экспорт `writeLoopOpen` (зовётся стеной как dep).
|
||||
- **Создать** `tools/enforce-gate3-loop.test.mjs` — юнит-тесты чистых ядер.
|
||||
- **Изменить** `tools/enforce-supreme-gate.mjs` — ТРИ несмежных участка: (а) сигнатура `runGate`
|
||||
(+param `writeLoopOpen`); (б) ветка `if (r.planComplete)` (+вызов `writeLoopOpen()`); (в) `main()`
|
||||
(динамические импорты + проброс замыкания `writeLoopOpen` в `runGate`).
|
||||
- **Изменить** `tools/enforce-supreme-gate.test.mjs` — реальный тест: последний шаг → `writeLoopOpen`
|
||||
вызван 1×; не-последний → 0×.
|
||||
|
||||
Контракты (имена фиксированы):
|
||||
- домен подписи метки: `const GATE3_LOOP_DOMAIN = 'gate3-loop'` (литерал, без правки `receipt-sign`).
|
||||
- файлы (в `~/.claude/runtime`): метка `gate3-loop-<sess>.json`, кэш `gate3-cache-<sess>.json`.
|
||||
- метка: `{ taskId, planId, artifactId, steps, at, sig }`.
|
||||
- escape-метки владельца: `gate3-arb:accept:<fp>` / `gate3-arb:continue:<fp>`.
|
||||
|
||||
```skills-json
|
||||
["test-driven-development"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Тесты чистых ядер (RED)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/enforce-gate3-loop.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты**
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
signLoopMarker, verifyLoopMarker, computeFingerprint,
|
||||
decideStopTeeth, resolveOwnerArbitration, buildGate3ProductFromMarker,
|
||||
} from './enforce-gate3-loop.mjs';
|
||||
|
||||
const KEY = 'test-key-123';
|
||||
|
||||
describe('signLoopMarker/verifyLoopMarker', () => {
|
||||
it('roundtrip', () => {
|
||||
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
|
||||
expect(typeof m.sig).toBe('string');
|
||||
expect(verifyLoopMarker(m, KEY)).toBe(true);
|
||||
});
|
||||
it('подмена поля ломает подпись', () => {
|
||||
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
|
||||
expect(verifyLoopMarker({ ...m, planId: 'p2' }, KEY)).toBe(false);
|
||||
});
|
||||
it('нет sig → false', () => { expect(verifyLoopMarker({ taskId: 't1' }, KEY)).toBe(false); });
|
||||
});
|
||||
|
||||
describe('computeFingerprint', () => {
|
||||
it('детерминирован, не зависит от порядка greens', () => {
|
||||
expect(computeFingerprint({ planId: 'p', greenIds: ['c2', 'c1'], negotiationText: 'x' }))
|
||||
.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' }));
|
||||
});
|
||||
it('меняется на новый green', () => {
|
||||
expect(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' }))
|
||||
.not.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' }));
|
||||
});
|
||||
it('меняется на новый довод', () => {
|
||||
expect(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' }))
|
||||
.not.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'y' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideStopTeeth', () => {
|
||||
const go = { wired: true, decision: 'GO' };
|
||||
const nogo = { wired: true, decision: 'NO-GO' };
|
||||
const degraded = { wired: false, decision: 'GO' };
|
||||
it('GO → allow + clear', () => {
|
||||
const r = decideStopTeeth({ verdict: go, noGoCount: 0, ownerArbitration: null });
|
||||
expect(r.block).toBe(false); expect(r.clear).toBe(true);
|
||||
});
|
||||
it('accept → allow + clear', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'accept' });
|
||||
expect(r.block).toBe(false); expect(r.clear).toBe(true);
|
||||
});
|
||||
it('continue → allow, держим', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'continue' });
|
||||
expect(r.block).toBe(false); expect(r.clear).toBe(false);
|
||||
});
|
||||
it('NO-GO круг<3 → block negotiate', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 1, ownerArbitration: null });
|
||||
expect(r.block).toBe(true); expect(r.state).toBe('negotiate');
|
||||
});
|
||||
it('NO-GO круг>=3 → block arbitrate+card', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 3, ownerArbitration: null });
|
||||
expect(r.block).toBe(true); expect(r.state).toBe('arbitrate'); expect(r.card).toBe(true);
|
||||
});
|
||||
it('degraded → block, НЕ closed/arbitrate', () => {
|
||||
const r = decideStopTeeth({ verdict: degraded, noGoCount: 5, ownerArbitration: null });
|
||||
expect(r.block).toBe(true); expect(r.clear).toBe(false); expect(r.state).toBe('negotiate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOwnerArbitration', () => {
|
||||
const fp = 'abc';
|
||||
const g = (action, ts = 1000) => ({ action, ts });
|
||||
it('accept грант → accept', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g(`gate3-arb:accept:${fp}`)], consumed: [], now: 1000 })).toBe('accept');
|
||||
});
|
||||
it('continue грант → continue', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g(`gate3-arb:continue:${fp}`)], consumed: [], now: 1000 })).toBe('continue');
|
||||
});
|
||||
it('нет гранта → null', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [], consumed: [], now: 1000 })).toBe(null);
|
||||
});
|
||||
it('грант на другой отпечаток → null (анти-реплей)', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g('gate3-arb:accept:OTHER')], consumed: [], now: 1000 })).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGate3ProductFromMarker', () => {
|
||||
it('цель из секций + шаги + greens', () => {
|
||||
const marker = { steps: [{ op: 'Write', object: 'a.mjs', criterion_id: 'c1' }], planId: 'p' };
|
||||
const frozenArtifact = { sections: { s1: 'построить X', s2: 'критерий Y' } };
|
||||
const out = buildGate3ProductFromMarker({ marker, frozenArtifact, greens: [{ criterion_id: 'c1', green: true }] });
|
||||
expect(out.goal).toContain('построить X');
|
||||
expect(out.product).toContain('a.mjs');
|
||||
expect(out.product).toContain('green');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон — падают**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: FAIL — модуля/экспортов нет.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Реализация `enforce-gate3-loop.mjs` (GREEN)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/enforce-gate3-loop.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать модуль целиком**
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-gate3-loop (E-S1 триггер) — Stop-хук «зубы петли»: стена на завершении плана пишет
|
||||
* метку «петля открыта»; здесь на конце хода судим «цель достигнута?» (gate-3) и блокируем
|
||||
* завершение, пока петля не закрыта. Закрытие — только реальный GO судьи ИЛИ подписанный выбор
|
||||
* владельца (SE-R7-6). Чистые ядра тестируемы без модели/IO; main() — тонкая обёртка.
|
||||
*/
|
||||
import fsDefault from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';
|
||||
import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs';
|
||||
import { escapeGrantOpen } from './escape-grant.mjs';
|
||||
import { parseNegotiationSection } from './negotiation-section.mjs';
|
||||
import { buildArbitrationCard } from './arbitration-card.mjs';
|
||||
import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs';
|
||||
|
||||
const GATE3_LOOP_DOMAIN = 'gate3-loop';
|
||||
const ESCALATE_AFTER = 3;
|
||||
|
||||
export function signLoopMarker(payload, key) { return { ...payload, sig: signPayload(payload, key, GATE3_LOOP_DOMAIN) }; }
|
||||
export function verifyLoopMarker(marker, key) { return verifyReceipt(marker, key, GATE3_LOOP_DOMAIN); }
|
||||
export function loopMarkerPath(runtimeDir, sess) { return join(runtimeDir, `gate3-loop-${sess}.json`); }
|
||||
export function cachePath(runtimeDir, sess) { return join(runtimeDir, `gate3-cache-${sess}.json`); }
|
||||
|
||||
export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) {
|
||||
const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key);
|
||||
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(loopMarkerPath(runtimeDir, sess), JSON.stringify(marker)); } catch { /* best-effort */ }
|
||||
}
|
||||
export function readLoopOpen({ runtimeDir, sess, key, fsImpl = fsDefault }) {
|
||||
let m = null;
|
||||
try { m = JSON.parse(fsImpl.readFileSync(loopMarkerPath(runtimeDir, sess), 'utf8')); } catch { return null; }
|
||||
return verifyLoopMarker(m, key) ? m : null;
|
||||
}
|
||||
export function clearLoopOpen({ runtimeDir, sess, fsImpl = fsDefault }) {
|
||||
try { fsImpl.unlinkSync(loopMarkerPath(runtimeDir, sess)); } catch { /* no-op */ }
|
||||
}
|
||||
|
||||
export function computeFingerprint({ planId = '', greenIds = [], negotiationText = '' } = {}) {
|
||||
const sorted = [...greenIds].map(String).sort();
|
||||
return createHash('sha256').update(canonicalJson({ planId: String(planId), greenIds: sorted, negotiationText: String(negotiationText) })).digest('hex');
|
||||
}
|
||||
export function loadCache({ runtimeDir, sess, fsImpl = fsDefault }) {
|
||||
try { return JSON.parse(fsImpl.readFileSync(cachePath(runtimeDir, sess), 'utf8')); } catch { return null; }
|
||||
}
|
||||
export function saveCache({ runtimeDir, sess, cache, fsImpl = fsDefault }) {
|
||||
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(cachePath(runtimeDir, sess), JSON.stringify(cache)); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
export function buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) {
|
||||
const sections = (frozenArtifact && frozenArtifact.sections) || {};
|
||||
const goal = Object.keys(sections).sort().map((k) => `[${k}] ${sections[k]}`).join('\n') || '(цель не извлечена из опечатанного артефакта)';
|
||||
const greenIds = new Set((Array.isArray(greens) ? greens : []).filter((g) => g && g.green).map((g) => String(g.criterion_id)));
|
||||
const planSteps = (marker && Array.isArray(marker.steps) ? marker.steps : []).map((s) => ({ id: s.criterion_id, op: s.op, object: s.object }));
|
||||
const greenRuns = planSteps.filter((s) => greenIds.has(String(s.id))).map((s) => ({ stepId: s.id, criterion: true }));
|
||||
return buildGate3Product({ goal, planSteps, greenRuns });
|
||||
}
|
||||
|
||||
export function resolveOwnerArbitration({ fingerprint, grants, consumed, now }) {
|
||||
if (escapeGrantOpen(`gate3-arb:accept:${fingerprint}`, grants, consumed, now)) return 'accept';
|
||||
if (escapeGrantOpen(`gate3-arb:continue:${fingerprint}`, grants, consumed, now)) return 'continue';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = null, maxRounds = ESCALATE_AFTER }) {
|
||||
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds });
|
||||
if (d.state === 'closed') return { block: false, clear: true, state: d.state, reason: d.reason };
|
||||
if (d.state === 'open') return { block: false, clear: false, state: d.state, reason: d.reason };
|
||||
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
|
||||
}
|
||||
|
||||
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
|
||||
export async function runGate3Stop(event, deps) {
|
||||
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps;
|
||||
const marker = readLoopOpen({ runtimeDir, sess, key });
|
||||
if (!marker) return { block: false };
|
||||
const greens = (loadGreens && loadGreens()) || [];
|
||||
const greenIds = greens.filter((g) => g && g.green).map((g) => g.criterion_id);
|
||||
const frozenArtifact = (loadArtifact && loadArtifact()) || null;
|
||||
const negotiationText = parseNegotiationSection((frozenArtifact && frozenArtifact._md) || '').map((r) => r.position).join('\n');
|
||||
const fingerprint = computeFingerprint({ planId: marker.planId, greenIds, negotiationText });
|
||||
const cache = loadCache({ runtimeDir, sess }) || { fingerprint: null, verdict: null, noGoCount: 0 };
|
||||
|
||||
let verdict = cache.verdict;
|
||||
let noGoCount = cache.noGoCount || 0;
|
||||
if (cache.fingerprint !== fingerprint) {
|
||||
if (judgeKey && callJudge) {
|
||||
verdict = await callJudge(buildGate3ProductFromMarker({ marker, frozenArtifact, greens }));
|
||||
} else {
|
||||
verdict = { wired: false, decision: 'GO', unavailable: true };
|
||||
}
|
||||
const isContentNoGo = !!verdict && verdict.wired === true && verdict.decision !== 'GO';
|
||||
const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO';
|
||||
noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount);
|
||||
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
|
||||
}
|
||||
|
||||
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
|
||||
const t = decideStopTeeth({ verdict, noGoCount, ownerArbitration });
|
||||
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0 } }); }
|
||||
if (!t.block) return { block: false };
|
||||
|
||||
let message;
|
||||
if (t.state === 'arbitrate') {
|
||||
const card = buildArbitrationCard({ side: 'judge', level: 'L2', round: noGoCount, objectionVerbatim: t.reason || '(возражение судьи)', controllerPositionVerbatim: negotiationText || '(позиция не указана)', sealAction: `gate3-arb:accept:${fingerprint}` });
|
||||
message = `[gate3-loop] ${card.title}\nЦель не подтверждена. Замечание: ${card.objection}\nВыбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять) / gate3-arb:continue:${fingerprint} (продолжать).`;
|
||||
} else if (verdict && verdict.wired === false) {
|
||||
message = buildDegradedFeedback({ side: 'judge', reason: 'судья gate-3 недоступен — петля не закрыта; выход: escape владельца ИЛИ plan-done' });
|
||||
} else {
|
||||
message = buildObjectionFeedback({ side: 'judge', text: t.reason || 'цель не достигнута — доработай или докажи' });
|
||||
}
|
||||
return { block: true, message };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { readStdin, parseEventJson, runtimeDir, exitDecision } = await import('./enforce-hook-helpers.mjs');
|
||||
const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
|
||||
const { resolveJudgeLlmKey } = await import('./judge-gate-config.mjs');
|
||||
const { callJudgeModel } = await import('./enforce-judge-gate.mjs');
|
||||
const { requiredLensesFor, runJudge } = await import('./judge-engine.mjs');
|
||||
const { loadFloorEscapes, loadConsumed } = await import('./escape-grant.mjs');
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const dir = runtimeDir();
|
||||
const sess = (event && event.session_id) || process.env.CLAUDE_SESSION_ID || 'unknown';
|
||||
const key = resolveReceiptKey();
|
||||
const judgeKey = resolveJudgeLlmKey();
|
||||
const loadGreens = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `criterion-greens-${sess}.json`), 'utf8')); } catch { return []; } };
|
||||
const loadArtifact = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `frozen-artifact-${sess}.json`), 'utf8')); } catch { return null; } };
|
||||
const callJudge = async (product) => {
|
||||
const requiredLenses = requiredLensesFor('gate3');
|
||||
const promptArgs = { ...product, roundMemory: {} };
|
||||
const raw = await callJudgeModel({ functionName: 'gate3', requiredLenses, promptArgs, apiKey: judgeKey });
|
||||
if (raw && raw.unavailable) return { wired: false, decision: 'GO', unavailable: true };
|
||||
const v = runJudge({ functionName: 'gate3', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
|
||||
return { wired: true, decision: v.decision, verdict: v };
|
||||
};
|
||||
const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now() });
|
||||
exitDecision({ block: !!r.block, message: r.block ? `[gate3-loop] ${r.message || 'петля открыта — цель не подтверждена'}` : undefined });
|
||||
} catch {
|
||||
exitDecision({ block: false }); // Stop fail-OPEN: внутренняя ошибка хука НЕ кирпичит конец хода
|
||||
}
|
||||
}
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isCli) main();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон — зелено**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: PASS (авторитетно у владельца).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Стена — сигнатура `runGate` (+param writeLoopOpen)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/enforce-supreme-gate.mjs`
|
||||
|
||||
- [ ] **Step 1: Точечная правка сигнатуры**
|
||||
|
||||
```javascript
|
||||
// old:
|
||||
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
|
||||
// new:
|
||||
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: PASS (новый необязательный параметр не ломает существующие вызовы).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Стена — вызов writeLoopOpen в ветке planComplete
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/enforce-supreme-gate.mjs`
|
||||
|
||||
- [ ] **Step 1: Точечная правка ветки**
|
||||
|
||||
```javascript
|
||||
// old:
|
||||
saveStep(r.advanceTo, null);
|
||||
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
|
||||
} else {
|
||||
// new:
|
||||
saveStep(r.advanceTo, null);
|
||||
if (typeof writeLoopOpen === 'function') { try { writeLoopOpen(); } catch { /* E-S1: сбой метки не ломает завершение */ } }
|
||||
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
|
||||
} else {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Стена — проброс замыкания writeLoopOpen в main()
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/enforce-supreme-gate.mjs` (вызов `runGate({...})` в `main()`)
|
||||
|
||||
- [ ] **Step 1: Точечная правка вызова runGate в main (динамические импорты + поле)**
|
||||
|
||||
```javascript
|
||||
// old:
|
||||
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 }), // Фаза 5: чистое завершение
|
||||
});
|
||||
// new:
|
||||
const { writeLoopOpen: writeLoopOpenMarker } = await import('./enforce-gate3-loop.mjs');
|
||||
const { loadTaskId } = await import('./router-task-id.mjs');
|
||||
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 }), // Фаза 5: чистое завершение
|
||||
writeLoopOpen: () => { // E-S1: метка «петля открыта» на planComplete (in-band)
|
||||
let taskId = null;
|
||||
try { taskId = loadTaskId({ sessionId: sess, runtimeDir, fsImpl: fs }); } catch { taskId = null; }
|
||||
writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Реальный тест стены — метка на planComplete
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/enforce-supreme-gate.test.mjs` (в describe «Фаза 5 — чистое завершение плана»: сетап `runGateCE`/`PLAN1`/`PLAN2`/`KCE`/`verifyCE`/`lowCE` уже в файле)
|
||||
|
||||
- [ ] **Step 1: Добавить два реальных теста**
|
||||
|
||||
```javascript
|
||||
it('runGate: последний шаг → writeLoopOpen вызван (метка петли)', () => {
|
||||
let opened = 0;
|
||||
const r = runGateCE({
|
||||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
|
||||
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
|
||||
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
|
||||
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => {}, writeLoopOpen: () => { opened++; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(opened).toBe(1);
|
||||
});
|
||||
it('runGate: НЕ последний шаг → writeLoopOpen НЕ вызван', () => {
|
||||
let opened = 0;
|
||||
runGateCE({
|
||||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/a.mjs' } },
|
||||
frozenPlan: { ...PLAN2, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
|
||||
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
|
||||
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => {}, writeLoopOpen: () => { opened++; },
|
||||
});
|
||||
expect(opened).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify полной регрессии**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: PASS — база + новые тесты зелёные.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Финальная регрессия, ручное ревью, коммит
|
||||
|
||||
- [ ] **Step 1: Полный свод (авторитетно — владелец в терминале)**
|
||||
|
||||
Run: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
Expected: PASS — 4105+новые passed, 0 регрессий.
|
||||
|
||||
- [ ] **Step 2: Ручное ревью diff `enforce-supreme-gate.mjs` (рекомендация наставника)**
|
||||
|
||||
Проверить минимальность врезки: только +param, +вызов в ветке planComplete, +замыкание в main;
|
||||
нет побочных эффектов на не-последних шагах.
|
||||
|
||||
- [ ] **Step 3: Коммит (через escape владельца)**
|
||||
|
||||
```bash
|
||||
git add tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
|
||||
git commit -m "feat: E-S1 gate-3 trigger Stop-hook enforce-gate3-loop plus wall loop-open marker" -m "Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>" -- tools/enforce-gate3-loop.mjs tools/enforce-gate3-loop.test.mjs tools/enforce-supreme-gate.mjs tools/enforce-supreme-gate.test.mjs
|
||||
git push gitea main
|
||||
```
|
||||
|
||||
## Критерий приёмки
|
||||
|
||||
- Юнит-тесты `enforce-gate3-loop.test.mjs` зелёные (подпись метки, отпечаток, decideStopTeeth все
|
||||
ветки, resolveOwnerArbitration анти-реплей, сбор продукта).
|
||||
- Тест стены НЕ заглушка: последний шаг → `writeLoopOpen` вызван 1×; не-последний → 0×.
|
||||
- Полная регрессия tools зелёная, 0 регрессий.
|
||||
- Стена: ровно три точечные несмежные правки (param + вызов + замыкание), без перепечатки функций.
|
||||
|
||||
```steps-json
|
||||
[
|
||||
{"op": "Write", "object": "tools/enforce-gate3-loop.test.mjs", "ref": "s9"},
|
||||
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s9"},
|
||||
{"op": "Write", "object": "tools/enforce-gate3-loop.mjs", "ref": "s6"},
|
||||
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
|
||||
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
|
||||
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
|
||||
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
|
||||
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
|
||||
{"op": "Edit", "object": "tools/enforce-supreme-gate.mjs", "ref": "s6"},
|
||||
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s6"},
|
||||
{"op": "Edit", "object": "tools/enforce-supreme-gate.test.mjs", "ref": "s9"},
|
||||
{"op": "Bash", "object": "npx vitest run --config vitest.config.tools.mjs --no-file-parallelism", "ref": "s9"}
|
||||
]
|
||||
```
|
||||
|
||||
```verified-context-json
|
||||
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}]
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# E-S1 спека #2 — триггер gate-3 на завершении плана (Stop-хук) + зубы петли
|
||||
|
||||
**Дата:** 2026-06-17
|
||||
**Эпик:** роутер-наставник, E-S1 (sub-plan C, триггер). **Статус:** дизайн под реализацию (TDD).
|
||||
**Кодовая фраза:** «роутер-наставник».
|
||||
**Предшественник:** безопасное ядро `2026-06-17-es1-gate3-safe-core-design-v2.md`
|
||||
(`buildGate3Product` + `decideGate3Closure` в `tools/loop-termination.mjs`).
|
||||
|
||||
## Цель
|
||||
|
||||
Безопасное ядро третьего гейта («цель достигнута?») готово, но у него нет ни «рта»
|
||||
(автозапуска), ни «зубов» (блокировки незавершённой петли). Машинерия судит замысел (gate1)
|
||||
и план (gate2) ДО работы — в момент записи спеки/плана. После работы никто не проверяет, достиг
|
||||
ли результат заявленной цели; внешнюю петлю фактически закрывает контроллер по своему усмотрению.
|
||||
Эта спека замыкает петлю: завершение плана автоматически открывает проверку цели, а попытка
|
||||
завершить ход при недостигнутой цели блокируется до реального GO судьи gate-3 либо подписанного
|
||||
решения владельца. Закрытие петли остаётся исключительной прерогативой судьи или владельца
|
||||
(инвариант SE-R7-6) — контроллер не закрывает её ни прямо, ни подделкой.
|
||||
|
||||
## Архитектура и поток {#s1}
|
||||
|
||||
Тяжёлый судья gate-3 — это внешний вызов модели (десятки секунд). Верховный хук стены
|
||||
(`enforce-supreme-gate`) — синхронный default-deny на каждое действие; запускать в нём минутный
|
||||
вызов нельзя (заморозка действия + риск fail-CLOSED). Поэтому ответственность разделена:
|
||||
|
||||
- **Стена** на завершении плана (`planComplete`) делает ОДНО аддитивное действие: записывает
|
||||
подписанную метку «петля открыта» (loop-open). Снятие печати плана (`removeFrozenPlan`)
|
||||
и сдвиг указателя остаются как есть; замороженный артефакт на диске НЕ трогается.
|
||||
- **Новый Stop-хук** `enforce-gate3-loop.mjs` на событии Stop (конец хода контроллера) читает
|
||||
метку, собирает продукт, при необходимости зовёт судью, применяет `decideGate3Closure` ядра и
|
||||
ставит «зубы»: блокирует завершение хода, пока петля не закрыта.
|
||||
|
||||
Поток на событии Stop:
|
||||
|
||||
1. метки нет → allow (вне нашей ответственности, $0);
|
||||
2. метка есть → собрать вход (вердикт из кэша или свежий вызов — {#s3}; счётчик NO-GO gate3;
|
||||
подписанный выбор владельца — {#s5}) → `decideGate3Closure` ({#s4});
|
||||
3. по `state` ядра: `closed` → снять метку, allow; `open` → allow, метку держать; `negotiate` →
|
||||
block с возражением; `arbitrate` → block с карточкой владельцу; degraded → block «судья
|
||||
недоступен» с клапаном владельца ({#s4}).
|
||||
|
||||
Стена меняется минимально (один блок) — риск самомодификации сведён к минимуму; вся тяжесть и
|
||||
переговорная логика — в изолированном Stop-хуке, который выключается удалением из реестра хуков.
|
||||
|
||||
## Метка «петля открыта» {#s2}
|
||||
|
||||
Файл `gate3-loop-<сессия>.json` в защищённом runtime (та же защита, что у указателя шага и печати:
|
||||
контроллер не читает, не пишет, не подделывает). Пишется стеной на `planComplete` из данных,
|
||||
которые у стены уже на руках. Состав:
|
||||
|
||||
- `taskId` — ключ задачи (тот же `loadTaskId`, что у наставника/судьи);
|
||||
- `planId`, `artifactId` — из замороженного плана (`frozenPlan.plan_id` / `.artifact_id`);
|
||||
- `steps` — снимок `frozenPlan.steps` (op/object/criterion_id) для продукта судьи;
|
||||
- `fingerprint` — отпечаток работы ({#s3});
|
||||
- `at` — время; `sig` — подпись ключом владельца (отдельный домен квитанции LOOP_OPEN).
|
||||
|
||||
Жизненный цикл:
|
||||
|
||||
- **открывается** на каждом `planComplete` (перезаписывает прежнюю — новый завершённый план =
|
||||
свежая петля; интервальные ре-планы тоже корректно ведут к проверке цели);
|
||||
- **закрывается** (метка удаляется) ТОЛЬКО: GO судьи gate-3 · подписанное `accept` владельца ·
|
||||
`plan-done` (досрочное завершение владельцем — без проверки цели, его прерогатива);
|
||||
- **не закрывается** деградированным судьёй, контроллером, истечением времени.
|
||||
|
||||
Цель в метку НЕ кладётся: стена её не извлекает, а замороженный артефакт остаётся на диске →
|
||||
Stop-хук берёт раздел `## Цель` из `frozen-artifact-<сессия>.json` (`sections`). Меньше логики в
|
||||
стене — меньше риск.
|
||||
|
||||
## Отпечаток работы и кэш вердикта {#s3}
|
||||
|
||||
Чтобы не звонить минутному судье на каждом Stop, вызов делается только когда работа реально
|
||||
изменилась. `fingerprint` — детерминированный хеш двух составляющих:
|
||||
|
||||
- **исполненная работа** — `planId` + множество подтверждённых по-критерию GREEN (тест прошёл И
|
||||
мутация убита; источник — стор criterion-greens, ключ по `criterion_id` шагов);
|
||||
- **довод контроллера** — содержимое раздела `## Переговоры` последнего артефакта/плана
|
||||
(`parseNegotiationSection`), чтобы новый довод заставил судью пересмотреть.
|
||||
|
||||
Кэш `{fingerprint, verdict, noGoCount}` лежит рядом с меткой. Правило на Stop:
|
||||
|
||||
- `fingerprint` == кэшированному → берём сохранённый вердикт, вызова нет ($0), зубы держат по нему;
|
||||
- `fingerprint` изменился → вызов судьи заново, кэш обновляется; на содержательном NO-GO растёт
|
||||
`noGoCount` (ключ стадии `gate3`, зеркало `bumpJudgeNoGo`/`mentor-nogo-counter`);
|
||||
- degraded (судья не дозвонился) → `noGoCount` НЕ растёт, кэш НЕ пишется (при восстановлении судьи
|
||||
вызов повторится).
|
||||
|
||||
## Зубы на Stop и fail-safe деградации {#s4}
|
||||
|
||||
Чистое ядро решения `decideStopTeeth({ verdict, noGoCount, ownerArbitration, hasMarker })` отделено
|
||||
от I/O (тестируемо без модели), внутри зовёт `decideGate3Closure`. Маппинг `state` → действие хука:
|
||||
|
||||
- `closed` (`terminate:true`) → снять метку → **allow Stop** (петля закрыта: GO судьи ИЛИ
|
||||
подписанное `accept` владельца);
|
||||
- `open` → **allow Stop**, метку держать (владелец выбрал «продолжать» — этот ход не блокируем);
|
||||
- `negotiate` → **block Stop**: дословное возражение судьи + «доработай/докажи» (через
|
||||
`objection-delivery`, симметрично gate1/gate2);
|
||||
- `arbitrate` → **block Stop**: карточка арбитража владельцу (`buildArbitrationCard`, 3 выбора:
|
||||
принять / продолжать / объясни);
|
||||
- degraded (вердикт `wired:false`, ядро → `negotiate`) → **block Stop**: «судья gate-3 недоступен»
|
||||
(`buildDegradedFeedback`).
|
||||
|
||||
Fail-safe деградации (контракт безопасности): деградированный судья НИКОГДА не закрывает и не
|
||||
арбитрит. Чтобы недоступность судьи (нет ключа / таймаут) не «закирпичила» сессию, выход из block
|
||||
по деградации — два уже существующих рычага ВЛАДЕЛЬЦА: его `escape`-грант (Stop-хук чтит escape,
|
||||
как стена/пол/живой судья) ЛИБО `plan-done` (досрочное завершение). Контроллер сам выйти не может —
|
||||
симметрично поведению живого судьи на degraded (block + клапан владельца).
|
||||
|
||||
Метку нельзя удалить или подделать (подпись + защищённый runtime). Единственный честный обход —
|
||||
не печатать план вовсе (работа через escape-per-step мимо стены) — пред-существующая граница
|
||||
floor-escape, вне объёма этой спеки.
|
||||
|
||||
## Доверие входов (анти-обход) {#s5}
|
||||
|
||||
Безопасность держит контракт ИСТОЧНИКА входов `decideGate3Closure` (иначе контроллер подделал бы
|
||||
«владелец принял» и закрыл петлю — класс премортем abuse/seam):
|
||||
|
||||
- `gate3Verdict` принимается ТОЛЬКО как выход реального движка `runJudge` (`functionName='gate3'`),
|
||||
не синтезируется контроллером. Любой битый/не-движковый объект → трактуется как NO-GO
|
||||
(сомнение → не закрывать).
|
||||
- `ownerArbitration` принимается ТОЛЬКО из подписанного канала владельца (зеркало owner-seal):
|
||||
- канонические резервные метки (ни одно реальное действие их не рождает):
|
||||
`gate3-arb:accept:<fingerprint>` (принять цель достигнутой) и
|
||||
`gate3-arb:continue:<fingerprint>` (продолжать работу);
|
||||
- `<fingerprint>` привязывает выбор к ЭТОЙ открытой петле (анти-реплей: старый грант не закроет
|
||||
новую петлю);
|
||||
- владелец кликает опцию AskUserQuestion `FLOOR-ESCAPE: gate3-arb:accept:<fp>` →
|
||||
`enforce-askuser-answer-parser` подписывает грант ключом владельца (тот же тракт, что
|
||||
floor-escape/owner-seal) → Stop-хук читает `loadFloorEscapes`/`escapeGrantOpen(canonicalAction)`
|
||||
→ маппит в `ownerArbitration='accept'|'continue'`; непроверенное/неподписанное → `null`.
|
||||
|
||||
Карточка арбитража несёт точную escape-строку `gate3-arb:accept:<fp>` (как owner-seal в SP3-c),
|
||||
чтобы владельцу было откуда взять метку. Инвариант SE-R7-6 сохранён: закрыть петлю может ТОЛЬКО
|
||||
подписанный владелец ЛИБО реальный судья gate-3.
|
||||
|
||||
## Точки врезки в код {#s6}
|
||||
|
||||
- `enforce-gate3-loop.mjs` (НОВЫЙ, требует override владельца + регистрации в Stop-реестре): весь
|
||||
Stop-хук — write/read/clear/sign метки, отпечаток+кэш, `decideStopTeeth`, сбор продукта (цель из
|
||||
артефакта + steps + GREEN → `buildGate3Product`), вызов судьи (`callJudgeModel`/`runJudge`),
|
||||
маппинг подписанного выбора владельца, доставка возражения/карточки/degraded.
|
||||
- `enforce-supreme-gate.mjs` (стена): ОДИН аддитивный блок в `runGate` на ветке `planComplete` —
|
||||
вызов `writeLoopOpen(...)` (импорт из нового файла). Снятие печати и сдвиг указателя не меняются.
|
||||
- переиспользование без модификации: `loop-termination` (ядро), `judge-engine`
|
||||
(`runJudge`/`buildJudgePrompt`/`VOTE_LAYOUTS.gate3`), `callJudgeModel`, `arbitration-card`,
|
||||
`mentor-nogo-counter`, `negotiation-section`, `objection-delivery`, `escape-grant`,
|
||||
`seal-orchestration` (паттерн owner-seal).
|
||||
|
||||
Порядок (урок самомодификации): сначала весь новый файл по TDD (стену не трогает, безопасно), затем
|
||||
ОДНА правка стены — последней и атомарно/escape-per-step с начала (не дробить мутирующие шаги под
|
||||
живой печатью). Регистрация в реестре хуков — действие владельца.
|
||||
|
||||
## Конвенция {#s7}
|
||||
|
||||
ES-модуль `tools/enforce-gate3-loop.mjs` с тонким `main()` (stdin/exit) и чистыми экспортируемыми
|
||||
функциями (`decideStopTeeth`, отпечаток, sign/verify метки), тестируемыми в изоляции без модели и
|
||||
I/O. Подпись метки — отдельный домен квитанции (`receipt-sign`), как у указателя шага. Без новых
|
||||
внешних зависимостей. Правка стены — минимальная и аддитивная.
|
||||
|
||||
## Вне объёма {#s8}
|
||||
|
||||
- Регистрация хука в реестре + перезапуск — действие владельца.
|
||||
- Полная M/J-память переговоров для gate-3 сверх нужд ядра (diff-версии, две дорожки) — отдельно.
|
||||
- Пред-существующая дыра escape-per-step (работа мимо печати плана) — отдельная граница.
|
||||
|
||||
## Критерий приёмки {#s9}
|
||||
|
||||
TDD-тесты (`enforce-gate3-loop.test.mjs` + врезка в тест стены):
|
||||
|
||||
- подпись метки sign/verify roundtrip; подмена полей → verify false;
|
||||
- отпечаток стабилен на неизменной работе и меняется на новый GREEN / новый довод «Переговоров»;
|
||||
- `decideStopTeeth`: `closed`→allow+clear · `open`→allow+keep · `negotiate`→block · `arbitrate`→
|
||||
block+card · degraded(`wired:false`)→block, НЕ closed/arbitrate;
|
||||
- подписанный канал: неподписанное `ownerArbitration` → `null` → петля не закрывается; подпись
|
||||
`accept` → closed+terminate; `continue` → open;
|
||||
- кэш: `fingerprint` не менялся → судья не зовётся (вызов-счётчик мока = 0);
|
||||
- стена: на `planComplete` пишется подписанная loop-open метка (расширение теста supreme-gate);
|
||||
- полная регрессия tools GREEN: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
|
||||
(база зелёная + новые тесты; переиспользуемые модули не модифицируются → 0 регрессий).
|
||||
|
||||
```verified-context-json
|
||||
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/loop-termination.mjs","anchor":"decideGate3Closure"}]
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* enforce-gate3-loop (E-S1 триггер) — Stop-хук «зубы петли»: стена на завершении плана пишет
|
||||
* метку «петля открыта»; здесь на конце хода судим «цель достигнута?» (gate-3) и блокируем
|
||||
* завершение, пока петля не закрыта. Закрытие — только реальный GO судьи ИЛИ подписанный выбор
|
||||
* владельца (SE-R7-6). Чистые ядра тестируемы без модели/IO; main() — тонкая обёртка.
|
||||
*/
|
||||
import fsDefault from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { canonicalJson, signPayload, verifyReceipt } from './receipt-sign.mjs';
|
||||
import { buildGate3Product, decideGate3Closure } from './loop-termination.mjs';
|
||||
import { escapeGrantOpen } from './escape-grant.mjs';
|
||||
import { parseNegotiationSection } from './negotiation-section.mjs';
|
||||
import { buildArbitrationCard } from './arbitration-card.mjs';
|
||||
import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs';
|
||||
|
||||
const GATE3_LOOP_DOMAIN = 'gate3-loop';
|
||||
const ESCALATE_AFTER = 3;
|
||||
|
||||
export function signLoopMarker(payload, key) { return { ...payload, sig: signPayload(payload, key, GATE3_LOOP_DOMAIN) }; }
|
||||
export function verifyLoopMarker(marker, key) { return verifyReceipt(marker, key, GATE3_LOOP_DOMAIN); }
|
||||
export function loopMarkerPath(runtimeDir, sess) { return join(runtimeDir, `gate3-loop-${sess}.json`); }
|
||||
export function cachePath(runtimeDir, sess) { return join(runtimeDir, `gate3-cache-${sess}.json`); }
|
||||
|
||||
export function writeLoopOpen({ taskId, planId, artifactId, steps, at, key, runtimeDir, sess, fsImpl = fsDefault }) {
|
||||
const marker = signLoopMarker({ taskId: taskId || null, planId, artifactId: artifactId || null, steps: steps || [], at: at || 0 }, key);
|
||||
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(loopMarkerPath(runtimeDir, sess), JSON.stringify(marker)); } catch { /* best-effort */ }
|
||||
}
|
||||
export function readLoopOpen({ runtimeDir, sess, key, fsImpl = fsDefault }) {
|
||||
let m = null;
|
||||
try { m = JSON.parse(fsImpl.readFileSync(loopMarkerPath(runtimeDir, sess), 'utf8')); } catch { return null; }
|
||||
return verifyLoopMarker(m, key) ? m : null;
|
||||
}
|
||||
export function clearLoopOpen({ runtimeDir, sess, fsImpl = fsDefault }) {
|
||||
try { fsImpl.unlinkSync(loopMarkerPath(runtimeDir, sess)); } catch { /* no-op */ }
|
||||
}
|
||||
|
||||
export function computeFingerprint({ planId = '', greenIds = [], negotiationText = '' } = {}) {
|
||||
const sorted = [...greenIds].map(String).sort();
|
||||
return createHash('sha256').update(canonicalJson({ planId: String(planId), greenIds: sorted, negotiationText: String(negotiationText) })).digest('hex');
|
||||
}
|
||||
export function loadCache({ runtimeDir, sess, fsImpl = fsDefault }) {
|
||||
try { return JSON.parse(fsImpl.readFileSync(cachePath(runtimeDir, sess), 'utf8')); } catch { return null; }
|
||||
}
|
||||
export function saveCache({ runtimeDir, sess, cache, fsImpl = fsDefault }) {
|
||||
try { fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.writeFileSync(cachePath(runtimeDir, sess), JSON.stringify(cache)); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
export function buildGate3ProductFromMarker({ marker, frozenArtifact, greens }) {
|
||||
const sections = (frozenArtifact && frozenArtifact.sections) || {};
|
||||
const goal = Object.keys(sections).sort().map((k) => `[${k}] ${sections[k]}`).join('\n') || '(цель не извлечена из опечатанного артефакта)';
|
||||
const greenIds = new Set((Array.isArray(greens) ? greens : []).filter((g) => g && g.green).map((g) => String(g.criterion_id)));
|
||||
const planSteps = (marker && Array.isArray(marker.steps) ? marker.steps : []).map((s) => ({ id: s.criterion_id, op: s.op, object: s.object }));
|
||||
const greenRuns = planSteps.filter((s) => greenIds.has(String(s.id))).map((s) => ({ stepId: s.id, criterion: true }));
|
||||
return buildGate3Product({ goal, planSteps, greenRuns });
|
||||
}
|
||||
|
||||
export function resolveOwnerArbitration({ fingerprint, grants, consumed, now }) {
|
||||
if (escapeGrantOpen(`gate3-arb:accept:${fingerprint}`, grants, consumed, now)) return 'accept';
|
||||
if (escapeGrantOpen(`gate3-arb:continue:${fingerprint}`, grants, consumed, now)) return 'continue';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = null, maxRounds = ESCALATE_AFTER }) {
|
||||
const d = decideGate3Closure({ gate3Verdict: verdict, noGoCount, ownerArbitration, maxRounds });
|
||||
if (d.state === 'closed') return { block: false, clear: true, state: d.state, reason: d.reason };
|
||||
if (d.state === 'open') return { block: false, clear: false, state: d.state, reason: d.reason };
|
||||
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
|
||||
}
|
||||
|
||||
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
|
||||
export async function runGate3Stop(event, deps) {
|
||||
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps;
|
||||
const marker = readLoopOpen({ runtimeDir, sess, key });
|
||||
if (!marker) return { block: false };
|
||||
const greens = (loadGreens && loadGreens()) || [];
|
||||
const greenIds = greens.filter((g) => g && g.green).map((g) => g.criterion_id);
|
||||
const frozenArtifact = (loadArtifact && loadArtifact()) || null;
|
||||
const negotiationText = parseNegotiationSection((frozenArtifact && frozenArtifact._md) || '').map((r) => r.position).join('\n');
|
||||
const fingerprint = computeFingerprint({ planId: marker.planId, greenIds, negotiationText });
|
||||
const cache = loadCache({ runtimeDir, sess }) || { fingerprint: null, verdict: null, noGoCount: 0 };
|
||||
|
||||
let verdict = cache.verdict;
|
||||
let noGoCount = cache.noGoCount || 0;
|
||||
if (cache.fingerprint !== fingerprint) {
|
||||
if (judgeKey && callJudge) {
|
||||
verdict = await callJudge(buildGate3ProductFromMarker({ marker, frozenArtifact, greens }));
|
||||
} else {
|
||||
verdict = { wired: false, decision: 'GO', unavailable: true };
|
||||
}
|
||||
const isContentNoGo = !!verdict && verdict.wired === true && verdict.decision !== 'GO';
|
||||
const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO';
|
||||
noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount);
|
||||
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
|
||||
}
|
||||
|
||||
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
|
||||
const t = decideStopTeeth({ verdict, noGoCount, ownerArbitration });
|
||||
if (t.clear) { clearLoopOpen({ runtimeDir, sess }); saveCache({ runtimeDir, sess, cache: { fingerprint: null, verdict: null, noGoCount: 0 } }); }
|
||||
if (!t.block) return { block: false };
|
||||
|
||||
let message;
|
||||
if (t.state === 'arbitrate') {
|
||||
const card = buildArbitrationCard({ side: 'judge', level: 'L2', round: noGoCount, objectionVerbatim: t.reason || '(возражение судьи)', controllerPositionVerbatim: negotiationText || '(позиция не указана)', sealAction: `gate3-arb:accept:${fingerprint}` });
|
||||
message = `[gate3-loop] ${card.title}\nЦель не подтверждена. Замечание: ${card.objection}\nВыбор владельца: FLOOR-ESCAPE: gate3-arb:accept:${fingerprint} (принять) / gate3-arb:continue:${fingerprint} (продолжать).`;
|
||||
} else if (verdict && verdict.wired === false) {
|
||||
message = buildDegradedFeedback({ side: 'judge', reason: 'судья gate-3 недоступен — петля не закрыта; выход: escape владельца ИЛИ plan-done' });
|
||||
} else {
|
||||
message = buildObjectionFeedback({ side: 'judge', text: t.reason || 'цель не достигнута — доработай или докажи' });
|
||||
}
|
||||
return { block: true, message };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { readStdin, parseEventJson, runtimeDir, exitDecision } = await import('./enforce-hook-helpers.mjs');
|
||||
const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
|
||||
const { resolveJudgeLlmKey } = await import('./judge-gate-config.mjs');
|
||||
const { callJudgeModel } = await import('./enforce-judge-gate.mjs');
|
||||
const { requiredLensesFor, runJudge } = await import('./judge-engine.mjs');
|
||||
const { loadFloorEscapes, loadConsumed } = await import('./escape-grant.mjs');
|
||||
try {
|
||||
const event = parseEventJson(await readStdin());
|
||||
const dir = runtimeDir();
|
||||
const sess = (event && event.session_id) || process.env.CLAUDE_SESSION_ID || 'unknown';
|
||||
const key = resolveReceiptKey();
|
||||
const judgeKey = resolveJudgeLlmKey();
|
||||
const loadGreens = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `criterion-greens-${sess}.json`), 'utf8')); } catch { return []; } };
|
||||
const loadArtifact = () => { try { return JSON.parse(fsDefault.readFileSync(join(dir, `frozen-artifact-${sess}.json`), 'utf8')); } catch { return null; } };
|
||||
const callJudge = async (product) => {
|
||||
const requiredLenses = requiredLensesFor('gate3');
|
||||
const promptArgs = { ...product, roundMemory: {} };
|
||||
const raw = await callJudgeModel({ functionName: 'gate3', requiredLenses, promptArgs, apiKey: judgeKey });
|
||||
if (raw && raw.unavailable) return { wired: false, decision: 'GO', unavailable: true };
|
||||
const v = runJudge({ functionName: 'gate3', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs });
|
||||
return { wired: true, decision: v.decision, verdict: v };
|
||||
};
|
||||
const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now() });
|
||||
exitDecision({ block: !!r.block, message: r.block ? `[gate3-loop] ${r.message || 'петля открыта — цель не подтверждена'}` : undefined });
|
||||
} catch {
|
||||
exitDecision({ block: false }); // Stop fail-OPEN: внутренняя ошибка хука НЕ кирпичит конец хода
|
||||
}
|
||||
}
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isCli) main();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
signLoopMarker, verifyLoopMarker, computeFingerprint,
|
||||
decideStopTeeth, resolveOwnerArbitration, buildGate3ProductFromMarker,
|
||||
} from './enforce-gate3-loop.mjs';
|
||||
|
||||
const KEY = 'test-key-123';
|
||||
|
||||
describe('signLoopMarker/verifyLoopMarker', () => {
|
||||
it('roundtrip', () => {
|
||||
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
|
||||
expect(typeof m.sig).toBe('string');
|
||||
expect(verifyLoopMarker(m, KEY)).toBe(true);
|
||||
});
|
||||
it('подмена поля ломает подпись', () => {
|
||||
const m = signLoopMarker({ taskId: 't1', planId: 'p1', artifactId: 'a1', steps: [], at: 1 }, KEY);
|
||||
expect(verifyLoopMarker({ ...m, planId: 'p2' }, KEY)).toBe(false);
|
||||
});
|
||||
it('нет sig → false', () => { expect(verifyLoopMarker({ taskId: 't1' }, KEY)).toBe(false); });
|
||||
});
|
||||
|
||||
describe('computeFingerprint', () => {
|
||||
it('детерминирован, не зависит от порядка greens', () => {
|
||||
expect(computeFingerprint({ planId: 'p', greenIds: ['c2', 'c1'], negotiationText: 'x' }))
|
||||
.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' }));
|
||||
});
|
||||
it('меняется на новый green', () => {
|
||||
expect(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' }))
|
||||
.not.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1', 'c2'], negotiationText: 'x' }));
|
||||
});
|
||||
it('меняется на новый довод', () => {
|
||||
expect(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'x' }))
|
||||
.not.toBe(computeFingerprint({ planId: 'p', greenIds: ['c1'], negotiationText: 'y' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideStopTeeth', () => {
|
||||
const go = { wired: true, decision: 'GO' };
|
||||
const nogo = { wired: true, decision: 'NO-GO' };
|
||||
const degraded = { wired: false, decision: 'GO' };
|
||||
it('GO → allow + clear', () => {
|
||||
const r = decideStopTeeth({ verdict: go, noGoCount: 0, ownerArbitration: null });
|
||||
expect(r.block).toBe(false); expect(r.clear).toBe(true);
|
||||
});
|
||||
it('accept → allow + clear', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'accept' });
|
||||
expect(r.block).toBe(false); expect(r.clear).toBe(true);
|
||||
});
|
||||
it('continue → allow, держим', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 0, ownerArbitration: 'continue' });
|
||||
expect(r.block).toBe(false); expect(r.clear).toBe(false);
|
||||
});
|
||||
it('NO-GO круг<3 → block negotiate', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 1, ownerArbitration: null });
|
||||
expect(r.block).toBe(true); expect(r.state).toBe('negotiate');
|
||||
});
|
||||
it('NO-GO круг>=3 → block arbitrate+card', () => {
|
||||
const r = decideStopTeeth({ verdict: nogo, noGoCount: 3, ownerArbitration: null });
|
||||
expect(r.block).toBe(true); expect(r.state).toBe('arbitrate'); expect(r.card).toBe(true);
|
||||
});
|
||||
it('degraded → block, НЕ closed/arbitrate', () => {
|
||||
const r = decideStopTeeth({ verdict: degraded, noGoCount: 5, ownerArbitration: null });
|
||||
expect(r.block).toBe(true); expect(r.clear).toBe(false); expect(r.state).toBe('negotiate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOwnerArbitration', () => {
|
||||
const fp = 'abc';
|
||||
const g = (action, ts = 1000) => ({ action, ts });
|
||||
it('accept грант → accept', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g(`gate3-arb:accept:${fp}`)], consumed: [], now: 1000 })).toBe('accept');
|
||||
});
|
||||
it('continue грант → continue', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g(`gate3-arb:continue:${fp}`)], consumed: [], now: 1000 })).toBe('continue');
|
||||
});
|
||||
it('нет гранта → null', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [], consumed: [], now: 1000 })).toBe(null);
|
||||
});
|
||||
it('грант на другой отпечаток → null (анти-реплей)', () => {
|
||||
expect(resolveOwnerArbitration({ fingerprint: fp, grants: [g('gate3-arb:accept:OTHER')], consumed: [], now: 1000 })).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGate3ProductFromMarker', () => {
|
||||
it('цель из секций + шаги + greens', () => {
|
||||
const marker = { steps: [{ op: 'Write', object: 'a.mjs', criterion_id: 'c1' }], planId: 'p' };
|
||||
const frozenArtifact = { sections: { s1: 'построить X', s2: 'критерий Y' } };
|
||||
const out = buildGate3ProductFromMarker({ marker, frozenArtifact, greens: [{ criterion_id: 'c1', green: true }] });
|
||||
expect(out.goal).toContain('построить X');
|
||||
expect(out.product).toContain('a.mjs');
|
||||
expect(out.product).toContain('green');
|
||||
});
|
||||
});
|
||||
@@ -390,7 +390,7 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k
|
||||
* Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг.
|
||||
* journal/saveStep инъектируются (в main — реальные Node fs).
|
||||
*/
|
||||
export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, 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, writeLoopOpen, escapeGrants = [], escapeConsumed = [], now = Date.now() }) {
|
||||
const toolUse = { name: event.tool_name, input: event.tool_input };
|
||||
const incomingAction = actionOf(toolUse);
|
||||
// F-J: двухтактный сдвиг. Сверить открытую ПРЕДВАРИТЕЛЬНУЮ пометку с входным действием ДО
|
||||
@@ -436,6 +436,7 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeT
|
||||
// шага не существует; блок последнего шага со-хуком отдаёт участника в разговорный режим,
|
||||
// не в тупик). Best-effort: сбой снятия НЕ ломает allow.
|
||||
saveStep(r.advanceTo, null);
|
||||
if (typeof writeLoopOpen === 'function') { try { writeLoopOpen(); } catch { /* E-S1: сбой метки не ломает завершение */ } }
|
||||
if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } }
|
||||
} else {
|
||||
// F-J: ПРЕДВАРИТЕЛЬНАЯ пометка вместо немедленного сдвига. Committed-указатель остаётся на
|
||||
@@ -477,11 +478,18 @@ async function main() {
|
||||
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 { writeLoopOpen: writeLoopOpenMarker } = await import('./enforce-gate3-loop.mjs');
|
||||
const { loadTaskId } = await import('./router-task-id.mjs');
|
||||
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 }), // Фаза 5: чистое завершение
|
||||
writeLoopOpen: () => { // E-S1: метка «петля открыта» на planComplete (in-band)
|
||||
let taskId = null;
|
||||
try { taskId = loadTaskId({ sessionId: sess, runtimeDir, fsImpl: fs }); } catch { taskId = null; }
|
||||
writeLoopOpenMarker({ taskId, planId: frozenPlan?.plan_id ?? null, artifactId: frozenPlan?.artifact_id ?? null, steps: (frozenPlan && frozenPlan.steps) || [], at: event.nowMs ?? Date.now(), key, runtimeDir, sess, fsImpl: fs });
|
||||
},
|
||||
});
|
||||
if (r.block) logGuardBlock(event, 'М2 Стена', r.message);
|
||||
exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined });
|
||||
|
||||
@@ -801,6 +801,27 @@ describe('Фаза 5 — чистое завершение плана (стен
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
});
|
||||
it('runGate: последний шаг → writeLoopOpen вызван (метка петли)', () => {
|
||||
let opened = 0;
|
||||
const r = runGateCE({
|
||||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } },
|
||||
frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
|
||||
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
|
||||
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => {}, writeLoopOpen: () => { opened++; },
|
||||
});
|
||||
expect(r.block).toBe(false);
|
||||
expect(opened).toBe(1);
|
||||
});
|
||||
it('runGate: НЕ последний шаг → writeLoopOpen НЕ вызван', () => {
|
||||
let opened = 0;
|
||||
runGateCE({
|
||||
event: { tool_name: 'Write', tool_input: { file_path: 'tools/a.mjs' } },
|
||||
frozenPlan: { ...PLAN2, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE,
|
||||
verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE,
|
||||
journal: () => true, saveStep: () => {}, removeFrozenPlan: () => {}, writeLoopOpen: () => { opened++; },
|
||||
});
|
||||
expect(opened).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── F-J: двухтактный сдвиг указателя (предварительная пометка + подтверждение) ──
|
||||
|
||||
Reference in New Issue
Block a user