Files
portal/tools/enforce-graph-first.mjs
T
Дмитрий 497d410ea1 feat(brain-governance): graph-first enforcer (Stop hook) + vocab gap fix for chain-recommendation
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).
2026-05-28 06:30:17 +03:00

141 lines
5.4 KiB
JavaScript

#!/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();