Files
portal/tools/enforce-todowrite-skill-verifier.mjs
T
Дмитрий a29fa9caa9 feat(m7-phase4a): todowrite-skill-verifier — журнал-факт session-scope + fail-CLOSE + PreToolUse (§4.2)
Выполненный todo, claim'ящий Skill, теперь сверяется с ЖУРНАЛОМ вызовов (extractSkillCalls,
канал М1) вместо transcript-извлечения. Session-scope осознанно (выполненный todo мог
закрыться в прошлом ходе — отличие от coverage, которое turn-scoped). decide получает
journalSkillCalls; main грузит журнал через loadJournal+extractSkillCalls, обёрнут
exitDisciplineDecision (fail-CLOSE Фазы 0). Переориентирован на PreToolUse-семантику
(предотвращение, §4.2 [Pre]; регистрация matcher — шаг владельца Ф8). 5/5 тестов GREEN.
2026-06-08 11:08:56 +03:00

83 lines
3.4 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.
/**
* 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, начинающийся с "<partial>:".
*/
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();