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:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user