2b23a1f210
Adds RATE_WINDOW_MIN=10 + RATE_THRESHOLD=5 alongside existing per-day THRESHOLD=5. Closes gap where per-day limit doesn't catch rate-spikes: - 2026-05-28 session 4a8b327e burned 40 events / 59 minutes (0.68/min). - Per-day=5 was breached after 5 events; rate-spike of next 35 went uncounted. shouldBlock returns triggered='daily' or 'rate' with reason. buildBlockOutput emits rate-specific message asking for 10-min pause + bypass-phrase confirmation. Per Level 1 plan docs/superpowers/plans/2026-05-28-router-discipline-level-1-2.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.1 KiB
JavaScript
171 lines
6.1 KiB
JavaScript
// 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 RATE_WINDOW_MIN = 10;
|
|
export const RATE_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 countWindowUsage(rawLog, phrase, now = new Date(), windowMinutes = 10) {
|
|
if (typeof rawLog !== 'string' || !rawLog) return 0;
|
|
const cutoffMs = now.getTime() - windowMinutes * 60_000;
|
|
let count = 0;
|
|
for (const line of rawLog.split('\n')) {
|
|
if (!line) continue;
|
|
try {
|
|
const e = JSON.parse(line);
|
|
if (e.phrase !== phrase) continue;
|
|
if (typeof e.ts !== 'string') continue;
|
|
const tsMs = Date.parse(e.ts);
|
|
if (Number.isFinite(tsMs) && tsMs >= cutoffMs && tsMs <= now.getTime()) {
|
|
count++;
|
|
}
|
|
} catch {
|
|
// ignore malformed
|
|
}
|
|
}
|
|
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,
|
|
triggered: 'daily',
|
|
reason: `daily count ${todayCount} >= ${THRESHOLD}`,
|
|
};
|
|
}
|
|
const windowCount = countWindowUsage(rawLog, phrase, now, RATE_WINDOW_MIN);
|
|
if (windowCount >= RATE_THRESHOLD) {
|
|
return {
|
|
block: true,
|
|
phrase,
|
|
windowCount,
|
|
triggered: 'rate',
|
|
reason: `rate-window count ${windowCount} >= ${RATE_THRESHOLD} in ${RATE_WINDOW_MIN} min`,
|
|
};
|
|
}
|
|
}
|
|
return { block: false };
|
|
}
|
|
|
|
export function buildBlockOutput({ phrase, todayCount, windowCount, triggered }) {
|
|
if (triggered === 'rate') {
|
|
return {
|
|
decision: 'block',
|
|
reason:
|
|
`[enforce-override-limit] Override-фраза «${phrase}» использована ${windowCount} раз за последние ${RATE_WINDOW_MIN} минут (порог ${RATE_THRESHOLD}). ` +
|
|
`Rate-spike обнаружен — это шаблонная привычка обхода, не реальная нужда. ` +
|
|
`Сделай ПАУЗУ 10 минут перед следующим override, или вызови AskUserQuestion и попроси заказчика подтвердить новый bypass через «${BYPASS_PHRASE}» (счётчик НЕ сбрасывается).`,
|
|
};
|
|
}
|
|
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() {
|
|
try {
|
|
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);
|
|
} catch {
|
|
// Fail-open: any internal error must NOT block the user.
|
|
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();
|