/** * PreToolUse-страж: TodoWrite заявил навык → должен быть в ЖУРНАЛЕ вызовов (М7 Фаза 4a, §4.2). * Выполненный todo, claim'ящий Skill, не подтверждённый журналом (канал М1) → блок следующего * мутирующего. Session-scope (выполненный todo мог закрыться в прошлом ходе — навык должен быть * в журнале за сессию, не обязательно в этом ходе; отличие от coverage, которое turn-scoped). * * fail-CLOSE (М7 Фаза 0 правило 1). PreToolUse (предотвращение до действия, §4.2 [Pre]). */ import { fileURLToPath } from 'url'; import { readStdin, parseEventJson, readTranscript, runtimeDir, exitDisciplineDecision, } from './enforce-hook-helpers.mjs'; import { extractSkillMentions, hardSyncCheck, } from './todowrite-skill-verifier.mjs'; import { loadJournal } from './action-journal.mjs'; import { extractSkillCalls } from './enforce-skill-journaler.mjs'; /** Find the latest TodoWrite tool_use entries in a transcript. */ export function lastTodoItems(transcript) { const recs = transcript || []; for (let i = recs.length - 1; i >= 0; i--) { const r = recs[i]; if (r && r.type === 'tool_use' && r.name === 'TodoWrite') { return r.input && Array.isArray(r.input.todos) ? r.input.todos : []; } } return []; } /** * Дедуп regex-артефактов extractSkillMentions: "invoke superpowers:brainstorming" даёт и полное * "superpowers:brainstorming", и частичное "superpowers" (invoke-паттерн стопится на двоеточии). * Частичное избыточно, если из того же текста есть mention, начинающийся с ":". */ export function deduplicateMentions(mentions) { return mentions.filter((m) => { const nameColon = m.skill_name + ':'; const coveredByLonger = mentions.some( (other) => other !== m && other.text === m.text && other.skill_name.startsWith(nameColon), ); return !coveredByLonger; }); } export function decide({ todoItems, journalSkillCalls = [] }) { const items = Array.isArray(todoItems) ? todoItems : []; const rawMentions = extractSkillMentions(items); const mentions = deduplicateMentions(rawMentions); const calls = (journalSkillCalls || []).map((n) => ({ skill_name: n })); const r = hardSyncCheck(mentions, calls); if (r.action === 'hard_block_next_mutating') { return { block: true, reason: r.reason }; } return { block: false, reason: null }; } async function main() { const event = parseEventJson(await readStdin()); await exitDisciplineDecision( () => { const transcript = readTranscript(event.transcript_path); const todoItems = lastTodoItems(transcript); const { entries } = loadJournal({ sessionId: event.session_id || 'unknown', runtimeDir: runtimeDir(), }); const journalSkillCalls = extractSkillCalls(entries); const r = decide({ todoItems, journalSkillCalls }); return { block: r.block, message: r.block ? `[todowrite-skill-verifier] ${r.reason}` : undefined }; }, { label: 'todowrite-skill-verifier' }, ); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();