diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 3baed96..f242972 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -29,6 +29,7 @@ import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs' import { loadRegistry } from './registry-load.mjs'; import { extractV4Signals } from './observer-v4-signals.mjs'; import { JUDGE_PER_CALL_USD } from './cost-pricing.mjs'; +import { readVerdictSnapshot, extractFourVerdicts } from './observer-verdicts.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -896,6 +897,9 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option return { schema_version: 4, schema_minor: 4, + // Item 3 (роутер-реестр §7): четыре вердикта (роутер/наставник/судья/gate3) из персистентного + // снимка verdict-surface — по логам восстановимо, на каком звене план отскочил. Нет снимка → все null. + verdicts: extractFourVerdicts(readVerdictSnapshot(sessionId, options.runtimeBaseDir || routerStateBaseDir)), task_id: sessionId, task_ref: sessionId, timestamps: { started_at, ended_at }, diff --git a/tools/observer-verdicts.mjs b/tools/observer-verdicts.mjs new file mode 100644 index 0000000..d42494f --- /dev/null +++ b/tools/observer-verdicts.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * observer-verdicts — извлечение четырёх вердиктов (роутер / наставник / судья / gate3) для + * эпизода наблюдателя из персистентного снимка решений verdict-snapshot-.json (его пишет + * writeStage из verdict-surface-store). Чистые функции; любой сбой I/O — мягкий ({}/null). + * Спека роутер-реестр §7 (логирование решающих — восстановимо, на каком звене план отскочил). + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** + * Читает снимок решений сессии и агрегирует по каждой стадии последнюю запись (max ts). + * @returns {{ [stage:string]: {status, reason, ts} }} либо {} (нет файла / сбой). + */ +export function readVerdictSnapshot(sessionId, baseDir) { + try { + const dir = baseDir || join(homedir(), '.claude', 'runtime'); + const obj = JSON.parse(readFileSync(join(dir, `verdict-snapshot-${sessionId}.json`), 'utf8')); + if (!obj || typeof obj !== 'object') return {}; + const latest = {}; + for (const hash of Object.keys(obj)) { + const stages = obj[hash]; + if (!stages || typeof stages !== 'object') continue; + for (const stage of Object.keys(stages)) { + const v = stages[stage]; + if (!v || typeof v !== 'object') continue; + const ts = Number(v.ts) || 0; + if (!latest[stage] || ts >= (latest[stage].ts || 0)) { + latest[stage] = { status: v.status ?? null, reason: v.reason ?? '', ts }; + } + } + } + return latest; + } catch { return {}; } +} + +/** + * Сводит агрегированный снимок к четырём звеньям. Для звена с вариантами стадий берётся запись + * с наибольшим ts. Поле = {status, reason} либо null (звено не выносило вердикта). + */ +export function extractFourVerdicts(snapshot) { + const s = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const pick = (...stages) => { + let best = null; + for (const st of stages) { + const v = s[st]; + if (v && (!best || (Number(v.ts) || 0) >= (Number(best.ts) || 0))) best = v; + } + return best ? { status: best.status ?? null, reason: best.reason ?? '' } : null; + }; + return { + router: pick('router'), + mentor: pick('mentor:plan', 'mentor:spec'), + judge: pick('judge:plan', 'judge:spec'), + gate3: pick('judge:gate3', 'judge:gate3card'), + }; +} diff --git a/tools/observer-verdicts.test.mjs b/tools/observer-verdicts.test.mjs new file mode 100644 index 0000000..3f2bc5a --- /dev/null +++ b/tools/observer-verdicts.test.mjs @@ -0,0 +1,57 @@ +// tools/observer-verdicts.test.mjs +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readVerdictSnapshot, extractFourVerdicts } from './observer-verdicts.mjs'; + +describe('extractFourVerdicts (D1)', () => { + it('снимок с четырьмя звеньями → все четыре с правильным status', () => { + const snap = { + router: { status: 'recommend', reason: 'r', ts: 1 }, + 'mentor:plan': { status: 'GO', reason: 'm', ts: 2 }, + 'judge:plan': { status: 'GO', reason: 'j', ts: 3 }, + 'judge:gate3': { status: 'NO-GO', reason: 'g', ts: 4 }, + }; + const v = extractFourVerdicts(snap); + expect(v.router.status).toBe('recommend'); + expect(v.mentor.status).toBe('GO'); + expect(v.judge.status).toBe('GO'); + expect(v.gate3.status).toBe('NO-GO'); + }); + it('несколько записей звена → берётся последняя по ts', () => { + const snap = { + 'mentor:plan': { status: 'NO-GO', reason: 'old', ts: 1 }, + 'mentor:spec': { status: 'GO', reason: 'new', ts: 5 }, + }; + expect(extractFourVerdicts(snap).mentor).toEqual({ status: 'GO', reason: 'new' }); + }); + it('отсутствующее звено → null', () => { + expect(extractFourVerdicts({ router: { status: 'recommend', ts: 1 } }).mentor).toBeNull(); + }); + it('пустой снимок → все четыре null', () => { + const v = extractFourVerdicts({}); + expect(v).toEqual({ router: null, mentor: null, judge: null, gate3: null }); + }); +}); + +describe('readVerdictSnapshot (D1)', () => { + it('читает файл снимка и агрегирует последнюю запись по стадии', () => { + const baseDir = mkdtempSync(join(tmpdir(), 'verdict-snap-')); + const obj = { + h1: { router: { status: 'recommend', reason: 'r', ts: 1 }, 'mentor:plan': { status: 'NO-GO', reason: 'a', ts: 2 } }, + h2: { 'mentor:plan': { status: 'GO', reason: 'b', ts: 9 }, 'judge:plan': { status: 'GO', reason: 'j', ts: 10 } }, + }; + writeFileSync(join(baseDir, 'verdict-snapshot-sX.json'), JSON.stringify(obj)); + const snap = readVerdictSnapshot('sX', baseDir); + expect(snap['router'].status).toBe('recommend'); + expect(snap['mentor:plan']).toMatchObject({ status: 'GO', ts: 9 }); // последняя по ts + expect(snap['judge:plan'].status).toBe('GO'); + rmSync(baseDir, { recursive: true, force: true }); + }); + it('нет файла → {} (fail-safe)', () => { + const baseDir = mkdtempSync(join(tmpdir(), 'verdict-snap-')); + expect(readVerdictSnapshot('nope', baseDir)).toEqual({}); + rmSync(baseDir, { recursive: true, force: true }); + }); +});