Files
portal/tools/enforce-override-limit.mjs
T
Дмитрий 2b23a1f210 feat(override-limit): add per-rate-window check (5 events / 10 min)
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>
2026-05-28 17:41:28 +03:00

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();