feat(brain-config): wire loadConfig into live hooks Фаза 1 Task 7 batch A-C

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-15 19:21:13 +03:00
parent ba10068e10
commit 165ff3a859
8 changed files with 93 additions and 15 deletions
+21
View File
@@ -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.
+10 -3
View File
@@ -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);
+8
View File
@@ -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');
});
});
+8 -3
View File
@@ -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);
+12
View File
@@ -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);
});
});
+6 -1
View File
@@ -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);
+18 -8
View File
@@ -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
}
+10
View File
@@ -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');