chore(router-gate-v4): delete 5 obsolete v3.9 hooks + vocab.json (Stream G cleanup)

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

Regression: 1705/1705 vitest tools GREEN after deletion.
This commit is contained in:
Дмитрий
2026-05-30 06:12:59 +03:00
parent a3002bbe3b
commit 1a84864e44
12 changed files with 9 additions and 2082 deletions
+9 -9
View File
@@ -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
-148
View File
@@ -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();
-360
View File
@@ -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);
});
});
-132
View File
@@ -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: <reason 50+ chars>" 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: <reason 50+ chars>" 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();
-268
View File
@@ -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);
});
});
-140
View File
@@ -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: <non-empty reason>» 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: <one-line non-empty reason>»
*
* 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();
-209
View File
@@ -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);
});
});
-170
View File
@@ -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();
-255
View File
@@ -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('');
});
});
-83
View File
@@ -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."
}
]
}
-135
View File
@@ -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: <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,
/(?:^|\/)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();
-173
View File
@@ -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();
});
});