Files
portal/tools/enforce-domain-skill-discipline.mjs
T
Дмитрий e243b8f77b feat(mentor): тупой судья навыков + фикс роутера prefilter-bypass
- router: classify({skipPrefilter}) — наставник зовёт мозг роутера мимо detectMicro
  (ловил 'format' подстрокой в имени модуля → роутер не доходил до LLM); recommendedChainOf
  в on-plan-write маппит node/recommended_node/recommended_chain (рекомендация не теряется)
- skills в ПОДПИСАННУЮ печать (Вариант 1): sealablePlan/freezePlan/sealPlan
- стена: isPlanDeclaredSkill — объявленный в опломбированном плане навык вызываем (снимает дедлок)
- enforce-domain-skill-discipline (новый хук): объявил → обязан вызвать (журнал M1) до
  первого мутирующего шага; поверх готового domain-skill-discipline
- гайд docs/superpowers/router-mentor-wall-GUIDE.md + дизайн/план-доки
- регрессия tools-only 3928 passed + 2 skip, 0 регрессий

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 04:08:53 +03:00

57 lines
3.5 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);
}
export function decide({ frozenPlan, journalSkillCalls = [] }) {
const declared = (frozenPlan && Array.isArray(frozenPlan.skills)) ? frozenPlan.skills : [];
if (declared.length === 0) return { block: false, reason: null };
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 };
}
async function main() {
try {
const event = parseEventJson(await readStdin());
const sess = resolveSessionId(event);
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 });
exitDecision({ block: r.block, message: r.block ? `[skill-discipline] ${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();