diff --git a/tools/enforce-override-limit.mjs b/tools/enforce-override-limit.mjs new file mode 100644 index 00000000..e09e11ba --- /dev/null +++ b/tools/enforce-override-limit.mjs @@ -0,0 +1,116 @@ +// PreToolUse hook: hard-block 6th+ usage of same override-phrase in one day. +// Phase 2 of router-hooks fixes (per brain-retro #9 candidate 6 + self-retrospect 28.05). +// +// Reads: +// - hook input JSON (passed via stdin) +// - ~/.claude/runtime/override-usage.jsonl (today's usage log) +// - tools/enforce-override-vocab.json (7 phrases) +// +// Writes (stdout): +// - empty if no block +// - JSON {decision: "block", reason: "..."} if 6th phrase usage detected +// +// Bypass: BYPASS_PHRASE in current prompt -> no block (counter unchanged). + +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const THRESHOLD = 5; +export const BYPASS_PHRASE = 'лимит снят'; + +function loadVocab() { + const vocabPath = join(__dirname, 'enforce-override-vocab.json'); + if (!existsSync(vocabPath)) return []; + try { + const j = JSON.parse(readFileSync(vocabPath, 'utf-8')); + return Array.isArray(j.phrases) ? j.phrases.map(p => p.phrase) : []; + } catch { + return []; + } +} + +export const VOCAB = loadVocab(); + +export function findPhrasesInPrompt(prompt) { + if (typeof prompt !== 'string' || !prompt) return []; + const lower = prompt.toLowerCase(); + return VOCAB.filter(p => lower.includes(p.toLowerCase())); +} + +export function countTodayUsage(rawLog, phrase, now = new Date()) { + if (typeof rawLog !== 'string' || !rawLog) return 0; + const today = now.toISOString().slice(0, 10); + let count = 0; + for (const line of rawLog.split('\n')) { + if (!line) continue; + try { + const e = JSON.parse(line); + if (e.phrase === phrase && typeof e.ts === 'string' && e.ts.slice(0, 10) === today) { + count++; + } + } catch { + // ignore malformed lines + } + } + return count; +} + +export function shouldBlock(prompt, rawLog, now = new Date()) { + if (typeof prompt === 'string' && prompt.toLowerCase().includes(BYPASS_PHRASE.toLowerCase())) { + return { block: false, bypass: true }; + } + const phrases = findPhrasesInPrompt(prompt); + for (const phrase of phrases) { + const todayCount = countTodayUsage(rawLog, phrase, now); + if (todayCount >= THRESHOLD) { + return { block: true, phrase, todayCount }; + } + } + return { block: false }; +} + +export function buildBlockOutput({ phrase, todayCount }) { + return { + decision: 'block', + reason: + `[enforce-override-limit] Override-фраза «${phrase}» уже использована ${todayCount} раз сегодня (порог ${THRESHOLD}/день per phrase). ` + + `Это 6-е или последующее использование — hard-block per Phase 2 plan. ` + + `Чтобы продолжить, вызови AskUserQuestion и спроси заказчика явно. ` + + `Если он подтверждает — следующий промпт должен содержать фразу «${BYPASS_PHRASE}» (one-shot bypass, счётчик НЕ сбрасывается).`, + }; +} + +// CLI: read hook input from stdin, write block-JSON to stdout if needed. +async function main() { + let raw = ''; + for await (const chunk of process.stdin) raw += chunk; + let input; + try { input = JSON.parse(raw || '{}'); } catch { input = {}; } + + // Find current user prompt - different hook payloads use different fields. + const prompt = + input?.prompt || + input?.hook_event?.prompt || + input?.user_prompt || + input?.transcript?.[input?.transcript?.length - 1]?.content || + ''; + + const logPath = join(homedir(), '.claude', 'runtime', 'override-usage.jsonl'); + const rawLog = existsSync(logPath) ? readFileSync(logPath, 'utf-8') : ''; + + const decision = shouldBlock(prompt, rawLog); + if (decision.block) { + process.stdout.write(JSON.stringify(buildBlockOutput(decision))); + process.exit(0); + } + // No block - silent pass. + process.exit(0); +} + +// Run as CLI if this file is the entrypoint (not when imported by tests). +const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-override-limit.mjs'); +if (isCli) main(); diff --git a/tools/enforce-override-limit.test.mjs b/tools/enforce-override-limit.test.mjs new file mode 100644 index 00000000..9f4c7c52 --- /dev/null +++ b/tools/enforce-override-limit.test.mjs @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { + countTodayUsage, + findPhrasesInPrompt, + shouldBlock, + buildBlockOutput, + VOCAB, + THRESHOLD, + BYPASS_PHRASE, +} from './enforce-override-limit.mjs'; + +describe('VOCAB + THRESHOLD constants', () => { + it('exports 7 phrases', () => { + expect(VOCAB.length).toBe(7); + expect(VOCAB).toContain('recovery'); + expect(VOCAB).toContain('ремонт инфраструктуры'); + expect(VOCAB).toContain('без скилов'); + }); + it('threshold is 5', () => { + expect(THRESHOLD).toBe(5); + }); + it('bypass phrase is "лимит снят"', () => { + expect(BYPASS_PHRASE).toBe('лимит снят'); + }); +}); + +describe('findPhrasesInPrompt', () => { + it('finds single phrase case-insensitively', () => { + expect(findPhrasesInPrompt('сделай recovery быстро')).toEqual(['recovery']); + expect(findPhrasesInPrompt('сделай RECOVERY')).toEqual(['recovery']); + }); + it('finds multiple phrases in one prompt', () => { + const found = findPhrasesInPrompt('срочно: recovery и быстрый коммит'); + expect(found.sort()).toEqual(['быстрый коммит', 'recovery', 'срочно'].sort()); + }); + it('returns empty array on no match', () => { + expect(findPhrasesInPrompt('обычный текст без override')).toEqual([]); + }); + it('handles empty/null prompt', () => { + expect(findPhrasesInPrompt('')).toEqual([]); + expect(findPhrasesInPrompt(null)).toEqual([]); + expect(findPhrasesInPrompt(undefined)).toEqual([]); + }); +}); + +describe('countTodayUsage', () => { + it('counts entries for given phrase on given date', () => { + const log = [ + '{"ts":"2026-05-28T10:00:00.000Z","phrase":"recovery"}', + '{"ts":"2026-05-28T11:00:00.000Z","phrase":"recovery"}', + '{"ts":"2026-05-28T12:00:00.000Z","phrase":"ремонт инфраструктуры"}', + '{"ts":"2026-05-27T10:00:00.000Z","phrase":"recovery"}', // вчера, не считается + ].join('\n'); + expect(countTodayUsage(log, 'recovery', new Date('2026-05-28T15:00:00Z'))).toBe(2); + expect(countTodayUsage(log, 'ремонт инфраструктуры', new Date('2026-05-28T15:00:00Z'))).toBe(1); + expect(countTodayUsage(log, 'recovery', new Date('2026-05-27T15:00:00Z'))).toBe(1); + }); + it('returns 0 on empty/malformed log', () => { + expect(countTodayUsage('', 'recovery', new Date())).toBe(0); + expect(countTodayUsage(null, 'recovery', new Date())).toBe(0); + expect(countTodayUsage('not json\nалсо not\n', 'recovery', new Date())).toBe(0); + }); + it('ignores malformed JSON lines mixed with valid', () => { + const log = [ + '{"ts":"2026-05-28T10:00:00.000Z","phrase":"recovery"}', + 'broken line', + '{"ts":"2026-05-28T11:00:00.000Z","phrase":"recovery"}', + ].join('\n'); + expect(countTodayUsage(log, 'recovery', new Date('2026-05-28T15:00:00Z'))).toBe(2); + }); +}); + +describe('shouldBlock', () => { + const now = new Date('2026-05-28T15:00:00Z'); + const fourUses = Array.from({ length: 4 }, (_, i) => + `{"ts":"2026-05-28T0${i}:00:00.000Z","phrase":"recovery"}` + ).join('\n'); + const fiveUses = Array.from({ length: 5 }, (_, i) => + `{"ts":"2026-05-28T0${i}:00:00.000Z","phrase":"recovery"}` + ).join('\n'); + + it('returns {block:false} when no override phrase in prompt', () => { + const r = shouldBlock('обычный текст', fiveUses, now); + expect(r.block).toBe(false); + }); + it('returns {block:false} when phrase used 4 times today (below threshold)', () => { + const r = shouldBlock('сделай recovery', fourUses, now); + expect(r.block).toBe(false); + }); + it('returns {block:true} when phrase used 5 times today (this is 6th)', () => { + const r = shouldBlock('сделай recovery', fiveUses, now); + expect(r.block).toBe(true); + expect(r.phrase).toBe('recovery'); + expect(r.todayCount).toBe(5); + }); + it('returns {block:false} when bypass phrase "лимит снят" present', () => { + const r = shouldBlock('сделай recovery лимит снят', fiveUses, now); + expect(r.block).toBe(false); + expect(r.bypass).toBe(true); + }); + it('blocks on FIRST exceeding phrase when multiple present', () => { + const log = [fiveUses, '{"ts":"2026-05-28T05:00:00.000Z","phrase":"срочно"}'].join('\n'); + const r = shouldBlock('срочно сделай recovery', log, now); + expect(r.block).toBe(true); + // Either recovery or срочно could be first found; must be a real over-threshold one. + expect(['recovery', 'срочно']).toContain(r.phrase); + }); +}); + +describe('buildBlockOutput', () => { + it('returns JSON with decision: block and informative reason', () => { + const out = buildBlockOutput({ phrase: 'recovery', todayCount: 5 }); + expect(out).toHaveProperty('decision', 'block'); + expect(out.reason).toContain('recovery'); + expect(out.reason).toContain('5'); + expect(out.reason).toContain('лимит снят'); + }); +});