64 lines
4.7 KiB
JavaScript
64 lines
4.7 KiB
JavaScript
#!/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).
|
||
* ОБЯЗАН приходить из неподделываемого источника (журнал М1) при live-wiring —
|
||
* модуль сам НЕ инкрементит; без внешнего инкремента терминатор не сработает.
|
||
* Битый/неподанный счётчик (не целое ≥0) → консервативно эскалируем (аудит F4),
|
||
* чтобы не зациклиться в вечном soft-return.
|
||
*/
|
||
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 (не подтвердили → пробел, безопасно).
|
||
*/
|
||
export async function runRoundControl({ slots = [], invokedSkills = [], requiredSkills = [], roundCount = 0, maxRounds = 3, verityCall } = {}) {
|
||
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);
|
||
const decision = decideRoundOutcome({ codeOk: codeCheck.ok, routerFaithful, roundCount, maxRounds });
|
||
return { outcome: decision.outcome, reason: decision.reason, codeCheck, verity, routerFaithful };
|
||
}
|