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:
Дмитрий
2026-06-17 08:54:54 +03:00
parent dfa5ef180f
commit e4a0b48c0a
6 changed files with 961 additions and 1 deletions
@@ -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"}]
```
+147
View File
@@ -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();
+93
View File
@@ -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');
});
});
+9 -1
View File
@@ -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 });
+21
View File
@@ -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: двухтактный сдвиг указателя (предварительная пометка + подтверждение) ──