497d410ea1
Closes third behavioral-debt block from retro #8: CLAUDE.md §5 п.14 (graph-first для codebase-вопросов) was being ignored — controller did 4+ Grep searches today without consulting graphify.
Three changes:
1. tools/enforce-graph-first.mjs (NEW): Stop hook blocking turn-end when Grep+Glob count >= 3 in turn AND no graphify invocation (Skill 'graphifyy' / Bash 'graphifyy' / SlashCommand 'graphify'). Override: 'graph-skip: <reason>' inline OR global override-phrase. 19 vitest tests cover empty toolUses, threshold boundary, graphify detection forms, override variants.
2. tools/enforce-override-vocab.json: added 'graph-first' AND 'chain-recommendation' to suppresses[] of all 7 global override phrases (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры). This closes a vocab gap that ALSO affected the previously-deployed chain-recommendation hook (a3 from d1d53080) — global overrides did not work for it either until now.
3. .claude/settings.json: registered enforce-graph-first.mjs as 5th Stop hook entry.
Full vitest tools-sweep: 1041/1041 GREEN. Reviewer APPROVE on spec + code quality. Pipe-test verified (empty event → exit 0, no block).
210 lines
6.4 KiB
JavaScript
210 lines
6.4 KiB
JavaScript
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);
|
||
});
|
||
});
|