feat: видимость вердиктов 2 сторож verdict-wait судья и печать в снимок пропуск сторожа в стене

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 13:27:58 +03:00
parent a8489a22c7
commit 9b85f51b7a
5 changed files with 85 additions and 2 deletions
+17 -2
View File
@@ -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, судья не дозвонился (нет ключа/таймаут) → контроллер
+4
View File
@@ -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)» схлопывается,
+6
View File
@@ -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);
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* verdict-wait read-only сторож видимости «всё в лоб». Ждёт ПО ЭТАПАМ, пока стадия снимка не
* «осядет» (status !== pending), с ранним возвратом: лёг за 30с вернул за 30с; иначе таймаут НА
* ЭТАП (до 5 мин). Ничего не мутирует (только чтение снимка). fail-quiet.
* CLI: node verdict-wait.mjs <hash> <stage> [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();
+15
View File
@@ -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(/нет записи/);
});
});