feat: видимость вердиктов 2 сторож verdict-wait судья и печать в снимок пропуск сторожа в стене
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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, судья не дозвонился (нет ключа/таймаут) → контроллер
|
||||
|
||||
@@ -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)» схлопывается,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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(/нет записи/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user