feat(brain-config): resolveStateDir + classifierContext config-seam (Фаза 1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-15 14:16:14 +03:00
parent bcd55abbc9
commit 88aa122cf8
6 changed files with 53 additions and 5 deletions
+8
View File
@@ -67,3 +67,11 @@ export function loadConfig(root = '.', fsImpl = fsDefault) {
}
return resolveConfig(parseBrainConfig(md));
}
/** fail-safe резолвер state_dir (§5.1): непустая строка → как есть; иначе → безопасный дефолт
* .claude/brain-state + warnedFallback (НЕ тихий no-op — wiring издаёт warn и пишет в fallback). */
export function resolveStateDir(value) {
const v = typeof value === 'string' ? value.trim() : '';
if (v.length > 0) return { stateDir: v, warnedFallback: false };
return { stateDir: '.claude/brain-state', warnedFallback: true };
}
+16
View File
@@ -77,3 +77,19 @@ describe('resolveConfig protected_paths (Task 4 security, §D1/§D2)', () => {
.toEqual(['secrets/keys']);
});
});
import { resolveStateDir } from './brain-config.mjs';
describe('resolveStateDir fail-safe (§D2)', () => {
it('непустая строка → как есть, без fallback', () => {
expect(resolveStateDir('docs/observer')).toEqual({ stateDir: 'docs/observer', warnedFallback: false });
});
it('пусто / пробелы → безопасный дефолт + warnedFallback', () => {
expect(resolveStateDir('')).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
expect(resolveStateDir(' ')).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
});
it('не-строка → безопасный дефолт + warnedFallback (не падает)', () => {
expect(resolveStateDir(null)).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
expect(resolveStateDir(undefined)).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
});
});
+2 -2
View File
@@ -42,7 +42,7 @@ const REQUIRED_REVIEW_FIELDS = [
* The split is still applied so the moment either condition flips, caching
* activates with zero further code changes.
*/
export function buildReviewPromptStructured(episode) {
export function buildReviewPromptStructured(episode, { classifierContext = 'Лидерра' } = {}) {
const v = Number(episode?.schema_version) || 0;
const cues = [
'node_quality: correct | wrong_node | overkill | underkill | disputable',
@@ -66,7 +66,7 @@ export function buildReviewPromptStructured(episode) {
}
const system = [
'You are the independent reviewer of routing decisions for the Лидерра brain-governance experiment.',
`You are the independent reviewer of routing decisions for the ${classifierContext} brain-governance experiment.`,
'Return ONLY a JSON object with the 8 fields below. No prose, no code fences.',
'',
'Fields:',
+10
View File
@@ -114,3 +114,13 @@ describe('parseReview — 8-dim review schema (spec §4.6)', () => {
expect(r?.reviewer_error).toBe('malformed episode');
});
});
describe('buildReviewPromptStructured classifierContext (config-seam §D1)', () => {
it('дефолт → Лидерра', () => {
expect(buildReviewPromptStructured({ schema_version: 4 }).system).toContain('Лидерра');
});
it('classifierContext инъектируется', () => {
expect(buildReviewPromptStructured({ schema_version: 4 }, { classifierContext: 'ProjZ' }).system)
.toContain('ProjZ');
});
});
+6 -3
View File
@@ -288,12 +288,12 @@ export function buildClassifierPrompt(userPrompt, registry, { enrichment = true
* Cache-eligibility: Sonnet requires ≥1024 tokens in the cached block.
* Active node registry (~85 nodes × ~100 tokens) easily clears this.
*/
export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true } = {}) {
export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true, classifierContext = 'CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3)' } = {}) {
const pamyatka = enrichment ? `\n\n${PAMYATKA}\n` : '\n';
const nodesBlock = buildNodesBlock(registry);
const chainsBlock = buildChainsBlock(registry);
const system = `Ты классификатор задач для CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3).
const system = `Ты классификатор задач для ${classifierContext}.
ОБЯЗАТЕЛЬНЫЕ выходные правила:
0. task_type — ОБЯЗАТЕЛЬНОЕ поле (ровно так, snake_case), одно из: feature, planning, bugfix, refactor, cleanup, marketing, security, analysis, monitoring, memory-sync, question, unknown.
@@ -557,7 +557,10 @@ export async function callAnthropicAPI(promptOrMessages, {
if (onUsage && data.usage) {
try { onUsage(data.usage); } catch { /* swallow callback errors */ }
}
return data.content?.[0]?.text || '';
const _blocks = Array.isArray(data.content) ? data.content : [];
const _textBlock = _blocks.find((b) => b && b.type === 'text' && typeof b.text === 'string')
|| _blocks.find((b) => b && typeof b.text === 'string');
return (_textBlock && _textBlock.text) || '';
}
// Retry on 5xx and 429; fail fast on 4xx (auth/quota/bad request — retry won't help).
if (r.status >= 500 || r.status === 429) {
+11
View File
@@ -654,3 +654,14 @@ describe('buildClassifierPromptStructured — requires task_type field (rasinhro
expect(system).toContain('"task_type"');
});
});
describe('buildClassifierPromptStructured classifierContext (config-seam §D1)', () => {
const reg = { nodes: [], chains: {} };
it('дефолт → текущая строка «Лидерра»', () => {
expect(buildClassifierPromptStructured('p', reg).system).toContain('«Лидерра»');
});
it('classifierContext инъектируется', () => {
expect(buildClassifierPromptStructured('p', reg, { classifierContext: 'ТестПроект XYZ' }).system)
.toContain('ТестПроект XYZ');
});
});