Files
brain/tools/enforce-coverage-verify.mjs
T

106 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
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();