feat(mentor): мерж роутера в наставника — единый рецензент (спека+план+скилы) + decision GO/NO-GO

Болезни B (роутер в пустоту) + A (наставник не заворачивал) — лечение Р7/Р8 (Подход 1):
наставник — единый мозг-рецензент, зовёт classify() как функцию (3 слоя + граф nodes.yaml +
карточки — код не тронут, новый вызыватель), судит спеку+план+выбор скилов, заворачивает NO-GO.

- validateMentorVerdict + промпты (план/спека): явное decision GO|NO-GO (поглощённый Р7)
- plan-skills.mjs: parsePlanSkills (skills-json) + extractPlanGoal (зеркало extractGoal судьи)
- mentor-seam: renderSkillContext; onPlanWrite зовёт classifyImpl (fail-safe: сбой → без скил-сверки)
- decideMentorObjection: заворот на decision=NO-GO ИЛИ сломанный вердикт; mentor-GO только на чистом GO
- formatMentorObjection доносит суть (recommendation + reasoning + plan_points), GO -> пусто
- enforce-mentor main: loadRegistry + classify; счётчик L1 decision-aware (Р7/§3.4)
- скил-сверка — только план (gate2); спека (gate1) — по сути + decision
- включает redesign согласования L1->L2 (Фазы 0-6, способ B: наставник->судья->печать)
- регрессия tools-only 3901 passed + 2 skip (база 3877, +24 теста, 0 регрессий)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-13 11:38:09 +03:00
parent 9d8d3de782
commit b739d5adad
29 changed files with 1012 additions and 88 deletions
+64 -26
View File
@@ -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) };
}
+78 -3
View File
@@ -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)', () => {
+113 -23
View File
@@ -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 { /* производитель никогда не блокирует */ }
+83 -2
View File
@@ -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)', () => {
@@ -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');
});
});
+33 -3
View File
@@ -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 });
+89
View File
@@ -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);
});
});
+1 -1
View File
@@ -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;
+50
View File
@@ -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-<sess>.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; }
}
+39
View File
@@ -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);
});
});
+4 -2
View File
@@ -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; }
+8
View File
@@ -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();
+11
View File
@@ -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) + проверенный контекст +
+14 -1
View File
@@ -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 } };
+52 -6
View File
@@ -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 } };
}
+60 -5
View File
@@ -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);
+1 -1
View File
@@ -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,
);
+18
View File
@@ -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`
+ 'Печати нет — это НЕ одобрение и НЕ возражение. Повтори попытку позже или сообщи владельцу.';
}
+39
View File
@@ -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(/наставник/);
});
});
+23 -8
View File
@@ -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') : '';
}
+19 -2
View File
@@ -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('');
});
});
+61 -2
View File
@@ -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 };
}
+33 -1
View File
@@ -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);
});
});
+11
View File
@@ -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): матчим по листьям дерева — иначе лист-действие не совпадёт с контейнером верхнего
+28 -1
View File
@@ -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';
+21
View File
@@ -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 || '';
}
+31
View File
@@ -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(''); });
});
Binary file not shown.
+1 -1
View File
@@ -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 НЕ дублирует