feat(router): пининг совета роутера по goalHash (переписка шагов не зовёт LLM заново)

Новый router-pin-store: пин совета роутера по (task_id, goalHash) пер-сессионно.
on-plan-write пин-aware: пин-попадание по неизменной цели → совет переиспользуется,
classifyImpl НЕ зовётся; промах/смена цели → classify + сохранение пина. Проводка в
активный наставник-хук инъекцией реального стора с sessionId (инъекция-выкл по умолчанию,
старое поведение/тесты целы). Хвост спеки роутера §4 (пининг по goalHash), эпик роутер-реестр
этап 3, item 2. Граница не тронута (recommended_chain, цепочки, observer-stop-hook, owner-seal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-21 08:18:15 +03:00
parent 4713a65b63
commit f22f8bd2ef
5 changed files with 150 additions and 5 deletions
+10
View File
@@ -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).
+17 -5
View File
@@ -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, не суд).
+32
View File
@@ -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;
+47
View File
@@ -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; }
}
+44
View File
@@ -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 });
});
});