diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs new file mode 100644 index 00000000..0bc59891 --- /dev/null +++ b/tools/enforce-judge-gate.mjs @@ -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(); diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs new file mode 100644 index 00000000..2c9f51ed --- /dev/null +++ b/tools/enforce-judge-gate.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-anchor.mjs b/tools/judge-anchor.mjs new file mode 100644 index 00000000..2b25d1c9 --- /dev/null +++ b/tools/judge-anchor.mjs @@ -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 }; +} diff --git a/tools/judge-anchor.test.mjs b/tools/judge-anchor.test.mjs new file mode 100644 index 00000000..b084a579 --- /dev/null +++ b/tools/judge-anchor.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-engine.mjs b/tools/judge-engine.mjs new file mode 100644 index 00000000..9938733b --- /dev/null +++ b/tools/judge-engine.mjs @@ -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'; +} diff --git a/tools/judge-engine.test.mjs b/tools/judge-engine.test.mjs new file mode 100644 index 00000000..b2b51064 --- /dev/null +++ b/tools/judge-engine.test.mjs @@ -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'); + }); +}); diff --git a/tools/judge-evaluator.mjs b/tools/judge-evaluator.mjs new file mode 100644 index 00000000..a6e05779 --- /dev/null +++ b/tools/judge-evaluator.mjs @@ -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 }; +} diff --git a/tools/judge-evaluator.test.mjs b/tools/judge-evaluator.test.mjs new file mode 100644 index 00000000..333ca2ae --- /dev/null +++ b/tools/judge-evaluator.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-gate-config.mjs b/tools/judge-gate-config.mjs new file mode 100644 index 00000000..841b446f --- /dev/null +++ b/tools/judge-gate-config.mjs @@ -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 }; diff --git a/tools/judge-gate-config.test.mjs b/tools/judge-gate-config.test.mjs new file mode 100644 index 00000000..37fdc096 --- /dev/null +++ b/tools/judge-gate-config.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-gate-floor.mjs b/tools/judge-gate-floor.mjs new file mode 100644 index 00000000..5c79866c --- /dev/null +++ b/tools/judge-gate-floor.mjs @@ -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 }; +} diff --git a/tools/judge-gate-floor.test.mjs b/tools/judge-gate-floor.test.mjs new file mode 100644 index 00000000..8c785704 --- /dev/null +++ b/tools/judge-gate-floor.test.mjs @@ -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']); + }); +}); diff --git a/tools/judge-key-config.mjs b/tools/judge-key-config.mjs new file mode 100644 index 00000000..bf37819e --- /dev/null +++ b/tools/judge-key-config.mjs @@ -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 }; diff --git a/tools/judge-key-config.test.mjs b/tools/judge-key-config.test.mjs new file mode 100644 index 00000000..1e37be81 --- /dev/null +++ b/tools/judge-key-config.test.mjs @@ -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'); + }); +}); diff --git a/tools/judge-orchestrator.mjs b/tools/judge-orchestrator.mjs new file mode 100644 index 00000000..19f6c276 --- /dev/null +++ b/tools/judge-orchestrator.mjs @@ -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; +} diff --git a/tools/judge-orchestrator.test.mjs b/tools/judge-orchestrator.test.mjs new file mode 100644 index 00000000..43f6f93f --- /dev/null +++ b/tools/judge-orchestrator.test.mjs @@ -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'); + }); +}); diff --git a/tools/judge-seal-channel.mjs b/tools/judge-seal-channel.mjs new file mode 100644 index 00000000..5eb188e1 --- /dev/null +++ b/tools/judge-seal-channel.mjs @@ -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 }; +} diff --git a/tools/judge-seal-channel.test.mjs b/tools/judge-seal-channel.test.mjs new file mode 100644 index 00000000..975267b3 --- /dev/null +++ b/tools/judge-seal-channel.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-slop-counter.mjs b/tools/judge-slop-counter.mjs new file mode 100644 index 00000000..2c5fbc16 --- /dev/null +++ b/tools/judge-slop-counter.mjs @@ -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 }; +} diff --git a/tools/judge-slop-counter.test.mjs b/tools/judge-slop-counter.test.mjs new file mode 100644 index 00000000..a7da65a6 --- /dev/null +++ b/tools/judge-slop-counter.test.mjs @@ -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 }); + }); +}); diff --git a/tools/judge-subrun-journal.mjs b/tools/judge-subrun-journal.mjs new file mode 100644 index 00000000..8195c193 --- /dev/null +++ b/tools/judge-subrun-journal.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * judge-subrun-journal (F1, §9.1) — НЕподделываемый контроллером канал прилежности + * судьи. Под-прогон (реальный запуск думающего навыка судьёй) пишется в ОТДЕЛЬНЫЙ + * judge-only файл judge-subrun-.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 }; +} diff --git a/tools/judge-subrun-journal.test.mjs b/tools/judge-subrun-journal.test.mjs new file mode 100644 index 00000000..781112f2 --- /dev/null +++ b/tools/judge-subrun-journal.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-verdict-slots.mjs b/tools/judge-verdict-slots.mjs new file mode 100644 index 00000000..e4faf4a8 --- /dev/null +++ b/tools/judge-verdict-slots.mjs @@ -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 }; +} diff --git a/tools/judge-verdict-slots.test.mjs b/tools/judge-verdict-slots.test.mjs new file mode 100644 index 00000000..a6e9213f --- /dev/null +++ b/tools/judge-verdict-slots.test.mjs @@ -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); + }); +}); diff --git a/tools/judge-watermark.mjs b/tools/judge-watermark.mjs new file mode 100644 index 00000000..b1291c69 --- /dev/null +++ b/tools/judge-watermark.mjs @@ -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 }; diff --git a/tools/judge-watermark.test.mjs b/tools/judge-watermark.test.mjs new file mode 100644 index 00000000..a7ab1f44 --- /dev/null +++ b/tools/judge-watermark.test.mjs @@ -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); + }); +}); diff --git a/tools/m4-judge-invariants.test.mjs b/tools/m4-judge-invariants.test.mjs new file mode 100644 index 00000000..1be184b4 --- /dev/null +++ b/tools/m4-judge-invariants.test.mjs @@ -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); + }); +}); diff --git a/tools/m4a-discipline-invariants.test.mjs b/tools/m4a-discipline-invariants.test.mjs new file mode 100644 index 00000000..e56e37ac --- /dev/null +++ b/tools/m4a-discipline-invariants.test.mjs @@ -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); + }); +});