From 9b2c4f9e864abad7deaa8c33604d2dee8a0fba76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 17 Jun 2026 03:17:14 +0300 Subject: [PATCH] feat: round-memory owner-seal judge-gate wiring runJudgeTurn sealOnWiredGo sealTurnProd SP3-b4 Co-Authored-By: Claude Opus 4.8 --- tools/enforce-judge-gate.mjs | 41 +++++++++++++++++++++++-------- tools/enforce-judge-gate.test.mjs | 37 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index 75357d5..bd88587 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -24,7 +24,9 @@ import { CLASSIFIER_MODEL, HEAVY_LLM_TIMEOUT_MS } from './router-config.mjs'; import fsDefault from 'node:fs'; import { join } from 'node:path'; // Task 5 (sealed-plan production): печать на реальном wired GO. -import { sealArtifact, sealPlan, sealablePlan, sealableArtifact, judgedHashOf } from './seal-orchestration.mjs'; +import { sealArtifact, sealPlan, sealablePlan, sealableArtifact, judgedHashOf, decideSeal, ownerSealAction } from './seal-orchestration.mjs'; +// SP3-b owner-seal: escape-грант владельца над owner-seal:<хеш тела> (читается в sealTurnProd, sync). +import { escapeGrantOpen, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; import { loadFrozenArtifact, saveFrozenArtifact, saveFrozenPlan, planId } from './plan-lock.mjs'; import { logGuardBlock } from './guard-block-log.mjs'; @@ -299,13 +301,16 @@ export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warn let sealResult = null; if (verdict && verdict.wired) { try { logImpl(buildVerdictEntry(verdict, nowMs)); } catch { /* best-effort */ } - // Task 5: печать на реальном wired GO. Best-effort, НЕ влияет на block-решение - // (печать = одобрение, не энфорсмент). Инъектируется в main() — юнит-тесты hermetic. - if (deps.onWiredSeal) { try { sealResult = deps.onWiredSeal(event, verdict, mode); } catch { /* best-effort */ } } } else if (verdict && verdict.unavailable) { // M7: причина+тип+время → WARN-лог (diagnose degraded без догадок). try { warnImpl(event, { cause: verdict.cause, errorType: verdict.errorType, nowMs }); } catch { /* best-effort */ } } + // SP3-b (ownerseal-wiring-bug): печать пытается встать на КАЖДОЙ записи спеки/плана (judged) — + // НЕ только на wired. sealTurnProd внутри решает (decideSeal): wired-GO обычным путём ЛИБО + // owner-seal перебивает NO-GO/degraded. Раньше вызов сидел под if(verdict.wired) → при NO-GO + // наставника (wired:false) печать пропускалась и owner-seal был мёртвой проводкой. Best-effort, + // НЕ влияет на block-решение (печать = одобрение). onWiredSeal инъектируется в main() — тесты hermetic. + if (judged && deps.onWiredSeal) { try { sealResult = deps.onWiredSeal(event, verdict, mode); } catch { /* best-effort */ } } // M7 наблюдаемость: исход судьи+печати для записи плана/спеки → seal-attempts.jsonl. seal({ functionName: verdict && verdict.verdict && verdict.verdict.functionName, @@ -356,13 +361,15 @@ export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warn * VA-3). judgeMode = режим гейта (shadow/live-block) → запечатан в печать (VA-2). Чистая * (роутинг по пути), реальные deps впрыснуты — для теста переиспользуется без I/O. */ -export function sealOnWiredGo({ event, verdict, judgeMode, deps = {} }) { - if (!(verdict && verdict.wired === true && verdict.decision === 'GO')) return { sealed: false }; +export function sealOnWiredGo({ event, verdict, judgeMode, ownerSealOpen = false, deps = {} }) { + // SP3-b: печать на обычном GO ИЛИ owner-seal (перевешивает NO-GO/degraded). decideSeal — + // единый «мозг» решения; ни GO, ни owner-seal → нет печати (как раньше при !wired-GO). + if (!decideSeal({ verdict, ownerSealOpen }).seal) return { sealed: false }; const fp = String((event && event.tool_input && event.tool_input.file_path) || ''); const content = String((event && event.tool_input && event.tool_input.content) ?? ''); const key = deps.key !== undefined ? deps.key : (deps.resolveReceiptKey ? deps.resolveReceiptKey() : null); if (SPEC_PATH_RE.test(fp)) { - const r = deps.sealArtifact({ md: content, verdict, key, judgeMode }); + const r = deps.sealArtifact({ md: content, verdict, key, judgeMode, ownerSealOpen }); if (r && r.sealed && deps.persistArtifact) deps.persistArtifact(r.seal); return { sealed: !!(r && r.sealed), kind: 'artifact' }; } @@ -370,13 +377,14 @@ export function sealOnWiredGo({ event, verdict, judgeMode, deps = {} }) { // T6 «зубы» наставника (решение владельца 2026-06-12): freeze-gate ПЕРЕД печатью // плана. mentorGate инъектируется прод-сборкой ТОЛЬКО при mentorSeamActive() — // выключен рубильник → undefined → печать как раньше. Бросок гейта → fail-CLOSE. - if (typeof deps.mentorGate === 'function') { + // SP3-b: на owner-seal mentor-gate ПРОПУСКАЕТСЯ (владелец перевешивает И судью, И наставника). + if (!ownerSealOpen && typeof deps.mentorGate === 'function') { let g; try { g = deps.mentorGate({ content }); } catch { g = { pass: false, reason: 'mentor freeze-gate бросил (fail-CLOSE)' }; } if (!g || g.pass !== true) return { sealed: false, kind: 'plan', reason: `mentor freeze-gate: ${(g && g.reason) || 'нет pass'}` }; } const cur = deps.loadCurrentArtifact ? deps.loadCurrentArtifact() : null; - const r = deps.sealPlan({ md: content, currentArtifact: cur, verdict, key, judgeMode }); + const r = deps.sealPlan({ md: content, currentArtifact: cur, verdict, key, judgeMode, ownerSealOpen }); if (r && r.sealed && deps.persistPlan) deps.persistPlan(r.seal); return { sealed: !!(r && r.sealed), kind: 'plan' }; } @@ -399,12 +407,25 @@ export function bindingHashForJudge({ content, functionName } = {}) { function sealTurnProd(event, verdict, mode) { const sessionId = (event && event.session_id) || 'unknown'; const dir = runtimeDir(); + // SP3-b owner-seal: владелец подписал owner-seal:<хеш тела> (escape-грант). Хеш считаем над + // РЕАЛЬНЫМ телом записи (как judged_hash на GO) → работает и на degraded/NO-GO, где вердиктного + // хеша нет. Спека → judgedHashOf(sealableArtifact); план → planId(steps) (как в карточке арбитража). + // Тотально (try): сбой расчёта → ownerSealOpen=false (печать как раньше), баг не кирпичит судью. + let ownerSealOpen = false; + try { + const fp = String((event && event.tool_input && event.tool_input.file_path) || ''); + const content = String((event && event.tool_input && event.tool_input.content) ?? ''); + const hash = SPEC_PATH_RE.test(fp) ? judgedHashOf(sealableArtifact(content)) + : PLAN_PATH_RE.test(fp) ? planId(sealablePlan(content).steps) + : null; + if (hash) ownerSealOpen = escapeGrantOpen(ownerSealAction(hash), loadFloorEscapes(sessionId), loadConsumed(sessionId)); + } catch { ownerSealOpen = false; } // Способ B (Фаза 2): судья — хук ПОСЛЕ наставника, поэтому вердикт наставника уже свежий // (mentor-GO + персист вердикта для plan_hash). Судья САМ печатает план здесь, через // sealOnWiredGo + freeze-gate (verity/VA-9). Прежний «фикс дедлока» (судья сохранял judge-GO, // наставник печатал в Post) снят — порядок теперь правильный. return sealOnWiredGo({ - event, verdict, judgeMode: mode, + event, verdict, judgeMode: mode, ownerSealOpen, deps: { resolveReceiptKey: () => resolveReceiptKey(), sealArtifact, sealPlan, diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs index e95138b..165a258 100644 --- a/tools/enforce-judge-gate.test.mjs +++ b/tools/enforce-judge-gate.test.mjs @@ -500,3 +500,40 @@ describe('M7 наблюдаемость degraded — классификация expect(degraded.at).toBe(777); }); }); + +// SP3-b: owner-seal проводка — оживление печати при NO-GO/degraded + пропуск mentor-gate. +describe('SP3-b owner-seal проводка (sealOnWiredGo + runJudgeTurn)', () => { + it('sealOnWiredGo: NO-GO + ownerSealOpen → sealArtifact зовётся (override перевешивает)', () => { + const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: '## R {#r}\nt' } }; + const calls = []; + sealOnWiredGo({ event, verdict: { wired: true, decision: 'NO-GO' }, judgeMode: 'live-block', ownerSealOpen: true, + deps: { sealArtifact: () => { calls.push('artifact'); return { sealed: true }; }, persistArtifact: () => {} } }); + expect(calls).toEqual(['artifact']); + }); + it('sealOnWiredGo: plan + ownerSealOpen → mentor freeze-gate ПРОПУСКАЕТСЯ, печать встаёт', () => { + const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '```steps-json\n[{"op":"Edit","object":"a","ref":"r"}]\n```' } }; + let mentorCalled = false; + const r = sealOnWiredGo({ event, verdict: { wired: true, decision: 'NO-GO' }, judgeMode: 'live-block', ownerSealOpen: true, + deps: { loadCurrentArtifact: () => ({ artifact_id: 'AID' }), sealPlan: () => ({ sealed: true, seal: {} }), persistPlan: () => {}, + mentorGate: () => { mentorCalled = true; return { pass: false, reason: 'x' }; } } }); + expect(mentorCalled).toBe(false); + expect(r.sealed).toBe(true); + }); + it('sealOnWiredGo: NO-GO без owner-seal → печати нет (sealArtifact не зовётся)', () => { + const event = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/x-design.md', content: 'x' } }; + const calls = []; + sealOnWiredGo({ event, verdict: { wired: true, decision: 'NO-GO' }, judgeMode: 'live-block', + deps: { sealArtifact: () => { calls.push('a'); return {}; } } }); + expect(calls).toEqual([]); + }); + it('runJudgeTurn: wired:false (degraded) + judged → onWiredSeal ВСЁ РАВНО зовётся (оживление проводки)', async () => { + const seen = []; + await runJudgeTurn(planEv(), { + mode: 'shadow', judgeActiveImpl: () => true, apiKey: '', + transport: async () => okText, + logImpl: () => {}, warnImpl: () => {}, sealLogImpl: () => {}, nowMs: 1, + onWiredSeal: (ev, v) => { seen.push(v && v.wired); return { sealed: false }; }, + }); + expect(seen).toEqual([false]); + }); +});