Files
brain/tools/todowrite-skill-verifier.mjs
T

98 lines
3.3 KiB
JavaScript

/**
* TodoWrite vs Skill transcript verifier — router-gate v4 spec §3.9 + v4.1 (Direction 4).
* Pure: ловит TodoWrite claims "invoked X skill" без actual Skill tool call + erasure.
* v4.1: hard sync — completed item claiming skill but not invoked → block next mutating.
*/
export const SKILL_MENTION_PATTERNS = [
/superpowers:[a-z-]+/g,
/Skill\(([^)]+)\)/g,
/\binvoke\s+([a-z][a-z0-9-]*)/gi,
/(?<![\p{L}\p{N}_])вызови\s+([a-z][a-z0-9-]*)/giu,
/(?<![\p{L}\p{N}_])делай\s+([a-z][a-z0-9-]*)/giu,
];
function normSkill(name) {
return String(name || '').trim().toLowerCase();
}
function itemText(item) {
return item.content ?? item.text ?? '';
}
export function extractSkillMentions(todoItems) {
const out = [];
for (const item of todoItems || []) {
const text = itemText(item);
const status = item.status || 'pending';
const found = new Set();
for (const pat of SKILL_MENTION_PATTERNS) {
const re = new RegExp(pat.source, pat.flags);
for (const m of String(text).matchAll(re)) {
// superpowers: and Skill() use m[0] / m[1] respectively
// invoke/вызови/делай capture group 1 for skill name
const val = m[1] !== undefined ? m[1] : m[0];
found.add(normSkill(val));
}
}
for (const skill_name of found) out.push({ skill_name, text, status });
}
return out;
}
export function extractSkillToolCalls(transcript) {
const out = [];
for (const rec of transcript || []) {
if (rec.type === 'tool_use' && rec.name === 'Skill') {
const name = rec.input?.skill ?? rec.input?.command ?? '';
out.push({ skill_name: normSkill(name), tool_use_id: rec.id ?? null });
}
}
return out;
}
export function skillNameMatches(mention, actualName) {
const a = normSkill(mention);
const b = normSkill(actualName);
if (a === b) return true;
// mention without namespace matches actual with namespace suffix
if (b.endsWith(':' + a)) return true;
if (a.endsWith(':' + b)) return true;
return false;
}
export function verifyClaims(skillMentions, actualSkillCalls) {
const mismatches = [];
for (const mention of skillMentions) {
const matched = (actualSkillCalls || []).some((c) => skillNameMatches(mention.skill_name, c.skill_name));
if (!matched) mismatches.push(mention);
}
return { mismatches };
}
export function detectErasure(historySnapshots) {
const erased = [];
for (const snap of historySnapshots || []) {
const removed = snap.diff?.removed || [];
for (const item of removed) {
const mentions = extractSkillMentions([item]);
if (mentions.length > 0) erased.push({ snapshot_ts: snap.ts, item, mentions });
}
}
return { erased };
}
export function hardSyncCheck(skillMentions, actualSkillCalls) {
for (const mention of skillMentions) {
if (mention.status !== 'completed') continue;
const wasInvoked = (actualSkillCalls || []).some((c) => skillNameMatches(mention.skill_name, c.skill_name));
if (!wasInvoked) {
return {
action: 'hard_block_next_mutating',
reason: `v4.1 TodoWrite hard sync: item "${mention.text}" marked completed claims Skill(${mention.skill_name}) invoked, но Skill tool call отсутствует. Либо invoke Skill сейчас, либо edit TodoWrite чтобы remove false claim.`,
};
}
}
return { action: 'allow' };
}