# Router/Hooks Fixes — Phase 2 (Override Hard-Limit) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Жёсткий hard-block на 6-ю overide-фразу одного типа за сегодняшний календарный день (А.1 / 5 per-phrase). Закрывает Phase 2 кандидата #6 brain-retro #9 + self-retrospect 28.05 (87 override-фраз за день).

**Architecture:** Новый PreToolUse-хук `tools/enforce-override-limit.mjs` на mutating tools (matcher `Edit|Write|MultiEdit|NotebookEdit|Bash|Task|Agent`). Хук парсит текст текущего промпта пользователя, ищет в нём любую из 7 фраз из `enforce-override-vocab.json`, считает по `~/.claude/runtime/override-usage.jsonl` сегодняшние срабатывания этой фразы. Если найдено ≥5 → блокирует tool call с сообщением Клоду: «вызови AskUserQuestion, спроси разрешение продолжить». Bypass через специальную фразу `лимит снят` в новом prompt'е (one-shot, счётчик не сбрасывается).

**Tech Stack:** Node.js (.mjs), vitest. Использует существующий vocab + JSONL без модификаций.

---

## Контекст

- **Self-retrospect 28.05 finding:** 87 override-фраз за один день (`recovery` 38, `ремонт инфраструктуры` 26, `без скилов` 17, `срочно` 6). Заказчик помечен это как симптом, не нормальный паттерн.
- **Существующий monitor (warn-only):** `computeOverrideUsageBlock` в `enforce-override-monitor.mjs` уже считает per-phrase per-day и ставит ⚠️ в STATUS.md при ≥5/день. Видно но не блокирует.
- **Existing PreToolUse hook pattern:** `enforce-chain-recommendation.mjs`, `enforce-classifier-match.mjs`, `enforce-tdd-gate.mjs` — все читают prompt из `transcript_path` либо `hook_event.prompt`, выдают block-JSON в stdout.
- **JSONL формат:** `{ts: ISO, session_id: UUID, rule: string, phrase: string}` — одна строка на каждое override-срабатывание. Already populated by other enforce-* hooks.

## Дизайн-выбор (зафиксирован заказчиком)

- **A.1 (per-phrase):** счётчик отдельный для каждой из 7 фраз.
- **Порог 5/день per phrase.** 6-я и далее этой же фразы → блок.
- **Сброс:** в 00:00 локальной даты (`ts.slice(0,10)` сравнение, как в `enforce-override-monitor.mjs`).
- **Bypass:** магическая фраза `лимит снят` в prompt'е снимает блок для ОДНОГО prompt'а; счётчик НЕ обнуляется.
- **Hard-block, не soft-warn.** Tool call не проходит до AskUserQuestion + явного user response.

---

## File Structure

| Файл | Responsibility | Действие |
|---|---|---|
| `tools/enforce-override-limit.mjs` | PreToolUse-hook: парсинг prompt + counting + block-decision. Экспорт чистых функций для теста. | **Create** |
| `tools/enforce-override-limit.test.mjs` | vitest тесты на счётчик + парсинг + bypass + структура output. | **Create** |
| `.claude/settings.json` | Регистрация хука в `hooks.PreToolUse` массиве с matcher для mutating tools. | **Modify** |
| `tools/enforce-override-vocab.json` | Без изменений — используется как read-only словарь. | — |
| `~/.claude/runtime/override-usage.jsonl` | Без изменений — читается как input. | — |

---

## Task 1: Pure module — counting + decision logic

**Files:**

- Create: `tools/enforce-override-limit.mjs`
- Create: `tools/enforce-override-limit.test.mjs`

### Step 1.1: Failing tests

Create `tools/enforce-override-limit.test.mjs`:

```js
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('лимит снят');
  });
});
```

### Step 1.2: Run tests — should FAIL

Run: `npx vitest run tools/enforce-override-limit.test.mjs 2>&1 | tail -15`
Expected: FAIL — module doesn't exist.

### Step 1.3: Implement `tools/enforce-override-limit.mjs`

```js
// 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 } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

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).
if (import.meta.url === `file://${process.argv[1].replaceAll('\\', '/')}`) {
  main().catch(e => {
    // Fail-open: any internal error must NOT block the user. Log to stderr.
    console.error('[enforce-override-limit] internal error:', e.message);
    process.exit(0);
  });
}
```

### Step 1.4: Run tests — should PASS

Run: `npx vitest run tools/enforce-override-limit.test.mjs 2>&1 | tail -15`
Expected: ALL 16 tests PASS.

### Step 1.5: Commit Task 1

```bash
git add tools/enforce-override-limit.mjs tools/enforce-override-limit.test.mjs
git commit -m "$(cat <<'EOF'
feat(enforce): override-limit hook (Phase 2 #6) — pure module + tests

Adds tools/enforce-override-limit.mjs as PreToolUse hook implementing
hard-block on 6th+ usage of same override-phrase within one calendar day
(threshold 5 per-phrase). Bypass via «лимит снят» in current prompt
(one-shot, counter not reset).

Pure exports: countTodayUsage, findPhrasesInPrompt, shouldBlock,
buildBlockOutput, VOCAB, THRESHOLD, BYPASS_PHRASE.

Closes brain-retro #9 candidate 6 (logic only — hook registration in Task 2).
EOF
)"
```

---

## Task 2: Register hook in `.claude/settings.json`

**Files:**

- Modify: `.claude/settings.json` (hooks.PreToolUse array)

### Step 2.1: Read current hook registration pattern

Run: `node -e "const j=require('./.claude/settings.json'); console.log(JSON.stringify(j.hooks?.PreToolUse?.slice(0,3), null, 2))"`
Expected: see existing PreToolUse entries with `matcher` + `hooks` (command via node).

### Step 2.2: Add new entry to PreToolUse

Find the `hooks.PreToolUse` array. Add one new entry **at the same nesting level** as existing enforce-chain-recommendation / enforce-tdd-gate entries:

```json
{
  "matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task|Agent",
  "hooks": [
    {
      "type": "command",
      "command": "node tools/enforce-override-limit.mjs",
      "timeout": 5
    }
  ]
}
```

Use Read to get current file → Edit (NOT Write — preserve other entries).

### Step 2.3: Validate JSON

Run: `node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf-8'))" && echo OK`
Expected: OK (no parse error).

### Step 2.4: Verify hook appears in registration

Run: `node -e "const j=require('./.claude/settings.json'); const e=(j.hooks?.PreToolUse||[]).find(h=>JSON.stringify(h).includes('enforce-override-limit')); console.log(e?'FOUND':'MISSING')"`
Expected: `FOUND`.

### Step 2.5: Commit Task 2

```bash
git add .claude/settings.json
git commit -m "$(cat <<'EOF'
feat(settings): register enforce-override-limit PreToolUse hook (Phase 2 #6)

Wires tools/enforce-override-limit.mjs into PreToolUse for mutating tools
matcher Edit|Write|MultiEdit|NotebookEdit|Bash|Task|Agent.

Activates the hard-limit logic from previous commit. From now: 6th use
of same override-phrase per day will block mutating tools until bypass
or new day.
EOF
)"
```

---

## Task 3: E2E integration test + smoke

**Files:**

- Modify: `tools/enforce-override-limit.test.mjs` (add e2e block)

### Step 3.1: Failing E2E test

Add to bottom of `tools/enforce-override-limit.test.mjs`:

```js
import { execFileSync } from 'child_process';
import { writeFileSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';

describe('CLI e2e', () => {
  let tmpDir;
  beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'ovrl-')); });
  afterEach(() => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} });

  it('writes block JSON when threshold exceeded', () => {
    // We cannot mock ~/.claude/runtime/override-usage.jsonl easily without env-injection.
    // Skip this e2e if log file is unavailable or too small in the test env.
    // Instead, verify CLI exits 0 always (fail-open behavior).
    const input = JSON.stringify({ prompt: 'обычный prompt без override' });
    const out = execFileSync('node', ['tools/enforce-override-limit.mjs'], {
      input,
      encoding: 'utf-8',
      timeout: 5000,
    });
    // No override phrase → no block JSON in stdout.
    expect(out.trim()).toBe('');
  });

  it('silent pass when CLI given empty stdin', () => {
    const out = execFileSync('node', ['tools/enforce-override-limit.mjs'], {
      input: '',
      encoding: 'utf-8',
      timeout: 5000,
    });
    expect(out.trim()).toBe('');
  });
});
```

Need `beforeEach`/`afterEach` import — add to existing import line:

```js
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
```

### Step 3.2: Run tests — should PASS

Run: `npx vitest run tools/enforce-override-limit.test.mjs 2>&1 | tail -15`
Expected: ALL PASS.

### Step 3.3: Smoke test — invoke hook manually with synthetic prompt

Run:

```bash
node -e "console.log(JSON.stringify({prompt:'обычный prompt'}))" | node tools/enforce-override-limit.mjs
```

Expected: empty output (no block).

Run:

```bash
node -e "console.log(JSON.stringify({prompt:'recovery опять'}))" | node tools/enforce-override-limit.mjs
```

Expected: if today's `recovery` count >= 5 → JSON block output; else empty. (NB: at test time today's count may already be ≥5 based on session — that's expected, validates the hook works.)

### Step 3.4: Full tools sweep

Run: `npx vitest run --exclude=".claude/**" --exclude=".claude.pre-ruflo.bak/**" --exclude="app/**" --exclude="tools/ruflo-*" --exclude="tools/subagent-prompt-prefix*" 2>&1 | tail -10`
Expected: ALL tests PASS, 0 regression.

### Step 3.5: Commit Task 3

```bash
git add tools/enforce-override-limit.test.mjs
git commit -m "$(cat <<'EOF'
test(enforce-override-limit): add CLI e2e tests (Phase 2 #6)

Verifies CLI exits cleanly on empty stdin and on prompt without override-
phrase. Block-JSON path is tested via the pure shouldBlock() function;
e2e CLI test confirms wiring without depending on per-machine JSONL state.
EOF
)"
```

---

## F: Final verification + push

### Step F.1: Full tools sweep (regression)

Run: `npx vitest run --exclude=".claude/**" --exclude=".claude.pre-ruflo.bak/**" --exclude="app/**" --exclude="tools/ruflo-*" --exclude="tools/subagent-prompt-prefix*" 2>&1 | tail -10`
Expected: PASS, 0 regression.

### Step F.2: Verify-record sentinel

Run: `node tools/enforce-verify-record.mjs pass`
Expected: sentinel written.

### Step F.3: Push (requires user consent — explicit ask before pushing)

```bash
git fetch origin && git rebase origin/main && git push origin main
```

NB: rebase may conflict with hook-regenerated STATUS.md (same pattern as Phase 1 push). Resolve per Phase 1 procedure.

---

## Roadmap — оставшиеся фазы

| Фаза | Задачи |
|---|---|
| Phase 3 | #7 / #1 / #8 / #10 — classifier improvements (separate plan) |
| Phase 4 | #9 Semgrep hook + #2 chain-hook measurement (separate plan) |
| Phase 5 | #4 cost-tracker (separate plan) |

---

## Self-Review

**1. Spec coverage:** Phase 2 plan covers candidate #6 — hard-block override-limit per-phrase 5/day. Bypass phrase `лимит снят` is one-shot per user choice. Phases 3-5 explicitly out of scope.

**2. Placeholder scan:** All steps contain full code/commands. No TBD/TODO. Task 3 has acknowledged-uncertainty about JSONL test env, addressed by testing fail-open behavior instead of fragile log mocking — explicit design choice, not placeholder.

**3. Type consistency:**

- `findPhrasesInPrompt(prompt)` → `string[]`
- `countTodayUsage(rawLog, phrase, now)` → `number`
- `shouldBlock(prompt, rawLog, now)` → `{block: bool, phrase?: string, todayCount?: number, bypass?: bool}`
- `buildBlockOutput({phrase, todayCount})` → `{decision: 'block', reason: string}`
- Constants: `THRESHOLD = 5`, `BYPASS_PHRASE = 'лимит снят'`, `VOCAB = string[]` (7 entries).

All consistent between Task 1 tests, Task 1 implementation, and Task 3 e2e.

---

## Метаданные

- **Версия:** 1.0 от 2026-05-28.
- **Источник:** brain-retro #9 (`docs/observer/notes/2026-05-28-brain-retro-9.md`) + self-retrospect 28.05.
- **User design choice:** A.1 / 5 per-phrase / hard-block / bypass via `лимит снят`.
- **Discipline:** реализация привычки №1 (override-дисциплина) из self-retrospect.
- **Не правит нормативку** — only `tools/` + `.claude/settings.json`.
