feat(m4): judge — discipline floor + seal channel + gate floor + engine + orchestration + postfactum evaluator + inert hook wrapper

This commit is contained in:
Дмитрий
2026-06-06 03:07:38 +03:00
parent afb01219cb
commit 079adfd184
28 changed files with 1503 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* enforce-judge-gate — тонкая хук-обёртка судьи (Машина 4). НЕ движок: движок —
* judge-orchestrator + judge-engine + пол судьи (4-A..4-F). Обёртка ИНЕРТНА, пока
* владелец не включит судью (флаг + ключ + регистрация в settings.json — шаг владельца).
* Даже включённая, по D28 «сперва тихо» она НЕ блокирует — лишь помечает теневое решение;
* живой блокирующий режим подключается отдельным шагом владельца. $0 пока выключен.
*
* Регистрировать в settings.json нужно ИМЕННО эту обёртку (не движок) — у движка нет
* рубильника $0; обёртка гейтит расход флагом+ключом (паттерн llm-judge v4).
*/
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
import { judgeActive } from './judge-gate-config.mjs';
/** Чистое решение обёртки. Никогда не блокирует на этом этапе (D28 тихий режим). */
export function decide({ active }) {
if (!active) return { block: false, reason: 'judge disabled ($0) — рубильник владельца выключен' };
return { block: false, reason: 'shadow (D28) — живой блок подключается шагом владельца (live wiring)' };
}
async function main() {
try {
parseEventJson(await readStdin()); // событие читаем, но пока не используем (тихий режим)
const active = judgeActive();
const r = decide({ active });
exitDecision({ block: r.block });
} catch {
exitDecision({ block: false }); // fail-open пока инертна; живой режим будет fail-closed
}
}
import { fileURLToPath } from 'node:url';
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
+16
View File
@@ -0,0 +1,16 @@
// tools/enforce-judge-gate.test.mjs
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-judge-gate.mjs';
describe('enforce-judge-gate.decide (обёртка судьи; инертна до включения владельцем)', () => {
it('судья выключен → пропуск ($0, ничего не блокирует)', () => {
const r = decide({ event: { tool_name: 'Write' }, active: false });
expect(r.block).toBe(false);
expect(r.reason).toMatch(/disabled|выключ|\$0/i);
});
it('судья включён → тихий режим D28 (пропуск + пометка, живой блок — шаг владельца)', () => {
const r = decide({ event: { tool_name: 'Write' }, active: true });
expect(r.block).toBe(false);
expect(r.reason).toMatch(/shadow|тих|D28|wiring/i);
});
});
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* judge-anchor (§9.3 / §11.1) — механическая граница «находка vs призрак».
* Возражение указывает ЯКОРЬ на конкретику; анкерованное МОЖЕТ блокировать,
* безъякорное авто-демотится в СОВЕТ (не блокирует, но и не глушится — в нём
* бывают зёрна, §13). Работает в обе стороны: голос-призрак с безъякорным «НЕТ»
* не клинит ворота. Качество мысли не судим — только наличие опоры на факт.
*/
export const ANCHOR_KINDS = Object.freeze([
'spec_section', // § спеки
'card_need', // нужда карточки навыка
'test_name', // имя теста
'failed_criterion', // проваленный критерий приёмки
'observation', // конкретное наблюдение (напр. строка кода)
]);
function isAnchored(objection) {
const a = objection && objection.anchor;
if (!a || typeof a !== 'object') return false;
if (!ANCHOR_KINDS.includes(a.kind)) return false;
return typeof a.ref === 'string' && a.ref.trim().length > 0;
}
export function classifyObjection(objection) {
const anchored = isAnchored(objection);
return { anchored, disposition: anchored ? 'block' : 'advice' };
}
export function partitionObjections(objections = []) {
const blocking = [];
const advisory = [];
for (const o of objections) {
(classifyObjection(o).disposition === 'block' ? blocking : advisory).push(o);
}
return { blocking, advisory };
}
+45
View File
@@ -0,0 +1,45 @@
// tools/judge-anchor.test.mjs
import { describe, it, expect } from 'vitest';
import { classifyObjection, partitionObjections, ANCHOR_KINDS } from './judge-anchor.mjs';
describe('classifyObjection (§9.3 anchor gate)', () => {
it('anchored objection (valid kind + ref) → can block', () => {
const r = classifyObjection({ text: 'X не закрыто', anchor: { kind: 'spec_section', ref: '§3' } });
expect(r.anchored).toBe(true);
expect(r.disposition).toBe('block');
});
it('every valid anchor kind is accepted', () => {
for (const kind of ANCHOR_KINDS) {
expect(classifyObjection({ text: 't', anchor: { kind, ref: 'r' } }).anchored).toBe(true);
}
});
it('anchorless objection → demoted to advice (not silenced)', () => {
const r = classifyObjection({ text: 'вообще тревожно' });
expect(r.anchored).toBe(false);
expect(r.disposition).toBe('advice');
});
it('invalid anchor kind → not anchored → advice', () => {
expect(classifyObjection({ text: 't', anchor: { kind: 'vibes', ref: 'x' } }).disposition).toBe('advice');
});
it('anchor present but empty ref → not anchored → advice', () => {
expect(classifyObjection({ text: 't', anchor: { kind: 'test_name', ref: ' ' } }).disposition).toBe('advice');
});
});
describe('partitionObjections (both-ways gate)', () => {
it('splits into blocking (anchored) and advisory (anchorless)', () => {
const objs = [
{ text: 'a', anchor: { kind: 'failed_criterion', ref: 'tY' } },
{ text: 'b' },
{ text: 'c', anchor: { kind: 'card_need', ref: 'frontend.mobile' } },
];
const r = partitionObjections(objs);
expect(r.blocking).toHaveLength(2);
expect(r.advisory).toHaveLength(1);
});
it('a ghost NO with no anchor never reaches the blocking set', () => {
const r = partitionObjections([{ text: 'НЕТ, не нравится', verdict: 'NO' }]);
expect(r.blocking).toEqual([]);
expect(r.advisory).toHaveLength(1);
});
});
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* judge-engine (§7/§8/§10, K7, G1) — думающая часть судьи. Образец — router-classifier:
* построитель промпта ЧИСТЫЙ (детерминированно тестируется), сам вызов модели МОКАЕТСЯ,
* а ПРОВЕРКИ вокруг — тупо-механические (пол судьи 4-A). «Умный не баран».
* - VOTE_LAYOUTS: раскладка голосов по функциям (§7), голоса РАЗДЕЛЬНО.
* - PREMORTEM_CLASSES: фиксированные классы провала (§10) — общая карта, не растущий список.
* - K7: премортем-слот на Гейте-1 обязателен (входит в layout gate1).
* - G1 classifyByReversibility: фатальное = тяжёлое И необратимое → блок; иначе совет.
* - runJudge: один вердикт + механический пост-чек (слоты/под-прогоны/якорь/обратимость).
* - consensusDecision (J3): на высоком риске несколько судей, любой НЕТ → блок.
* Судья СЛЕП к переписке: на вход подаётся только продукт+цель+карточки (по конструкции).
*/
import { canonicalJson } from './receipt-sign.mjs';
import { validateVerdictSlots } from './judge-verdict-slots.mjs';
import { partitionObjections } from './judge-anchor.mjs';
import { requiredSubRunsPresent } from './judge-subrun-journal.mjs';
export const VOTE_LAYOUTS = Object.freeze({
gate1: ['completeness', 'premortem', 'goal_advocate', 'correctness'], // премортем = K7
gate2: ['spec_fidelity', 'verifiability', 'plan_soundness', 'execution_risk', 'step_clarity'],
a2_divergence: ['intent_fidelity'],
a2_destructive: ['radius_reversibility', 'attacker'],
part_light: ['correctness', 'simplicity', 'footgun'],
part_risky: ['radius_tests', 'twins', 'attacker'],
gate3: ['goal_achieved', 'premortem_whole', 'behavior_vs_goal'],
});
export const PREMORTEM_CLASSES = Object.freeze([
'technique', 'seam', 'user', 'future_change', 'abuse', 'money_irreversible',
]);
/** Линзы функции (+ по риску добавляются attacker и money). */
export function requiredLensesFor(functionName, { risk = false } = {}) {
const base = VOTE_LAYOUTS[functionName] ? [...VOTE_LAYOUTS[functionName]] : [];
if (risk) {
if (!base.includes('attacker')) base.push('attacker');
if (!base.includes('money')) base.push('money');
}
return base;
}
/** Чистый построитель промпта судьи: {system, user}. Детерминирован. */
export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [] }) {
const system = [
'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.',
'Ты СЛЕП к переписке и рассуждениям сторон — судишь только поданный продукт против цели.',
'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).',
`Функция: ${functionName}.`,
`Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`,
`Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${PREMORTEM_CLASSES.join(', ')}.`,
'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.',
].join('\n');
const user = [
`ЦЕЛЬ (неизменный якорь): ${goal}`,
`КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`,
`ПРОДУКТ НА СУД: ${canonicalJson(product)}`,
].join('\n');
return { system, user };
}
/** G1: что блокирует (фатальное=тяжёлое И необратимое), что идёт советом. */
export function classifyByReversibility({ severity, reversible } = {}) {
const heavy = severity === 'fatal' || severity === 'heavy';
return (heavy && reversible === false) ? 'block' : 'advice';
}
function isBlockingObjection(o) {
return classifyByReversibility(o) === 'block';
}
/**
* Один заход судьи + механический пост-чек. llmCall(prompt)→{decision, slots, objections}.
* Механика поверх модели: слоты валидны? под-прогоны на месте? какие возражения реально
* блокируют (якорь + необратимость)? Модель НЕ может проштамповать GO мимо механики.
*/
export function runJudge({ functionName, requiredLenses, subRunsRequired = [], subRuns = [], llmCall, promptArgs = {} }) {
const prompt = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
const raw = llmCall(prompt) || {};
const slotCheck = validateVerdictSlots(raw, requiredLenses);
if (!slotCheck.valid) {
return { decision: 'NO-GO', accepted: false, reason: 'слоты вердикта невалидны (§9.2)', slotCheck };
}
const subRunCheck = requiredSubRunsPresent(subRunsRequired, subRuns);
if (!subRunCheck.ok) {
return { decision: 'NO-GO', accepted: false, reason: 'нет требуемого под-прогона (фейк прилежности, дисциплина #1)', subRunCheck };
}
const noObjs = (raw.objections || []).filter((o) => o.verdict === 'NO');
const { blocking: anchored, advisory: anchorless } = partitionObjections(noObjs);
const trulyBlocking = anchored.filter(isBlockingObjection); // якорь + необратимость
const demotedReversible = anchored.filter((o) => !isBlockingObjection(o)); // якорь, но обратимо → совет
const advice = [...anchorless, ...demotedReversible];
return {
decision: trulyBlocking.length ? 'NO-GO' : 'GO',
accepted: true,
blocking: trulyBlocking,
advice,
slots: raw.slots,
};
}
/** J3-консенсус (высокий риск, несколько судей): один НЕТ → общий блок. */
export function consensusDecision(verdicts = []) {
return (verdicts || []).some((v) => v && v.decision === 'NO-GO') ? 'NO-GO' : 'GO';
}
+110
View File
@@ -0,0 +1,110 @@
// tools/judge-engine.test.mjs
import { describe, it, expect } from 'vitest';
import {
requiredLensesFor, buildJudgePrompt, classifyByReversibility, runJudge, consensusDecision,
PREMORTEM_CLASSES, VOTE_LAYOUTS,
} from './judge-engine.mjs';
describe('requiredLensesFor (§7 раскладка голосов по функциям)', () => {
it('Гейт-1 несёт 4 ядра, включая премортем (линза K7)', () => {
const l = requiredLensesFor('gate1');
expect(l).toContain('completeness');
expect(l).toContain('premortem'); // K7
expect(l).toContain('goal_advocate');
expect(l).toContain('correctness');
});
it('Гейт-2 несёт 5 голосов, включая проверяемость (K5)', () => {
expect(requiredLensesFor('gate2')).toEqual(VOTE_LAYOUTS.gate2);
expect(requiredLensesFor('gate2')).toContain('verifiability');
});
it('риск добавляет Атакующего и Деньги', () => {
const l = requiredLensesFor('gate2', { risk: true });
expect(l).toContain('attacker');
expect(l).toContain('money');
});
});
describe('buildJudgePrompt (чистая, детерминированная; слепа к переписке)', () => {
const args = { functionName: 'gate1', requiredLenses: ['completeness', 'premortem'], product: { spec: 'X' }, goal: 'лендинг', cards: ['frontend-design'] };
it('тот же вход → тот же промпт (детерминизм)', () => {
expect(buildJudgePrompt(args)).toEqual(buildJudgePrompt({ ...args }));
});
it('system содержит требуемые линзы и классы премортема и якорь цели', () => {
const { system, user } = buildJudgePrompt(args);
expect(system).toContain('completeness');
expect(system).toContain('premortem');
for (const c of PREMORTEM_CLASSES) expect(system).toContain(c);
expect(user).toContain('лендинг');
});
});
describe('classifyByReversibility (G1: фатальное=тяжёлое И необратимое → блок)', () => {
it('тяжёлое И необратимое → блок', () => {
expect(classifyByReversibility({ severity: 'fatal', reversible: false })).toBe('block');
expect(classifyByReversibility({ severity: 'heavy', reversible: false })).toBe('block');
});
it('обратимое → совет (даже тяжёлое)', () => {
expect(classifyByReversibility({ severity: 'heavy', reversible: true })).toBe('advice');
});
it('косметика → совет', () => {
expect(classifyByReversibility({ severity: 'cosmetic', reversible: false })).toBe('advice');
});
});
describe('runJudge (механические проверки вокруг мокнутой модели)', () => {
const lenses = ['completeness', 'premortem'];
const fullSlots = { completeness: 'все нужды §1 закрыты подробно', premortem: 'риск отступа не закрыт детально' };
const base = { functionName: 'gate1', requiredLenses: lenses, promptArgs: { product: {}, goal: 'g', cards: [] } };
it('полные слоты + GO + нет блокирующих → GO', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.accepted).toBe(true);
});
it('пустой требуемый слот → вердикт невалиден → НЕ GO', () => {
const llmCall = () => ({ decision: 'GO', slots: { completeness: 'ок и подробно' }, objections: [] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('NO-GO');
expect(r.accepted).toBe(false);
});
it('якорное фатально-необратимое возражение → блок', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'дыра', severity: 'fatal', reversible: false, anchor: { kind: 'spec_section', ref: '§2' } },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('NO-GO');
expect(r.blocking).toHaveLength(1);
});
it('безъякорное «НЕТ» → демотится в совет, ворота не клинит → GO', () => {
const llmCall = () => ({ decision: 'NO-GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'вообще не нравится', severity: 'fatal', reversible: false },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.advice.length).toBeGreaterThan(0);
});
it('обратимое якорное возражение → совет, не блок', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'некрасиво', severity: 'heavy', reversible: true, anchor: { kind: 'observation', ref: 'L10' } },
] });
const r = runJudge({ ...base, llmCall });
expect(r.decision).toBe('GO');
expect(r.advice).toHaveLength(1);
});
it('требуемый под-прогон отсутствует → вердикт не принят (фейк прилежности)', () => {
const llmCall = () => ({ decision: 'GO', slots: fullSlots, objections: [] });
const r = runJudge({ ...base, llmCall, subRunsRequired: [{ lens: 'premortem' }], subRuns: [] });
expect(r.decision).toBe('NO-GO');
expect(r.accepted).toBe(false);
});
});
describe('consensusDecision (J3: один НЕТ → блок)', () => {
it('любой NO-GO среди судей → общий NO-GO', () => {
expect(consensusDecision([{ decision: 'GO' }, { decision: 'NO-GO' }, { decision: 'GO' }])).toBe('NO-GO');
});
it('все GO → GO', () => {
expect(consensusDecision([{ decision: 'GO' }, { decision: 'GO' }])).toBe('GO');
});
});
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* judge-evaluator (D31, §9.4) — оценщик ПОСТФАКТУМ. Отдельный (не сам судья):
* сверяет прошлые вердикты судьи И роутера с тем, что вылезло НИЖЕ (на Гейте-3 / у
* владельца). Промах = проблема, которую пропустил GO-вердикт. Систематическую халтуру
* ловит по СЛЕДУ (частота промахов), а не по одному вердикту. Чистая функция над данными;
* источник (лог вердиктов J8 + всплывшие проблемы) подаётся снаружи (4-G/живой прогон).
*/
const DEFAULT_THRESHOLD = 0.2;
function rate(misses, totalGo) {
return totalGo > 0 ? misses / totalGo : 0;
}
/**
* @param {object} a
* @param {Array<{verdict_id,decision,source?}>} a.verdicts - прошлые вердикты (GO/NO-GO)
* @param {Array<{missed_by,where?}>} a.surfaced - проблемы, вылезшие ниже (missed_by → id вердикта)
* @param {number} [a.threshold]
*/
export function evaluatePostfactum({ verdicts = [], surfaced = [], threshold = DEFAULT_THRESHOLD } = {}) {
const goById = new Map();
for (const v of verdicts) {
if (v && v.decision === 'GO') goById.set(v.verdict_id, v.source || 'unknown');
}
// Промах = всплывшая проблема, пропущенная GO-вердиктом (NO-GO-вердикт её «поймал» — не промах).
const missList = (surfaced || []).filter((s) => s && goById.has(s.missed_by));
const totalGo = goById.size;
const misses = missList.length;
const bySource = {};
const sources = new Set([...goById.values()]);
for (const src of sources) {
const srcGo = [...goById.values()].filter((x) => x === src).length;
const srcMiss = missList.filter((s) => goById.get(s.missed_by) === src).length;
bySource[src] = { totalGo: srcGo, misses: srcMiss, missRate: rate(srcMiss, srcGo) };
}
const missRate = rate(misses, totalGo);
return { totalGo, misses, missRate, flag: totalGo > 0 && missRate > threshold, bySource };
}
+50
View File
@@ -0,0 +1,50 @@
// tools/judge-evaluator.test.mjs
import { describe, it, expect } from 'vitest';
import { evaluatePostfactum } from './judge-evaluator.mjs';
describe('evaluatePostfactum (D31, §9.4): прошлые GO vs что вылезло ниже', () => {
it('нет всплывших проблем → промахов нет, флага нет', () => {
const r = evaluatePostfactum({
verdicts: [{ verdict_id: 'v1', decision: 'GO', source: 'judge' }],
surfaced: [],
});
expect(r.misses).toBe(0);
expect(r.flag).toBe(false);
});
it('проблема вылезла ниже и её пропустил GO-вердикт → промах', () => {
const r = evaluatePostfactum({
verdicts: [{ verdict_id: 'v1', decision: 'GO', source: 'judge' }, { verdict_id: 'v2', decision: 'GO', source: 'judge' }],
surfaced: [{ missed_by: 'v1', where: 'gate3' }],
threshold: 0.4,
});
expect(r.misses).toBe(1);
expect(r.totalGo).toBe(2);
expect(r.missRate).toBeCloseTo(0.5);
expect(r.flag).toBe(true);
});
it('проблема указывает на NO-GO вердикт (судья поймал) → не промах того вердикта', () => {
const r = evaluatePostfactum({
verdicts: [{ verdict_id: 'v1', decision: 'NO-GO', source: 'judge' }],
surfaced: [{ missed_by: 'v1', where: 'owner' }],
});
expect(r.misses).toBe(0);
});
it('разбивка по источнику (судья vs роутер)', () => {
const r = evaluatePostfactum({
verdicts: [
{ verdict_id: 'j1', decision: 'GO', source: 'judge' },
{ verdict_id: 'r1', decision: 'GO', source: 'router' },
],
surfaced: [{ missed_by: 'j1' }, { missed_by: 'r1' }],
threshold: 0.9,
});
expect(r.bySource.judge.misses).toBe(1);
expect(r.bySource.router.misses).toBe(1);
});
it('ниже порога → флага нет', () => {
const verdicts = Array.from({ length: 10 }, (_, i) => ({ verdict_id: 'v' + i, decision: 'GO', source: 'judge' }));
const r = evaluatePostfactum({ verdicts, surfaced: [{ missed_by: 'v0' }], threshold: 0.2 });
expect(r.missRate).toBeCloseTo(0.1);
expect(r.flag).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* judge-gate-config — рубильник судьи (как resolveJudgeConfig у llm-judge v4).
* Судья АКТИВЕН только если владелец включил флаг ROUTER_MENTOR_JUDGE_ENABLED=1 И
* ключ судьи доступен. Без флага/ключа — инертен, $0, ничего не блокирует (режим D28
* «сперва тихо»). Регистрация хука в settings.json + флаг + ключ — шаг ВЛАДЕЛЬЦА.
*/
import { resolveJudgeKey } from './judge-key-config.mjs';
const ENABLED_ENV = 'ROUTER_MENTOR_JUDGE_ENABLED';
export function resolveJudgeEnabled({ env = process.env } = {}) {
return env[ENABLED_ENV] === '1';
}
export function judgeActive({ env = process.env, keychainGet } = {}) {
if (!resolveJudgeEnabled({ env })) return false;
return !!resolveJudgeKey({ env, keychainGet });
}
export const _internals = { ENABLED_ENV };
+24
View File
@@ -0,0 +1,24 @@
// tools/judge-gate-config.test.mjs
import { describe, it, expect } from 'vitest';
import { resolveJudgeEnabled, judgeActive } from './judge-gate-config.mjs';
describe('resolveJudgeEnabled (рубильник владельца)', () => {
it('флаг =1 → включён', () => {
expect(resolveJudgeEnabled({ env: { ROUTER_MENTOR_JUDGE_ENABLED: '1' } })).toBe(true);
});
it('нет флага → выключен ($0)', () => {
expect(resolveJudgeEnabled({ env: {} })).toBe(false);
});
});
describe('judgeActive (активен только при флаге И ключе — иначе $0/инертен)', () => {
it('флаг + ключ → активен', () => {
expect(judgeActive({ env: { ROUTER_MENTOR_JUDGE_ENABLED: '1' }, keychainGet: () => 'k' })).toBe(true);
});
it('флаг есть, ключа нет → НЕ активен (fail-closed, нечем подписать)', () => {
expect(judgeActive({ env: { ROUTER_MENTOR_JUDGE_ENABLED: '1' }, keychainGet: () => null })).toBe(false);
});
it('ключ есть, флага нет → НЕ активен ($0 до рубильника)', () => {
expect(judgeActive({ env: {}, keychainGet: () => 'k' })).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* judge-gate-floor (§3.1/§3.2/§3.5, K2/K5, F7) — механический пол гейтов ($0, не ИИ).
* Бесплатные проверки «было/не было», на которых стоит думающая часть судьи:
* - existenceCheck (Гейт-1 шаг 1): все вопросы отвечены + обещанные навыки реально
* вызваны по журналу (неподделываемый канал), не по тексту.
* - specToPlanCoverage (Гейт-2): каждый § запечатанной спеки покрыт ≥1 шагом плана.
* - k5CriterionCheck (Гейт-2, жемчужина K5): значимый шаг объявляет КОНКРЕТНЫЙ критерий;
* голое «проверено/готово» за факт НЕ берём; значимость осторожна (сомнение → значим).
* - criteriaGreenMatched (Гейт-3): каждый критерий сопоставлен с настоящим ЗЕЛЁНЫМ прогоном.
* - skillTakenByJournal (A2, K2): навык взят по журналу вызовов, не по тексту TodoWrite.
* Тупо-механика: уболтать нельзя. Качество — думающая часть 4-D + владелец.
*/
/** Обещанные навыки реально вызваны (по журналу)? Общий помощник для Гейта-1 и A2. */
function skillsCoveredByJournal(promised = [], invoked = []) {
const seen = new Set(invoked);
return (promised || []).filter((s) => !seen.has(s));
}
/** Гейт-1 шаг 1: вопросы отвечены + обещанные навыки в журнале. */
export function existenceCheck({ questions = [], skillsPromised = [], skillsInvoked = [] }) {
const unanswered = (questions || []).filter((q) => !q.answered).map((q) => q.id);
const missingSkills = skillsCoveredByJournal(skillsPromised, skillsInvoked);
return { ok: unanswered.length === 0 && missingSkills.length === 0, unanswered, missingSkills };
}
/** Гейт-2 пол: каждый § спеки покрыт хотя бы одним шагом плана (по step.ref). */
export function specToPlanCoverage({ specSections = [], planSteps = [] }) {
const covered = new Set((planSteps || []).map((s) => s && s.ref).filter(Boolean));
const uncovered = (specSections || []).filter((sec) => !covered.has(sec));
return { ok: uncovered.length === 0, uncovered };
}
// Голое done-слово (не критерий). Кириллические варианты — подстрокой/якорем строки.
const BARE_DONE_RE = /^(проверено|проверен[аоы]|сделано|готово|ок|done|passed|pass|ready)\.?$/i;
const MIN_CRITERION_LEN = 6;
/** Гейт-2 K5: значимый шаг → конкретный проверяемый критерий; «проверено» не факт. */
export function k5CriterionCheck({ planSteps = [] }) {
const missingCriterion = [];
for (const s of planSteps || []) {
if (s && s.trivial === true) continue; // механический микрошаг — критерий не нужен
if (s && s.significant === false) continue; // явно НЕ значим
const c = s && typeof s.criterion === 'string' ? s.criterion.trim() : '';
const concrete = c.length >= MIN_CRITERION_LEN && !BARE_DONE_RE.test(c);
if (!concrete) missingCriterion.push(s.n);
}
return { ok: missingCriterion.length === 0, missingCriterion };
}
/** Гейт-3 пол: каждый объявленный критерий сопоставлен с настоящим ЗЕЛЁНЫМ прогоном. */
export function criteriaGreenMatched({ criteria = [], greenRuns = [] }) {
const greenIds = new Set((greenRuns || []).filter((r) => r && r.green === true).map((r) => r.criterion_id));
const unproven = (criteria || []).filter((c) => !greenIds.has(c.id)).map((c) => c.id);
return { ok: unproven.length === 0, unproven };
}
/** A2 K2: требуемые навыки взяты по фактическому журналу вызовов (не по тексту). */
export function skillTakenByJournal({ requiredSkills = [], journalSkillCalls = [] }) {
const missing = skillsCoveredByJournal(requiredSkills, journalSkillCalls);
return { ok: missing.length === 0, missing };
}
+94
View File
@@ -0,0 +1,94 @@
// tools/judge-gate-floor.test.mjs
import { describe, it, expect } from 'vitest';
import {
existenceCheck, specToPlanCoverage, k5CriterionCheck, criteriaGreenMatched, skillTakenByJournal,
} from './judge-gate-floor.mjs';
describe('existenceCheck (Гейт-1 шаг 1, $0): вопросы отвечены + навыки реально вызваны', () => {
it('ok когда все вопросы отвечены и все обещанные навыки в журнале', () => {
const r = existenceCheck({
questions: [{ id: 'q1', answered: true }, { id: 'q2', answered: true }],
skillsPromised: ['brainstorming', 'frontend-design'],
skillsInvoked: ['brainstorming', 'frontend-design'],
});
expect(r.ok).toBe(true);
expect(r.unanswered).toEqual([]);
expect(r.missingSkills).toEqual([]);
});
it('повисший вопрос → не ok', () => {
const r = existenceCheck({ questions: [{ id: 'q1', answered: false }], skillsPromised: [], skillsInvoked: [] });
expect(r.ok).toBe(false);
expect(r.unanswered).toEqual(['q1']);
});
it('обещанный навык не вызван по журналу → не ok (фейк-покрытие)', () => {
const r = existenceCheck({ questions: [], skillsPromised: ['frontend-design'], skillsInvoked: ['brainstorming'] });
expect(r.ok).toBe(false);
expect(r.missingSkills).toEqual(['frontend-design']);
});
});
describe('specToPlanCoverage (Гейт-2 пол): каждый § спеки покрыт шагом', () => {
it('все секции покрыты → ok', () => {
const r = specToPlanCoverage({ specSections: ['§1', '§2'], planSteps: [{ ref: '§1' }, { ref: '§2' }, { ref: '§1' }] });
expect(r.ok).toBe(true);
});
it('секция без шага → uncovered', () => {
const r = specToPlanCoverage({ specSections: ['§1', '§2', '§3'], planSteps: [{ ref: '§1' }] });
expect(r.ok).toBe(false);
expect(r.uncovered.sort()).toEqual(['§2', '§3']);
});
});
describe('k5CriterionCheck (Гейт-2, K5): значимый шаг → конкретный критерий; «проверено» не факт', () => {
it('значимый шаг с конкретным критерием → ok', () => {
const r = k5CriterionCheck({ planSteps: [{ n: 1, criterion: 'тест RlsSmokeTest проходит' }] });
expect(r.ok).toBe(true);
});
it('значимый шаг без критерия → missing', () => {
const r = k5CriterionCheck({ planSteps: [{ n: 1 }] });
expect(r.ok).toBe(false);
expect(r.missingCriterion).toEqual([1]);
});
it('критерий = голое слово «проверено» → за факт НЕ берём → missing', () => {
const r = k5CriterionCheck({ planSteps: [{ n: 1, criterion: 'проверено' }, { n: 2, criterion: 'готово' }] });
expect(r.ok).toBe(false);
expect(r.missingCriterion).toEqual([1, 2]);
});
it('механический микрошаг (trivial) → критерий не требуется', () => {
const r = k5CriterionCheck({ planSteps: [{ n: 1, trivial: true }] });
expect(r.ok).toBe(true);
});
it('сомнение в значимости → шаг считается значимым (нельзя «разжаловать» чтобы проскочить)', () => {
const r = k5CriterionCheck({ planSteps: [{ n: 5 }] }); // significant не указан → значим
expect(r.ok).toBe(false);
expect(r.missingCriterion).toEqual([5]);
});
});
describe('criteriaGreenMatched (Гейт-3, $0): критерий ↔ настоящий зелёный прогон', () => {
it('каждый критерий сопоставлен с зелёным прогоном → ok', () => {
const r = criteriaGreenMatched({ criteria: [{ id: 'c1' }, { id: 'c2' }], greenRuns: [{ criterion_id: 'c1', green: true }, { criterion_id: 'c2', green: true }] });
expect(r.ok).toBe(true);
});
it('критерий без зелёного прогона → unproven', () => {
const r = criteriaGreenMatched({ criteria: [{ id: 'c1' }, { id: 'c2' }], greenRuns: [{ criterion_id: 'c1', green: true }] });
expect(r.ok).toBe(false);
expect(r.unproven).toEqual(['c2']);
});
it('прогон есть, но КРАСНЫЙ (green:false) → критерий не доказан', () => {
const r = criteriaGreenMatched({ criteria: [{ id: 'c1' }], greenRuns: [{ criterion_id: 'c1', green: false }] });
expect(r.ok).toBe(false);
expect(r.unproven).toEqual(['c1']);
});
});
describe('skillTakenByJournal (A2, K2): навык взят по журналу, не по тексту', () => {
it('требуемые навыки есть в журнале → ok', () => {
expect(skillTakenByJournal({ requiredSkills: ['Pest'], journalSkillCalls: ['Pest', 'Read'] }).ok).toBe(true);
});
it('требуемый навык отсутствует в журнале → missing', () => {
const r = skillTakenByJournal({ requiredSkills: ['Pest'], journalSkillCalls: ['Read'] });
expect(r.ok).toBe(false);
expect(r.missing).toEqual(['Pest']);
});
});
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* judge-key-config — резолв HMAC-ключа СУДЬИ (отдельный домен от расписок R-31).
* keychain (service router-mentor-judge) → env ROUTER_MENTOR_JUDGE_KEY → null.
* Ключ провижинит владелец. Без ключа — null → подпись под-прогонов невозможна
* → дисциплина #1 fail-closed (вердикт без валидного под-прогона не принимается).
* Контроллеру этот ключ НЕдоступен — потому он не может подделать прилежность судьи.
*/
import { createRequire } from 'node:module';
const KEY_ENV = 'ROUTER_MENTOR_JUDGE_KEY';
const KEYCHAIN_SERVICE = 'router-mentor-judge';
const KEYCHAIN_ACCOUNT = 'default';
/**
* @param {object} [args]
* @param {object} [args.env] - окружение (default process.env)
* @param {Function} [args.keychainGet] - () => string|null (инъекция для тестов)
* @returns {string|null}
*/
export function resolveJudgeKey({ env = process.env, keychainGet = defaultKeychainGet } = {}) {
let chainKey = null;
try {
const v = keychainGet();
chainKey = v ? String(v) : null;
} catch {
chainKey = null;
}
const envKey = env[KEY_ENV] ? String(env[KEY_ENV]) : null;
return chainKey || envKey || null;
}
/** Lazy keytar-ридер; null если keytar отсутствует или записи нет. Никогда не бросает. */
export function defaultKeychainGet() {
try {
const require = createRequire(import.meta.url);
const keytar = require('keytar');
return (keytar.getPasswordSync?.(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)) || null;
} catch {
return null;
}
}
export const _internals = { KEY_ENV, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT };
+26
View File
@@ -0,0 +1,26 @@
// tools/judge-key-config.test.mjs
import { describe, it, expect } from 'vitest';
import { resolveJudgeKey } from './judge-key-config.mjs';
describe('resolveJudgeKey', () => {
it('returns keychain key when present', () => {
expect(resolveJudgeKey({ env: {}, keychainGet: () => 'k-chain' })).toBe('k-chain');
});
it('falls back to env ROUTER_MENTOR_JUDGE_KEY when keychain empty', () => {
expect(resolveJudgeKey({ env: { ROUTER_MENTOR_JUDGE_KEY: 'k-env' }, keychainGet: () => null })).toBe('k-env');
});
it('prefers keychain over env', () => {
expect(resolveJudgeKey({ env: { ROUTER_MENTOR_JUDGE_KEY: 'k-env' }, keychainGet: () => 'k-chain' })).toBe('k-chain');
});
it('returns null when neither present', () => {
expect(resolveJudgeKey({ env: {}, keychainGet: () => null })).toBe(null);
});
it('never throws when keychainGet throws → null', () => {
expect(resolveJudgeKey({ env: {}, keychainGet: () => { throw new Error('boom'); } })).toBe(null);
});
it('is a DIFFERENT key domain than receipts (separate service)', async () => {
const { _internals } = await import('./judge-key-config.mjs');
expect(_internals.KEYCHAIN_SERVICE).toBe('router-mentor-judge');
expect(_internals.KEY_ENV).toBe('ROUTER_MENTOR_JUDGE_KEY');
});
});
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* judge-orchestrator (§3, §6, §12, J1/J2/J5/J8/J9, F8/F10, A0) — машинерия судьи:
* тупо-механическая сборка из пола (4-A/4-C), думающей части (4-D) и печати (4-B).
* - gateFires (A0): ворота срабатывают СТРУКТУРНО от состояния, судью не зовут по желанию.
* - runGateLadder: 3-шаговая лесенка (существование $0 → верность → судья); провал → стоп раньше.
* - finalGate (J9): «да» судьи НЕ отпирает детерминированный пол; пол перевешивает.
* - appealStep (F8): 3 круга на ОДНО несогласие → эскалация владельцу.
* - judgeHealth (J5): упал/деградировал → громкий крик + деградация до пола (молча не пропускает).
* - gate3Terminal (F10): Гейт-3 ничего не печатает вниз — только принято/не принято.
* - logVerdict (J8): каждый вердикт append-only в журнал (инъекция).
* Сам вызов модели и persist — в 4-D (мок) и хук-обёртках 4-G; здесь чистая логика.
*/
/** A0: ворота срабатывают от состояния (готово И не запечатано), не «по желанию». */
export function gateFires({ ready, alreadySealed }) {
return !!ready && !alreadySealed;
}
/** Лесенка: шаги по порядку; первый ok===false → стоп раньше (остальные не запускаются). */
export function runGateLadder(steps = []) {
for (const step of steps) {
const result = step.run();
if (!result || result.ok !== true) {
return { passed: false, stoppedAt: step.name, detail: result && result.detail };
}
}
return { passed: true };
}
/** J9: пол перевешивает «да» судьи. floorBlocked → block всегда; иначе по судье. */
export function finalGate({ judgeDecision, floorBlocked }) {
if (floorBlocked) return 'block';
return judgeDecision === 'GO' ? 'allow' : 'block';
}
/** F8: следующий круг апелляции; >3 кругов на одно несогласие → к владельцу. */
export function appealStep({ round, maxRounds = 3 }) {
const next = round + 1;
return { round: next, toOwner: next > maxRounds };
}
/** J5: здоровье судьи. Недоступен/деградировал → пост пустой + крик + режим пола. */
export function judgeHealth({ available, degraded }) {
const healthy = !!available && !degraded;
return healthy
? { onPost: true, cry: false, mode: 'judge' }
: { onPost: false, cry: true, mode: 'floor-only' };
}
/** F10: Гейт-3 терминален — печати-вниз нет, только принято/не принято. */
export function gate3Terminal(verdict) {
return { accepted: !!verdict && verdict.decision === 'GO', sealDown: null };
}
/** J8: записать вердикт append-only (журнал инъектируется). */
export function logVerdict({ verdict, nowMs, journal }) {
const entry = {
kind: 'verdict',
functionName: verdict && verdict.functionName,
decision: verdict && verdict.decision,
at: typeof nowMs === 'number' ? nowMs : null,
};
if (typeof journal === 'function') journal(entry);
return entry;
}
+93
View File
@@ -0,0 +1,93 @@
// tools/judge-orchestrator.test.mjs
import { describe, it, expect } from 'vitest';
import {
gateFires, runGateLadder, finalGate, appealStep, judgeHealth, gate3Terminal, logVerdict,
} from './judge-orchestrator.mjs';
describe('gateFires (A0: ворота срабатывают СТРУКТУРНО от состояния, не по желанию)', () => {
it('готово и не запечатано → ворота срабатывают', () => {
expect(gateFires({ ready: true, alreadySealed: false })).toBe(true);
});
it('не готово → не срабатывают', () => {
expect(gateFires({ ready: false, alreadySealed: false })).toBe(false);
});
it('уже запечатано → не пере-срабатывают', () => {
expect(gateFires({ ready: true, alreadySealed: true })).toBe(false);
});
});
describe('runGateLadder (лесенка: провал любого шага → стоп раньше)', () => {
it('все шаги ок → passed', () => {
const r = runGateLadder([
{ name: 'существование', run: () => ({ ok: true }) },
{ name: 'верность', run: () => ({ ok: true }) },
{ name: 'судья', run: () => ({ ok: true, decision: 'GO' }) },
]);
expect(r.passed).toBe(true);
});
it('первый провал → стоп раньше, последующие НЕ запускаются', () => {
let ran3 = false;
const r = runGateLadder([
{ name: 'существование', run: () => ({ ok: false, detail: 'вопрос повис' }) },
{ name: 'верность', run: () => ({ ok: true }) },
{ name: 'судья', run: () => { ran3 = true; return { ok: true }; } },
]);
expect(r.passed).toBe(false);
expect(r.stoppedAt).toBe('существование');
expect(ran3).toBe(false);
});
});
describe('finalGate (J9: «да» судьи НЕ отпирает пол)', () => {
it('пол блокирует (необратимое) → блок даже при GO судьи', () => {
expect(finalGate({ judgeDecision: 'GO', floorBlocked: true })).toBe('block');
});
it('пол чист + судья GO → allow', () => {
expect(finalGate({ judgeDecision: 'GO', floorBlocked: false })).toBe('allow');
});
it('пол чист + судья NO-GO → block', () => {
expect(finalGate({ judgeDecision: 'NO-GO', floorBlocked: false })).toBe('block');
});
});
describe('appealStep (F8: 3 круга на одно несогласие → владелец)', () => {
it('круги 1..3 не эскалируют', () => {
expect(appealStep({ round: 0 })).toEqual({ round: 1, toOwner: false });
expect(appealStep({ round: 1 })).toEqual({ round: 2, toOwner: false });
expect(appealStep({ round: 2 })).toEqual({ round: 3, toOwner: false });
});
it('4-й круг → к владельцу', () => {
expect(appealStep({ round: 3 })).toEqual({ round: 4, toOwner: true });
});
});
describe('judgeHealth (J5: упал/деградировал → громкий крик + деградация до пола)', () => {
it('доступен и не деградировал → на посту', () => {
expect(judgeHealth({ available: true, degraded: false })).toEqual({ onPost: true, cry: false, mode: 'judge' });
});
it('недоступен → пост пустой, крик, режим пола', () => {
expect(judgeHealth({ available: false, degraded: false })).toEqual({ onPost: false, cry: true, mode: 'floor-only' });
});
it('деградировал → крик + режим пола', () => {
expect(judgeHealth({ available: true, degraded: true }).mode).toBe('floor-only');
});
});
describe('gate3Terminal (F10: Гейт-3 ничего не печатает вниз)', () => {
it('GO → принято, печати-вниз НЕТ', () => {
expect(gate3Terminal({ decision: 'GO' })).toEqual({ accepted: true, sealDown: null });
});
it('NO-GO → не принято', () => {
expect(gate3Terminal({ decision: 'NO-GO' })).toEqual({ accepted: false, sealDown: null });
});
});
describe('logVerdict (J8: каждый вердикт append-only в журнал)', () => {
it('пишет запись вердикта через инъектированный журнал', () => {
const written = [];
const entry = logVerdict({ verdict: { decision: 'GO', functionName: 'gate1' }, nowMs: 5, journal: (e) => written.push(e) });
expect(written).toHaveLength(1);
expect(entry.kind).toBe('verdict');
expect(entry.decision).toBe('GO');
});
});
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* judge-seal-channel (K1 / F4 / F5 / F6, §6) — единственная разрешённая запись судьи:
* ПЕЧАТЬ на ГО. Печать ставит ТОЛЬКО этот канал одобрения гейта (ключ контроллеру
* недоступен — owner-provisioned keychain); путь «контроллер сам зовёт freeze» не строится.
* - F4: печать связывает хеш РОВНО судимого содержания, атомарно с вердиктом — судья
* одобрил X, контроллер подменил на X′ → хеш не сойдётся → печать НЕ ставится (пере-суд).
* - Carve-out (§6): деньги (осторожный детерминированный детектор) или 3-круговой тупик
* → печать ставит ВЛАДЕЛЕЦ, судья сам не печатает.
* - F6: возражение, снятое владельцем (вейвер), на нижних воротах не пере-блокируется.
* Само-freeze делегируется injected freezeImpl (в проде — freezeArtifact Машины 2);
* persist в runtime + триггер «одобрил→печать» — оркестрация 4-E.
*/
import { createHash } from 'node:crypto';
import { canonicalJson } from './receipt-sign.mjs';
/** Хеш РОВНО содержания (для F4-сверки «то ли судили»). */
export function contentHash(obj) {
return createHash('sha256').update(canonicalJson(obj)).digest('hex');
}
// Осторожно-широкий список денежных маркеров: при сомнении → деньги → владелец.
// NB: без \b у кириллицы (ASCII-границы слова кириллицу не видят) — только подстроки.
const MONEY_RE = /(руб|₽|копе|тариф|списан|биллинг|баланс|оплат|плат[её]ж|выручк|начислен|деньг|payment|charge|refund|invoice|money)/i;
/** Осторожный детерминированный детектор денег (НЕ «на глаз» модели). */
export function detectMoney(input) {
const text = typeof input === 'string' ? input : canonicalJson(input);
return MONEY_RE.test(String(text));
}
/** Печать ставит владелец (а не судья), если задеты деньги ИЛИ вердикт в тупике. */
export function requiresOwnerSeal({ artifact, verdict }) {
if (verdict && verdict.deadlock) return true;
return detectMoney(artifact);
}
/**
* Поставить печать на ГО. Возвращает {sealed, seal?} либо {sealed:false, reason|ownerRequired}.
* @param {object} a
* @param {object} a.artifact - содержание, которое печатаем
* @param {object} a.verdict - вердикт судьи: {decision:'GO'|'NO-GO', judged_hash, deadlock?, verdict_id}
* @param {string} a.key - ключ печати (контроллеру недоступен)
* @param {number} [a.nowMs]
* @param {Function} a.freezeImpl - ({artifact,key,nowMs}) => sealed-object. ОБЯЗАТЕЛЕН;
* в проде оркестрация 4-E передаёт freezeArtifact Машины 2 (ESM-импортом, не require).
*/
export function sealOnApproval({ artifact, verdict, key, nowMs, freezeImpl }) {
if (typeof freezeImpl !== 'function') {
return { sealed: false, reason: 'freezeImpl не передан — печать-движок Машины 2 не подключён' };
}
if (!verdict || verdict.decision !== 'GO') {
return { sealed: false, reason: 'нет GO судьи — печать не ставится' };
}
if (requiresOwnerSeal({ artifact, verdict })) {
return { sealed: false, ownerRequired: true, reason: 'деньги/тупик — печать ставит владелец (carve-out §6)' };
}
const currentHash = contentHash(artifact);
if (verdict.judged_hash !== currentHash) {
return { sealed: false, reason: 'содержание изменилось после суда (TOCTOU F4) — нужен пере-суд' };
}
const seal = freezeImpl({
artifact: { ...artifact, judged_by: verdict.verdict_id ?? null, judged_hash: currentHash },
key, nowMs,
});
return { sealed: true, seal };
}
/**
* F6: возражения, снятые владельцем (вейверы по anchor.ref), исключаются из активных
* на нижних воротах — судья не поднимает заново то, что владелец уже отвёл.
*/
export function applyWaivers(objections = [], waivers = []) {
const refs = new Set((waivers || []).map((w) => w && w.ref).filter(Boolean));
const active = [];
const waived = [];
for (const o of objections) {
const ref = o && o.anchor && o.anchor.ref;
(ref && refs.has(ref) ? waived : active).push(o);
}
return { active, waived };
}
+75
View File
@@ -0,0 +1,75 @@
// tools/judge-seal-channel.test.mjs
import { describe, it, expect } from 'vitest';
import {
sealOnApproval, detectMoney, requiresOwnerSeal, applyWaivers, contentHash,
} from './judge-seal-channel.mjs';
const ART = { sections: { '§1': 'teal' }, goal: 'landing' };
const stubFreeze = ({ artifact }) => ({ ...artifact, _sealed: true, sig: 'STUBSIG' });
const verdict = (art, over) => ({ verdict_id: 'v1', decision: 'GO', judged_hash: contentHash(art), ...over });
describe('sealOnApproval (K1 печать только по одобрению + F4 атомарность хеша)', () => {
it('GO + хеш судимого совпал → печать ставится через freezeImpl', () => {
const r = sealOnApproval({ artifact: ART, verdict: verdict(ART), key: 'k', freezeImpl: stubFreeze });
expect(r.sealed).toBe(true);
expect(r.seal._sealed).toBe(true);
expect(r.seal.judged_hash).toBe(contentHash(ART));
});
it('нет GO судьи → печать НЕ ставится', () => {
const r = sealOnApproval({ artifact: ART, verdict: { decision: 'NO-GO' }, key: 'k', freezeImpl: stubFreeze });
expect(r.sealed).toBe(false);
expect(r.reason).toMatch(/нет GO|no go/i);
});
it('F4: содержание изменилось после суда (хеш не тот) → печать НЕ ставится', () => {
const changed = { ...ART, sections: { '§1': 'EVIL' } };
const r = sealOnApproval({ artifact: changed, verdict: verdict(ART), key: 'k', freezeImpl: stubFreeze });
expect(r.sealed).toBe(false);
expect(r.reason).toMatch(/изменил|TOCTOU|пере-суд/i);
});
it('денежный артефакт → печать ставит ВЛАДЕЛЕЦ (carve-out), судья сам не печатает', () => {
const moneyArt = { sections: { '§1': 'списание 100 ₽ с баланса' }, goal: 'биллинг' };
const r = sealOnApproval({ artifact: moneyArt, verdict: verdict(moneyArt), key: 'k', freezeImpl: stubFreeze });
expect(r.sealed).toBe(false);
expect(r.ownerRequired).toBe(true);
});
it('тупик (deadlock) в вердикте → печать ставит владелец', () => {
const r = sealOnApproval({ artifact: ART, verdict: verdict(ART, { deadlock: true }), key: 'k', freezeImpl: stubFreeze });
expect(r.sealed).toBe(false);
expect(r.ownerRequired).toBe(true);
});
});
describe('detectMoney (осторожный детерминированный детектор)', () => {
it('ловит денежные термины (ru+en)', () => {
for (const t of ['списание', '100 руб', '50 ₽', 'тариф', 'биллинг', 'payment', 'refund', 'invoice', 'выручка']) {
expect(detectMoney(t)).toBe(true);
}
});
it('обычный текст без денег → false', () => {
expect(detectMoney('палитра teal и шрифт Inter')).toBe(false);
});
});
describe('requiresOwnerSeal', () => {
it('деньги ИЛИ тупик → true; чистый GO → false', () => {
expect(requiresOwnerSeal({ artifact: { x: 'тариф' }, verdict: { decision: 'GO' } })).toBe(true);
expect(requiresOwnerSeal({ artifact: ART, verdict: { decision: 'GO', deadlock: true } })).toBe(true);
expect(requiresOwnerSeal({ artifact: ART, verdict: { decision: 'GO' } })).toBe(false);
});
});
describe('applyWaivers (F6 — снятое владельцем не пере-блокируется)', () => {
const objs = [
{ text: 'a', anchor: { kind: 'spec_section', ref: '§1' } },
{ text: 'b', anchor: { kind: 'spec_section', ref: '§2' } },
];
it('возражение, снятое владельцем, исключается из активных', () => {
const r = applyWaivers(objs, [{ ref: '§1' }]);
expect(r.active).toHaveLength(1);
expect(r.active[0].anchor.ref).toBe('§2');
expect(r.waived).toHaveLength(1);
});
it('без вейверов все возражения активны', () => {
expect(applyWaivers(objs, []).active).toHaveLength(2);
});
});
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
/**
* judge-slop-counter (§9.5) — частота халтуры судьи: голые ГО / тонкие вердикты /
* пропуски линзы. Чистая функция над списком вердиктов (каждый уже несёт missing[]/
* trivial[] из validateVerdictSlots). Живой источник (лог J8) вшивается в 4-E/4-F;
* здесь — детерминированный счёт + флаг по порогу. Ловит систематическую штамповку
* (не качество одного вердикта — это оценщик постфактум 4-F/D31).
*/
const DEFAULT_THRESHOLD = 0.3;
export function computeSlopStats(verdicts = [], { threshold = DEFAULT_THRESHOLD } = {}) {
const total = verdicts.length;
let lensSkips = 0, thinVerdicts = 0, dirty = 0, clean = 0;
for (const v of verdicts) {
const hasMissing = Array.isArray(v.missing) && v.missing.length > 0;
const hasTrivial = Array.isArray(v.trivial) && v.trivial.length > 0;
if (hasMissing) lensSkips++;
if (hasTrivial) thinVerdicts++;
if (hasMissing || hasTrivial) dirty++; else clean++;
}
const slopRate = total ? dirty / total : 0;
return { total, lensSkips, thinVerdicts, clean, slopRate, flag: total > 0 && slopRate > threshold };
}
+38
View File
@@ -0,0 +1,38 @@
// tools/judge-slop-counter.test.mjs
import { describe, it, expect } from 'vitest';
import { computeSlopStats } from './judge-slop-counter.mjs';
// каждый вердикт несёт уже-посчитанные флаги формы (из validateVerdictSlots)
const mk = (over) => ({ decision: 'GO', missing: [], trivial: [], ...over });
describe('computeSlopStats (§9.5)', () => {
it('counts lens skips, thin verdicts and clean verdicts', () => {
const verdicts = [
mk({ decision: 'GO', missing: [], trivial: [] }), // clean
mk({ decision: 'GO', missing: ['premortem'], trivial: [] }), // lens skip
mk({ decision: 'GO', missing: [], trivial: ['correctness'] }), // thin
mk({ decision: 'GO', missing: ['a', 'b'], trivial: ['c'] }), // both
];
const r = computeSlopStats(verdicts);
expect(r.total).toBe(4);
expect(r.lensSkips).toBe(2); // verdicts with >=1 missing
expect(r.thinVerdicts).toBe(2);// verdicts with >=1 trivial
expect(r.clean).toBe(1);
});
it('flags when slop rate exceeds threshold', () => {
const dirty = [mk({ missing: ['x'] }), mk({ trivial: ['y'] }), mk({ missing: ['z'] })];
const r = computeSlopStats(dirty, { threshold: 0.5 });
expect(r.slopRate).toBeCloseTo(1.0);
expect(r.flag).toBe(true);
});
it('no flag below threshold', () => {
const mostlyClean = [mk(), mk(), mk(), mk({ missing: ['x'] })];
const r = computeSlopStats(mostlyClean, { threshold: 0.5 });
expect(r.slopRate).toBeCloseTo(0.25);
expect(r.flag).toBe(false);
});
it('empty input → zeroed, no flag', () => {
const r = computeSlopStats([]);
expect(r).toMatchObject({ total: 0, slopRate: 0, flag: false });
});
});
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* judge-subrun-journal (F1, §9.1) — НЕподделываемый контроллером канал прилежности
* судьи. Под-прогон (реальный запуск думающего навыка судьёй) пишется в ОТДЕЛЬНЫЙ
* judge-only файл judge-subrun-<sess>.jsonl, цепочка+голова подписаны КЛЮЧОМ СУДЬИ
* (router-mentor-judge). Контроллер пишет только в общий action-journal — туда
* запись «судья прогнал X» доверять НЕЛЬЗЯ; доверяем лишь этому каналу под judge-ключом.
* Переиспользует хеш-цепочку Машины 1 (appendEntry/verifyChain), отдельный путь+ключ.
*/
import fsDefault from 'node:fs';
import { appendEntry, verifyChain } from './action-journal.mjs';
function paths(runtimeDir, sessionId) {
const sep = runtimeDir.endsWith('/') ? '' : '/';
return {
jsonl: `${runtimeDir}${sep}judge-subrun-${sessionId}.jsonl`,
head: `${runtimeDir}${sep}judge-subrun-${sessionId}.head`,
};
}
/** Прочитать judge-only журнал под-прогонов (+ подпись головы). Нет файла → пусто. */
export function loadJudgeSubRuns({ sessionId, runtimeDir, fsImpl = fsDefault }) {
const { jsonl, head } = paths(runtimeDir, sessionId);
let entries = [];
try {
const raw = fsImpl.readFileSync(jsonl, 'utf8');
entries = String(raw).split('\n').filter(Boolean).map((l) => JSON.parse(l));
} catch (e) { if (e && e.code === 'ENOENT') return { entries: [], headSig: null }; throw e; }
let headSig = null;
try { headSig = String(fsImpl.readFileSync(head, 'utf8')).trim() || null; } catch { headSig = null; }
return { entries, headSig };
}
/** Добавить под-прогон судьи (персист через Node fs, подпись головы judge-ключом). */
export function appendJudgeSubRun({ payload, key, sessionId, runtimeDir, nowMs, fsImpl = fsDefault }) {
const { entries } = loadJudgeSubRuns({ sessionId, runtimeDir, fsImpl });
const { entries: next, headSig } = appendEntry(entries, payload, { key, nowMs });
const entry = next[next.length - 1];
const { jsonl, head } = paths(runtimeDir, sessionId);
fsImpl.appendFileSync(jsonl, JSON.stringify(entry) + '\n');
fsImpl.writeFileSync(head, String(headSig ?? ''));
return { entry, headSig };
}
/** Цепочка цела И голова подписана judge-ключом? (контроллер без ключа → false). */
export function verifyJudgeJournal(entries, headSig, judgeKey) {
return verifyChain(entries, headSig, { key: judgeKey });
}
/**
* Дисциплина #1: для каждой требующей реального запуска линзы вердикта в журнале
* должен быть совпавший под-прогон. required: [{lens, skill?}]; subRuns: payload'ы.
* Совпадение по lens (+ skill, если задан в required). Нет → missing (вердикт фейк).
*/
export function requiredSubRunsPresent(required = [], subRuns = []) {
const missing = (required || []).filter((req) =>
!(subRuns || []).some((r) =>
r.lens === req.lens && (req.skill === undefined || r.skill === req.skill))
);
return { ok: missing.length === 0, missing };
}
+67
View File
@@ -0,0 +1,67 @@
// tools/judge-subrun-journal.test.mjs
import { describe, it, expect } from 'vitest';
import {
appendJudgeSubRun, loadJudgeSubRuns, verifyJudgeJournal, requiredSubRunsPresent,
} from './judge-subrun-journal.mjs';
const JKEY = 'judge-test-key';
function memFs() {
const store = new Map();
return { store,
readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); },
appendFileSync: (p, d) => store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(d)),
writeFileSync: (p, d) => store.set(String(p), String(d)) };
}
const opts = (fs) => ({ key: JKEY, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
describe('judge sub-run journal (F1)', () => {
it('writes a sub-run to a JUDGE-ONLY file (judge-subrun-*.jsonl)', () => {
const fs = memFs();
const r = appendJudgeSubRun({ payload: { lens: 'premortem', skill: 'audit-context-building' }, nowMs: 1, ...opts(fs) });
expect(r.entry.seq).toBe(1);
expect(fs.store.has('/rt/judge-subrun-S.jsonl')).toBe(true);
expect(fs.store.has('/rt/judge-subrun-S.head')).toBe(true);
});
it('chain + head verify with judge key', () => {
const fs = memFs();
appendJudgeSubRun({ payload: { lens: 'a', skill: 'x' }, nowMs: 1, ...opts(fs) });
const r2 = appendJudgeSubRun({ payload: { lens: 'b', skill: 'y' }, nowMs: 2, ...opts(fs) });
const loaded = loadJudgeSubRuns({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(verifyJudgeJournal(loaded.entries, r2.headSig, JKEY).ok).toBe(true);
});
it('a forged sub-run signed with the WRONG (controller) key fails verification', () => {
const fs = memFs();
appendJudgeSubRun({ payload: { lens: 'a', skill: 'x' }, nowMs: 1, key: 'controller-key', sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
const loaded = loadJudgeSubRuns({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(verifyJudgeJournal(loaded.entries, loaded.headSig, JKEY).ok).toBe(false);
});
it('tampering a recorded sub-run breaks the chain', () => {
const fs = memFs();
appendJudgeSubRun({ payload: { lens: 'a', skill: 'x' }, nowMs: 1, ...opts(fs) });
const r2 = appendJudgeSubRun({ payload: { lens: 'b', skill: 'y' }, nowMs: 2, ...opts(fs) });
const loaded = loadJudgeSubRuns({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
const tampered = loaded.entries.map((e, i) => i === 0 ? { ...e, payload: { lens: 'EVIL', skill: 'z' } } : e);
expect(verifyJudgeJournal(tampered, r2.headSig, JKEY).ok).toBe(false);
});
});
describe('requiredSubRunsPresent (discipline #1: no run → verdict not accepted)', () => {
const runs = [{ lens: 'premortem', skill: 'audit-context-building' }, { lens: 'twins', skill: 'variant-analysis' }];
it('ok when every required lens has a matching sub-run', () => {
const r = requiredSubRunsPresent([{ lens: 'premortem' }, { lens: 'twins' }], runs);
expect(r.ok).toBe(true);
expect(r.missing).toEqual([]);
});
it('flags a required lens with no sub-run as missing (fake diligence)', () => {
const r = requiredSubRunsPresent([{ lens: 'premortem' }, { lens: 'attacker' }], runs);
expect(r.ok).toBe(false);
expect(r.missing).toEqual([{ lens: 'attacker' }]);
});
it('matches by skill when required declares a specific skill', () => {
expect(requiredSubRunsPresent([{ lens: 'twins', skill: 'variant-analysis' }], runs).ok).toBe(true);
expect(requiredSubRunsPresent([{ lens: 'twins', skill: 'OTHER' }], runs).ok).toBe(false);
});
it('empty required → ok (nothing demanded a real run)', () => {
expect(requiredSubRunsPresent([], runs).ok).toBe(true);
});
});
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* judge-verdict-slots (§9.2) — ОТДЕЛЬНЫЙ валидатор формы вердикта (не сам судья,
* как A0: подсудный не судит себя). Вердикт обязан нести слот на КАЖДУЮ требуемую
* для гейта линзу; пустой/тривиальный слот → невалидно → ГО не выдаётся. Тут проверяем
* только ФОРМУ (наличие+нетривиальность); качество мысли внутри слота — не сюда
* (часть честного потолка ~0.5%, добивают премортем/оценщик/владелец).
*/
const DEFAULT_MIN_LEN = 8; // тривиальным считаем слот короче порога (после trim)
export function validateVerdictSlots(verdict, requiredLenses = [], { minLen = DEFAULT_MIN_LEN } = {}) {
const slots = (verdict && typeof verdict === 'object' && verdict.slots) || {};
const missing = [];
const trivial = [];
for (const lens of requiredLenses) {
const raw = slots[lens];
if (raw === undefined || raw === null) { missing.push(lens); continue; }
if (String(raw).trim().length < minLen) trivial.push(lens);
}
return { valid: missing.length === 0 && trivial.length === 0, missing, trivial };
}
+44
View File
@@ -0,0 +1,44 @@
// tools/judge-verdict-slots.test.mjs
import { describe, it, expect } from 'vitest';
import { validateVerdictSlots } from './judge-verdict-slots.mjs';
const LENSES = ['completeness', 'premortem', 'goal_advocate', 'correctness'];
describe('validateVerdictSlots (§9.2 separate validator)', () => {
it('valid when every required lens has a substantive slot', () => {
const verdict = { slots: {
completeness: 'все нужды §1-§4 закрыты, кроме §3 (см. ниже)',
premortem: 'отступ §2 → ломает мобильный, не закрыто',
goal_advocate: 'цель — лендинг; спека её покрывает',
correctness: 'логика секций непротиворечива',
} };
const r = validateVerdictSlots(verdict, LENSES);
expect(r.valid).toBe(true);
expect(r.missing).toEqual([]);
expect(r.trivial).toEqual([]);
});
it('invalid when a required lens slot is missing', () => {
const verdict = { slots: { completeness: 'ок и подробно расписано', premortem: 'риск X не закрыт', goal_advocate: 'цель покрыта' } };
const r = validateVerdictSlots(verdict, LENSES);
expect(r.valid).toBe(false);
expect(r.missing).toEqual(['correctness']);
});
it('invalid when a slot is empty / whitespace / too short (trivial)', () => {
const verdict = { slots: {
completeness: 'нормально всё закрыто подробно', premortem: ' ', goal_advocate: 'ok', correctness: 'да',
} };
const r = validateVerdictSlots(verdict, LENSES);
expect(r.valid).toBe(false);
expect(r.trivial.sort()).toEqual(['correctness', 'goal_advocate', 'premortem']);
});
it('missing slots object entirely → all required missing', () => {
const r = validateVerdictSlots({}, LENSES);
expect(r.valid).toBe(false);
expect(r.missing).toEqual(LENSES);
});
it('minLen is configurable', () => {
const verdict = { slots: { a: 'short' } };
expect(validateVerdictSlots(verdict, ['a'], { minLen: 3 }).valid).toBe(true);
expect(validateVerdictSlots(verdict, ['a'], { minLen: 50 }).valid).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* judge-watermark (K6, §11/§2; F2 per-session) — анти-откатная монотонная зарубка.
* Журнал/план/указатель шага лежат в runtime и ПОДПИСАНЫ, но СТАРАЯ подпись валидна
* → подмена на раннюю валидную пару файлов = откат (спрятать прошлое / отмотать шаг).
* verifyChain это НЕ ловит (печать цела, просто старая). Лечение: max(seq/шаг),
* виденный судьёй, хранится в OS-keychain рядом с ключом судьи — ВНЕ откатываемого
* runtime. current < виденного → откат → блок. Per-session: свежая сессия = генезис 0
* (это не обход — там отката нет). keychainGet/Set инъектируются (тесты).
*/
const SERVICE = 'router-mentor-judge-watermark';
function account(sessionId) { return `wm:${sessionId}`; }
export function readWatermark({ sessionId, keychainGet }) {
let raw = null;
try { raw = keychainGet({ service: SERVICE, account: account(sessionId) }); } catch { raw = null; }
const n = Number.parseInt(String(raw ?? ''), 10);
return Number.isFinite(n) && n >= 0 ? n : 0; // genesis 0 при отсутствии/мусоре
}
/** Поднять зарубку ТОЛЬКО вверх (монотонно). */
export function bumpWatermark({ sessionId, value, keychainGet, keychainSet }) {
const cur = readWatermark({ sessionId, keychainGet });
const next = Math.max(cur, Number.isFinite(value) ? value : cur);
if (next !== cur) keychainSet({ service: SERVICE, account: account(sessionId), value: next });
return next;
}
/** current ниже виденного → откат → блок. */
export function checkNoRollback({ sessionId, current, keychainGet }) {
const seen = readWatermark({ sessionId, keychainGet });
const cur = Number.isFinite(current) ? current : 0;
const rolledBack = cur < seen;
return { ok: !rolledBack, rolledBack, seen };
}
export const _internals = { SERVICE };
+52
View File
@@ -0,0 +1,52 @@
// tools/judge-watermark.test.mjs
import { describe, it, expect } from 'vitest';
import { readWatermark, bumpWatermark, checkNoRollback } from './judge-watermark.mjs';
function memChain() {
const store = new Map(); // account -> value
return { store,
get: ({ account }) => (store.has(account) ? store.get(account) : null),
set: ({ account, value }) => store.set(account, String(value)) };
}
const kc = (c) => ({ keychainGet: c.get, keychainSet: c.set });
describe('judge-watermark (K6 anti-rollback, per-session)', () => {
it('fresh session reads genesis 0 (no mark yet → not a rollback)', () => {
const c = memChain();
expect(readWatermark({ sessionId: 'S', ...kc(c) })).toBe(0);
});
it('bump only increases; lower bump is ignored (monotonic)', () => {
const c = memChain();
bumpWatermark({ sessionId: 'S', value: 5, ...kc(c) });
expect(readWatermark({ sessionId: 'S', ...kc(c) })).toBe(5);
bumpWatermark({ sessionId: 'S', value: 3, ...kc(c) }); // lower → ignored
expect(readWatermark({ sessionId: 'S', ...kc(c) })).toBe(5);
bumpWatermark({ sessionId: 'S', value: 9, ...kc(c) });
expect(readWatermark({ sessionId: 'S', ...kc(c) })).toBe(9);
});
it('checkNoRollback: current >= mark → ok', () => {
const c = memChain();
bumpWatermark({ sessionId: 'S', value: 7, ...kc(c) });
expect(checkNoRollback({ sessionId: 'S', current: 7, ...kc(c) })).toEqual({ ok: true, rolledBack: false, seen: 7 });
expect(checkNoRollback({ sessionId: 'S', current: 8, ...kc(c) }).ok).toBe(true);
});
it('checkNoRollback: current < mark → ROLLBACK detected (block)', () => {
const c = memChain();
bumpWatermark({ sessionId: 'S', value: 10, ...kc(c) });
const r = checkNoRollback({ sessionId: 'S', current: 4, ...kc(c) });
expect(r.ok).toBe(false);
expect(r.rolledBack).toBe(true);
expect(r.seen).toBe(10);
});
it('watermark is per-session (different sessionId independent)', () => {
const c = memChain();
bumpWatermark({ sessionId: 'A', value: 9, ...kc(c) });
expect(readWatermark({ sessionId: 'B', ...kc(c) })).toBe(0); // B fresh genesis
expect(checkNoRollback({ sessionId: 'B', current: 0, ...kc(c) }).ok).toBe(true);
});
it('corrupt/non-numeric stored value → treated as genesis 0 (fail-safe read)', () => {
const c = memChain();
c.store.set('wm:S', 'garbage');
expect(readWatermark({ sessionId: 'S', ...kc(c) })).toBe(0);
});
});
+90
View File
@@ -0,0 +1,90 @@
// tools/m4-judge-invariants.test.mjs
// Машина 4 — сквозные инварианты судьи: все 7 частей работают вместе (дизайн §1-§13).
import { describe, it, expect } from 'vitest';
import { appendJudgeSubRun, loadJudgeSubRuns, verifyJudgeJournal } from './judge-subrun-journal.mjs';
import { existenceCheck } from './judge-gate-floor.mjs';
import { runJudge, requiredLensesFor } from './judge-engine.mjs';
import { sealOnApproval, contentHash } from './judge-seal-channel.mjs';
import { runGateLadder, finalGate, gate3Terminal, judgeHealth } from './judge-orchestrator.mjs';
function memFs() {
const store = new Map();
return { store,
readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); },
appendFileSync: (p, d) => store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(d)),
writeFileSync: (p, d) => store.set(String(p), String(d)) };
}
const stubFreeze = ({ artifact }) => ({ ...artifact, _sealed: true });
const ARTIFACT = { sections: { '§1': 'teal палитра' }, goal: 'лендинг' };
const fullSlots = {
completeness: 'все нужды §1 закрыты подробно и аккуратно',
premortem: 'риск мобильного отступа не закрыт — указан §1',
goal_advocate: 'цель лендинг — спека её покрывает целиком',
correctness: 'логика секций спеки непротиворечива',
};
describe('Машина 4 — сквозной инвариант Гейта-1 (happy path)', () => {
it('существование→верность→судья GO → печать ставится → пол пропускает', () => {
const lenses = requiredLensesFor('gate1');
const llmGo = () => ({ decision: 'GO', slots: fullSlots, objections: [] });
const ladder = runGateLadder([
{ name: 'существование', run: () => existenceCheck({ questions: [{ id: 'q1', answered: true }], skillsPromised: ['brainstorming'], skillsInvoked: ['brainstorming'] }) },
{ name: 'верность', run: () => ({ ok: true }) },
{ name: 'судья', run: () => { const v = runJudge({ functionName: 'gate1', requiredLenses: lenses, llmCall: llmGo, promptArgs: { product: ARTIFACT, goal: 'лендинг', cards: [] } }); return { ok: v.decision === 'GO', verdict: v }; } },
]);
expect(ladder.passed).toBe(true);
const verdict = { verdict_id: 'v1', decision: 'GO', judged_hash: contentHash(ARTIFACT) };
const sealed = sealOnApproval({ artifact: ARTIFACT, verdict, key: 'rk', freezeImpl: stubFreeze });
expect(sealed.sealed).toBe(true);
expect(finalGate({ judgeDecision: 'GO', floorBlocked: false })).toBe('allow');
});
});
describe('Машина 4 — сквозной инвариант: фатально-необратимое возражение блокирует', () => {
it('якорное fatal+irreversible → судья NO-GO → печать не ставится', () => {
const lenses = requiredLensesFor('gate1');
const llmBlock = () => ({ decision: 'NO-GO', slots: fullSlots, objections: [
{ verdict: 'NO', text: 'дыра', severity: 'fatal', reversible: false, anchor: { kind: 'spec_section', ref: '§1' } },
] });
const v = runJudge({ functionName: 'gate1', requiredLenses: lenses, llmCall: llmBlock, promptArgs: { product: ARTIFACT, goal: 'лендинг', cards: [] } });
expect(v.decision).toBe('NO-GO');
const verdict = { verdict_id: 'v2', decision: v.decision, judged_hash: contentHash(ARTIFACT) };
expect(sealOnApproval({ artifact: ARTIFACT, verdict, key: 'rk', freezeImpl: stubFreeze }).sealed).toBe(false);
});
});
describe('Машина 4 — J9: «да» судьи не отпирает пол', () => {
it('судья GO, но пол блокирует необратимое → итог блок', () => {
expect(finalGate({ judgeDecision: 'GO', floorBlocked: true })).toBe('block');
});
});
describe('Машина 4 — F1: контроллер не подделает прилежность судьи', () => {
it('журнал под-прогонов, подписанный НЕ ключом судьи, невалиден', () => {
const fs = memFs();
appendJudgeSubRun({ payload: { lens: 'premortem' }, key: 'controller-key', sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
const loaded = loadJudgeSubRuns({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(verifyJudgeJournal(loaded.entries, loaded.headSig, 'judge-key').ok).toBe(false);
});
});
describe('Машина 4 — лесенка стоп-раньше + здоровье/деградация', () => {
it('провал существования → судья не запускается', () => {
let judgeRan = false;
const r = runGateLadder([
{ name: 'существование', run: () => ({ ok: false, detail: 'вопрос повис' }) },
{ name: 'судья', run: () => { judgeRan = true; return { ok: true }; } },
]);
expect(r.passed).toBe(false);
expect(judgeRan).toBe(false);
});
it('судья упал → деградация до пола + громкий крик', () => {
const h = judgeHealth({ available: false, degraded: false });
expect(h.mode).toBe('floor-only');
expect(h.cry).toBe(true);
});
it('Гейт-3 терминален — печати-вниз нет', () => {
expect(gate3Terminal({ decision: 'GO' }).sealDown).toBe(null);
});
});
+41
View File
@@ -0,0 +1,41 @@
// tools/m4a-discipline-invariants.test.mjs
// Машина 4-A — сквозные инварианты дисциплины судьи (дизайн §9, §11, F1/F2).
import { describe, it, expect } from 'vitest';
import { appendJudgeSubRun, loadJudgeSubRuns, verifyJudgeJournal, requiredSubRunsPresent } from './judge-subrun-journal.mjs';
import { validateVerdictSlots } from './judge-verdict-slots.mjs';
import { partitionObjections } from './judge-anchor.mjs';
import { bumpWatermark, checkNoRollback } from './judge-watermark.mjs';
function memFs() {
const store = new Map();
return { store,
readFileSync: (p) => { if (!store.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return store.get(String(p)); },
appendFileSync: (p, d) => store.set(String(p), (store.has(String(p)) ? store.get(String(p)) : '') + String(d)),
writeFileSync: (p, d) => store.set(String(p), String(d)) };
}
function memChain() { const s = new Map(); return { get: ({ account }) => s.has(account) ? s.get(account) : null, set: ({ account, value }) => s.set(account, String(value)) }; }
const JKEY = 'judge-key';
describe('Машина 4-A — инварианты пола судьи', () => {
it('F1: контроллер не подделает прилежность судьи (чужой ключ → журнал невалиден)', () => {
const fs = memFs();
appendJudgeSubRun({ payload: { lens: 'premortem' }, key: 'controller-key', sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
const loaded = loadJudgeSubRuns({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs });
expect(verifyJudgeJournal(loaded.entries, loaded.headSig, JKEY).ok).toBe(false);
});
it('дисциплина #1: требуемая линза без под-прогона → вердикт фейк', () => {
expect(requiredSubRunsPresent([{ lens: 'attacker' }], [{ lens: 'premortem' }]).ok).toBe(false);
});
it('§9.2: ГО с пустым требуемым слотом невалидно', () => {
expect(validateVerdictSlots({ slots: { a: 'подробный слот' } }, ['a', 'b']).valid).toBe(false);
});
it('§9.3: безъякорное возражение не блокирует (уходит в совет)', () => {
const r = partitionObjections([{ text: 'тревожно вообще' }]);
expect(r.blocking).toEqual([]);
});
it('K6: откат зарубки на меньшее значение → блок', () => {
const c = memChain();
bumpWatermark({ sessionId: 'S', value: 12, keychainGet: c.get, keychainSet: c.set });
expect(checkNoRollback({ sessionId: 'S', current: 5, keychainGet: c.get }).rolledBack).toBe(true);
});
});