fix(mentor): резолв ID узлов в имена в скил-контексте наставника

renderSkillContext резолвит #N -> '#N - имя' через registry.indexById (resolveNodeName, fail-safe -> голый #N); onPlanWrite прокидывает registry. Наставник видит рекомендацию роутера именами. TDD +4 теста, регрессия tools-only 3910 GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-13 17:15:14 +03:00
parent 40811c5bfd
commit db1eb8e337
4 changed files with 47 additions and 4 deletions
+14 -3
View File
@@ -53,13 +53,24 @@ export function renderNegotiation(log) {
return ['--- ПЕРЕГОВОРЫ ЗАДАЧИ (история кругов) ---', ...lines].join('\n');
}
/** Резолв ID узла → «#N — имя» через registry.indexById; нет узла/реестра → голый #N (fail-safe). */
function resolveNodeName(id, registry) {
const node = registry && registry.indexById && typeof registry.indexById.get === 'function'
? registry.indexById.get(id) : null;
const label = node && (node.name || node.slug);
return label ? `${id}${label}` : String(id);
}
/** Рендер скил-контекста для промпта наставника (мерж): объявленные в плане скилы +
* рекомендация роутера. recommendedChain=null → классификатор недоступен (не сверять). */
export function renderSkillContext({ declared = [], recommendedChain = null } = {}) {
* рекомендация роутера (ID резолвятся в имена через registry). recommendedChain=null →
* классификатор недоступен (не сверять). */
export function renderSkillContext({ declared = [], recommendedChain = null, registry = null } = {}) {
const dec = declared.length ? declared.join(', ') : '(не объявлены)';
const rec = recommendedChain === null
? '(рекомендация роутера недоступна — НЕ заворачивай за скилы)'
: (Array.isArray(recommendedChain) && recommendedChain.length ? recommendedChain.join(', ') : '(роутер ничего не порекомендовал)');
: (Array.isArray(recommendedChain) && recommendedChain.length
? recommendedChain.map((id) => resolveNodeName(id, registry)).join(', ')
: '(роутер ничего не порекомендовал)');
return `--- СКИЛЫ ---\nОбъявлены в плане: ${dec}\nРекомендация роутера: ${rec}\n`
+ 'Оцени уместность выбора скилов; неуместный/неполный выбор → decision="NO-GO" + что добавить/убрать.';
}
+17
View File
@@ -15,6 +15,23 @@ describe('renderSkillContext (мерж роутер↔наставник)', () =
});
});
describe('renderSkillContext — резолв ID→имя (фикс)', () => {
const registry = { indexById: new Map([['#4', { id: '#4', name: 'markdownlint-cli2', slug: 'markdownlint' }]]) };
it('с реестром резолвит #4 → имя', () => {
const s = renderSkillContext({ declared: ['executing-plans'], recommendedChain: ['#4'], registry });
expect(s).toMatch(/#4 — markdownlint-cli2/);
});
it('без реестра → голый ID (fail-safe)', () => {
const s = renderSkillContext({ declared: ['x'], recommendedChain: ['#4'] });
expect(s).toMatch(/#4/);
expect(s).not.toMatch(/markdownlint/);
});
it('неизвестный ID → голый ID (не падает)', () => {
const s = renderSkillContext({ declared: ['x'], recommendedChain: ['#999'], registry });
expect(s).toMatch(/#999/);
});
});
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 } };
const verifiedContext = [{ id: '1', claim: 'есть runRouter', ref: 'router-engine.mjs:140', kind: 'EXTRACTED' }];
+1 -1
View File
@@ -52,7 +52,7 @@ export async function onPlanWrite({
recommendedChain = (c && c.recommended_chain) || (c && c.recommended_node ? [c.recommended_node] : []);
} catch { recommendedChain = null; }
}
const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain });
const skillContext = renderSkillContext({ declared: declaredSkills, recommendedChain, registry });
// Производитель вердикта (C T5b): сбой → ok:false/wired:false (SE-R6-6, не суд).
const r = await runMentorVerdictImpl({
plan: { steps: planSteps }, planHash, verifiedContext, negotiationLog, graphSection, skillContext, llmCall,
+15
View File
@@ -84,4 +84,19 @@ describe('onPlanWrite — скил-сверка через classify (мерж)',
expect(r.ok).toBe(true);
expect(capturedUser).toMatch(/недоступн/i);
});
it('резолвит ID рекомендации в имя через registry (фикс)', async () => {
let capturedUser = null;
const registry = { indexById: new Map([['#4', { id: '#4', name: 'markdownlint-cli2' }]]) };
await onPlanWrite({
planSteps: [{ n: 1, op: 'Edit', object: 'x', ref: 'D1' }],
declaredSkills: ['executing-plans'],
classifyImpl: async () => ({ recommended_chain: ['#4'] }),
registry,
llmCall: async ({ buildPrompt }) => { capturedUser = buildPrompt().user; return GO; },
planGoal: 'правка .md',
});
expect(capturedUser).toMatch(/markdownlint-cli2/);
expect(capturedUser).not.toMatch(/#4(?! — )/); // голого #4 без имени нет
});
});