69e20099db
Второй аудит машин 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>
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 };
|
||
}
|