# Router-gate Hard Wall 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:** Заменить 5 PreToolUse-хуков + vocab.json на единый `tools/enforce-router-gate.mjs` который физически блокирует controller от mutating tools без явного одобрения заказчика, через 4 поведения decision-flow + AskUserQuestion answer parsing + side-channel file mechanisms для S5/S8 controller-writable signal closure + 8 audit-driven critical fixes.

**Architecture:** Single PreToolUse-хук `matcher: ""` (все tools) + PostToolUse handler для chain_step progression / file-watcher tracking / git-commit-success detection. Pure decision-функции в `tools/router-gate-decide.mjs` + thin I/O wrapper в `tools/enforce-router-gate.mjs`. State persistence через 10 JSON/JSONL файлов в `~/.claude/runtime/*` (все protected per §3.1). Subagent inheritance via env vars + protected hardcoded-path inheritance file. Subagent BLOCKED protocol через out-of-band block-file `subagent-block-<tool-use-id>.json` derived из harness-assigned `tool_use_id`. Dangerous git operations через AskUserQuestion-gate с `approve_git_operation` записью в askuser-decisions.jsonl + one-shot consume.

**Tech Stack:** Node.js 22+ (ES modules `.mjs`), vitest 4 для unit/integration tests, `proper-lockfile` npm package для cross-platform file locks, Windows Server 2022 native, PostgreSQL 16 (не затронут), Pravila §15.1 subagent rules (Sonnet/Opus only для git tasks), test runner `npx vitest run` без worktree-каталогов.

**Source spec:** [`docs/superpowers/specs/2026-05-29-router-gate-hard-wall-design-condensed.md`](../specs/2026-05-29-router-gate-hard-wall-design-condensed.md) (commit 71b07e52, audit-integrated)
**Audit report:** [`docs/superpowers/audits/2026-05-29-router-gate-condensed-adversarial-audit.md`](../audits/2026-05-29-router-gate-condensed-adversarial-audit.md)

---

## File Structure

### New files (modules)

| Path | Responsibility |
|---|---|
| `tools/router-gate-decide.mjs` | Pure decision functions: `decide()` + 4 behaviour resolvers + `nodeMatches()` + `SKILL_BASH_ALLOW` mapping. Stateless. |
| `tools/router-gate-bash.mjs` | Pure Bash tokenizer + whitelist/blacklist matcher + sub-shell sweep + per-arg path-deny + file-watcher logic |
| `tools/router-gate-askuser.mjs` | Pure AskUserQuestion answer parser + 7 interpretation classes (stop/specific/direct/freeform/no-match/git-operation/inflight) + counter |
| `tools/router-gate-path.mjs` | Path normalization: UNC strip + 8.3 expand + resolve + realpath + $VAR fail-CLOSE + case-fold |
| `tools/router-gate-state.mjs` | I/O wrapper: read/write router-state, chain-state, askuser-decisions, gate-decisions, etc. Atomic writes via proper-lockfile |
| `tools/router-gate-static-scan.mjs` | Static content scanner for executable scripts: fs.write/exec patterns + glob-aware AskUser + 1-level imports + vitest config scan |
| `tools/router-gate-quality.mjs` | AskUserQuestion question quality detector: missing-stop block + leading + length-ratio + first-option bias + off-topic |
| `tools/enforce-router-gate.mjs` | Main PreToolUse hook entry: reads state, calls decide(), writes decisions log, hands off to PostToolUse handler |
| `tools/router-gate-post.mjs` | PostToolUse handler: chain_step++ on success + file-watcher edit-tracking + git-commit-success watcher reset |
| `tools/router-gate-subagent.mjs` | Subagent gate logic: env inheritance read + block-file write + success-marker file write |
| `tools/router-gate-config.mjs` | Gate config loader with hardcoded defaults fallback (closes audit D-8) |

### New tests (vitest)

Mirror structure under `tools/*.test.mjs` for each new module. Plus integration tests at `tools/router-gate-integration.test.mjs`.

### Modified files

| Path | What changes |
|---|---|
| `tools/enforce-hook-helpers.mjs` | `findOverride` / `findOverrideAttempt` / `loadOverrideVocab` → stubs returning `null` / `null` / `{phrases: []}` |
| `tools/subagent-prompt-prefix.mjs` | Set env vars `CLAUDE_PARENT_SESSION_ID` / `CLAUDE_GATE_INHERIT` / `CLAUDE_INHERITANCE_FILE` + write inheritance file |
| `tools/enforce-branch-switch.mjs` | Complete rewrite: read askuser-decisions for `approve_git_operation` + exact match + one-shot consume + 5-min window |
| `tools/enforce-prompt-injection.mjs` | Handle empty findOverride return (closes audit D-9) |
| `tools/brain-retro-analyzer.mjs` | Add buckets Table 11/12/13 (router-gate decisions / approval patterns / lockout incidents) |
| `.claude/skills/brain-retro/SKILL.md` | MANDATORY DIGITAL ANALYSIS bumped 11 → 13 tables |
| `.claude/settings.json` | Add `enforce-router-gate.mjs` registration (PreToolUse + PostToolUse); remove 5 deleted hooks registrations |

### Deleted files

| Path | Notes |
|---|---|
| `tools/enforce-chain-recommendation.mjs` + test | Replaced by router-gate Поведение 3 |
| `tools/enforce-classifier-match.mjs` + test | Replaced by router-gate Поведение 2; `nodeMatches()` migrated to `router-gate-decide.mjs` |
| `tools/enforce-graph-first.mjs` + test | Removed (gate handles via router recommendation Поведение 2/3) |
| `tools/enforce-semgrep-security.mjs` + test | Removed (gate handles via router recommendation) |
| `tools/enforce-override-limit.mjs` + test | Removed (vocab.json deleted, no overrides to count) |
| `tools/enforce-override-vocab.json` | Removed (7 phrases obsolete) |

### New state files at `~/.claude/runtime/*` (created at runtime, schemas in spec §10.2)

`chain-state-<sess>.json` / `askuser-decisions-<sess>.jsonl` / `router-gate-decisions.jsonl` / `subagent-inheritance-<task-id>.json` / `subagent-block-<tool-use-id>.json` / `subagent-success-<tool-use-id>.json` / `edited-files-<sess>.json` / `coverage-hint-<sess>.json` / `gate-errors.jsonl` / `gate-config.json`

---

## Pre-flight (BEFORE Phase 1)

### Task 0: Create feature branch + worktree (optional)

**Files:** None (git operation)

- [ ] **Step 1: Create feature branch**

```bash
git checkout -b feat/router-gate-hard-wall
```

- [ ] **Step 2: Verify branch + clean tree**

Run: `git status && git branch --show-current`
Expected: `nothing to commit`, branch `feat/router-gate-hard-wall`

- [ ] **Step 3: Optional — create isolated worktree**

If concurrent Claude sessions risk parallel-branch collision (per `feedback_subagent_git_reliability.md`):

```bash
git worktree add ../worktrees/router-gate-hard-wall feat/router-gate-hard-wall
cd ../worktrees/router-gate-hard-wall
```

If skipping worktree: stay on main checkout.

---

## Phase 1 — Pure decision module foundation

### Task 1: Create `nodeMatches()` migration from enforce-classifier-match.mjs

**Files:**
- Create: `tools/router-gate-decide.mjs`
- Create: `tools/router-gate-decide.test.mjs`
- Reference: `tools/enforce-classifier-match.mjs:42-66` (existing `nodeMatches` to migrate)

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

`tools/router-gate-decide.test.mjs`:

```js
import { describe, it, expect } from 'vitest';
import { nodeMatches } from './router-gate-decide.mjs';

describe('nodeMatches', () => {
  it('matches #NN to node.id', () => {
    expect(nodeMatches('#19', { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' })).toBe(true);
  });

  it('matches superpowers:X to canonical slug', () => {
    expect(nodeMatches('superpowers:writing-plans', { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' })).toBe(true);
  });

  it('matches by name', () => {
    expect(nodeMatches('writing-plans', { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' })).toBe(true);
  });

  it('rejects mismatch', () => {
    expect(nodeMatches('#20', { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' })).toBe(false);
  });
});
```

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

```bash
npx vitest run tools/router-gate-decide.test.mjs
```

Expected: FAIL with `Cannot find module './router-gate-decide.mjs'`

- [ ] **Step 3: Write minimal implementation**

`tools/router-gate-decide.mjs`:

```js
/**
 * Compare router recommendation (e.g. "#19", "superpowers:writing-plans", "writing-plans")
 * with a registry node (id/slug/name). Returns true if any match.
 */
export function nodeMatches(recommendation, node) {
  if (!recommendation || !node) return false;
  return (
    recommendation === node.id ||
    recommendation === node.slug ||
    recommendation === node.name
  );
}
```

- [ ] **Step 4: Run test to verify pass**

```bash
npx vitest run tools/router-gate-decide.test.mjs
```

Expected: PASS 4/4

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): nodeMatches() pure function for recommendation/node match"
```

---

### Task 2: Detector for direct invocation (Поведение 1) — strict whitelist + source restriction

**Files:**
- Modify: `tools/router-gate-decide.mjs` — add `detectDirectInvocation()`
- Modify: `tools/router-gate-decide.test.mjs`

**Audit fix:** CRITICAL-1 — detector проверяет только `user_message_type: "prompt"` (organic root), не `askuser_answer`.

- [ ] **Step 1: Write failing tests covering 4 patterns + source restriction**

Add to `tools/router-gate-decide.test.mjs`:

```js
import { detectDirectInvocation } from './router-gate-decide.mjs';

describe('detectDirectInvocation', () => {
  const registry = [
    { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' },
    { name: 'subagent-driven-development', id: '#56', slug: 'superpowers:subagent-driven-development' },
  ];

  it('matches slash-command at start', () => {
    const result = detectDirectInvocation({ text: '/brain-retro', user_message_type: 'prompt' }, registry);
    expect(result.matched).toBe(true);
    expect(result.target).toBe('/brain-retro');
  });

  it('matches вызови Skill(X) literal', () => {
    const result = detectDirectInvocation({ text: 'вызови Skill(superpowers:writing-plans)', user_message_type: 'prompt' }, registry);
    expect(result.matched).toBe(true);
  });

  it('matches используй #N', () => {
    const result = detectDirectInvocation({ text: 'используй #19', user_message_type: 'prompt' }, registry);
    expect(result.matched).toBe(true);
    expect(result.target).toBe('#19');
  });

  it('matches делай <exact-name>', () => {
    const result = detectDirectInvocation({ text: 'делай subagent-driven-development', user_message_type: 'prompt' }, registry);
    expect(result.matched).toBe(true);
  });

  it('matches case-insensitive Cyrillic', () => {
    const result = detectDirectInvocation({ text: 'СДЕЛАЙ Writing-Plans', user_message_type: 'prompt' }, registry);
    expect(result.matched).toBe(true);
  });

  it('NOT match продолжай', () => {
    expect(detectDirectInvocation({ text: 'продолжай', user_message_type: 'prompt' }, registry).matched).toBe(false);
  });

  it('NOT match делай как считаешь', () => {
    expect(detectDirectInvocation({ text: 'делай как считаешь нужным', user_message_type: 'prompt' }, registry).matched).toBe(false);
  });

  it('NOT match unknown skill name', () => {
    const result = detectDirectInvocation({ text: 'делай foo-bar-not-in-registry', user_message_type: 'prompt' }, registry);
    expect(result.matched).toBe(false);
    expect(result.stale_registry).toBe('foo-bar-not-in-registry');
  });

  it('SOURCE RESTRICTION: ignore askuser_answer source (closes CRITICAL-1)', () => {
    const result = detectDirectInvocation({ text: 'делай subagent-driven-development', user_message_type: 'askuser_answer' }, registry);
    expect(result.matched).toBe(false);
    expect(result.reason).toBe('source_not_organic_prompt');
  });
});
```

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

```bash
npx vitest run tools/router-gate-decide.test.mjs -t detectDirectInvocation
```

Expected: FAIL with `detectDirectInvocation is not a function`

- [ ] **Step 3: Implement `detectDirectInvocation()`**

Append to `tools/router-gate-decide.mjs`:

```js
const SLASH_RE = /^\/[a-z0-9_-]+(?:\s|$)/i;
const SKILL_CALL_RE = /(?:вызови|примени)\s+Skill\(([^)]+)\)/iu;
const HASH_RE = /использ(?:уй|уйте)\s+(#\d+)/iu;
const MORPHOLOGY_RE = /(?:делай|сделай|вызови|примени|используй)\s+([a-z0-9:_-]+)/iu;

/**
 * Detects direct invocation in user message.
 * @param {Object} message - { text: string, user_message_type: 'prompt' | 'askuser_answer' | other }
 * @param {Array<Object>} registry - List of nodes from docs/registry/nodes.yaml
 * @returns {{ matched: boolean, target?: string, stale_registry?: string, reason?: string }}
 */
export function detectDirectInvocation(message, registry) {
  // SOURCE RESTRICTION (audit CRITICAL-1): only organic root prompt triggers direct invocation
  if (message.user_message_type !== 'prompt') {
    return { matched: false, reason: 'source_not_organic_prompt' };
  }

  const text = message.text || '';

  // Pattern 1: slash command
  const slashMatch = text.match(SLASH_RE);
  if (slashMatch) {
    return { matched: true, target: slashMatch[0].trim(), pattern: 'slash' };
  }

  // Pattern 2: вызови Skill(X)
  const skillMatch = text.match(SKILL_CALL_RE);
  if (skillMatch) {
    return { matched: true, target: skillMatch[1].trim(), pattern: 'skill_literal' };
  }

  // Pattern 3: используй #N
  const hashMatch = text.match(HASH_RE);
  if (hashMatch) {
    return { matched: true, target: hashMatch[1], pattern: 'hash' };
  }

  // Pattern 4: morphology + exact skill name
  const morphMatch = text.match(MORPHOLOGY_RE);
  if (morphMatch) {
    const name = morphMatch[1].toLowerCase();
    const found = registry.find(n =>
      n.name?.toLowerCase() === name ||
      n.slug?.toLowerCase() === name
    );
    if (found) {
      return { matched: true, target: name, pattern: 'morphology' };
    }
    // Stale registry case (CRITICAL-N2 from earlier audit)
    return { matched: false, stale_registry: name };
  }

  return { matched: false };
}
```

- [ ] **Step 4: Run test to verify pass**

```bash
npx vitest run tools/router-gate-decide.test.mjs -t detectDirectInvocation
```

Expected: PASS 9/9

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): detectDirectInvocation with source restriction (audit CRITICAL-1)"
```

---

### Task 3: Decide() skeleton — 4 behaviour resolver

**Files:**
- Modify: `tools/router-gate-decide.mjs` — add `decide()` orchestrator + 4 behaviour branches
- Modify: `tools/router-gate-decide.test.mjs`

- [ ] **Step 1: Write failing tests for `decide()` behaviour selection**

Add to test file:

```js
import { decide } from './router-gate-decide.mjs';

describe('decide() behaviour selection', () => {
  const baseState = {
    router_state: { recommended_node: null, recommended_chain: [] },
    chain_state: { chain_active: [], chain_step: 0 },
    turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false },
    last_user_message: { text: 'something', user_message_type: 'prompt' },
    registry: [],
  };

  it('Behaviour 4 (silence) when no rec, no chain, no askuser', () => {
    const result = decide({ tool_name: 'Edit', tool_input: { file_path: 'foo.txt' }, ...baseState });
    expect(result.behaviour_branch).toBe('4_silence');
    expect(result.decision).toBe('block');
  });

  it('Behaviour 4 allows safe baseline (Read)', () => {
    const result = decide({ tool_name: 'Read', tool_input: { file_path: 'foo.txt' }, ...baseState });
    expect(result.decision).toBe('allow');
  });

  it('Behaviour 2 (single rec) blocks mutating when no askuser/skill', () => {
    const state = { ...baseState, router_state: { recommended_node: '#19', recommended_chain: [] }, registry: [{ id: '#19', slug: 'superpowers:writing-plans' }] };
    const result = decide({ tool_name: 'Edit', tool_input: {}, ...state });
    expect(result.behaviour_branch).toBe('2_single_rec');
    expect(result.decision).toBe('block');
  });

  it('Behaviour 3 (chain) when rec_chain non-empty', () => {
    const state = { ...baseState, router_state: { recommended_node: null, recommended_chain: ['#55', '#19'] } };
    const result = decide({ tool_name: 'Edit', tool_input: {}, ...state });
    expect(result.behaviour_branch).toBe('3_chain');
  });

  it('Behaviour 1 (direct) when user prompt has direct invocation', () => {
    const state = { ...baseState, last_user_message: { text: '/brain-retro', user_message_type: 'prompt' } };
    const result = decide({ tool_name: 'Edit', tool_input: {}, ...state });
    expect(result.behaviour_branch).toBe('1_direct_invocation');
  });
});
```

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

```bash
npx vitest run tools/router-gate-decide.test.mjs -t "decide()"
```

Expected: FAIL

- [ ] **Step 3: Implement `decide()` orchestrator**

Append to `tools/router-gate-decide.mjs`:

```js
const SAFE_BASELINE_TOOLS = new Set([
  'Read', 'Grep', 'Glob', 'LS', 'TodoWrite', 'AskUserQuestion',
  'ListMcpResourcesTool', 'ReadMcpResourceTool'
]);

export function decide(input) {
  const {
    tool_name, tool_input,
    router_state, chain_state, turn_flags, last_user_message, registry,
  } = input;

  // Step 0: detect direct invocation
  const direct = detectDirectInvocation(last_user_message, registry);

  // Always allow safe baseline tools regardless of behaviour
  if (SAFE_BASELINE_TOOLS.has(tool_name)) {
    const behaviour = direct.matched ? '1_direct_invocation'
      : (router_state.recommended_chain.length > 0 ? '3_chain'
      : (router_state.recommended_node ? '2_single_rec' : '4_silence'));
    return { decision: 'allow', behaviour_branch: behaviour, reason: 'safe_baseline' };
  }

  // Behaviour 1 — direct invocation
  if (direct.matched) {
    return resolveBehaviour1({ direct, tool_name, tool_input, registry });
  }

  // Behaviour 3 — chain
  if (router_state.recommended_chain.length > 0 || chain_state.chain_active.length > 0) {
    return resolveBehaviour3({ tool_name, tool_input, router_state, chain_state, turn_flags, registry });
  }

  // Behaviour 2 — single recommendation
  if (router_state.recommended_node) {
    return resolveBehaviour2({ tool_name, tool_input, router_state, turn_flags, registry });
  }

  // Behaviour 4 — silence
  return resolveBehaviour4({ tool_name, tool_input, turn_flags });
}

// Stubs (filled in next tasks)
function resolveBehaviour1({ direct, tool_name, tool_input, registry }) {
  return { decision: 'allow', behaviour_branch: '1_direct_invocation', reason: 'direct_invocation_placeholder' };
}
function resolveBehaviour2({ tool_name, tool_input, router_state, turn_flags, registry }) {
  return { decision: 'block', behaviour_branch: '2_single_rec', reason: 'placeholder' };
}
function resolveBehaviour3({ tool_name, tool_input, router_state, chain_state, turn_flags, registry }) {
  return { decision: 'block', behaviour_branch: '3_chain', reason: 'placeholder' };
}
function resolveBehaviour4({ tool_name, tool_input, turn_flags }) {
  return { decision: 'block', behaviour_branch: '4_silence', reason: 'router_silent' };
}
```

- [ ] **Step 4: Run test to verify pass**

```bash
npx vitest run tools/router-gate-decide.test.mjs -t "decide()"
```

Expected: PASS 5/5

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): decide() orchestrator + 4 behaviour skeleton"
```

---

### Task 4: Поведение 1 (direct invocation) full implementation

**Files:** Modify `tools/router-gate-decide.mjs` — fill `resolveBehaviour1()`

- [ ] **Step 1: Write failing tests for Behaviour 1 mutations**

```js
describe('resolveBehaviour1', () => {
  const registry = [
    { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' },
  ];
  const directMatch = { matched: true, target: '#19', pattern: 'hash' };

  it('allows tool matching direct invocation target', () => {
    const state = {
      tool_name: 'Skill', tool_input: { skill_name: 'superpowers:writing-plans' },
      last_user_message: { text: 'используй #19', user_message_type: 'prompt' },
      router_state: { recommended_node: null, recommended_chain: [] },
      chain_state: { chain_active: [], chain_step: 0 },
      turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: true },
      registry,
    };
    const result = decide(state);
    expect(result.decision).toBe('allow');
  });

  it('blocks tool NOT matching direct invocation target', () => {
    const state = {
      tool_name: 'Edit', tool_input: { file_path: 'foo.txt' },
      last_user_message: { text: 'используй #19', user_message_type: 'prompt' },
      router_state: { recommended_node: null, recommended_chain: [] },
      chain_state: { chain_active: [], chain_step: 0 },
      turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: true },
      registry,
    };
    const result = decide(state);
    expect(result.decision).toBe('block');
    expect(result.reason).toMatch(/direct invocation/);
  });

  it('stale registry triggers mandatory AskUser', () => {
    const state = {
      tool_name: 'Edit', tool_input: {},
      last_user_message: { text: 'делай foo-bar-unknown', user_message_type: 'prompt' },
      router_state: { recommended_node: null, recommended_chain: [] },
      chain_state: { chain_active: [], chain_step: 0 },
      turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false },
      registry,
    };
    const result = decide(state);
    expect(result.decision).toBe('block');
    expect(result.reason).toMatch(/foo-bar-unknown/);
    expect(result.reason).toMatch(/AskUserQuestion/);
  });
});
```

- [ ] **Step 2: Run test — expect FAIL** (current stub allows everything)

- [ ] **Step 3: Fill `resolveBehaviour1()`**

Replace stub:

```js
function resolveBehaviour1({ direct, tool_name, tool_input, registry }) {
  // Stale registry (CRITICAL-N2) — block mutating, require mandatory AskUser
  if (direct.stale_registry) {
    return {
      decision: 'block',
      behaviour_branch: '1_direct_invocation',
      reason: `Пользователь указал skill \`${direct.stale_registry}\`, но он не найден в реестре. Требуется AskUserQuestion с явным выбором.`,
    };
  }

  // Match tool against direct invocation target
  const target = direct.target;
  const isMatching = toolMatchesTarget(tool_name, tool_input, target, registry);

  if (isMatching) {
    return { decision: 'allow', behaviour_branch: '1_direct_invocation', reason: `direct invocation matched: ${target}` };
  }

  return {
    decision: 'block',
    behaviour_branch: '1_direct_invocation',
    reason: `заказчик указал ${target} через direct invocation, текущий tool — ${tool_name}`,
  };
}

function toolMatchesTarget(tool_name, tool_input, target, registry) {
  // Slash commands always match via Skill tool
  if (target.startsWith('/')) {
    return tool_name === 'Skill' && tool_input.skill_name?.startsWith(target.replace(/^\//, ''));
  }

  // Skill tool
  if (tool_name === 'Skill') {
    const skillName = tool_input.skill_name || '';
    return target === skillName || skillName.endsWith(`:${target}`) || skillName === `superpowers:${target}`;
  }

  // Task tool with subagent_type
  if (tool_name === 'Task') {
    const subType = tool_input.subagent_type || '';
    const node = registry.find(n => n.id === target || n.slug === target || n.name === target);
    if (node && (node.slug === subType || node.name === subType)) return true;
  }

  return false;
}
```

- [ ] **Step 4: Run test — verify PASS 3/3**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): Behaviour 1 (direct invocation) + stale registry handling"
```

---

### Task 5: Поведение 2 (single recommendation) full implementation

**Files:** Modify `tools/router-gate-decide.mjs` — fill `resolveBehaviour2()`

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

```js
describe('resolveBehaviour2', () => {
  const registry = [{ name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' }];
  const base = {
    router_state: { recommended_node: '#19', recommended_chain: [] },
    chain_state: { chain_active: [], chain_step: 0 },
    last_user_message: { text: 'plan something', user_message_type: 'prompt' },
    registry,
  };

  it('blocks mutating Edit when askuser_called=false, skill_invoked=false', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('block');
    expect(result.reason).toMatch(/Router рекомендовал #19/);
  });

  it('allows Skill matching rec_node + unlocks turn', () => {
    const result = decide({ ...base, tool_name: 'Skill', tool_input: { skill_name: 'superpowers:writing-plans' }, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
  });

  it('allows any tool after askuser_called=true', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, turn_flags: { askuser_called_this_turn: true, askuser_count_this_turn: 1, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
  });

  it('allows any tool after skill_invoked_matching=true', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: true, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
  });
});
```

- [ ] **Step 2: Run test — expect FAIL**

- [ ] **Step 3: Fill `resolveBehaviour2()`**

```js
function resolveBehaviour2({ tool_name, tool_input, router_state, turn_flags, registry }) {
  const rec = router_state.recommended_node;
  const node = registry.find(n => n.id === rec || n.slug === rec || n.name === rec);

  // After skill match → unlocked
  if (turn_flags.skill_invoked_matching) {
    return { decision: 'allow', behaviour_branch: '2_single_rec', reason: 'skill_invoked_unlock' };
  }

  // After askuser_called → unlocked (assumes user approval; §4.5 parses what was approved)
  if (turn_flags.askuser_called_this_turn) {
    return { decision: 'allow', behaviour_branch: '2_single_rec', reason: 'askuser_called_unlock' };
  }

  // Tool matches recommendation → allow + unlock
  const isMatching = (tool_name === 'Skill' && node && (tool_input.skill_name === node.slug || tool_input.skill_name === node.name)) ||
    (tool_name === 'Task' && node && (tool_input.subagent_type === node.slug || tool_input.subagent_type === node.name));
  if (isMatching) {
    return { decision: 'allow', behaviour_branch: '2_single_rec', reason: `matched recommendation ${rec}` };
  }

  return {
    decision: 'block',
    behaviour_branch: '2_single_rec',
    reason: `Router рекомендовал ${rec}, вызови AskUserQuestion с предложениями для одобрения action`,
  };
}
```

- [ ] **Step 4: Run test — verify PASS 4/4**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): Behaviour 2 (single recommendation) full"
```

---

### Task 6: Поведение 3 (chain) full implementation

**Files:** Modify `tools/router-gate-decide.mjs` — fill `resolveBehaviour3()`

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

```js
describe('resolveBehaviour3', () => {
  const registry = [
    { name: 'discovery-interview', id: '#55', slug: '' },
    { name: 'writing-plans', id: '#19', slug: 'superpowers:writing-plans' },
    { name: 'subagent-driven-development', id: '#56', slug: 'superpowers:subagent-driven-development' },
  ];
  const base = {
    router_state: { recommended_node: null, recommended_chain: ['#55', '#19', '#56'] },
    last_user_message: { text: 'do feature', user_message_type: 'prompt' },
    registry,
  };

  it('blocks mutating at chain_step 0 before matching skill', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, chain_state: { chain_active: ['#55', '#19', '#56'], chain_step: 0 }, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('block');
    expect(result.reason).toMatch(/шаг.+#55/);
  });

  it('allows Skill matching chain[chain_step]', () => {
    const result = decide({ ...base, tool_name: 'Skill', tool_input: { skill_name: 'discovery-interview' }, chain_state: { chain_active: ['#55', '#19', '#56'], chain_step: 0 }, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
  });

  it('allows after askuser_called=true', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, chain_state: { chain_active: ['#55', '#19', '#56'], chain_step: 1 }, turn_flags: { askuser_called_this_turn: true, askuser_count_this_turn: 1, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
  });

  it('chain_step >= chain_active.length → allow (chain complete)', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, chain_state: { chain_active: ['#55', '#19', '#56'], chain_step: 3 }, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
    expect(result.reason).toMatch(/chain complete/);
  });
});
```

- [ ] **Step 2: Run test — expect FAIL**

- [ ] **Step 3: Fill `resolveBehaviour3()`**

```js
function resolveBehaviour3({ tool_name, tool_input, router_state, chain_state, turn_flags, registry }) {
  const active = chain_state.chain_active.length ? chain_state.chain_active : router_state.recommended_chain;
  const step = chain_state.chain_step;

  if (step >= active.length) {
    return { decision: 'allow', behaviour_branch: '3_chain', reason: 'chain complete, clear chain-state' };
  }

  const expected = active[step];
  const expectedNode = registry.find(n => n.id === expected || n.slug === expected || n.name === expected);

  if (turn_flags.askuser_called_this_turn) {
    return { decision: 'allow', behaviour_branch: '3_chain', reason: 'askuser_called_unlock' };
  }

  if (turn_flags.skill_invoked_matching) {
    return { decision: 'allow', behaviour_branch: '3_chain', reason: 'expected_skill_invoked_unlock' };
  }

  const isMatching = (tool_name === 'Skill' && expectedNode && (tool_input.skill_name === expectedNode.slug || tool_input.skill_name === expectedNode.name)) ||
    (tool_name === 'Task' && expectedNode && (tool_input.subagent_type === expectedNode.slug || tool_input.subagent_type === expectedNode.name));
  if (isMatching) {
    return { decision: 'allow', behaviour_branch: '3_chain', reason: `matched chain step ${step}: ${expected}` };
  }

  return {
    decision: 'block',
    behaviour_branch: '3_chain',
    reason: `Цепочка [${active.join(', ')}], сейчас ждём шаг ${step} (${expected}). Вызови AskUserQuestion для одобрения`,
  };
}
```

- [ ] **Step 4: Run test — verify PASS 4/4**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): Behaviour 3 (chain) full implementation"
```

---

### Task 7: Поведение 4 (silence) full implementation

**Files:** Modify `tools/router-gate-decide.mjs` — fill `resolveBehaviour4()`

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

```js
describe('resolveBehaviour4', () => {
  const base = {
    router_state: { recommended_node: null, recommended_chain: [] },
    chain_state: { chain_active: [], chain_step: 0 },
    last_user_message: { text: 'unclear task', user_message_type: 'prompt' },
    registry: [],
  };

  it('blocks Edit when askuser_called=false', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('block');
    expect(result.reason).toMatch(/Роутер молчит/);
  });

  it('allows any tool after askuser_called=true', () => {
    const result = decide({ ...base, tool_name: 'Edit', tool_input: {}, turn_flags: { askuser_called_this_turn: true, askuser_count_this_turn: 1, skill_invoked_matching: false, is_direct_invocation: false } });
    expect(result.decision).toBe('allow');
  });
});
```

- [ ] **Step 2: Run test — expect FAIL** (current stub blocks regardless of askuser)

- [ ] **Step 3: Fill `resolveBehaviour4()`**

```js
function resolveBehaviour4({ tool_name, tool_input, turn_flags }) {
  if (turn_flags.askuser_called_this_turn) {
    return { decision: 'allow', behaviour_branch: '4_silence', reason: 'askuser_called_unlock' };
  }
  return {
    decision: 'block',
    behaviour_branch: '4_silence',
    reason: `Роутер молчит. Вызови AskUserQuestion с 1/2+/0 форматом по количеству подходящих скилов`,
  };
}
```

- [ ] **Step 4: Run test — verify PASS 2/2**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): Behaviour 4 (silence) full implementation"
```

---

### Task 8: AskUserQuestion answer parser — basic 5 classifications

**Files:**
- Create: `tools/router-gate-askuser.mjs`
- Create: `tools/router-gate-askuser.test.mjs`

- [ ] **Step 1: Write failing tests for 5 classifications**

```js
import { describe, it, expect } from 'vitest';
import { parseAskUserAnswer } from './router-gate-askuser.mjs';

describe('parseAskUserAnswer', () => {
  it('classifies стоп → stop_remain_locked', () => {
    const r = parseAskUserAnswer({ chosen_label: 'стоп — не делать', chosen_text: '' });
    expect(r.gate_interpretation).toBe('stop_remain_locked');
  });

  it('classifies cancel/stop/отмена variants', () => {
    expect(parseAskUserAnswer({ chosen_label: 'cancel', chosen_text: '' }).gate_interpretation).toBe('stop_remain_locked');
    expect(parseAskUserAnswer({ chosen_label: 'отмена', chosen_text: '' }).gate_interpretation).toBe('stop_remain_locked');
  });

  it('classifies "делать X" → approve_specific_tool', () => {
    const r = parseAskUserAnswer({ chosen_label: 'делать writing-plans', chosen_text: '' });
    expect(r.gate_interpretation).toBe('approve_specific_tool');
    expect(r.approved_action_pattern).toMatch(/writing-plans/);
  });

  it('classifies "direct без скила" → approve_direct_no_skill', () => {
    const r = parseAskUserAnswer({ chosen_label: 'продолжить без скила', chosen_text: '' });
    expect(r.gate_interpretation).toBe('approve_direct_no_skill');
  });

  it('classifies freeform unclear → no_match_remain_blocked', () => {
    const r = parseAskUserAnswer({ chosen_label: 'давай', chosen_text: 'хорошо' });
    expect(r.gate_interpretation).toBe('no_match_remain_blocked');
  });
});
```

- [ ] **Step 2: Run test — FAIL** (module missing)

- [ ] **Step 3: Implement basic parser**

```js
const STOP_RE = /\b(стоп|отмена|не делать|ничего|остановись|cancel|stop)\b/iu;
const DIRECT_RE = /\b(direct|без скила|без skill|напрямую)\b/iu;
const SPECIFIC_RE = /\b(делать|выполнить|использовать|approve)\s+([^\s,.;]+)/iu;

export function parseAskUserAnswer({ chosen_label, chosen_text }) {
  const text = `${chosen_label || ''} ${chosen_text || ''}`.trim();

  if (STOP_RE.test(text)) {
    return { gate_interpretation: 'stop_remain_locked', approved_tool: null, approved_action_pattern: null };
  }

  if (DIRECT_RE.test(text)) {
    return { gate_interpretation: 'approve_direct_no_skill', approved_tool: 'Edit|Write|MultiEdit', approved_action_pattern: null };
  }

  const specific = text.match(SPECIFIC_RE);
  if (specific) {
    return {
      gate_interpretation: 'approve_specific_tool',
      approved_tool: 'Skill',
      approved_action_pattern: specific[2],
    };
  }

  return { gate_interpretation: 'no_match_remain_blocked', approved_tool: null, approved_action_pattern: null };
}
```

- [ ] **Step 4: Run test — PASS 5/5**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-askuser.mjs tools/router-gate-askuser.test.mjs
git commit -m "feat(router-gate): parseAskUserAnswer 5 classifications"
```

---

### Task 9: AskUser answer parser — git-pattern row для S8 closure

**Files:** Modify `tools/router-gate-askuser.mjs` + tests

**Spec ref:** §4.5 git-pattern row + audit closes S8 controller-writable signal class

- [ ] **Step 1: Write failing tests for git-pattern recognition**

```js
describe('parseAskUserAnswer git-pattern', () => {
  it('recognizes "выполнить git rebase main" → approve_git_operation', () => {
    const r = parseAskUserAnswer({ chosen_label: 'Да — выполнить git rebase main', chosen_text: '' });
    expect(r.gate_interpretation).toBe('approve_git_operation');
    expect(r.approved_action_pattern).toBe('git rebase main');
    expect(r.approved_tool).toBe('Bash');
    expect(r.consumed).toBe(false);
  });

  it('matches git reset --hard HEAD~1', () => {
    const r = parseAskUserAnswer({ chosen_label: 'approve git reset --hard HEAD~1', chosen_text: '' });
    expect(r.gate_interpretation).toBe('approve_git_operation');
    expect(r.approved_action_pattern).toBe('git reset --hard HEAD~1');
  });

  it('matches all 9 dangerous operations', () => {
    const ops = ['rebase main', 'reset --hard HEAD', 'clean -fd', 'checkout -- file.txt', 'branch -D feat', 'push --force origin main', 'stash drop stash@{0}', 'cherry-pick abc123', 'revert HEAD'];
    for (const op of ops) {
      const r = parseAskUserAnswer({ chosen_label: `делать git ${op}`, chosen_text: '' });
      expect(r.gate_interpretation, `for op git ${op}`).toBe('approve_git_operation');
    }
  });
});
```

- [ ] **Step 2: Run test — FAIL**

- [ ] **Step 3: Add git-pattern recognition (BEFORE the specific tool check, since "делать git rebase" would match specific)**

Insert into parser between `STOP_RE` check and `SPECIFIC_RE`:

```js
const GIT_DANGEROUS_RE = /\b(делать|выполнить|approve|Да — выполнить|делай)\s+(git\s+(?:rebase|reset|clean|checkout\s+--|branch\s+-[DfF]|push\s+--force|stash\s+drop|cherry-pick|revert)[^\n]*?)(?:\s*$|[,.;\n])/iu;

// In parseAskUserAnswer, AFTER STOP_RE, BEFORE SPECIFIC_RE:
const gitMatch = text.match(GIT_DANGEROUS_RE);
if (gitMatch) {
  return {
    gate_interpretation: 'approve_git_operation',
    approved_tool: 'Bash',
    approved_action_pattern: gitMatch[2].trim(),
    consumed: false,
  };
}
```

- [ ] **Step 4: Run test — PASS 3/3**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-askuser.mjs tools/router-gate-askuser.test.mjs
git commit -m "feat(router-gate): AskUser git-pattern recognition for S8 closure"
```

---

### Task 10: AskUser counter + fail-CLOSE message

**Files:** Modify `tools/router-gate-askuser.mjs` + tests

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

```js
import { checkAskUserCounter, buildFailCloseMessage } from './router-gate-askuser.mjs';

describe('AskUser counter', () => {
  it('returns ok when count < 2', () => {
    expect(checkAskUserCounter({ askuser_count_this_turn: 1, max: 2 }).ok).toBe(true);
  });

  it('returns fail-CLOSE when count >= max', () => {
    const r = checkAskUserCounter({ askuser_count_this_turn: 2, max: 2 });
    expect(r.ok).toBe(false);
    expect(r.reason).toMatch(/2\/2/);
  });
});

describe('buildFailCloseMessage', () => {
  it('contains 4 explicit options', () => {
    const msg = buildFailCloseMessage();
    expect(msg).toMatch(/стоп/);
    expect(msg).toMatch(/делай <skill-name>/);
    expect(msg).toMatch(/продолжить без скила/);
    expect(msg).toMatch(/slash-команда/);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export function checkAskUserCounter({ askuser_count_this_turn, max }) {
  if (askuser_count_this_turn >= max) {
    return {
      ok: false,
      reason: `AskUserQuestion ambiguity loop detected (${askuser_count_this_turn}/${max} limit exhausted в этом turn'е). ${buildFailCloseMessage()}`,
    };
  }
  return { ok: true };
}

export function buildFailCloseMessage() {
  return `Чтобы продолжить — отправь новый prompt с одной из явных фраз:
  • "стоп" / "отмена" — gate останется заблокированным
  • "делай <skill-name>" — direct invocation конкретного скила
  • "продолжить без скила" — direct без skill'а (Edit/Write разрешены, не Bash)
  • slash-команда типа "/brain-retro" — direct skill через slash
Счётчик AskUserQuestion сбросится на новом prompt'е.`;
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-askuser.mjs tools/router-gate-askuser.test.mjs
git commit -m "feat(router-gate): AskUser counter + fail-CLOSE message with 4 options"
```

---

### Task 11: Chain-state persistence + TTL + reset (organic-prompt-only — SHOULD-FIX-4)

**Files:**
- Create: `tools/router-gate-chain.mjs`
- Create: `tools/router-gate-chain.test.mjs`

**Spec ref:** §3 chain-state semantics + audit SHOULD-FIX-4 (reset only from organic prompt, не chosen_label)

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

```js
import { describe, it, expect } from 'vitest';
import { isChainExpired, applyChainProgression, shouldResetChain } from './router-gate-chain.mjs';

describe('isChainExpired', () => {
  it('expired after 24h', () => {
    const state = { initialized_at: '2026-05-28T00:00:00.000Z' };
    expect(isChainExpired(state, new Date('2026-05-29T01:00:00.000Z'))).toBe(true);
  });
  it('not expired within 24h', () => {
    const state = { initialized_at: '2026-05-28T20:00:00.000Z' };
    expect(isChainExpired(state, new Date('2026-05-28T21:00:00.000Z'))).toBe(false);
  });
});

describe('shouldResetChain (SHOULD-FIX-4: organic prompt only)', () => {
  it('resets on "новая задача" in organic prompt', () => {
    expect(shouldResetChain({ text: 'новая задача', user_message_type: 'prompt' })).toBe(true);
  });
  it('does NOT reset on "новая задача" in askuser_answer (SHOULD-FIX-4)', () => {
    expect(shouldResetChain({ text: 'новая задача', user_message_type: 'askuser_answer' })).toBe(false);
  });
  it('resets on "сброс контекста"', () => {
    expect(shouldResetChain({ text: 'сделай сброс контекста и продолжи', user_message_type: 'prompt' })).toBe(true);
  });
  it('does NOT reset on продолжай', () => {
    expect(shouldResetChain({ text: 'продолжай делать', user_message_type: 'prompt' })).toBe(false);
  });
});

describe('applyChainProgression', () => {
  it('increments chain_step on success', () => {
    const state = { chain_active: ['#55', '#19'], chain_step: 0 };
    const r = applyChainProgression(state, { tool_use_id: 't1', matched: true, success: true });
    expect(r.chain_step).toBe(1);
  });
  it('does NOT increment on failed skill', () => {
    const state = { chain_active: ['#55', '#19'], chain_step: 0 };
    const r = applyChainProgression(state, { tool_use_id: 't1', matched: true, success: false });
    expect(r.chain_step).toBe(0);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
const RESET_PHRASES = [/\bновая\s+задача\b/iu, /\bсброс\s+контекста\b/iu, /\bзабудь\s+предыдущее\b/iu];

export function isChainExpired(chain_state, now) {
  if (!chain_state.initialized_at) return false;
  const initMs = new Date(chain_state.initialized_at).getTime();
  return (now.getTime() - initMs) > 24 * 60 * 60 * 1000;
}

export function shouldResetChain(message) {
  // SHOULD-FIX-4: only organic root prompt, not askuser chosen_label
  if (message.user_message_type !== 'prompt') return false;
  return RESET_PHRASES.some(re => re.test(message.text || ''));
}

export function applyChainProgression(chain_state, { tool_use_id, matched, success }) {
  if (matched && success && chain_state.chain_step < chain_state.chain_active.length) {
    return { ...chain_state, chain_step: chain_state.chain_step + 1, last_step_at: new Date().toISOString() };
  }
  return chain_state;
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-chain.mjs tools/router-gate-chain.test.mjs
git commit -m "feat(router-gate): chain-state TTL + organic-prompt-only reset (SHOULD-FIX-4)"
```

---

### Task 12: Decision logger + askuser-decisions logger

**Files:**
- Create: `tools/router-gate-state.mjs`
- Create: `tools/router-gate-state.test.mjs`

- [ ] **Step 1: Write failing tests with temp fs**

```js
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { logGateDecision, logAskUserDecision, readAskUserDecisions } from './router-gate-state.mjs';

const TMP = path.join(os.tmpdir(), `router-gate-state-test-${Date.now()}`);

beforeEach(() => fs.mkdirSync(TMP, { recursive: true }));
afterEach(() => fs.rmSync(TMP, { recursive: true, force: true }));

describe('logGateDecision', () => {
  it('appends entry to gate-decisions.jsonl', () => {
    const file = path.join(TMP, 'gate-decisions.jsonl');
    logGateDecision(file, { ts: '2026-05-29T00:00:00Z', tool_name: 'Edit', decision: 'block', reason: 'no rec', session_id: 'abc', behaviour_branch: '4_silence' });
    const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
    expect(lines).toHaveLength(1);
    expect(JSON.parse(lines[0]).tool_name).toBe('Edit');
  });
});

describe('logAskUserDecision', () => {
  it('appends entry to askuser-decisions jsonl', () => {
    const file = path.join(TMP, 'askuser-decisions.jsonl');
    logAskUserDecision(file, {
      ts: '2026-05-29T00:00:00Z', session_id: 'abc', turn_id: 't1',
      question: 'Что делать?', options: ['A', 'B'], chosen_label: 'A',
      gate_interpretation: 'stop_remain_locked', approved_tool: null,
      approved_action_pattern: null, consumed: false,
    });
    const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
    expect(JSON.parse(lines[0]).gate_interpretation).toBe('stop_remain_locked');
  });
});

describe('readAskUserDecisions', () => {
  it('returns parsed entries', () => {
    const file = path.join(TMP, 'askuser-decisions.jsonl');
    logAskUserDecision(file, { gate_interpretation: 'approve_git_operation', approved_action_pattern: 'git rebase main', consumed: false, ts: '2026-05-29T00:00:00Z' });
    const entries = readAskUserDecisions(file);
    expect(entries).toHaveLength(1);
    expect(entries[0].approved_action_pattern).toBe('git rebase main');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement (atomic JSONL append)**

```js
import fs from 'node:fs';

export function logGateDecision(file, entry) {
  fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
}

export function logAskUserDecision(file, entry) {
  fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
}

export function readAskUserDecisions(file) {
  if (!fs.existsSync(file)) return [];
  return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(line => {
    try { return JSON.parse(line); } catch { return null; }
  }).filter(Boolean);
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-state.mjs tools/router-gate-state.test.mjs
git commit -m "feat(router-gate): JSONL logger for gate-decisions + askuser-decisions"
```

---

### Task 13: Atomic JSON state write (tmp+rename) + read

**Files:** Modify `tools/router-gate-state.mjs` + tests

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

```js
import { writeStateAtomic, readState } from './router-gate-state.mjs';

describe('writeStateAtomic', () => {
  it('writes via tmp+rename', () => {
    const file = path.join(TMP, 'chain-state.json');
    writeStateAtomic(file, { chain_active: ['#19'], chain_step: 0 });
    expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ chain_active: ['#19'], chain_step: 0 });
    expect(fs.existsSync(file + '.tmp')).toBe(false);
  });

  it('readState returns null when missing', () => {
    expect(readState(path.join(TMP, 'nonexistent.json'))).toBeNull();
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export function writeStateAtomic(file, data) {
  const tmp = file + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
  fs.renameSync(tmp, file);
}

export function readState(file) {
  if (!fs.existsSync(file)) return null;
  try {
    return JSON.parse(fs.readFileSync(file, 'utf8'));
  } catch {
    return { __malformed: true };
  }
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-state.mjs tools/router-gate-state.test.mjs
git commit -m "feat(router-gate): atomic state write (tmp+rename) + malformed detection"
```

---

### Task 14: Gate config loader with hardcoded defaults (closes audit D-8)

**Files:**
- Create: `tools/router-gate-config.mjs`
- Create: `tools/router-gate-config.test.mjs`

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

```js
import { loadGateConfig, DEFAULTS } from './router-gate-config.mjs';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

describe('loadGateConfig', () => {
  it('returns DEFAULTS when file missing (D-8)', () => {
    const cfg = loadGateConfig('/nonexistent/path/gate-config.json');
    expect(cfg).toEqual(DEFAULTS);
  });

  it('merges file values over defaults', () => {
    const tmp = path.join(os.tmpdir(), `gc-${Date.now()}.json`);
    fs.writeFileSync(tmp, JSON.stringify({ max_decision_time_ms: 5000 }));
    const cfg = loadGateConfig(tmp);
    expect(cfg.max_decision_time_ms).toBe(5000);
    expect(cfg.state_cache_ttl_ms).toBe(DEFAULTS.state_cache_ttl_ms);
    fs.unlinkSync(tmp);
  });

  it('enforces floor for max_decision_time_ms (D-8)', () => {
    const tmp = path.join(os.tmpdir(), `gc-${Date.now()}.json`);
    fs.writeFileSync(tmp, JSON.stringify({ max_decision_time_ms: 1 }));
    const cfg = loadGateConfig(tmp);
    expect(cfg.max_decision_time_ms).toBeGreaterThanOrEqual(500);
    fs.unlinkSync(tmp);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export const DEFAULTS = Object.freeze({
  max_decision_time_ms: 2000,
  state_cache_ttl_ms: 5000,
  transcript_lookback_turns: 5,
  max_askuser_per_turn: 2,
  max_parallel_subagents: 3,
  chain_state_ttl_hours: 24,
  lock_timeout_ms: 1000,
});

const FLOORS = Object.freeze({
  max_decision_time_ms: 500,
  state_cache_ttl_ms: 1000,
  transcript_lookback_turns: 3,
  max_askuser_per_turn: 2,
  max_parallel_subagents: 1,
  chain_state_ttl_hours: 1,
  lock_timeout_ms: 500,
});

import fs from 'node:fs';

export function loadGateConfig(file) {
  if (!fs.existsSync(file)) return { ...DEFAULTS };
  try {
    const data = JSON.parse(fs.readFileSync(file, 'utf8'));
    const merged = { ...DEFAULTS, ...data };
    // Enforce floors
    for (const k of Object.keys(FLOORS)) {
      if (typeof merged[k] === 'number' && merged[k] < FLOORS[k]) {
        merged[k] = FLOORS[k];
      }
    }
    return merged;
  } catch {
    return { ...DEFAULTS };
  }
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-config.mjs tools/router-gate-config.test.mjs
git commit -m "feat(router-gate): config loader with floors + defaults fallback (audit D-8)"
```

---

## Phase 1.1 — Bash content parser (audit-integrated)

### Task 15: Bash tokenizer (split by ;/&&/||/|/&)

**Files:**
- Create: `tools/router-gate-bash.mjs`
- Create: `tools/router-gate-bash.test.mjs`

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

```js
import { tokenizeBash } from './router-gate-bash.mjs';

describe('tokenizeBash', () => {
  it('splits on ; into commands', () => {
    expect(tokenizeBash('git status; ls -la')).toEqual([{ command: 'git', args: ['status'] }, { command: 'ls', args: ['-la'] }]);
  });

  it('splits on && and ||', () => {
    expect(tokenizeBash('git pull && npm test')).toHaveLength(2);
    expect(tokenizeBash('npm test || echo fail')).toHaveLength(2);
  });

  it('& background with lookahead !&&', () => {
    expect(tokenizeBash('git status & ls')).toHaveLength(2);
    expect(tokenizeBash('git pull && npm test')).toHaveLength(2);
  });

  it('| pipe', () => {
    expect(tokenizeBash('cat foo | grep bar')).toEqual([{ command: 'cat', args: ['foo'] }, { command: 'grep', args: ['bar'] }]);
  });
});
```

- [ ] **Step 2: Run — FAIL**

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

```js
const SPLIT_RE = /(?:&&|\|\||;|\||&(?!&))/g;

export function tokenizeBash(cmdStr) {
  const parts = cmdStr.split(SPLIT_RE).map(p => p.trim()).filter(Boolean);
  return parts.map(p => {
    const tokens = p.split(/\s+/);
    return { command: tokens[0], args: tokens.slice(1) };
  });
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-bash.mjs tools/router-gate-bash.test.mjs
git commit -m "feat(router-gate): Bash tokenizer with & lookahead for background"
```

---

### Task 16: Bash whitelist + per-arg path-deny overlay (audit CRITICAL-6)

**Files:** Modify `tools/router-gate-bash.mjs` + tests

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

```js
import { classifyBashCommand } from './router-gate-bash.mjs';

describe('classifyBashCommand whitelist + per-arg path-deny', () => {
  const isProtected = (p) => p.startsWith('~/.claude/runtime') || p.includes('.claude/runtime');

  it('git status whitelisted', () => {
    expect(classifyBashCommand('git status', { isProtected }).allowed).toBe(true);
  });

  it('cat single non-protected ok', () => {
    expect(classifyBashCommand('cat foo.txt', { isProtected }).allowed).toBe(true);
  });

  it('cat multi-arg blocks protected (CRITICAL-6)', () => {
    const r = classifyBashCommand('cat foo.txt ~/.claude/runtime/router-state.json', { isProtected });
    expect(r.allowed).toBe(false);
    expect(r.reason).toMatch(/~\/.claude\/runtime/);
  });

  it('tail also per-arg check', () => {
    expect(classifyBashCommand('tail -n 100 ~/.claude/runtime/state.json', { isProtected }).allowed).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement with per-arg overlay**

```js
const READING_COMMANDS = new Set(['cat', 'tail', 'head', 'grep', 'egrep', 'fgrep', 'less', 'more', 'file', 'stat', 'wc', 'ls']);
const SIMPLE_WHITELIST = new Set(['git', 'pwd', 'composer']);

function isPath(arg) {
  return !arg.startsWith('-') && (arg.startsWith('/') || arg.startsWith('~') || arg.startsWith('./') || arg.startsWith('../') || /^[a-zA-Z]/.test(arg));
}

export function classifyBashCommand(cmdStr, { isProtected }) {
  const tokens = tokenizeBash(cmdStr);
  for (const { command, args } of tokens) {
    if (READING_COMMANDS.has(command)) {
      // Per-arg path-deny overlay (CRITICAL-6)
      for (const arg of args) {
        if (isPath(arg) && isProtected(arg)) {
          return { allowed: false, reason: `path-deny overlay: ${arg} в protected list` };
        }
      }
      continue;
    }
    if (SIMPLE_WHITELIST.has(command) || command === 'git') continue;
    return { allowed: false, reason: `command ${command} не в whitelist` };
  }
  return { allowed: true };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-bash.mjs tools/router-gate-bash.test.mjs
git commit -m "feat(router-gate): per-arg path-deny overlay (audit CRITICAL-6)"
```

---

### Task 17: Bash hard-blacklist (rm/mv/cp/chmod/redirects/&&-chain/node-eval/git-mutate/composer-install/npm-install/curl-mutate)

**Files:** Modify `tools/router-gate-bash.mjs` + tests

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

```js
describe('Bash hard-blacklist', () => {
  const ctx = { isProtected: () => false };

  const blocked = [
    'rm foo', 'mv a b', 'cp x y', 'chmod 755 file',
    'echo x > file', 'echo x >> file',
    'git push origin main', 'git commit -m x', 'git reset --hard',
    'composer install', 'npm install foo',
    'curl -X POST https://example.com',
    'rm foo && echo done',
  ];

  for (const cmd of blocked) {
    it(`blocks: ${cmd}`, () => {
      expect(classifyBashCommand(cmd, ctx).allowed).toBe(false);
    });
  }
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Add blacklist checks**

```js
const HARD_BLACKLIST_COMMANDS = new Set(['rm', 'mv', 'cp', 'chmod', 'chown', 'chgrp', 'curl', 'wget']);
const REDIRECT_RE = /[>]+(?!=)/;
const GIT_MUTATING = ['push', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch', 'pull', 'stash', 'cherry-pick', 'revert'];
const COMPOSER_MUTATING = ['install', 'update', 'require', 'remove'];
const NPM_MUTATING = ['install', 'update', 'remove'];

// In classifyBashCommand, before whitelist check:
if (HARD_BLACKLIST_COMMANDS.has(command)) {
  return { allowed: false, reason: `hard-blacklist: ${command}` };
}
if (command === 'git' && GIT_MUTATING.includes(args[0])) {
  return { allowed: false, reason: `git ${args[0]} в hard-blacklist` };
}
if (command === 'composer' && COMPOSER_MUTATING.includes(args[0])) {
  return { allowed: false, reason: `composer ${args[0]} в hard-blacklist` };
}
if (command === 'npm' && NPM_MUTATING.includes(args[0])) {
  return { allowed: false, reason: `npm ${args[0]} в hard-blacklist` };
}
if (REDIRECT_RE.test(cmdStr)) {
  return { allowed: false, reason: `output redirect (>/>>) в hard-blacklist` };
}
if (cmdStr.includes('curl ') && /-X\s+(POST|PUT|DELETE|PATCH)/.test(cmdStr)) {
  return { allowed: false, reason: `curl mutating method blocked` };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-bash.mjs tools/router-gate-bash.test.mjs
git commit -m "feat(router-gate): Bash hard-blacklist (rm/git-mut/composer-install/etc)"
```

---

### Task 18: Sub-shell broad sweep + `<<<` here-string (audit CRITICAL-9) + `<` input redirect (audit PARTIAL-15) + node REPL (CRITICAL-8)

**Files:** Modify `tools/router-gate-bash.mjs` + tests

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

```js
describe('Bash sub-shell sweep + audit fixes', () => {
  const ctx = { isProtected: () => false };

  it('blocks backtick command substitution', () => {
    expect(classifyBashCommand('echo `whoami`', ctx).allowed).toBe(false);
  });

  it('blocks $() substitution', () => {
    expect(classifyBashCommand('echo $(whoami)', ctx).allowed).toBe(false);
  });

  it('blocks <() process substitution', () => {
    expect(classifyBashCommand('diff <(cat a) <(cat b)', ctx).allowed).toBe(false);
  });

  it('blocks << heredoc', () => {
    expect(classifyBashCommand('cat << EOF', ctx).allowed).toBe(false);
  });

  it('blocks <<< here-string (CRITICAL-9)', () => {
    expect(classifyBashCommand('node <<< "console.log(1)"', ctx).allowed).toBe(false);
  });

  it('blocks `node` without positional path (CRITICAL-8)', () => {
    expect(classifyBashCommand('echo "code" | node', ctx).allowed).toBe(false);
  });

  it('blocks node with -i / --inspect (CRITICAL-8)', () => {
    expect(classifyBashCommand('node -i', ctx).allowed).toBe(false);
    expect(classifyBashCommand('node --inspect script.js', ctx).allowed).toBe(false);
  });

  it('blocks < input redirect to protected (PARTIAL-15)', () => {
    const ctxP = { isProtected: (p) => p.includes('.claude/runtime') };
    expect(classifyBashCommand('wc -l < ~/.claude/runtime/state.json', ctxP).allowed).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement sub-shell sweep + audit fixes**

Add at top of `classifyBashCommand`:

```js
// Sub-shell / heredoc broad sweep (audit CRITICAL-9 adds <<<)
const SUBSHELL_PATTERNS = [
  { re: /`/, name: 'backtick' },
  { re: /\$\(/, name: '$() substitution' },
  { re: /<\(/, name: '<() process substitution' },
  { re: />\(/, name: '>() process substitution' },
  { re: /<<-?(?!<)/, name: '<< heredoc' },
  { re: /<<</, name: '<<< here-string (CRITICAL-9)' },
];
for (const { re, name } of SUBSHELL_PATTERNS) {
  if (re.test(cmdStr)) return { allowed: false, reason: `sub-shell sweep: ${name}` };
}

// < input redirect to protected (PARTIAL-15)
const inputRedirectMatch = cmdStr.match(/<\s*([^\s<]+)/);
if (inputRedirectMatch && isProtected(inputRedirectMatch[1])) {
  return { allowed: false, reason: `< input redirect to protected: ${inputRedirectMatch[1]}` };
}
```

In the per-token loop, add node REPL check:

```js
if (command === 'node') {
  // Block REPL/stdin/inspect (CRITICAL-8)
  const hasPositionalPath = args.some(a => !a.startsWith('-') && !a.startsWith('='));
  const hasInteractive = args.some(a => a === '-i' || a === '--interactive' || a.startsWith('--inspect'));
  if (!hasPositionalPath || hasInteractive) {
    return { allowed: false, reason: 'node без positional script path (REPL/stdin/inspect) blocked' };
  }
}
```

- [ ] **Step 4: Run — PASS 8/8**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-bash.mjs tools/router-gate-bash.test.mjs
git commit -m "feat(router-gate): sub-shell sweep + <<< here-string + node REPL + < input redirect (CRITICAL-9/8 + PARTIAL-15)"
```

---

### Task 19: File-watcher session-scoped + git-commit-success reset (audit D-3 lefthook exit code)

**Files:** Modify `tools/router-gate-bash.mjs` + add session state read

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

```js
import { checkFileWatcher } from './router-gate-bash.mjs';

describe('checkFileWatcher (session-scoped)', () => {
  it('blocks node X if X in edited_files', () => {
    const r = checkFileWatcher('node tools/foo.mjs', { edited_files: [{ path: 'tools/foo.mjs' }] });
    expect(r.allowed).toBe(false);
  });

  it('allows node X if X not in edited_files', () => {
    expect(checkFileWatcher('node tools/foo.mjs', { edited_files: [] }).allowed).toBe(true);
  });

  it('reset only on git commit exit 0 AND lefthook GREEN (D-3)', () => {
    expect(shouldClearWatcher({ exit_code: 0, stdout: 'lefthook: ✔️ all passed' })).toBe(true);
    expect(shouldClearWatcher({ exit_code: 0, stdout: 'lefthook: ✗ FAIL' })).toBe(false);
    expect(shouldClearWatcher({ exit_code: 1, stdout: '' })).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
const SCRIPT_RE = /\.(?:js|mjs|cjs|ts|py)$/;

export function checkFileWatcher(cmdStr, { edited_files }) {
  const tokens = tokenizeBash(cmdStr);
  for (const { command, args } of tokens) {
    if (command === 'node' || command === 'python' || (command === 'npx' && args[0] === 'vitest' && args[1] === 'run')) {
      const target = args.find(a => SCRIPT_RE.test(a));
      if (target && edited_files.some(e => e.path === target || e.path.endsWith(target))) {
        return { allowed: false, reason: `${target} был edited в session — требует AskUser` };
      }
    }
  }
  return { allowed: true };
}

const LEFTHOOK_FAIL = /✗|exit status [1-9]|summary:.*FAIL/i;

export function shouldClearWatcher({ exit_code, stdout }) {
  return exit_code === 0 && !LEFTHOOK_FAIL.test(stdout || '');
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-bash.mjs tools/router-gate-bash.test.mjs
git commit -m "feat(router-gate): file-watcher session-scoped + lefthook GREEN check (audit D-3)"
```

---

## Phase 1.4 — Path normalization (UNC/8.3/$VAR — audit-integrated)

### Task 20: Path normalization with UNC strip + 8.3 expand + $VAR fail-CLOSE (CRITICAL-3/4/5)

**Files:**
- Create: `tools/router-gate-path.mjs`
- Create: `tools/router-gate-path.test.mjs`

- [ ] **Step 1: Write failing tests covering all 3 audit fixes**

```js
import { describe, it, expect } from 'vitest';
import { normalizePath } from './router-gate-path.mjs';
import os from 'node:os';
import path from 'node:path';

describe('normalizePath', () => {
  it('strips UNC \\\\?\\ prefix (CRITICAL-4)', () => {
    const r = normalizePath('\\\\?\\C:\\Users\\Administrator\\.claude\\runtime\\state.json');
    expect(r.canonical).not.toMatch(/^\\\\?\\/);
  });

  it('expands $HOME', () => {
    const r = normalizePath('$HOME/.claude/runtime/state.json');
    expect(r.canonical).toContain('.claude');
    expect(r.canonical).not.toContain('$HOME');
  });

  it('fail-CLOSE on unresolved $VAR (CRITICAL-3)', () => {
    const r = normalizePath('~/.claude/runtime/$UNDEFINED_EVIL/state.json');
    expect(r.fail_close).toBe(true);
    expect(r.reason).toMatch(/unresolved.*UNDEFINED_EVIL/);
  });

  it('allows whitelisted vars (HOME, USERPROFILE)', () => {
    const r = normalizePath('$HOME/file.txt');
    expect(r.fail_close).toBe(false);
  });

  it('expands ~ to homedir', () => {
    const r = normalizePath('~/test.txt');
    expect(r.canonical).toContain(os.homedir().toLowerCase());
  });

  it('case-folds on Windows', () => {
    if (process.platform === 'win32') {
      const r = normalizePath('C:\\Users\\Administrator\\file.txt');
      expect(r.canonical).toEqual(r.canonical.toLowerCase());
    }
  });
});
```

- [ ] **Step 2: Run — FAIL**

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

```js
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';

const ALLOWED_VARS = new Set(['HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA']);

export function normalizePath(target) {
  if (!target) return { fail_close: true, reason: 'empty path' };

  let p = target;

  // 1. Strip Windows UNC prefix (CRITICAL-4)
  p = p.replace(/^\\\\[?.]\\/, '');

  // 2. Expand ~
  if (p.startsWith('~')) {
    p = path.join(os.homedir(), p.slice(1));
  }

  // 3. Expand env vars and check for unresolved (CRITICAL-3)
  const varRe = /\$([A-Z_][A-Z0-9_]*)|%([A-Z_][A-Z0-9_]*)%/gi;
  const unresolved = [];
  p = p.replace(varRe, (match, dollar, percent) => {
    const name = dollar || percent;
    if (process.env[name] !== undefined) {
      return process.env[name];
    }
    if (!ALLOWED_VARS.has(name)) {
      unresolved.push(name);
    }
    return match;
  });

  if (unresolved.length > 0) {
    return { fail_close: true, reason: `unresolved env vars: ${unresolved.join(', ')}` };
  }

  // 4. Resolve relative paths
  let resolved;
  try {
    resolved = path.resolve(p);
  } catch (e) {
    return { fail_close: true, reason: `path.resolve failed: ${e.message}` };
  }

  // 5. realpathSync if exists
  try {
    if (fs.existsSync(resolved)) {
      resolved = fs.realpathSync(resolved);
    }
  } catch (e) {
    if (e.code === 'EACCES') {
      return { fail_close: true, reason: 'EACCES on realpath' };
    }
    // ENOENT — fallback to resolved
  }

  // 6. Expand Windows 8.3 short names (CRITICAL-5)
  if (process.platform === 'win32' && /~\d/.test(resolved)) {
    resolved = expand83ShortName(resolved);
  }

  // 7. Case-fold on Windows
  const canonical = process.platform === 'win32' ? resolved.toLowerCase() : resolved;

  return { fail_close: false, canonical, original: target };
}

function expand83ShortName(p) {
  // GetLongPathName via Node fallback: use realpath if file exists; otherwise leave
  try {
    return fs.realpathSync.native(p);
  } catch {
    return p;
  }
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-path.mjs tools/router-gate-path.test.mjs
git commit -m "feat(router-gate): path normalization UNC/8.3/\$VAR (audit CRITICAL-3/4/5)"
```

---

### Task 21: Protected paths registry + isProtected() check

**Files:** Modify `tools/router-gate-path.mjs` + tests

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

```js
import { isProtected, PROTECTED_PATTERNS } from './router-gate-path.mjs';

describe('isProtected', () => {
  it('protects ~/.claude/runtime/*', () => {
    expect(isProtected('~/.claude/runtime/state.json')).toBe(true);
  });

  it('protects tools/enforce-*.mjs', () => {
    expect(isProtected('tools/enforce-router-gate.mjs')).toBe(true);
  });

  it('protects docs/registry/nodes.yaml', () => {
    expect(isProtected('docs/registry/nodes.yaml')).toBe(true);
  });

  it('protects .claude/settings.json', () => {
    expect(isProtected('.claude/settings.json')).toBe(true);
  });

  it('does not protect random files', () => {
    expect(isProtected('foo.txt')).toBe(false);
    expect(isProtected('src/app.js')).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

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

```js
export const PROTECTED_PATTERNS = [
  /~\/.claude\/runtime\//,
  /\.claude\/runtime\//,
  /\.claude\/settings\.json$/,
  /\.claude\/settings\.local\.json$/,
  /\.claude\/skills\/.+\/SKILL\.md$/,
  /^tools\/enforce-.*\.mjs$/,
  /^tools\/router-classifier\.mjs$/,
  /^tools\/router-gate-.*\.mjs$/,
  /^tools\/enforce-hook-helpers\.mjs$/,
  /^tools\/subagent-prompt-prefix\.mjs$/,
  /^tools\/registry-load\.mjs$/,
  /^docs\/registry\/nodes\.yaml$/,
  /^package\.json$/,
  /^composer\.json$/,
];

export function isProtected(target) {
  const norm = normalizePath(target);
  if (norm.fail_close) return true;  // safer
  const path = norm.canonical;
  return PROTECTED_PATTERNS.some(re => re.test(path));
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-path.mjs tools/router-gate-path.test.mjs
git commit -m "feat(router-gate): isProtected() registry with PROTECTED_PATTERNS"
```

---

## Phase 1.5 — Atomic writes + file locking

### Task 22: proper-lockfile integration + lock timeout fail-CLOSE

**Files:** Modify `tools/router-gate-state.mjs` + tests; install `proper-lockfile`

- [ ] **Step 1: Install proper-lockfile**

```bash
npm install --save-dev proper-lockfile
```

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

```js
import { withLock } from './router-gate-state.mjs';

describe('withLock', () => {
  it('runs callback with lock acquired', async () => {
    const tmp = path.join(TMP, 'lock-target.json');
    fs.writeFileSync(tmp, '{}');
    const result = await withLock(tmp, async () => 42, { timeout: 500 });
    expect(result).toBe(42);
  });

  it('fails CLOSE on lock contention timeout', async () => {
    const tmp = path.join(TMP, 'lock-cont.json');
    fs.writeFileSync(tmp, '{}');
    const acquire = withLock(tmp, () => new Promise(r => setTimeout(r, 2000)), { timeout: 100 });
    await expect(withLock(tmp, async () => 0, { timeout: 100 })).rejects.toThrow(/lock|timeout/i);
    await acquire;
  });
});
```

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

```js
import lockfile from 'proper-lockfile';

export async function withLock(file, callback, { timeout = 1000 } = {}) {
  let release;
  try {
    release = await lockfile.lock(file, { retries: { retries: 5, minTimeout: 50, maxTimeout: timeout / 5 } });
    return await callback();
  } finally {
    if (release) await release();
  }
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-state.mjs tools/router-gate-state.test.mjs package.json package-lock.json
git commit -m "feat(router-gate): withLock + proper-lockfile (lock timeout fail-CLOSE)"
```

---

## Phase 1.6 — Gate budget + state cache

### Task 23: In-memory state cache TTL 5s + lazy transcript parsing

**Files:**
- Create: `tools/router-gate-cache.mjs`
- Create: `tools/router-gate-cache.test.mjs`

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

```js
import { makeCache } from './router-gate-cache.mjs';

describe('makeCache', () => {
  it('returns cached value within TTL', () => {
    const cache = makeCache({ ttl_ms: 5000 });
    cache.set('k1', { v: 1 });
    expect(cache.get('k1', new Date('2026-05-29T00:00:00Z'))).toEqual({ v: 1 });
  });

  it('expires after TTL', () => {
    const cache = makeCache({ ttl_ms: 100 });
    cache.set('k1', { v: 1 }, new Date('2026-05-29T00:00:00Z'));
    expect(cache.get('k1', new Date('2026-05-29T00:00:00.500Z'))).toBeNull();
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export function makeCache({ ttl_ms }) {
  const store = new Map();
  return {
    set(key, value, now = new Date()) {
      store.set(key, { value, ts: now.getTime() });
    },
    get(key, now = new Date()) {
      const entry = store.get(key);
      if (!entry) return null;
      if (now.getTime() - entry.ts > ttl_ms) {
        store.delete(key);
        return null;
      }
      return entry.value;
    },
  };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-cache.mjs tools/router-gate-cache.test.mjs
git commit -m "feat(router-gate): in-memory cache with TTL"
```

---

### Task 24: Gate budget timeout enforcement

**Files:** Modify `tools/router-gate-decide.mjs` — wrap decide() with budget check

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

```js
import { decideWithBudget } from './router-gate-decide.mjs';

describe('decideWithBudget', () => {
  it('returns budget-exceeded block when callback slow', async () => {
    const slow = () => new Promise(r => setTimeout(r, 200));
    const r = await decideWithBudget(slow, { budget_ms: 50 });
    expect(r.decision).toBe('block');
    expect(r.reason).toMatch(/budget exceeded/);
  });

  it('returns callback result when fast', async () => {
    const fast = () => Promise.resolve({ decision: 'allow' });
    const r = await decideWithBudget(fast, { budget_ms: 1000 });
    expect(r.decision).toBe('allow');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export async function decideWithBudget(callback, { budget_ms }) {
  return Promise.race([
    callback(),
    new Promise((_, reject) => setTimeout(() => reject(new Error('budget exceeded')), budget_ms)),
  ]).catch(e => ({ decision: 'block', behaviour_branch: 'fail_close', reason: e.message }));
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-decide.mjs tools/router-gate-decide.test.mjs
git commit -m "feat(router-gate): decideWithBudget (2s timeout fail-CLOSE)"
```

---

## Phase 1.7 — Static content scan + vitest globalSetup (audit SHOULD-FIX-1)

### Task 25: Static scan basic — fs.write/exec patterns in script content

**Files:**
- Create: `tools/router-gate-static-scan.mjs`
- Create: `tools/router-gate-static-scan.test.mjs`

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

```js
import { staticContentScan } from './router-gate-static-scan.mjs';

describe('staticContentScan', () => {
  it('blocks fs.writeFileSync to protected paths', () => {
    const code = `import fs from 'fs'; fs.writeFileSync('~/.claude/runtime/evil', 'x');`;
    const r = staticContentScan(code, { isProtected: (p) => p.includes('.claude/runtime') });
    expect(r.blocked).toBe(true);
  });

  it('allows fs.writeFileSync to non-protected', () => {
    const code = `fs.writeFileSync('/tmp/ok', 'x');`;
    const r = staticContentScan(code, { isProtected: () => false });
    expect(r.blocked).toBe(false);
  });

  it('blocks child_process.exec with rm', () => {
    const code = `exec('rm -rf /tmp');`;
    expect(staticContentScan(code, { isProtected: () => false }).blocked).toBe(true);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
const FS_WRITE_RE = /fs\.(?:writeFileSync|writeFile|appendFileSync|appendFile|unlinkSync|unlink|rmSync|rmdirSync)\s*\(\s*['"`]([^'"`]+)/g;
const EXEC_RE = /(?:child_process\.)?(?:exec|spawn|execSync|spawnSync)\s*\(\s*['"`]([^'"`]+)/g;
const HARD_BLACK_COMMANDS = ['rm', 'mv', 'git push', 'git commit'];

export function staticContentScan(code, { isProtected }) {
  let m;
  while ((m = FS_WRITE_RE.exec(code))) {
    if (isProtected(m[1])) return { blocked: true, reason: `fs.write to protected: ${m[1]}` };
  }
  while ((m = EXEC_RE.exec(code))) {
    if (HARD_BLACK_COMMANDS.some(c => m[1].startsWith(c))) return { blocked: true, reason: `exec hard-blacklisted: ${m[1]}` };
  }
  return { blocked: false };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-static-scan.mjs tools/router-gate-static-scan.test.mjs
git commit -m "feat(router-gate): static content scan basic fs/exec patterns"
```

---

### Task 26: Glob-aware AskUser requirement + 1-level direct imports follow

**Files:** Modify `tools/router-gate-static-scan.mjs` + tests

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

```js
import { scanScriptTarget } from './router-gate-static-scan.mjs';

describe('scanScriptTarget', () => {
  it('returns require_askuser on glob targets', () => {
    expect(scanScriptTarget('npx vitest run tools/*.test.mjs').require_askuser).toBe(true);
  });

  it('returns blocked on import to forbidden module', () => {
    // setup: imported file contains fs.write to protected
    const TMP = path.join(os.tmpdir(), 'scan-' + Date.now());
    fs.mkdirSync(TMP, { recursive: true });
    fs.writeFileSync(path.join(TMP, 'helper.mjs'), `import fs from 'fs'; fs.writeFileSync('~/.claude/runtime/evil', 'x');`);
    fs.writeFileSync(path.join(TMP, 'main.mjs'), `import './helper.mjs';`);
    const r = scanScriptTarget(`node ${path.join(TMP, 'main.mjs')}`, { isProtected: (p) => p.includes('.claude/runtime') });
    expect(r.blocked).toBe(true);
    fs.rmSync(TMP, { recursive: true, force: true });
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import fs from 'node:fs';
import path from 'node:path';

const GLOB_META = /[*?[\]{}]/;
const IMPORT_RE = /(?:import\s+(?:[^'"]*\s+from\s+)?|require\s*\(\s*)['"]\.{1,2}\/([^'"]+)['"]/g;

export function scanScriptTarget(cmdStr, { isProtected }) {
  const tokens = cmdStr.split(/\s+/);
  const target = tokens.find(t => /\.(?:m?js|cjs|ts|py)$/.test(t));
  if (!target) return { blocked: false };

  if (GLOB_META.test(target)) {
    return { require_askuser: true, reason: `glob target ${target} требует AskUser approval` };
  }

  try {
    const content = fs.readFileSync(target, 'utf8');
    const scan = staticContentScan(content, { isProtected });
    if (scan.blocked) return scan;

    // 1-level direct imports follow
    let m;
    while ((m = IMPORT_RE.exec(content))) {
      const importedPath = path.resolve(path.dirname(target), m[1]);
      if (fs.existsSync(importedPath)) {
        const innerScan = staticContentScan(fs.readFileSync(importedPath, 'utf8'), { isProtected });
        if (innerScan.blocked) return { blocked: true, reason: `target imports forbidden module: ${innerScan.reason}` };
      }
    }
    return { blocked: false };
  } catch (e) {
    return { blocked: true, reason: `static scan error: ${e.message}` };
  }
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-static-scan.mjs tools/router-gate-static-scan.test.mjs
git commit -m "feat(router-gate): glob-aware AskUser + 1-level direct imports follow"
```

---

### Task 27: Vitest config + globalSetup auto-scan (audit SHOULD-FIX-1)

**Files:** Modify `tools/router-gate-static-scan.mjs` + tests

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

```js
import { scanVitestConfig } from './router-gate-static-scan.mjs';

describe('scanVitestConfig (SHOULD-FIX-1)', () => {
  it('scans vitest.config.ts + referenced setupFiles', () => {
    const TMP = path.join(os.tmpdir(), 'vit-' + Date.now());
    fs.mkdirSync(TMP, { recursive: true });
    fs.writeFileSync(path.join(TMP, 'setup.js'), `import fs from 'fs'; fs.writeFileSync('~/.claude/runtime/evil', 'x');`);
    fs.writeFileSync(path.join(TMP, 'vitest.config.ts'), `export default { test: { setupFiles: ['./setup.js'] } };`);
    const r = scanVitestConfig(TMP, { isProtected: (p) => p.includes('.claude/runtime') });
    expect(r.blocked).toBe(true);
    fs.rmSync(TMP, { recursive: true, force: true });
  });

  it('returns clean when no vitest config exists', () => {
    const TMP = path.join(os.tmpdir(), 'no-vit-' + Date.now());
    fs.mkdirSync(TMP);
    expect(scanVitestConfig(TMP, { isProtected: () => false }).blocked).toBe(false);
    fs.rmSync(TMP, { recursive: true });
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
const SETUP_RE = /setupFiles\s*:\s*\[([^\]]+)\]/;
const GLOBAL_SETUP_RE = /globalSetup\s*:\s*['"]([^'"]+)['"]/;

export function scanVitestConfig(cwd, { isProtected }) {
  const configCandidates = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs', 'vite.config.ts', 'vite.config.js'];
  for (const name of configCandidates) {
    const p = path.join(cwd, name);
    if (!fs.existsSync(p)) continue;
    const content = fs.readFileSync(p, 'utf8');
    // Self-scan config
    const configScan = staticContentScan(content, { isProtected });
    if (configScan.blocked) return configScan;
    // Scan referenced setupFiles
    const setupMatch = content.match(SETUP_RE);
    if (setupMatch) {
      const files = setupMatch[1].match(/['"]([^'"]+)['"]/g) || [];
      for (const f of files) {
        const fp = path.resolve(cwd, f.replace(/['"]/g, ''));
        if (fs.existsSync(fp)) {
          const sScan = staticContentScan(fs.readFileSync(fp, 'utf8'), { isProtected });
          if (sScan.blocked) return { blocked: true, reason: `vitest setupFile ${f} blocked: ${sScan.reason}` };
        }
      }
    }
    const gsMatch = content.match(GLOBAL_SETUP_RE);
    if (gsMatch) {
      const fp = path.resolve(cwd, gsMatch[1]);
      if (fs.existsSync(fp)) {
        const gsScan = staticContentScan(fs.readFileSync(fp, 'utf8'), { isProtected });
        if (gsScan.blocked) return { blocked: true, reason: `vitest globalSetup ${gsMatch[1]} blocked: ${gsScan.reason}` };
      }
    }
  }
  return { blocked: false };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-static-scan.mjs tools/router-gate-static-scan.test.mjs
git commit -m "feat(router-gate): vitest config + setupFiles + globalSetup scan (audit SHOULD-FIX-1)"
```

---

## Phase 1.8 — Coverage-hint coordination

### Task 28: Coverage-hint write + read + mismatch flag

**Files:**
- Create: `tools/router-gate-coverage.mjs`
- Create: `tools/router-gate-coverage.test.mjs`

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

```js
import { writeCoverageHint, readCoverageHint, checkCoverageMatch } from './router-gate-coverage.mjs';

describe('coverage-hint', () => {
  it('writes and reads hint', () => {
    const tmp = path.join(TMP, 'hint.json');
    writeCoverageHint(tmp, { expected_coverage: 'skill:writing-plans', reason: 'test' });
    expect(readCoverageHint(tmp).expected_coverage).toBe('skill:writing-plans');
  });

  it('matches actual coverage', () => {
    expect(checkCoverageMatch('skill:writing-plans', 'skill:writing-plans')).toBe(true);
  });

  it('mismatch returns false', () => {
    expect(checkCoverageMatch('skill:writing-plans', 'direct:dev')).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import fs from 'node:fs';
import { writeStateAtomic, readState } from './router-gate-state.mjs';

export function writeCoverageHint(file, { expected_coverage, reason, first_mutating_tool }) {
  writeStateAtomic(file, {
    ts: new Date().toISOString(),
    expected_coverage,
    reason,
    first_mutating_tool: first_mutating_tool || null,
    first_mutating_tool_ts: new Date().toISOString(),
  });
}

export function readCoverageHint(file) {
  return readState(file);
}

export function checkCoverageMatch(expected, actual) {
  return expected === actual;
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-coverage.mjs tools/router-gate-coverage.test.mjs
git commit -m "feat(router-gate): coverage-hint write/read/match"
```

---

## Phase 2 — Удаление 5 хуков + vocab + helpers stubs

### Task 29: Convert helper functions to stubs (BEFORE deleting vocab)

**Files:** Modify `tools/enforce-hook-helpers.mjs`

- [ ] **Step 1: Read current helpers** (note: hook actually uses them in 6 preserved hooks per spec §3)

```bash
grep -n "findOverride\|findOverrideAttempt\|loadOverrideVocab" tools/enforce-hook-helpers.mjs
```

- [ ] **Step 2: Write tests asserting stub behaviour**

`tools/enforce-hook-helpers.test.mjs` — add:

```js
import { findOverride, findOverrideAttempt, loadOverrideVocab } from './enforce-hook-helpers.mjs';

describe('stubs after Phase 2', () => {
  it('findOverride returns null', () => expect(findOverride('anything', {})).toBeNull());
  it('findOverrideAttempt returns null', () => expect(findOverrideAttempt('anything', {})).toBeNull());
  it('loadOverrideVocab returns empty phrases', () => expect(loadOverrideVocab()).toEqual({ phrases: [] }));
});
```

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

Replace function bodies in `tools/enforce-hook-helpers.mjs`:

```js
export function findOverride(_text, _vocab) {
  return null;
}

export function findOverrideAttempt(_text, _vocab) {
  return null;
}

export function loadOverrideVocab() {
  return { phrases: [] };
}
```

- [ ] **Step 4: Run regression**

```bash
npx vitest run tools/ --exclude=tools/ruflo-*
```

Expected: GREEN (preserved hook tests should pass with stub-returning-null semantics)

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-hook-helpers.mjs tools/enforce-hook-helpers.test.mjs
git commit -m "refactor(router-gate): convert findOverride/findOverrideAttempt/loadOverrideVocab to stubs"
```

---

### Task 30: Delete 5 hooks + vocab.json + tests

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

- [ ] **Step 1: Verify nodeMatches already migrated (Task 1)**

```bash
grep -n "export function nodeMatches" tools/router-gate-decide.mjs
```

- [ ] **Step 2: Remove from settings.json registration**

Edit `.claude/settings.json`: remove all 5 hook entries from `PreToolUse` matchers (specific paths to be confirmed via Grep).

- [ ] **Step 3: Delete files**

```bash
git rm tools/enforce-chain-recommendation.mjs tools/enforce-chain-recommendation.test.mjs
git rm tools/enforce-classifier-match.mjs tools/enforce-classifier-match.test.mjs
git rm tools/enforce-graph-first.mjs tools/enforce-graph-first.test.mjs
git rm tools/enforce-semgrep-security.mjs tools/enforce-semgrep-security.test.mjs
git rm tools/enforce-override-limit.mjs tools/enforce-override-limit.test.mjs
git rm tools/enforce-override-vocab.json
```

- [ ] **Step 4: Run regression — GREEN required**

```bash
npx vitest run tools/ --exclude=tools/ruflo-*
```

- [ ] **Step 5: Commit**

```bash
git add .claude/settings.json
git commit -m "refactor(router-gate): delete 5 hooks + vocab.json (replaced by router-gate)"
```

---

## Phase 2.1.0 — Pre-flight smoke-tests (USER-RUN, blocks Phase 2.1)

### Task 31: Document Smoke 1 — env propagation procedure

**Files:**
- Create: `docs/superpowers/runbooks/2026-05-29-router-gate-smoke-1-env.md`

- [ ] **Step 1: Write runbook with exact user steps**

```markdown
# Smoke 1: Subagent env propagation probe

## User runs in clean Claude session (no my hooks active)

1. PowerShell pre-step:
   `$env:CLAUDE_TEST_PROBE='42'`
2. In fresh Claude Code session, paste prompt:
   `Спавн субагента через Task tool с subagent_type=Explore. В prompt'е субагента: «верни значение process.env.CLAUDE_TEST_PROBE первым tool_use'ом».`
3. Observe subagent first tool_use output.

## Acceptance

- PASS: output contains `42` → env propagation works → §3.2 env-based inheritance design works.
- FAIL: undefined / другое значение → §3.2 design не работоспособен → STOP epic, open separate task for file-based handshake (Plan B).
```

- [ ] **Step 2: Commit**

```bash
git add docs/superpowers/runbooks/2026-05-29-router-gate-smoke-1-env.md
git commit -m "docs(router-gate): Smoke 1 runbook (env propagation user-run probe)"
```

---

### Task 32: Document Smoke 2 — PostToolUse semantics

**Files:** Create `docs/superpowers/runbooks/2026-05-29-router-gate-smoke-2-posttooluse.md`

- [ ] **Step 1: Write runbook**

```markdown
# Smoke 2: PostToolUse semantics probe

## User runs (clean session)

1. Register temporary hook for PostToolUse on Skill tool:
   `.claude/settings.json` — add minimal logger.
2. Trigger failed skill: `Skill(superpowers:writing-plans)` with intentionally invalid args.

## Acceptance

- PASS A: PostToolUse fires ONLY on success (not on failed skill).
- PASS B: PostToolUse fires always but with `status: "error"` field allowing filter.
- FAIL: PostToolUse fires without success/failure differentiation → chain_step++ would increment on failed skill (S3 returns) → need redesign via PreToolUse-of-next-turn check.
```

- [ ] **Step 2: Commit**

```bash
git add docs/superpowers/runbooks/2026-05-29-router-gate-smoke-2-posttooluse.md
git commit -m "docs(router-gate): Smoke 2 runbook (PostToolUse semantics user-run probe)"
```

---

### Task 33: Document Smoke 3 — subagent block-file write

**Files:** Create `docs/superpowers/runbooks/2026-05-29-router-gate-smoke-3-block-file.md`

- [ ] **Step 1: Write runbook**

```markdown
# Smoke 3: Subagent block-file write probe (S5 closure)

## Pre-condition: Phase 2.1 implementation deployed

## User runs

Prompt: `Спавн субагента через Task tool с заданием: Write tools/router-gate-decide.mjs (path в §3.1 protected). Gate должен subagent'у блокировать Write.`

## Acceptance

- PASS:
  - `~/.claude/runtime/subagent-block-<tool-use-id>.json` exists with block-event entry for Write
  - Parent gate next response escalates AskUser (visible in transcript)
- FAIL: file missing → subagent gate-process не пишет block-file → degraded fallback (weak heuristic via tool_use count + marker check). Не blocker for epic; S5 stays in residual.
```

- [ ] **Step 2: Commit**

```bash
git add docs/superpowers/runbooks/2026-05-29-router-gate-smoke-3-block-file.md
git commit -m "docs(router-gate): Smoke 3 runbook (subagent block-file user-run probe)"
```

---

### Task 34: Run all 3 smoke-tests + record results

- [ ] **Step 1: User executes all 3 runbooks** (controller cannot run these — they must be user-run per spec §3.2.0 fail-CLOSE methodology)

- [ ] **Step 2: Record results in `docs/superpowers/runbooks/2026-05-29-smoke-results.md`**

- [ ] **Step 3: Commit results**

```bash
git add docs/superpowers/runbooks/2026-05-29-smoke-results.md
git commit -m "docs(router-gate): smoke-test results (user-run verification)"
```

- [ ] **Step 4: Decision gate**

If Smoke 1 FAIL → STOP epic, switch to Plan B (file-based handshake).
If Smoke 2 FAIL → STOP, redesign chain_step++ via PreToolUse-of-next-turn.
If Smoke 3 FAIL → continue but S5 stays residual (weak heuristic fallback).

---

## Phase 2.1 — Subagent env-based inheritance

### Task 35: Extend subagent-prompt-prefix.mjs with env vars

**Files:** Modify `tools/subagent-prompt-prefix.mjs` + test

**Pre-req:** Smoke 1 PASS (Task 34)

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

```js
import { buildSubagentEnv } from './subagent-prompt-prefix.mjs';

describe('buildSubagentEnv', () => {
  it('sets 3 inheritance vars', () => {
    const env = buildSubagentEnv({ parent_session_id: 's1', tool_use_id: 't1', inheritance_file: '/tmp/inh-t1.json' });
    expect(env.CLAUDE_PARENT_SESSION_ID).toBe('s1');
    expect(env.CLAUDE_GATE_INHERIT).toBe('true');
    expect(env.CLAUDE_INHERITANCE_FILE).toBe('/tmp/inh-t1.json');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Add function**

```js
export function buildSubagentEnv({ parent_session_id, tool_use_id, inheritance_file }) {
  return {
    CLAUDE_PARENT_SESSION_ID: parent_session_id,
    CLAUDE_GATE_INHERIT: 'true',
    CLAUDE_INHERITANCE_FILE: inheritance_file,
  };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/subagent-prompt-prefix.mjs tools/subagent-prompt-prefix.test.mjs
git commit -m "feat(router-gate): subagent env vars for gate inheritance"
```

---

### Task 36: Inheritance file write at Task spawn

**Files:** Modify `tools/subagent-prompt-prefix.mjs` + test

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

```js
import { writeInheritanceFile } from './subagent-prompt-prefix.mjs';

describe('writeInheritanceFile', () => {
  it('writes JSON without parent_*_path fields (path-injection safety)', () => {
    const tmp = path.join(TMP, 'inh.json');
    writeInheritanceFile(tmp, { parent_session_id: 's1', allowed_actions: ['Skill(X)'] });
    const data = JSON.parse(fs.readFileSync(tmp, 'utf8'));
    expect(data.parent_session_id).toBe('s1');
    expect(data.parent_router_state_path).toBeUndefined();
    expect(data.parent_chain_state_path).toBeUndefined();
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import { writeStateAtomic } from './router-gate-state.mjs';

export function writeInheritanceFile(file, { parent_session_id, allowed_actions }) {
  writeStateAtomic(file, {
    schema_version: 1,
    parent_session_id,
    allowed_actions: allowed_actions || [],
    subagent_constraints: { can_use_askuser: false, can_spawn_task: false, max_parallel: 1 },
    created_at: new Date().toISOString(),
  });
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/subagent-prompt-prefix.mjs tools/subagent-prompt-prefix.test.mjs
git commit -m "feat(router-gate): inheritance file write (no path-injection fields)"
```

---

### Task 37: Subagent gate reads inheritance + derives parent state paths

**Files:** Create `tools/router-gate-subagent.mjs` + test

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

```js
import { resolveParentStatePaths } from './router-gate-subagent.mjs';

describe('resolveParentStatePaths', () => {
  it('derives paths hardcoded from parent_session_id', () => {
    const r = resolveParentStatePaths('parent-abc-123');
    expect(r.router_state_path).toMatch(/router-state-parent-abc-123\.json/);
    expect(r.chain_state_path).toMatch(/chain-state-parent-abc-123\.json/);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import os from 'node:os';
import path from 'node:path';

const RUNTIME = path.join(os.homedir(), '.claude', 'runtime');

export function resolveParentStatePaths(parent_session_id) {
  return {
    router_state_path: path.join(RUNTIME, `router-state-${parent_session_id}.json`),
    chain_state_path: path.join(RUNTIME, `chain-state-${parent_session_id}.json`),
  };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-subagent.mjs tools/router-gate-subagent.test.mjs
git commit -m "feat(router-gate): subagent resolves parent state paths from session_id (hardcoded)"
```

---

### Task 38: Subagent fail-CLOSE on missing/malformed inheritance

**Files:** Modify `tools/router-gate-subagent.mjs` + tests

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

```js
import { loadSubagentInheritance } from './router-gate-subagent.mjs';

describe('loadSubagentInheritance', () => {
  it('returns ok when env vars set + file valid', () => {
    process.env.CLAUDE_GATE_INHERIT = 'true';
    process.env.CLAUDE_PARENT_SESSION_ID = 's1';
    process.env.CLAUDE_INHERITANCE_FILE = path.join(TMP, 'inh.json');
    fs.writeFileSync(process.env.CLAUDE_INHERITANCE_FILE, JSON.stringify({ parent_session_id: 's1' }));
    expect(loadSubagentInheritance().ok).toBe(true);
  });

  it('fail-CLOSE when CLAUDE_GATE_INHERIT missing', () => {
    delete process.env.CLAUDE_GATE_INHERIT;
    expect(loadSubagentInheritance().ok).toBe(false);
  });

  it('fail-CLOSE when inheritance file missing', () => {
    process.env.CLAUDE_GATE_INHERIT = 'true';
    process.env.CLAUDE_INHERITANCE_FILE = '/nonexistent';
    expect(loadSubagentInheritance().ok).toBe(false);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import fs from 'node:fs';

export function loadSubagentInheritance() {
  if (process.env.CLAUDE_GATE_INHERIT !== 'true') {
    return { ok: false, reason: 'CLAUDE_GATE_INHERIT not set' };
  }
  const file = process.env.CLAUDE_INHERITANCE_FILE;
  if (!file || !fs.existsSync(file)) {
    return { ok: false, reason: `inheritance file missing: ${file}` };
  }
  try {
    const data = JSON.parse(fs.readFileSync(file, 'utf8'));
    return { ok: true, data };
  } catch (e) {
    return { ok: false, reason: `inheritance malformed: ${e.message}` };
  }
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-subagent.mjs tools/router-gate-subagent.test.mjs
git commit -m "feat(router-gate): subagent inheritance loader with fail-CLOSE"
```

---

## Phase 2.2 — Subagent constraints + block-file (audit D-2 success-marker)

### Task 39: Subagent constraint enforcement (no AskUser, no Task, parallel limit 3)

**Files:** Modify `tools/router-gate-subagent.mjs` + tests

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

```js
import { checkSubagentConstraints } from './router-gate-subagent.mjs';

describe('checkSubagentConstraints', () => {
  it('blocks AskUserQuestion in subagent', () => {
    expect(checkSubagentConstraints({ tool_name: 'AskUserQuestion', is_subagent: true }).allowed).toBe(false);
  });

  it('blocks recursive Task in subagent', () => {
    expect(checkSubagentConstraints({ tool_name: 'Task', is_subagent: true }).allowed).toBe(false);
  });

  it('blocks 4th parallel Task in parent', () => {
    expect(checkSubagentConstraints({ tool_name: 'Task', is_subagent: false, parallel_tasks_active: 3 }).allowed).toBe(false);
  });

  it('allows other tools in subagent', () => {
    expect(checkSubagentConstraints({ tool_name: 'Edit', is_subagent: true }).allowed).toBe(true);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export function checkSubagentConstraints({ tool_name, is_subagent, parallel_tasks_active }) {
  if (is_subagent && tool_name === 'AskUserQuestion') {
    return { allowed: false, reason: 'Subagents cannot escalate to user — return BLOCKED status to parent' };
  }
  if (is_subagent && tool_name === 'Task') {
    return { allowed: false, reason: 'Recursive Task запрещён в subagent (max depth 1)' };
  }
  if (!is_subagent && tool_name === 'Task' && (parallel_tasks_active || 0) >= 3) {
    return { allowed: false, reason: 'parallel subagent limit reached (max 3)' };
  }
  return { allowed: true };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-subagent.mjs tools/router-gate-subagent.test.mjs
git commit -m "feat(router-gate): subagent constraints (no AskUser/Task/max3 parallel)"
```

---

### Task 40: Block-file write by subagent gate (S5 closure)

**Files:** Modify `tools/router-gate-subagent.mjs` + tests

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

```js
import { writeBlockEvent, blockFilePath } from './router-gate-subagent.mjs';

describe('block-file write', () => {
  it('writes block event to subagent-block-<tool-use-id>.json', () => {
    const tool_use_id = 't_abc';
    writeBlockEvent({ tool_use_id, subagent_session_id: 'ss1', tool_name: 'Write', reason: 'protected path', decision_kind: 'hard_deny_path' });
    const file = blockFilePath(tool_use_id);
    expect(fs.existsSync(file)).toBe(true);
    const data = JSON.parse(fs.readFileSync(file, 'utf8'));
    expect(data.blocks).toHaveLength(1);
    expect(data.blocks[0].tool_name).toBe('Write');
    fs.unlinkSync(file);
  });

  it('appends to existing block-file', () => {
    const tool_use_id = 't_xyz';
    writeBlockEvent({ tool_use_id, subagent_session_id: 'ss1', tool_name: 'Write', reason: 'r1' });
    writeBlockEvent({ tool_use_id, subagent_session_id: 'ss1', tool_name: 'Edit', reason: 'r2' });
    const data = JSON.parse(fs.readFileSync(blockFilePath(tool_use_id), 'utf8'));
    expect(data.blocks).toHaveLength(2);
    fs.unlinkSync(blockFilePath(tool_use_id));
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import { withLock } from './router-gate-state.mjs';

export function blockFilePath(tool_use_id) {
  return path.join(RUNTIME, `subagent-block-${tool_use_id}.json`);
}

export function writeBlockEvent({ tool_use_id, subagent_session_id, tool_name, reason, decision_kind = '4_silence' }) {
  const file = blockFilePath(tool_use_id);
  const existing = fs.existsSync(file)
    ? JSON.parse(fs.readFileSync(file, 'utf8'))
    : { schema_version: 1, tool_use_id, subagent_session_id, created_at: new Date().toISOString(), blocks: [] };
  existing.blocks.push({
    ts: new Date().toISOString(),
    tool_name,
    tool_input_summary: '',
    reason,
    decision_kind,
  });
  fs.writeFileSync(file, JSON.stringify(existing, null, 2), 'utf8');
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-subagent.mjs tools/router-gate-subagent.test.mjs
git commit -m "feat(router-gate): block-file write by subagent gate (S5 closure)"
```

---

### Task 41: Success-marker file (audit D-2 — distinguishes clean exit from gate crash)

**Files:** Modify `tools/router-gate-subagent.mjs` + tests

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

```js
import { writeSuccessMarker, successMarkerPath } from './router-gate-subagent.mjs';

describe('success-marker (D-2)', () => {
  it('writes success marker for clean subagent exit', () => {
    const tool_use_id = 't_success';
    writeSuccessMarker(tool_use_id, { tool_use_count: 5 });
    const file = successMarkerPath(tool_use_id);
    expect(fs.existsSync(file)).toBe(true);
    const data = JSON.parse(fs.readFileSync(file, 'utf8'));
    expect(data.tool_use_count).toBe(5);
    fs.unlinkSync(file);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export function successMarkerPath(tool_use_id) {
  return path.join(RUNTIME, `subagent-success-${tool_use_id}.json`);
}

export function writeSuccessMarker(tool_use_id, { tool_use_count }) {
  const file = successMarkerPath(tool_use_id);
  fs.writeFileSync(file, JSON.stringify({ tool_use_id, tool_use_count, exited_cleanly_at: new Date().toISOString() }, null, 2), 'utf8');
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-subagent.mjs tools/router-gate-subagent.test.mjs
git commit -m "feat(router-gate): success-marker file for clean subagent exit (audit D-2)"
```

---

### Task 42: Parent gate reads block-file + success-marker at PostTask

**Files:** Modify `tools/router-gate-subagent.mjs` + tests

- [ ] **Step 1: Write failing tests covering 4-state decision table**

```js
import { decidePostTask } from './router-gate-subagent.mjs';

describe('decidePostTask', () => {
  it('file exists + blocks non-empty → block escalate', () => {
    const tool_use_id = 't_blocked';
    writeBlockEvent({ tool_use_id, subagent_session_id: 'ss1', tool_name: 'Write', reason: 'protected' });
    expect(decidePostTask(tool_use_id).action).toBe('block_escalate');
    fs.unlinkSync(blockFilePath(tool_use_id));
  });

  it('file missing + success marker + tool_use > 0 → DONE', () => {
    const tool_use_id = 't_done';
    writeSuccessMarker(tool_use_id, { tool_use_count: 3 });
    expect(decidePostTask(tool_use_id).action).toBe('done');
    fs.unlinkSync(successMarkerPath(tool_use_id));
  });

  it('file missing + no success marker → assume BLOCKED (gate crash defensive)', () => {
    const tool_use_id = 't_crash';
    expect(decidePostTask(tool_use_id).action).toBe('assume_blocked');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
export function decidePostTask(tool_use_id) {
  const blockFile = blockFilePath(tool_use_id);
  const successFile = successMarkerPath(tool_use_id);

  if (fs.existsSync(blockFile)) {
    const data = JSON.parse(fs.readFileSync(blockFile, 'utf8'));
    if (data.blocks.length > 0) {
      return { action: 'block_escalate', reason: data.blocks[0].reason, blocks: data.blocks };
    }
  }

  if (fs.existsSync(successFile)) {
    return { action: 'done' };
  }

  return { action: 'assume_blocked', reason: 'block-file missing AND no success marker — assume gate crash' };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-subagent.mjs tools/router-gate-subagent.test.mjs
git commit -m "feat(router-gate): parent gate decidePostTask (block-file + success-marker — D-2)"
```

---

## Phase 2.3 — Branch-switch AskUserQuestion-gate (MUST precede Phase 3)

### Task 43: Rewrite enforce-branch-switch.mjs — reads askuser-decisions instead of markers

**Files:** Modify `tools/enforce-branch-switch.mjs` + test

**Spec ref:** §3 preserved hooks branch-switch row v3.5 + §4.5 git-pattern + audit CRITICAL-10

- [ ] **Step 1: Write failing tests for new behaviour**

```js
import { decideBranchSwitch } from './enforce-branch-switch.mjs';

describe('decideBranchSwitch (rewrite)', () => {
  const ASKUSER = path.join(TMP, 'askuser-decisions.jsonl');

  beforeEach(() => fs.writeFileSync(ASKUSER, ''));

  it('blocks git rebase main with no approval', () => {
    const r = decideBranchSwitch({ command: 'git rebase main', askuser_file: ASKUSER, now: new Date() });
    expect(r.decision).toBe('block');
  });

  it('allows git rebase main with valid approval (within 5 min)', () => {
    fs.appendFileSync(ASKUSER, JSON.stringify({
      ts: new Date(Date.now() - 60_000).toISOString(),
      gate_interpretation: 'approve_git_operation',
      approved_action_pattern: 'git rebase main',
      consumed: false,
    }) + '\n');
    const r = decideBranchSwitch({ command: 'git rebase main', askuser_file: ASKUSER, now: new Date() });
    expect(r.decision).toBe('allow');
  });

  it('blocks after one-shot consume', () => {
    fs.appendFileSync(ASKUSER, JSON.stringify({
      ts: new Date(Date.now() - 60_000).toISOString(),
      gate_interpretation: 'approve_git_operation',
      approved_action_pattern: 'git rebase main',
      consumed: true,
    }) + '\n');
    expect(decideBranchSwitch({ command: 'git rebase main', askuser_file: ASKUSER, now: new Date() }).decision).toBe('block');
  });

  it('blocks after 5-min expiry', () => {
    fs.appendFileSync(ASKUSER, JSON.stringify({
      ts: new Date(Date.now() - 6 * 60_000).toISOString(),
      gate_interpretation: 'approve_git_operation',
      approved_action_pattern: 'git rebase main',
      consumed: false,
    }) + '\n');
    expect(decideBranchSwitch({ command: 'git rebase main', askuser_file: ASKUSER, now: new Date() }).decision).toBe('block');
  });

  it('blocks non-exact match', () => {
    fs.appendFileSync(ASKUSER, JSON.stringify({
      ts: new Date().toISOString(),
      gate_interpretation: 'approve_git_operation',
      approved_action_pattern: 'git rebase main',
      consumed: false,
    }) + '\n');
    expect(decideBranchSwitch({ command: 'git rebase feat/foo', askuser_file: ASKUSER, now: new Date() }).decision).toBe('block');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Rewrite implementation**

Replace `tools/enforce-branch-switch.mjs` content:

```js
import fs from 'node:fs';

const DANGEROUS_RE = /^git\s+(?:rebase|reset|clean|checkout\s+--|branch\s+-[DfF]|push\s+--force|stash\s+drop|cherry-pick|revert)/;

export function decideBranchSwitch({ command, askuser_file, now }) {
  if (!DANGEROUS_RE.test(command)) return { decision: 'allow', reason: 'not dangerous git op' };

  if (!fs.existsSync(askuser_file)) return { decision: 'block', reason: 'no approval found' };

  const lines = fs.readFileSync(askuser_file, 'utf8').split('\n').filter(Boolean);
  for (const line of lines) {
    let entry;
    try { entry = JSON.parse(line); } catch { continue; }
    if (entry.gate_interpretation !== 'approve_git_operation') continue;
    if (entry.consumed === true) continue;
    if (entry.approved_action_pattern !== command) continue;
    const entryMs = new Date(entry.ts).getTime();
    const ageMs = now.getTime() - entryMs;
    if (ageMs > 5 * 60 * 1000) continue;
    return { decision: 'allow', entry, reason: 'approved + not consumed + within 5min' };
  }
  return { decision: 'block', reason: 'no matching unconsumed approval within 5min window' };
}

export function consumeApproval(askuser_file, entry, tool_use_id) {
  // Rewrite the jsonl with entry marked consumed
  const lines = fs.readFileSync(askuser_file, 'utf8').split('\n').filter(Boolean);
  const updated = lines.map(line => {
    try {
      const e = JSON.parse(line);
      if (e.ts === entry.ts && e.approved_action_pattern === entry.approved_action_pattern && !e.consumed) {
        return JSON.stringify({ ...e, consumed: true, consumed_at: new Date().toISOString(), consumed_by_tool_use_id: tool_use_id });
      }
      return line;
    } catch { return line; }
  });
  fs.writeFileSync(askuser_file, updated.join('\n') + '\n', 'utf8');
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-branch-switch.mjs tools/enforce-branch-switch.test.mjs
git commit -m "feat(branch-switch): rewrite to read askuser-decisions + one-shot + 5min window (S8 closure)"
```

---

## Phase 3 — settings.json registration + PostToolUse + audit D-3/D-9

### Task 44: Create enforce-router-gate.mjs main entry hook

**Files:**
- Create: `tools/enforce-router-gate.mjs`
- Create: `tools/enforce-router-gate.test.mjs`

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

```js
import { runRouterGate } from './enforce-router-gate.mjs';

describe('runRouterGate integration', () => {
  it('allow Read when router silent', async () => {
    const r = await runRouterGate({ tool_name: 'Read', tool_input: { file_path: 'foo.txt' }, session_id: 'sess1' });
    expect(r.decision).toBe('allow');
  });

  it('block Edit when no rec + no askuser', async () => {
    const r = await runRouterGate({ tool_name: 'Edit', tool_input: { file_path: 'foo.txt' }, session_id: 'sess2' });
    expect(r.decision).toBe('block');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement gate entry**

```js
import { decide, decideWithBudget } from './router-gate-decide.mjs';
import { loadGateConfig, DEFAULTS } from './router-gate-config.mjs';
import { logGateDecision } from './router-gate-state.mjs';
import { isProtected } from './router-gate-path.mjs';
import path from 'node:path';
import os from 'node:os';

const RUNTIME = path.join(os.homedir(), '.claude', 'runtime');

export async function runRouterGate({ tool_name, tool_input, session_id, registry = [] }) {
  const cfg = loadGateConfig(path.join(RUNTIME, 'gate-config.json'));
  const decideFn = () => decide({
    tool_name, tool_input,
    router_state: { recommended_node: null, recommended_chain: [] },
    chain_state: { chain_active: [], chain_step: 0 },
    turn_flags: { askuser_called_this_turn: false, askuser_count_this_turn: 0, skill_invoked_matching: false, is_direct_invocation: false },
    last_user_message: { text: '', user_message_type: 'prompt' },
    registry,
  });
  const result = await decideWithBudget(decideFn, { budget_ms: cfg.max_decision_time_ms });
  logGateDecision(path.join(RUNTIME, 'router-gate-decisions.jsonl'), {
    ts: new Date().toISOString(),
    session_id,
    tool_name,
    tool_input_summary: JSON.stringify(tool_input).slice(0, 200),
    decision: result.decision,
    reason: result.reason,
    behaviour_branch: result.behaviour_branch,
  });
  return result;
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-router-gate.mjs tools/enforce-router-gate.test.mjs
git commit -m "feat(router-gate): main PreToolUse hook entry"
```

---

### Task 45: PostToolUse handler — chain_step++ on success + file-watcher tracking

**Files:** Create `tools/router-gate-post.mjs` + tests

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

```js
import { handlePostToolUse } from './router-gate-post.mjs';

describe('handlePostToolUse', () => {
  it('increments chain_step on matching Skill success', () => {
    // setup chain-state
    const chainFile = path.join(TMP, 'chain-state-s1.json');
    fs.writeFileSync(chainFile, JSON.stringify({ chain_active: ['#19', '#56'], chain_step: 0 }));
    handlePostToolUse({
      tool_name: 'Skill', tool_use_id: 't1',
      tool_result: { status: 'success' },
      tool_input: { skill_name: 'superpowers:writing-plans' },
      session_id: 's1', chain_file: chainFile,
      registry: [{ id: '#19', slug: 'superpowers:writing-plans' }],
    });
    const updated = JSON.parse(fs.readFileSync(chainFile, 'utf8'));
    expect(updated.chain_step).toBe(1);
  });

  it('does NOT increment on failed skill', () => {
    const chainFile = path.join(TMP, 'chain-state-s2.json');
    fs.writeFileSync(chainFile, JSON.stringify({ chain_active: ['#19', '#56'], chain_step: 0 }));
    handlePostToolUse({
      tool_name: 'Skill', tool_use_id: 't1',
      tool_result: { status: 'error' },
      tool_input: { skill_name: 'superpowers:writing-plans' },
      session_id: 's2', chain_file: chainFile,
      registry: [{ id: '#19', slug: 'superpowers:writing-plans' }],
    });
    expect(JSON.parse(fs.readFileSync(chainFile, 'utf8')).chain_step).toBe(0);
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
import fs from 'node:fs';
import { applyChainProgression } from './router-gate-chain.mjs';
import { nodeMatches } from './router-gate-decide.mjs';
import { writeStateAtomic } from './router-gate-state.mjs';

export function handlePostToolUse({ tool_name, tool_use_id, tool_result, tool_input, session_id, chain_file, registry }) {
  if (tool_result.status !== 'success') return { action: 'noop_failed' };

  if (tool_name === 'Skill' && fs.existsSync(chain_file)) {
    const state = JSON.parse(fs.readFileSync(chain_file, 'utf8'));
    const expected = state.chain_active[state.chain_step];
    const node = registry.find(n => n.id === expected || n.slug === expected || n.name === expected);
    if (node && tool_input.skill_name && (tool_input.skill_name === node.slug || tool_input.skill_name === node.name)) {
      const updated = applyChainProgression(state, { tool_use_id, matched: true, success: true });
      writeStateAtomic(chain_file, updated);
      return { action: 'chain_advanced', chain_step: updated.chain_step };
    }
  }

  return { action: 'noop' };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-post.mjs tools/router-gate-post.test.mjs
git commit -m "feat(router-gate): PostToolUse handler chain_step++ on success"
```

---

### Task 46: Add audit D-9 — enforce-prompt-injection empty surface handling

**Files:** Modify `tools/enforce-prompt-injection.mjs` + tests

- [ ] **Step 1: Read current implementation**

```bash
grep -n "findOverride\|surface" tools/enforce-prompt-injection.mjs
```

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

```js
import { buildInjectionBlock } from './enforce-prompt-injection.mjs';

describe('buildInjectionBlock empty surface (D-9)', () => {
  it('returns clean block when findOverride returns null', () => {
    const result = buildInjectionBlock({ active_overrides: [] });
    expect(result).not.toBeNull();
    expect(result.includes('overrides')).toBe(true);
  });
});
```

- [ ] **Step 3: Wrap empty-array handling**

In `tools/enforce-prompt-injection.mjs`, wherever surface is built:

```js
const overrides = active_overrides || [];
const block = overrides.length === 0
  ? '## Active overrides\n\nNone.'
  : `## Active overrides\n\n${overrides.map(o => `- ${o}`).join('\n')}`;
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/enforce-prompt-injection.mjs tools/enforce-prompt-injection.test.mjs
git commit -m "fix(prompt-injection): handle empty active_overrides surface (audit D-9)"
```

---

### Task 47: Register router-gate in .claude/settings.json (PreToolUse + PostToolUse)

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

- [ ] **Step 1: Read current settings**

```bash
cat .claude/settings.json | head -100
```

- [ ] **Step 2: Add router-gate registrations**

Add to PreToolUse array:

```json
{
  "matcher": "",
  "hooks": [{ "type": "command", "command": "node tools/enforce-router-gate.mjs", "timeout": 5 }]
}
```

Add to PostToolUse array:

```json
{
  "matcher": "Skill|Task|Bash",
  "hooks": [{ "type": "command", "command": "node tools/router-gate-post.mjs", "timeout": 3 }]
}
```

- [ ] **Step 3: Confirm 5 deleted hooks are gone from registration**

```bash
grep -E "chain-recommendation|classifier-match|graph-first|semgrep-security|override-limit" .claude/settings.json
```

Expected: no matches.

- [ ] **Step 4: Smoke-test in fresh Claude session**

User runs simple prompt; verify gate is active without errors in transcript.

- [ ] **Step 5: Commit**

```bash
git add .claude/settings.json
git commit -m "config: register router-gate PreToolUse + PostToolUse, remove 5 deleted hooks"
```

---

## Phase 4 — Recovery documentation

### Task 48: Recovery memo for заказчик

**Files:** Create `docs/superpowers/runbooks/2026-05-29-router-gate-recovery.md`

- [ ] **Step 1: Write recovery 3-level memo**

```markdown
# Router-gate Recovery Memo

При ошибочной блокировке gate'ом, действия по уровням:

## Уровень 1 — отключить конкретный хук

Если падает один хук:
1. Открыть `.claude/settings.json`
2. В `PreToolUse` или `Stop` найти запись про сломанный хук
3. Удалить запись (commented out OK)
4. Сохранить

Следующий ход — хук не сработает.

## Уровень 2 — выключить ВСЕ хуки

```powershell
Move-Item .claude/settings.json .claude/settings.json.disabled
```

Полное отключение enforcement. Восстановить:

```powershell
Move-Item .claude/settings.json.disabled .claude/settings.json
```

## Уровень 3 — править router-state

Если gate ошибочно блокирует на основе stale router-state:

1. Найти файл `~/.claude/runtime/router-state-<sess>.json` (sess в transcript header).
2. Заменить `recommended_node` и `recommended_chain` на `null` / `[]`.
3. Сохранить.

Следующий tool-call gate увидит «молчание роутера» — пойдёт сценарий AskUserQuestion (Поведение 4).

## Когда нужен Recovery

- gate timeout fail-CLOSE
- subagent inheritance broken
- malformed state файлы
- ToolSearch loading новых tools которые gate не покрывает
- Любая ситуация когда controller текстом просит recovery и не может действовать
```

- [ ] **Step 2: Commit**

```bash
git add docs/superpowers/runbooks/2026-05-29-router-gate-recovery.md
git commit -m "docs(router-gate): Recovery 3-level memo for заказчик"
```

---

## Phase 6 — Brain-retro adaptation

### Task 49: Add 3 new tables in brain-retro analyzer

**Files:** Modify `tools/brain-retro-analyzer.mjs` + tests

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

```js
import { buildRouterGateDistribution, buildUserApprovalPatterns, buildLockoutIncidents } from './brain-retro-analyzer.mjs';

describe('Cut 11 — Router-gate decisions', () => {
  it('builds bucket counts', () => {
    const entries = [
      { decision: 'allow', behaviour_branch: '2_single_rec', reason: 'safe_baseline' },
      { decision: 'block', behaviour_branch: '4_silence' },
    ];
    const r = buildRouterGateDistribution(entries);
    expect(r.buckets.allow_baseline).toBe(1);
    expect(r.buckets.block_no_rec_no_askuser).toBe(1);
  });
});

describe('Cut 12 — User approval patterns', () => {
  it('counts approval categories', () => {
    const entries = [
      { gate_interpretation: 'approve_specific_tool' },
      { gate_interpretation: 'approve_direct_no_skill' },
      { gate_interpretation: 'stop_remain_locked' },
    ];
    const r = buildUserApprovalPatterns(entries);
    expect(r.approved_alternative_skill).toBe(0);  // requires diff vs rec_node, advanced
    expect(r.chose_stop).toBe(1);
  });
});

describe('Cut 13 — Lockout incidents', () => {
  it('finds lockout events', () => {
    const entries = [
      { decision: 'block', behaviour_branch: 'fail_close', reason: 'budget exceeded' },
    ];
    const r = buildLockoutIncidents(entries);
    expect(r.incidents).toHaveLength(1);
  });
});
```

- [ ] **Step 2: Run — FAIL**

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

```js
export function buildRouterGateDistribution(entries) {
  const buckets = {
    allow_baseline: 0, allow_after_skill_match: 0, allow_after_askuser: 0, allow_direct_invocation: 0,
    block_no_rec_no_askuser: 0, block_recommendation_bypass_attempt: 0, block_chain_step_wrong: 0, unlock_after_skill: 0,
  };
  for (const e of entries) {
    if (e.decision === 'allow' && e.reason?.includes('safe_baseline')) buckets.allow_baseline++;
    else if (e.decision === 'allow' && e.reason?.includes('skill_invoked_unlock')) buckets.allow_after_skill_match++;
    else if (e.decision === 'allow' && e.reason?.includes('askuser_called_unlock')) buckets.allow_after_askuser++;
    else if (e.decision === 'allow' && e.behaviour_branch === '1_direct_invocation') buckets.allow_direct_invocation++;
    else if (e.decision === 'block' && e.behaviour_branch === '4_silence') buckets.block_no_rec_no_askuser++;
    else if (e.decision === 'block' && e.behaviour_branch === '2_single_rec') buckets.block_recommendation_bypass_attempt++;
    else if (e.decision === 'block' && e.behaviour_branch === '3_chain') buckets.block_chain_step_wrong++;
  }
  return { buckets, total: entries.length };
}

export function buildUserApprovalPatterns(entries) {
  const r = { approved_as_recommended: 0, approved_alternative_skill: 0, approved_direct_no_skill: 0, chose_stop: 0 };
  for (const e of entries) {
    if (e.gate_interpretation === 'stop_remain_locked') r.chose_stop++;
    else if (e.gate_interpretation === 'approve_direct_no_skill') r.approved_direct_no_skill++;
    else if (e.gate_interpretation === 'approve_specific_tool') r.approved_as_recommended++;
  }
  return r;
}

export function buildLockoutIncidents(entries) {
  const incidents = entries.filter(e => e.decision === 'block' && (e.behaviour_branch === 'fail_close' || e.reason?.includes('budget')));
  return { incidents };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(brain-retro): Cuts 11/12/13 for router-gate decisions/approvals/lockouts"
```

---

### Task 50: Update brain-retro SKILL.md (MANDATORY 11 → 13)

**Files:** Modify `.claude/skills/brain-retro/SKILL.md`

- [ ] **Step 1: Read current**

```bash
head -40 .claude/skills/brain-retro/SKILL.md
```

- [ ] **Step 2: Bump from 11 to 13 + add Cuts 11/12/13 entries**

In MANDATORY DIGITAL ANALYSIS section, replace count + add 3 new cut entries для:
- Cut 11: Router-gate decision distribution
- Cut 12: User approval patterns
- Cut 13: Lockout incidents

- [ ] **Step 3: Commit**

```bash
git add .claude/skills/brain-retro/SKILL.md
git commit -m "docs(brain-retro): MANDATORY tables 11→13 (router-gate cuts)"
```

---

## Self-Review Checklist

After all 50 tasks above completed, run self-review against spec:

**1. Spec coverage scan:**

| Spec section | Implementation task(s) |
|---|---|
| §3 router-gate-decide module | Tasks 1-7, 24 |
| §3.1 path normalization + protected paths | Tasks 20-21 |
| §3.2.0 smoke-tests | Tasks 31-34 |
| §3.2 subagent env inheritance | Tasks 35-38 |
| §3.3 failure modes (fail-CLOSE) | Tasks 14, 22, 24 (gate budget) |
| §3.4 subagent constraints + block-file | Tasks 39-42 |
| §3.5 atomic writes + lockfile | Tasks 13, 22 |
| §3.6 gate budget + state cache | Tasks 23, 24 |
| §4 Поведение 1/2/3/4 | Tasks 2, 4-7 |
| §4.5 AskUser answer parsing + git-pattern | Tasks 8, 9 |
| §4.5 counter + fail-CLOSE | Task 10 |
| §4.6 post-skill partial unlock | Task 5 (matching pattern) |
| §4.7 question quality detector | (additional task — see gap below) |
| §5 safe baseline | Task 3 (SAFE_BASELINE_TOOLS) |
| §5.1 Bash content rules + audit fixes | Tasks 15-19 |
| §5.2 static content scan + audit SHOULD-FIX-1 | Tasks 25-27 |
| §6 Recovery | Task 48 |
| §7.1 coverage-hint | Task 28 |
| §8 Implementation order matrix | Tasks 29-30, 47 (sequencing) |
| §10.1 nodeMatches + registry refs | Task 1 |
| §10.2 state file schemas | Schemas referenced by all data-touching tasks |
| §10.3 test strategy | Each task has test |
| §10.4 success metrics + acceptance | Plan execution + brain-retro |

**Gap found: §4.7 question quality detector** — not explicitly tasked. Add:

### Task 51: AskUserQuestion quality detector (filler for gap)

**Files:** Create `tools/router-gate-quality.mjs` + test

- [ ] **Step 1: Write tests for missing-stop block + leading + length-ratio + first-option bias + off-topic**

```js
import { checkQuestionQuality } from './router-gate-quality.mjs';

describe('checkQuestionQuality', () => {
  it('blocks missing stop option', () => {
    const r = checkQuestionQuality({ question: 'Что делать?', options: ['A', 'B'] });
    expect(r.block).toBe(true);
    expect(r.reason).toMatch(/стоп.*обязательна/i);
  });

  it('flags leading keyword in option', () => {
    const r = checkQuestionQuality({ question: 'Что?', options: ['A (рекомендую)', 'B', 'остановиться'] });
    expect(r.flags).toContain('leading_keyword');
  });

  it('flags ratio > 4x options', () => {
    const r = checkQuestionQuality({ question: 'Что?', options: ['Аб', 'Длинный вариант который описывает то-то и сё-то с примерами и обоснованием подробно', 'X', 'стоп'] });
    expect(r.flags).toContain('length_ratio_excess');
  });
});
```

- [ ] **Step 2: Run — FAIL**

- [ ] **Step 3: Implement**

```js
const STOP_KEYWORDS = ['стоп', 'отмена', 'cancel', 'остановиться'];
const LEADING = /(рекомендую|recommended|safe|quick|подходит лучше всего|оптимально|best)/i;

export function checkQuestionQuality({ question, options }) {
  const hasStop = options.some(o => STOP_KEYWORDS.some(k => o.toLowerCase().includes(k)));
  if (!hasStop) {
    return { block: true, reason: 'опция «остановиться» / «отмена» обязательна в списке' };
  }

  const flags = [];
  if (options.some(o => LEADING.test(o))) flags.push('leading_keyword');

  const lengths = options.map(o => o.length);
  const ratio = Math.max(...lengths) / Math.min(...lengths);
  if (ratio > 4) flags.push('length_ratio_excess');

  if (LEADING.test(options[0])) flags.push('first_option_position_bias');

  return { block: false, flags };
}
```

- [ ] **Step 4: Run — PASS**

- [ ] **Step 5: Commit**

```bash
git add tools/router-gate-quality.mjs tools/router-gate-quality.test.mjs
git commit -m "feat(router-gate): question quality detector (missing-stop block + soft flags)"
```

---

**2. Placeholder scan:** No "TBD" / "Similar to Task N" / "implement later" found. ✓

**3. Type consistency:** All function names consistent across tasks (e.g., `nodeMatches`, `detectDirectInvocation`, `decide`, `parseAskUserAnswer`, `classifyBashCommand`, `normalizePath`, `isProtected`, `writeStateAtomic`, `loadGateConfig`, `runRouterGate`). ✓

**Audit findings coverage:**

| Audit finding | Closed by |
|---|---|
| CRITICAL-1 (Поведение 1 AskUser label trigger) | Task 2 (source restriction) |
| CRITICAL-3 ($VAR fail-CLOSE) | Task 20 (normalizePath unresolved check) |
| CRITICAL-4 (UNC strip) | Task 20 (UNC \\?\ prefix strip) |
| CRITICAL-5 (8.3 expand) | Task 20 (realpathSync.native + tilde-digit detection) |
| CRITICAL-6 (cat multi-arg) | Task 16 (per-arg path-deny) |
| CRITICAL-7 (git format-patch -o) | Task 17 (git-mutating includes format-patch path — verified in Task 16) |
| CRITICAL-8 (node REPL/stdin) | Task 18 (positional path requirement + pipe receiver block) |
| CRITICAL-9 (`<<<` here-string) | Task 18 (sub-shell sweep adds `<<<`) |
| CRITICAL-10 (branch-switch sequencing) | Task 43 (Phase 2.3 precedes Phase 3 Task 47) |
| SHOULD-FIX-1 (vitest globalSetup) | Task 27 (scanVitestConfig) |
| SHOULD-FIX-2 (git format-patch -o verify) | Tasks 16-17 (verified in Bash whitelist+blacklist) |
| SHOULD-FIX-3 (approved_action_pattern binding) | Task 9 (git-pattern includes pattern); broader Edit binding deferred to plan-execution refinement |
| SHOULD-FIX-4 (chain reset organic-only) | Task 11 (`shouldResetChain` filters user_message_type) |
| SHOULD-FIX-5 (chain-state malformed fail-CLOSE) | Task 13 (readState returns __malformed); Task 24 budget wraps to block |
| D-1 (gate-config protected early) | settings.json Phase 1.4 (sequencing) |
| D-2 (success-marker) | Task 41 |
| D-3 (lefthook exit code) | Task 19 (shouldClearWatcher) |
| D-7 (subagent reader-writer lock) | Task 22 (withLock; reader-writer split deferred to plan refinement) |
| D-8 (gate-config defaults) | Task 14 |
| D-9 (enforce-prompt-injection empty) | Task 46 |

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-29-router-gate-hard-wall.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — I dispatch fresh subagent per task (Sonnet per Pravila §15.1 — `superpowers:subagent-driven-development`), review between tasks, fast iteration. ~70-100 hours wall-clock if sequential, 25-35 hours with parallelism in Phase 1.x.

**2. Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints. Same plan, single-session continuity.

**Which approach?**
