Files
portal/tools/round-control.mjs
T
Дмитрий 69e20099db fix(router-mentor): sharp-edges audit M1-M4 — close 8 misuse-resistance holes
Второй аудит машин 1-4 другим объективом (sharp-edges: устойчивость к
неправильному применению / мягкие умолчания / совпадение по пустоте-подстроке).
Криптоядра здоровы (подтверждено). 8 реальных дыр закрыты по TDD:

M3:
- coverage-machine F-1: покрытие считалось по двусторонней ПОДСТРОКЕ — produces
  "a" покрывал запрос "audit-rls-policy" (ложное «всё покрыто»). Новый tokensCover:
  точное равенство ИЛИ подмножество слов по границам. coveringSkill + coverageRegistry.
- router-engine F-8: confidence не проверялся на диапазон — 5/Infinity проходили как
  «уверен» (обход воздержания 5.2), -3 как принуд. abstain. validateTrace: [0,1] finite.
- round-control C: пустой roundKey="" активировал managed-режим (!= null) → все сессии
  делили один счётчик-бакет. Теперь managed требует непустую строку.
- router-learning-queue G: повторное approve уже-решённого id повторно клало запись в
  фонд (дубль). applyApprovalBatch: переводит только status==='pending'.

M2:
- plan-lock F5: шаг с пустым object был джокером (object:'' матчил действие, чей путь
  не извлёкся → object''). actionMatchesStep: пустой object шага не матчит ничего.

M4 (инертна; чистые fail-closed правки кода, корректны и при включении):
- judge-slop-counter H: битый/null вердикт в списке ронял счёт (v.missing на null).
  Теперь не крашит, считается халтурой (безопасная сторона).
- judge-engine J: consensusDecision на пустом/битом списке дрейфовал к GO. Теперь GO
  только если есть голоса И каждый чистый GO; иначе NO-GO (fail-closed для hard-risk).
- judge-orchestrator K: finalGate снимал вето пола на любой falsy floorBlocked
  (undefined от упавшей проверки = fail-open). Теперь снять может только явный false.

Регрессия tools-only 2555 passed + 2 skip (+15 TDD-тестов, 0 регрессий).

Осознанно НЕ менялось (без призраков):
- M1 receipt-sign domain default '' / разделитель пробел — backward-compat контракт
  (тест 18-19), инъективен на enum-доменах без пробелов.
- M1 action-journal атомарность записи головы + битая .jsonl строка — fail-closed
  (битьё → verifyChain ok:false → стена блокирует); чистого behavioral-теста нет.
- M3 round-control requiredSkills=[] — контракт вызывающего (пустой = не требуется).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 06:24:21 +03:00

87 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 };
}