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:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user