diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index 9f3a072..c2a32d8 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -325,15 +325,30 @@ export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warn }); // SP1: громкая видимость вердикта судьи (best-effort, fail-quiet). if (judged) { + const sessJ = (event && event.session_id) || 'unknown'; + const judgeReason = (verdict && (verdict.reason || (verdict.verdict && verdict.verdict.recommendation))) || ''; try { - pushVerdict((event && event.session_id) || 'unknown', { + pushVerdict(sessJ, { outcome: classifyJudgeOutcome(verdict), gate: 'judge', round: (verdict && verdict.round) ?? null, version: (verdict && verdict.version) ?? null, - reason: (verdict && (verdict.reason || (verdict.verdict && verdict.verdict.recommendation))) || '', + reason: judgeReason, }); } catch { /* fail-quiet */ } + // Видимость «всё в лоб»: судья + печать → персистентный снимок (по этапу/hash, как наставник). + try { + const { writeStage } = await import('./verdict-surface-store.mjs'); + const fpJ = String((event && event.tool_input && event.tool_input.file_path) || ''); + const contentJ = String((event && event.tool_input && event.tool_input.content) ?? ''); + const isSpecJ = SPEC_PATH_RE.test(fpJ); + let hashJ = null; + try { hashJ = isSpecJ ? judgedHashOf(sealableArtifact(contentJ)) : planId(sealablePlan(contentJ).steps); } catch { hashJ = null; } + writeStage(sessJ, { stage: isSpecJ ? 'judge:spec' : 'judge:plan', hash: hashJ, status: classifyJudgeOutcome(verdict), reason: judgeReason, ts: Date.now() }); + if (sealResult && sealResult.sealed) { + writeStage(sessJ, { stage: 'seal', hash: hashJ, status: 'sealed', reason: `печать: ${sealResult.kind || ''}`, ts: Date.now() }); + } + } catch { /* fail-quiet */ } } if (mode === 'shadow') return { block: false }; // D28: логирует, не блокирует // Способ B + §9 (degraded): live-block, судья не дозвонился (нет ключа/таймаут) → контроллер diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index b217859..e34c83c 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -84,6 +84,10 @@ export function isObserveOnly(toolUse, { classifyBash = classifyBashCommand } = // Условие А: read-only Bash по ЭФФЕКТУ; classifyBash консервативен (редирект/писатель → НЕ readonly). if (toolUse.name === 'Bash') { const cmd = toolUse.input?.command || ''; + // verdict-wait — строго read-only сторож видимости (только чтение снимка). Узкий именованный + // пропуск: ровно `node [tools/]verdict-wait.mjs <безопасные-аргументы>`, якорь конца строки → + // никаких ;&|>< (цепочки/субшелл/редирект исключены), чужой node сюда не попадает. + if (/^\s*node\s+(?:\.\/)?(?:tools\/)?verdict-wait\.mjs(?:\s+[\w:.\-/]+)*\s*$/.test(cmd)) return true; const c = classifyBash(cmd); if (!c || c.result !== 'allow' || !/readonly|reading/.test(c.reason || '')) return false; // F-A (аудит 2026-06-07): reason «whitelisted reading command(s)» схлопывается, diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index e7deb60..c5e0396 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -325,6 +325,12 @@ describe('isObserveOnly (зелёный проход = нет долговрем it('мутирующие — НЕТ', () => { for (const n of ['Write', 'Edit', 'MultiEdit', 'Task']) expect(isObserveOnly({ name: n })).toBe(false); }); + it('verdict-wait сторож (строго read-only) — да; цепочка/чужой node — НЕТ', () => { + expect(isObserveOnly({ name: 'Bash', input: { command: 'node tools/verdict-wait.mjs abc judge:plan' } })).toBe(true); + expect(isObserveOnly({ name: 'Bash', input: { command: 'node verdict-wait.mjs abc 300000' } })).toBe(true); + expect(isObserveOnly({ name: 'Bash', input: { command: 'node tools/verdict-wait.mjs && rm -rf x' } })).toBe(false); + expect(isObserveOnly({ name: 'Bash', input: { command: 'node tools/other.mjs' } })).toBe(false); + }); it('исходящие (WebFetch/WebSearch) — НЕТ (риск утечки → пол/судья)', () => { expect(isObserveOnly({ name: 'WebFetch' })).toBe(false); expect(isObserveOnly({ name: 'WebSearch' })).toBe(false); diff --git a/tools/verdict-wait.mjs b/tools/verdict-wait.mjs new file mode 100644 index 0000000..af2efa4 --- /dev/null +++ b/tools/verdict-wait.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * verdict-wait — read-only сторож видимости «всё в лоб». Ждёт ПО ЭТАПАМ, пока стадия снимка не + * «осядет» (status !== pending), с ранним возвратом: лёг за 30с → вернул за 30с; иначе таймаут НА + * ЭТАП (до 5 мин). Ничего не мутирует (только чтение снимка). fail-quiet. + * CLI: node verdict-wait.mjs [timeoutMs]. Запускать в фоне (run_in_background) — + * выход = доставка исхода стадии разработчику. + */ +import { readSnapshot } from './verdict-surface-store.mjs'; + +/** Стадия «осела» — есть запись и статус не pending (терминальный исход). */ +export function isStageSettled(snapshotMap, stage) { + const r = snapshotMap && snapshotMap[stage]; + return !!(r && r.status && r.status !== 'pending'); +} + +/** Человекочитаемая сводка по стадии — для доставки в лицо. */ +export function formatWaitResult(snapshotMap, stage) { + const r = (snapshotMap && snapshotMap[stage]) || null; + if (!r) return `[verdict-wait] ${stage}: нет записи (этап не стартовал)`; + if (r.status === 'pending') return `[verdict-wait] ${stage}: ещё печётся (pending)`; + return `[verdict-wait] ${stage}: ${r.status}${r.reason ? ` — ${r.reason}` : ''}`; +} + +async function main() { + const [hash, stage, timeoutArg] = process.argv.slice(2); + const sid = process.env.CLAUDE_SESSION_ID || 'unknown'; + const timeoutMs = Math.min(Number(timeoutArg) || 300000, 300000); // предел НА ЭТАП — до 5 мин + const stepMs = 2000; + let waited = 0; + for (;;) { + let snap = {}; + try { snap = readSnapshot(sid, hash); } catch { snap = {}; } + if (isStageSettled(snap, stage)) { process.stdout.write(formatWaitResult(snap, stage) + '\n'); return; } + if (waited >= timeoutMs) { process.stdout.write(`[verdict-wait] ${stage}: таймаут ${timeoutMs}ms — этап не осел\n`); return; } + await new Promise((res) => setTimeout(res, stepMs)); + waited += stepMs; + } +} + +import { fileURLToPath } from 'node:url'; +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/verdict-wait.test.mjs b/tools/verdict-wait.test.mjs new file mode 100644 index 0000000..0e8b82e --- /dev/null +++ b/tools/verdict-wait.test.mjs @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { isStageSettled, formatWaitResult } from './verdict-wait.mjs'; + +describe('verdict-wait сторож (read-only, по-этапно)', () => { + it('isStageSettled: pending → false, исход → true, нет записи → false', () => { + expect(isStageSettled({ 'mentor:plan': { status: 'pending' } }, 'mentor:plan')).toBe(false); + expect(isStageSettled({ 'mentor:plan': { status: 'GO' } }, 'mentor:plan')).toBe(true); + expect(isStageSettled({}, 'mentor:plan')).toBe(false); + }); + it('formatWaitResult: pending / исход / нет записи', () => { + expect(formatWaitResult({ router: { status: 'pending' } }, 'router')).toMatch(/печётся/); + expect(formatWaitResult({ router: { status: 'recommend', reason: 'skill-x' } }, 'router')).toMatch(/recommend/); + expect(formatWaitResult({}, 'router')).toMatch(/нет записи/); + }); +});