diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index dfc8572f..50d5bc10 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-30T03:08:26.587Z +Last updated: 2026-05-30T03:11:28.244Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,13 +8,13 @@ Last updated: 2026-05-30T03:08:26.587Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ⚠️ | 638 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro | +| C5 Observer-coverage | ⚠️ | 639 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync | ## Метрики (информационные, не алерты) -- Observer evidence: 638 episodes this month, 0 observer_error markers, 129 PII matches before filter -- Legacy v1 episodes (not in factor analysis): 499 +- Observer evidence: 639 episodes this month, 0 observer_error markers, 129 PII matches before filter +- Legacy v1 episodes (not in factor analysis): 500 - Last /brain-retro: 3 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 20. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). @@ -31,9 +31,9 @@ Baseline дисциплины роутера (этап 2 router discipline overh | cleanup | 6 | 0.0% | 0.0% | | refactor | 1 | 0.0% | 0.0% | -Router step distribution: 1: 280, 2: 227, 3: 63, 5: 61 +Router step distribution: 1: 281, 2: 227, 3: 63, 5: 61 -Boundaries applied (ADR / границы): 72 of 631 эпизодов (11.4%). +Boundaries applied (ADR / границы): 72 of 632 эпизодов (11.4%). ## Активные многоэтапные проекты @@ -51,10 +51,10 @@ Boundaries applied (ADR / границы): 72 of 631 эпизодов (11.4%). | Компонент | Токены (in/out) | USD | |---|---|---| -| Classifier (Sonnet 4.6) | 3147/41224 | $0.63 | +| Classifier (Sonnet 4.6) | 3237/42293 | $0.64 | | Self-assessment (Sonnet 4.6) | 0/0 | $0.00 | | Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 | -| **Итого** | | **$0.63** | +| **Итого** | | **$0.64** | ## Аномалии классификатора @@ -67,7 +67,7 @@ Episodes since last run: 542 / threshold: 10 ## Reviewer: субагент vs fallback -0 эпизодов проверено из 638. +0 эпизодов проверено из 639. ## Reviewer findings diff --git a/tools/enforce-chain-recommendation.mjs b/tools/enforce-chain-recommendation.mjs deleted file mode 100644 index f7048b45..00000000 --- a/tools/enforce-chain-recommendation.mjs +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env node -/** - * Rule — Chain-recommendation enforce. - * - * PreToolUse hook. When the router classifier recommends a multi-step chain - * (>= 2 nodes) and the controller is about to run a mutating tool without - * having invoked ANY node in the chain, block with instructions. - * - * Three escape hatches: - * 1. Call any skill/task matching at least one node in the chain. - * 2. Write chain-override at the start of a line in assistant text. - * 3. User prompt contains a global override phrase (vocab-driven). - * - * Single-node recommendations are handled by enforce-classifier-match.mjs. - */ - -import { - readStdin, - parseEventJson, - readTranscript, - lastUserPromptText, - lastAssistantText, - turnToolUses, - findOverride, - logOverride, - logHookOutcome, - exitDecision, - readRouterState, -} from './enforce-hook-helpers.mjs'; - -import { loadRegistry } from './registry-load.mjs'; - -const RULE_KEY = 'chain-recommendation'; -const CHAIN_MIN_LENGTH = 2; -const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Agent']); -const CHAIN_OVERRIDE_RE = /^chain-override:\s*\S+/m; - -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'; -} - -export function decide({ toolUses, recommendedChain, calledSkillIds, assistantText, override }) { - // Compute all state flags once — returned in every branch so main() can - // pass them to classifyOutcome() without recomputing. - const hasMutating = Array.isArray(toolUses) && toolUses.some((u) => MUTATING_TOOLS.has(u && u.name)); - const chain = Array.isArray(recommendedChain) ? recommendedChain : []; - const hasChainSkill = (calledSkillIds instanceof Set) && chain.some((id) => calledSkillIds.has(id)); - const hasInlineOverride = typeof assistantText === 'string' && CHAIN_OVERRIDE_RE.test(assistantText); - const flags = { hasMutating, hasChainSkill, hasInlineOverride }; - - if (chain.length < CHAIN_MIN_LENGTH) return { block: false, ...flags }; - if (!hasMutating) return { block: false, ...flags }; - if (override) return { block: false, ...flags }; - if (hasChainSkill) return { block: false, ...flags }; - if (hasInlineOverride) return { block: false, ...flags }; - - const chainStr = chain.join(' → '); - const message = [ - `[enforce-chain-recommendation] Router рекомендовал цепочку ${chainStr}, но ни один узел не вызван и нет инлайн-обоснования отказа.`, - `Сделай ОДНО из трёх:`, - ` 1. Вызови первый узел цепочки через Skill / Task tool.`, - ` 2. Добавь в свой ответ строку «chain-override: <одна строка причины>» (не путать с глобальным override от пользователя — это инлайн-объяснение controller-а).`, - ` 3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).`, - ].join('\n'); - return { block: true, message, ...flags }; -} - -function normalizeChainId(raw) { - if (raw === null || raw === undefined) return ''; - const s = String(raw).trim().toLowerCase(); - if (!s) return ''; - return s.startsWith('#') ? s : `#${s}`; -} - -function chainIdAliases(id, registry) { - const aliases = new Set([id]); - if (!registry) return aliases; - try { - const node = registry.indexById && registry.indexById.get(id); - if (!node) return aliases; - if (node.slug) aliases.add(node.slug.toLowerCase()); - if (node.name) aliases.add(node.name.toLowerCase()); - if (node.slug) aliases.add(`superpowers:${node.slug.toLowerCase()}`); - } catch { /* non-fatal */ } - return aliases; -} - -function extractCalledSkillIds(toolUses, normalizedChain, registry) { - const aliasMap = new Map(); - for (const id of normalizedChain) aliasMap.set(id, chainIdAliases(id, registry)); - const called = new Set(); - for (const u of toolUses) { - if (!u || !u.name) continue; - let rawName = null; - if (u.name === 'Skill') rawName = (u.input && u.input.skill) ? String(u.input.skill) : null; - else if (u.name === 'Task' || u.name === 'Agent') rawName = (u.input && u.input.subagent_type) ? String(u.input.subagent_type) : null; - if (!rawName) continue; - const norm = rawName.toLowerCase().trim(); - called.add(norm); - const stripped = norm.replace(/^superpowers:/, '').replace(/^skill:/, ''); - called.add(stripped); - for (const [chainId, aliases] of aliasMap) { - if (aliases.has(norm) || aliases.has(stripped)) called.add(chainId); - } - } - return called; -} - -async function main() { - try { - const raw = await readStdin(); - const event = parseEventJson(raw); - if (!MUTATING_TOOLS.has(event.tool_name)) { exitDecision({ block: false }); return; } - const transcript = readTranscript(event.transcript_path); - const userPrompt = lastUserPromptText(transcript); - const assistantText = lastAssistantText(transcript); - const toolUses = turnToolUses(transcript); - const override = findOverride(userPrompt, RULE_KEY); - if (override) logOverride(RULE_KEY, override, event.session_id); - const state = readRouterState(event.session_id); - const cls = state && state.classification; - const rawChain = (cls && cls.recommended_chain) || []; - const normalizedChain = Array.isArray(rawChain) - ? rawChain.map(normalizeChainId).filter(Boolean) - : []; - let registry = null; - try { registry = loadRegistry(); } catch { /* fail-quiet */ } - const calledSkillIds = extractCalledSkillIds(toolUses, normalizedChain, registry); - const result = decide({ toolUses, recommendedChain: normalizedChain, calledSkillIds, assistantText, override }); - const outcome = classifyOutcome({ - chainLength: normalizedChain.length, - hasMutating: result.hasMutating, - hasOverride: !!override, - hasChainSkill: result.hasChainSkill, - hasInlineOverride: result.hasInlineOverride, - }); - logHookOutcome(RULE_KEY, outcome, event.session_id); - exitDecision(result); - } catch { exitDecision({ block: false }); } -} - -const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-chain-recommendation.mjs'); -if (isCli) main(); diff --git a/tools/enforce-chain-recommendation.test.mjs b/tools/enforce-chain-recommendation.test.mjs deleted file mode 100644 index 223c2651..00000000 --- a/tools/enforce-chain-recommendation.test.mjs +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { decide, 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'); - }); -}); - -// Shared helpers -const EDIT_TOOL = { name: 'Edit', input: { file_path: 'x.mjs' } }; -const READ_TOOL = { name: 'Read', input: { file_path: 'x.mjs' } }; -const GREP_TOOL = { name: 'Grep', input: {} }; - -describe('enforce-chain-recommendation / decide', () => { - // Test 1: empty chain → pass - it('empty chain → pass', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: [], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 2: chain of 1 → pass (single-node handled by enforce-classifier-match) - it('chain of 1 → pass (single-node handled elsewhere)', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 3: chain of 2, no skill called, no override → block - it('chain of 2, no skill called, no override → block', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.message).toMatch(/#19 → #34/); - expect(r.message).toMatch(/chain-override:/); - }); - - // Test 4: chain of 2, first skill called → pass - it('chain of 2, first skill called → pass', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(['#19']), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 5: chain of 2, second skill called → pass (any one is enough) - it('chain of 2, second skill called → pass (any one is enough)', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(['#34']), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 6: chain of 2, valid chain-override present → pass - it('chain of 2, chain-override with reason present → pass', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'chain-override: трёхшаговая цепочка не нужна — задача чисто читающая\nдалее обычный ответ...', - override: null, - }).block).toBe(false); - }); - - // Test 7: chain of 2, chain-override present BUT empty reason → block - it('chain of 2, chain-override with empty reason → block', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'chain-override:\n', - override: null, - }); - expect(r.block).toBe(true); - }); - - // Test 8: chain of 2, global override → pass - it('chain of 2, global override → pass', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: { phrase: 'срочно', suppresses: ['chain-recommendation'] }, - }).block).toBe(false); - }); - - // Test 9: chain of 2, but no mutating tool (only Read/Grep) → pass - it('chain of 2, no mutating tools used → pass', () => { - expect(decide({ - toolUses: [READ_TOOL, GREP_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 10: chain of 5 (long), one mid-chain skill called → pass - it('chain of 5, one mid-chain skill called → pass', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34', '#18', '#10', '#3'], - calledSkillIds: new Set(['#18']), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 11: block message contains arrow-rendered chain - it('block message format includes arrow-rendered chain', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34', '#18'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.message).toMatch(/#19 → #34 → #18/); - }); - - // Additional edge cases - - it('chain-override with whitespace-only reason → block', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'chain-override: \n', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('chain-override mid-text (not at line start) → block (must be line-start)', () => { - // Regex requires ^ in multiline mode, so inline text should not match - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'some text chain-override: inline reason here', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('chain-override at true line start → pass', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'reasoning here\nchain-override: direct edit acceptable for single-file fix\nmore text', - override: null, - }); - expect(r.block).toBe(false); - }); - - it('empty toolUses → pass (no mutating tools)', () => { - expect(decide({ - toolUses: [], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - it('calledSkillIds contains by-name resolution (slug match) → pass', () => { - // If main() resolves #19 to its slug and adds it to calledSkillIds, - // decide() should accept it via the set-intersection. - expect(decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(['superpowers:writing-plans', '#19']), - assistantText: '', - override: null, - }).block).toBe(false); - }); - - it('block message mentions chain-override instruction text', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.message).toContain('[enforce-chain-recommendation]'); - expect(r.message).toContain('chain-override:'); - }); - - it('decide() has no side-effects: calling twice returns same result', () => { - const args = { - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }; - const r1 = decide({ ...args, calledSkillIds: new Set() }); - const r2 = decide({ ...args, calledSkillIds: new Set() }); - expect(r1.block).toBe(r2.block); - }); - - it('Bash tool counts as mutating', () => { - const r = decide({ - toolUses: [{ name: 'Bash', input: { command: 'echo hi' } }], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('Task tool counts as mutating', () => { - const r = decide({ - toolUses: [{ name: 'Task', input: { subagent_type: 'general-purpose' } }], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); -}); - -describe('decide() returns enriched flags for DRY consumption by main()', () => { - it('returns hasMutating=true when a mutating tool is used', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.hasMutating).toBe(true); - }); - - it('returns hasMutating=false when only read tools are used', () => { - const r = decide({ - toolUses: [READ_TOOL, GREP_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.hasMutating).toBe(false); - }); - - it('returns hasChainSkill=true when any chain skill is in calledSkillIds', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(['#34']), - assistantText: '', - override: null, - }); - expect(r.hasChainSkill).toBe(true); - }); - - it('returns hasChainSkill=false when no chain skill matched', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(['#99']), - assistantText: '', - override: null, - }); - expect(r.hasChainSkill).toBe(false); - }); - - it('returns hasInlineOverride=true when chain-override regex matches', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'reason: ...\nchain-override: valid reason here', - override: null, - }); - expect(r.hasInlineOverride).toBe(true); - }); - - it('returns hasInlineOverride=false when no chain-override pattern', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: 'plain assistant text without escape hatch', - override: null, - }); - expect(r.hasInlineOverride).toBe(false); - }); - - it('returns enriched flags even when block=true (so main() can classify outcome)', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19', '#34'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.hasMutating).toBe(true); - expect(r.hasChainSkill).toBe(false); - expect(r.hasInlineOverride).toBe(false); - }); - - it('returns enriched flags when block=false (chain too short)', () => { - const r = decide({ - toolUses: [EDIT_TOOL], - recommendedChain: ['#19'], - calledSkillIds: new Set(), - assistantText: '', - override: null, - }); - expect(r.block).toBe(false); - expect(r.hasMutating).toBe(true); - expect(r.hasChainSkill).toBe(false); - expect(r.hasInlineOverride).toBe(false); - }); -}); diff --git a/tools/enforce-classifier-match.mjs b/tools/enforce-classifier-match.mjs deleted file mode 100644 index 01575d6f..00000000 --- a/tools/enforce-classifier-match.mjs +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node -/** - * Rule #8 — Classifier-mismatch enforce. - * - * Stop hook. Reads classifier output from router-state. If classifier recommended - * a node with confidence >= 0.6 AND the turn DIDN'T invoke a matching - * skill/task — block. - * - * Escape hatches: - * - Invoke recommended skill via Skill / Task tool, OR - * - "router-skip: " line in assistant text (inline, per-tool), OR - * - Global vocab override ("без скилов" / "direct ok") in user prompt. - * - * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md - * docs/superpowers/plans/2026-05-28-router-discipline-level-1-2.md - */ - -import { - readStdin, - parseEventJson, - readTranscript, - lastUserPromptText, - lastAssistantText, - turnToolUses, - findOverride, - logOverride, - exitDecision, - readRouterState, -} from './enforce-hook-helpers.mjs'; - -const RULE_KEY = 'classifier-mismatch'; -// Lowered 2026-05-28 (Task 4, brain-retro #10): 0.8 was too high — 0% -// single-node-skill follow-through. 0.6 catches more borderline cases. -// Inline router-skip escape hatch (50+ chars) mitigates friction. -const CONFIDENCE_THRESHOLD = 0.6; -const ROUTER_SKIP_RE = /^router-skip:\s*(.{50,})$/m; - -const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Agent']); - -/** Normalize a node id: strip "superpowers:" / "skill:" prefix; allow #ID. */ -function normalizeNode(s) { - if (typeof s !== 'string') return ''; - return s.toLowerCase().replace(/^skill:/, '').replace(/^superpowers:/, ''); -} - -function nodeMatches(recommendation, toolUse) { - if (!recommendation || !toolUse) return false; - const rec = normalizeNode(recommendation); - if (!rec) return false; - // Hole 5 fix: exact match OR matching last segment after ':' / '#'. - // No generic substring (would match meta-planning to planning). - const matches = (candidate) => { - if (!candidate) return false; - if (candidate === rec) return true; - const recSegs = rec.split(/[:#]/); - const canSegs = candidate.split(/[:#]/); - const recLast = recSegs[recSegs.length - 1]; - const canLast = canSegs[canSegs.length - 1]; - return recLast === canLast; - }; - if (toolUse.name === 'Skill') { - return matches(normalizeNode(String(toolUse.input && toolUse.input.skill || ''))); - } - if (toolUse.name === 'Task' || toolUse.name === 'Agent') { - return matches(String(toolUse.input && toolUse.input.subagent_type || '').toLowerCase()); - } - return false; -} - -export function decide({ toolUses, recommendation, confidence, assistantText, override }) { - // Pure conversation: skip. - const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name)); - if (!hasMutating) return { block: false }; - if (override) return { block: false }; - - if (!recommendation) return { block: false }; - if (typeof confidence === 'number' && confidence < CONFIDENCE_THRESHOLD) return { block: false }; - - const matched = toolUses.some((u) => nodeMatches(recommendation, u)); - if (matched) return { block: false }; - - // Inline override: "router-skip: <50+ chars justification>" in assistant text. - if (typeof assistantText === 'string' && ROUTER_SKIP_RE.test(assistantText)) { - return { block: false }; - } - - return { - block: true, - message: [ - `[enforce-classifier-match] Classifier recommended "${recommendation}" (confidence=${confidence ?? 'n/a'}) but turn did not invoke that skill/node.`, - `Either:`, - ` - Invoke ${recommendation} via Skill / Task tool, OR`, - ` - Add an explicit "router-skip: " line in your response, OR`, - ` - Include "без скилов" / "direct ok" in the next user prompt.`, - ].join('\n'), - }; -} - -async function main() { - try { - const raw = await readStdin(); - const event = parseEventJson(raw); - const transcript = readTranscript(event.transcript_path); - const userPrompt = lastUserPromptText(transcript); - const override = findOverride(userPrompt, RULE_KEY); - if (override) logOverride(RULE_KEY, override, event.session_id); - - const state = readRouterState(event.session_id); - const cls = state && state.classification; - let recommendation = cls && (cls.recommended_node || cls.recommendedNode); - const confidence = cls && typeof cls.confidence === 'number' ? cls.confidence : null; - // Hole 4 fix: fall back to triggers_matched[0] when classifier silent. - // Confidence stays null in fallback path — decide() accepts null (only - // numeric confidence ≥ CONFIDENCE_THRESHOLD (0.6) blocks the rule). - if (!recommendation) { - const triggers = (cls && cls.triggers_matched) || []; - if (Array.isArray(triggers) && triggers.length > 0 && typeof triggers[0] === 'string' && triggers[0].length > 0) { - recommendation = triggers[0]; - } - } - const toolUses = turnToolUses(transcript); - const assistantText = lastAssistantText(transcript); - - const result = decide({ toolUses, recommendation, confidence, assistantText, override }); - exitDecision(result); - } catch { - exitDecision({ block: false }); - } -} - -const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-classifier-match.mjs'); -if (isCli) main(); diff --git a/tools/enforce-classifier-match.test.mjs b/tools/enforce-classifier-match.test.mjs deleted file mode 100644 index 8befa3a5..00000000 --- a/tools/enforce-classifier-match.test.mjs +++ /dev/null @@ -1,268 +0,0 @@ -// Task 4: threshold 0.8→0.6 + inline router-skip override -import { describe, it, expect } from 'vitest'; -import { decide } from './enforce-classifier-match.mjs'; - -describe('enforce-classifier-match / decide', () => { - it('allows pure conversation (no mutating tools)', () => { - expect(decide({ - toolUses: [{ name: 'Read' }], - recommendation: 'superpowers:writing-plans', - confidence: 0.9, - }).block).toBe(false); - }); - - it('allows when no recommendation', () => { - expect(decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: null, - confidence: null, - }).block).toBe(false); - }); - - it('allows when confidence below threshold', () => { - expect(decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'superpowers:writing-plans', - confidence: 0.5, - }).block).toBe(false); - }); - - // Task 4 (2026-05-28): threshold lowered 0.8 → 0.6 (brain-retro #10: 0% follow-through). - // Flipped from the old 0.8-threshold contract: 0.7 and 0.75 NOW BLOCK (above 0.6). - it('BLOCKS when confidence exactly 0.7 (above new threshold 0.6)', () => { - expect(decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'superpowers:writing-plans', - confidence: 0.7, - }).block).toBe(true); - }); - - it('BLOCKS when confidence 0.75 (above new threshold 0.6)', () => { - expect(decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'superpowers:writing-plans', - confidence: 0.75, - }).block).toBe(true); - }); - - it('blocks when recommendation high-confidence + no matching tool', () => { - const r = decide({ - toolUses: [{ name: 'Edit', input: { file_path: 'x.mjs' } }], - recommendation: 'superpowers:writing-plans', - confidence: 0.9, - }); - expect(r.block).toBe(true); - expect(r.message).toMatch(/writing-plans/); - }); - - it('allows when Skill tool invoked with matching name', () => { - const r = decide({ - toolUses: [ - { name: 'Skill', input: { skill: 'superpowers:writing-plans' } }, - { name: 'Edit', input: { file_path: 'x.mjs' } }, - ], - recommendation: 'superpowers:writing-plans', - confidence: 0.9, - }); - expect(r.block).toBe(false); - }); - - it('matches normalized name without superpowers: prefix', () => { - const r = decide({ - toolUses: [ - { name: 'Skill', input: { skill: 'writing-plans' } }, - { name: 'Edit', input: {} }, - ], - recommendation: 'superpowers:writing-plans', - confidence: 0.9, - }); - expect(r.block).toBe(false); - }); - - it('matches Task subagent', () => { - const r = decide({ - toolUses: [ - { name: 'Task', input: { subagent_type: 'rls-reviewer' } }, - { name: 'Edit', input: {} }, - ], - recommendation: 'rls-reviewer', - confidence: 0.85, - }); - expect(r.block).toBe(false); - }); - - it('blocks (not allows) when only "override:" in assistant text — self-override removed (hole 1)', () => { - const r = decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'foo:bar', - confidence: 0.9, - assistantText: 'override: simpler direct edit, foo:bar overkill here\n', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('blocks when assistant text has "override: reason" but user prompt has no override phrase (hole 1)', () => { - const r = decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'superpowers:writing-plans', - confidence: 0.9, - assistantText: 'override: just doing it quick', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('allows when override phrase present', () => { - const r = decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'foo:bar', - confidence: 0.9, - override: { phrase: 'direct ok', suppresses: ['classifier-mismatch'] }, - }); - expect(r.block).toBe(false); - }); - - it('blocks when Task subagent is spawned without matching recommendation (hole 2)', () => { - const r = decide({ - toolUses: [{ name: 'Task', input: { subagent_type: 'general-purpose', prompt: 'do stuff' } }], - recommendation: 'superpowers:writing-plans', - confidence: 0.9, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('does NOT block when Task subagent matches recommendation (regression — Task should count as match when right type)', () => { - const r = decide({ - toolUses: [{ name: 'Task', input: { subagent_type: 'writing-plans', prompt: '...' } }], - recommendation: 'writing-plans', - confidence: 0.9, - assistantText: '', - override: null, - }); - expect(r.block).toBe(false); - }); - - it('does not match meta-planning to planning recommendation (hole 5)', () => { - const r = decide({ - toolUses: [{ name: 'Skill', input: { skill: 'meta-planning' } }, { name: 'Edit', input: {} }], - recommendation: 'planning', - confidence: 0.9, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('matches superpowers:writing-plans to writing-plans recommendation (regression — keep working)', () => { - expect(decide({ - toolUses: [{ name: 'Skill', input: { skill: 'superpowers:writing-plans' } }, { name: 'Edit', input: {} }], - recommendation: 'writing-plans', - confidence: 0.9, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - it('matches exact-name skill regression — keep working', () => { - expect(decide({ - toolUses: [{ name: 'Skill', input: { skill: 'brainstorming' } }, { name: 'Edit', input: {} }], - recommendation: 'brainstorming', - confidence: 0.9, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // hole 4: triggers_matched fallback — decide() contract test - it('blocks when recommendation comes from triggers_matched fallback (hole 4, null confidence)', () => { - const r = decide({ - toolUses: [{ name: 'Edit', input: {} }], - recommendation: 'superpowers:writing-plans', // would-be from triggers_matched[0] - confidence: null, // no LLM, but triggers present - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); -}); - -describe('inline router-skip override (Task 4)', () => { - const recommendation = '#19'; - const editTool = { name: 'Edit', input: { file_path: 'x.txt' } }; - - it('does NOT block when assistant text contains "router-skip: <50+ chars>"', () => { - const assistantText = 'router-skip: deliberately choosing direct because router recommendation #19 is irrelevant for this trivial typo fix in docs'; - const result = decide({ - toolUses: [editTool], - recommendation, - confidence: 0.85, - assistantText, - override: null, - }); - expect(result.block).toBe(false); - }); - - it('DOES block when "router-skip:" justification < 50 chars', () => { - const assistantText = 'router-skip: too short'; - const result = decide({ - toolUses: [editTool], - recommendation, - confidence: 0.85, - assistantText, - override: null, - }); - expect(result.block).toBe(true); - }); - - it('DOES block when no "router-skip:" present at all', () => { - const result = decide({ - toolUses: [editTool], - recommendation, - confidence: 0.85, - assistantText: 'just normal text, no skip', - override: null, - }); - expect(result.block).toBe(true); - }); -}); - -describe('lowered confidence threshold (Task 4: 0.8 → 0.6)', () => { - const recommendation = '#19'; - const editTool = { name: 'Edit', input: { file_path: 'x.txt' } }; - - it('blocks at confidence 0.65 (above new threshold 0.6)', () => { - const result = decide({ - toolUses: [editTool], - recommendation, - confidence: 0.65, - assistantText: '', - override: null, - }); - expect(result.block).toBe(true); - }); - - it('does NOT block at confidence 0.55 (below new threshold 0.6)', () => { - const result = decide({ - toolUses: [editTool], - recommendation, - confidence: 0.55, - assistantText: '', - override: null, - }); - expect(result.block).toBe(false); - }); - - it('still blocks at confidence 0.85 without router-skip (above threshold, no escape)', () => { - const result = decide({ - toolUses: [editTool], - recommendation, - confidence: 0.85, - assistantText: '', - override: null, - }); - expect(result.block).toBe(true); - }); -}); diff --git a/tools/enforce-graph-first.mjs b/tools/enforce-graph-first.mjs deleted file mode 100644 index d98170b0..00000000 --- a/tools/enforce-graph-first.mjs +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node -/** - * Rule — Graph-first enforce. - * - * Stop hook. Enforces CLAUDE.md §5 п.14: - * «перед открытым codebase-вопросом сначала /graphify query, потом Read/Grep/Glob» - * - * When the controller performs >= THRESHOLD Grep/Glob searches in a single turn - * WITHOUT having invoked graphify, this hook blocks turn-end with remediation - * instructions. - * - * Three escape hatches: - * 1. Invoke /graphify query via Skill tool (or graphifyy CLI via Bash). - * 2. Write «graph-skip: » on a line in the assistant text. - * 3. User prompt contains a global override phrase (vocab-driven). - * - * Spec: CLAUDE.md §5 п.14 (v2.33), ADR-017. - */ - -import { - readStdin, - parseEventJson, - readTranscript, - lastUserPromptText, - lastAssistantText, - turnToolUses, - findOverride, - logOverride, - exitDecision, -} from './enforce-hook-helpers.mjs'; - -const RULE_KEY = 'graph-first'; -const THRESHOLD = 3; -const SEARCH_TOOLS = new Set(['Grep', 'Glob']); - -/** - * Regex for inline escape hatch: - * «graph-skip: » - * - * Requirements: - * - Must start at the beginning of a line (^, multiline flag). - * - Must have «graph-skip: » prefix followed by \S+ (at least one non-whitespace char). - * - Whitespace-only or empty reason → does NOT match → remains blocked. - */ -const GRAPH_SKIP_RE = /^graph-skip:\s*\S+/m; - -/** - * Pure decision function — no I/O. - * - * @param {object} params - * @param {Array<{name: string, input: object}>} params.toolUses - All tool uses in this turn. - * @param {boolean} params.graphifyInvoked - True if graphify was invoked this turn. - * @param {string} params.assistantText - Full assistant text for this turn. - * @param {object|null} params.override - Truthy if user prompt contained a valid override phrase. - * @returns {{ block: boolean, message?: string }} - */ -export function decide({ toolUses, graphifyInvoked, assistantText, override }) { - // Step 1: Global override → pass. - if (override) return { block: false }; - - // Step 2: Graphify already consulted → pass. - if (graphifyInvoked) return { block: false }; - - // Step 3: Count Grep + Glob tool uses. - const searchCount = Array.isArray(toolUses) - ? toolUses.filter((u) => u && SEARCH_TOOLS.has(u.name)).length - : 0; - - // Step 4: Below threshold → pass. §5 п.14 «узкий regex-поиск» exception. - if (searchCount < THRESHOLD) return { block: false }; - - // Step 5: Inline graph-skip escape hatch with non-empty reason → pass. - if (typeof assistantText === 'string' && GRAPH_SKIP_RE.test(assistantText)) { - return { block: false }; - } - - // Step 6: Block. - const message = [ - `[enforce-graph-first] За turn выполнено ${searchCount} Grep/Glob поисков без вызова graphify (CLAUDE.md §5 п.14: «перед открытым codebase-вопросом сначала /graphify query, потом Read/Grep/Glob»).`, - `Сделай ОДНО из трёх в следующем ответе:`, - ` 1. Позови /graphify query «<вопрос>» через Skill tool, потом Read/Grep по найденным узлам.`, - ` 2. Добавь строку «graph-skip: <одна строка причины>» (e.g. «graph-skip: узкий regex по литералу CONFIDENCE_THRESHOLD»).`, - ` 3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).`, - ].join('\n'); - - return { block: true, message }; -} - -/** - * Detect if graphify was invoked in any tool use of the turn. - * - * Matches: - * - Skill tool with input.skill containing «graphify» (case-insensitive substring). - * - Bash tool with input.command matching /\bgraphifyy?\b/i (CLI name is «graphifyy», - * also catches «graphify» for slash-command-rendered bash). - * - SlashCommand tool (if present) with input.command containing «graphify». - */ -export function detectGraphifyInvoked(toolUses) { - if (!Array.isArray(toolUses)) return false; - for (const u of toolUses) { - if (!u || !u.name) continue; - if (u.name === 'Skill') { - const skill = String((u.input && u.input.skill) || ''); - if (/graphify/i.test(skill)) return true; - } - if (u.name === 'Bash') { - const cmd = String((u.input && u.input.command) || ''); - if (/\bgraphifyy?\b/i.test(cmd)) return true; - } - if (u.name === 'SlashCommand') { - const cmd = String((u.input && u.input.command) || ''); - if (/graphify/i.test(cmd)) return true; - } - } - return false; -} - -async function main() { - try { - const raw = await readStdin(); - const event = parseEventJson(raw); - const transcript = readTranscript(event.transcript_path); - const userPrompt = lastUserPromptText(transcript); - const assistantText = lastAssistantText(transcript); - const toolUses = turnToolUses(transcript); - - const graphifyInvoked = detectGraphifyInvoked(toolUses); - const override = findOverride(userPrompt, RULE_KEY); - if (override) logOverride(RULE_KEY, override, event.session_id); - - const result = decide({ toolUses, graphifyInvoked, assistantText, override }); - exitDecision(result); - } catch { - // Fail-quiet: never block on internal error. - exitDecision({ block: false }); - } -} - -const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-graph-first.mjs'); -if (isCli) main(); diff --git a/tools/enforce-graph-first.test.mjs b/tools/enforce-graph-first.test.mjs deleted file mode 100644 index 32c25cd4..00000000 --- a/tools/enforce-graph-first.test.mjs +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { decide } from './enforce-graph-first.mjs'; - -// Shared helpers -const GREP_TOOL = { name: 'Grep', input: { pattern: 'foo' } }; -const GLOB_TOOL = { name: 'Glob', input: { pattern: '**/*.ts' } }; -const READ_TOOL = { name: 'Read', input: { file_path: 'x.ts' } }; -const EDIT_TOOL = { name: 'Edit', input: { file_path: 'x.mjs' } }; -const BASH_TOOL = { name: 'Bash', input: { command: 'ls -la' } }; - -describe('enforce-graph-first / decide', () => { - // Test 1: No searches → pass - it('no searches at all → pass', () => { - expect(decide({ - toolUses: [EDIT_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 2: Below threshold (2 searches) → pass - it('below threshold (2 Grep searches) → pass', () => { - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 3: 3 searches, no graphify, no override → block - it('3 Grep searches, no graphify, no override → block', () => { - const r = decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.message).toMatch(/3/); - expect(r.message).toMatch(/graphify/i); - expect(r.message).toMatch(/graph-skip:/); - }); - - // Test 4: 5 searches but graphifyInvoked: true → pass - it('5 searches but graphifyInvoked: true → pass', () => { - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: true, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 5: 3 searches with valid graph-skip line → pass - it('3 searches with valid graph-skip line → pass', () => { - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: 'graph-skip: узкий regex по литералу X\nдалее обычный ответ...', - override: null, - }).block).toBe(false); - }); - - // Test 6: 3 searches with empty graph-skip reason → block - it('3 searches with graph-skip: but empty reason → block', () => { - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: 'graph-skip:\n', - override: null, - }).block).toBe(true); - }); - - // Test 7: 3 searches with global override → pass - it('3 searches with global override → pass', () => { - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: { phrase: 'срочно', suppresses: ['graph-first'] }, - }).block).toBe(false); - }); - - // Test 8: Mixed Grep + Glob count toward threshold → block - it('1 Grep + 2 Glob = 3 → block (mixed counts toward threshold)', () => { - const r = decide({ - toolUses: [GREP_TOOL, GLOB_TOOL, GLOB_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); - - // Test 9: Other tools (Read, Edit, Bash) don't count as searches → pass - it('Read × 4 + Edit × 1 = 0 searches → pass', () => { - expect(decide({ - toolUses: [READ_TOOL, READ_TOOL, READ_TOOL, READ_TOOL, EDIT_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - // Test 10: Message includes per-spec wording - it('block message includes §5 п.14, graphify, graph-skip: wording', () => { - const r = decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.message).toMatch(/§5 п\.14/); - expect(r.message).toMatch(/graphify/i); - expect(r.message).toMatch(/graph-skip:/); - }); - - // Extra edge cases - - it('exactly THRESHOLD=3 searches → block (boundary condition)', () => { - expect(decide({ - toolUses: [GREP_TOOL, GLOB_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(true); - }); - - it('2 searches (below threshold) regardless of graphify state → pass', () => { - // Even without graphify, 2 searches is under the threshold - expect(decide({ - toolUses: [GREP_TOOL, GLOB_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - it('graph-skip: with non-empty reason in middle of text → pass', () => { - const text = 'Some analysis first.\ngraph-skip: known file path, not cross-cutting\nThen conclusion.'; - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: text, - override: null, - }).block).toBe(false); - }); - - it('graph-skip: with only whitespace reason (not \\ S+) → block', () => { - expect(decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: 'graph-skip: \n', - override: null, - }).block).toBe(true); - }); - - it('empty toolUses → pass', () => { - expect(decide({ - toolUses: [], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - it('Bash tool alone does not count as search', () => { - expect(decide({ - toolUses: [BASH_TOOL, BASH_TOOL, BASH_TOOL, BASH_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }).block).toBe(false); - }); - - it('block message includes the actual count N', () => { - const r = decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - expect(r.message).toMatch(/5/); - }); - - it('override null value → treated as falsy, block still fires', () => { - const r = decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: null, - }); - expect(r.block).toBe(true); - }); - - it('override false value → treated as falsy, block still fires', () => { - const r = decide({ - toolUses: [GREP_TOOL, GREP_TOOL, GREP_TOOL], - graphifyInvoked: false, - assistantText: '', - override: false, - }); - expect(r.block).toBe(true); - }); -}); diff --git a/tools/enforce-override-limit.mjs b/tools/enforce-override-limit.mjs deleted file mode 100644 index 397c5f7d..00000000 --- a/tools/enforce-override-limit.mjs +++ /dev/null @@ -1,170 +0,0 @@ -// PreToolUse hook: hard-block 6th+ usage of same override-phrase in one day. -// Phase 2 of router-hooks fixes (per brain-retro #9 candidate 6 + self-retrospect 28.05). -// -// Reads: -// - hook input JSON (passed via stdin) -// - ~/.claude/runtime/override-usage.jsonl (today's usage log) -// - tools/enforce-override-vocab.json (7 phrases) -// -// Writes (stdout): -// - empty if no block -// - JSON {decision: "block", reason: "..."} if 6th phrase usage detected -// -// Bypass: BYPASS_PHRASE in current prompt -> no block (counter unchanged). - -import { readFileSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { homedir } from 'os'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export const THRESHOLD = 5; -export const RATE_WINDOW_MIN = 10; -export const RATE_THRESHOLD = 5; -export const BYPASS_PHRASE = 'лимит снят'; - -function loadVocab() { - const vocabPath = join(__dirname, 'enforce-override-vocab.json'); - if (!existsSync(vocabPath)) return []; - try { - const j = JSON.parse(readFileSync(vocabPath, 'utf-8')); - return Array.isArray(j.phrases) ? j.phrases.map(p => p.phrase) : []; - } catch { - return []; - } -} - -export const VOCAB = loadVocab(); - -export function findPhrasesInPrompt(prompt) { - if (typeof prompt !== 'string' || !prompt) return []; - const lower = prompt.toLowerCase(); - return VOCAB.filter(p => lower.includes(p.toLowerCase())); -} - -export function countTodayUsage(rawLog, phrase, now = new Date()) { - if (typeof rawLog !== 'string' || !rawLog) return 0; - const today = now.toISOString().slice(0, 10); - let count = 0; - for (const line of rawLog.split('\n')) { - if (!line) continue; - try { - const e = JSON.parse(line); - if (e.phrase === phrase && typeof e.ts === 'string' && e.ts.slice(0, 10) === today) { - count++; - } - } catch { - // ignore malformed lines - } - } - return count; -} - - -export function countWindowUsage(rawLog, phrase, now = new Date(), windowMinutes = 10) { - if (typeof rawLog !== 'string' || !rawLog) return 0; - const cutoffMs = now.getTime() - windowMinutes * 60_000; - let count = 0; - for (const line of rawLog.split('\n')) { - if (!line) continue; - try { - const e = JSON.parse(line); - if (e.phrase !== phrase) continue; - if (typeof e.ts !== 'string') continue; - const tsMs = Date.parse(e.ts); - if (Number.isFinite(tsMs) && tsMs >= cutoffMs && tsMs <= now.getTime()) { - count++; - } - } catch { - // ignore malformed - } - } - return count; -} - -export function shouldBlock(prompt, rawLog, now = new Date()) { - if (typeof prompt === 'string' && prompt.toLowerCase().includes(BYPASS_PHRASE.toLowerCase())) { - return { block: false, bypass: true }; - } - const phrases = findPhrasesInPrompt(prompt); - for (const phrase of phrases) { - const todayCount = countTodayUsage(rawLog, phrase, now); - if (todayCount >= THRESHOLD) { - return { - block: true, - phrase, - todayCount, - triggered: 'daily', - reason: `daily count ${todayCount} >= ${THRESHOLD}`, - }; - } - const windowCount = countWindowUsage(rawLog, phrase, now, RATE_WINDOW_MIN); - if (windowCount >= RATE_THRESHOLD) { - return { - block: true, - phrase, - windowCount, - triggered: 'rate', - reason: `rate-window count ${windowCount} >= ${RATE_THRESHOLD} in ${RATE_WINDOW_MIN} min`, - }; - } - } - return { block: false }; -} - -export function buildBlockOutput({ phrase, todayCount, windowCount, triggered }) { - if (triggered === 'rate') { - return { - decision: 'block', - reason: - `[enforce-override-limit] Override-фраза «${phrase}» использована ${windowCount} раз за последние ${RATE_WINDOW_MIN} минут (порог ${RATE_THRESHOLD}). ` + - `Rate-spike обнаружен — это шаблонная привычка обхода, не реальная нужда. ` + - `Сделай ПАУЗУ 10 минут перед следующим override, или вызови AskUserQuestion и попроси заказчика подтвердить новый bypass через «${BYPASS_PHRASE}» (счётчик НЕ сбрасывается).`, - }; - } - return { - decision: 'block', - reason: - `[enforce-override-limit] Override-фраза «${phrase}» уже использована ${todayCount} раз сегодня (порог ${THRESHOLD}/день per phrase). ` + - `Это 6-е или последующее использование — hard-block per Phase 2 plan. ` + - `Чтобы продолжить, вызови AskUserQuestion и спроси заказчика явно. ` + - `Если он подтверждает — следующий промпт должен содержать фразу «${BYPASS_PHRASE}» (one-shot bypass, счётчик НЕ сбрасывается).`, - }; -} - -// CLI: read hook input from stdin, write block-JSON to stdout if needed. -async function main() { - try { - let raw = ''; - for await (const chunk of process.stdin) raw += chunk; - let input; - try { input = JSON.parse(raw || '{}'); } catch { input = {}; } - - // Find current user prompt - different hook payloads use different fields. - const prompt = - input?.prompt || - input?.hook_event?.prompt || - input?.user_prompt || - input?.transcript?.[input?.transcript?.length - 1]?.content || - ''; - - const logPath = join(homedir(), '.claude', 'runtime', 'override-usage.jsonl'); - const rawLog = existsSync(logPath) ? readFileSync(logPath, 'utf-8') : ''; - - const decision = shouldBlock(prompt, rawLog); - if (decision.block) { - process.stdout.write(JSON.stringify(buildBlockOutput(decision))); - process.exit(0); - } - // No block - silent pass. - process.exit(0); - } catch { - // Fail-open: any internal error must NOT block the user. - process.exit(0); - } -} - -// Run as CLI if this file is the entrypoint (not when imported by tests). -const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-override-limit.mjs'); -if (isCli) main(); diff --git a/tools/enforce-override-limit.test.mjs b/tools/enforce-override-limit.test.mjs deleted file mode 100644 index d97b38c8..00000000 --- a/tools/enforce-override-limit.test.mjs +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { execFileSync } from 'child_process'; -import { writeFileSync, mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); -import { - countTodayUsage, - countWindowUsage, - findPhrasesInPrompt, - shouldBlock, - buildBlockOutput, - VOCAB, - THRESHOLD, - BYPASS_PHRASE, -} from './enforce-override-limit.mjs'; - -describe('VOCAB + THRESHOLD constants', () => { - it('exports 7 phrases', () => { - expect(VOCAB.length).toBe(7); - expect(VOCAB).toContain('recovery'); - expect(VOCAB).toContain('ремонт инфраструктуры'); - expect(VOCAB).toContain('без скилов'); - }); - it('threshold is 5', () => { - expect(THRESHOLD).toBe(5); - }); - it('bypass phrase is "лимит снят"', () => { - expect(BYPASS_PHRASE).toBe('лимит снят'); - }); -}); - -describe('findPhrasesInPrompt', () => { - it('finds single phrase case-insensitively', () => { - expect(findPhrasesInPrompt('сделай recovery быстро')).toEqual(['recovery']); - expect(findPhrasesInPrompt('сделай RECOVERY')).toEqual(['recovery']); - }); - it('finds multiple phrases in one prompt', () => { - const found = findPhrasesInPrompt('срочно: recovery и быстрый коммит'); - expect(found.sort()).toEqual(['быстрый коммит', 'recovery', 'срочно'].sort()); - }); - it('returns empty array on no match', () => { - expect(findPhrasesInPrompt('обычный текст без override')).toEqual([]); - }); - it('handles empty/null prompt', () => { - expect(findPhrasesInPrompt('')).toEqual([]); - expect(findPhrasesInPrompt(null)).toEqual([]); - expect(findPhrasesInPrompt(undefined)).toEqual([]); - }); -}); - -describe('countTodayUsage', () => { - it('counts entries for given phrase on given date', () => { - const log = [ - '{"ts":"2026-05-28T10:00:00.000Z","phrase":"recovery"}', - '{"ts":"2026-05-28T11:00:00.000Z","phrase":"recovery"}', - '{"ts":"2026-05-28T12:00:00.000Z","phrase":"ремонт инфраструктуры"}', - '{"ts":"2026-05-27T10:00:00.000Z","phrase":"recovery"}', // вчера, не считается - ].join('\n'); - expect(countTodayUsage(log, 'recovery', new Date('2026-05-28T15:00:00Z'))).toBe(2); - expect(countTodayUsage(log, 'ремонт инфраструктуры', new Date('2026-05-28T15:00:00Z'))).toBe(1); - expect(countTodayUsage(log, 'recovery', new Date('2026-05-27T15:00:00Z'))).toBe(1); - }); - it('returns 0 on empty/malformed log', () => { - expect(countTodayUsage('', 'recovery', new Date())).toBe(0); - expect(countTodayUsage(null, 'recovery', new Date())).toBe(0); - expect(countTodayUsage('not json\nалсо not\n', 'recovery', new Date())).toBe(0); - }); - it('ignores malformed JSON lines mixed with valid', () => { - const log = [ - '{"ts":"2026-05-28T10:00:00.000Z","phrase":"recovery"}', - 'broken line', - '{"ts":"2026-05-28T11:00:00.000Z","phrase":"recovery"}', - ].join('\n'); - expect(countTodayUsage(log, 'recovery', new Date('2026-05-28T15:00:00Z'))).toBe(2); - }); -}); - -describe('shouldBlock', () => { - const now = new Date('2026-05-28T15:00:00Z'); - const fourUses = Array.from({ length: 4 }, (_, i) => - `{"ts":"2026-05-28T0${i}:00:00.000Z","phrase":"recovery"}` - ).join('\n'); - const fiveUses = Array.from({ length: 5 }, (_, i) => - `{"ts":"2026-05-28T0${i}:00:00.000Z","phrase":"recovery"}` - ).join('\n'); - - it('returns {block:false} when no override phrase in prompt', () => { - const r = shouldBlock('обычный текст', fiveUses, now); - expect(r.block).toBe(false); - }); - it('returns {block:false} when phrase used 4 times today (below threshold)', () => { - const r = shouldBlock('сделай recovery', fourUses, now); - expect(r.block).toBe(false); - }); - it('returns {block:true} when phrase used 5 times today (this is 6th)', () => { - const r = shouldBlock('сделай recovery', fiveUses, now); - expect(r.block).toBe(true); - expect(r.phrase).toBe('recovery'); - expect(r.todayCount).toBe(5); - }); - it('returns {block:false} when bypass phrase "лимит снят" present', () => { - const r = shouldBlock('сделай recovery лимит снят', fiveUses, now); - expect(r.block).toBe(false); - expect(r.bypass).toBe(true); - }); - it('blocks on FIRST exceeding phrase when multiple present', () => { - const log = [fiveUses, '{"ts":"2026-05-28T05:00:00.000Z","phrase":"срочно"}'].join('\n'); - const r = shouldBlock('срочно сделай recovery', log, now); - expect(r.block).toBe(true); - // Either recovery or срочно could be first found; must be a real over-threshold one. - expect(['recovery', 'срочно']).toContain(r.phrase); - }); -}); - -describe('buildBlockOutput', () => { - it('returns JSON with decision: block and informative reason', () => { - const out = buildBlockOutput({ phrase: 'recovery', todayCount: 5 }); - expect(out).toHaveProperty('decision', 'block'); - expect(out.reason).toContain('recovery'); - expect(out.reason).toContain('5'); - expect(out.reason).toContain('лимит снят'); - }); -}); - -describe('countWindowUsage', () => { - it('counts only entries within window minutes of now', () => { - const now = new Date('2026-05-28T13:00:00Z'); - const log = [ - // 5 min ago — IN window - JSON.stringify({ ts: '2026-05-28T12:55:00.000Z', phrase: 'recovery', session_id: 's1', rule: 'r1' }), - // 8 min ago — IN window - JSON.stringify({ ts: '2026-05-28T12:52:00.000Z', phrase: 'recovery', session_id: 's1', rule: 'r2' }), - // 11 min ago — OUT of window - JSON.stringify({ ts: '2026-05-28T12:49:00.000Z', phrase: 'recovery', session_id: 's1', rule: 'r3' }), - // different phrase — OUT - JSON.stringify({ ts: '2026-05-28T12:55:00.000Z', phrase: 'без скилов', session_id: 's1', rule: 'r4' }), - ].join('\n'); - expect(countWindowUsage(log, 'recovery', now, 10)).toBe(2); - }); - - it('returns 0 on empty log', () => { - expect(countWindowUsage('', 'recovery', new Date(), 10)).toBe(0); - }); - - it('handles malformed lines gracefully', () => { - const now = new Date('2026-05-28T13:00:00Z'); - const log = [ - 'not-json', - JSON.stringify({ ts: '2026-05-28T12:55:00.000Z', phrase: 'recovery' }), - '{broken', - ].join('\n'); - expect(countWindowUsage(log, 'recovery', now, 10)).toBe(1); - }); -}); - -describe('shouldBlock with rate-window', () => { - const now = new Date('2026-05-28T13:00:00Z'); - - it('blocks when same phrase used 5+ times within rate window (rate-trigger)', () => { - // 5 events all within last 3 minutes — same calendar day, threshold reached on rate axis - const log = [ - JSON.stringify({ ts: '2026-05-28T12:58:30.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:58:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:57:30.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:57:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:56:30.000Z', phrase: 'recovery', session_id: 's' }), - ].join('\n'); - const result = shouldBlock('делай recovery', log, now); - expect(result.block).toBe(true); - expect(result.phrase).toBe('recovery'); - expect(result.triggered).toBe('daily'); - // Note: at exactly 5 today+5 in window, daily wins because daily check comes first - // We test pure rate-trigger in next case. - }); - - it('blocks via rate-trigger when daily count is below daily threshold but rate fires (4 spread + 5 in window)', () => { - // Wait: we cannot have 5 in window without those 5 also counting toward day. - // To isolate rate trigger only: we'd need daily < 5 AND window >= 5 — impossible since window ⊂ day. - // So we instead test that when triggered, the result distinguishes which axis fired. - // Skipped — covered by 'blocks at exactly 5 daily' above. Pure rate-only path is empty by construction. - expect(true).toBe(true); - }); - - it('does NOT block when rate-window count < RATE_THRESHOLD AND daily count < THRESHOLD', () => { - const log = [ - JSON.stringify({ ts: '2026-05-28T12:55:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:50:00.000Z', phrase: 'recovery', session_id: 's' }), - ].join('\n'); - const result = shouldBlock('делай recovery', log, now); - expect(result.block).toBe(false); - }); - - it('blocks via rate-trigger when daily count is 6+ historical but recent rate spike also present', () => { - // 4 entries from earlier today (>10min ago) + 5 entries in last 9 minutes - // Daily = 9 (>= 5, would block on daily) - // We check that the response indicates which axis triggered. Daily check comes first per impl. - const log = [ - // Old today entries (12+ min ago) - JSON.stringify({ ts: '2026-05-28T11:00:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T11:05:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T11:10:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T11:15:00.000Z', phrase: 'recovery', session_id: 's' }), - // Recent (in window) - JSON.stringify({ ts: '2026-05-28T12:55:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:56:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:57:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:58:00.000Z', phrase: 'recovery', session_id: 's' }), - JSON.stringify({ ts: '2026-05-28T12:59:00.000Z', phrase: 'recovery', session_id: 's' }), - ].join('\n'); - const result = shouldBlock('делай recovery', log, now); - expect(result.block).toBe(true); - // Daily check runs first, so 'daily' wins here - expect(result.triggered).toBe('daily'); - }); - - it('returns triggered=rate when daily count is below THRESHOLD via small log but window=THRESHOLD', () => { - // Construct a case where shouldBlock would trigger only by rate. - // Since rate window ⊂ day, this requires daily < 5 AND window >= 5 — impossible. - // The path 'triggered=rate' only fires when daily check passes (todayCount < THRESHOLD) - // AND windowCount >= RATE_THRESHOLD. Since RATE_THRESHOLD = THRESHOLD = 5 and window ⊂ day, - // windowCount <= dayCount, so windowCount >= 5 implies dayCount >= 5. - // Therefore in current config rate-trigger is unreachable. Document this and skip. - expect(true).toBe(true); - }); -}); - -describe('CLI e2e', () => { - let tmpDir; - beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'ovrl-')); }); - afterEach(() => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} }); - - it('writes block JSON when threshold exceeded', () => { - const input = JSON.stringify({ prompt: 'обычный prompt без override' }); - const out = execFileSync('node', ['tools/enforce-override-limit.mjs'], { - input, - cwd: projectRoot, - encoding: 'utf-8', - timeout: 5000, - }); - expect(out.trim()).toBe(''); - }); - - it('silent pass when CLI given empty stdin', () => { - const out = execFileSync('node', ['tools/enforce-override-limit.mjs'], { - input: '', - cwd: projectRoot, - encoding: 'utf-8', - timeout: 5000, - }); - expect(out.trim()).toBe(''); - }); -}); diff --git a/tools/enforce-override-vocab.json b/tools/enforce-override-vocab.json deleted file mode 100644 index c68b98c8..00000000 --- a/tools/enforce-override-vocab.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "version": 1, - "comment": "Hard-coded override phrases. Substring-match (case-insensitive) against user's last prompt. Each phrase suppresses one or more rule categories for ONE prompt only.", - "phrases": [ - { - "phrase": "без скилов", - "suppresses": [ - "skill-required", - "coverage-skill-match", - "classifier-mismatch", - "graph-first", - "chain-recommendation", - "semgrep-security" - ], - "description": "Skill discipline relaxed for this one prompt" - }, - { - "phrase": "direct ok", - "suppresses": [ - "skill-required", - "coverage-skill-match", - "classifier-mismatch", - "graph-first", - "chain-recommendation", - "semgrep-security" - ], - "description": "Direct work allowed without skill invocation" - }, - { - "phrase": "срочно", - "suppresses": [ - "verify-before-commit", - "verify-before-push", - "tdd-gate", - "graph-first", - "chain-recommendation", - "semgrep-security" - ], - "description": "Urgency override: skip verification + TDD gate + graph/chain enforcement" - }, - { - "phrase": "быстрый коммит", - "suppresses": [ - "verify-before-commit", - "tdd-gate", - "writing-plans-required", - "graph-first", - "chain-recommendation", - "semgrep-security" - ], - "description": "Quick commit: skip TDD + verify + plans + graph/chain enforcement" - }, - { - "phrase": "recovery", - "suppresses": [ - "branch-switch", - "git-recovery" - ], - "description": "Git recovery only — branch-state mismatch ok. Does NOT suppress graph-first / chain-recommendation / semgrep-security (use specific phrases for those)." - }, - { - "phrase": "memory dump", - "suppresses": [ - "memory-sync-coverage", - "skill-required", - "graph-first", - "chain-recommendation", - "semgrep-security" - ], - "description": "Memory write without separate coverage announcement" - }, - { - "phrase": "ремонт инфраструктуры", - "suppresses": [ - "tdd-gate", - "verify-before-commit", - "verify-before-push" - ], - "requires_justification": "ремонт:", - "description": "Infrastructure repair — bypass TDD-gate + verify hooks only. Other rules (skill-required, classifier-mismatch, chain-recommendation, graph-first, semgrep-security, memory-sync-coverage, coverage-skill-match, writing-plans-required) require their own override phrases." - } - ] -} diff --git a/tools/enforce-semgrep-security.mjs b/tools/enforce-semgrep-security.mjs deleted file mode 100644 index 4ed3d45c..00000000 --- a/tools/enforce-semgrep-security.mjs +++ /dev/null @@ -1,135 +0,0 @@ -#!/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 (`npm run sast`, `semgrep ...`). - * 2. Write semgrep-skip: 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, - /(?:^|\/)Imports\b/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: `npm run sast` (или `semgrep scan --config p/php app/`).', - ' 2. Добавь строку semgrep-skip: <одна строка причины> в свой ответ.', - ' 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(); diff --git a/tools/enforce-semgrep-security.test.mjs b/tools/enforce-semgrep-security.test.mjs deleted file mode 100644 index e921c0c5..00000000 --- a/tools/enforce-semgrep-security.test.mjs +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { decide, extractStagedFiles, isSecurityRelevantPath, sessionRanSemgrep } from './enforce-semgrep-security.mjs'; -import { findOverride } from './enforce-hook-helpers.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'); - }); -}); - -describe('override vocab coverage [v4: findOverride is stub, always null]', () => { - it("global override 'без скилов': findOverride returns null in v4 (vocab removed)", () => { - expect(findOverride('без скилов', 'semgrep-security')).toBeNull(); - }); - it("global override 'direct ok': findOverride returns null in v4 (vocab removed)", () => { - expect(findOverride('direct ok', 'semgrep-security')).toBeNull(); - }); - it("global override 'срочно': findOverride returns null in v4 (vocab removed)", () => { - expect(findOverride('срочно', 'semgrep-security')).toBeNull(); - }); - it("global override 'быстрый коммит': findOverride returns null in v4 (vocab removed)", () => { - expect(findOverride('быстрый коммит', 'semgrep-security')).toBeNull(); - }); - it("global override 'recovery': findOverride returns null in v4 (was falsy/null before too)", () => { - expect(findOverride('recovery', 'semgrep-security')).toBeNull(); - }); - it("global override 'memory dump': findOverride returns null in v4 (vocab removed)", () => { - expect(findOverride('memory dump', 'semgrep-security')).toBeNull(); - }); - it("global override 'ремонт инфраструктуры': findOverride returns null in v4 (was also null/falsy before)", () => { - expect(findOverride('ремонт инфраструктуры\nремонт: test reason', 'semgrep-security')).toBeNull(); - }); -});