diff --git a/tools/enforce-judge-gate.mjs b/tools/enforce-judge-gate.mjs index b535a0c7..28aed5c2 100644 --- a/tools/enforce-judge-gate.mjs +++ b/tools/enforce-judge-gate.mjs @@ -37,11 +37,14 @@ import { resolve as pathResolve } from 'node:path'; // Волна 6 (двухуровневые переговоры §6): эскалация судьи → карточка арбитража. import { buildArbitrationCard } from './arbitration-card.mjs'; import { formatJudgeObjection } from './objection-format.mjs'; +import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs'; import { parseNegotiationSection } from './negotiation-section.mjs'; // M7 наблюдаемость печати (ремонт «провал печати нигде не логируется»). import { buildSealEntry, logSealAttempt } from './seal-log.mjs'; -// Фикс дедлока: судья (Pre) сохраняет GO, печать плана — в Post-хуке наставника (свежий вердикт). -import { buildJudgeGo, persistJudgeGo } from './judge-go-store.mjs'; +// Способ B (Фаза 2): судья сам печатает план в Post при валидном mentor-GO (fail-safe). +import { loadMentorGo, mentorGoValidFor } from './mentor-go-store.mjs'; +// Фаза 4: счётчик судьи на стэк (спека+план) — task-id (наставник его уже сохранил в Post-до). +import { loadTaskId } from './router-task-id.mjs'; /** * Волна 6 (§6): сообщение арбитража при 3 NO-GO судьи — дословное замечание судьи + @@ -76,9 +79,14 @@ export function decide({ mode, verdict, floorBlocked = false } = {}) { } const decision = verdict && verdict.decision === 'GO' ? 'GO' : 'NO-GO'; const gate = finalGate({ judgeDecision: decision, floorBlocked }); - return gate === 'allow' - ? { block: false, reason: 'live-block: судья GO + пол чист' } - : { block: true, message: `[judge-gate] live-block: судья=${decision}, пол=${floorBlocked} → блок` }; + if (gate === 'allow') return { block: false, reason: 'live-block: судья GO + пол чист' }; + // Фаза 1 (Р2): на NO-GO контроллеру доходит ПОЛНЫЙ дословный текст возражения судьи + // через рабочий exit-2 канал. Нет текста (degraded/пол) → скупой fallback с диагностикой. + const objText = formatJudgeObjection(verdict && verdict.verdict); + const message = objText + ? buildObjectionFeedback({ side: 'judge', text: objText }) + : `[judge-gate] live-block: судья=${decision}, пол=${floorBlocked} → блок`; + return { block: true, message }; } /** @@ -99,6 +107,12 @@ export async function runJudgeGate(event, deps = {}) { const g = g1.shouldJudge ? g1 : extractGate2Product(event); if (!g.shouldJudge) return { decision: 'GO', wired: false, skip: 'not_plan' }; const functionName = g.functionName; // 'gate1' | 'gate2' + // Способ B fail-safe (Фаза 2): судья судит/печатает ТОЛЬКО при одобрении наставника. + // mentorApproved инъектируется прод-сборкой лишь при mentorSeamActive(); нет одобрения → + // судья молчит ($0, без LLM-вызова). undefined → нет гейта (backward-compat, наставник off). + if (typeof deps.mentorApproved === 'function' && !deps.mentorApproved(event, functionName)) { + return { decision: 'GO', wired: false, skip: 'no_mentor_go' }; + } // «Оба строго» (2026-06-12): СВОЙ ключ судьи ROUTER_JUDGE_LLM_KEY, общий не фолбэк. const apiKey = deps.apiKey !== undefined ? deps.apiKey : resolveJudgeLlmKey(); const requiredLenses = requiredLensesFor(functionName); @@ -290,6 +304,13 @@ export async function runJudgeTurn(event, { mode, logImpl = logVerdictLine, warn sealResult, }); if (mode === 'shadow') return { block: false }; // D28: логирует, не блокирует + // Способ B + §9 (degraded): live-block, судья не дозвонился (нет ключа/таймаут) → контроллер + // ИНФОРМИРУЕТСЯ «судья не дозвонился» (не тихо), печати нет. Это НЕ NO-GO (escalation не растёт). + if (verdict && verdict.unavailable) { + const reason = verdict.cause === 'no_key' ? 'нет ключа судьи' + : (verdict.errorType || verdict.cause || 'транспорт недоступен'); + return { block: true, degraded: true, message: buildDegradedFeedback({ side: 'judge', reason }) }; + } // M7: escape-валве владельца (эскалация). Судья чтит escape-грант, как стена/пол: упёрся // NO-GO → владелец кликает «разрешаю» → судья пропускает. «Не смог договориться → вышел на владельца». try { @@ -337,26 +358,26 @@ export function sealOnWiredGo({ event, verdict, judgeMode, deps = {} }) { return { sealed: false }; } +/** + * Фаза 3: какой binding-хеш судья сверяет с mentor-GO. gate1 (спека) → хеш артефакта + * (judgedHashOf(sealableArtifact) — тот же, чем судья печатает gate1); gate2 (план) → + * plan_hash из steps. Бросок (нет steps и т.п.) ловит вызывающий → не одобрено (fail-safe). + */ +export function bindingHashForJudge({ content, functionName } = {}) { + const c = String(content ?? ''); + return functionName === 'gate1' + ? judgedHashOf(sealableArtifact(c)) + : planId(sealablePlan(c).steps); +} + /** Прод-сборка seal-deps (Node fs + keychain). Зовётся только из main() (не в юнит-тестах). */ function sealTurnProd(event, verdict, mode) { const sessionId = (event && event.session_id) || 'unknown'; const dir = runtimeDir(); - const fp = String((event && event.tool_input && event.tool_input.file_path) || ''); - // ФИКС ДЕДЛОКА: судья (Pre) пломбировать ПЛАН не может — freeze-gate требует вердикт - // наставника, а он рождается в Post (после судьи) → stale-binding нах.F4. Поэтому для ПЛАНА - // судья лишь СОХРАНЯЕТ подписанный GO (привязан к plan_hash); печать плана делает наставник - // в Post, где вердикт уже свежий для текущего плана. Спека (gate1, без freeze-gate) — печатается тут. - if (PLAN_PATH_RE.test(fp) && verdict && verdict.wired === true && verdict.decision === 'GO') { - try { - const content = String((event && event.tool_input && event.tool_input.content) ?? ''); - const planHash = planId(sealablePlan(content).steps); - persistJudgeGo({ - record: buildJudgeGo({ planHash, judgedHash: verdict.judged_hash, judgeMode: mode, key: resolveReceiptKey() }), - sessionId, runtimeDir: dir, - }); - } catch { /* best-effort: нет GO судьи → наставник просто не запечатает */ } - return { sealed: false, kind: 'plan', reason: 'отложено в Post-печать наставника (GO судьи сохранён)' }; - } + // Способ B (Фаза 2): судья — хук ПОСЛЕ наставника, поэтому вердикт наставника уже свежий + // (mentor-GO + персист вердикта для plan_hash). Судья САМ печатает план здесь, через + // sealOnWiredGo + freeze-gate (verity/VA-9). Прежний «фикс дедлока» (судья сохранял judge-GO, + // наставник печатал в Post) снят — порядок теперь правильный. return sealOnWiredGo({ event, verdict, judgeMode: mode, deps: { @@ -391,8 +412,9 @@ const JUDGE_ESCALATE_AFTER = 3; * blocked=true → +1; blocked=false (allow) → сброс 0. Возвращает новый счёт. * fsImpl/dir инъектируемы для тестов. Best-effort — ошибка I/O не ломает судью. */ -export function bumpJudgeNoGo({ sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { - const safe = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); +export function bumpJudgeNoGo({ taskId, sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { + // Фаза 4: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sessionId — fallback). + const safe = String(taskId || sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); const path = join(dir, `judge-nogo-${safe}.json`); let count = 0; try { count = (JSON.parse(fsImpl.readFileSync(path, 'utf8')).count) || 0; } catch { count = 0; } @@ -407,9 +429,20 @@ async function main() { event = parseEventJson(await readStdin()); mode = judgeGateMode(); } catch { exitDecision({ block: false }); return; } // pre-gate ошибка → inert-safe allow ($0) + // Способ B fail-safe (Фазы 2-3): судья судит/печатает ТОЛЬКО при валидном mentor-GO + // (наставник одобрил ЭТОТ артефакт). Активно лишь при mentorSeamActive(); иначе undefined → + // нет гейта (backward-compat). Гейтим и план (gate2), и спеку (gate1) — один сценарий. + const mentorApproved = !mentorSeamActive() ? undefined : (ev, fn) => { + try { + const content = String((ev && ev.tool_input && ev.tool_input.content) ?? ''); + const bindingHash = bindingHashForJudge({ content, functionName: fn }); + const rec = loadMentorGo({ sessionId: (ev && ev.session_id) || 'unknown', runtimeDir: runtimeDir() }); + return mentorGoValidFor(rec, { planHash: bindingHash, key: resolveReceiptKey() }); + } catch { return false; } // не смогли проверить → не одобрено → судья молчит (fail-safe) + }; let result; try { - result = await runJudgeTurn(event, { mode, nowMs: Date.now(), onWiredSeal: sealTurnProd }); // inert/shadow/live-block внутри; nowMs → at в seal/verdict/warn (M7) + result = await runJudgeTurn(event, { mode, nowMs: Date.now(), onWiredSeal: sealTurnProd, mentorApproved }); // inert/shadow/live-block внутри; nowMs → at в seal/verdict/warn (M7) } catch { exitDecision({ block: mode === 'live-block' }); return; } // fail-CLOSE только в live-block // M7 эскалация (round-control C-12): подряд идущие NO-GO судьи. allow → сброс. После 3-го подряд — // сообщение «ЭСКАЛАЦИЯ ВЛАДЕЛЬЦУ» (судья сам выходит на владельца; продавить — escape, который судья @@ -417,8 +450,13 @@ async function main() { try { const judgedPlan = extractGate1Product(event).shouldJudge || extractGate2Product(event).shouldJudge; if (judgedPlan) { - const n = bumpJudgeNoGo({ sessionId: (event && event.session_id) || 'unknown', blocked: !!result.block }); - if (result.block && n >= JUDGE_ESCALATE_AFTER) { + // degraded (судья не дозвонился) — НЕ NO-GO: счётчик эскалации не растёт, карточки нет. + const isNoGo = !!result.block && !result.degraded; + // Фаза 4: ключ счётчика — task-id (наставник сохранил его в Post-до судьи); sess — fallback. + let taskId = null; + try { taskId = loadTaskId({ sessionId: (event && event.session_id) || 'unknown', runtimeDir: runtimeDir(), fsImpl: fsDefault }); } catch { taskId = null; } + const n = bumpJudgeNoGo({ taskId, sessionId: (event && event.session_id) || 'unknown', blocked: isNoGo }); + if (isNoGo && n >= JUDGE_ESCALATE_AFTER) { const planContent = String((event && event.tool_input && event.tool_input.content) ?? ''); result = { ...result, message: buildJudgeArbitrationMessage(result.verdict, planContent, n) }; } diff --git a/tools/enforce-judge-gate.test.mjs b/tools/enforce-judge-gate.test.mjs index e453dcd1..fdfd64f8 100644 --- a/tools/enforce-judge-gate.test.mjs +++ b/tools/enforce-judge-gate.test.mjs @@ -3,8 +3,37 @@ import { describe, it, expect } from 'vitest'; import { decide, runJudgeGate, parseJudgeResponse, extractGate2Product, callJudgeModel, buildVerdictEntry, logVerdictLine, warnJudgeUnavailable, runJudgeTurn, - sealOnWiredGo, SPEC_PATH_RE, + sealOnWiredGo, SPEC_PATH_RE, bindingHashForJudge, bumpJudgeNoGo, } from './enforce-judge-gate.mjs'; +import { sealableArtifact, judgedHashOf, sealablePlan } from './seal-orchestration.mjs'; +import { planId } from './plan-lock.mjs'; + +describe('bumpJudgeNoGo per task-id (Фаза 4 — счётчик на стэк спека+план)', () => { + const mem = () => { const s = {}; return { readFileSync: (p) => { if (!(p in s)) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } return s[p]; }, writeFileSync: (p, d) => { s[p] = d; }, mkdirSync: () => {} }; }; + it('два разных task-id в одной сессии → независимые счётчики', () => { + const fsImpl = mem(); const dir = '/r'; + expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(1); + expect(bumpJudgeNoGo({ taskId: 'task:B', blocked: true, fsImpl, dir })).toBe(1); + expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(2); + expect(bumpJudgeNoGo({ taskId: 'task:B', blocked: false, fsImpl, dir })).toBe(0); + expect(bumpJudgeNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(3); + }); +}); + +describe('bindingHashForJudge (Фаза 3 — какой хеш судья сверяет с mentor-GO)', () => { + it('gate1 (спека) → хеш артефакта спеки (тот же, чем судья печатает gate1)', () => { + const content = '# Спека\n## Цель\nцель спеки'; + expect(bindingHashForJudge({ content, functionName: 'gate1' })).toBe(judgedHashOf(sealableArtifact(content))); + }); + it('gate2 (план) → plan_hash из steps', () => { + const content = ['# План', '```steps-json', '[{"n":1,"op":"Edit","object":"x","ref":"D1"}]', '```'].join('\n'); + expect(bindingHashForJudge({ content, functionName: 'gate2' })).toBe(planId(sealablePlan(content).steps)); + }); + it('gate1 и gate2 на одной спеке дают разные привязки (спека ≠ план)', () => { + const spec = '# Спека\n## Цель\nцель'; + expect(bindingHashForJudge({ content: spec, functionName: 'gate1' })).not.toBe(planId(sealablePlan('```steps-json\n[{"n":1,"op":"Edit","object":"x","ref":"D1"}]\n```').steps)); + }); +}); 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 }] }); @@ -36,6 +65,13 @@ describe('enforce-judge-gate decide (М7 Фаза 7 §8) — mode-aware + finalG expect(decide({ mode: 'live-block', verdict: null, floorBlocked: false }).block).toBe(true); expect(decide({ mode: 'live-block', verdict: {}, floorBlocked: false }).block).toBe(true); }); + it('live-block NO-GO → message несёт ПОЛНЫЙ текст возражения судьи (Фаза 1, Р2)', () => { + const verdict = { decision: 'NO-GO', verdict: { objections: [{ anchor: { ref: '§4 порог' }, severity: 'heavy' }] } }; + const r = decide({ mode: 'live-block', verdict, floorBlocked: false }); + expect(r.block).toBe(true); + expect(r.message).toContain('судья'); + expect(r.message).toContain('§4 порог'); + }); }); describe('runJudgeGate (async) — рубильник + детект + префетч (Δ-C degraded-allow)', () => { @@ -48,6 +84,31 @@ describe('runJudgeGate (async) — рубильник + детект + преф expect(r.wired).toBe(false); expect(calls).toBe(0); }); + it('Способ B fail-safe: нет одобрения наставника (mentorApproved→false) → судья молчит (skip no_mentor_go), транспорт НЕ зовётся ($0)', async () => { + let calls = 0; + const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; }, mentorApproved: () => false }); + expect(r.skip).toBe('no_mentor_go'); + expect(r.wired).toBe(false); + expect(calls).toBe(0); + }); + it('Способ B: есть одобрение наставника (mentorApproved→true) → судья судит (транспорт зовётся)', async () => { + let calls = 0; + const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; }, mentorApproved: () => true }); + expect(calls).toBe(1); + expect(r.wired).toBe(true); + }); + it('независимость (судья вслепую): промпт строится из плана, не несёт мнения наставника', async () => { + const prompts = []; + await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async (p) => { prompts.push(p); return okText; }, mentorApproved: () => true }); + expect(prompts).toHaveLength(1); + expect(JSON.stringify(prompts[0])).not.toMatch(/наставник|mentor-go|одобрил/i); + }); + it('mentorApproved не задан (наставник выключен) → судья судит как раньше (backward-compat)', async () => { + let calls = 0; + const r = await runJudgeGate(planEv(), { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; } }); + expect(calls).toBe(1); + expect(r.wired).toBe(true); + }); it('активен, но не план (Bash) → wired:false, транспорт не зовётся ($0)', async () => { let calls = 0; const deps = { judgeActiveImpl: () => true, apiKey: 'K', transport: async () => { calls++; return okText; } }; @@ -232,18 +293,32 @@ describe('runJudgeTurn — режим-aware (Δ-D inert/shadow/live-block, бе const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: () => {} }); expect(r.block).toBe(true); }); + it('live-block + NO-GO → полный текст возражения судьи доходит до контроллера (Фаза 1, Р2)', async () => { + const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => noText, logImpl: () => {} }); + expect(r.block).toBe(true); + expect(r.message).toContain('§1'); // дословный anchor.ref возражения судьи + expect(r.message).toContain('судья'); + }); it('live-block + не-план → allow (нечего судить, $0)', async () => { const r = await runJudgeTurn({ tool_name: 'Bash', tool_input: { command: 'ls' } }, { mode: 'live-block', judgeActiveImpl: () => true, apiKey: 'K', transport: async () => okText, logImpl: () => {} }); expect(r.block).toBe(false); }); - it('live-block + unavailable (нет ключа) → allow + warnImpl вызван, не verdict-лог (Δ-D)', async () => { + it('live-block + unavailable (§9): warnImpl вызван, не verdict-лог, контроллер ИНФОРМИРОВАН (block:true degraded «не дозвонился»)', async () => { const logged = []; let warned = 0; const r = await runJudgeTurn(planEv(), { mode: 'live-block', judgeActiveImpl: () => true, apiKey: '', transport: async () => okText, logImpl: (e) => logged.push(e), warnImpl: () => { warned++; } }); - expect(r.block).toBe(false); + expect(r.block).toBe(true); + expect(r.degraded).toBe(true); + expect(r.message).toMatch(/не смог дозвониться|недоступен/i); expect(logged).toHaveLength(0); expect(warned).toBe(1); }); + it('shadow + unavailable → тихо (block:false, D28 не блокирует), warnImpl вызван', async () => { + let warned = 0; + const r = await runJudgeTurn(planEv(), { mode: 'shadow', judgeActiveImpl: () => true, apiKey: '', transport: async () => okText, warnImpl: () => { warned++; } }); + expect(r.block).toBe(false); + expect(warned).toBe(1); + }); }); describe('sealed-plan production Task 5 — seal on wired GO (SPEC_PATH_RE + sealOnWiredGo)', () => { diff --git a/tools/enforce-mentor-on-plan-write.mjs b/tools/enforce-mentor-on-plan-write.mjs index ba9bf192..b9c6c528 100644 --- a/tools/enforce-mentor-on-plan-write.mjs +++ b/tools/enforce-mentor-on-plan-write.mjs @@ -7,12 +7,17 @@ * freeze-gate в пути судьи (T6). Рубильник mentorSeamActive: без флага+ключа — $0 no-op. */ import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs'; -import { mentorSeamActive, resolveMentorLlmKey, repoRootOf } from './mentor-gate-config.mjs'; -import { PLAN_PATH_RE } from './enforce-judge-gate.mjs'; -import { sealablePlan } from './seal-orchestration.mjs'; +import { mentorSeamActive, resolveMentorLlmKey } from './mentor-gate-config.mjs'; +import { PLAN_PATH_RE, SPEC_PATH_RE } from './enforce-judge-gate.mjs'; +import { sealablePlan, sealableArtifact, judgedHashOf } from './seal-orchestration.mjs'; import { planId } from './plan-lock.mjs'; -import { onPlanWrite } from './on-plan-write.mjs'; +import { onPlanWrite, onSpecWrite } from './on-plan-write.mjs'; import { parseVerifiedContext } from './plan-verified-context.mjs'; +// Мерж роутер↔наставник (Р8): наставник зовёт мозг роутера classify() как функцию + грузит +// граф (loadRegistry). parsePlanSkills/extractPlanGoal — объявленные скилы + цель из плана. +import { parsePlanSkills, extractPlanGoal } from './plan-skills.mjs'; +import { loadRegistry } from './registry-load.mjs'; +import { classify } from './router-classifier.mjs'; import { loadMentorJournal, persistMentorJournal, persistMentorVerdict } from './mentor-journal-store.mjs'; import { loadTaskId, saveTaskId, deriveTaskId } from './router-task-id.mjs'; import { callAnthropicAPI } from './router-classifier.mjs'; @@ -22,11 +27,12 @@ import { resolveSessionId } from './enforce-supreme-gate.mjs'; // Волна 7 (двухуровневые переговоры §6): наставник — surface + счётчик + эскалация → карточка. import { buildArbitrationCard } from './arbitration-card.mjs'; import { formatMentorObjection } from './objection-format.mjs'; +import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs'; import { parseNegotiationSection } from './negotiation-section.mjs'; import { bumpMentorNoGo, MENTOR_ESCALATE_AFTER } from './mentor-nogo-counter.mjs'; -// ФИКС ДЕДЛОКА: печать плана в Post (свежий вердикт для текущего плана) + наблюдаемость. -import { postSealPlan } from './post-seal.mjs'; -import { buildSealEntry, logSealAttempt } from './seal-log.mjs'; +// Способ B (Фаза 2): наставник НЕ печатает — на GO лишь записывает подписанное одобрение +// (mentor-GO, привязка к plan_hash). Печать делает судья (хук ПОСЛЕ наставника) при валидном mentor-GO. +import { buildMentorGo, persistMentorGo } from './mentor-go-store.mjs'; /** * Волна 7 (§6): сообщение арбитража при 3 NO-GO наставника — дословное замечание + @@ -51,6 +57,36 @@ export function buildMentorArbitrationMessage(res, planContent, n) { } +/** + * Фаза 1 (канал замечаний, Р2): чистое решение «что отдать контроллеру» по результату + * наставника. Только настоящий NO-GO (wired && !ok) → block:true с ПОЛНЫМ текстом замечания + * (через рабочий exit-2 канал); на 3-м заходе — карточка арбитража. GO/degraded → block:false. + */ +export function decideMentorObjection({ res, planContent, n } = {}) { + // degraded (наставник не дозвонился, спека §9): block:true с «не смог дозвониться», + // одобрения нет (recordMentorGo:false), это НЕ NO-GO (escalation не растёт). + if (res && res.ran && res.wired === false) { + return { + block: true, degraded: true, recordMentorGo: false, + message: buildDegradedFeedback({ side: 'mentor', reason: res.reason || 'транспорт недоступен' }), + }; + } + // Р7/мерж: содержательное «переделай» обязано заворачивать — явная кнопка decision='NO-GO' + // блокирует наряду со сломанным вердиктом (ok!==true). Раньше блокировал только сломанный → + // содержательный NO-GO тонул как GO (круг L1 не работал). + const decision = res && res.verdict && res.verdict.decision; + const blocked = !!(res && res.wired === true && (decision === 'NO-GO' || res.ok !== true)); + if (!blocked) { + // GO (wired && ok && decision==='GO'): наставник одобрил ЭТОТ план → записать mentor-GO. + const recordMentorGo = !!(res && res.wired === true && res.ok === true && decision === 'GO'); + return { block: false, recordMentorGo }; + } + const message = n >= MENTOR_ESCALATE_AFTER + ? buildMentorArbitrationMessage(res, String(planContent ?? ''), n) + : buildObjectionFeedback({ side: 'mentor', text: formatMentorObjection(res) }); + return { block: true, recordMentorGo: false, message }; +} + /** Адаптер llmCall (паттерн судьи [enforce-judge-gate.mjs:167-177]): throw НЕ глотаем — * его ловит runMentorVerdict → wired:false (SE-R6-6, не суд). */ export function buildLlmCall({ apiKey, model = CLASSIFIER_MODEL, transport = callAnthropicAPI }) { @@ -65,12 +101,45 @@ export function buildLlmCall({ apiKey, model = CLASSIFIER_MODEL, transport = cal export async function runMentorOnPlanWrite(event, { mentorActiveImpl, llmCall, loadJournalImpl, persistJournalImpl, persistVerdictImpl, loadTaskIdImpl, persistTaskIdImpl, journalKey, graphSectionImpl, nowMs = null, + classifyImpl = null, registryImpl = null, } = {}) { if (!mentorActiveImpl()) return { ran: false, reason: 'mentor inert ($0)' }; const tool = event && event.tool_name; const filePath = String((event && event.tool_input && event.tool_input.file_path) || ''); - if (tool !== 'Write' || !PLAN_PATH_RE.test(filePath)) return { ran: false, reason: 'не запись плана' }; const content = String((event && event.tool_input && event.tool_input.content) ?? ''); + // Фаза 3 (Р6, отдельный spec-путь): запись СПЕКИ тоже будит наставника. Вердикт по спеке + // (видит контекст: verified-context + переговоры), binding к хешу артефакта спеки (тот же, + // чем судья печатает gate1). Один сценарий со спекой и планом — наставник одобряет оба. + if (tool === 'Write' && SPEC_PATH_RE.test(filePath)) { + let specHash; + try { specHash = judgedHashOf(sealableArtifact(content)); } catch { specHash = null; } + if (!specHash) return { ran: false, reason: 'спека без артефакт-хеша — вердикт не фабрикуется' }; + const journalS = loadJournalImpl(); + const taskIdForPromptS = deriveTaskId({ existingTaskId: loadTaskIdImpl(), firstPlanHash: specHash }); + const negotiationLogS = (journalS.entries || []) + .map((e) => e && e.payload) + .filter((p) => p && p.task_id === taskIdForPromptS); + let graphSectionS = null; + try { graphSectionS = graphSectionImpl(); } catch { graphSectionS = null; } + const verifiedContextS = parseVerifiedContext(content); + const rs = await onSpecWrite({ + specContent: content, + specHash, + existingTaskId: loadTaskIdImpl(), + persistTaskIdImpl, + llmCall, + journalEntries: journalS.entries, + journalKey, + nowMs, + verifiedContext: verifiedContextS, + negotiationLog: negotiationLogS, + graphSection: graphSectionS, + }); + try { persistVerdictImpl({ ok: rs.ok, wired: rs.wired, reason: rs.reason ?? null, planHash: specHash, verdict: rs.verdict }); } catch { /* best-effort */ } + if (rs.journalOk && rs.journal) { try { persistJournalImpl(rs.journal); } catch { /* best-effort (SE10) */ } } + return { ran: true, ok: rs.ok, wired: rs.wired, reason: rs.reason, taskId: rs.taskId, planHash: specHash, verdict: rs.verdict }; + } + if (tool !== 'Write' || !PLAN_PATH_RE.test(filePath)) return { ran: false, reason: 'не запись плана/спеки' }; let steps; try { steps = sealablePlan(content).steps; } catch { steps = null; } if (!Array.isArray(steps) || steps.length === 0) { @@ -86,6 +155,12 @@ export async function runMentorOnPlanWrite(event, { let graphSection = null; try { graphSection = graphSectionImpl(); } catch { graphSection = null; } // F-C6: null → маркер ОТСУТСТВИЯ const verifiedContext = parseVerifiedContext(content); + // Мерж (Р8): объявленные в плане скилы + цель → onPlanWrite зовёт classify() (мозг роутера), + // кладёт «рекомендация vs объявлено» в вердикт. registryImpl?.() — граф/карточки nodes.yaml. + const declaredSkills = parsePlanSkills(content); + const planGoal = extractPlanGoal(content); + let registry = null; + try { registry = typeof registryImpl === 'function' ? registryImpl() : null; } catch { registry = null; } const r = await onPlanWrite({ planSteps: steps, existingTaskId: loadTaskIdImpl(), @@ -97,6 +172,10 @@ export async function runMentorOnPlanWrite(event, { verifiedContext, negotiationLog, graphSection, + classifyImpl, + registry, + declaredSkills, + planGoal, }); const planHash = planId(steps); try { persistVerdictImpl({ ok: r.ok, wired: r.wired, reason: r.reason ?? null, planHash, verdict: r.verdict }); } catch { /* best-effort */ } @@ -123,25 +202,36 @@ async function main() { // Боевой граф B — следующий шаг после обкатки (runbook-нота T7): null → промпт // наставника несёт явный маркер «КАРТА РАЙОНОВ ОТСУТСТВУЕТ» (F-C6, не тихо). graphSectionImpl: () => null, + // Мерж (Р8): мозг роутера classify() как функция (3 слоя + граф/карточки nodes.yaml, + // код classify/registry-load НЕ тронут — новый вызыватель). classify сам берёт свой + // ключ/транспорт; сбой ловит onPlanWrite → fail-safe (план без скил-сверки, §5). + classifyImpl: async (goal, registry) => classify(goal, registry, {}), + registryImpl: () => { try { return loadRegistry({ useCache: false }); } catch { return null; } }, }); if (res && res.ran) { - const blocked = res.wired === true && res.ok !== true; - const n = bumpMentorNoGo({ sessionId: sess, blocked }); - if (blocked) { - const planContent = String((event.tool_input && event.tool_input.content) ?? ''); - const msg = n >= MENTOR_ESCALATE_AFTER ? buildMentorArbitrationMessage(res, planContent, n) : formatMentorObjection(res); - if (msg) console.error(msg); - } - // ФИКС ДЕДЛОКА: печать плана ЗДЕСЬ (Post) — вердикт наставника свежий для текущего плана, - // судья (Pre) уже сохранил подписанный GO. Best-effort: производитель НИКОГДА не блокирует. - if (res.wired === true && res.planHash) { + // Р7/§3.4: счётчик L1 растёт на NO-GO = содержательный decision='NO-GO' ИЛИ сломанный + // вердикт (ok!==true). degraded (wired:false) не считается (escalation L1 не растёт). + const verdictDecision = res.verdict && res.verdict.decision; + const blocked = res.wired === true && (verdictDecision === 'NO-GO' || res.ok !== true); + // Фаза 4: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sess — fallback). + const n = bumpMentorNoGo({ taskId: res.taskId, sessionId: sess, blocked }); + // Фаза 1 (Р2): на NO-GO/degraded — ПОЛНЫЙ текст доходит до контроллера через рабочий + // exit-2 канал (подтверждён Фазой 0). На 3-м NO-GO — карточка арбитража. + const planContent = String((event.tool_input && event.tool_input.content) ?? ''); + const decision = decideMentorObjection({ res, planContent, n }); + // Способ B (Task 2.2): наставник НЕ печатает. На GO — записывает подписанное одобрение + // (mentor-GO, binding plan_hash); печать сделает судья (хук ПОСЛЕ) при валидном mentor-GO. + if (decision.recordMentorGo && res.planHash) { try { - const sealRes = postSealPlan({ - event, mentorVerdict: res.verdict, planHash: res.planHash, - sessionId: sess, runtimeDir: dir, repoRoot: repoRootOf(event), key: resolveReceiptKey(), + persistMentorGo({ + record: buildMentorGo({ planHash: res.planHash, key: resolveReceiptKey() }), + sessionId: sess, runtimeDir: dir, }); - logSealAttempt(buildSealEntry({ functionName: 'gate2-post', judgeActive: true, wired: true, decision: 'GO', sealResult: sealRes })); - } catch { /* best-effort */ } + } catch { /* best-effort: нет записи → судья просто не запечатает (fail-safe) */ } + } + if (decision.block) { + exitDecision(decision); // exit 2 со stderr-сообщением (замечание/degraded) + return; } } } catch { /* производитель никогда не блокирует */ } diff --git a/tools/enforce-mentor-on-plan-write.test.mjs b/tools/enforce-mentor-on-plan-write.test.mjs index be4ab32b..a1a7b23e 100644 --- a/tools/enforce-mentor-on-plan-write.test.mjs +++ b/tools/enforce-mentor-on-plan-write.test.mjs @@ -1,6 +1,6 @@ // tools/enforce-mentor-on-plan-write.test.mjs import { describe, it, expect } from 'vitest'; -import { runMentorOnPlanWrite, buildLlmCall } from './enforce-mentor-on-plan-write.mjs'; +import { runMentorOnPlanWrite, buildLlmCall, decideMentorObjection } from './enforce-mentor-on-plan-write.mjs'; const PLAN_MD = [ '# План', '', @@ -8,7 +8,7 @@ const PLAN_MD = [ '```steps-json', '[{"n":1,"op":"Edit","object":"tools/x.mjs","ref":"D1"}]', '```', '', '```verified-context-json', '[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"tools/x.mjs:1","anchor":"якорь-подстрока"}]', '```', ].join('\n'); -const GOOD_VERDICT = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9 }; +const GOOD_VERDICT = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' }; const planEvent = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/2026-06-12-t.md', content: PLAN_MD }, session_id: 'S1' }; // real-test маркер региона правки (фикстура выше используется ассертами ниже): @@ -29,6 +29,8 @@ function deps(over = {}) { persistTaskIdImpl: (id) => { persisted.taskId = id; }, journalKey: 'k', graphSectionImpl: () => null, + classifyImpl: async () => ({ recommended_chain: [] }), + registryImpl: () => ({}), ...over, }; } @@ -68,6 +70,25 @@ describe('runMentorOnPlanWrite (обёртка-производитель W7)', expect(r.wired).toBe(false); expect(d.persisted.verdict.wired).toBe(false); }); + // Фаза 3 (Р6): наставник судит и СПЕКУ (отдельный spec-путь), binding к хешу артефакта спеки. + const SPEC_MD = [ + '# Спека', '## Цель', 'описание решения', '', + '```verified-context-json', '[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"tools/x.mjs:1","anchor":"якорь-подстрока"}]', '```', + ].join('\n'); + const specEvent = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/specs/2026-06-13-x.md', content: SPEC_MD }, session_id: 'S1' }; + it('запись СПЕКИ → наставник судит спеку, вердикт произведён, binding specHash, verdict персистнут', async () => { + const d = deps(); + const r = await runMentorOnPlanWrite(specEvent, d); + expect(r.ran).toBe(true); + expect(r.ok).toBe(true); + expect(typeof r.planHash).toBe('string'); + expect(r.planHash.length).toBeGreaterThan(0); + expect(d.persisted.verdict.verdict.plan_hash).toBe(r.planHash); // binding к артефакту спеки + }); + it('запись СПЕКИ: чужой путь (другой .md) → no-op', async () => { + expect((await runMentorOnPlanWrite({ tool_name: 'Write', tool_input: { file_path: 'docs/x.md', content: SPEC_MD }, session_id: 'S1' }, deps())).ran).toBe(false); + }); + // W-3 (sharp-edges 2026-06-12): в промпт наставника идут переговоры ТОЛЬКО текущей // задачи — чужие task_id из общей цепи журнала не текут между задачами. it('W-3: negotiationLog фильтруется по task_id текущей задачи', async () => { @@ -84,6 +105,66 @@ describe('runMentorOnPlanWrite (обёртка-производитель W7)', expect(capturedUser).toMatch(/СВОЁ-СООБЩЕНИЕ/); expect(capturedUser).not.toMatch(/ЧУЖОЕ-СООБЩЕНИЕ/); }); + + // Мерж (Task 8): план-Write парсит объявленные скилы и зовёт classifyImpl (мозг роутера). + it('runMentorOnPlanWrite (план): парсит скилы + зовёт classifyImpl', async () => { + let classifyCalled = false; + const PLAN = ['# План', '```skills-json', '["executing-plans"]', '```', '## Цель', 'чинить', '```steps-json', '[{"n":1,"op":"Edit","object":"x","ref":"D1"}]', '```', '```verified-context-json', '[{"id":"1","kind":"EXTRACTED","claim":"c","ref":"x:1","anchor":"я"}]', '```'].join('\n'); + const ev = { tool_name: 'Write', tool_input: { file_path: 'docs/superpowers/plans/2026-06-13-t.md', content: PLAN }, session_id: 'S1' }; + const d = deps({ classifyImpl: async () => { classifyCalled = true; return { recommended_chain: ['systematic-debugging'] }; } }); + const r = await runMentorOnPlanWrite(ev, d); + expect(r.ran).toBe(true); + expect(classifyCalled).toBe(true); + }); +}); + +describe('decideMentorObjection (Фаза 1 — канал замечаний наставника контроллеру)', () => { + const noGo = { ran: true, wired: true, ok: false, reason: 'шаг 2 трогает файл X без обоснования', verdict: { objections: [] } }; + it('NO-GO (n<3) → block:true + полный текст замечания доходит до контроллера', () => { + const d = decideMentorObjection({ res: noGo, planContent: '# план', n: 1 }); + expect(d.block).toBe(true); + expect(d.message).toContain('наставник'); + expect(d.message).toContain('шаг 2 трогает файл X без обоснования'); + }); + it('NO-GO на 3-м заходе (эскалация) → block:true + карточка арбитража с дословным замечанием', () => { + const d = decideMentorObjection({ res: noGo, planContent: '# план', n: 3 }); + expect(d.block).toBe(true); + expect(d.message).toContain('Что меняет выбор'); // маркер карточки арбитража + expect(d.message).toContain('шаг 2 трогает файл X без обоснования'); // дословное замечание сохранено + }); + it('GO (wired:true, ok:true, decision=GO) → block:false + recordMentorGo:true (наставник одобрил)', () => { + const d = decideMentorObjection({ res: { ran: true, wired: true, ok: true, planHash: 'PH', verdict: { decision: 'GO' } }, planContent: '', n: 0 }); + expect(d.block).toBe(false); + expect(d.recordMentorGo).toBe(true); + }); + it('NO-GO → recordMentorGo:false (одобрения нет)', () => { + expect(decideMentorObjection({ res: noGo, planContent: '# план', n: 1 }).recordMentorGo).toBe(false); + }); + it('degraded (wired:false — спека §9) → block:true + degraded-сообщение, recordMentorGo:false', () => { + const d = decideMentorObjection({ res: { ran: true, wired: false, ok: false, reason: 'timeout' }, planContent: '', n: 0 }); + expect(d.block).toBe(true); + expect(d.degraded).toBe(true); + expect(d.message).toMatch(/не смог дозвониться|недоступен/i); + expect(d.recordMentorGo).toBe(false); + }); +}); + +describe('decideMentorObjection — decision (мерж/Р7)', () => { + const noGo = { ran: true, wired: true, ok: true, verdict: { decision: 'NO-GO', recommendation: 'добавь systematic-debugging' } }; + const go = { ran: true, wired: true, ok: true, verdict: { decision: 'GO' } }; + it('содержательный NO-GO (ok=true, decision=NO-GO) → block + recordMentorGo:false', () => { + const d = decideMentorObjection({ res: noGo, planContent: '# п', n: 1 }); + expect(d.block).toBe(true); + expect(d.recordMentorGo).toBe(false); + }); + it('GO (decision=GO) → block:false + recordMentorGo:true', () => { + const d = decideMentorObjection({ res: go, planContent: '', n: 0 }); + expect(d.block).toBe(false); + expect(d.recordMentorGo).toBe(true); + }); + it('сломанный вердикт (wired && !ok) → block (как раньше)', () => { + expect(decideMentorObjection({ res: { ran: true, wired: true, ok: false }, planContent: '', n: 0 }).block).toBe(true); + }); }); describe('buildLlmCall (адаптер транспорта, паттерн судьи :167-177)', () => { diff --git a/tools/enforce-supreme-gate-bootstrap.test.mjs b/tools/enforce-supreme-gate-bootstrap.test.mjs new file mode 100644 index 00000000..efd68e21 --- /dev/null +++ b/tools/enforce-supreme-gate-bootstrap.test.mjs @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthoringWrite, decideMode } from './enforce-supreme-gate.mjs'; + +const newFile = { existsImpl: () => false }; // файла ещё нет на диске + +describe('M7 Ф8 bootstrap-карвут: авторская запись нового плана/спеки', () => { + it('Write нового .md в specs/ или plans/ → true', () => { + expect(isAuthoringWrite({ name: 'Write', input: { file_path: 'docs/superpowers/specs/2026-06-12-x.md' } }, newFile)).toBe(true); + expect(isAuthoringWrite({ name: 'Write', input: { file_path: 'docs/superpowers/plans/2026-06-12-x.md' } }, newFile)).toBe(true); + }); + it('перезапись существующего файла → false', () => { + expect(isAuthoringWrite({ name: 'Write', input: { file_path: 'docs/superpowers/plans/x.md' } }, { existsImpl: () => true })).toBe(false); + }); + it('код / чужой путь / не-Write → false', () => { + expect(isAuthoringWrite({ name: 'Write', input: { file_path: 'tools/enforce-supreme-gate.mjs' } }, newFile)).toBe(false); + expect(isAuthoringWrite({ name: 'Write', input: { file_path: 'app/Foo.php' } }, newFile)).toBe(false); + expect(isAuthoringWrite({ name: 'Edit', input: { file_path: 'docs/superpowers/plans/x.md' } }, newFile)).toBe(false); + }); + it('decideMode без плана + авторская запись нового файла → allow', () => { + const r = decideMode({ toolUse: { name: 'Write', input: { file_path: 'docs/superpowers/plans/__nonexistent_bootstrap_test__.md' } }, frozenPlan: null, key: 'k' }); + expect(r.decision).toBe('allow'); + }); + it('decideMode без плана + запись в код → по-прежнему block (регрессия)', () => { + const r = decideMode({ toolUse: { name: 'Write', input: { file_path: 'app/Foo.php' } }, frozenPlan: null, key: 'k' }); + expect(r.decision).toBe('block'); + }); +}); diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index 7e6d9f41..ea21ca43 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -17,6 +17,11 @@ import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs' import { assertSafeSessionId } from './action-journal.mjs'; import { classifyDestructive } from './classify-destructive.mjs'; import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; + +// Фаза 5 Task 5.2 (Вариант А): зарезервированная canonical-метка finish-гранта владельца +// «план завершён досрочно». НЕ совпадает ни с одним реальным действием (canonicalAction даёт +// write:/bash:/skill:/mcp:/powershell:/unknown:) → срабатывает только на намеренный finish-грант. +export const PLAN_FINISH_ACTION = 'plan-done'; import { logGuardBlock } from './guard-block-log.mjs'; import { existsSync } from 'node:fs'; @@ -250,7 +255,12 @@ export function decide({ toolUse, frozenPlan, frozenArtifact = null, stepPtr = 0 const cur = normalizeToLeaf(frozenPlan.steps, deserializePointer(stepPtr, frozenPlan.steps)); advanceTo = serializePointer(advanceOverTree(frozenPlan.steps, cur)); } catch { return { decision: 'block', reason: 'навигация по дереву превысила предел (fail-CLOSED)' }; } - return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo }; + // Фаза 5 (чистое завершение): этот шаг — последний, если следующий указатель уже не + // резолвится в лист. runGate тогда сам снимет печать → следующее действие в разговорном + // (вместо вечного «план исчерпан»). Сбой проверки → не complete (печать держится, безопасно). + let planComplete = false; + try { planComplete = !treeLeafAt(frozenPlan.steps, advanceTo); } catch { planComplete = false; } + return { decision: 'allow', reason: `шаг ${step.n} плана`, advance: true, advanceTo, planComplete }; } /** ✅O18: рассинхрон печатей judge_mode — РОВНО одна 'live-block' (XOR). Обе одинаковы → false. */ @@ -271,6 +281,13 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k if (escapeGrantOpen(canonicalAction(toolUse?.name, toolUse?.input || {}), escapeGrants, escapeConsumed, now)) { return { decision: 'allow', mode: 'escape', reason: 'разрешено аварийным выходом владельца (floor_escape) — указатель не двигается' }; } + // Фаза 5 Task 5.2 (Вариант А): досрочное завершение плана владельцем. Открыт finish-грант + // (floor_escape с зарезервированной меткой PLAN_FINISH_ACTION — её НЕ порождает ни одно + // реальное действие) И есть запечатанный план → план завершается: печать снимается + // (runGate зовёт removeFrozenPlan), возврат в разговорный. Не требует валидного артефакта. + if (frozenPlan && escapeGrantOpen(PLAN_FINISH_ACTION, escapeGrants, escapeConsumed, now)) { + return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' }; + } if (!frozenPlan) { if (isSeed(toolUse) || isObserveOnly(toolUse) || isAuthoringWrite(toolUse)) return { decision: 'allow', mode: 'conversational', reason: 'seed/observe/authoring (разговорный режим)' }; return { decision: 'block', mode: 'conversational', reason: 'разговорный режим: только думать/спрашивать (реализация — после печати артефакта и плана)' }; @@ -303,7 +320,7 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k * Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг. * journal/saveStep инъектируются (в main — реальные Node fs). */ -export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { +export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { const toolUse = { name: event.tool_name, input: event.tool_input }; const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); // FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал @@ -317,6 +334,12 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verif } return { block: false, message: r.reason }; } + // Фаза 5 Task 5.2 (Вариант А): владелец завершил план досрочно (finish-грант) → снять печать + // (best-effort, сбой не ломает allow) и вернуться в разговорный. Указатель не двигаем. + if (r.finishPlan) { + if (typeof removeFrozenPlan === 'function') { try { removeFrozenPlan(); } catch { /* best-effort */ } } + return { block: false, message: r.reason }; + } // W4 (✅O18, C2): warn от decideMode (judge_mode рассинхрон) НЕ роняется — дописывается // в message вывода хука (владелец видит «энфорсмент off» громко; полное owner-резюме // гейта-1 — поведенческая сборка контроллера, owner-activation). @@ -334,6 +357,12 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, key, verif return { block: true, message: 'Δ3: не удалось пред-записать намерение в журнал — действие не разрешено (нет записи → нет действия)' }; } saveStep(r.advanceTo); + // Фаза 5 (Task 5.1, чистое завершение): последний шаг плана выполнен → стена САМА снимает + // печать (removeFrozenPlan) → следующее действие в разговорном режиме (нет «план исчерпан», + // не нужно ручное удаление файла). Best-effort: сбой снятия НЕ ломает allow. + if (r.planComplete && typeof removeFrozenPlan === 'function') { + try { removeFrozenPlan(); } catch { /* best-effort */ } + } } return { block: r.decision === 'block', message: withWarn(r.reason) }; } @@ -352,7 +381,7 @@ async function main() { try { event = parseEventJson(await readStdin()); const { resolveReceiptKey } = await import('./receipt-key-config.mjs'); - const { loadFrozenPlan, loadFrozenArtifact } = await import('./plan-lock.mjs'); + const { loadFrozenPlan, loadFrozenArtifact, removeFrozenPlan } = await import('./plan-lock.mjs'); const { journalAppend } = await import('./action-journal.mjs'); const os = await import('node:os'); const fs = await import('node:fs'); const runtimeDir = `${os.homedir()}/.claude/runtime`; @@ -371,6 +400,7 @@ async function main() { event, frozenPlan, frozenArtifact, stepPtr, key, escapeGrants, escapeConsumed, journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }), saveStep: (n) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key))), // R-19: подписано + removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение }); if (r.block) logGuardBlock(event, 'М2 Стена', r.message); exitDecision({ block: r.block, message: r.block ? `[supreme-gate] ${r.message}` : undefined }); diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 8d4226b1..2aadf063 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -692,3 +692,92 @@ describe('buildPlanAuthorizesPath (W2 продюсер, SE5/Д-С2-1)', () => { expect(buildPlanAuthorizesPath(plan1, { stepPtr: 99, normalize: low })('tools/a.mjs')).toBe(false); }); }); + +import { decideMode as decideModeFin, runGate as runGateFin, PLAN_FINISH_ACTION } from './enforce-supreme-gate.mjs'; +describe('Фаза 5 Task 5.2 — досрочное завершение владельцем (finish-грант «plan-done», Вариант А)', () => { + const KFIN = 'k'; + const verifyFin = (p) => p && p.sig !== 'BAD'; + const lowFin = (p) => p.toLowerCase(); + const PLANF = { plan_id: 'pf', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs' }, { n: 2, op: 'Edit', object: 'tools/b.mjs' }], sig: 'ok', judge_mode: 'live-block' }; + const ART = { sig: 'ok', judge_mode: 'live-block' }; + const finGrant = (now) => [{ action: PLAN_FINISH_ACTION, ts: now }]; + + it('PLAN_FINISH_ACTION — зарезервированная метка (не совпадает с реальными действиями)', () => { + expect(typeof PLAN_FINISH_ACTION).toBe('string'); + expect(PLAN_FINISH_ACTION).not.toMatch(/^(write|bash|skill|mcp|powershell):/); + }); + it('decideMode: открыт finish-грант + есть план → allow, conversational, finishPlan:true (даже на середине)', () => { + const now = 1000; + const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: finGrant(now), escapeConsumed: [], now }); + expect(r.decision).toBe('allow'); + expect(r.mode).toBe('conversational'); + expect(r.finishPlan).toBe(true); + }); + it('decideMode: нет finish-гранта → обычный план-режим (finishPlan не выставлен)', () => { + const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: [], escapeConsumed: [], now: 1000 }); + expect(r.finishPlan).toBeUndefined(); + }); + it('runGate: finishPlan → removeFrozenPlan вызван + allow (печать снята досрочно)', () => { + let removed = 0; const now = 1000; + const r = runGateFin({ + event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } }, + frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, + verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, + journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; }, + escapeGrants: finGrant(now), escapeConsumed: [], now, + }); + expect(r.block).toBe(false); + expect(removed).toBe(1); + }); +}); + +import { decide as decideCE, runGate as runGateCE } from './enforce-supreme-gate.mjs'; +describe('Фаза 5 — чистое завершение плана (стена сама снимает печать)', () => { + const KCE = 'k-ce'; + const verifyCE = (p) => p && p.sig !== 'BAD'; + const lowCE = (p) => p.toLowerCase(); + const PLAN1 = { plan_id: 'p1', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/foo.mjs', intent: 'i' }], sig: 'ok' }; + const PLAN2 = { plan_id: 'p2', frozen_at: 1, steps: [{ n: 1, op: 'Write', object: 'tools/a.mjs' }, { n: 2, op: 'Edit', object: 'tools/b.mjs' }], sig: 'ok' }; + const dctx = (over) => ({ key: KCE, frozenArtifact: { sig: 'ok' }, verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, ...over }); + + it('decide: последний шаг плана → allow + planComplete:true', () => { + const r = decideCE(dctx({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLAN1, stepPtr: 0 })); + expect(r.decision).toBe('allow'); + expect(r.planComplete).toBe(true); + }); + it('decide: НЕ последний шаг (впереди есть) → planComplete:false', () => { + const r = decideCE(dctx({ toolUse: { name: 'Write', input: { file_path: 'tools/a.mjs' } }, frozenPlan: PLAN2, stepPtr: 0 })); + expect(r.decision).toBe('allow'); + expect(r.planComplete).toBe(false); + }); + it('runGate: последний шаг выполнен → removeFrozenPlan вызван (печать снята → разговорный)', () => { + let removed = 0; + const r = runGateCE({ + event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } }, + frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE, + verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, + journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; }, + }); + expect(r.block).toBe(false); + expect(removed).toBe(1); + }); + it('runGate: НЕ последний шаг → removeFrozenPlan НЕ вызван (печать держится)', () => { + let removed = 0; + runGateCE({ + event: { tool_name: 'Write', tool_input: { file_path: 'tools/a.mjs' } }, + frozenPlan: { ...PLAN2, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE, + verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, + journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; }, + }); + expect(removed).toBe(0); + }); + it('runGate: снятие печати best-effort — бросок removeFrozenPlan НЕ ломает allow', () => { + const r = runGateCE({ + event: { tool_name: 'Write', tool_input: { file_path: 'tools/foo.mjs' } }, + frozenPlan: { ...PLAN1, judge_mode: 'live-block' }, frozenArtifact: { sig: 'ok', judge_mode: 'live-block' }, stepPtr: 0, key: KCE, + verifyImpl: verifyCE, verifyArtifactImpl: verifyCE, normalize: lowCE, + journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { throw new Error('unlink fail'); }, + }); + expect(r.block).toBe(false); + }); +}); diff --git a/tools/freeze-gate.test.mjs b/tools/freeze-gate.test.mjs index 95253c62..22ba4418 100644 --- a/tools/freeze-gate.test.mjs +++ b/tools/freeze-gate.test.mjs @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { freezeGate } from './freeze-gate.mjs'; -const goodVerdict = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec', confidence: 0.8, plan_hash: 'PH1' }; +const goodVerdict = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec', confidence: 0.8, decision: 'GO', plan_hash: 'PH1' }; // hasUnresolvedExtractedImpl: true = ЕСТЬ неразрешённая EXTRACTED = ГРЯЗНО = блок. Стаб вместо A. const verityClean = () => false; const verityDirty = () => true; diff --git a/tools/mentor-go-store.mjs b/tools/mentor-go-store.mjs new file mode 100644 index 00000000..74c4184c --- /dev/null +++ b/tools/mentor-go-store.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * mentor-go-store (способ B, Фаза 2) — наставник в Post записывает подписанное «я одобрил + * ЭТОТ план» (привязка к plan_hash, нах.F4). Судья (хук ПОСЛЕ наставника) читает запись и + * судит/печатает ТОЛЬКО при валидном mentor-GO; нет одобрения наставника → судья молчит + * (fail-safe). Зеркало judge-go-store, домен подписи MENTOR_GO. + */ +import fsDefault from 'node:fs'; +import { assertSafeSessionId } from './action-journal.mjs'; +import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; + +const DOMAIN = RECEIPT_DOMAINS.MENTOR_GO; + +function mentorGoPath(runtimeDir, sessionId) { + assertSafeSessionId(sessionId); + const sep = runtimeDir.endsWith('/') ? '' : '/'; + return `${runtimeDir}${sep}mentor-go-${sessionId}.json`; +} + +/** Чистая сборка подписанной записи «наставник одобрил» для плана (plan_hash — binding нах.F4). */ +export function buildMentorGo({ planHash, judgeMode = null, key, nowMs = null }) { + const base = { + plan_hash: planHash ?? null, + approved: true, + at: typeof nowMs === 'number' ? nowMs : null, + }; + return { ...base, sig: signPayload(base, key, DOMAIN) }; +} + +/** Запись валидна И принадлежит ЭТОМУ плану И подпись цела? Иначе false (fail-closed). */ +export function mentorGoValidFor(record, { planHash, key } = {}) { + if (!record || typeof record !== 'object') return false; + if (record.plan_hash !== planHash) return false; + if (record.approved !== true) return false; + return verifyReceipt(record, key, DOMAIN); +} + +/** Атомарная запись одобрения наставника в ~/.claude/runtime/mentor-go-.json. */ +export function persistMentorGo({ record, sessionId, runtimeDir, fsImpl = fsDefault }) { + const p = mentorGoPath(runtimeDir, sessionId); + const tmp = `${p}.tmp`; + fsImpl.writeFileSync(tmp, JSON.stringify(record)); + fsImpl.renameSync(tmp, p); +} + +/** Загрузка одобрения наставника (нет файла → null). */ +export function loadMentorGo({ sessionId, runtimeDir, fsImpl = fsDefault }) { + try { return JSON.parse(fsImpl.readFileSync(mentorGoPath(runtimeDir, sessionId), 'utf8')); } + catch (e) { if (e && e.code === 'ENOENT') return null; throw e; } +} diff --git a/tools/mentor-go-store.test.mjs b/tools/mentor-go-store.test.mjs new file mode 100644 index 00000000..a5b5e473 --- /dev/null +++ b/tools/mentor-go-store.test.mjs @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { buildMentorGo, mentorGoValidFor, persistMentorGo, loadMentorGo } from './mentor-go-store.mjs'; + +const KEY = 'k-test'; + +describe('mentor-go-store (наставник одобрил этот план — зеркало judge-go-store)', () => { + it('buildMentorGo подписан; mentorGoValidFor true для своего plan_hash + валидной подписи', () => { + const rec = buildMentorGo({ planHash: 'PH1', key: KEY }); + expect(mentorGoValidFor(rec, { planHash: 'PH1', key: KEY })).toBe(true); + }); + it('чужой plan_hash → false (не одобрение этого плана)', () => { + const rec = buildMentorGo({ planHash: 'PH1', key: KEY }); + expect(mentorGoValidFor(rec, { planHash: 'OTHER', key: KEY })).toBe(false); + }); + it('битая подпись / чужой ключ → false (fail-closed)', () => { + const rec = buildMentorGo({ planHash: 'PH1', key: KEY }); + expect(mentorGoValidFor(rec, { planHash: 'PH1', key: 'wrong' })).toBe(false); + expect(mentorGoValidFor({ ...rec, sig: 'tampered' }, { planHash: 'PH1', key: KEY })).toBe(false); + }); + it('null / без approved → false', () => { + expect(mentorGoValidFor(null, { planHash: 'PH1', key: KEY })).toBe(false); + expect(mentorGoValidFor({ plan_hash: 'PH1' }, { planHash: 'PH1', key: KEY })).toBe(false); + }); +}); + +describe('persistMentorGo / loadMentorGo (атомарная запись tmp+rename, mock fs)', () => { + it('persist пишет через tmp+rename; load читает обратно; нет файла → null', () => { + const files = {}; + const fsImpl = { + writeFileSync: (p, d) => { files[p] = d; }, + renameSync: (a, b) => { files[b] = files[a]; delete files[a]; }, + readFileSync: (p) => { if (!(p in files)) { const e = new Error('no'); e.code = 'ENOENT'; throw e; } return files[p]; }, + }; + const rec = buildMentorGo({ planHash: 'PH1', key: KEY }); + expect(loadMentorGo({ sessionId: 'S1', runtimeDir: '/rt', fsImpl })).toBe(null); + persistMentorGo({ record: rec, sessionId: 'S1', runtimeDir: '/rt', fsImpl }); + expect(loadMentorGo({ sessionId: 'S1', runtimeDir: '/rt', fsImpl })).toEqual(rec); + }); +}); diff --git a/tools/mentor-nogo-counter.mjs b/tools/mentor-nogo-counter.mjs index c823699b..e05baed1 100644 --- a/tools/mentor-nogo-counter.mjs +++ b/tools/mentor-nogo-counter.mjs @@ -9,8 +9,10 @@ import { runtimeDir } from './enforce-hook-helpers.mjs'; export const MENTOR_ESCALATE_AFTER = 3; -export function bumpMentorNoGo({ sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { - const safe = String(sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); +export function bumpMentorNoGo({ taskId, sessionId, blocked, fsImpl = fsDefault, dir = runtimeDir() } = {}) { + // Фаза 4: счётчик на СТЭК (спека+план) одной задачи — ключ task-id (sessionId — fallback + // backward-compat). Две правки в одной сессии → независимые счётчики. + const safe = String(taskId || sessionId || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); const path = join(dir, `mentor-nogo-${safe}.json`); let count = 0; try { count = (JSON.parse(fsImpl.readFileSync(path, 'utf8')).count) || 0; } catch { count = 0; } diff --git a/tools/mentor-nogo-counter.test.mjs b/tools/mentor-nogo-counter.test.mjs index a93a0fa1..130815f6 100644 --- a/tools/mentor-nogo-counter.test.mjs +++ b/tools/mentor-nogo-counter.test.mjs @@ -17,6 +17,14 @@ describe('bumpMentorNoGo', () => { expect(bumpMentorNoGo({ sessionId, blocked: false, fsImpl, dir })).toBe(0); expect(bumpMentorNoGo({ sessionId, blocked: true, fsImpl, dir })).toBe(1); }); + it('Фаза 4: два разных task-id в одной сессии → независимые счётчики (per-task стэк)', () => { + const fsImpl = mem(); const dir = '/r'; + expect(bumpMentorNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(1); + expect(bumpMentorNoGo({ taskId: 'task:B', blocked: true, fsImpl, dir })).toBe(1); // B независим от A + expect(bumpMentorNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(2); + expect(bumpMentorNoGo({ taskId: 'task:B', blocked: false, fsImpl, dir })).toBe(0); // сброс только B + expect(bumpMentorNoGo({ taskId: 'task:A', blocked: true, fsImpl, dir })).toBe(3); // A не тронут + }); it('порог эскалации = 3', () => { expect(MENTOR_ESCALATE_AFTER).toBe(3); }); it('небезопасный sessionId санитизируется (не падает)', () => { const fsImpl = mem(); diff --git a/tools/mentor-seam.mjs b/tools/mentor-seam.mjs index 68f922ce..67fba3fc 100644 --- a/tools/mentor-seam.mjs +++ b/tools/mentor-seam.mjs @@ -53,6 +53,17 @@ export function renderNegotiation(log) { return ['--- ПЕРЕГОВОРЫ ЗАДАЧИ (история кругов) ---', ...lines].join('\n'); } +/** Рендер скил-контекста для промпта наставника (мерж): объявленные в плане скилы + + * рекомендация роутера. recommendedChain=null → классификатор недоступен (не сверять). */ +export function renderSkillContext({ declared = [], recommendedChain = null } = {}) { + const dec = declared.length ? declared.join(', ') : '(не объявлены)'; + const rec = recommendedChain === null + ? '(рекомендация роутера недоступна — НЕ заворачивай за скилы)' + : (Array.isArray(recommendedChain) && recommendedChain.length ? recommendedChain.join(', ') : '(роутер ничего не порекомендовал)'); + return `--- СКИЛЫ ---\nОбъявлены в плане: ${dec}\nРекомендация роутера: ${rec}\n` + + 'Оцени уместность выбора скилов; неуместный/неполный выбор → decision="NO-GO" + что добавить/убрать.'; +} + /** * Построить промпт наставника = buildRouterPrompt(graph=граф-секция B; районы+staleness * рендерит БАЗА в system — W1-канон, FR-2 финревью 2026-06-11) + проверенный контекст + diff --git a/tools/mentor-seam.test.mjs b/tools/mentor-seam.test.mjs index f4fab69e..93d46143 100644 --- a/tools/mentor-seam.test.mjs +++ b/tools/mentor-seam.test.mjs @@ -1,6 +1,19 @@ // tools/mentor-seam.test.mjs import { describe, it, expect } from 'vitest'; -import { buildMentorPrompt } from './mentor-seam.mjs'; +import { buildMentorPrompt, renderSkillContext } from './mentor-seam.mjs'; + +describe('renderSkillContext (мерж роутер↔наставник)', () => { + it('содержит объявленные скилы и рекомендацию роутера', () => { + const s = renderSkillContext({ declared: ['executing-plans'], recommendedChain: ['systematic-debugging', 'test-driven-development'] }); + expect(s).toMatch(/executing-plans/); + expect(s).toMatch(/systematic-debugging/); + expect(s).toMatch(/скил/i); + }); + it('classify недоступен (null) → маркер «рекомендация недоступна», не пусто', () => { + const s = renderSkillContext({ declared: ['x'], recommendedChain: null }); + expect(s).toMatch(/недоступн/i); + }); +}); describe('buildMentorPrompt (§6.2)', () => { const graphSection = { kind: 'project-graph', districtCount: 2, layer0: [{ district: 'tools', nodeCount: 3 }, { district: 'app-backend', nodeCount: 7 }], staleness: { stale: true, commits_behind: 12, uncommitted: 1 } }; diff --git a/tools/mentor-verdict.mjs b/tools/mentor-verdict.mjs index 3b815aaa..d992c056 100644 --- a/tools/mentor-verdict.mjs +++ b/tools/mentor-verdict.mjs @@ -6,8 +6,9 @@ * вызов, не stub/degraded). Зеркало: судья {decision:'GO', wired:false} не суд. */ -/** СОБСТВЕННЫЕ слоты наставника (адресуют план по-пунктам). */ -export const MENTOR_VERDICT_SLOTS = Object.freeze(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence']); +/** СОБСТВЕННЫЕ слоты наставника (адресуют план по-пунктам). decision (Р7/мерж) — явная + * кнопка GO/NO-GO: содержательное «переделай» обязано заворачивать, не тонуть как GO. */ +export const MENTOR_VERDICT_SLOTS = Object.freeze(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence', 'decision']); /** Валидация вердикта по СВОИМ слотам. plan_points_addressed — непустой массив; * reasoning/recommendation — непустые строки; confidence — конечное число ∈[0,1] @@ -23,6 +24,8 @@ export function validateMentorVerdict(verdict) { if (typeof verdict[slot] !== 'string' || !verdict[slot].trim()) missingSlots.push(slot); } if (typeof verdict.confidence !== 'number' || !Number.isFinite(verdict.confidence) || verdict.confidence < 0 || verdict.confidence > 1) missingSlots.push('confidence'); + // decision (Р7/мерж): явная кнопка GO/NO-GO — только {GO, NO-GO}; иначе вердикт несодержателен. + if (verdict.decision !== 'GO' && verdict.decision !== 'NO-GO') missingSlots.push('decision'); return { ok: missingSlots.length === 0, missingSlots }; } @@ -44,19 +47,21 @@ import { DR1_LINE, renderVerifiedContext, renderNegotiation } from './mentor-sea * контекст + журнал переговоров (единые рендеры seam, VA-1; пустой контекст НЕ молчит, * VA-2) + граф-секция (опора наставника). A8 (нах.F6): system несёт ДР-1 гранулярность. */ -export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null } = {}) { +export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, skillContext = null } = {}) { const system = [ 'Ты — НАСТАВНИК. Разбери ПЛАН ПО ПУНКТАМ (не выбирай скил — это другой вызов).', DR1_LINE, // Smoke 2026-06-12: «статус+замечание» без типа элемента провоцировал массив объектов — // валидатор (F-C3/М1-М4: слот = строки) браковал содержательный вердикт. Тип — явно. - 'ВЫВОД — строго JSON-вердикт со слотами: plan_points_addressed (массив СТРОК — по КАЖДОМУ пункту плана ровно одна строка вида «пункт N: статус — замечание»; элемент-объект недопустим, не объект а строка), reasoning (строка-разбор), recommendation (строка — что править), confidence (число 0..1). Пустой слот недопустим.', + 'Вынеси РЕШЕНИЕ: decision="GO" если можно реализовывать, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).', + 'ВЫВОД — строго JSON-вердикт со слотами: plan_points_addressed (массив СТРОК — по КАЖДОМУ пункту плана ровно одна строка вида «пункт N: статус — замечание»; элемент-объект недопустим, не объект а строка), reasoning (строка-разбор), recommendation (строка — что править), confidence (число 0..1), decision ("GO"|"NO-GO"). Пустой слот недопустим.', ].join('\n'); const user = [ `ПЛАН: ${plan ? JSON.stringify(plan) : '(нет)'}`, renderVerifiedContext(verifiedContext), renderNegotiation(negotiationLog), graphSection ? `--- ГРАФ (карта районов) ---\n${JSON.stringify(graphSection.layer0 || [])}` : '', + skillContext ? skillContext : '', ].filter(Boolean).join('\n'); return { system, user }; } @@ -68,10 +73,10 @@ export function buildMentorVerdictPrompt({ plan = null, verifiedContext = [], ne * verdict.plan_hash === planId(steps) — stale/чужой вердикт не пройдёт). Сбой → wired:false * (SE-R6-6: не суд). */ -export async function runMentorVerdict({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, planHash = null, llmCall }) { +export async function runMentorVerdict({ plan = null, verifiedContext = [], negotiationLog = [], graphSection = null, planHash = null, skillContext = null, llmCall }) { let v; try { - v = await llmCall({ buildPrompt: () => buildMentorVerdictPrompt({ plan, verifiedContext, negotiationLog, graphSection }) }); + v = await llmCall({ buildPrompt: () => buildMentorVerdictPrompt({ plan, verifiedContext, negotiationLog, graphSection, skillContext }) }); } catch (e) { // Smoke 2026-06-12: тихий catch не давал отличить 401 (ключ) от сети — деталь // обязана доехать до вердикт-файла/журнала (усечённая; ключ в message не попадает). @@ -84,3 +89,44 @@ export async function runMentorVerdict({ plan = null, verifiedContext = [], nego if (!chk.ok) return { ok: false, wired: true, reason: `несодержательный вердикт: пустые слоты [${chk.missingSlots.join(', ')}]`, verdict: v }; return { ok: true, wired: true, verdict: { ...v, plan_hash: planHash } }; } + +/** + * Фаза 3 (отдельный spec-путь, Р6): промпт-производитель вердикта наставника по СПЕКЕ. + * Зеркало buildMentorVerdictPrompt, но просит разбор СПЕКИ по разделам (не плана по пунктам). + * Наставник ВИДИТ контекст (verified-context + переговоры — Р6), судья — нет. Те же слоты. + */ +export function buildMentorSpecVerdictPrompt({ specContent = '', verifiedContext = [], negotiationLog = [], graphSection = null } = {}) { + const system = [ + 'Ты — НАСТАВНИК. Разбери СПЕКУ ПО РАЗДЕЛАМ (это спецификация решения, не план; скил не выбираешь).', + DR1_LINE, + 'Вынеси РЕШЕНИЕ: decision="GO" если спеку можно принять, decision="NO-GO" если нужна переделка (тогда в recommendation — ЧТО править).', + 'ВЫВОД — строго JSON-вердикт со слотами: plan_points_addressed (массив СТРОК — по КАЖДОМУ разделу/решению спеки ровно одна строка вида «раздел X: статус — замечание»; элемент-объект недопустим, не объект а строка), reasoning (строка-разбор), recommendation (строка — что править), confidence (число 0..1), decision ("GO"|"NO-GO"). Пустой слот недопустим.', + ].join('\n'); + const user = [ + `СПЕКА:\n${specContent || '(нет)'}`, + renderVerifiedContext(verifiedContext), + renderNegotiation(negotiationLog), + graphSection ? `--- ГРАФ (карта районов) ---\n${JSON.stringify(graphSection.layer0 || [])}` : '', + ].filter(Boolean).join('\n'); + return { system, user }; +} + +/** + * Фаза 3 (Р6): производитель вердикта наставника по СПЕКЕ. Зеркало runMentorVerdict, но + * spec-промпт; binding plan_hash = specHash (хеш артефакта спеки — judgedHashOf(sealableArtifact), + * тот же, чем судья печатает gate1). Сбой → wired:false (SE-R6-6, не суд). + */ +export async function runMentorSpecVerdict({ specContent = '', specHash = null, verifiedContext = [], negotiationLog = [], graphSection = null, llmCall }) { + let v; + try { + v = await llmCall({ buildPrompt: () => buildMentorSpecVerdictPrompt({ specContent, verifiedContext, negotiationLog, graphSection }) }); + } catch (e) { + const detail = String((e && e.message) || e).slice(0, 200); + return { ok: false, wired: false, reason: `сбой вызова наставника-вердикта (спека): ${detail}`, verdict: null }; + } + if (typeof v === 'string') v = parseRouterResponse(v); + if (!v) return { ok: false, wired: false, reason: 'пустой/неразборный вердикт', verdict: null }; + const chk = validateMentorVerdict(v); + if (!chk.ok) return { ok: false, wired: true, reason: `несодержательный вердикт: пустые слоты [${chk.missingSlots.join(', ')}]`, verdict: v }; + return { ok: true, wired: true, verdict: { ...v, plan_hash: specHash } }; +} diff --git a/tools/mentor-verdict.test.mjs b/tools/mentor-verdict.test.mjs index e2fba0b1..72e70abc 100644 --- a/tools/mentor-verdict.test.mjs +++ b/tools/mentor-verdict.test.mjs @@ -1,14 +1,43 @@ // tools/mentor-verdict.test.mjs import { describe, it, expect } from 'vitest'; -import { validateMentorVerdict, isMentorVerdictSubstantive, MENTOR_VERDICT_SLOTS } from './mentor-verdict.mjs'; +import { validateMentorVerdict, isMentorVerdictSubstantive, MENTOR_VERDICT_SLOTS, runMentorSpecVerdict, buildMentorSpecVerdictPrompt } from './mentor-verdict.mjs'; + +describe('runMentorSpecVerdict (Фаза 3 — наставник судит СПЕКУ, видит контекст Р6)', () => { + const GOOD = { plan_points_addressed: ['раздел §2 ок'], reasoning: 'разбор', recommendation: 'править §3', confidence: 0.9, decision: 'GO' }; + it('валидный вердикт + wired:true + binding к хешу артефакта спеки', async () => { + const r = await runMentorSpecVerdict({ specContent: '# Спека\n## §2\nтекст', specHash: 'SH1', verifiedContext: [], negotiationLog: [], llmCall: async () => GOOD }); + expect(r.ok).toBe(true); + expect(r.wired).toBe(true); + expect(r.verdict.plan_hash).toBe('SH1'); + }); + it('Р6: промпт несёт КОНТЕКСТ наставнику (verified-context), но просит разбор СПЕКИ', async () => { + let captured = null; + await runMentorSpecVerdict({ + specContent: '# Спека', specHash: 'SH', + verifiedContext: [{ id: '1', kind: 'EXTRACTED', claim: 'утв', ref: 'x:1', anchor: 'якорь' }], + negotiationLog: [], llmCall: async ({ buildPrompt }) => { captured = buildPrompt(); return GOOD; }, + }); + expect(captured.system).toMatch(/спек/i); + expect(JSON.stringify(captured)).toMatch(/EXTRACTED|контекст/i); + }); + it('сбой транспорта → wired:false (не суд, SE-R6-6)', async () => { + const r = await runMentorSpecVerdict({ specContent: 's', specHash: 'SH', llmCall: async () => { throw new Error('сеть'); } }); + expect(r.wired).toBe(false); + }); + it('несодержательный вердикт → ok:false, wired:true', async () => { + const r = await runMentorSpecVerdict({ specContent: 's', specHash: 'SH', llmCall: async () => ({ reasoning: 'r' }) }); + expect(r.ok).toBe(false); + expect(r.wired).toBe(true); + }); +}); describe('mentor-verdict (§6.1 СВОИ слоты)', () => { it('MENTOR_VERDICT_SLOTS заморожен и СВОЙ (не судейский)', () => { expect(Object.isFrozen(MENTOR_VERDICT_SLOTS)).toBe(true); - expect(MENTOR_VERDICT_SLOTS).toEqual(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence']); + expect(MENTOR_VERDICT_SLOTS).toEqual(['plan_points_addressed', 'reasoning', 'recommendation', 'confidence', 'decision']); }); it('валидный вердикт → ok', () => { - const v = { plan_points_addressed: ['шаг 1 ок', 'шаг 2 риск'], reasoning: 'разбор', recommendation: 'править шаг 2', confidence: 0.8 }; + const v = { plan_points_addressed: ['шаг 1 ок', 'шаг 2 риск'], reasoning: 'разбор', recommendation: 'править шаг 2', confidence: 0.8, decision: 'GO' }; expect(validateMentorVerdict(v).ok).toBe(true); }); it('пустой слот → не ok', () => { @@ -30,12 +59,38 @@ describe('mentor-verdict (§6.1 СВОИ слоты)', () => { expect(validateMentorVerdict({ ...base, confidence: NaN }).ok).toBe(false); }); it('substance: wired:true + валиден → содержателен; wired:false → НЕ содержателен (SE-R6-6)', () => { - const v = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec', confidence: 0.7 }; + const v = { plan_points_addressed: ['a'], reasoning: 'r', recommendation: 'rec', confidence: 0.7, decision: 'GO' }; expect(isMentorVerdictSubstantive(v, { wired: true })).toBe(true); expect(isMentorVerdictSubstantive(v, { wired: false })).toBe(false); }); }); +describe('validateMentorVerdict — decision (Р7/мерж)', () => { + const base = { plan_points_addressed: ['п1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9 }; + it('decision="GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'GO' }).ok).toBe(true); }); + it('decision="NO-GO" валиден', () => { expect(validateMentorVerdict({ ...base, decision: 'NO-GO' }).ok).toBe(true); }); + it('decision отсутствует → невалиден', () => { + const r = validateMentorVerdict(base); + expect(r.ok).toBe(false); expect(r.missingSlots).toContain('decision'); + }); + it('decision мусор → невалиден', () => { + expect(validateMentorVerdict({ ...base, decision: 'maybe' }).ok).toBe(false); + }); +}); + +describe('промпты просят decision (Р7/мерж)', () => { + it('промпт плана требует decision GO/NO-GO', () => { + const p = buildMentorVerdictPrompt({ plan: { steps: [] } }); + expect(p.system).toMatch(/decision/i); + expect(p.system).toMatch(/NO-GO/); + }); + it('промпт спеки требует decision GO/NO-GO', () => { + const p = buildMentorSpecVerdictPrompt({ specContent: '# с' }); + expect(p.system).toMatch(/decision/i); + expect(p.system).toMatch(/NO-GO/); + }); +}); + // Task 3b — производитель вердикта (C-1, нах.F4/F5): импорт внизу перед describe (ESM hoisting) import { buildMentorVerdictPrompt, runMentorVerdict } from './mentor-verdict.mjs'; @@ -85,7 +140,7 @@ describe('buildMentorVerdictPrompt (§6.1 verdict-слоты, НЕ router)', () }); describe('runMentorVerdict (§6.1 производитель + binding нах.F4)', () => { - const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.8 }; + const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.8, decision: 'GO' }; it('валидный вердикт → ok + wired + verdict.plan_hash проставлен (binding)', async () => { const r = await runMentorVerdict({ plan: {}, planHash: 'PH1', llmCall: async () => goodVerdict }); expect(r.ok).toBe(true); diff --git a/tools/mentor-wiring.test.mjs b/tools/mentor-wiring.test.mjs index 9fa8ca41..fb865296 100644 --- a/tools/mentor-wiring.test.mjs +++ b/tools/mentor-wiring.test.mjs @@ -4,7 +4,7 @@ import { buildMentorArbitrationMessage } from './enforce-mentor-on-plan-write.mj describe('buildMentorArbitrationMessage (wave 7 — mentor escalation → card)', () => { it('дословное замечание наставника + позиция контроллера + 3 выбора + L1', () => { const msg = buildMentorArbitrationMessage( - { ok: false, wired: true, reason: 'шаг 2 склеен', verdict: { objections: [{ anchor: { ref: 'разбей шаг 2' } }] } }, + { ok: false, wired: true, reason: 'шаг 2 склеен', verdict: { decision: 'NO-GO', recommendation: 'разбей шаг 2' } }, '## Переговоры\n### Круг 3\nШаг 2 атомарен — настаиваю.', 3, ); diff --git a/tools/objection-delivery.mjs b/tools/objection-delivery.mjs new file mode 100644 index 00000000..24048393 --- /dev/null +++ b/tools/objection-delivery.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +/** Сборка текста замечания для доставки контроллеру (полный текст, не обрезаем). */ +export function buildObjectionFeedback({ side, text } = {}) { + const who = side === 'judge' ? 'судья' : 'наставник'; + const body = (typeof text === 'string' && text.trim()) ? text.trim() : '(текст замечания пуст)'; + return `[${who}] замечание (нужно учесть и переписать):\n${body}`; +} + +/** + * Спека §9: degraded ИИ (наставник/судья упал/таймаут/нет ключа) → контроллер НЕ молчит, + * а получает «не смог дозвониться» + причину. Печати нет — это не одобрение и не возражение. + */ +export function buildDegradedFeedback({ side, reason } = {}) { + const who = side === 'judge' ? 'судья' : 'наставник'; + const why = (typeof reason === 'string' && reason.trim()) ? reason.trim() : '(причина не указана)'; + return `[${who}] не смог дозвониться (ИИ недоступен): ${why}.\n` + + 'Печати нет — это НЕ одобрение и НЕ возражение. Повтори попытку позже или сообщи владельцу.'; +} diff --git a/tools/objection-delivery.test.mjs b/tools/objection-delivery.test.mjs new file mode 100644 index 00000000..acba27ec --- /dev/null +++ b/tools/objection-delivery.test.mjs @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { buildObjectionFeedback, buildDegradedFeedback } from './objection-delivery.mjs'; + +describe('buildObjectionFeedback', () => { + it('собирает полный текст замечания с пометкой стороны (наставник)', () => { + const out = buildObjectionFeedback({ side: 'mentor', text: 'пункт 2 плохой' }); + expect(out).toContain('наставник'); + expect(out).toContain('пункт 2 плохой'); + }); + it('пометка судьи для side=judge', () => { + const out = buildObjectionFeedback({ side: 'judge', text: 'шаг 4 трогает порог' }); + expect(out).toContain('судья'); + expect(out).toContain('шаг 4 трогает порог'); + }); + it('пустой текст → безопасная заглушка, не пусто', () => { + expect(buildObjectionFeedback({ side: 'judge', text: '' })).toMatch(/судья/); + }); + it('не обрезает длинный текст замечания', () => { + const long = 'A'.repeat(5000); + expect(buildObjectionFeedback({ side: 'mentor', text: long })).toContain(long); + }); +}); + +describe('buildDegradedFeedback (спека §9 — degraded ИИ информирует контроллера, не тишина)', () => { + it('наставник degraded → «не смог дозвониться» + причина, помечен наставник', () => { + const out = buildDegradedFeedback({ side: 'mentor', reason: 'timeout' }); + expect(out).toContain('наставник'); + expect(out).toMatch(/не смог дозвониться|недоступен/i); + expect(out).toContain('timeout'); + }); + it('судья degraded → помечен судья, печати нет', () => { + const out = buildDegradedFeedback({ side: 'judge', reason: 'no_key' }); + expect(out).toContain('судья'); + expect(out).toMatch(/печат/i); + }); + it('пустая причина → безопасная заглушка, не пусто', () => { + expect(buildDegradedFeedback({ side: 'mentor' })).toMatch(/наставник/); + }); +}); diff --git a/tools/objection-format.mjs b/tools/objection-format.mjs index d50fb37b..5af86d16 100644 --- a/tools/objection-format.mjs +++ b/tools/objection-format.mjs @@ -5,20 +5,35 @@ * Fail-safe: мусор/нет возражений → пустая строка. */ -/** Дословная сводка возражений судьи (форма parseJudgeResponse: objections[{anchor:{ref},severity}]). */ +/** + * Дословная сводка возражений судьи. Две формы: + * - результат runJudge — блокирующие в `blocking[{anchor:{ref},severity}]` (приоритет); + * - сырой parseJudgeResponse — `objections[{anchor:{ref},severity}]` (fallback). + */ export function formatJudgeObjection(verdict) { - const objs = verdict && Array.isArray(verdict.objections) ? verdict.objections : []; + const blocking = verdict && Array.isArray(verdict.blocking) ? verdict.blocking : null; + const objs = (blocking && blocking.length) ? blocking + : (verdict && Array.isArray(verdict.objections) ? verdict.objections : []); const lines = objs .filter((o) => o && o.anchor && o.anchor.ref) .map((o) => `— [${o.severity || 'light'}] ${o.anchor.ref}`); return lines.join('\n'); } -/** Дословная сводка замечания наставника (результат onPlanWrite {ok,wired,reason,verdict}). */ +/** Дословная сводка замечания наставника (результат onPlanWrite {ok,wired,reason,verdict}). + * Р7/мерж: NO-GO = decision==='NO-GO' ИЛИ сломанный вердикт (ok!==true). Доносит СУТЬ — + * recommendation (что править) + reasoning (разбор) + plan_points_addressed (по пунктам, + * включая скил-конкретику) + reason сбоя. GO → пустая строка (не заворот). */ export function formatMentorObjection(r) { - if (!r || r.wired !== true || r.ok === true) return ''; - const head = typeof r.reason === 'string' && r.reason ? `Замечание наставника: ${r.reason}` : 'Замечание наставника.'; - const objs = r.verdict && Array.isArray(r.verdict.objections) ? r.verdict.objections : []; - const lines = objs.filter((o) => o && o.anchor && o.anchor.ref).map((o) => `— ${o.anchor.ref}`); - return [head, ...lines].join('\n'); + if (!r || r.wired !== true) return ''; + const v = r.verdict || {}; + const isNoGo = v.decision === 'NO-GO' || r.ok !== true; + if (!isNoGo) return ''; + const lines = ['Замечание наставника:']; + if (typeof v.recommendation === 'string' && v.recommendation.trim()) lines.push(`Что править: ${v.recommendation.trim()}`); + if (typeof v.reasoning === 'string' && v.reasoning.trim()) lines.push(`Разбор: ${v.reasoning.trim()}`); + const pts = Array.isArray(v.plan_points_addressed) ? v.plan_points_addressed.filter((p) => typeof p === 'string' && p.trim()) : []; + if (pts.length) lines.push('По пунктам:', ...pts.map((p) => `— ${p}`)); + if (typeof r.reason === 'string' && r.reason.trim()) lines.push(`(${r.reason.trim()})`); + return lines.length > 1 ? lines.join('\n') : ''; } diff --git a/tools/objection-format.test.mjs b/tools/objection-format.test.mjs index 0305c96f..7f4398e7 100644 --- a/tools/objection-format.test.mjs +++ b/tools/objection-format.test.mjs @@ -26,11 +26,17 @@ describe('formatJudgeObjection', () => { it('возражение без anchor.ref пропускается', () => { expect(formatJudgeObjection({ objections: [{ severity: 'heavy' }] })).toBe(''); }); + it('читает блокирующие возражения судьи из поля blocking (форма runJudge, Фаза 1)', () => { + const v = { decision: 'NO-GO', blocking: [{ verdict: 'NO', anchor: { kind: 'spec_section', ref: '§4 порог' }, severity: 'heavy' }], advice: [], slots: {} }; + const s = formatJudgeObjection(v); + expect(s).toContain('§4 порог'); + expect(s).toContain('heavy'); + }); }); describe('formatMentorObjection', () => { - it('собирает дословный reason + замечания вердикта', () => { - const s = formatMentorObjection({ ok: false, wired: true, reason: 'шаг 2 склеен', verdict: { objections: [{ anchor: { ref: 'разбей шаг 2' } }] } }); + it('собирает дословный reason + суть вердикта (recommendation)', () => { + const s = formatMentorObjection({ ok: false, wired: true, reason: 'шаг 2 склеен', verdict: { decision: 'NO-GO', recommendation: 'разбей шаг 2' } }); expect(s).toContain('шаг 2 склеен'); expect(s).toContain('разбей шаг 2'); }); @@ -41,4 +47,15 @@ describe('formatMentorObjection', () => { expect(formatMentorObjection({ ok: false, wired: false })).toBe(''); }); it('мусор не роняет', () => { expect(formatMentorObjection(null)).toBe(''); }); + it('содержательный NO-GO → суть: recommendation + reasoning + пункты', () => { + const r = { ok: true, wired: true, verdict: { decision: 'NO-GO', recommendation: 'добавь systematic-debugging', + reasoning: 'это отладка бага', plan_points_addressed: ['шаг 2: скил не тот'] } }; + const s = formatMentorObjection(r); + expect(s).toMatch(/systematic-debugging/); + expect(s).toMatch(/отладка бага/); + expect(s).toMatch(/шаг 2/); + }); + it('GO → пустая строка (не заворот)', () => { + expect(formatMentorObjection({ ok: true, wired: true, verdict: { decision: 'GO' } })).toBe(''); + }); }); diff --git a/tools/on-plan-write.mjs b/tools/on-plan-write.mjs index 32830ad8..2d64f665 100644 --- a/tools/on-plan-write.mjs +++ b/tools/on-plan-write.mjs @@ -10,8 +10,9 @@ */ import { planId } from './plan-lock.mjs'; import { deriveTaskId } from './router-task-id.mjs'; -import { runMentorVerdict } from './mentor-verdict.mjs'; +import { runMentorVerdict, runMentorSpecVerdict } from './mentor-verdict.mjs'; import { appendNegotiation, roundCount } from './mentor-journal.mjs'; +import { renderSkillContext } from './mentor-seam.mjs'; /** * @returns {{taskId, taskIdPersisted, ok, wired, verdict, reason?, journal, journalOk}} @@ -30,6 +31,10 @@ export async function onPlanWrite({ verifiedContext = [], negotiationLog = [], graphSection = null, + classifyImpl = null, + registry = null, + declaredSkills = [], + planGoal = '', } = {}) { const planHash = planId(planSteps); // ✅O17: существующий task-id побеждает (re-issue не сбрасывает); первый план — якорь. @@ -38,9 +43,19 @@ export async function onPlanWrite({ if (taskId && typeof persistTaskIdImpl === 'function') { try { persistTaskIdImpl(taskId); taskIdPersisted = true; } catch { taskIdPersisted = false; } } + // Мерж роутер↔наставник: зовём classify() как функцию (мозг роутера цел). Сбой/недоступен → + // recommendedChain=null → наставник судит план БЕЗ скил-сверки (fail-safe §5, не ложный NO-GO). + let recommendedChain = null; + if (typeof classifyImpl === 'function') { + try { + const c = await classifyImpl(planGoal, registry); + recommendedChain = (c && c.recommended_chain) || (c && c.recommended_node ? [c.recommended_node] : []); + } catch { recommendedChain = null; } + } + const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain }); // Производитель вердикта (C T5b): сбой → ok:false/wired:false (SE-R6-6, не суд). const r = await runMentorVerdictImpl({ - plan: { steps: planSteps }, planHash, verifiedContext, negotiationLog, graphSection, llmCall, + plan: { steps: planSteps }, planHash, verifiedContext, negotiationLog, graphSection, skillContext, llmCall, }); // SE10 (A4): журнал — best-effort; throw ловится, круг не падает. Обоснование непустое // всегда (F-C2/ДР-6): из вердикта либо из reason сбоя. @@ -59,3 +74,47 @@ export async function onPlanWrite({ } catch { journal = null; journalOk = false; } return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk }; } + +/** + * Фаза 3 (отдельный spec-путь, Р6) — оркестратор записи СПЕКИ. Зеркало onPlanWrite, но + * вердикт по спеке (runMentorSpecVerdict, binding specHash — хеш артефакта спеки). task-id + * анкорится specHash (спека первая в стэке спека+план → план переиспользует тот же task-id). + * Журнал переговоров — best-effort (SE10). I/O/llmCall инъектируются. + */ +export async function onSpecWrite({ + specContent = '', + specHash = null, + existingTaskId = null, + persistTaskIdImpl = null, + llmCall, + runMentorSpecVerdictImpl = runMentorSpecVerdict, + appendNegotiationImpl = appendNegotiation, + journalEntries = [], + journalKey = null, + nowMs = null, + verifiedContext = [], + negotiationLog = [], + graphSection = null, +} = {}) { + // ✅O17: существующий task-id побеждает; спека анкорит стэк (спека+план) по specHash. + const taskId = deriveTaskId({ existingTaskId, firstPlanHash: specHash }); + let taskIdPersisted = false; + if (taskId && typeof persistTaskIdImpl === 'function') { + try { persistTaskIdImpl(taskId); taskIdPersisted = true; } catch { taskIdPersisted = false; } + } + const r = await runMentorSpecVerdictImpl({ specContent, specHash, verifiedContext, negotiationLog, graphSection, llmCall }); + let journal = null; + let journalOk = false; + try { + const round = roundCount(journalEntries, taskId) + 1; + journal = appendNegotiationImpl(journalEntries, { + taskId, + round, + side: 'mentor', + utterance: (r.verdict && r.verdict.recommendation) || 'вердикт не произведён', + justification: (r.verdict && r.verdict.reasoning) || r.reason || 'сбой производителя вердикта', + }, { key: journalKey, nowMs }); + journalOk = true; + } catch { journal = null; journalOk = false; } + return { taskId, taskIdPersisted, ok: r.ok, wired: r.wired, verdict: r.verdict ?? null, reason: r.reason, journal, journalOk }; +} diff --git a/tools/on-plan-write.test.mjs b/tools/on-plan-write.test.mjs index f40a8be9..b828c838 100644 --- a/tools/on-plan-write.test.mjs +++ b/tools/on-plan-write.test.mjs @@ -4,7 +4,7 @@ import { onPlanWrite } from './on-plan-write.mjs'; import { planId } from './plan-lock.mjs'; const STEPS = [{ n: 1, op: 'Write', object: 'tools/a.mjs' }]; -const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'разбор', recommendation: 'ок', confidence: 0.8 }; +const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'разбор', recommendation: 'ок', confidence: 0.8, decision: 'GO' }; const llmOk = async () => goodVerdict; describe('onPlanWrite (W3, A0 — нах.F5/C-1)', () => { @@ -53,3 +53,35 @@ describe('onPlanWrite (W3, A0 — нах.F5/C-1)', () => { expect(r.verdict).toBe(null); }); }); + +describe('onPlanWrite — скил-сверка через classify (мерж)', () => { + const GO = { plan_points_addressed: ['ок'], reasoning: 'r', recommendation: 'rec', confidence: 0.9, decision: 'GO' }; + it('зовёт classifyImpl и кладёт рекомендацию в промпт вердикта', async () => { + let classified = null; let capturedUser = null; + const r = await onPlanWrite({ + planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }], + declaredSkills: ['executing-plans'], + classifyImpl: async (goal) => { classified = goal; return { recommended_chain: ['systematic-debugging'] }; }, + registry: {}, + llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; }, + planGoal: 'починить парсер', + }); + expect(classified).toBe('починить парсер'); + expect(capturedUser).toMatch(/systematic-debugging/); + expect(capturedUser).toMatch(/executing-plans/); + expect(r.ok).toBe(true); + }); + it('classifyImpl бросил → вердикт без скил-сверки (маркер недоступности), не падает', async () => { + let capturedUser = null; + const r = await onPlanWrite({ + planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }], + declaredSkills: ['x'], + classifyImpl: async () => { throw new Error('классификатор недоступен'); }, + registry: {}, + llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; }, + planGoal: 'g', + }); + expect(r.ok).toBe(true); + expect(capturedUser).toMatch(/недоступн/i); + }); +}); diff --git a/tools/plan-lock.mjs b/tools/plan-lock.mjs index f8cc4fd0..5a64ebf8 100644 --- a/tools/plan-lock.mjs +++ b/tools/plan-lock.mjs @@ -175,6 +175,17 @@ export function loadFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) { catch (e) { if (e && e.code === 'ENOENT') return null; throw e; } } +/** + * Фаза 5 (чистое завершение, спека §6.5): снять печать плана (unlink). После последнего + * шага стена зовёт это сама → следующее действие в разговорном режиме. Нет файла → no-op + * (best-effort на ENOENT). path-guard через planPath (assertSafeSessionId). + */ +export function removeFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) { + const p = planPath(runtimeDir, sessionId); + try { fsImpl.unlinkSync(p); } + catch (e) { if (e && e.code === 'ENOENT') return; throw e; } +} + /** Каждое журнальное действие обязано иметь шаг плана; иначе — сирота (пропущен гейт). */ export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) { // V2 (R-08): матчим по листьям дерева — иначе лист-действие не совпадёт с контейнером верхнего diff --git a/tools/plan-lock.test.mjs b/tools/plan-lock.test.mjs index aa8ff69f..6f9742ac 100644 --- a/tools/plan-lock.test.mjs +++ b/tools/plan-lock.test.mjs @@ -2,7 +2,34 @@ import { describe, it, expect } from 'vitest'; import { freezePlan, verifyFrozenPlan, planId } from './plan-lock.mjs'; import { actionMatchesStep, nextStep } from './plan-lock.mjs'; -import { saveFrozenPlan, loadFrozenPlan } from './plan-lock.mjs'; +import { saveFrozenPlan, loadFrozenPlan, removeFrozenPlan } from './plan-lock.mjs'; + +describe('removeFrozenPlan (Фаза 5 — чистое завершение: стена снимает печать)', () => { + const fsWithUnlink = () => { + const s = new Map(); + return { + s, + readFileSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return s.get(String(p)); }, + writeFileSync: (p, d) => s.set(String(p), String(d)), + renameSync: (a, b) => { s.set(String(b), s.get(String(a))); s.delete(String(a)); }, + unlinkSync: (p) => { if (!s.has(String(p))) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } s.delete(String(p)); }, + }; + }; + it('удаляет файл печати → loadFrozenPlan → null', () => { + const fs = fsWithUnlink(); + saveFrozenPlan({ plan: { plan_id: 'x' }, sessionId: 'S', runtimeDir: '/rt', fsImpl: fs }); + expect(loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs })).not.toBe(null); + removeFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs }); + expect(loadFrozenPlan({ sessionId: 'S', runtimeDir: '/rt', fsImpl: fs })).toBe(null); + }); + it('нет файла → no-op (не бросает на ENOENT)', () => { + const fs = fsWithUnlink(); + expect(() => removeFrozenPlan({ sessionId: 'none', runtimeDir: '/rt', fsImpl: fs })).not.toThrow(); + }); + it('traversal-sessionId бросает (path-guard)', () => { + expect(() => removeFrozenPlan({ sessionId: '../evil', runtimeDir: '/rt', fsImpl: fsWithUnlink() })).toThrow(); + }); +}); import { reconcileJournalToPlan } from './plan-lock.mjs'; import { freezeArtifact, verifyFrozenArtifact, artifactId } from './plan-lock.mjs'; import { saveFrozenArtifact, loadFrozenArtifact } from './plan-lock.mjs'; diff --git a/tools/plan-skills.mjs b/tools/plan-skills.mjs new file mode 100644 index 00000000..d90955e9 --- /dev/null +++ b/tools/plan-skills.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** Парсер объявленных в плане скилов (мерж роутер↔наставник). Зеркало parseVerifiedContext: + * ищет fenced-блок ```skills-json со списком строк. Нет/битый → []. */ +export function parsePlanSkills(content) { + const m = String(content ?? '').match(/```skills-json\s*\n([\s\S]*?)\n```/i); + if (!m) return []; + let arr; + try { arr = JSON.parse(m[1]); } catch { return []; } + if (!Array.isArray(arr)) return []; + return arr.filter((s) => typeof s === 'string' && s.trim()); +} + +/** Цель плана для classify(): секция ## Цель / ## Goal (до след. заголовка) или первый + * непустой не-заголовок абзац. Зеркало extractGoal судьи (enforce-judge-gate.mjs:174). */ +export function extractPlanGoal(content) { + const text = String(content ?? ''); + const m = text.match(/^##\s*(?:Цель|Goal)[^\n]*\n([\s\S]*?)(?:\n##\s|$)/im); + if (m && m[1].trim()) return m[1].trim(); + const para = text.split(/\n\s*\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('#')); + return para || ''; +} diff --git a/tools/plan-skills.test.mjs b/tools/plan-skills.test.mjs new file mode 100644 index 00000000..dc432290 --- /dev/null +++ b/tools/plan-skills.test.mjs @@ -0,0 +1,31 @@ +// tools/plan-skills.test.mjs +import { describe, it, expect } from 'vitest'; +import { parsePlanSkills, extractPlanGoal } from './plan-skills.mjs'; + +describe('parsePlanSkills', () => { + const md = ['# План', '```skills-json', '["executing-plans","test-driven-development"]', '```', '## Цель', 'x'].join('\n'); + it('достаёт список скилов из ```skills-json блока', () => { + expect(parsePlanSkills(md)).toEqual(['executing-plans', 'test-driven-development']); + }); + it('нет блока → пустой массив', () => { expect(parsePlanSkills('# План\nтекст')).toEqual([]); }); + it('битый JSON → пустой массив (fail-safe)', () => { + expect(parsePlanSkills('```skills-json\n[не json\n```')).toEqual([]); + }); + it('не-массив / не-строки отфильтрованы', () => { + expect(parsePlanSkills('```skills-json\n["ok", 5, null, "two"]\n```')).toEqual(['ok', 'two']); + }); +}); + +describe('extractPlanGoal (зеркало extractGoal судьи)', () => { + it('секция ## Цель → её текст до след. заголовка', () => { + const md = ['# План', '## Цель', 'починить парсер X', '', '## Шаги', 'шаг 1'].join('\n'); + expect(extractPlanGoal(md)).toBe('починить парсер X'); + }); + it('секция ## Goal → её текст', () => { + expect(extractPlanGoal('## Goal\nfix the bug\n## Steps')).toBe('fix the bug'); + }); + it('нет секции цели → первый непустой не-заголовок абзац', () => { + expect(extractPlanGoal('# План\n\nделаем фичу\n')).toBe('делаем фичу'); + }); + it('пусто → пустая строка', () => { expect(extractPlanGoal('')).toBe(''); }); +}); diff --git a/tools/receipt-sign.mjs b/tools/receipt-sign.mjs index 43a4ae65..f5b82df3 100644 Binary files a/tools/receipt-sign.mjs and b/tools/receipt-sign.mjs differ diff --git a/tools/router-mentor-integration.test.mjs b/tools/router-mentor-integration.test.mjs index adc58380..4cbf84bb 100644 --- a/tools/router-mentor-integration.test.mjs +++ b/tools/router-mentor-integration.test.mjs @@ -28,7 +28,7 @@ const cleanArtifact = [{ id: '1', kind: 'EXTRACTED', claim: 'есть runRouter' const dirtyArtifact = [{ id: '1', kind: 'EXTRACTED', claim: 'выдумка', ref: 'tools/x.mjs:1', anchor: 'НЕСУЩЕСТВУЮЩИЙ_ЯКОРЬ' }]; const STEPS = [{ n: 1, op: 'Edit', object: 'tools/x.mjs' }]; -const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'разбор', recommendation: 'ок', confidence: 0.8 }; +const goodVerdict = { plan_points_addressed: ['шаг 1 ок'], reasoning: 'разбор', recommendation: 'ок', confidence: 0.8, decision: 'GO' }; describe('W6 — интеграция B→C/W1: районы видны наставнику', () => { // FR-2 (финревью 2026-06-11): канон W1 — районы в system (база), seam НЕ дублирует