feat: видимость вердиктов роутер и gate3 в баннер и снимок-стор

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 13:06:07 +03:00
parent fed3c4f9b8
commit a8489a22c7
11 changed files with 307 additions and 12 deletions
@@ -0,0 +1,108 @@
# Полная видимость вердиктов гейтов переговоров — «всё в лоб»
**Дата:** 2026-06-17
**Эпик:** роутер-наставник, наблюдаемость решений. **Статус:** дизайн под реализацию (TDD).
**Предшественник:** SP1 видимость (`verdict-surface-store.mjs` + `enforce-verdict-surface.mjs` +
`verdict-outcome-line.mjs` + `enforce-verdict-ack.mjs`).
## Цель
Разработчик должен видеть **все** решения участников цикла переговоров (роутер, наставник по
спеке и плану, судья по спеке/плану/цели) и факт печати — **в момент их готовности**, единым
достоверным источником, не выводя их по косвенным артефактам. Сейчас в видимость попадают только
наставник и судья (по спеке/плану) и только на старте следующего хода (баннер), а решение роутера
и судьи цели (gate3) теряются. Латентность одного решения — до 5 минут, участники считают
последовательно, поэтому пассивное «подождать и прочитать» не годится: нужен снимок-источник
правды с по-этапным статусом и наблюдатель, доставляющий каждое решение по мере готовности.
## Снимок-стор решений {#snap}
Единый пер-сессионный транзиентный файл `verdict-snapshot-<сессия>.json` (рядом с очередью SP1).
Чистые функции записи/чтения; fail-quiet (любая ошибка → безопасный no-op/пусто).
Запись на участника+артефакт: `{ stage, hash, status, reason, ts }`, где:
- **stage** — `router` | `mentor:spec` | `mentor:plan` | `judge:spec` | `judge:plan` | `judge:gate3` | `seal`;
- **hash** — идентификатор артефакта (план/спека), к которому относится решение;
- **status** — `pending` (старт) → одно из `GO` | `NO-GO` | `degraded` | `skip` | `recommend` (для роутера) | `sealed`;
- **reason** — дословное обоснование/рекомендация;
- **ts** — момент обновления.
Старт стадии пишет `status:'pending'`; завершение перезаписывает её на конечный статус. Чтение
по `hash` возвращает текущую карту стадий. Снимок персистентен внутри сессии (в отличие от очереди,
дренящейся один раз) — читается достоверно в любой момент.
## По-этапная доставка наблюдателем {#watch}
Read-only наблюдатель `verdict-wait` следит за снимком и доставляет решения **по этапам** с ранним
возвратом: ждёт конкретную стадию по `hash`; как только её статус перешёл из `pending` в конечный —
немедленно возвращает (лёг за 30с → вернул за 30с; до предела стадии — ждёт молча). Предел —
**на стадию** (до 5 минут), не общий на весь цикл: реальное ожидание = сумма фактических, а каждое
решение видно в момент готовности. Наблюдатель не мутирует состояние (только чтение снимка).
## Печать и все исходы в лоб {#face}
Доставке подлежит **каждое** приземление, не только печать: решение роутера, наставника (спека/план),
судьи (спека/план/цель) и событие печати (`seal`). Печать — асинхронна (ставится спустя минуты после
записи артефакта), поэтому доставляется тем же наблюдателем как стадия `seal`, громким событием
(«печать встала: <артефакт> <gate> <итог>»). Ни одно решение не требует от разработчика «пойти
посмотреть» — оно приходит само.
## Полнота канала подтверждения {#banner}
Существующий баннер (на старте следующего хода, `enforce-verdict-surface`) дополняется недостающими
входами — решением роутера и судьи цели (gate3) — чтобы оставаться полным как страховочный канал
подтверждения (ack). Источник правды — снимок {#snap}; баннер — удобная сводка, может отставать от
многоминутного решения, поэтому не единственный.
## Проводка недостающих производителей {#wire}
- **Роутер.** Результат `classify` сейчас оседает внутри `onPlanWrite` (используется для контекста
навыков) и наружу не возвращается. Вернуть его рядом с вердиктом наставника, чтобы вызыватель мог
записать стадию `router` в снимок и баннер. Исход роутера — `recommend` (рекомендует цепочку) /
`skip` (скил не нужен, `direct`) / `degraded` (недоступен). Роутер не блокирует — это информативная
стадия.
- **Судья цели (gate3).** Завершение цели судит Stop-обработчик; его решение в снимок/баннер сейчас
не идёт. Добавить запись стадии `judge:gate3` (GO/NO-GO/degraded) в снимок и баннер.
## Крайние случаи {#edge}
- **Решение пришло после дренажа очереди** — снимок персистентен, поэтому статус всё равно читается;
баннер догонит на следующем дренаже. Не теряется.
- **degraded** (участник не дозвонился) маркируется явно отдельным статусом — не выдаётся за исход.
- **Предел стадии исчерпан** (до 5 минут без приземления) — наблюдатель возвращает явный
`timeout`-итог по стадии (не молчит и не выдаёт пустое за результат).
- Всё fail-quiet: сбой записи/чтения снимка никогда не роняет ход и не подделывает решение.
## Конвенция {#conv}
Чистые экспортируемые функции снимка и классификации исхода тестируемы в изоляции без модели/IO.
Наблюдатель — строго read-only (только чтение снимка, без записи/мутаций). Без новых внешних
зависимостей. Изменения существующих производителей — аддитивные (добавляют запись стадии, не
меняют их вердиктную логику).
## ОТЛОЖЕНО — НЕ ПОТЕРЯТЬ {#deferred}
Судья пользовательской карточки (`gate3card`, Фаза 2b приёмки владельца — ещё не построен) при
появлении **обязан** писать своё решение в снимок {#snap} и баннер {#banner} тем же каналом, что
роутер/наставник/судья. Зафиксировано здесь как непропускаемый пункт: добавить стадию `judge:gate3card`
в момент постройки судьи карточек, иначе видимость снова станет неполной.
## Критерий приёмки {#accept}
TDD-тесты:
- снимок: старт пишет `pending`; завершение перезаписывает на конечный статус; чтение по `hash`
возвращает карту стадий; неизвестный hash → пусто; сбой → fail-quiet no-op.
- классификация исхода роутера: `recommend` / `skip` / `degraded` по форме результата `classify`.
- наблюдатель: ранний возврат при переходе стадии `pending→конечный`; предел на стадию (не общий);
read-only (снимок не мутируется).
- проводка: результат роутера возвращается из `onPlanWrite`; стадия `router` пишется в снимок;
стадия `judge:gate3` пишется из Stop-обработчика.
- баннер: содержит роутер и gate3, когда они есть.
- полная регрессия tools зелёная: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism`
(база зелёная + новые тесты; 0 регрессий в SP1-модулях).
```verified-context-json
[{"id":"vc1","kind":"EXTRACTED","ref":"tools/verdict-surface-store.mjs","anchor":"export function pushVerdict("}]
```
+16
View File
@@ -69,6 +69,15 @@ export function decideStopTeeth({ verdict, noGoCount = 0, ownerArbitration = nul
return { block: true, clear: false, state: d.state, card: !!d.card, reason: d.reason };
}
/** Видимость gate3: вердикт судьи цели → запись стадии снимка/баннера. Чистая (без import). */
export function gate3SurfaceRecord({ verdict, hash } = {}) {
let status = 'skip';
if (verdict && verdict.wired === false) status = 'degraded';
else if (verdict && verdict.decision === 'GO') status = 'GO';
else if (verdict && verdict.decision === 'NO-GO') status = 'NO-GO';
return { stage: 'judge:gate3', hash: hash || null, status, reason: (verdict && verdict.reason) || '' };
}
/** Чистая оркестрация хода Stop (deps инъектируются — тест без IO/модели). {block, message?}. */
export async function runGate3Stop(event, deps) {
const { runtimeDir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, grants, consumed, now } = deps;
@@ -93,6 +102,13 @@ export async function runGate3Stop(event, deps) {
const isContentGo = !!verdict && verdict.wired === true && verdict.decision === 'GO';
noGoCount = isContentNoGo ? noGoCount + 1 : (isContentGo ? 0 : noGoCount);
if (verdict && verdict.wired !== false) saveCache({ runtimeDir, sess, cache: { fingerprint, verdict, noGoCount } });
// Видимость «всё в лоб»: свежий gate3-вердикт → снимок + баннер (fail-quiet, не влияет на зубы).
try {
const { writeStage, pushVerdict } = await import('./verdict-surface-store.mjs');
const rec = gate3SurfaceRecord({ verdict, hash: marker.planId });
writeStage(sess, { ...rec, ts: Date.now() }, runtimeDir);
pushVerdict(sess, { outcome: rec.status, gate: 'judge:gate3', round: null, version: null, reason: rec.reason }, runtimeDir);
} catch { /* fail-quiet */ }
}
const ownerArbitration = resolveOwnerArbitration({ fingerprint, grants, consumed, now });
+21
View File
@@ -92,6 +92,27 @@ describe('buildGate3ProductFromMarker', () => {
});
});
import { gate3SurfaceRecord } from './enforce-gate3-loop.mjs';
describe('gate3SurfaceRecord (видимость gate3)', () => {
it('GO verdict → stage judge:gate3, status GO', () => {
expect(gate3SurfaceRecord({ verdict: { wired: true, decision: 'GO' }, hash: 'h' }))
.toEqual({ stage: 'judge:gate3', hash: 'h', status: 'GO', reason: '' });
});
it('NO-GO → status NO-GO, reason дословно', () => {
const r = gate3SurfaceRecord({ verdict: { wired: true, decision: 'NO-GO', reason: 'не достигнуто' }, hash: 'h' });
expect(r.status).toBe('NO-GO');
expect(r.reason).toBe('не достигнуто');
});
it('degraded (wired:false) → degraded', () => {
expect(gate3SurfaceRecord({ verdict: { wired: false }, hash: 'h' }).status).toBe('degraded');
});
it('нет verdict → skip, hash null', () => {
expect(gate3SurfaceRecord({}).status).toBe('skip');
expect(gate3SurfaceRecord({}).hash).toBe(null);
});
});
describe('loop marker delivery', () => {
const KEY = 'k-loop-deliv';
it('delivery в подписанной метке верифицируется и ломается при подмене', () => {
+17 -8
View File
@@ -199,7 +199,7 @@ export async function runMentorOnPlanWrite(event, {
const planHash = planId(steps);
try { persistVerdictImpl({ ok: r.ok, wired: r.wired, reason: r.reason ?? null, planHash, verdict: r.verdict }); } catch { /* best-effort */ }
if (r.journalOk && r.journal) { try { persistJournalImpl(r.journal); } catch { /* best-effort (SE10) */ } }
return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict };
return { ran: true, ok: r.ok, wired: r.wired, reason: r.reason, taskId: r.taskId, planHash, verdict: r.verdict, routerClassification: r.routerClassification ?? null };
}
async function main() {
@@ -233,13 +233,22 @@ async function main() {
if (res && res.ran) {
// SP1: громкая видимость вердикта наставника (best-effort, fail-quiet).
try {
pushVerdict(sess, {
outcome: classifyJudgeOutcome({ wired: res.wired, decision: res.verdict && res.verdict.decision }),
gate: 'mentor',
round: null,
version: null,
reason: (res.verdict && res.verdict.recommendation) || res.reason || '',
});
const mentorOutcome = classifyJudgeOutcome({ wired: res.wired, decision: res.verdict && res.verdict.decision });
const mentorReason = (res.verdict && res.verdict.recommendation) || res.reason || '';
pushVerdict(sess, { outcome: mentorOutcome, gate: 'mentor', round: null, version: null, reason: mentorReason });
// Видимость «всё в лоб»: снимок-стадии наставника + роутера (баннер + персистентный снимок).
const fpV = String((event.tool_input && event.tool_input.file_path) || '');
const stageM = SPEC_PATH_RE.test(fpV) ? 'mentor:spec' : 'mentor:plan';
const { writeStage } = await import('./verdict-surface-store.mjs');
writeStage(sess, { stage: stageM, hash: res.planHash, status: mentorOutcome, reason: mentorReason, ts: Date.now() });
if (res.routerClassification) {
const { classifyRouterOutcome } = await import('./verdict-outcome-line.mjs');
const routerOutcome = classifyRouterOutcome(res.routerClassification);
const rc = res.routerClassification.recommended_chain;
const routerReason = Array.isArray(rc) && rc.length ? `рекомендует: ${rc.join(', ')}` : 'скил не требуется';
pushVerdict(sess, { outcome: routerOutcome, gate: 'router', round: null, version: null, reason: routerReason });
writeStage(sess, { stage: 'router', hash: res.planHash, status: routerOutcome, reason: routerReason, ts: Date.now() });
}
} catch { /* fail-quiet */ }
// Р7/§3.4: счётчик L1 растёт на NO-GO = содержательный decision='NO-GO' ИЛИ сломанный
// вердикт (ok!==true). degraded (wired:false) не считается (escalation L1 не растёт).
@@ -139,6 +139,12 @@ describe('runMentorOnPlanWrite (обёртка-производитель W7)',
expect(r.ran).toBe(true);
expect(capturedUser).toContain('память дорожки spec');
});
// Видимость: результат роутера протягивается наружу из runMentorOnPlanWrite (для снимка/баннера).
it('видимость: runMentorOnPlanWrite (план) возвращает routerClassification', async () => {
const d = deps({ classifyImpl: async () => ({ recommended_chain: ['systematic-debugging'] }) });
const r = await runMentorOnPlanWrite(planEvent, d);
expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] });
});
});
describe('decideMentorObjection (Фаза 1 — канал замечаний наставника контроллеру)', () => {
+4 -2
View File
@@ -60,11 +60,13 @@ export async function onPlanWrite({
// Мерж роутер↔наставник: зовём classify() как функцию (мозг роутера цел). Сбой/недоступен →
// recommendedChain=null → наставник судит план БЕЗ скил-сверки (fail-safe §5, не ложный NO-GO).
let recommendedChain = null;
let routerClassification = null; // видимость: сырой результат classify наружу для снимка/баннера
if (typeof classifyImpl === 'function') {
try {
const c = await classifyImpl(planGoal, registry);
routerClassification = c ?? null;
recommendedChain = recommendedChainOf(c);
} catch { recommendedChain = null; }
} catch { recommendedChain = null; routerClassification = { unavailable: true }; }
}
const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain, registry });
// Производитель вердикта (C T5b): сбой → ok:false/wired:false (SE-R6-6, не суд).
@@ -86,7 +88,7 @@ export async function onPlanWrite({
}, { key: journalKey, nowMs });
journalOk = true;
} catch { journal = null; journalOk = false; }
return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk };
return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk, routerClassification };
}
/**
+24
View File
@@ -101,6 +101,30 @@ describe('onPlanWrite — скил-сверка через classify (мерж)',
});
});
describe('onPlanWrite возвращает результат роутера наружу (видимость)', () => {
const GO = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' };
it('routerClassification = результат classify', async () => {
const r = await onPlanWrite({
planSteps: STEPS,
classifyImpl: async () => ({ recommended_chain: ['systematic-debugging'] }),
registry: {}, llmCall: async () => GO, journalKey: 'k', nowMs: 1, planGoal: 'g',
});
expect(r.routerClassification).toEqual({ recommended_chain: ['systematic-debugging'] });
});
it('classifyImpl бросил → routerClassification = { unavailable: true }', async () => {
const r = await onPlanWrite({
planSteps: STEPS,
classifyImpl: async () => { throw new Error('нет'); },
registry: {}, llmCall: async () => GO, journalKey: 'k', nowMs: 1, planGoal: 'g',
});
expect(r.routerClassification).toEqual({ unavailable: true });
});
it('без classifyImpl → routerClassification = null (роутер не звался)', async () => {
const r = await onPlanWrite({ planSteps: STEPS, llmCall: async () => GO, journalKey: 'k', nowMs: 1 });
expect(r.routerClassification).toBe(null);
});
});
describe('оркестратор протягивает roundMemory до построителя (SP2c-2)', () => {
it('onPlanWrite → roundMemory доходит до промпта вердикта', async () => {
let capturedUser = null;
+15 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
/** verdict-outcome-line — чистые функции отображения вердикта (SP1, спек 2026-06-16-verdict-visibility). */
const EMOJI = { 'GO': '✅', 'NO-GO': '🚫', 'degraded': '⚠', 'skip': '⤳' };
const EMOJI = { 'GO': '✅', 'NO-GO': '🚫', 'degraded': '⚠', 'skip': '⤳', 'recommend': '🧭' };
const BAR = '━'.repeat(36);
/** Сырой вердикт судьи/наставника → исход. wired:false = degraded; иначе по decision. */
@@ -32,5 +32,18 @@ export function buildVerdictBanner({ outcome, gate, round, version, reason } = {
/** true, если в первых строках текста есть подтверждение `вердикт: <outcome>`. */
export function parseVerdictAck(text) {
return /^\s*вердикт:\s*(GO|NO-GO|NOGO|degraded|skip)\b/im.test(String(text || ''));
return /^\s*вердикт:\s*(GO|NO-GO|NOGO|degraded|skip|recommend)\b/im.test(String(text || ''));
}
/** Исход роутера (совещательный, не блокирует): recommend (рекомендует цепочку) / skip (скил не
* нужен, direct) / degraded (недоступен/сбой). Вход — результат classify (recommended_chain или
* recommended_node/node). */
export function classifyRouterOutcome(c) {
if (!c || typeof c !== 'object') return 'degraded';
if (c.unavailable === true || c.wired === false) return 'degraded';
const chain = Array.isArray(c.recommended_chain) ? c.recommended_chain : [];
if (chain.length) return 'recommend';
const node = c.recommended_node ?? c.node ?? null;
if (node && node !== 'direct') return 'recommend';
return 'skip';
}
+31
View File
@@ -31,3 +31,34 @@ describe('parseVerdictAck', () => {
expect(parseVerdictAck('обычный ответ')).toBe(false);
});
});
import { classifyRouterOutcome } from './verdict-outcome-line.mjs';
describe('classifyRouterOutcome (видимость роутера)', () => {
it('recommended_chain непустой → recommend', () => {
expect(classifyRouterOutcome({ recommended_chain: ['skill-x'] })).toBe('recommend');
});
it('recommended_node не direct → recommend', () => {
expect(classifyRouterOutcome({ recommended_node: 'skill-y' })).toBe('recommend');
});
it('direct / пустой → skip (скил не нужен)', () => {
expect(classifyRouterOutcome({ recommended_node: 'direct' })).toBe('skip');
expect(classifyRouterOutcome({ recommended_chain: [] })).toBe('skip');
});
it('null / недоступен → degraded', () => {
expect(classifyRouterOutcome(null)).toBe('degraded');
expect(classifyRouterOutcome({ unavailable: true })).toBe('degraded');
expect(classifyRouterOutcome({ wired: false })).toBe('degraded');
});
});
describe('buildVerdictBanner + parseVerdictAck: recommend', () => {
it('баннер recommend несёт ярлык и гейт', () => {
const b = buildVerdictBanner({ outcome: 'recommend', gate: 'router', reason: 'рекомендую skill-x' });
expect(b).toContain('recommend');
expect(b).toContain('[router]');
});
it('parseVerdictAck ловит вердикт: recommend', () => {
expect(parseVerdictAck('вердикт: recommend')).toBe(true);
});
});
+31
View File
@@ -51,3 +51,34 @@ export function clearPendingAck(sid, baseDir) {
try { const p = ackPath(sid, baseDir); if (existsSync(p)) writeFileSync(p, 'null'); return true; }
catch { return false; }
}
// Снимок стадий (видимость «всё в лоб»): персистентная карта решений по артефакту, читаемая в любой
// момент (в отличие от очереди, дренящейся один раз). Файл = { [hash]: { [stage]: {status,reason,ts} } }.
function snapshotPath(sid, baseDir) { return join(baseOf(baseDir), `verdict-snapshot-${sid || 'unknown'}.json`); }
/** Upsert стадии решения по hash артефакта. status: pending → GO|NO-GO|degraded|skip|recommend|sealed. */
export function writeStage(sid, { stage, hash, status, reason = '', ts = 0 } = {}, baseDir) {
try {
if (!stage || !hash) return false;
ensureDir(baseDir);
const p = snapshotPath(sid, baseDir);
let obj = existsSync(p) ? readJson(p) : {};
if (!obj || typeof obj !== 'object') obj = {};
if (!obj[hash] || typeof obj[hash] !== 'object') obj[hash] = {};
obj[hash][stage] = { status, reason, ts };
writeFileSync(p, JSON.stringify(obj));
return true;
} catch { return false; }
}
/** Карта стадий по hash: { [stage]: {status,reason,ts} } или {} (неизвестный hash / сбой). */
export function readSnapshot(sid, hash, baseDir) {
try {
const p = snapshotPath(sid, baseDir);
if (!existsSync(p)) return {};
const obj = readJson(p);
if (!obj || typeof obj !== 'object') return {};
const v = obj[hash];
return v && typeof v === 'object' ? v : {};
} catch { return {}; }
}
+34
View File
@@ -30,3 +30,37 @@ describe('verdict-surface-store', () => {
expect(drainVerdicts('s1', '\0bad')).toEqual([]);
});
});
import { writeStage, readSnapshot } from './verdict-surface-store.mjs';
describe('verdict-surface-store снимок стадий (видимость «всё в лоб»)', () => {
it('writeStage pending → читается через readSnapshot по hash', () => {
const d = dir();
writeStage('s1', { stage: 'mentor:plan', hash: 'h1', status: 'pending', ts: 1 }, d);
expect(readSnapshot('s1', 'h1', d)['mentor:plan'].status).toBe('pending');
});
it('завершение перезаписывает pending на исход (та же стадия+hash)', () => {
const d = dir();
writeStage('s1', { stage: 'mentor:plan', hash: 'h1', status: 'pending', ts: 1 }, d);
writeStage('s1', { stage: 'mentor:plan', hash: 'h1', status: 'NO-GO', reason: 'переделай', ts: 2 }, d);
const snap = readSnapshot('s1', 'h1', d);
expect(snap['mentor:plan'].status).toBe('NO-GO');
expect(snap['mentor:plan'].reason).toBe('переделай');
});
it('разные стадии одного hash сосуществуют', () => {
const d = dir();
writeStage('s1', { stage: 'router', hash: 'h1', status: 'recommend', ts: 1 }, d);
writeStage('s1', { stage: 'judge:plan', hash: 'h1', status: 'GO', ts: 2 }, d);
const snap = readSnapshot('s1', 'h1', d);
expect(snap.router.status).toBe('recommend');
expect(snap['judge:plan'].status).toBe('GO');
});
it('неизвестный hash → пустой объект', () => {
const d = dir();
expect(readSnapshot('s1', 'nope', d)).toEqual({});
});
it('fail-quiet: битый baseDir не кидает (снимок)', () => {
expect(() => writeStage('s1', { stage: 'router', hash: 'h', status: 'GO' }, '\0bad')).not.toThrow();
expect(readSnapshot('s1', 'h', '\0bad')).toEqual({});
});
});