abf2060328
Сессионный флаг standby-mode + управляющий UserPromptSubmit-хук рукопожатия + SessionStart-сброс. Страж if standbyActive в 12 блокирующих хуках; рельсы floor/snapshot/verify-gate не тронуты. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
84 lines
3.6 KiB
JavaScript
84 lines
3.6 KiB
JavaScript
/**
|
||
* 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());
|
||
{ const __h = await import('./enforce-hook-helpers.mjs'); if (__h.standbyActive((event && event.session_id) || 'unknown')) return __h.exitDecision({ block: false }); }
|
||
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();
|