feat(observer): эпизод несёт четыре вердикта (роутер/наставник/судья/gate3)
Новый observer-verdicts: читает персистентный verdict-snapshot-<sid>.json и сводит к четырём звеньям (последний вердикт по ts на звено). Эпизод наблюдателя получил поле verdicts из снимка текущей сессии → по логам восстановимо, на каком звене план отскочил. Раньше в эпизоде был только сигнал роутера. Граница не тронута (observer-stop-hook, recommended_chain, цепочки). Хвост спеки роутера §7 (логирование решающих), эпик роутер-реестр этап 3, item 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* observer-verdicts — извлечение четырёх вердиктов (роутер / наставник / судья / gate3) для
|
||||
* эпизода наблюдателя из персистентного снимка решений verdict-snapshot-<sid>.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'),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user