#!/usr/bin/env node /** * Rule — Graph-first enforce. * * Stop hook. Enforces CLAUDE.md §5 п.14: * «перед открытым codebase-вопросом сначала /graphify query, потом Read/Grep/Glob» * * When the controller performs >= THRESHOLD Grep/Glob searches in a single turn * WITHOUT having invoked graphify, this hook blocks turn-end with remediation * instructions. * * Three escape hatches: * 1. Invoke /graphify query via Skill tool (or graphifyy CLI via Bash). * 2. Write «graph-skip: » on a line in the assistant text. * 3. User prompt contains a global override phrase (vocab-driven). * * Spec: CLAUDE.md §5 п.14 (v2.33), ADR-017. */ import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastAssistantText, turnToolUses, findOverride, logOverride, exitDecision, } from './enforce-hook-helpers.mjs'; const RULE_KEY = 'graph-first'; const THRESHOLD = 3; const SEARCH_TOOLS = new Set(['Grep', 'Glob']); /** * Regex for inline escape hatch: * «graph-skip: » * * Requirements: * - Must start at the beginning of a line (^, multiline flag). * - Must have «graph-skip: » prefix followed by \S+ (at least one non-whitespace char). * - Whitespace-only or empty reason → does NOT match → remains blocked. */ const GRAPH_SKIP_RE = /^graph-skip:\s*\S+/m; /** * Pure decision function — no I/O. * * @param {object} params * @param {Array<{name: string, input: object}>} params.toolUses - All tool uses in this turn. * @param {boolean} params.graphifyInvoked - True if graphify was invoked this turn. * @param {string} params.assistantText - Full assistant text for this turn. * @param {object|null} params.override - Truthy if user prompt contained a valid override phrase. * @returns {{ block: boolean, message?: string }} */ export function decide({ toolUses, graphifyInvoked, assistantText, override }) { // Step 1: Global override → pass. if (override) return { block: false }; // Step 2: Graphify already consulted → pass. if (graphifyInvoked) return { block: false }; // Step 3: Count Grep + Glob tool uses. const searchCount = Array.isArray(toolUses) ? toolUses.filter((u) => u && SEARCH_TOOLS.has(u.name)).length : 0; // Step 4: Below threshold → pass. §5 п.14 «узкий regex-поиск» exception. if (searchCount < THRESHOLD) return { block: false }; // Step 5: Inline graph-skip escape hatch with non-empty reason → pass. if (typeof assistantText === 'string' && GRAPH_SKIP_RE.test(assistantText)) { return { block: false }; } // Step 6: Block. const message = [ `[enforce-graph-first] За turn выполнено ${searchCount} Grep/Glob поисков без вызова graphify (CLAUDE.md §5 п.14: «перед открытым codebase-вопросом сначала /graphify query, потом Read/Grep/Glob»).`, `Сделай ОДНО из трёх в следующем ответе:`, ` 1. Позови /graphify query «<вопрос>» через Skill tool, потом Read/Grep по найденным узлам.`, ` 2. Добавь строку «graph-skip: <одна строка причины>» (e.g. «graph-skip: узкий regex по литералу CONFIDENCE_THRESHOLD»).`, ` 3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).`, ].join('\n'); return { block: true, message }; } /** * Detect if graphify was invoked in any tool use of the turn. * * Matches: * - Skill tool with input.skill containing «graphify» (case-insensitive substring). * - Bash tool with input.command matching /\bgraphifyy?\b/i (CLI name is «graphifyy», * also catches «graphify» for slash-command-rendered bash). * - SlashCommand tool (if present) with input.command containing «graphify». */ export function detectGraphifyInvoked(toolUses) { if (!Array.isArray(toolUses)) return false; for (const u of toolUses) { if (!u || !u.name) continue; if (u.name === 'Skill') { const skill = String((u.input && u.input.skill) || ''); if (/graphify/i.test(skill)) return true; } if (u.name === 'Bash') { const cmd = String((u.input && u.input.command) || ''); if (/\bgraphifyy?\b/i.test(cmd)) return true; } if (u.name === 'SlashCommand') { const cmd = String((u.input && u.input.command) || ''); if (/graphify/i.test(cmd)) return true; } } return false; } async function main() { try { const raw = await readStdin(); const event = parseEventJson(raw); const transcript = readTranscript(event.transcript_path); const userPrompt = lastUserPromptText(transcript); const assistantText = lastAssistantText(transcript); const toolUses = turnToolUses(transcript); const graphifyInvoked = detectGraphifyInvoked(toolUses); const override = findOverride(userPrompt, RULE_KEY); if (override) logOverride(RULE_KEY, override, event.session_id); const result = decide({ toolUses, graphifyInvoked, assistantText, override }); exitDecision(result); } catch { // Fail-quiet: never block on internal error. exitDecision({ block: false }); } } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-graph-first.mjs'); if (isCli) main();