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:
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user