9ec5e1ee58
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>
70 lines
4.6 KiB
JavaScript
70 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* enforce-domain-skill-discipline — детерминированный судья дисциплины навыков (подключение
|
|
* готового domain-skill-discipline). Навыки, ОБЪЯВЛЕННЫЕ в ОПЛОМБИРОВАННОМ плане
|
|
* (frozenPlan.skills — подписанное поле, Вариант 1), обязаны быть РЕАЛЬНО вызваны (журнал M1)
|
|
* до первого мутирующего шага. Иначе блок с перечнем недостающих. fail-CLOSE (сбой → блок).
|
|
* PreToolUse на мутирующие инструменты; регистрация в settings.json — шаг владельца.
|
|
*/
|
|
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
|
|
import { domainCallDiscipline } from './domain-skill-discipline.mjs';
|
|
import { loadFrozenPlan } from './plan-lock.mjs';
|
|
import { loadJournal } from './action-journal.mjs';
|
|
import { extractSkillCalls } from './enforce-skill-journaler.mjs';
|
|
import { resolveSessionId } from './enforce-supreme-gate.mjs';
|
|
|
|
const suffix = (n) => { const s = String(n || '').toLowerCase(); return s.includes(':') ? s.split(':').pop() : s; };
|
|
|
|
// Объявленный навык покрыт вызовом, если: точное равенство ИЛИ вызванный начинается с
|
|
// 'объявленный:' (объявлен плагин) ИЛИ совпали суффиксы (бэр-имена навыка). Зеркало
|
|
// isPlanDeclaredSkill стены — declared↔invoked нормализуются одинаково.
|
|
function callCovers(declared, invoked) {
|
|
const d = String(declared || '').toLowerCase();
|
|
const i = String(invoked || '').toLowerCase();
|
|
if (!d || !i) return false;
|
|
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, 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 });
|
|
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() {
|
|
try {
|
|
const event = parseEventJson(await readStdin());
|
|
const sess = resolveSessionId(event);
|
|
if ((await import('./enforce-hook-helpers.mjs')).standbyActive(sess)) return exitDecision({ block: false });
|
|
const dir = runtimeDir();
|
|
const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir: dir });
|
|
const { entries } = loadJournal({ sessionId: sess, runtimeDir: dir });
|
|
const journalSkillCalls = extractSkillCalls(entries);
|
|
const r = decide({ frozenPlan, journalSkillCalls });
|
|
// 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' });
|
|
}
|
|
}
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
if (isCli) main();
|