diff --git a/tools/enforce-mentor-on-plan-write.mjs b/tools/enforce-mentor-on-plan-write.mjs index e4a0076..0605437 100644 --- a/tools/enforce-mentor-on-plan-write.mjs +++ b/tools/enforce-mentor-on-plan-write.mjs @@ -41,6 +41,9 @@ import { pushVerdict } from './verdict-surface-store.mjs'; // SP2c-2: загрузчик памяти кругов M-side (свои замечания + M-доводы + diff + замечание судьи // при возврате) — инъектируется в runMentorOnPlanWrite, протягивается до построителя вердикта. import { buildRoundMemory } from './round-memory-store.mjs'; +// Пининг роутера (спека роутер-реестр §4): пер-сессионный пин совета по (task_id, goalHash) — +// переписка шагов не зовёт LLM заново. Инъектируется в onPlanWrite через runMentorOnPlanWrite. +import { loadRouterPin, saveRouterPin } from './router-pin-store.mjs'; /** * Волна 7 (§6): сообщение арбитража при 3 NO-GO наставника — дословное замечание + @@ -127,6 +130,7 @@ export async function runMentorOnPlanWrite(event, { mentorActiveImpl, llmCall, loadJournalImpl, persistJournalImpl, persistVerdictImpl, loadTaskIdImpl, persistTaskIdImpl, journalKey, graphSectionImpl, nowMs = null, classifyImpl = null, registryImpl = null, roundMemoryImpl = null, + pinLoadImpl = null, pinSaveImpl = null, } = {}) { if (!mentorActiveImpl()) return { ran: false, reason: 'mentor inert ($0)' }; const tool = event && event.tool_name; @@ -220,6 +224,8 @@ export async function runMentorOnPlanWrite(event, { registry, declaredSkills, planGoal, + pinLoadImpl, + pinSaveImpl, roundMemory: roundMemoryP, }); } catch (e) { @@ -257,6 +263,10 @@ async function main() { // SP2c-2: реальный загрузчик памяти кругов M-side из стора (fail-quiet внутри // buildRoundMemory). baseDir = runtime; side='mentor' → M-дорожка + замечание судьи при возврате. roundMemoryImpl: ({ stage, content, taskId }) => buildRoundMemory({ taskId, stage, side: 'mentor', currentContent: content, baseDir: dir }), + // Пининг роутера (спека роутер-реестр §4): реальный пин-стор сессии. Пин-попадание по + // (taskId, goalHash) → совет переиспользуется (classify не зовётся); промах → classify + save. + pinLoadImpl: ({ taskId, planGoal }) => loadRouterPin({ taskId, planGoal, sessionId: sess }), + pinSaveImpl: ({ taskId, planGoal, classification }) => saveRouterPin({ taskId, planGoal, classification, sessionId: sess }), }); if (res && res.ran) { // SP1: громкая видимость вердикта наставника (best-effort, fail-quiet). diff --git a/tools/on-plan-write.mjs b/tools/on-plan-write.mjs index 9953271..72cc403 100644 --- a/tools/on-plan-write.mjs +++ b/tools/on-plan-write.mjs @@ -48,6 +48,8 @@ export async function onPlanWrite({ registry = null, declaredSkills = [], planGoal = '', + pinLoadImpl = null, + pinSaveImpl = null, roundMemory = {}, } = {}) { const planHash = planId(planSteps); @@ -59,14 +61,24 @@ export async function onPlanWrite({ } // Мерж роутер↔наставник: зовём classify() как функцию (мозг роутера цел). Сбой/недоступен → // recommendedChain=null → наставник судит план БЕЗ скил-сверки (fail-safe §5, не ложный NO-GO). + // Пининг (спека роутер-реестр §4): пин-попадание по (taskId, goalHash) → совет переиспользуется, + // classifyImpl НЕ зовётся; промах → classifyImpl + сохранение пина. Без пин-имплов — прежнее поведение. let recommendedChain = null; let routerClassification = null; // видимость: сырой результат classify наружу для снимка/баннера if (typeof classifyImpl === 'function') { - try { - const c = await classifyImpl(planGoal, registry); - routerClassification = c ?? null; - recommendedChain = recommendedChainOf(c); - } catch { recommendedChain = null; routerClassification = { unavailable: true }; } + let pinned = null; + try { pinned = typeof pinLoadImpl === 'function' ? pinLoadImpl({ taskId, planGoal }) : null; } catch { pinned = null; } + if (pinned) { + routerClassification = pinned; + recommendedChain = recommendedChainOf(pinned); + } else { + try { + const c = await classifyImpl(planGoal, registry); + routerClassification = c ?? null; + recommendedChain = recommendedChainOf(c); + if (c && typeof pinSaveImpl === 'function') { try { pinSaveImpl({ taskId, planGoal, classification: c }); } catch { /* best-effort */ } } + } catch { recommendedChain = null; routerClassification = { unavailable: true }; } + } } const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain, registry }); // Производитель вердикта (C T5b): сбой → ok:false/wired:false (SE-R6-6, не суд). diff --git a/tools/on-plan-write.test.mjs b/tools/on-plan-write.test.mjs index 49286b2..0411bb8 100644 --- a/tools/on-plan-write.test.mjs +++ b/tools/on-plan-write.test.mjs @@ -125,6 +125,38 @@ describe('onPlanWrite возвращает результат роутера н }); }); +describe('пининг роутера по goalHash (D2)', () => { + it('пин-попадание → classifyImpl НЕ зовётся, routerClassification = пин', async () => { + let called = false; + const r = await onPlanWrite({ + planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, planGoal: 'g', + classifyImpl: async () => { called = true; return { recommended_chain: ['fresh'] }; }, + pinLoadImpl: () => ({ recommended_chain: ['pinned'] }), + }); + expect(called).toBe(false); + expect(r.routerClassification).toEqual({ recommended_chain: ['pinned'] }); + }); + it('промах пина → classifyImpl зовётся и pinSaveImpl получает результат', async () => { + let saved = null; + const r = await onPlanWrite({ + planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, planGoal: 'g', + classifyImpl: async () => ({ recommended_chain: ['fresh'] }), + pinLoadImpl: () => null, + pinSaveImpl: (rec) => { saved = rec; }, + }); + expect(r.routerClassification).toEqual({ recommended_chain: ['fresh'] }); + expect(saved && saved.classification).toEqual({ recommended_chain: ['fresh'] }); + }); + it('без пин-имплов → classifyImpl зовётся (старое поведение)', async () => { + let called = false; + await onPlanWrite({ + planSteps: STEPS, llmCall: llmOk, journalKey: 'k', nowMs: 1, planGoal: 'g', + classifyImpl: async () => { called = true; return { recommended_chain: ['x'] }; }, + }); + expect(called).toBe(true); + }); +}); + describe('оркестратор протягивает roundMemory до построителя (SP2c-2)', () => { it('onPlanWrite → roundMemory доходит до промпта вердикта', async () => { let capturedUser = null; diff --git a/tools/router-pin-store.mjs b/tools/router-pin-store.mjs new file mode 100644 index 0000000..2d250b0 --- /dev/null +++ b/tools/router-pin-store.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * router-pin-store — пер-сессионный пининг совета роутера по (task_id, goalHash). + * Пока цель (тело «## Цель») не менялась, совет роутера переиспользуется — LLM-классификатор + * не зовётся заново на переписку шагов плана (спека роутер-реестр §4, пининг по goalHash). + * Ключ записи — task_id; запись несёт goalHash + классификацию. Чтение возвращает совет только + * при совпавшем goalHash, иначе null. Любой сбой I/O — мягкий (load→null, save→false). goalHash + * считается из текста цели тем же способом, что якорь цели судьи (canonicalJson + sha256). + */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { canonicalJson } from './receipt-sign.mjs'; + +export function goalHashOf(goal) { + return createHash('sha256').update(canonicalJson(String(goal ?? ''))).digest('hex'); +} + +function baseOf(baseDir) { return baseDir || join(homedir(), '.claude', 'runtime'); } +function pinPath(sessionId, baseDir) { return join(baseOf(baseDir), `router-pin-${sessionId || 'unknown'}.json`); } + +/** Совет роутера по (taskId, текущая цель) — только если сохранённый goalHash совпал; иначе null. */ +export function loadRouterPin({ taskId, planGoal, sessionId, baseDir } = {}) { + try { + const p = pinPath(sessionId, baseDir); + if (!existsSync(p)) return null; + const obj = JSON.parse(readFileSync(p, 'utf8')); + const rec = obj && obj[taskId]; + if (rec && rec.goalHash === goalHashOf(planGoal)) return rec.classification ?? null; + return null; + } catch { return null; } +} + +/** Записать пин для taskId (перезапись своей записи). Best-effort: сбой → false, без throw. */ +export function saveRouterPin({ taskId, planGoal, classification, sessionId, baseDir, nowMs = null } = {}) { + try { + const dir = baseOf(baseDir); + mkdirSync(dir, { recursive: true }); + const p = pinPath(sessionId, baseDir); + let obj = existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {}; + if (!obj || typeof obj !== 'object') obj = {}; + obj[taskId] = { goalHash: goalHashOf(planGoal), classification, ts: nowMs ?? Date.now() }; + writeFileSync(p, JSON.stringify(obj)); + return true; + } catch { return false; } +} diff --git a/tools/router-pin-store.test.mjs b/tools/router-pin-store.test.mjs new file mode 100644 index 0000000..2539b06 --- /dev/null +++ b/tools/router-pin-store.test.mjs @@ -0,0 +1,44 @@ +// tools/router-pin-store.test.mjs +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { goalHashOf, loadRouterPin, saveRouterPin } from './router-pin-store.mjs'; + +const mkBase = () => mkdtempSync(join(tmpdir(), 'router-pin-')); + +describe('goalHashOf (D1)', () => { + it('детерминирован для одинакового текста', () => { + expect(goalHashOf('цель X')).toBe(goalHashOf('цель X')); + }); + it('разный текст → разный хеш', () => { + expect(goalHashOf('цель X')).not.toBe(goalHashOf('цель Y')); + }); +}); + +describe('saveRouterPin / loadRouterPin (D1)', () => { + it('save→load с той же целью возвращает совет', () => { + const baseDir = mkBase(); + saveRouterPin({ taskId: 't1', planGoal: 'цель A', classification: { recommended_chain: ['x'] }, sessionId: 's', baseDir }); + expect(loadRouterPin({ taskId: 't1', planGoal: 'цель A', sessionId: 's', baseDir })).toEqual({ recommended_chain: ['x'] }); + rmSync(baseDir, { recursive: true, force: true }); + }); + it('смена цели (другой goalHash) → load даёт null', () => { + const baseDir = mkBase(); + saveRouterPin({ taskId: 't1', planGoal: 'цель A', classification: { a: 1 }, sessionId: 's', baseDir }); + expect(loadRouterPin({ taskId: 't1', planGoal: 'цель B', sessionId: 's', baseDir })).toBeNull(); + rmSync(baseDir, { recursive: true, force: true }); + }); + it('перезапись per taskId', () => { + const baseDir = mkBase(); + saveRouterPin({ taskId: 't1', planGoal: 'A', classification: { v: 1 }, sessionId: 's', baseDir }); + saveRouterPin({ taskId: 't1', planGoal: 'A', classification: { v: 2 }, sessionId: 's', baseDir }); + expect(loadRouterPin({ taskId: 't1', planGoal: 'A', sessionId: 's', baseDir })).toEqual({ v: 2 }); + rmSync(baseDir, { recursive: true, force: true }); + }); + it('нет пина / нет файла → null (fail-safe)', () => { + const baseDir = mkBase(); + expect(loadRouterPin({ taskId: 'nope', planGoal: 'A', sessionId: 's', baseDir })).toBeNull(); + rmSync(baseDir, { recursive: true, force: true }); + }); +});