#!/usr/bin/env node /** * spec-verity — роутер-шаг ВЫХОДНОЙ лесенки (spec 2026-06-05 §3А «контроль на * выходе», шаг 2): когда контроллер объявил «готово», роутер сверяет ГОТОВУЮ * спеку против СЫРЫХ ответов владельца + ОДОБРЕННОГО плана. Ловит «прошёл * движения, а написал своё» (kind fabricated — в спеке есть, в ответах/плане нет). * Существование ($0) и судья (полнота+пре-мортем) — Машина 4, НЕ здесь. * РАССУЖДЕНИЕ — у модели (llmCall мокается); проверки вокруг — механические. * R8: это ИИ — грубое «написал не то» ловит, тонкую подмену смыслом может * пропустить → последний рубеж владелец. */ import { parseRouterResponse } from './router-engine.mjs'; export const SPEC_VERITY_SLOTS = Object.freeze(['promised', 'discrepancies']); export const SPEC_DISCREPANCY_KINDS = Object.freeze(['missing', 'distorted', 'fabricated']); function isNonEmptyString(v) { return typeof v === 'string' && v.trim().length > 0; } function isSpecSourceRef(v) { return v === 'prompt' || v === 'plan' || (typeof v === 'number' && Number.isInteger(v)); } /** * Валидатор формы (отдельный код): promised — НЕПУСТОЙ массив {item, source}; * discrepancies — массив (пуст = спека верна) {kind∈SPEC_DISCREPANCY_KINDS, * detail, source}. source: 'prompt' | 'plan' | номер ответа. */ export function validateSpecVerityTrace(trace) { const missingSlots = []; const badItems = []; if (!trace || typeof trace !== 'object') return { ok: false, missingSlots: [...SPEC_VERITY_SLOTS], badItems }; if (!Array.isArray(trace.promised) || trace.promised.length === 0) { missingSlots.push('promised'); } else { trace.promised.forEach((it, i) => { if (!it || typeof it !== 'object' || !isNonEmptyString(it.item) || !isSpecSourceRef(it.source)) badItems.push(`promised[${i}]`); }); } if (!Array.isArray(trace.discrepancies)) { missingSlots.push('discrepancies'); } else { trace.discrepancies.forEach((it, i) => { const kindOk = it && typeof it === 'object' && SPEC_DISCREPANCY_KINDS.includes(it.kind); if (!kindOk || !isNonEmptyString(it.detail) || !isSpecSourceRef(it.source)) badItems.push(`discrepancies[${i}]`); }); } return { ok: missingSlots.length === 0 && badItems.length === 0, missingSlots, badItems }; } /** Заземление: source резолвится 'prompt' | 'plan' | индекс ответа в [0, answersCount). */ export function groundSpecVerityTrace(trace, { answersCount = 0 } = {}) { const dangling = []; const check = (src, where) => { if (src === 'prompt' || src === 'plan') return; if (typeof src === 'number' && Number.isInteger(src) && src >= 0 && src < answersCount) return; dangling.push(`${where}:${JSON.stringify(src)}`); }; (trace?.promised || []).forEach((it, i) => check(it?.source, `promised[${i}]`)); (trace?.discrepancies || []).forEach((it, i) => check(it?.source, `discrepancies[${i}]`)); return { grounded: dangling.length === 0, dangling }; } /** Построитель промпта (чистый, {system,user}). Анти-внушение: promised из якорей ДО чтения спеки. */ export function buildSpecVerityPrompt({ rawPrompt = '', answers = [], approvedPlan = null, spec = '' } = {}) { const ans = Array.isArray(answers) ? answers : []; const answersText = ans.length ? ans.map((a, i) => `Ответ ${i}: ${typeof a === 'string' ? a : JSON.stringify(a)}`).join('\n') : '(ответов нет)'; const planText = approvedPlan == null ? '(одобренного плана нет)' : (typeof approvedPlan === 'string' ? approvedPlan : JSON.stringify(approvedPlan)); const specText = typeof spec === 'string' ? spec : JSON.stringify(spec); const system = [ 'Ты — РОУТЕР-НАСТАВНИК, ВЫХОДНАЯ сверка верности спеки. Контроллер объявил «готово».', 'ПОРЯДОК (анти-внушение): СНАЧАЛА, читая ТОЛЬКО якоря (сырые ответы владельца + одобренный план), независимо выпиши, что было ОБЕЩАНО → promised (source: "prompt"|"plan"|номер ответа). ДО чтения спеки.', 'ПОТОМ сверь готовую спеку с promised и выпиши discrepancies: missing (обещано, в спеке нет), distorted (в спеке искажено), fabricated (в спеке есть, но НИ в ответах НИ в плане не обещано — «написал своё»).', 'Спека верна (расхождений нет) → discrepancies пустой — допустимо.', 'ВЫВОД — строго JSON: {"promised":[{"item":"…","source":"prompt"|"plan"|N}], "discrepancies":[{"kind":"missing"|"distorted"|"fabricated","detail":"…","source":"prompt"|"plan"|N}]}. Пустой promised недопустим.', ].join('\n'); const user = [ '--- СЫРОЙ ПРОМПТ ВЛАДЕЛЬЦА ---', rawPrompt, '--- СЫРЫЕ ОТВЕТЫ ВЛАДЕЛЬЦА ПО ПОРЯДКУ ---', answersText, '--- ОДОБРЕННЫЙ ПЛАН (якорь) ---', planText, '--- ГОТОВАЯ СПЕКА (читать ПОСЛЕ promised) ---', specText, ].join('\n'); return { system, user }; } /** Оркестратор: построить → llmCall → распарсить → валидировать → заземлить → faithful=код. */ export async function runSpecVerityCheck({ rawPrompt = '', answers = [], approvedPlan = null, spec = '', llmCall }) { const ans = Array.isArray(answers) ? answers : []; let trace; try { trace = await llmCall({ buildPrompt: () => buildSpecVerityPrompt({ rawPrompt, answers: ans, approvedPlan, spec }) }); } catch { return { ok: false, reason: 'сбой вызова модели', trace: null }; } if (typeof trace === 'string') trace = parseRouterResponse(trace); if (!trace) return { ok: false, reason: 'пустой/неразборный ответ роутера', trace: null }; const v = validateSpecVerityTrace(trace); if (!v.ok) return { ok: false, reason: `невалидная трасса спеки: слоты [${v.missingSlots.join(', ')}] пункты [${v.badItems.join(', ')}]`, trace }; const g = groundSpecVerityTrace(trace, { answersCount: ans.length }); if (!g.grounded) return { ok: false, reason: `висящие ссылки: [${g.dangling.join(', ')}]`, trace }; const faithful = trace.discrepancies.length === 0; return { ok: true, faithful, discrepancies: trace.discrepancies, trace }; }