2026-06-15 08:06:08 +03:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Rule #2 — Coverage = реальный навык, по ЖУРНАЛУ K2 (М7 Фаза 4a, §4.2).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Stop-хук. Парсит `coverage: <channel>:<id>` из текста ассистента. Для 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:<name> (навык должен быть РЕАЛЬНО вызван в этом ходе — проверяется по журналу)`,
|
|
|
|
|
|
` coverage: direct:<role> (например 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:<role> с обоснованием.`,
|
|
|
|
|
|
].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);
|
2026-06-18 10:07:04 +03:00
|
|
|
|
{ const __h = await import('./enforce-hook-helpers.mjs'); if (__h.standbyActive((event && event.session_id) || 'unknown')) return __h.exitDecision({ block: false }); }
|
2026-06-15 08:06:08 +03:00
|
|
|
|
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();
|