feat(router): exit-2 судьи дисциплины несёт имя забытого навыка с директивой вызвать

buildForgottenSkillCard формирует внятную карточку «ПЛАН ЗАБЫЛ вызвать навык(и) из skills-json:
X → вызови Skill X» вместо общей прозы. decide() возвращает список uncalled; main() отдаёт карточку
в exit-2. callCovers/нормализация/fail-CLOSE — без изменений. Хвост спеки роутера §6 (контроллеру —
имя забытого навыка), эпик роутер-реестр этап 3, item 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-21 09:05:17 +03:00
parent 5eea8e2a43
commit 9ec5e1ee58
2 changed files with 44 additions and 4 deletions
+15 -3
View File
@@ -25,15 +25,26 @@ function callCovers(declared, invoked) {
return i === d || i.startsWith(d + ':') || suffix(i) === suffix(d);
}
// item 4 (роутер-реестр §6): exit-2 карточка с ИМЕНЕМ забытого навыка + директивой вызвать.
// Пустой список → '' (этот путь не блокирует). Чистая.
export function buildForgottenSkillCard(uncalled) {
const names = (Array.isArray(uncalled) ? uncalled : []).filter((s) => s && String(s).trim());
if (names.length === 0) return '';
const list = names.map((n) => `«${n}»`).join(', ');
const calls = names.map((n) => `Skill ${n}`).join(' / ');
return `ПЛАН ЗАБЫЛ вызвать навык(и) из skills-json: ${list} → вызови ${calls} до первого мутирующего шага.`;
}
export function decide({ frozenPlan, journalSkillCalls = [] }) {
const declared = (frozenPlan && Array.isArray(frozenPlan.skills)) ? frozenPlan.skills : [];
if (declared.length === 0) return { block: false, reason: null };
if (declared.length === 0) return { block: false, reason: null, uncalled: [] };
const calls = journalSkillCalls || [];
// Устойчивый матч ДО сверки: «фактически покрытые» объявленные навыки подаём как invoked
// (точными строками declared), чтобы domainCallDiscipline (Set-равенство) дал верный uncalled/reason.
const covered = declared.filter((dskill) => calls.some((c) => callCovers(dskill, c)));
const r = domainCallDiscipline({ recommendedDomainSkills: declared, invokedSkills: covered });
return r.ok ? { block: false, reason: null } : { block: true, reason: r.reason };
const uncalled = declared.filter((dskill) => !calls.some((c) => callCovers(dskill, c)));
return r.ok ? { block: false, reason: null, uncalled: [] } : { block: true, reason: r.reason, uncalled };
}
async function main() {
@@ -46,7 +57,8 @@ async function main() {
const { entries } = loadJournal({ sessionId: sess, runtimeDir: dir });
const journalSkillCalls = extractSkillCalls(entries);
const r = decide({ frozenPlan, journalSkillCalls });
exitDecision({ block: r.block, message: r.block ? `[skill-discipline] ${r.reason}` : undefined });
// item 4: exit-2 несёт ИМЯ забытого навыка с директивой (карточка); иначе — общая причина.
exitDecision({ block: r.block, message: r.block ? `[skill-discipline] ${buildForgottenSkillCard(r.uncalled) || r.reason}` : undefined });
} catch {
exitDecision({ block: true, message: '[skill-discipline] внутренняя ошибка — fail-CLOSE' });
}
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-domain-skill-discipline.mjs';
import { decide, buildForgottenSkillCard } from './enforce-domain-skill-discipline.mjs';
describe('судья дисциплины: объявленный навык должен быть вызван', () => {
it('объявлен, не вызван → блок с перечнем', () => {
@@ -26,3 +26,31 @@ describe('судья дисциплины: объявленный навык д
expect(decide({ frozenPlan: null, journalSkillCalls: [] }).block).toBe(false);
});
});
describe('exit-2 карточка забытого навыка (item 4)', () => {
it('одно имя → содержит имя и директиву вызвать', () => {
const m = buildForgottenSkillCard(['frontend-design']);
expect(m).toMatch(/frontend-design/);
expect(m).toMatch(/вызови/i);
});
it('несколько имён → все имена в карточке', () => {
const m = buildForgottenSkillCard(['alpha', 'beta']);
expect(m).toMatch(/alpha/);
expect(m).toMatch(/beta/);
});
it('пустой список → пустая строка', () => {
expect(buildForgottenSkillCard([])).toBe('');
});
it('decide возвращает uncalled при незваном навыке', () => {
const r = decide({ frozenPlan: { skills: ['frontend-design'] }, journalSkillCalls: [] });
expect(r.uncalled).toEqual(['frontend-design']);
});
it('decide: вызванный навык не попадает в uncalled', () => {
const r = decide({ frozenPlan: { skills: ['test-driven-development'] }, journalSkillCalls: ['superpowers:test-driven-development'] });
expect(r.uncalled).toEqual([]);
});
});