#!/usr/bin/env node /** * round-control — покруговой контроль разговорной фазы (spec 2026-06-05 §3А реш.3): * новый план не рождается, пока текущий круг не сошёлся. Здесь — ЧИСТЫЕ ($0) * части: код-проверка круга (скил реально вызван по журналу + ни один вопрос не * повис) + решение круга (3 прохода диалога → эскалация владельцу). LLM-проверка * смысла (роутер-верность) и оркестратор — под-план 2b. */ import { hangingQuestions } from './question-slots.mjs'; /** * Код-проверка круга ($0, НЕ ИИ, §3А): (1) ни один заданный вопрос не повис * (question-slots); (2) каждый требуемый скил реально вызван (по журналу М1 — * invokedSkills; принцип K2: факт вызова, не текст/слово). requiredSkills/ * invokedSkills — инъекция (журнал читается при live-wiring). */ export function codeRoundCheck({ slots = [], invokedSkills = [], requiredSkills = [] } = {}) { const reasons = []; const hanging = hangingQuestions(slots); if (hanging.length > 0) reasons.push(`повисшие вопросы: [${hanging.map((s) => s.id).join(', ')}]`); const invoked = new Set(invokedSkills); const missing = requiredSkills.filter((s) => !invoked.has(s)); if (missing.length > 0) reasons.push(`скил не вызван (журнал): [${missing.join(', ')}]`); return { ok: reasons.length === 0, reasons }; } // Исходы круга (закрытый список). export const ROUND_OUTCOMES = Object.freeze(['proceed', 'soft-return', 'escalate-owner']); /** * Решение круга (§3А реш.2/3): сошёлся (код+смысл) → proceed; пробел и проходов * < maxRounds → soft-return (дозадай/перезадай нужным скилом); пробел и проходы * исчерпаны → escalate-owner (та же тройка, что апелляция C-12). Двойной * терминатор: раунды→владелец (тут) + Гейт-1 (в конце). * * roundCount — счётчик ЗАВЕРШЁННЫХ проходов (0-based; escalate при >= maxRounds). * Битый счётчик (не целое ≥0) → консервативно эскалируем (аудит F4). * * ⚠️ ДВА РЕЖИМА (аудит F2): * • managed (рекомендуемый, live-wiring): runRoundControl сам ведёт счётчик по * roundKey через инъектируемый стор — инкремент = САМ факт вызова, забыть * невозможно → вечный soft-return исключён по конструкции. См. runRoundControl. * • legacy (pure decideRoundOutcome с явным roundCount): инкремент на * ответственности caller'а; без него — повторный soft-return. Оставлен для * обратной совместимости/юнит-тестов; в проде использовать managed-режим. */ export function decideRoundOutcome({ codeOk, routerFaithful, roundCount = 0, maxRounds = 3 } = {}) { const gap = !codeOk || !routerFaithful; if (!gap) return { outcome: 'proceed', reason: 'круг сошёлся: код + смысл' }; const rc = (Number.isInteger(roundCount) && roundCount >= 0) ? roundCount : maxRounds; if (rc < maxRounds) return { outcome: 'soft-return', reason: `пробел (код:${codeOk} смысл:${routerFaithful}); проход ${rc}/${maxRounds} — мягкий возврат` }; return { outcome: 'escalate-owner', reason: `пробел не закрыт за ${maxRounds} прохода — эскалация владельцу (C-12)` }; } /** * Оркестратор круга (§3А реш.3): связывает код-проверку ($0) + роутер-верность * смысла (verityCall — предсвязанный runVerityCheck: ответы легли в план?) + * решение (3 прохода → владелец). verityCall инъецируется (round-control не знает * про LLM). Сбой верности → routerFaithful=false (не подтвердили → пробел, безопасно). * * F2 — managed-режим терминатора: если переданы roundKey + readRounds/writeRounds * (инъектируемый стор, как в parallel-session-lock/learning-queue), счётчик ведёт * САМ оркестратор: soft-return ⇒ +1 (проход потрачен), proceed/escalate ⇒ сброс в 0. * Инкремент = факт вызова → «забыть подвинуть счётчик» невозможно, вечного * soft-return нет по конструкции. Без roundKey — legacy: используется явный roundCount. */ export async function runRoundControl({ slots = [], invokedSkills = [], requiredSkills = [], roundCount = 0, maxRounds = 3, verityCall, roundKey = null, readRounds, writeRounds } = {}) { const codeCheck = codeRoundCheck({ slots, invokedSkills, requiredSkills }); let verity; try { verity = await verityCall(); } catch { verity = { ok: false, faithful: false, reason: 'сбой верности' }; } const routerFaithful = !!(verity && verity.ok && verity.faithful); // C (аудит M1-M4): пустой/нестроковый roundKey НЕ активирует managed — иначе все // сессии с roundKey='' делят один счётчик-бакет (преждевременная эскалация/сброс чужого круга). const managed = typeof roundKey === 'string' && roundKey.length > 0 && typeof readRounds === 'function' && typeof writeRounds === 'function'; const effectiveCount = managed ? (Number(readRounds(roundKey)) || 0) : roundCount; const decision = decideRoundOutcome({ codeOk: codeCheck.ok, routerFaithful, roundCount: effectiveCount, maxRounds }); if (managed) { if (decision.outcome === 'soft-return') writeRounds(roundKey, effectiveCount + 1); // проход потрачен → следующий вызов видит +1 else writeRounds(roundKey, 0); // proceed/escalate → круг разрешён, сброс } return { outcome: decision.outcome, reason: decision.reason, codeCheck, verity, routerFaithful, roundCount: effectiveCount }; }