diff --git a/docs/superpowers/plans/2026-05-29-router-gate-hard-wall.md b/docs/superpowers/plans/2026-05-29-router-gate-hard-wall.md new file mode 100644 index 00000000..8e866f1c --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-router-gate-hard-wall.md @@ -0,0 +1,3814 @@ +# 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-.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-.json` / `askuser-decisions-.jsonl` / `router-gate-decisions.jsonl` / `subagent-inheritance-.json` / `subagent-block-.json` / `subagent-success-.json` / `edited-files-.json` / `coverage-hint-.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 делай ', () => { + 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} 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(/делай /); + 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 останется заблокированным + • "делай " — 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: /<< !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-.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-.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-.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?**