397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
6.6 KiB
JavaScript
87 lines
6.6 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).
|
||
* Битый счётчик (не целое ≥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 };
|
||
}
|