# Router-gate v4 Stream G — Cleanup + Registration 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:** Remove 5 obsolete v3.9 enforcement hooks and register all 12 active router-gate v4 hooks in `.claude/settings.json` in block-mode, creating 5 thin wrappers for pure modules that still need them.

**Architecture:** Three layers:
1. **Pure modules** in `tools/<name>.mjs` — already created by streams A-E.
2. **Thin `enforce-<name>.mjs` wrappers** — stdin event → pure module `decide()` → `exitDecision`. Pattern lifted from existing `tools/enforce-router-gate.mjs:183-204`.
3. **`.claude/settings.json` registration** — matcher + command path + timeout. Block-mode means `exitDecision({ block: true })` exits with code 2 stopping the originating tool call.

**Tech Stack:** Node.js ESM (`.mjs`), `vitest` (jsdom env), `lefthook` pre-commit, `.claude/settings.json` schema `https://json.schemastore.org/claude-code-settings.json`.

**Reference helpers** (already present in `tools/enforce-hook-helpers.mjs`):
- `readStdin()` — read PreToolUse/PostToolUse/Stop event JSON.
- `parseEventJson(raw)` — safe JSON.parse with `{}` fallback.
- `readTranscript(event.transcript_path)` — load JSONL.
- `sessionToolUses(entries)` / `turnToolUses(entries)`.
- `exitDecision({ block, message })` — `block:true` → stderr msg + `exit(2)`; otherwise `stdout('{}')` + `exit(0)`.
- `runtimeDir()` / `sentinelPath()` / `writeSentinel()` / `readSentinel()`.

**Module API summary** (extracted from streams A/C/E source):

| Pure module | Export to call | Input | Output `.action` values |
|---|---|---|---|
| `todowrite-skill-verifier.mjs` | `verifyClaims(mentions, calls)` + `hardSyncCheck(mentions, calls)` | TodoWrite items + transcript Skill calls | `allow` \| `hard_block_next_mutating` |
| `tdd-real-test-verifier.mjs` | `verifyRealTest(testPath, editedFilesList)` | path string + edited-files string[] | `{valid: bool, reason}` |
| `self-debrief-detector.mjs` | `detectSelfDebrief(responseText, transcript, opts)` | last controller text + transcript | `allow` \| `hard_block_next_mutating` |
| `mcp-tool-classifier.mjs` | `classifyMcpTool(toolName, toolInput, ctx)` | event.tool_name + tool_input | `{result: 'allow'\|'block', reason}` |
| `decomposition-detector.mjs` | `detectDecomposition(turnToolUses, opts)` | turn entries array | `allow` \| `hard_block` |

**Skip:** `parallel-session-lock` — not implemented by Streams A-E (out of scope here). Documented as future TODO in Task 9 settings.json comment block.

**Branch strategy:** Work on `main` directly (Stream G is sequential per master plan §3). Backup branch `backup-pre-v4-cleanup` created **first**, pushed, then changes applied. Push at end only.

---

## File Structure

```
tools/
  enforce-hook-helpers.mjs              MODIFY (stub findOverride/findOverrideAttempt/loadOverrideVocab)
  enforce-hook-helpers.test.mjs         MODIFY (drop vocab-dependent assertions, keep stub-return contract)

  enforce-todowrite-skill-verifier.mjs       CREATE (Stop hook wrapper)
  enforce-todowrite-skill-verifier.test.mjs  CREATE

  enforce-tdd-real-test-verifier.mjs         CREATE (PreToolUse Edit|Write wrapper)
  enforce-tdd-real-test-verifier.test.mjs    CREATE

  enforce-self-debrief-detector.mjs          CREATE (PreToolUse mutating wrapper)
  enforce-self-debrief-detector.test.mjs     CREATE

  enforce-mcp-classification.mjs             CREATE (PreToolUse mcp__* wrapper)
  enforce-mcp-classification.test.mjs        CREATE

  enforce-decomposition-detector.mjs         CREATE (PreToolUse mutating wrapper)
  enforce-decomposition-detector.test.mjs    CREATE

  enforce-chain-recommendation.mjs           DELETE
  enforce-chain-recommendation.test.mjs      DELETE
  enforce-classifier-match.mjs               DELETE
  enforce-classifier-match.test.mjs          DELETE
  enforce-graph-first.mjs                    DELETE
  enforce-graph-first.test.mjs               DELETE
  enforce-semgrep-security.mjs               DELETE
  enforce-semgrep-security.test.mjs          DELETE
  enforce-override-limit.mjs                 DELETE
  enforce-override-limit.test.mjs            DELETE
  enforce-override-vocab.json                DELETE (if exists)

.claude/
  settings.json                          MODIFY (remove 5 v3.9 regs, add 12 v4 regs)
```

---

## Task 0: Create backup branch

**Files:** none (git operation).

- [ ] **Step 1: Create backup branch from current main**

```bash
git branch backup-pre-v4-cleanup main
```

Expected: silent success.

- [ ] **Step 2: Push backup to origin**

```bash
git push origin backup-pre-v4-cleanup
```

Expected: `* [new branch] backup-pre-v4-cleanup -> backup-pre-v4-cleanup`.

- [ ] **Step 3: Verify backup is on origin**

```bash
git ls-remote origin backup-pre-v4-cleanup
```

Expected: line with sha + `refs/heads/backup-pre-v4-cleanup`.

---

## Task 1: Stub override helpers in `enforce-hook-helpers.mjs`

The vocab-based override system is fully removed in v4 (universal vocab removal per spec §4.2). Existing call sites in deleted hooks go away in Task 7; other callers (`enforce-verify-before-push.mjs`, `enforce-tdd-gate.mjs`, `enforce-memory-coverage.mjs`, `enforce-branch-switch.mjs`) still import `findOverride` / `findOverrideAttempt` / `loadOverrideVocab`. We keep these symbols as permanent stubs so non-deleted hooks keep building.

**Files:**
- Modify: `tools/enforce-hook-helpers.mjs:197-249` (functions `loadOverrideVocab`, `findOverride`, `findOverrideAttempt`)
- Modify: `tools/enforce-hook-helpers.test.mjs` (drop vocab-dependent assertions, add stub contract tests)

- [ ] **Step 1: Write failing test for stub behaviour**

Add to `tools/enforce-hook-helpers.test.mjs` (top of file, after existing imports):

```javascript
import { describe, it, expect } from 'vitest';
import { findOverride, findOverrideAttempt, loadOverrideVocab, _resetVocabCache } from './enforce-hook-helpers.mjs';

describe('v4 override stubs', () => {
  it('loadOverrideVocab returns empty phrases array (stub)', () => {
    _resetVocabCache();
    expect(loadOverrideVocab()).toEqual({ phrases: [] });
  });
  it('findOverride always returns null (vocab removed in v4)', () => {
    _resetVocabCache();
    expect(findOverride('срочно: ремонт', 'verify-before-push')).toBe(null);
    expect(findOverride('memory dump fix it now', 'memory-coverage')).toBe(null);
    expect(findOverride('', 'anything')).toBe(null);
  });
  it('findOverrideAttempt always returns null (vocab removed in v4)', () => {
    _resetVocabCache();
    expect(findOverrideAttempt('срочно push it', 'verify-before-push')).toBe(null);
    expect(findOverrideAttempt('', 'anything')).toBe(null);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t "v4 override stubs"`
Expected: 3 tests FAIL (`findOverride` returns matching phrase object instead of null when prompt contains "срочно").

- [ ] **Step 3: Replace the three functions with stubs**

In `tools/enforce-hook-helpers.mjs`, replace lines 197–249 (the entire `loadOverrideVocab` + `findOverride` + `findOverrideAttempt` block) with:

```javascript
// v4 stubs — universal vocab override surface removed per spec §4.2.
// Keep symbols exported so callers in other hooks compile; runtime returns null/empty.
export function loadOverrideVocab(_path) {
  return { phrases: [] };
}

export function _resetVocabCache() { /* no-op, vocab disabled */ }

export function findOverride(_userPrompt, _ruleKey, _vocab) {
  return null;
}

export function findOverrideAttempt(_userPrompt, _ruleKey, _vocab) {
  return null;
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t "v4 override stubs"`
Expected: 3 tests PASS.

- [ ] **Step 5: Run full helpers test suite to verify no regressions**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs`
Expected: all PASS (or only fails relate to deleted vocab JSON file — fix those by removing assertions about phrase content).

- [ ] **Step 6: Commit**

```bash
git add tools/enforce-hook-helpers.mjs tools/enforce-hook-helpers.test.mjs
git commit -m "refactor(router-gate-v4): stub override helpers (universal vocab removed per spec §4.2)

findOverride/findOverrideAttempt/loadOverrideVocab become permanent stubs returning null/null/empty.
Non-deleted hooks (verify-before-push, tdd-gate, memory-coverage, branch-switch) still import these
symbols and need them to compile; runtime always reports 'no override'.

Stream G of router-gate v4 deployment."
```

---

## Task 2: Create `enforce-todowrite-skill-verifier.mjs` (Stop hook wrapper)

Wraps `tools/todowrite-skill-verifier.mjs::verifyClaims + hardSyncCheck`. Fires on Stop event. Reads transcript, extracts last TodoWrite items + Skill calls, blocks Stop with `{ block: true }` when hard_sync_check returns hard_block.

**Pattern reference:** `tools/enforce-router-gate.mjs:183-207` (main() shape, fail-CLOSE behaviour). For Stop hooks we use fail-open (`block: false`) because false-positive Stop block would freeze sessions.

**Files:**
- Create: `tools/enforce-todowrite-skill-verifier.mjs`
- Create: `tools/enforce-todowrite-skill-verifier.test.mjs`

- [ ] **Step 1: Write failing test**

Create `tools/enforce-todowrite-skill-verifier.test.mjs`:

```javascript
import { describe, it, expect, vi } from 'vitest';
import { decide } from './enforce-todowrite-skill-verifier.mjs';

describe('enforce-todowrite-skill-verifier decide()', () => {
  it('allows when all completed mentions actually invoked Skill', () => {
    const todoItems = [
      { content: 'invoke superpowers:brainstorming', status: 'completed' },
    ];
    const transcript = [
      { type: 'tool_use', name: 'Skill', input: { skill: 'superpowers:brainstorming' } },
    ];
    expect(decide({ todoItems, transcript }).block).toBe(false);
  });
  it('blocks when completed mention has NO matching Skill call', () => {
    const todoItems = [
      { content: 'invoke superpowers:brainstorming', status: 'completed' },
    ];
    const transcript = [];
    const r = decide({ todoItems, transcript });
    expect(r.block).toBe(true);
    expect(r.reason).toMatch(/v4\.1 TodoWrite hard sync/);
  });
  it('allows when mention is pending (not completed)', () => {
    const todoItems = [
      { content: 'invoke superpowers:brainstorming', status: 'pending' },
    ];
    expect(decide({ todoItems, transcript: [] }).block).toBe(false);
  });
  it('allows when todoItems is empty', () => {
    expect(decide({ todoItems: [], transcript: [] }).block).toBe(false);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run tools/enforce-todowrite-skill-verifier.test.mjs`
Expected: FAIL — `Cannot find module './enforce-todowrite-skill-verifier.mjs'`.

- [ ] **Step 3: Implement wrapper**

Create `tools/enforce-todowrite-skill-verifier.mjs`:

```javascript
/**
 * Stop-hook wrapper for tools/todowrite-skill-verifier.mjs.
 * Router-gate v4 spec §3.9 + v4.1 Direction 4.
 *
 * Reads transcript at Stop, extracts last TodoWrite items + Skill calls,
 * blocks next mutating (effectively: emits exit 2 from Stop hook → controller
 * sees the message; mutating-tool gate downstream re-checks the persisted sentinel).
 *
 * Fail-open on internal error (Stop must not freeze sessions).
 */
import { fileURLToPath } from 'url';
import {
  readStdin,
  parseEventJson,
  readTranscript,
  exitDecision,
} from './enforce-hook-helpers.mjs';
import { extractSkillMentions, extractSkillToolCalls, hardSyncCheck } from './todowrite-skill-verifier.mjs';

/** Find the latest TodoWrite tool_use entries in a transcript. */
export function lastTodoItems(transcript) {
  const recs = transcript || [];
  for (let i = recs.length - 1; i >= 0; i--) {
    const r = recs[i];
    if (r && r.type === 'tool_use' && r.name === 'TodoWrite') {
      const items = r.input && Array.isArray(r.input.todos) ? r.input.todos : [];
      return items;
    }
  }
  return [];
}

export function decide({ todoItems, transcript }) {
  const items = Array.isArray(todoItems) ? todoItems : [];
  const mentions = extractSkillMentions(items);
  const calls = extractSkillToolCalls(transcript || []);
  const r = hardSyncCheck(mentions, calls);
  if (r.action === 'hard_block_next_mutating') {
    return { block: true, reason: r.reason };
  }
  return { block: false, reason: null };
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    const transcript = readTranscript(event.transcript_path);
    const todoItems = lastTodoItems(transcript);
    const r = decide({ todoItems, transcript });
    if (r.block) {
      return exitDecision({ block: true, message: `[todowrite-skill-verifier] ${r.reason}` });
    }
    return exitDecision({ block: false });
  } catch {
    return exitDecision({ block: false }); // fail-open
  }
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run tools/enforce-todowrite-skill-verifier.test.mjs`
Expected: 4/4 PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-todowrite-skill-verifier.mjs tools/enforce-todowrite-skill-verifier.test.mjs
git commit -m "feat(router-gate-v4): enforce-todowrite-skill-verifier (Stop hook wrapper, §3.9 Direction 4)"
```

---

## Task 3: Create `enforce-tdd-real-test-verifier.mjs` (PreToolUse Edit|Write wrapper)

Wraps `tools/tdd-real-test-verifier.mjs::verifyRealTest`. Fires on Edit/Write of a `*.test.*` or `*.spec.*` file. If the test content lacks `expect(...)` / `it(...)` / `test(...)` or covers none of the prod files edited in this session, blocks.

**Files:**
- Create: `tools/enforce-tdd-real-test-verifier.mjs`
- Create: `tools/enforce-tdd-real-test-verifier.test.mjs`

- [ ] **Step 1: Write failing test**

Create `tools/enforce-tdd-real-test-verifier.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-tdd-real-test-verifier.mjs';

describe('enforce-tdd-real-test-verifier decide()', () => {
  it('allows real test with expect + it covering edited prod file', () => {
    const r = decide({
      filePath: 'tools/foo.test.mjs',
      content: "import { foo } from './foo.mjs';\nit('foo works', () => { expect(foo()).toBe(1); });",
      editedFiles: ['tools/foo.mjs'],
    });
    expect(r.block).toBe(false);
  });
  it('blocks test with no expect()', () => {
    const r = decide({
      filePath: 'tools/foo.test.mjs',
      content: "it('does nothing', () => { /* sentinel */ });",
      editedFiles: ['tools/foo.mjs'],
    });
    expect(r.block).toBe(true);
    expect(r.reason).toMatch(/no_expect_call/);
  });
  it('blocks test with no it/test block', () => {
    const r = decide({
      filePath: 'tools/foo.test.mjs',
      content: "expect(1).toBe(1);",
      editedFiles: ['tools/foo.mjs'],
    });
    expect(r.block).toBe(true);
    expect(r.reason).toMatch(/no_test_block/);
  });
  it('allows when filePath is not a test file (no-op)', () => {
    const r = decide({
      filePath: 'tools/foo.mjs',
      content: 'export const foo = 1;',
      editedFiles: [],
    });
    expect(r.block).toBe(false);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run tools/enforce-tdd-real-test-verifier.test.mjs`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement wrapper**

Create `tools/enforce-tdd-real-test-verifier.mjs`:

```javascript
/**
 * PreToolUse(Edit|Write) wrapper for tools/tdd-real-test-verifier.mjs.
 * Router-gate v4 spec §3.11.
 *
 * Blocks Edit/Write on a *.test.* / *.spec.* file when the proposed content
 * lacks expect() / it() OR doesn't reference any of the prod files edited
 * this session (the sentinel-gaming guard from spec §3.11).
 *
 * Fail-CLOSE: an internal error blocks (security-hook default).
 */
import { fileURLToPath } from 'url';
import { join } from 'path';
import { existsSync, readFileSync } from 'fs';
import {
  readStdin,
  parseEventJson,
  exitDecision,
  runtimeDir,
} from './enforce-hook-helpers.mjs';
import { verifyRealTestContent } from './tdd-real-test-verifier.mjs';

const TEST_FILE_RE = /\.(?:test|spec)\.[a-z0-9]+$/i;

function readEditedFiles(sessionId) {
  try {
    const p = join(runtimeDir(), `edited-files-${sessionId || 'unknown'}.json`);
    if (!existsSync(p)) return [];
    const j = JSON.parse(readFileSync(p, 'utf-8'));
    return Array.isArray(j.files) ? j.files : [];
  } catch { return []; }
}

export function decide({ filePath, content, editedFiles }) {
  const fp = String(filePath || '').replace(/\\/g, '/');
  if (!TEST_FILE_RE.test(fp)) return { block: false, reason: null };
  const r = verifyRealTestContent(content, editedFiles || []);
  if (r.valid) return { block: false, reason: null };
  return { block: true, reason: r.reason };
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    if (event.tool_name !== 'Edit' && event.tool_name !== 'Write') {
      return exitDecision({ block: false });
    }
    const filePath = event.tool_input?.file_path || '';
    const content = event.tool_input?.content || event.tool_input?.new_string || '';
    const sessionId = event.session_id || 'unknown';
    const editedFiles = readEditedFiles(sessionId);
    const r = decide({ filePath, content, editedFiles });
    if (r.block) {
      return exitDecision({
        block: true,
        message: `[tdd-real-test-verifier] proposed test file fails real-test check: ${r.reason}. Write a test that asserts behaviour (expect + it/test) and references one of the edited prod files.`,
      });
    }
    return exitDecision({ block: false });
  } catch {
    return exitDecision({ block: true, message: '[tdd-real-test-verifier] внутренняя ошибка — fail-CLOSE' });
  }
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run tools/enforce-tdd-real-test-verifier.test.mjs`
Expected: 4/4 PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-tdd-real-test-verifier.mjs tools/enforce-tdd-real-test-verifier.test.mjs
git commit -m "feat(router-gate-v4): enforce-tdd-real-test-verifier (PreToolUse Edit|Write wrapper, §3.11)"
```

---

## Task 4: Create `enforce-self-debrief-detector.mjs` (PreToolUse mutating wrapper)

Wraps `tools/self-debrief-detector.mjs::detectSelfDebrief`. Fires on mutating tools (Edit|Write|MultiEdit|Bash). Reads transcript; if last controller text matches self-debrief patterns and no `self-retrospect` / `brain-retro` Skill invoked recently — block.

**Files:**
- Create: `tools/enforce-self-debrief-detector.mjs`
- Create: `tools/enforce-self-debrief-detector.test.mjs`

- [ ] **Step 1: Write failing test**

Create `tools/enforce-self-debrief-detector.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-self-debrief-detector.mjs';

describe('enforce-self-debrief-detector decide()', () => {
  it('allows neutral controller text', () => {
    expect(decide({ controllerText: 'Implementing feature X.', transcript: [] }).block).toBe(false);
  });
  it('blocks retrospect-style text without self-retrospect skill call', () => {
    const r = decide({
      controllerText: 'Я заметил паттерн в своих ответах — generalisable lesson: ...',
      transcript: [],
    });
    expect(r.block).toBe(true);
    expect(r.reason).toMatch(/self-debrief hard-block/);
  });
  it('allows retrospect-style text when self-retrospect was invoked recently', () => {
    const r = decide({
      controllerText: 'я обобщаю опыт',
      transcript: [
        { type: 'tool_use', name: 'Skill', input: { skill: 'self-retrospect' }, turn: 1 },
      ],
    });
    expect(r.block).toBe(false);
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npx vitest run tools/enforce-self-debrief-detector.test.mjs`
Expected: FAIL — module not found.

- [ ] **Step 3: Implement wrapper**

Create `tools/enforce-self-debrief-detector.mjs`:

```javascript
/**
 * PreToolUse(Edit|Write|MultiEdit|Bash) wrapper for tools/self-debrief-detector.mjs.
 * Router-gate v4.1 spec §3.12 (NEW).
 *
 * Reads last controller text from transcript; if it matches self-debrief patterns
 * (я заметил паттерн / generalisable lesson / etc.) AND no self-retrospect or
 * brain-retro Skill in recent turns — block.
 *
 * Fail-CLOSE on internal error.
 */
import { fileURLToPath } from 'url';
import {
  readStdin,
  parseEventJson,
  readTranscript,
  exitDecision,
} from './enforce-hook-helpers.mjs';
import { detectSelfDebrief } from './self-debrief-detector.mjs';

/** Extract last controller (assistant) text block from transcript. */
export function lastControllerText(transcript) {
  const recs = transcript || [];
  for (let i = recs.length - 1; i >= 0; i--) {
    const r = recs[i];
    if (r && r.type === 'text' && r.role === 'assistant') return String(r.text || '');
    if (r && r.role === 'assistant' && typeof r.content === 'string') return r.content;
  }
  return '';
}

export function decide({ controllerText, transcript }) {
  const r = detectSelfDebrief(controllerText, transcript || []);
  if (r.action === 'hard_block_next_mutating') {
    return { block: true, reason: r.reason };
  }
  return { block: false, reason: null };
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    const mutating = ['Edit', 'Write', 'MultiEdit', 'Bash'];
    if (!mutating.includes(event.tool_name)) return exitDecision({ block: false });

    const transcript = readTranscript(event.transcript_path);
    const controllerText = lastControllerText(transcript);
    const r = decide({ controllerText, transcript });
    if (r.block) {
      return exitDecision({ block: true, message: `[self-debrief-detector] ${r.reason}` });
    }
    return exitDecision({ block: false });
  } catch {
    return exitDecision({ block: true, message: '[self-debrief-detector] внутренняя ошибка — fail-CLOSE' });
  }
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npx vitest run tools/enforce-self-debrief-detector.test.mjs`
Expected: 3/3 PASS.

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-self-debrief-detector.mjs tools/enforce-self-debrief-detector.test.mjs
git commit -m "feat(router-gate-v4): enforce-self-debrief-detector (PreToolUse mutating wrapper, §3.12 NEW)"
```

---

## Task 5: Create `enforce-mcp-classification.mjs` (PreToolUse mcp__* wrapper)

Wraps `tools/mcp-tool-classifier.mjs::classifyMcpTool`. Fires on any `mcp__*` tool call. Classifier categorises (read_only / hard_blacklist / conditional / default) and decides allow/block. Block on `hard_blacklist` or `default` (default-deny per spec §5.3).

**Pre-step:** Inspect `tools/mcp-tool-classifier.mjs` exported function names (`classifyMcpTool` vs `classify` vs other) — adjust import below if name differs.

**Files:**
- Create: `tools/enforce-mcp-classification.mjs`
- Create: `tools/enforce-mcp-classification.test.mjs`

- [ ] **Step 1: Inspect pure module export name**

Run: `grep -n "^export" tools/mcp-tool-classifier.mjs`

If the exported function is named differently than `classifyMcpTool`, substitute it everywhere below.

- [ ] **Step 2: Write failing test**

Create `tools/enforce-mcp-classification.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-mcp-classification.mjs';

describe('enforce-mcp-classification decide()', () => {
  it('allows non-mcp tools', () => {
    expect(decide({ toolName: 'Bash', toolInput: { command: 'ls' } }).block).toBe(false);
  });
  it('blocks an unknown mcp tool (default-deny)', () => {
    const r = decide({ toolName: 'mcp__unknown__doSomething', toolInput: {} });
    expect(r.block).toBe(true);
  });
});
```

- [ ] **Step 3: Run test to verify it fails**

Run: `npx vitest run tools/enforce-mcp-classification.test.mjs`
Expected: FAIL — module not found.

- [ ] **Step 4: Implement wrapper**

Create `tools/enforce-mcp-classification.mjs`:

```javascript
/**
 * PreToolUse(mcp__*) wrapper for tools/mcp-tool-classifier.mjs.
 * Router-gate v4 spec §5.3 + v4.1 G1/G12.
 *
 * Classifier categorises MCP tool calls; default-deny on unknown.
 * Fail-CLOSE on internal error.
 */
import { fileURLToPath } from 'url';
import {
  readStdin,
  parseEventJson,
  exitDecision,
} from './enforce-hook-helpers.mjs';
// NOTE: adjust the import name below if Step 1 found a different export.
import { classifyMcpTool } from './mcp-tool-classifier.mjs';

export function decide({ toolName, toolInput }) {
  const name = String(toolName || '');
  if (!name.startsWith('mcp__')) return { block: false, reason: null };
  const verdict = classifyMcpTool(name, toolInput || {}, {});
  if (verdict && verdict.result === 'block') {
    return { block: true, reason: verdict.reason || 'default-deny on unknown MCP tool' };
  }
  return { block: false, reason: null };
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    const r = decide({ toolName: event.tool_name, toolInput: event.tool_input });
    if (r.block) {
      return exitDecision({ block: true, message: `[mcp-classification] ${r.reason}` });
    }
    return exitDecision({ block: false });
  } catch {
    return exitDecision({ block: true, message: '[mcp-classification] внутренняя ошибка — fail-CLOSE' });
  }
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 5: Run test to verify it passes**

Run: `npx vitest run tools/enforce-mcp-classification.test.mjs`
Expected: 2/2 PASS. **If classifyMcpTool returns a different verdict shape**, adapt `decide()` to that shape and update the test.

- [ ] **Step 6: Commit**

```bash
git add tools/enforce-mcp-classification.mjs tools/enforce-mcp-classification.test.mjs
git commit -m "feat(router-gate-v4): enforce-mcp-classification (PreToolUse mcp__* wrapper, §5.3 + G1/G12)"
```

---

## Task 6: Create `enforce-decomposition-detector.mjs` (PreToolUse mutating wrapper)

Wraps `tools/decomposition-detector.mjs::detectDecomposition`. Fires on mutating tools. Detects when controller decomposes a single logical task into many small Edit/Write/Bash chunks to dodge upstream check; blocks when threshold exceeded.

**Pre-step:** Inspect `tools/decomposition-detector.mjs` for the actual function name and signature; adapt below.

**Files:**
- Create: `tools/enforce-decomposition-detector.mjs`
- Create: `tools/enforce-decomposition-detector.test.mjs`

- [ ] **Step 1: Inspect pure module export name and signature**

Run: `grep -n "^export" tools/decomposition-detector.mjs`

Adapt import / call below.

- [ ] **Step 2: Write failing test**

Create `tools/enforce-decomposition-detector.test.mjs`:

```javascript
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-decomposition-detector.mjs';

describe('enforce-decomposition-detector decide()', () => {
  it('allows a small number of mutating tool calls in a turn', () => {
    const turnEntries = [
      { type: 'tool_use', name: 'Edit', input: { file_path: 'a.mjs' } },
      { type: 'tool_use', name: 'Edit', input: { file_path: 'a.mjs' } },
    ];
    expect(decide({ turnEntries }).block).toBe(false);
  });
});
```

- [ ] **Step 3: Run test to verify it fails**

Run: `npx vitest run tools/enforce-decomposition-detector.test.mjs`
Expected: FAIL — module not found.

- [ ] **Step 4: Implement wrapper**

Create `tools/enforce-decomposition-detector.mjs`:

```javascript
/**
 * PreToolUse(Edit|Write|MultiEdit|Bash) wrapper for tools/decomposition-detector.mjs.
 * Router-gate v4 spec §3.8 Direction 3.
 *
 * Pure module decides whether the current turn's tool-use sequence looks like
 * a decomposed-to-evade pattern; block when detector says so.
 *
 * Fail-CLOSE on internal error.
 */
import { fileURLToPath } from 'url';
import {
  readStdin,
  parseEventJson,
  readTranscript,
  turnToolUses,
  exitDecision,
} from './enforce-hook-helpers.mjs';
import { detectDecomposition } from './decomposition-detector.mjs';

export function decide({ turnEntries }) {
  const entries = Array.isArray(turnEntries) ? turnEntries : [];
  const r = detectDecomposition(entries, {}) || {};
  if (r.action === 'hard_block') {
    return { block: true, reason: r.reason || 'decomposition-detector: pattern matches' };
  }
  return { block: false, reason: null };
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    const mutating = ['Edit', 'Write', 'MultiEdit', 'Bash'];
    if (!mutating.includes(event.tool_name)) return exitDecision({ block: false });

    const transcript = readTranscript(event.transcript_path);
    const turnEntries = turnToolUses(transcript);
    const r = decide({ turnEntries });
    if (r.block) {
      return exitDecision({ block: true, message: `[decomposition-detector] ${r.reason}` });
    }
    return exitDecision({ block: false });
  } catch {
    return exitDecision({ block: true, message: '[decomposition-detector] внутренняя ошибка — fail-CLOSE' });
  }
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
```

- [ ] **Step 5: Run test to verify it passes**

Run: `npx vitest run tools/enforce-decomposition-detector.test.mjs`
Expected: 1/1 PASS. If signature differs, update both the test and the wrapper to match the pure module.

- [ ] **Step 6: Commit**

```bash
git add tools/enforce-decomposition-detector.mjs tools/enforce-decomposition-detector.test.mjs
git commit -m "feat(router-gate-v4): enforce-decomposition-detector (PreToolUse mutating wrapper, §3.8 Direction 3)"
```

---

## Task 7: Delete 5 v3.9 hooks and the vocab file

**Files (delete):**
- `tools/enforce-chain-recommendation.mjs`
- `tools/enforce-chain-recommendation.test.mjs`
- `tools/enforce-classifier-match.mjs`
- `tools/enforce-classifier-match.test.mjs`
- `tools/enforce-graph-first.mjs`
- `tools/enforce-graph-first.test.mjs`
- `tools/enforce-semgrep-security.mjs`
- `tools/enforce-semgrep-security.test.mjs`
- `tools/enforce-override-limit.mjs`
- `tools/enforce-override-limit.test.mjs`
- `tools/enforce-override-vocab.json` (only if present — check first)

- [ ] **Step 1: Verify vocab.json presence**

Run: `ls tools/enforce-override-vocab.json 2>/dev/null || echo "absent"`
Note whether it exists.

- [ ] **Step 2: Delete the 10 (or 11) files**

```bash
git rm tools/enforce-chain-recommendation.mjs tools/enforce-chain-recommendation.test.mjs \
       tools/enforce-classifier-match.mjs tools/enforce-classifier-match.test.mjs \
       tools/enforce-graph-first.mjs tools/enforce-graph-first.test.mjs \
       tools/enforce-semgrep-security.mjs tools/enforce-semgrep-security.test.mjs \
       tools/enforce-override-limit.mjs tools/enforce-override-limit.test.mjs
# If vocab file existed in Step 1:
git rm tools/enforce-override-vocab.json
```

- [ ] **Step 3: Run full vitest tools suite (must still pass — no orphan references)**

Run:
```
npx vitest run tools/ \
  --exclude='**/worktrees/**' \
  --exclude='**/ruflo-*' \
  --exclude='**/subagent-prompt-prefix*' \
  --exclude='**/llm-judge.integration*'
```
Expected: all PASS. If any failure references a deleted file — it's a stale import; fix that file by removing the dead import.

- [ ] **Step 4: Commit**

```bash
git commit -m "chore(router-gate-v4): delete 5 obsolete v3.9 hooks + vocab.json (Stream G cleanup)

Deleted hooks superseded by v4 architecture (spec §4 behavioural pivot):
- enforce-chain-recommendation (replaced by router-gate decide())
- enforce-classifier-match (replaced by skill-scope-verifier Direction 2)
- enforce-graph-first (replaced by decide() classification)
- enforce-semgrep-security (folded into normative-content-rules + per-tool LLM-judge)
- enforce-override-limit (universal vocab removal §4.2)
- enforce-override-vocab.json (vocab abolished)"
```

---

## Task 8: Update `.claude/settings.json` — remove 5 v3.9 regs, add 12 v4 regs in block-mode

**Files:**
- Modify: `.claude/settings.json`

**Plan:** Read the current file (already done at planning time — see baseline below), then apply edits via multiple `Edit` tool calls because `settings.json` is JSON (no comments allowed) and the changes are scattered across the `hooks.PreToolUse`, `hooks.PostToolUse`, and `hooks.Stop` arrays.

**Baseline (current state, lines 39–262):** five v3.9 hook blocks present at:
- PreToolUse[3] (lines 69–78) — `enforce-chain-recommendation` — REMOVE
- PreToolUse[4] (lines 79–88) — `enforce-override-limit` — REMOVE
- PreToolUse[7] (lines 119–128) — `enforce-semgrep-security` — REMOVE
- Stop[3] (lines 203–211) — `enforce-classifier-match` — REMOVE
- Stop[4] (lines 212–220) — `enforce-graph-first` — REMOVE

- [ ] **Step 1: Make a working backup of settings.json**

```bash
cp .claude/settings.json .claude/settings.json.pre-streamG.bak
```

(Local-only safety net; the file is git-tracked so the backup is just for fast manual revert during editing.)

- [ ] **Step 2: Remove the 5 v3.9 hook blocks**

Use the `Edit` tool 5 times. Match each removed block as `old_string` and provide `new_string` as empty (or as the comma-handling neighbour if removal would leave a dangling comma — JSON is strict).

After removing all 5: the remaining `PreToolUse` array has the original entries minus the three v3.9 ones; `Stop` array minus the two v3.9 ones.

- [ ] **Step 3: Validate JSON syntax**

Run: `node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf-8')); console.log('OK')"`
Expected: `OK`.

- [ ] **Step 4: Add the 12 v4 hook registrations**

Insert new blocks in the relevant arrays. Order inside each array does not affect runtime behaviour; group by event for readability.

**PreToolUse additions** (append after the existing block-with-`matcher:"Bash"`):

```json
{
  "matcher": "Bash",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-router-gate.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "PowerShell",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-powershell-gate.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "Edit|Write|MultiEdit",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-normative-content-rules.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "Edit|Write",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-tdd-real-test-verifier.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "Edit|Write|MultiEdit|Bash",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-self-debrief-detector.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "Edit|Write|MultiEdit|Bash",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-decomposition-detector.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "Edit|Write|MultiEdit|Bash",
  "hooks": [
    { "type": "command", "command": "node tools/llm-judge-per-tool.mjs", "timeout": 30 }
  ]
},
{
  "matcher": "AskUserQuestion",
  "hooks": [
    { "type": "command", "command": "node tools/askuser-cosmetic-detector.mjs", "timeout": 5 }
  ]
},
{
  "matcher": "mcp__.*",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-mcp-classification.mjs", "timeout": 5 }
  ]
}
```

**PostToolUse additions:**

```json
{
  "matcher": "Task",
  "hooks": [
    { "type": "command", "command": "node tools/enforce-subagent-return-scanner.mjs", "timeout": 10 }
  ]
}
```

**Stop additions:**

```json
{
  "hooks": [
    { "type": "command", "command": "node tools/enforce-todowrite-skill-verifier.mjs", "timeout": 5 }
  ]
},
{
  "hooks": [
    { "type": "command", "command": "node tools/llm-judge-response-scan.mjs", "timeout": 30 }
  ]
}
```

- [ ] **Step 5: Re-validate JSON syntax**

Run: `node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf-8')); console.log('OK')"`
Expected: `OK`.

- [ ] **Step 6: Delete the local backup**

```bash
rm .claude/settings.json.pre-streamG.bak
```

- [ ] **Step 7: Commit**

```bash
git add .claude/settings.json
git commit -m "feat(router-gate-v4): register 12 v4 hooks in .claude/settings.json (block-mode)

Removed 5 v3.9 registrations (chain-recommendation, classifier-match, graph-first,
semgrep-security, override-limit). Added 12 v4 hooks:

PreToolUse:
- enforce-router-gate (Bash)
- enforce-powershell-gate (PowerShell)
- enforce-normative-content-rules (Edit|Write|MultiEdit)
- enforce-tdd-real-test-verifier (Edit|Write)
- enforce-self-debrief-detector (Edit|Write|MultiEdit|Bash)
- enforce-decomposition-detector (Edit|Write|MultiEdit|Bash)
- llm-judge-per-tool (Edit|Write|MultiEdit|Bash)
- askuser-cosmetic-detector (AskUserQuestion)
- enforce-mcp-classification (mcp__.*)

PostToolUse:
- enforce-subagent-return-scanner (Task)

Stop:
- enforce-todowrite-skill-verifier
- llm-judge-response-scan

TODO follow-up: parallel-session-lock (SessionStart hook) — pure module not yet
implemented by streams A-E; tracked separately.

Stream G of router-gate v4 deployment, last step before user-run smokes."
```

---

## Task 9: Full regression + push

- [ ] **Step 1: Full vitest tools suite**

Run:
```
npx vitest run tools/ \
  --exclude='**/worktrees/**' \
  --exclude='**/ruflo-*' \
  --exclude='**/subagent-prompt-prefix*' \
  --exclude='**/llm-judge.integration*'
```

Expected: 0 failed, all PASS. Note the totals — expect approximately +30 to +50 new tests from Tasks 2–6 (~7 tests each).

- [ ] **Step 2: Confirm verify-pass sentinel updated to result=pass**

Run: `cat ~/.claude/runtime/verify-pass-*.json | tail -20`
Expected: most recent file shows `"result": "pass"`.

- [ ] **Step 3: Push all Stream G commits**

```bash
git push origin main
```

Expected: pre-push lefthook runs gitleaks-full-history + lychee-links + status-md + graphify-safe-update. All GREEN. Remote updates from `60104433` to the new HEAD (8 new commits: backup-branch is a separate ref, no main commit).

- [ ] **Step 4: Verify on origin**

```bash
git log --oneline origin/main -10
```

Expected: top 8 commits are the new Stream G commits (Tasks 1–8).

---

## Self-Review

**Spec coverage check (against `docs/superpowers/specs/2026-05-29-router-gate-v4-design.md` and `docs/superpowers/plans/2026-05-29-router-gate-v4-master.md` §Stream G):**

| Master plan item | Task |
|---|---|
| Delete enforce-chain-recommendation.mjs + test | Task 7 |
| Delete enforce-classifier-match.mjs + test | Task 7 |
| Delete enforce-graph-first.mjs + test | Task 7 |
| Delete enforce-semgrep-security.mjs + test | Task 7 |
| Delete enforce-override-limit.mjs + test | Task 7 |
| Delete enforce-override-vocab.json | Task 7 |
| Stub findOverride/findOverrideAttempt/loadOverrideVocab | Task 1 |
| Register enforce-router-gate.mjs | Task 8 (already exists from Stream B) |
| Register enforce-powershell-gate.mjs | Task 8 (already exists from Stream B) |
| Register enforce-normative-content-rules.mjs | Task 8 (already exists from Stream D) |
| Register enforce-subagent-return-scanner.mjs | Task 8 (already exists from Stream E) |
| Register enforce-tdd-real-test-verifier.mjs | Task 3 + Task 8 |
| Register enforce-self-debrief-detector.mjs | Task 4 + Task 8 |
| Register enforce-todowrite-skill-verifier.mjs | Task 2 + Task 8 |
| Register enforce-askuser-cosmetic-detector.mjs | Task 8 (pure module already has main(), register directly) |
| Register enforce-llm-judge-per-tool.mjs | Task 8 (pure module already has main(), register directly) |
| Register enforce-llm-judge-response-scan.mjs | Task 8 (pure module already has main(), register directly) |
| Register enforce-mcp-classification.mjs | Task 5 + Task 8 |
| Register enforce-decomposition-detector.mjs | Task 6 + Task 8 |
| Register enforce-parallel-session-lock.mjs | **Out of scope** — pure module not implemented by Streams A-E. Tracked as TODO in Task 8 commit message. |

All deletable items and all wireable items are covered. The skipped item is explicitly out of scope.

**Placeholder scan:** Each Task block contains explicit file paths, explicit commands, explicit expected outputs, and complete code in the steps that create code. No `TBD` / `add appropriate error handling` / `similar to Task N`. The Tasks 5 and 6 Step 1 instruction to inspect export names is concrete (one grep command) and the wrapper code below uses an explicit symbol that the engineer can adjust if the grep finds a different name — that's a real instruction, not a placeholder.

**Type consistency:** `decide()` returns `{ block: boolean, reason: string|null }` consistently across Tasks 2–6. The `event` field names (`event.tool_name`, `event.tool_input`, `event.transcript_path`, `event.session_id`) match the existing PreToolUse hook event schema used by `enforce-router-gate.mjs`. `extractSkillMentions` / `extractSkillToolCalls` / `hardSyncCheck` names match the actual `tools/todowrite-skill-verifier.mjs` exports. `detectSelfDebrief` matches. `verifyRealTestContent` matches.

The plan is complete. Saved to `docs/superpowers/plans/2026-05-29-router-gate-v4-stream-G-cleanup-register.md`.
