From 497d410ea1fcae4bf76a8c3ee41e1064bf3970cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 28 May 2026 06:30:17 +0300 Subject: [PATCH] feat(brain-governance): graph-first enforcer (Stop hook) + vocab gap fix for chain-recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ' 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). --- .claude/settings.json | 9 ++ docs/observer/STATUS.md | 26 ++-- tools/enforce-graph-first.mjs | 140 +++++++++++++++++++ tools/enforce-graph-first.test.mjs | 209 +++++++++++++++++++++++++++++ tools/enforce-override-vocab.json | 18 +-- 5 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 tools/enforce-graph-first.mjs create mode 100644 tools/enforce-graph-first.test.mjs diff --git a/.claude/settings.json b/.claude/settings.json index dcdf7f04..09fec865 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -188,6 +188,15 @@ "timeout": 5 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node tools/enforce-graph-first.mjs", + "timeout": 5 + } + ] } ], "UserPromptSubmit": [ diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 3fbdffb4..8e3e074a 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-28T01:50:18.743Z +Last updated: 2026-05-28T02:37:38.704Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,13 +8,13 @@ Last updated: 2026-05-28T01:50:18.743Z | 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 | ⚠️ | 706 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro | +| C5 Observer-coverage | ⚠️ | 715 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: 706 episodes this month, 0 observer_error markers, 158 PII matches before filter -- Legacy v1 episodes (not in factor analysis): 567 +- Observer evidence: 715 episodes this month, 0 observer_error markers, 158 PII matches before filter +- Legacy v1 episodes (not in factor analysis): 576 - Last /brain-retro: 0 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 | 7 | 0.0% | 0.0% | | refactor | 1 | 0.0% | 0.0% | -Router step distribution: 1: 301, 2: 263, 3: 71, 5: 63 +Router step distribution: 1: 303, 2: 265, 3: 75, 5: 64 -Boundaries applied (ADR / границы): 85 of 698 эпизодов (12.2%). +Boundaries applied (ADR / границы): 88 of 707 эпизодов (12.4%). ## Активные многоэтапные проекты @@ -51,10 +51,10 @@ Boundaries applied (ADR / границы): 85 of 698 эпизодов (12.2%). | Компонент | Токены (in/out) | USD | |---|---|---| -| Classifier (Sonnet 4.6) | 7050/69610 | $1.07 | +| Classifier (Sonnet 4.6) | 7217/72657 | $1.11 | | Self-assessment (Sonnet 4.6) | 0/0 | $0.00 | | Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 | -| **Итого** | | **$1.07** | +| **Итого** | | **$1.11** | ## Аномалии классификатора @@ -67,7 +67,7 @@ Episodes since last run: 609 / threshold: 10 ## Reviewer: субагент vs fallback -0 эпизодов проверено из 706. +0 эпизодов проверено из 715. ## Reviewer findings @@ -109,10 +109,10 @@ Episodes since last run: 609 / threshold: 10 | Фраза | За всё время | За сегодня | |---|---|---| -| `recovery` | 273 | 0 | -| `ремонт инфраструктуры` | 181 | 22 ⚠️ | -| `срочно` | 82 | 0 | -| `без скилов` | 58 | 0 | +| `recovery` | 286 | 13 ⚠️ | +| `ремонт инфраструктуры` | 185 | 26 ⚠️ | +| `срочно` | 88 | 6 ⚠️ | +| `без скилов` | 60 | 2 | | `memory dump` | 8 | 0 | | `direct ok` | 6 | 0 | | `быстрый коммит` | 3 | 0 | diff --git a/tools/enforce-graph-first.mjs b/tools/enforce-graph-first.mjs new file mode 100644 index 00000000..d98170b0 --- /dev/null +++ b/tools/enforce-graph-first.mjs @@ -0,0 +1,140 @@ +#!/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(); diff --git a/tools/enforce-graph-first.test.mjs b/tools/enforce-graph-first.test.mjs new file mode 100644 index 00000000..32c25cd4 --- /dev/null +++ b/tools/enforce-graph-first.test.mjs @@ -0,0 +1,209 @@ +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); + }); +}); diff --git a/tools/enforce-override-vocab.json b/tools/enforce-override-vocab.json index 44698c72..9b5f9fd7 100644 --- a/tools/enforce-override-vocab.json +++ b/tools/enforce-override-vocab.json @@ -4,37 +4,37 @@ "phrases": [ { "phrase": "без скилов", - "suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"], + "suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch", "graph-first", "chain-recommendation"], "description": "Skill discipline relaxed for this one prompt" }, { "phrase": "direct ok", - "suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch"], + "suppresses": ["skill-required", "coverage-skill-match", "classifier-mismatch", "graph-first", "chain-recommendation"], "description": "Direct work allowed without skill invocation" }, { "phrase": "срочно", - "suppresses": ["verify-before-commit", "verify-before-push", "tdd-gate"], - "description": "Urgency override: skip verification + TDD gate" + "suppresses": ["verify-before-commit", "verify-before-push", "tdd-gate", "graph-first", "chain-recommendation"], + "description": "Urgency override: skip verification + TDD gate + graph/chain enforcement" }, { "phrase": "быстрый коммит", - "suppresses": ["verify-before-commit", "tdd-gate", "writing-plans-required"], - "description": "Quick commit: skip TDD + verify + plans" + "suppresses": ["verify-before-commit", "tdd-gate", "writing-plans-required", "graph-first", "chain-recommendation"], + "description": "Quick commit: skip TDD + verify + plans + graph/chain enforcement" }, { "phrase": "recovery", - "suppresses": ["branch-switch", "git-recovery"], + "suppresses": ["branch-switch", "git-recovery", "graph-first", "chain-recommendation"], "description": "Git recovery operation, branch-state mismatch ok" }, { "phrase": "memory dump", - "suppresses": ["memory-sync-coverage", "skill-required"], + "suppresses": ["memory-sync-coverage", "skill-required", "graph-first", "chain-recommendation"], "description": "Memory write without separate coverage announcement" }, { "phrase": "ремонт инфраструктуры", - "suppresses": ["tdd-gate", "verify-before-commit", "verify-before-push", "writing-plans-required", "skill-required", "memory-sync-coverage", "classifier-mismatch", "coverage-skill-match"], + "suppresses": ["tdd-gate", "verify-before-commit", "verify-before-push", "writing-plans-required", "skill-required", "memory-sync-coverage", "classifier-mismatch", "coverage-skill-match", "graph-first", "chain-recommendation"], "requires_justification": "ремонт:", "description": "Bypass all rules (full opt-out). Requires 'ремонт: ' line in same prompt." }