diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index c2a32d8..5c3b854 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -131,7 +131,9 @@ export async function runJudgeGate(event, deps = {}) { if (typeof deps.roundMemoryImpl === 'function') { try { roundMemory = (await deps.roundMemoryImpl({ stage, content: rmContent })) || {}; } catch { roundMemory = {}; } } - const promptArgs = { product: g.product, goal: g.goal, cards: g.cards, roundMemory }; + let delivery = null; + if (functionName === 'gate2') { try { delivery = sealablePlan(rmContent).delivery; } catch { delivery = 'internal'; } } + const promptArgs = { product: g.product, goal: g.goal, cards: g.cards, roundMemory, delivery }; const raw = await callJudgeModel({ functionName, requiredLenses, promptArgs, apiKey, model: deps.model, transport: deps.transport }); if (raw && raw.unavailable) { // M7: причина недоступности протекает в вердикт → лог-WARN + seal-запись её фиксируют. diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs index 165a258..8ba9bb3 100644 --- a/tools/enforce-judge-gate.test.mjs +++ b/tools/enforce-judge-gate.test.mjs @@ -43,8 +43,17 @@ describe('bindingHashForJudge (Фаза 3 — какой хеш судья св }); }); -const okText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [] }); -const noText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§1' }, severity: 'heavy', reversible: false }] }); +const okText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee', delivery_honesty: 'ffffffff' }, objections: [] }); +const noText = JSON.stringify({ slots: { spec_fidelity: 'aaaaaaaa', verifiability: 'bbbbbbbb', plan_soundness: 'cccccccc', execution_risk: 'dddddddd', step_clarity: 'eeeeeeee', delivery_honesty: 'ffffffff' }, objections: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§1' }, severity: 'heavy', reversible: false }] }); + +describe('runJudgeGate — delivery в промпте судьи', () => { + it('gate2 прокидывает пометку delivery в user-промпт', async () => { + const prompts = []; + const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n**Delivery:** user-result\n## Цель\nцель\n```steps-json\n[{"op":"Edit","object":"tools/x.mjs","ref":"d1"}]\n```' } }; + await runJudgeGate(ev, { judgeActiveImpl: () => true, apiKey: 'K', transport: async (p) => { prompts.push(p); return okText; } }); + expect(prompts[0].user).toContain('ПОМЕТКА DELIVERY: user-result'); + }); +}); const planEv = () => ({ tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/x.md', content: '# П\n## Цель\nцель плана тут\nшаг' } }); describe('enforce-judge-gate decide (М7 Фаза 7 §8) — mode-aware + finalGate', () => { diff --git a/tools/judge-engine.mjs b/tools/judge-engine.mjs index 3943b89..385b517 100644 --- a/tools/judge-engine.mjs +++ b/tools/judge-engine.mjs @@ -19,7 +19,7 @@ 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'], + gate2: ['spec_fidelity', 'verifiability', 'plan_soundness', 'execution_risk', 'step_clarity', 'delivery_honesty'], a2_divergence: ['intent_fidelity'], a2_destructive: ['radius_reversibility', 'attacker'], part_light: ['correctness', 'simplicity', 'footgun'], @@ -42,8 +42,8 @@ export function requiredLensesFor(functionName, { risk = false } = {}) { } /** Чистый построитель промпта судьи: {system, user}. Детерминирован. */ -export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], roundMemory = {} }) { - const system = [ +export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], roundMemory = {}, delivery = null }) { + const systemLines = [ 'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.', 'Ты СЛЕП к любой внешней переписке и чужим мнениям — судишь продукт против цели; учитываешь лишь СВОИ прошлые замечания и доводы контроллера лично тебе.', 'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).', @@ -51,10 +51,15 @@ export function buildJudgePrompt({ functionName, requiredLenses = [], product = `Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`, `Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${PREMORTEM_CLASSES.join(', ')}.`, 'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.', - ].join('\n'); + ]; + if (requiredLenses.includes('delivery_honesty')) { + systemLines.push('Линза delivery_honesty (честность пометки поставки): план несёт пометку DELIVERY (internal|user-result). Если план доводит ЦЕЛЬ спеки до готового ПОЛЬЗОВАТЕЛЬСКОГО результата (а не только внутренней машинерии), а помечен internal — это СОКРЫТИЕ готового результата от владельца: вынеси возражение verdict:NO, severity:heavy, reversible:false, anchor {kind:"failed_criterion", ref:"delivery=internal при пользовательском результате"}. Честная пометка возражения не требует.'); + } + const system = systemLines.join('\n'); const user = [ `ЦЕЛЬ (неизменный якорь): ${goal}`, `КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`, + delivery ? `ПОМЕТКА DELIVERY: ${delivery}` : null, `ПРОДУКТ НА СУД: ${canonicalJson(product)}`, renderRoundMemory(roundMemory), ].filter(Boolean).join('\n'); diff --git a/tools/judge-engine.test.mjs b/tools/judge-engine.test.mjs index d018d39..7311c53 100644 --- a/tools/judge-engine.test.mjs +++ b/tools/judge-engine.test.mjs @@ -148,3 +148,37 @@ describe('consensusDecision (J3: один НЕТ → блок)', () => { expect(consensusDecision([{ decision: 'GO' }, null])).toBe('NO-GO'); }); }); + +describe('delivery_honesty линза (gate2)', () => { + it('gate2 несёт линзу delivery_honesty', () => { + expect(requiredLensesFor('gate2')).toContain('delivery_honesty'); + }); + it('buildJudgePrompt с delivery рендерит пометку и правило честности', () => { + const { system, user } = buildJudgePrompt({ + functionName: 'gate2', requiredLenses: requiredLensesFor('gate2'), + product: { plan: 'X' }, goal: 'довести фичу до пользователя', cards: [], delivery: 'internal', + }); + expect(user).toContain('ПОМЕТКА DELIVERY: internal'); + expect(system).toContain('delivery_honesty'); + expect(system).toContain('СОКРЫТИЕ'); + }); + it('buildJudgePrompt без delivery (gate1) → строки DELIVERY нет', () => { + const { user } = buildJudgePrompt({ functionName: 'gate1', requiredLenses: ['completeness'], product: {}, goal: 'g', cards: [] }); + expect(user).not.toContain('ПОМЕТКА DELIVERY'); + }); + it('runJudge gate2 честный internal → GO', () => { + const lenses = requiredLensesFor('gate2'); + const slots = Object.fromEntries(lenses.map((l) => [l, `слот линзы ${l} заполнен подробно`])); + const r = runJudge({ functionName: 'gate2', requiredLenses: lenses, llmCall: () => ({ slots, objections: [] }), promptArgs: { product: {}, goal: 'g', cards: [], delivery: 'internal' } }); + expect(r.decision).toBe('GO'); + }); + it('runJudge gate2 internal при пользе → якорное NO на delivery_honesty → NO-GO', () => { + const lenses = requiredLensesFor('gate2'); + const slots = Object.fromEntries(lenses.map((l) => [l, `слот линзы ${l} заполнен подробно`])); + const r = runJudge({ functionName: 'gate2', requiredLenses: lenses, llmCall: () => ({ slots, objections: [ + { verdict: 'NO', severity: 'heavy', reversible: false, anchor: { kind: 'failed_criterion', ref: 'delivery=internal при пользовательском результате' } }, + ] }), promptArgs: { product: {}, goal: 'g', cards: [], delivery: 'internal' } }); + expect(r.decision).toBe('NO-GO'); + expect(r.blocking).toHaveLength(1); + }); +});