From 165ff3a8593734aeb42b72b7ebf55b096e549f27 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: Mon, 15 Jun 2026 19:21:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(brain-config):=20wire=20loadConfig=20into?= =?UTF-8?q?=20live=20hooks=20=D0=A4=D0=B0=D0=B7=D0=B0=201=20Task=207=20bat?= =?UTF-8?q?ch=20A-C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .claude/brain.local.md | 21 ++++++++++++++++++ tools/cost-stop-hook.mjs | 13 +++++++++--- tools/cost-stop-hook.test.mjs | 8 +++++++ tools/enforce-mcp-classification.mjs | 11 +++++++--- tools/enforce-mcp-classification.test.mjs | 12 +++++++++++ tools/enforce-normative-content-rules.mjs | 7 +++++- tools/observer-stop-hook.mjs | 26 ++++++++++++++++------- tools/observer-stop-hook.test.mjs | 10 +++++++++ 8 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 .claude/brain.local.md diff --git a/.claude/brain.local.md b/.claude/brain.local.md new file mode 100644 index 0000000..8b21011 --- /dev/null +++ b/.claude/brain.local.md @@ -0,0 +1,21 @@ +--- +config_version: 1 +registry_path: docs/registry/nodes.yaml +state_dir: docs/observer +normative_files: + - docs/Pravila_raboty_Claude_v1_1.md + - docs/Plugin_stack_rules_v1.md + - docs/Tooling_v8_3.md +project_url_whitelist: + - liderra.ru + - github.com/liderra +classifier_context: CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3) +enabled_hook_groups: + - core-discipline + - router-mentor + - normative +--- + +Настройка мозга для самого claude-brain (dogfood-консьюмер). Значения = текущие +дефолты, чтобы Фаза 1 не меняла поведение. state_dir: docs/observer сохраняет +нынешнее расположение журнала; перенос на .claude/brain-state — Фаза 3. diff --git a/tools/cost-stop-hook.mjs b/tools/cost-stop-hook.mjs index ae2f91e..3f3615b 100644 --- a/tools/cost-stop-hook.mjs +++ b/tools/cost-stop-hook.mjs @@ -29,10 +29,10 @@ export function todayISO(now = new Date()) { return `${y}-${m}-${d}`; } -export function currentMonthFile(now = new Date(), repoRoot = process.cwd()) { +export function currentMonthFile(now = new Date(), repoRoot = process.cwd(), stateDir = 'docs/observer') { const y = now.getUTCFullYear(); const m = String(now.getUTCMonth() + 1).padStart(2, '0'); - return join(repoRoot, 'docs', 'observer', `episodes-${y}-${m}.jsonl`); + return join(repoRoot, stateDir, `episodes-${y}-${m}.jsonl`); } export function readEpisodesJsonl(path) { @@ -76,7 +76,14 @@ async function main() { const repoRoot = process.cwd(); const now = new Date(); const date = todayISO(now); - const monthFile = currentMonthFile(now, repoRoot); + let stateDir = 'docs/observer'; + try { + const { loadConfig, resolveStateDir } = await import('./brain-config.mjs'); + ({ stateDir } = resolveStateDir(loadConfig(repoRoot).state_dir)); + } catch (e) { + console.warn('[cost-stop] brain-config недоступен, fallback docs/observer:', e && e.message); + } + const monthFile = currentMonthFile(now, repoRoot, stateDir); const episodes = readEpisodesJsonl(monthFile); const costDailyPath = join(homedir(), '.claude', 'runtime', 'cost-daily.json'); const existing = loadCostDaily(costDailyPath); diff --git a/tools/cost-stop-hook.test.mjs b/tools/cost-stop-hook.test.mjs index e9aba33..1fdb348 100644 --- a/tools/cost-stop-hook.test.mjs +++ b/tools/cost-stop-hook.test.mjs @@ -71,4 +71,12 @@ describe('currentMonthFile', () => { const p = currentMonthFile(new Date('2026-01-05T12:00:00.000Z'), '/repo'); expect(p.replace(/\\/g, '/')).toBe('/repo/docs/observer/episodes-2026-01.jsonl'); }); + + it('uses stateDir param (config-seam); дефолт docs/observer', () => { + const now = new Date('2026-06-15T12:00:00.000Z'); + expect(currentMonthFile(now, '/repo', '.claude/brain-state').replace(/\\/g, '/')) + .toBe('/repo/.claude/brain-state/episodes-2026-06.jsonl'); + expect(currentMonthFile(now, '/repo').replace(/\\/g, '/')) + .toBe('/repo/docs/observer/episodes-2026-06.jsonl'); + }); }); diff --git a/tools/enforce-mcp-classification.mjs b/tools/enforce-mcp-classification.mjs index 445ff01..a41e781 100644 --- a/tools/enforce-mcp-classification.mjs +++ b/tools/enforce-mcp-classification.mjs @@ -43,10 +43,10 @@ export function scanEgress(toolInput, { maxBytes = 100000 } = {}) { return { block: false }; } -export function decide({ toolName, toolInput, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { +export function decide({ toolName, toolInput, urlWhitelist, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { const name = String(toolName || ''); if (!name.startsWith('mcp__')) return { block: false, reason: null }; - const verdict = classifyMcpTool(name, toolInput || {}, {}); + const verdict = classifyMcpTool(name, toolInput || {}, urlWhitelist !== undefined ? { urlWhitelist } : {}); // М6: сквозной аварийный выход владельца — снимает verdict/egress-блок при точном совпадении канон-строки. const action = canonicalAction(name, toolInput || {}); const escaped = () => escapeGrantOpen(action, escapeGrants, escapeConsumed, now); @@ -70,7 +70,12 @@ async function main() { const raw = await readStdin(); const event = parseEventJson(raw); const sess = (event && event.session_id) || 'unknown'; - const r = decide({ toolName: event.tool_name, toolInput: event.tool_input, + let urlWhitelist = []; + try { + const { loadConfig } = await import('./brain-config.mjs'); + urlWhitelist = loadConfig(process.cwd()).project_url_whitelist; + } catch { urlWhitelist = []; } + const r = decide({ toolName: event.tool_name, toolInput: event.tool_input, urlWhitelist, escapeGrants: loadFloorEscapes(sess), escapeConsumed: loadConsumed(sess) }); if (r.block) { logGuardBlock(event, 'М5 Egress-страж', r.reason); diff --git a/tools/enforce-mcp-classification.test.mjs b/tools/enforce-mcp-classification.test.mjs index 297d537..47e2f87 100644 --- a/tools/enforce-mcp-classification.test.mjs +++ b/tools/enforce-mcp-classification.test.mjs @@ -101,3 +101,15 @@ describe('enforce-mcp-classification G-5 egress/verdict токен (M6 FIX-1)', expect(r.reason).toContain(`FLOOR-ESCAPE: ${action}`); }); }); + +// Task 7 wiring — decide() прокидывает config urlWhitelist в classifyMcpTool (config-seam). +describe('decide — config-seam urlWhitelist (Task 7 wiring)', () => { + it('пустой urlWhitelist → даже liderra.ru блокируется (fail-CLOSED)', () => { + const r = decide({ toolName: 'mcp__playwright__browser_navigate', toolInput: { url: 'https://liderra.ru/x' }, urlWhitelist: [] }); + expect(r.block).toBe(true); + }); + it('urlWhitelist с доменом → тот же домен разрешён', () => { + const r = decide({ toolName: 'mcp__playwright__browser_navigate', toolInput: { url: 'https://example.com/x' }, urlWhitelist: ['example.com'] }); + expect(r.block).toBe(false); + }); +}); diff --git a/tools/enforce-normative-content-rules.mjs b/tools/enforce-normative-content-rules.mjs index cac55c1..a73eebc 100644 --- a/tools/enforce-normative-content-rules.mjs +++ b/tools/enforce-normative-content-rules.mjs @@ -280,9 +280,14 @@ async function main() { const event = parseEventJson(await readStdin()); const toolName = event.tool_name; const filePath = event.tool_input && event.tool_input.file_path; + let protectedPaths = []; + try { + const { loadConfig } = await import('./brain-config.mjs'); + protectedPaths = loadConfig(process.cwd()).protected_paths; + } catch { protectedPaths = []; } // M7 Ф8 (§6): matcher расширен с нормативных ДОКУМЕНТОВ на дисциплинарные исходники машин — // ad-hoc правка tools/enforce-*.mjs ловится как ЗАКОН (требует escape), build-loop под планом → CARD. - if (!isNormativePath(filePath) && !isDisciplineSourcePath(filePath)) { exitDecision({ block: false }); return; } + if (!isNormativePath(filePath, protectedPaths) && !isDisciplineSourcePath(filePath)) { exitDecision({ block: false }); return; } const content = extractWrittenContent(toolName, event.tool_input); const transcript = readTranscript(event.transcript_path); diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index 176411f..6b4c800 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -48,8 +48,8 @@ const RATIONALE_FIELDS = [ ]; /** Update the monthly PII counter JSON with counts from a single episode write. */ -function bumpPiiCounter(counts, baseDir, month) { - const counterPath = join(baseDir, 'docs', 'observer', '.pii-counters.json'); +function bumpPiiCounter(counts, baseDir, month, stateDir = 'docs/observer') { + const counterPath = join(baseDir, stateDir, '.pii-counters.json'); let store = {}; if (existsSync(counterPath)) { try { store = JSON.parse(readFileSync(counterPath, 'utf-8')); } catch { store = {}; } @@ -77,8 +77,8 @@ function validateRationale(rationale) { * @param {string} baseDir - Repository root (default: process.cwd()). * @param {string} month - YYYY-MM string for the file name (default: current UTC month). */ -export function appendEpisode(episode, baseDir = process.cwd(), month = currentMonth()) { - const dir = join(baseDir, 'docs', 'observer'); +export function appendEpisode(episode, baseDir = process.cwd(), month = currentMonth(), stateDir = 'docs/observer') { + const dir = join(baseDir, stateDir); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } @@ -92,7 +92,7 @@ export function appendEpisode(episode, baseDir = process.cwd(), month = currentM } const { sanitized: sanitizedErr, counts: countsErr } = sanitizeWithCount(episode); appendFileSync(file, JSON.stringify(sanitizedErr) + '\n', 'utf-8'); - bumpPiiCounter(countsErr, baseDir, month); + bumpPiiCounter(countsErr, baseDir, month, stateDir); return; } @@ -113,7 +113,7 @@ export function appendEpisode(episode, baseDir = process.cwd(), month = currentM const { sanitized, counts } = sanitizeWithCount(episode); appendFileSync(file, JSON.stringify(sanitized) + '\n', 'utf-8'); - bumpPiiCounter(counts, baseDir, month); + bumpPiiCounter(counts, baseDir, month, stateDir); } /** @@ -420,7 +420,12 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s await computeEmbeddingForEpisode(ep, { ...ctx, prompt: userPrompt }, { embedMode: embMode }); // Always write the episode first — exit-0-safe (spec §5.1 step 1). - appendEpisode(ep); + let stateDir = 'docs/observer'; + try { + const { loadConfig, resolveStateDir } = await import('./brain-config.mjs'); + ({ stateDir } = resolveStateDir(loadConfig(process.cwd()).state_dir)); + } catch { /* brain-config недоступен → fallback docs/observer */ } + appendEpisode(ep, process.cwd(), currentMonth(), stateDir); // Then the routing-gate (spec §5.1 steps 2-4). if (transcriptText) { const promptText = extractLastUserPromptText(transcriptText); @@ -434,7 +439,12 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s } catch (err) { // Visible failure (spec §5.2): write an observer_error marker, never a silent skip. try { - appendEpisode(buildObserverError(ctx, err)); + let stateDir = 'docs/observer'; + try { + const { loadConfig, resolveStateDir } = await import('./brain-config.mjs'); + ({ stateDir } = resolveStateDir(loadConfig(process.cwd()).state_dir)); + } catch { /* fallback docs/observer */ } + appendEpisode(buildObserverError(ctx, err), process.cwd(), currentMonth(), stateDir); } catch (_e2) { // last-resort: even the marker failed — do not crash the Stop-event } diff --git a/tools/observer-stop-hook.test.mjs b/tools/observer-stop-hook.test.mjs index 9e81a96..5ef4e79 100644 --- a/tools/observer-stop-hook.test.mjs +++ b/tools/observer-stop-hook.test.mjs @@ -51,6 +51,16 @@ describe('appendEpisode', () => { expect(content.endsWith('\n')).toBe(true); }); + it('uses stateDir param for the episode dir (config-seam); дефолт docs/observer', () => { + appendEpisode(v2Episode({ task_id: 'sd' }), workdir, '2026-05', '.claude/brain-state'); + const content = readFileSync(join(workdir, '.claude', 'brain-state', 'episodes-2026-05.jsonl'), 'utf-8'); + expect(content).toContain('"task_id":"sd"'); + // без 4-го аргумента — прежний путь docs/observer (backward-compat) + appendEpisode(v2Episode({ task_id: 'def' }), workdir, '2026-05'); + const def = readFileSync(join(workdir, 'docs', 'observer', 'episodes-2026-05.jsonl'), 'utf-8'); + expect(def).toContain('"task_id":"def"'); + }); + it('appends to an existing file without overwrite', () => { appendEpisode(v2Episode({ task_id: 'a' }), workdir, '2026-05'); appendEpisode(v2Episode({ task_id: 'b', outcome: 'partial' }), workdir, '2026-05');