// tools/self-debrief-detector.mjs /** * Self-debrief detector — router-gate v4.1 spec §3.12 (NEW). Закрывает F18. * Pure: ловит retrospective/self-analysis content в response без recent * self-retrospect / brain-retro Skill invocation. */ // NOTE: \b does not fire before Cyrillic characters in Node.js (ASCII word boundary). // Cyrillic-leading patterns use Unicode lookbehind (? { const t = normSkill(s); return n === t || n.endsWith(':' + t) || t.endsWith(':' + n); }); } export function extractSkillCallsLastNTurns(transcript, n) { const recs = transcript || []; let scoped = recs; const withTurn = recs.filter((r) => typeof r.turn === 'number'); if (withTurn.length > 0) { const maxTurn = Math.max(...withTurn.map((r) => r.turn)); scoped = recs.filter((r) => typeof r.turn !== 'number' || r.turn > maxTurn - n); } const out = []; for (const rec of scoped) { if (rec.type === 'tool_use' && rec.name === 'Skill') { out.push({ skill_name: normSkill(rec.input?.skill ?? rec.input?.command ?? '') }); } } return out; } export function detectSelfDebrief(controllerResponseText, transcript, opts = {}) { const { recentTurns = 30, selfRetroSkills = ['self-retrospect', 'brain-retro'] } = opts; const text = String(controllerResponseText || ''); const matched = SELF_DEBRIEF_PATTERNS.some((re) => re.test(text)); if (!matched) return { action: 'allow' }; const recent = extractSkillCallsLastNTurns(transcript, recentTurns); const selfRetroInvoked = recent.some((c) => skillMatchesAny(c.skill_name, selfRetroSkills)); if (selfRetroInvoked) return { action: 'allow' }; return { action: 'hard_block_next_mutating', reason: 'v4.1 self-debrief hard-block: response содержит retrospective/self-analysis content без recent self-retrospect или brain-retro Skill invocation. Invoke matching Skill для honest captured retrospect, не inline narrative analysis.', }; }