#!/usr/bin/env node /** * Rule #2 — Coverage = реальный навык, по ЖУРНАЛУ K2 (М7 Фаза 4a, §4.2). * * Stop-хук. Парсит `coverage: :` из текста ассистента. Для skill:-канала * требует навык X в ПЕРЕСЕЧЕНИИ «Skill-tool_use ЭТОГО хода ∩ журнал вызовов» — turn-scope * даёт граница хода transcript (turnToolUses), факт «реально отработал» — журнал (канал М1, * skillTakenByJournal K2). Не по строке coverage: (Класс 1 закрыт). direct/node/chain/hook/agent * журналом не верифицируемы (§4.2) — принимаются на этом слое (их судит думающий М4, §1 П3). * * fail-CLOSE (М7 Фаза 0 правило 1): любая внутренняя ошибка → блок. Override-вокабуляр снят * (§12 escape≠override — единственная дверь escape М6). * * Spec: docs/superpowers/specs/2026-06-08-router-mentor-machine-7-design.md §4.2 */ import { readStdin, parseEventJson, readTranscript, lastAssistantText, parseCoverageLine, turnToolUses, runtimeDir, exitDisciplineDecision, } from './enforce-hook-helpers.mjs'; import { skillTakenByJournal } from './judge-gate-floor.mjs'; import { loadJournal } from './action-journal.mjs'; import { extractSkillCalls } from './enforce-skill-journaler.mjs'; const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']); /** Нормализация имени навыка: trim+lower, снять префикс superpowers:. */ function normName(x) { return String(x || '').trim().toLowerCase().replace(/^superpowers:/, ''); } export function decide({ toolUses = [], assistantText = '', journalSkillCalls = [] }) { // Чисто-разговорный ход (нет мутирующих) — пропуск. const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name)); if (!hasMutating) return { block: false }; const cov = parseCoverageLine(assistantText); if (!cov) { return { block: true, message: [ `[enforce-coverage-verify] ход выполнил мутирующие вызовы, но в ответе нет строки \`coverage:\`.`, `Первой строкой следующего ответа:`, ` coverage: skill: (навык должен быть РЕАЛЬНО вызван в этом ходе — проверяется по журналу)`, ` coverage: direct: (например direct:memory-sync)`, ].join('\n'), }; } if (cov.channel === 'skill') { const want = normName(cov.id); const turnSkillNames = toolUses .filter((u) => u.name === 'Skill' && u.input && u.input.skill) .map((u) => normName(u.input.skill)); const inTurn = turnSkillNames.includes(want); const inJournal = skillTakenByJournal({ requiredSkills: [want], journalSkillCalls: (journalSkillCalls || []).map(normName), }).ok === true; if (!(inTurn && inJournal)) { return { block: true, message: [ `[enforce-coverage-verify] coverage skill:${cov.id} не подтверждён фактом.`, inTurn ? `Навык НЕ найден в журнале вызовов (skill-журналер М1) — не доказано, что он реально отработал.` : `Навык НЕ вызван Skill-инструментом в ЭТОМ ходе (журнал кумулятивен — нужен вызов в текущем ходе).`, `Вызови навык через Skill, либо переключи coverage на direct: с обоснованием.`, ].join('\n'), }; } return { block: false }; } // direct / node / chain / hook / agent — журналом не верифицируемы (§4.2), приняты здесь. return { block: false }; } async function main() { const raw = await readStdin(); const event = parseEventJson(raw); await exitDisciplineDecision( () => { const transcript = readTranscript(event.transcript_path); const toolUses = turnToolUses(transcript); const assistantText = lastAssistantText(transcript); const { entries } = loadJournal({ sessionId: event.session_id || 'unknown', runtimeDir: runtimeDir(), }); const journalSkillCalls = extractSkillCalls(entries); return decide({ toolUses, assistantText, journalSkillCalls }); }, { label: 'enforce-coverage-verify' }, ); } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-coverage-verify.mjs'); if (isCli) main();