diff --git a/tools/enforce-domain-skill-discipline.mjs b/tools/enforce-domain-skill-discipline.mjs index 8f6c70f..7bfd19e 100644 --- a/tools/enforce-domain-skill-discipline.mjs +++ b/tools/enforce-domain-skill-discipline.mjs @@ -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' }); } diff --git a/tools/enforce-domain-skill-discipline.test.mjs b/tools/enforce-domain-skill-discipline.test.mjs index 6988093..de64748 100644 --- a/tools/enforce-domain-skill-discipline.test.mjs +++ b/tools/enforce-domain-skill-discipline.test.mjs @@ -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([]); + }); +});