From a8489a22c7846d799ed025584d577a726a970584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 17 Jun 2026 13:06:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=80=D0=BE=D1=83=D1=82=D0=B5=D1=80=20=D0=B8=20?= =?UTF-8?q?gate3=20=D0=B2=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA-=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...7-verdict-visibility-all-in-face-design.md | 108 ++++++++++++++++++ tools/enforce-gate3-loop.mjs | 16 +++ tools/enforce-gate3-loop.test.mjs | 21 ++++ tools/enforce-mentor-on-plan-write.mjs | 25 ++-- tools/enforce-mentor-on-plan-write.test.mjs | 6 + tools/on-plan-write.mjs | 6 +- tools/on-plan-write.test.mjs | 24 ++++ tools/verdict-outcome-line.mjs | 17 ++- tools/verdict-outcome-line.test.mjs | 31 +++++ tools/verdict-surface-store.mjs | 31 +++++ tools/verdict-surface-store.test.mjs | 34 ++++++ 11 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-17-verdict-visibility-all-in-face-design.md diff --git a/docs/superpowers/specs/2026-06-17-verdict-visibility-all-in-face-design.md b/docs/superpowers/specs/2026-06-17-verdict-visibility-all-in-face-design.md new file mode 100644 index 0000000..ffcd5ab --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-verdict-visibility-all-in-face-design.md @@ -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`, громким событием +(«печать встала: <артефакт> <итог>»). Ни одно решение не требует от разработчика «пойти +посмотреть» — оно приходит само. + +## Полнота канала подтверждения {#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("}] +``` diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs index cb4ecb2..4a74a1f 100644 --- a/tools/enforce-gate3-loop.mjs +++ b/tools/enforce-gate3-loop.mjs @@ -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 }); diff --git a/tools/enforce-gate3-loop.test.mjs b/tools/enforce-gate3-loop.test.mjs index 1ed0f88..bfb0bc7 100644 --- a/tools/enforce-gate3-loop.test.mjs +++ b/tools/enforce-gate3-loop.test.mjs @@ -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 в подписанной метке верифицируется и ломается при подмене', () => { diff --git a/tools/enforce-mentor-on-plan-write.mjs b/tools/enforce-mentor-on-plan-write.mjs index 2a6b348..5fecfda 100644 --- a/tools/enforce-mentor-on-plan-write.mjs +++ b/tools/enforce-mentor-on-plan-write.mjs @@ -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 не растёт). diff --git a/tools/enforce-mentor-on-plan-write.test.mjs b/tools/enforce-mentor-on-plan-write.test.mjs index b526b72..62803f7 100644 --- a/tools/enforce-mentor-on-plan-write.test.mjs +++ b/tools/enforce-mentor-on-plan-write.test.mjs @@ -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 — канал замечаний наставника контроллеру)', () => { diff --git a/tools/on-plan-write.mjs b/tools/on-plan-write.mjs index b4639ae..9953271 100644 --- a/tools/on-plan-write.mjs +++ b/tools/on-plan-write.mjs @@ -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 }; } /** diff --git a/tools/on-plan-write.test.mjs b/tools/on-plan-write.test.mjs index 79d889c..49286b2 100644 --- a/tools/on-plan-write.test.mjs +++ b/tools/on-plan-write.test.mjs @@ -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; diff --git a/tools/verdict-outcome-line.mjs b/tools/verdict-outcome-line.mjs index e601dcc..83d1f8e 100644 --- a/tools/verdict-outcome-line.mjs +++ b/tools/verdict-outcome-line.mjs @@ -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, если в первых строках текста есть подтверждение `вердикт: `. */ 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'; } diff --git a/tools/verdict-outcome-line.test.mjs b/tools/verdict-outcome-line.test.mjs index 1456c72..b7ddbe1 100644 --- a/tools/verdict-outcome-line.test.mjs +++ b/tools/verdict-outcome-line.test.mjs @@ -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); + }); +}); diff --git a/tools/verdict-surface-store.mjs b/tools/verdict-surface-store.mjs index 150f5e4..271cf9f 100644 --- a/tools/verdict-surface-store.mjs +++ b/tools/verdict-surface-store.mjs @@ -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 {}; } +} diff --git a/tools/verdict-surface-store.test.mjs b/tools/verdict-surface-store.test.mjs index 92777c9..a181dad 100644 --- a/tools/verdict-surface-store.test.mjs +++ b/tools/verdict-surface-store.test.mjs @@ -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({}); + }); +});