# Router/Hooks Fixes — Phase 4 (Semgrep on Security-Edit + Chain-Hook Measurement) 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:** Закрыть кандидатов #9 (Semgrep на security-edit, привычка self-retrospect 28.05) и #2 (chain-recommendation effectiveness measurement, brain-retro #9 Candidate 2) — четвёртая из пяти фаз router-hooks эпика.

**Architecture:** Две независимые поставки. **Task A** добавляет новый block-режим PreToolUse-хук `enforce-semgrep-security.mjs` на `Bash` matcher, который при `git commit` сканирует staged-файлы и блокирует если в diff есть auth/billing/CSV/webhook артефакты, а Semgrep в этой сессии не запускался. **Task B** добавляет в `enforce-chain-recommendation.mjs` per-fire outcome-ledger через новый helper `logHookOutcome`, плюс новую функцию `analyzeChainHookEffectiveness()` в `brain-retro-analyzer.mjs` (Cut 11) с разбивкой `blocked / passed-with-skill / inline-override / global-override / passed-no-mutating / short-chain`.

**Tech Stack:** Node.js (.mjs), vitest, существующие enforce-hook-helpers (writeSentinel/readSentinel/findOverride/logOverride pattern).

**Контекст:** четвёртая из 5 фаз закрытия 10 кандидатов brain-retro #9 + self-retrospect 28.05. Полный roadmap внизу.

---

## Контекст из brain-retro #9 / self-retrospect 28.05

- **Task A (#9 Semgrep на security-edit).** Self-retrospect 28.05 пункт 4 («Топ reviewer-рекомендаций»): #25 Semgrep — 15 раз reviewer советует, недоиспользую. Привычка #4: «Правки в auth/billing/CSV/webhook → Semgrep на diff перед коммитом». Хук добавляет аппаратную поддержку этой привычки.
- **Task B (#2 chain-hook effectiveness).** Brain-retro #9 Candidate 2 (строки 369-374 файла `docs/observer/notes/2026-05-28-brain-retro-9.md`): chain-ignore = 83% при rework=0; хук `enforce-chain-recommendation` залит вечером 27.05, 125 fires за период, но нет статистики «блок vs override-inline vs override-global vs passed». Без этой статистики невозможно судить эффективен хук или нет. Source proposal: «парсить `events[].hook_fired.scripts['tools/enforce-chain-recommendation.mjs']` с разбивкой blocked / override_inline / passed». Pure-data подход без modify hook'а не даёт достаточно сигнала (matcher counts не различают исходы), поэтому ledger-based.

---

## File Structure

| Файл | Responsibility | Действие |
|---|---|---|
| `tools/enforce-semgrep-security.mjs` | PreToolUse Bash-хук, блок при `git commit` security-файлов без Semgrep | **Create** |
| `tools/enforce-semgrep-security.test.mjs` | Тесты pure `decide()` + `extractStagedFiles()` + `sessionRanSemgrep()` | **Create** |
| `tools/enforce-hook-helpers.mjs` | +`logHookOutcome(rule, outcome, sessionId)` для chain-hook ledger; +`sessionToolUses(transcript)` для session-wide tool scan | **Modify** |
| `tools/enforce-hook-helpers.test.mjs` | Тесты новых helpers | **Modify** |
| `tools/enforce-chain-recommendation.mjs` | +вызов `logHookOutcome` per fire с outcome-classification (6 buckets) | **Modify** |
| `tools/enforce-chain-recommendation.test.mjs` | Тесты выбора outcome bucket | **Modify** |
| `tools/enforce-override-vocab.json` | +`semgrep-security` в `suppresses` каждой из 7 override-фраз | **Modify** |
| `.claude/settings.json` | +регистрация `enforce-semgrep-security.mjs` под PreToolUse Bash matcher (block-режим) | **Modify** |
| `tools/brain-retro-analyzer.mjs` | +`analyzeChainHookEffectiveness(periodStart, periodEnd)` Cut 11; вынос в `analyze()` output | **Modify** |
| `tools/brain-retro-analyzer.test.mjs` | Тесты Cut 11 | **Modify** |
| `.claude/skills/brain-retro/SKILL.md` | 10→11 mandatory tables; добавить Cut 11 «Chain-hook effectiveness» | **Modify** |
| `.claude/skills/brain-retro/aggregation-template.md` | +шаблон Cut 11 (если файл существует — иначе skip) | **Modify if exists** |

---

## Task A: Semgrep-security PreToolUse hook

**Files:**

- Create: `tools/enforce-semgrep-security.mjs`
- Create: `tools/enforce-semgrep-security.test.mjs`
- Modify: `tools/enforce-hook-helpers.mjs` (добавить `sessionToolUses`)
- Modify: `tools/enforce-hook-helpers.test.mjs`
- Modify: `tools/enforce-override-vocab.json`
- Modify: `.claude/settings.json`

### Step A.1: Прочитать существующие override-фразы

Run: `node -e "const j=JSON.parse(require('fs').readFileSync('tools/enforce-override-vocab.json','utf-8')); console.log(JSON.stringify(j.phrases.map(p=>({phrase:p.phrase,suppresses:p.suppresses})), null, 2))"`

Expected: видеть массив из 7 фраз (`без скилов`, `direct ok`, `срочно`, `быстрый коммит`, `recovery`, `memory dump`, `ремонт инфраструктуры`), каждая со списком `suppresses`. Понять, какие сейчас rule-ключи поддерживаются — должны там быть `graph-first` и `chain-recommendation` (из v2.34).

- [ ] **Step A.2: Написать failing test для `sessionToolUses` helper**

В `tools/enforce-hook-helpers.test.mjs` добавить:

```js
import { describe, it, expect } from 'vitest';
import { sessionToolUses } from './enforce-hook-helpers.mjs';

describe('sessionToolUses', () => {
  it('returns ALL tool uses across the full session, not just last turn', () => {
    const entries = [
      // turn 1
      { type: 'user', message: { content: [{ type: 'text', text: 'first' }] } },
      { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'echo a' } }] } },
      // turn 2
      { type: 'user', message: { content: [{ type: 'text', text: 'second' }] } },
      { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'composer sast' } }] } },
      // turn 3 (current)
      { type: 'user', message: { content: [{ type: 'text', text: 'third' }] } },
      { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: { command: 'git status' } }] } },
    ];
    const uses = sessionToolUses(entries);
    expect(uses).toHaveLength(3);
    expect(uses.map(u => u.input.command)).toEqual(['echo a', 'composer sast', 'git status']);
  });

  it('returns [] for empty entries', () => {
    expect(sessionToolUses([])).toEqual([]);
  });

  it('skips non-tool_use blocks', () => {
    const entries = [
      { type: 'assistant', message: { content: [
        { type: 'text', text: 'hi' },
        { type: 'tool_use', name: 'Bash', input: { command: 'pwd' } },
      ] } },
    ];
    const uses = sessionToolUses(entries);
    expect(uses).toHaveLength(1);
    expect(uses[0].name).toBe('Bash');
  });
});
```

- [ ] **Step A.3: Запустить тест — упадёт «sessionToolUses is not a function»**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t "sessionToolUses" 2>&1 | tail -10`
Expected: FAIL with `ReferenceError` or `is not a function`.

- [ ] **Step A.4: Реализовать `sessionToolUses` в helpers**

В `tools/enforce-hook-helpers.mjs` добавить (рядом с `turnToolUses`):

```js
export function sessionToolUses(entries) {
  if (!Array.isArray(entries)) return [];
  const uses = [];
  for (const e of entries) {
    const c = e && e.message && e.message.content;
    if (!Array.isArray(c)) continue;
    for (const b of c) {
      if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
    }
  }
  return uses;
}
```

- [ ] **Step A.5: Запустить тест — должен пройти**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t "sessionToolUses" 2>&1 | tail -10`
Expected: PASS (3 tests).

- [ ] **Step A.6: Написать failing tests для `enforce-semgrep-security` pure decision**

Создать `tools/enforce-semgrep-security.test.mjs`:

```js
import { describe, it, expect } from 'vitest';
import { decide, extractStagedFiles, isSecurityRelevantPath, sessionRanSemgrep } from './enforce-semgrep-security.mjs';

describe('isSecurityRelevantPath', () => {
  it('matches auth files', () => {
    expect(isSecurityRelevantPath('app/Http/Controllers/Auth/LoginController.php')).toBe(true);
    expect(isSecurityRelevantPath('app/Http/Middleware/Authenticate.php')).toBe(true);
  });
  it('matches billing/ledger files', () => {
    expect(isSecurityRelevantPath('app/Services/BillingService.php')).toBe(true);
    expect(isSecurityRelevantPath('app/Services/LedgerService.php')).toBe(true);
  });
  it('matches CSV import/export files', () => {
    expect(isSecurityRelevantPath('app/Imports/SupplierLeadsImport.php')).toBe(true);
    expect(isSecurityRelevantPath('app/Jobs/CsvReconcileJob.php')).toBe(true);
    expect(isSecurityRelevantPath('app/Http/Controllers/DealCsvController.php')).toBe(true);
  });
  it('matches webhook files', () => {
    expect(isSecurityRelevantPath('app/Http/Controllers/SupplierWebhookController.php')).toBe(true);
    expect(isSecurityRelevantPath('app/Services/WebhookSignatureVerifier.php')).toBe(true);
  });
  it('does NOT match docs/normal files', () => {
    expect(isSecurityRelevantPath('docs/superpowers/plans/2026-05-28-phase4.md')).toBe(false);
    expect(isSecurityRelevantPath('memory/feedback_communication.md')).toBe(false);
    expect(isSecurityRelevantPath('app/Models/Tenant.php')).toBe(false);
    expect(isSecurityRelevantPath('app/Http/Controllers/HomeController.php')).toBe(false);
  });
  it('returns false for null/empty', () => {
    expect(isSecurityRelevantPath(null)).toBe(false);
    expect(isSecurityRelevantPath('')).toBe(false);
  });
});

describe('extractStagedFiles', () => {
  it('parses git diff --cached --name-only output', () => {
    const stdout = 'app/Services/BillingService.php\napp/Models/Deal.php\n';
    expect(extractStagedFiles(stdout)).toEqual([
      'app/Services/BillingService.php',
      'app/Models/Deal.php',
    ]);
  });
  it('skips blank lines', () => {
    expect(extractStagedFiles('a.php\n\nb.php\n')).toEqual(['a.php', 'b.php']);
  });
  it('returns [] for empty stdout', () => {
    expect(extractStagedFiles('')).toEqual([]);
    expect(extractStagedFiles(null)).toEqual([]);
  });
});

describe('sessionRanSemgrep', () => {
  it('returns true when a Bash tool_use ran semgrep CLI', () => {
    const sessionUses = [
      { name: 'Bash', input: { command: 'pwd' } },
      { name: 'Bash', input: { command: 'semgrep scan --config p/php' } },
    ];
    expect(sessionRanSemgrep(sessionUses)).toBe(true);
  });
  it('returns true when "composer sast" ran', () => {
    expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'composer sast' } }])).toBe(true);
    expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'composer sast -- --diff' } }])).toBe(true);
  });
  it('returns true when "npm run sast" ran', () => {
    expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'npm run sast' } }])).toBe(true);
  });
  it('returns false when no semgrep-like command ran', () => {
    expect(sessionRanSemgrep([
      { name: 'Bash', input: { command: 'git status' } },
      { name: 'Bash', input: { command: 'npm test' } },
    ])).toBe(false);
  });
  it('returns false for empty list', () => {
    expect(sessionRanSemgrep([])).toBe(false);
  });
  it('ignores tool_use that is not Bash', () => {
    expect(sessionRanSemgrep([{ name: 'Skill', input: { skill: 'semgrep' } }])).toBe(false);
  });
});

describe('decide() — enforce-semgrep-security', () => {
  it('passes when command is NOT a git commit', () => {
    expect(decide({
      command: 'git status',
      stagedFiles: ['app/Services/BillingService.php'],
      semgrepRan: false,
      assistantText: '',
      override: null,
    })).toEqual({ block: false });
  });
  it('passes when no security-relevant files in staged', () => {
    expect(decide({
      command: 'git commit -m "docs: update"',
      stagedFiles: ['docs/foo.md', 'memory/bar.md'],
      semgrepRan: false,
      assistantText: '',
      override: null,
    })).toEqual({ block: false });
  });
  it('passes when Semgrep ran this session', () => {
    expect(decide({
      command: 'git commit -m "feat: billing"',
      stagedFiles: ['app/Services/BillingService.php'],
      semgrepRan: true,
      assistantText: '',
      override: null,
    })).toEqual({ block: false });
  });
  it('passes with global override', () => {
    expect(decide({
      command: 'git commit -m "fix"',
      stagedFiles: ['app/Services/BillingService.php'],
      semgrepRan: false,
      assistantText: '',
      override: { phrase: 'срочно' },
    })).toEqual({ block: false });
  });
  it('passes with inline semgrep-skip with non-empty reason', () => {
    expect(decide({
      command: 'git commit -m "fix"',
      stagedFiles: ['app/Services/BillingService.php'],
      semgrepRan: false,
      assistantText: 'something\nsemgrep-skip: тривиальный docstring fix\nother',
      override: null,
    })).toEqual({ block: false });
  });
  it('does NOT pass with empty semgrep-skip reason', () => {
    const r = decide({
      command: 'git commit -m "fix"',
      stagedFiles: ['app/Services/BillingService.php'],
      semgrepRan: false,
      assistantText: 'semgrep-skip: ',
      override: null,
    });
    expect(r.block).toBe(true);
  });
  it('blocks when commit has security file + no Semgrep + no override', () => {
    const r = decide({
      command: 'git commit -m "feat: billing fix"',
      stagedFiles: ['app/Services/BillingService.php', 'app/Models/Deal.php'],
      semgrepRan: false,
      assistantText: '',
      override: null,
    });
    expect(r.block).toBe(true);
    expect(r.message).toContain('Semgrep');
    expect(r.message).toContain('BillingService');
  });
});
```

- [ ] **Step A.7: Запустить тесты — упадут «module not found»**

Run: `npx vitest run tools/enforce-semgrep-security.test.mjs 2>&1 | tail -10`
Expected: FAIL — модуль ещё не создан.

- [ ] **Step A.8: Создать `tools/enforce-semgrep-security.mjs`**

```js
#!/usr/bin/env node
/**
 * Rule — Semgrep on security-edit.
 *
 * PreToolUse Bash hook. When the controller invokes `git commit` and the staged
 * diff includes auth/billing/CSV/webhook files but Semgrep has not been run in
 * this session, block with remediation instructions.
 *
 * Three escape hatches:
 *   1. Run Semgrep first via Bash (`composer sast`, `npm run sast`, `semgrep ...`).
 *   2. Write «semgrep-skip: <non-empty reason>» on a line in the assistant text.
 *   3. User prompt contains a global override phrase (vocab-driven).
 *
 * Spec: self-retrospect 28.05 habit #4. brain-retro #9 + retro-7 background.
 */

import { execFileSync } from 'child_process';
import {
  readStdin,
  parseEventJson,
  readTranscript,
  lastUserPromptText,
  lastAssistantText,
  sessionToolUses,
  findOverride,
  logOverride,
  exitDecision,
} from './enforce-hook-helpers.mjs';

const RULE_KEY = 'semgrep-security';
const GIT_COMMIT_RE = /^\s*git\s+commit\b/;
const SEMGREP_SKIP_RE = /^semgrep-skip:\s*\S+/m;
const SEMGREP_CMD_RE = /\b(semgrep\b|composer\s+sast\b|npm\s+run\s+sast\b)/i;

const SECURITY_PATH_PATTERNS = [
  /(?:^|\/)(?:Auth|Authenticate|Authenticated|Authorization|Authorize)\b/i,
  /Billing/i,
  /Ledger/i,
  /(?:Csv|CSV)/i,
  /Webhook/i,
];

export function isSecurityRelevantPath(path) {
  if (!path || typeof path !== 'string') return false;
  const norm = path.replace(/\\/g, '/');
  for (const re of SECURITY_PATH_PATTERNS) {
    if (re.test(norm)) return true;
  }
  return false;
}

export function extractStagedFiles(stdout) {
  if (!stdout || typeof stdout !== 'string') return [];
  return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
}

export function sessionRanSemgrep(toolUses) {
  if (!Array.isArray(toolUses)) return false;
  for (const u of toolUses) {
    if (!u || u.name !== 'Bash') continue;
    const cmd = String((u.input && u.input.command) || '');
    if (SEMGREP_CMD_RE.test(cmd)) return true;
  }
  return false;
}

export function decide({ command, stagedFiles, semgrepRan, assistantText, override }) {
  // Step 1: only act on git commit invocations.
  if (typeof command !== 'string' || !GIT_COMMIT_RE.test(command)) return { block: false };

  // Step 2: global override → pass.
  if (override) return { block: false };

  // Step 3: identify security-relevant staged files.
  const security = (Array.isArray(stagedFiles) ? stagedFiles : []).filter(isSecurityRelevantPath);
  if (security.length === 0) return { block: false };

  // Step 4: Semgrep already ran this session → pass.
  if (semgrepRan) return { block: false };

  // Step 5: inline semgrep-skip with non-empty reason → pass.
  if (typeof assistantText === 'string' && SEMGREP_SKIP_RE.test(assistantText)) return { block: false };

  // Step 6: block.
  const list = security.slice(0, 5).map((p) => `    - ${p}`).join('\n');
  const extra = security.length > 5 ? `    ... (+${security.length - 5} ещё)\n` : '';
  const message = [
    `[enforce-semgrep-security] В коммите есть ${security.length} файл(ов) с security-влиянием (auth/billing/CSV/webhook):`,
    list + (extra ? '\n' + extra : ''),
    `но Semgrep не запускался в этой сессии (self-retrospect 28.05 привычка #4).`,
    `Сделай ОДНО из трёх:`,
    `  1. Запусти Semgrep на diff: \`composer sast\` (или \`npm run sast\`, или \`semgrep scan --config p/php app/\`).`,
    `  2. Добавь строку «semgrep-skip: <одна строка причины>» в свой ответ (e.g. «semgrep-skip: правка только в docstring»).`,
    `  3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).`,
  ].join('\n');

  return { block: true, message };
}

function readStagedFilesSafe() {
  try {
    const out = execFileSync('git', ['diff', '--cached', '--name-only'], { encoding: 'utf-8' });
    return extractStagedFiles(out);
  } catch {
    return [];
  }
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    if (event.tool_name !== 'Bash') { exitDecision({ block: false }); return; }
    const command = String((event.tool_input && event.tool_input.command) || '');
    if (!GIT_COMMIT_RE.test(command)) { exitDecision({ block: false }); return; }

    const transcript = readTranscript(event.transcript_path);
    const userPrompt = lastUserPromptText(transcript);
    const assistantText = lastAssistantText(transcript);
    const sessionUses = sessionToolUses(transcript);
    const override = findOverride(userPrompt, RULE_KEY);
    if (override) logOverride(RULE_KEY, override, event.session_id);

    const stagedFiles = readStagedFilesSafe();
    const semgrepRan = sessionRanSemgrep(sessionUses);

    exitDecision(decide({ command, stagedFiles, semgrepRan, assistantText, override }));
  } catch {
    exitDecision({ block: false });
  }
}

const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-semgrep-security.mjs');
if (isCli) main();
```

- [ ] **Step A.9: Запустить тесты — должны пройти**

Run: `npx vitest run tools/enforce-semgrep-security.test.mjs 2>&1 | tail -20`
Expected: PASS (≥18 tests across 4 describe blocks).

- [ ] **Step A.10: Обновить override-vocab — добавить `semgrep-security` в suppresses**

В `tools/enforce-override-vocab.json` для **каждой из 7 фраз** (`без скилов`, `direct ok`, `срочно`, `быстрый коммит`, `recovery`, `memory dump`, `ремонт инфраструктуры`) добавить `"semgrep-security"` в массив `suppresses`. Должно стать рядом с уже существующими `"graph-first"` и `"chain-recommendation"`.

После правки run: `node -e "const j=JSON.parse(require('fs').readFileSync('tools/enforce-override-vocab.json','utf-8')); for (const p of j.phrases) { if (!p.suppresses.includes('semgrep-security')) throw new Error('missing semgrep-security in '+p.phrase); } console.log('OK — все 7 фраз содержат semgrep-security')"`
Expected: `OK — все 7 фраз содержат semgrep-security`.

- [ ] **Step A.11: Написать regression-test на полный набор фраз**

В `tools/enforce-semgrep-security.test.mjs` добавить describe block:

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

describe('override vocab coverage', () => {
  const phrases = ['без скилов', 'direct ok', 'срочно', 'быстрый коммит', 'recovery', 'memory dump', 'ремонт инфраструктуры'];
  for (const phrase of phrases) {
    it(`global override "${phrase}" suppresses semgrep-security`, () => {
      const userPrompt = phrase === 'recovery' || phrase === 'memory dump' || phrase === 'ремонт инфраструктуры'
        ? `${phrase}: тестовое обоснование`
        : phrase;
      const o = findOverride(userPrompt, 'semgrep-security');
      expect(o).toBeTruthy();
    });
  }
});
```

NB: некоторые фразы требуют justification suffix (см. `requires_justification` в vocab) — для них тестовый промпт даёт префикс `<phrase>: тестовое обоснование`. Прочитай vocab перед написанием теста чтоб правильно собрать промпт.

Run: `npx vitest run tools/enforce-semgrep-security.test.mjs -t "override vocab" 2>&1 | tail -20`
Expected: PASS (7 tests).

- [ ] **Step A.12: Прочитать `.claude/settings.json` PreToolUse секцию**

Run: `node -e "const j=JSON.parse(require('fs').readFileSync('.claude/settings.json','utf-8')); console.log(JSON.stringify(j.hooks.PreToolUse.filter(h => /enforce-(graph-first|chain-recommendation|semgrep|coverage|tdd)/.test(JSON.stringify(h))), null, 2))"`

Expected: видеть существующие enforce-хуки. Понять формат — matcher, command (`node tools/<name>.mjs`).

- [ ] **Step A.13: Зарегистрировать `enforce-semgrep-security.mjs` в settings.json**

В `.claude/settings.json` секция `hooks.PreToolUse` добавить новую запись рядом с `enforce-chain-recommendation` (тоже PreToolUse Bash). Использовать matcher `Bash`. Block-режим (как у chain-recommendation).

После правки run: `node -e "const j=JSON.parse(require('fs').readFileSync('.claude/settings.json','utf-8')); const found=JSON.stringify(j.hooks).includes('enforce-semgrep-security.mjs'); console.log('registered:', found)"`
Expected: `registered: true`.

- [ ] **Step A.14: Smoke-test хука — НЕ git commit → пропуск**

Run:

```bash
echo '{"tool_name":"Bash","tool_input":{"command":"git status"},"session_id":"smoke-1","transcript_path":""}' | node tools/enforce-semgrep-security.mjs; echo "exit=$?"
```

Expected: `exit=0` (никакого stderr).

- [ ] **Step A.15: Smoke-test хука — git commit, нет staged → пропуск**

(Если в репо есть staged-изменения, отложите их `git stash` или закоммитьте перед smoke'ом.)

Run:

```bash
echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m test"},"session_id":"smoke-2","transcript_path":""}' | node tools/enforce-semgrep-security.mjs; echo "exit=$?"
```

Expected: `exit=0` (нет staged-файлов → нет security-files → пропуск).

- [ ] **Step A.16: Commit Task A**

```bash
git add tools/enforce-semgrep-security.mjs tools/enforce-semgrep-security.test.mjs tools/enforce-hook-helpers.mjs tools/enforce-hook-helpers.test.mjs tools/enforce-override-vocab.json .claude/settings.json
git commit -m "feat(hooks): enforce-semgrep-security — block git commit when auth/billing/CSV/webhook in staged без Semgrep (Phase 4 #9)"
```

---

## Task B: Chain-hook effectiveness ledger + analyzer Cut 11

**Files:**

- Modify: `tools/enforce-hook-helpers.mjs` (добавить `logHookOutcome`)
- Modify: `tools/enforce-hook-helpers.test.mjs`
- Modify: `tools/enforce-chain-recommendation.mjs`
- Modify: `tools/enforce-chain-recommendation.test.mjs`
- Modify: `tools/brain-retro-analyzer.mjs`
- Modify: `tools/brain-retro-analyzer.test.mjs`
- Modify: `.claude/skills/brain-retro/SKILL.md`

### Step B.1: Написать failing test для `logHookOutcome`

В `tools/enforce-hook-helpers.test.mjs` добавить:

```js
import { describe, it, expect, beforeEach } from 'vitest';
import { logHookOutcome, runtimeDir } from './enforce-hook-helpers.mjs';
import fs from 'fs';
import path from 'path';

describe('logHookOutcome', () => {
  const ledgerPath = () => path.join(runtimeDir(), 'hook-outcomes.jsonl');

  beforeEach(() => {
    try { fs.unlinkSync(ledgerPath()); } catch { /* may not exist */ }
  });

  it('appends a JSONL line with rule/outcome/session_id/ts', () => {
    logHookOutcome('chain-recommendation', 'blocked', 'sess-abc');
    const raw = fs.readFileSync(ledgerPath(), 'utf-8');
    const line = JSON.parse(raw.trim().split('\n').pop());
    expect(line.rule).toBe('chain-recommendation');
    expect(line.outcome).toBe('blocked');
    expect(line.session_id).toBe('sess-abc');
    expect(typeof line.ts).toBe('string');
    expect(line.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
  });

  it('does not throw on null session_id', () => {
    expect(() => logHookOutcome('rule', 'passed-skill', null)).not.toThrow();
  });

  it('appends, not overwrites', () => {
    logHookOutcome('rule', 'blocked', 's1');
    logHookOutcome('rule', 'passed-skill', 's1');
    const lines = fs.readFileSync(ledgerPath(), 'utf-8').trim().split('\n');
    expect(lines.length).toBeGreaterThanOrEqual(2);
  });
});
```

- [ ] **Step B.2: Запустить тесты — должны упасть «is not a function»**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t "logHookOutcome" 2>&1 | tail -10`
Expected: FAIL.

- [ ] **Step B.3: Реализовать `logHookOutcome` в helpers**

В `tools/enforce-hook-helpers.mjs` добавить (рядом с `logOverride`):

```js
export function logHookOutcome(ruleKey, outcome, sessionId) {
  try {
    const f = join(runtimeDir(), 'hook-outcomes.jsonl');
    appendFileSync(f, JSON.stringify({
      ts: new Date().toISOString(),
      session_id: sessionId || null,
      rule: ruleKey,
      outcome,
    }) + '\n');
  } catch { /* ignore */ }
}
```

- [ ] **Step B.4: Тесты проходят**

Run: `npx vitest run tools/enforce-hook-helpers.test.mjs -t "logHookOutcome" 2>&1 | tail -10`
Expected: PASS (3 tests).

- [ ] **Step B.5: Написать failing tests на outcome-classification в `enforce-chain-recommendation`**

В `tools/enforce-chain-recommendation.test.mjs` добавить:

```js
import { classifyOutcome } from './enforce-chain-recommendation.mjs';

describe('classifyOutcome', () => {
  it('returns "passed-short-chain" when chain length < 2', () => {
    expect(classifyOutcome({ chainLength: 0 })).toBe('passed-short-chain');
    expect(classifyOutcome({ chainLength: 1 })).toBe('passed-short-chain');
  });
  it('returns "passed-no-mutating" when no mutating tool used', () => {
    expect(classifyOutcome({ chainLength: 2, hasMutating: false })).toBe('passed-no-mutating');
  });
  it('returns "passed-global-override" when override present', () => {
    expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: true })).toBe('passed-global-override');
  });
  it('returns "passed-with-skill" when a chain skill was invoked', () => {
    expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: false, hasChainSkill: true })).toBe('passed-with-skill');
  });
  it('returns "passed-inline-override" when chain-override regex matched', () => {
    expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: false, hasChainSkill: false, hasInlineOverride: true })).toBe('passed-inline-override');
  });
  it('returns "blocked" when none of the escapes apply', () => {
    expect(classifyOutcome({ chainLength: 2, hasMutating: true, hasOverride: false, hasChainSkill: false, hasInlineOverride: false })).toBe('blocked');
  });
});
```

Run: `npx vitest run tools/enforce-chain-recommendation.test.mjs -t "classifyOutcome" 2>&1 | tail -10`
Expected: FAIL — функция ещё не экспортирована.

- [ ] **Step B.6: Реализовать `classifyOutcome` и вызов `logHookOutcome` в `enforce-chain-recommendation.mjs`**

В файле:

1. Импортировать `logHookOutcome` рядом с `logOverride`:

```js
import {
  readStdin,
  parseEventJson,
  readTranscript,
  lastUserPromptText,
  lastAssistantText,
  turnToolUses,
  findOverride,
  logOverride,
  logHookOutcome,
  exitDecision,
  readRouterState,
} from './enforce-hook-helpers.mjs';
```

1. Добавить экспорт `classifyOutcome` (pure):

```js
export function classifyOutcome({ chainLength, hasMutating, hasOverride, hasChainSkill, hasInlineOverride } = {}) {
  if ((chainLength || 0) < CHAIN_MIN_LENGTH) return 'passed-short-chain';
  if (!hasMutating) return 'passed-no-mutating';
  if (hasOverride) return 'passed-global-override';
  if (hasChainSkill) return 'passed-with-skill';
  if (hasInlineOverride) return 'passed-inline-override';
  return 'blocked';
}
```

1. В `main()` сразу перед `exitDecision(decide(...))` вычислить bucket и записать ledger:

```js
const hasMutating = Array.isArray(toolUses) && toolUses.some((u) => MUTATING_TOOLS.has(u && u.name));
const hasChainSkill = normalizedChain.some((id) => calledSkillIds.has(id));
const hasInlineOverride = typeof assistantText === 'string' && CHAIN_OVERRIDE_RE.test(assistantText);
const outcome = classifyOutcome({
  chainLength: normalizedChain.length,
  hasMutating,
  hasOverride: !!override,
  hasChainSkill,
  hasInlineOverride,
});
logHookOutcome(RULE_KEY, outcome, event.session_id);
exitDecision(decide({ toolUses, recommendedChain: normalizedChain, calledSkillIds, assistantText, override }));
```

NB: можно вынести вычисление `hasMutating` / `hasChainSkill` / `hasInlineOverride` отдельными переменными и передать как в `decide`, так и в `classifyOutcome` — DRY. Но `decide()` уже принимает свои аргументы и менять её сигнатуру не нужно.

- [ ] **Step B.7: Тесты outcome-classification проходят**

Run: `npx vitest run tools/enforce-chain-recommendation.test.mjs 2>&1 | tail -20`
Expected: PASS (всё, включая старые тесты + 6 новых на classifyOutcome).

- [ ] **Step B.8: Smoke-test — fire хука с фиктивным транскриптом**

Этот шаг — опциональный. Если есть существующий smoke-runner, можно использовать. Иначе пропустить и положиться на тесты.

- [ ] **Step B.9: Написать failing test для `analyzeChainHookEffectiveness`**

В `tools/brain-retro-analyzer.test.mjs` добавить:

```js
import { analyzeChainHookEffectiveness, buildChainHookEffectiveness } from './brain-retro-analyzer.mjs';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

describe('analyzeChainHookEffectiveness', () => {
  const tmpLedger = path.join(__dirname, '..', '.scratch', `hook-outcomes-test-${Date.now()}.jsonl`);

  beforeEach(() => {
    fs.mkdirSync(path.dirname(tmpLedger), { recursive: true });
    fs.writeFileSync(tmpLedger, [
      JSON.stringify({ ts: '2026-05-28T10:00:00Z', rule: 'chain-recommendation', outcome: 'blocked', session_id: 's1' }),
      JSON.stringify({ ts: '2026-05-28T10:01:00Z', rule: 'chain-recommendation', outcome: 'passed-with-skill', session_id: 's1' }),
      JSON.stringify({ ts: '2026-05-28T10:02:00Z', rule: 'chain-recommendation', outcome: 'passed-inline-override', session_id: 's1' }),
      JSON.stringify({ ts: '2026-05-28T10:03:00Z', rule: 'chain-recommendation', outcome: 'passed-global-override', session_id: 's1' }),
      JSON.stringify({ ts: '2026-05-28T10:04:00Z', rule: 'chain-recommendation', outcome: 'passed-short-chain', session_id: 's1' }),
      JSON.stringify({ ts: '2026-05-28T10:05:00Z', rule: 'chain-recommendation', outcome: 'passed-no-mutating', session_id: 's1' }),
      JSON.stringify({ ts: '2026-05-28T10:06:00Z', rule: 'graph-first', outcome: 'blocked', session_id: 's1' }), // OTHER RULE — must be ignored
      JSON.stringify({ ts: '2026-05-27T10:00:00Z', rule: 'chain-recommendation', outcome: 'blocked', session_id: 's0' }), // BEFORE period
      '',
    ].join('\n'));
  });
  afterEach(() => { try { fs.unlinkSync(tmpLedger); } catch {} });

  it('counts outcomes within [periodStart, periodEnd]', () => {
    const result = analyzeChainHookEffectiveness({
      ledgerPath: tmpLedger,
      periodStart: '2026-05-28T00:00:00Z',
      periodEnd: '2026-05-28T23:59:59Z',
    });
    expect(result.total).toBe(6);
    expect(result.buckets.blocked).toBe(1);
    expect(result.buckets['passed-with-skill']).toBe(1);
    expect(result.buckets['passed-inline-override']).toBe(1);
    expect(result.buckets['passed-global-override']).toBe(1);
    expect(result.buckets['passed-short-chain']).toBe(1);
    expect(result.buckets['passed-no-mutating']).toBe(1);
  });

  it('returns zero-counts when ledger missing', () => {
    const result = analyzeChainHookEffectiveness({
      ledgerPath: '/nonexistent/ledger.jsonl',
      periodStart: '2026-05-28T00:00:00Z',
      periodEnd: '2026-05-28T23:59:59Z',
    });
    expect(result.total).toBe(0);
  });
});

describe('buildChainHookEffectiveness markdown', () => {
  it('renders markdown table with totals and percentages', () => {
    const md = buildChainHookEffectiveness({
      total: 10,
      buckets: {
        blocked: 5,
        'passed-with-skill': 2,
        'passed-inline-override': 1,
        'passed-global-override': 1,
        'passed-short-chain': 0,
        'passed-no-mutating': 1,
      },
    });
    expect(md).toContain('Chain-hook effectiveness');
    expect(md).toContain('blocked');
    expect(md).toContain('50%'); // 5/10
    expect(md).toContain('passed-with-skill');
    expect(md).toContain('20%'); // 2/10
  });

  it('returns "(нет данных)" when total=0', () => {
    const md = buildChainHookEffectiveness({ total: 0, buckets: {} });
    expect(md).toMatch(/нет данных|no data/i);
  });
});
```

Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "ChainHookEffectiveness" 2>&1 | tail -20`
Expected: FAIL — функции ещё не экспортированы.

- [ ] **Step B.10: Реализовать `analyzeChainHookEffectiveness` + `buildChainHookEffectiveness` в analyzer**

В `tools/brain-retro-analyzer.mjs`:

1. В шапке (рядом с `import { readFileSync, existsSync } from 'fs';`) добавить:

```js
import { join as pathJoin } from 'path';
import { homedir } from 'os';
```

1. Добавить (рядом с другими Cut-функциями):

```js
const CHAIN_OUTCOME_BUCKETS = [
  'blocked',
  'passed-with-skill',
  'passed-inline-override',
  'passed-global-override',
  'passed-short-chain',
  'passed-no-mutating',
];

export function analyzeChainHookEffectiveness({ ledgerPath, periodStart, periodEnd } = {}) {
  const lp = ledgerPath || pathJoin(homedir(), '.claude', 'runtime', 'hook-outcomes.jsonl');
  const buckets = Object.fromEntries(CHAIN_OUTCOME_BUCKETS.map((b) => [b, 0]));
  let total = 0;
  let raw;
  try { raw = readFileSync(lp, 'utf-8'); } catch { return { total: 0, buckets }; }
  const startMs = periodStart ? Date.parse(periodStart) : -Infinity;
  const endMs = periodEnd ? Date.parse(periodEnd) : Infinity;
  for (const line of raw.split('\n')) {
    if (!line.trim()) continue;
    let entry;
    try { entry = JSON.parse(line); } catch { continue; }
    if (!entry || entry.rule !== 'chain-recommendation') continue;
    const ts = Date.parse(entry.ts || '');
    if (Number.isNaN(ts) || ts < startMs || ts > endMs) continue;
    const bucket = CHAIN_OUTCOME_BUCKETS.includes(entry.outcome) ? entry.outcome : null;
    if (!bucket) continue;
    buckets[bucket] += 1;
    total += 1;
  }
  return { total, buckets };
}

export function buildChainHookEffectiveness({ total, buckets }) {
  if (!total) return '_(нет данных за период — хук не срабатывал или ledger пуст)_\n';
  const lines = [
    '| Outcome | Count | % |',
    '|---|---:|---:|',
  ];
  for (const b of CHAIN_OUTCOME_BUCKETS) {
    const c = buckets[b] || 0;
    const pct = total ? Math.round((c / total) * 100) : 0;
    lines.push(`| ${b} | ${c} | ${pct}% |`);
  }
  lines.push(`| **TOTAL** | **${total}** | **100%** |`);
  return lines.join('\n') + '\n';
}
```

NB: existing analyzer импортирует `readFileSync, existsSync` named-import'ом — поэтому используем `readFileSync(lp, ...)`, а не `fs.readFileSync(...)`. `path.join` импортируется как `pathJoin` чтобы не конфликтовать с возможной локальной переменной `path` в analyzer.

В Step B.9 тест использует `fs.readFileSync`/`fs.writeFileSync`/`fs.unlinkSync`/`fs.mkdirSync` — это OK, потому что тест-файл `brain-retro-analyzer.test.mjs` импортирует `import fs from 'fs'` для удобства; analyzer-модуль — нет.

- [ ] **Step B.11: Тесты проходят**

Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "ChainHookEffectiveness" 2>&1 | tail -20`
Expected: PASS (4 tests).

- [ ] **Step B.12: Подключить Cut 11 к `analyze()` output**

В `tools/brain-retro-analyzer.mjs` функция `analyze(episodes, opts)` уже возвращает объект с полями типа `result.classCanonCoverage` / `result.routerVsOpus` / `result.chainIgnoreBreakdown`. Добавить:

```js
result.chainHookEffectiveness = analyzeChainHookEffectiveness({
  ledgerPath: opts && opts.hookOutcomesLedgerPath,
  periodStart: opts && opts.periodStart,
  periodEnd: opts && opts.periodEnd,
});
```

Параметры `periodStart` / `periodEnd` уже принимаются в `analyze()` для других Cut'ов — переиспользовать; если нет — добавить как опции.

- [ ] **Step B.13: Тест на `analyze()` integration**

В `tools/brain-retro-analyzer.test.mjs` добавить (после существующих integration-tests):

```js
describe('analyze() integration — chainHookEffectiveness', () => {
  it('exposes chainHookEffectiveness in result', () => {
    const result = analyze([], { hookOutcomesLedgerPath: '/nonexistent.jsonl' });
    expect(result.chainHookEffectiveness).toBeDefined();
    expect(result.chainHookEffectiveness.total).toBe(0);
    expect(result.chainHookEffectiveness.buckets).toBeDefined();
  });
});
```

Run: `npx vitest run tools/brain-retro-analyzer.test.mjs -t "integration — chainHookEffectiveness" 2>&1 | tail -10`
Expected: PASS.

- [ ] **Step B.14: Обновить brain-retro SKILL.md — 10 → 11 mandatory tables**

В `.claude/skills/brain-retro/SKILL.md` найти секцию «MANDATORY DIGITAL ANALYSIS» и:

1. Header счёта таблиц `10` → `11`.
2. Добавить новую строку про Cut 11 «Chain-hook effectiveness» — описание: «Парсит `~/.claude/runtime/hook-outcomes.jsonl` за период retro. Buckets: blocked / passed-with-skill / passed-inline-override / passed-global-override / passed-short-chain / passed-no-mutating. Источник — `result.chainHookEffectiveness` из analyzer. Источник правила — brain-retro #9 Candidate 2.»
3. Добавить regression-test (если в SKILL.md есть таблица регрешн-проверок, она уже расширялась с 7→10 cuts в commit `b1398883`):

Run после правки:

```bash
grep -c "11 mandatory tables\|11 обязательных таблиц\|11 cuts" .claude/skills/brain-retro/SKILL.md
```

Expected: ≥ 1 (хотя бы одно упоминание счёта 11).

- [ ] **Step B.15: Если aggregation-template.md существует — добавить Cut 11 шаблон**

Run: `test -f .claude/skills/brain-retro/aggregation-template.md && echo EXISTS || echo NONE`

Если EXISTS — добавить в шаблон секцию `### Cut 11: Chain-hook effectiveness` с заглушкой `{{chainHookEffectiveness}}` или прямой markdown-таблицей из `buildChainHookEffectiveness(result.chainHookEffectiveness)`. Если NONE — пропустить, SKILL.md достаточно.

- [ ] **Step B.16: Полная регрессия tools-only**

Run: `LEFTHOOK=0 npx vitest run --exclude=".claude/**" --exclude=".claude.pre-ruflo.bak/**" --exclude="app/**" --exclude="tools/ruflo-*" --exclude="tools/subagent-prompt-prefix*" 2>&1 | tail -10`
Expected: PASS — все tools-tests зелёные (включая 989+ существующих + ~30 новых из Phase 4).

- [ ] **Step B.17: Commit Task B**

```bash
git add tools/enforce-hook-helpers.mjs tools/enforce-hook-helpers.test.mjs tools/enforce-chain-recommendation.mjs tools/enforce-chain-recommendation.test.mjs tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs .claude/skills/brain-retro/SKILL.md
# опционально: .claude/skills/brain-retro/aggregation-template.md, если правил
git commit -m "feat(brain-retro): Cut 11 chain-hook effectiveness ledger + analyzer (Phase 4 #2)"
```

---

## Roadmap (после Phase 4)

| Фаза | Содержание |
|---|---|
| Phase 5 | #4 cost-tracker Stop-hook (Candidate 4 brain-retro #9 — `~/.claude/runtime/cost-daily.json` per Task 20 Step 5 в `2026-05-25-llm-first-router-overhaul.md`) — отдельный план |

---

## Self-Review

**1. Spec coverage:** Phase 4 покрывает 2 кандидата:

- Candidate self-retrospect #9 «Security-edit → Semgrep» → Task A (новый блок-хук + override vocab + settings.json).
- Candidate brain-retro #9 #2 «chain-ignore 83% при rework=0 — оценить эффект `enforce-chain-recommendation`» → Task B (ledger + Cut 11 + brain-retro SKILL.md обновление 10→11).

Существующие 4 enforcement-хука retro #8 follow-up (`enforce-classifier-match` threshold, `enforce-chain-recommendation`, `enforce-graph-first`, override-vocab gap) не трогаются кроме adddition нового rule-ключа в vocab и outcome-ledger в chain-hook.

**2. Placeholder scan:** Каждый Step содержит либо точный код, либо точную команду с expected. Step A.10 говорит «прочитай vocab перед написанием теста чтоб правильно собрать промпт» — это указание исполнителю проверить per-phrase justification requirements, не placeholder работы. Step B.15 говорит «если aggregation-template.md существует — добавить» — это документированная conditional ветка, не TBD.

**3. Type consistency:**

- `logHookOutcome(ruleKey, outcome, sessionId)` — signature consistent между Steps B.1, B.3, B.6.
- `classifyOutcome({ chainLength, hasMutating, hasOverride, hasChainSkill, hasInlineOverride })` — поля consistent в B.5/B.6/B.7.
- `analyzeChainHookEffectiveness({ ledgerPath, periodStart, periodEnd })` — поля consistent B.9/B.10/B.12.
- `buildChainHookEffectiveness({ total, buckets })` — поля consistent B.9/B.10.
- `decide()` enforce-semgrep-security — signature consistent A.6/A.8.
- `isSecurityRelevantPath` / `extractStagedFiles` / `sessionRanSemgrep` / `sessionToolUses` — экспорт consistent с импортом в тестах.

**4. Risk assessment:**

- **Task A — false positives.** Regex `/Auth/i` ловит `AuthorizationServiceProvider` (нерелевантно). Альтернатива — более строгий путевой регекс типа `/Controllers\/Auth\/|Middleware\/Authenticate/`. Принято: широкий match, потому что override легко (`semgrep-skip:` inline), а false negative (пропустить Billing-правку) дороже false positive.
- **Task A — Semgrep установка.** Semgrep уже в Tooling Прил.Н #25 (фаза 3, активирован). `composer sast` / `npm run sast` — нужно проверить наличие команд в `composer.json` / `package.json`. Если их нет — добавить (отдельный мини-task внутри A.8 либо после).
- **Task B — ledger growth.** `~/.claude/runtime/hook-outcomes.jsonl` будет расти от хука на каждом fire (125/период). За 100 retro-периодов = 12,500 строк × 150 байт = 1.9 МБ. Приемлемо. Ротация — не нужна на этой фазе; добавить если файл >50 МБ.
- **Task B — analyzer reads runtime file.** Brain-retro analyzer теперь читает `~/.claude/runtime/hook-outcomes.jsonl` вне репозитория. Это новый внешний source. Падение на чтении не должно ломать analyzer — `try/catch` уже в реализации (B.10).
- **Task B — Cut 11 vs существующий chainIgnoreBreakdown.** Cut 10 (chainIgnoreBreakdown) уже считает игнор chain-recommendations по rework, но не разбивает по причинам отказа. Cut 11 — orthogonal: считает per-fire decision хука. Они дополняют друг друга, не дублируют.

**5. TDD дисциплина:** каждая новая функция (`sessionToolUses`, `isSecurityRelevantPath`, `extractStagedFiles`, `sessionRanSemgrep`, `decide`, `logHookOutcome`, `classifyOutcome`, `analyzeChainHookEffectiveness`, `buildChainHookEffectiveness`) — failing test → реализация → passing test. Никаких «implement first, test later» циклов.

**6. Commit hygiene:** Two commits — Task A и Task B независимы и могут быть смерджены отдельно. Task A trivial revertable (новый файл + локальные правки vocab + settings.json). Task B trivial revertable (ledger исчезнет, остальной analyzer работает).

Если по итогам реализации найдутся плейсхолдеры или несоответствия — fix inline без re-review.
