diff --git a/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v3.md b/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v3.md new file mode 100644 index 0000000..24811e9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-es1-gate3-trigger-stop-hook-v3.md @@ -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-.json`, кэш `gate3-cache-.json`. +- метка: `{ taskId, planId, artifactId, steps, at, sig }`. +- escape-метки владельца: `gate3-arb:accept:` / `gate3-arb:continue:`. + +```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 " -- 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"}] +``` diff --git a/docs/superpowers/specs/2026-06-17-es1-gate3-trigger-stop-hook-design.md b/docs/superpowers/specs/2026-06-17-es1-gate3-trigger-stop-hook-design.md new file mode 100644 index 0000000..00ae121 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-es1-gate3-trigger-stop-hook-design.md @@ -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:` (принять цель достигнутой) и + `gate3-arb:continue:` (продолжать работу); + - `` привязывает выбор к ЭТОЙ открытой петле (анти-реплей: старый грант не закроет + новую петлю); + - владелец кликает опцию AskUserQuestion `FLOOR-ESCAPE: gate3-arb:accept:` → + `enforce-askuser-answer-parser` подписывает грант ключом владельца (тот же тракт, что + floor-escape/owner-seal) → Stop-хук читает `loadFloorEscapes`/`escapeGrantOpen(canonicalAction)` + → маппит в `ownerArbitration='accept'|'continue'`; непроверенное/неподписанное → `null`. + +Карточка арбитража несёт точную escape-строку `gate3-arb:accept:` (как 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"}] +``` diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs new file mode 100644 index 0000000..bdd6d20 --- /dev/null +++ b/tools/enforce-gate3-loop.mjs @@ -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(); diff --git a/tools/enforce-gate3-loop.test.mjs b/tools/enforce-gate3-loop.test.mjs new file mode 100644 index 0000000..fb21c30 --- /dev/null +++ b/tools/enforce-gate3-loop.test.mjs @@ -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'); + }); +}); diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index f2f7c87..66a539e 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -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 }); diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 012a4ef..e7deb60 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -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: двухтактный сдвиг указателя (предварительная пометка + подтверждение) ──