From b9730afb8a14e98ddf70e20387349cd4413be5dc 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 10:21:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(brain-config):=20=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D1=87=D0=B8?= =?UTF-8?q?=D0=BA=20brain.local.md=20+=20fail-safe=20resolveConfig=20(?= =?UTF-8?q?=D0=A4=D0=B0=D0=B7=D0=B0=201=20=D0=97=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=201-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...26-06-15-brain-config-task1-ceremony-v3.md | 52 +++++++++++ .../2026-06-15-brain-config-module-spec.md | 92 +++++++++++++++++++ tools/brain-config.mjs | 68 ++++++++++++++ tools/brain-config.test.mjs | 69 ++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-brain-config-task1-ceremony-v3.md create mode 100644 docs/superpowers/specs/2026-06-15-brain-config-module-spec.md create mode 100644 tools/brain-config.mjs create mode 100644 tools/brain-config.test.mjs diff --git a/docs/superpowers/plans/2026-06-15-brain-config-task1-ceremony-v3.md b/docs/superpowers/plans/2026-06-15-brain-config-task1-ceremony-v3.md new file mode 100644 index 0000000..d190d5d --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-brain-config-task1-ceremony-v3.md @@ -0,0 +1,52 @@ +# План-церемония v3: фикс сбора тестов `brain-config` (Фаза 1, Задачи 1–2) + +## Цель + +Исправить сбор тестов модуля `tools/brain-config.mjs`. Конфиг `vitest.config.tools.mjs` +задаёт `globals: true` (верифицировано — см. `verified-context-json`, якорь +`vitest-globals`), значит `describe/it/expect` доступны как глобалы и **не должны** +импортироваться явно. Текущий тест-файл импортирует их из `'vitest'`, из-за чего +`describe` приходит из контекста без раннера и сбор падает («reading config»). +Фикс: переписать тест без этого импорта (опора на глобалы конфига). Код модуля уже +корректен и не трогается. Контракт и крайние случаи — спека +`2026-06-15-brain-config-module-spec.md` (якоря D1–D6). Итог: единичный файл и +полный свод `tools/` зелёные. + +## Безопасность отката (премортем) + +- **Глобалы гарантированы.** `globals: true` в конфиге (верифицировано якорем) — + значит `describe/it/expect` инъектируются раннером глобально; тест без импорта + их получит. Если бы глобалов не было, этот же тест падал бы по `describe is not + defined` — но конфиг подтверждает обратное. +- **Нет необратимых действий.** План — только Write тест-файла + два прогона тестов. + Ни `git commit`, ни `push`, ни CI не запускаются → денежных/CI-последствий нет. +- **Откат тривиален.** Трогается один файл (`tools/brain-config.test.mjs`); модуль + `brain-config.mjs` не меняется. Если шаг 2 неожиданно RED — план встаёт до шага 3 + (регрессии), откат = восстановить прежний тест-файл, другие файлы не затронуты. + +```skills-json +["test-driven-development"] +``` + +```steps-json +[ + {"op":"Write","object":"tools/brain-config.test.mjs","ref":"D6"}, + {"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs tools/brain-config.test.mjs","ref":"D6"}, + {"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs","ref":"D6"} +] +``` + +## Порядок шагов (человекочитаемо) + +1. **Write** `tools/brain-config.test.mjs` — те же тесты `parseBrainConfig` + + `resolveConfig` fail-safe (D1/D2/D3), но **без** `import ... from 'vitest'` + (глобалы из конфига, `globals: true`). Критерий — D6. +2. **Bash** vitest на файл — ожидается GREEN (сбор починен, контракт + fail-safe). +3. **Bash** полный свод `tools/` — ожидается GREEN (регрессия не сломана, модуль новый). + +```verified-context-json +[ + {"id":"tools-pure-module-style","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("}, + {"id":"vitest-globals","kind":"EXTRACTED","ref":"vitest.config.tools.mjs","anchor":"globals: true"} +] +``` diff --git a/docs/superpowers/specs/2026-06-15-brain-config-module-spec.md b/docs/superpowers/specs/2026-06-15-brain-config-module-spec.md new file mode 100644 index 0000000..4c8dd6c --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-brain-config-module-spec.md @@ -0,0 +1,92 @@ +# Спека: модуль `tools/brain-config.mjs` — чтение проектной настройки мозга + +**Дата:** 2026-06-15 +**Статус:** к исполнению (Фаза 1, Задачи 1–2) + +## Цель + +Дать движку единый чистый модуль, который читает проектную настройку из файла +`.claude/brain.local.md` и отдаёт типизированные значения с безопасными +направлениями отказа. Захардкоженные «знания про конкретный проект», рассыпанные +по `tools/*.mjs`, должны получить один источник правды. Этот модуль — только +загрузчик и нормализатор настройки; он не меняет поведение существующих файлов +(они подключатся к нему отдельными задачами с дефолтами = текущие значения). + +## Контракт API {#D1} + +Модуль `tools/brain-config.mjs` (Node ESM, стиль каталога `tools/`) экспортирует +три чистые/полу-чистые функции: + +- `parseBrainConfig(md) -> object` — чистая. На вход markdown-строка (содержимое + `.claude/brain.local.md`). Возвращает объект ключей из YAML-frontmatter. На `null`, + пустую строку или текст без frontmatter — пустой объект `{}`. +- `resolveConfig(raw) -> object` — чистая. На вход результат `parseBrainConfig`. + Накладывает дефолты и fail-safe направления (§D3). Возвращает полный объект + настройки. +- `loadConfig(root = '.', fsImpl = fs) -> object` — I/O-обёртка. Читает + `${root}/.claude/brain.local.md`; если файла нет — отдаёт дефолты (через + `resolveConfig({})`). `fsImpl` инъектируется для тестов. + +Ключи настройки: `config_version`, `registry_path`, `state_dir`, `evidence_archive`, +`normative_files` (список), `project_url_whitelist` (список), `classifier_context`, +`economy_default`, `enabled_hook_groups` (список). + +## Алгоритм парсинга frontmatter {#D2} + +Минимальный YAML-frontmatter без внешних зависимостей: + +- Границы frontmatter — регэксп `^---\n([\s\S]*?)\n---` (блок между первыми `---`). +- Внутри блока построчно: + - строка вида ` - item` (элемент списка) добавляется к текущему ключу-списку; + - строка вида `key: value` начинает новый ключ; пустое `value` означает начало + списка (ключ инициализируется пустым массивом, последующие `- item` в него + падают); + - непустое числовое `value` (`^\d+$`) приводится к числу, иначе остаётся строкой; + - после скалярного ключа «текущий ключ-список» сбрасывается. +- Строки, не подходящие ни под один шаблон, игнорируются. + +## Fail-safe направления (дефолты + отказ) {#D3} + +`resolveConfig` накладывает безопасные дефолты на отсутствующие ключи. Отсутствие +или невалидность ключа НЕ отключает молча безопасность. + +| Ключ | Дефолт при отсутствии | Направление | +|---|---|---| +| `state_dir` | `.claude/brain-state` | безопасный дефолт | +| `evidence_archive` | `brain-state` | безопасный дефолт | +| `normative_files` | `[]` (легитимно пусто) | явная пустота | +| `registry_path` | `''` (зависимости off осознанно) | явная пустота | +| `project_url_whitelist` | `[]` + флаг `project_url_whitelist_failClosed: true` | **fail-closed** (внешнее закрыто) | +| `classifier_context` | строка с маркером `generic` | безопасная деградация качества | +| `economy_default` | `'100'` | безопасный дефолт | + +`project_url_whitelist_failClosed` равен `true`, когда whitelist пуст или не массив +(потребитель обязан блокировать внешние адреса), и `false`, когда список непуст. + +## Крайние случаи {#D4} + +- `parseBrainConfig(null)` и `parseBrainConfig('')` → `{}`. +- Текст без frontmatter (`'тело без ---'`) → `{}`. +- `loadConfig` при отсутствующем файле (читалка бросает) → дефолты, без падения. +- Числовое значение (`config_version: 1`) → число `1`, не строка. +- Список из одного элемента → массив длины 1. + +## Конвенция заголовка модуля {#D5} + +Файл начинается с shebang `#!/usr/bin/env node` и JSDoc-блока, описывающего +назначение (единый источник проектной настройки мозга, чистый парсер + +`resolveConfig` с fail-safe дефолтами). Стиль — как у соседних чистых модулей +каталога `tools/` (замороженные константы через `Object.freeze`, ESM-импорт fs). + +## Критерий приёмки {#D6} + +- `npx vitest run --config vitest.config.tools.mjs tools/brain-config.test.mjs` — + сначала RED (нет реализации), затем GREEN (все кейсы контракта и fail-safe). +- `npx vitest run --config vitest.config.tools.mjs` — полный свод `tools/` остаётся + зелёным (модуль новый, существующее поведение не затронуто). + +```verified-context-json +[ + {"id":"tools-pure-module-style","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("} +] +``` diff --git a/tools/brain-config.mjs b/tools/brain-config.mjs new file mode 100644 index 0000000..24ce2d1 --- /dev/null +++ b/tools/brain-config.mjs @@ -0,0 +1,68 @@ +/** + * brain-config — единый источник проектной настройки мозга (.claude/brain.local.md). + * + * Чистый парсер frontmatter (parseBrainConfig) + resolveConfig с fail-safe + * дефолтами (спека 2026-06-15-brain-config-module-spec.md §D3) + I/O-обёртка + * loadConfig. Захардкоженные «знания про конкретный проект» из tools/*.mjs + * получают один источник правды; направления отказа безопасны (отсутствие ключа + * не отключает молча безопасность/деньги/защиту). + */ +import fsDefault from 'node:fs'; + +const FM_RE = /^---\n([\s\S]*?)\n---/; + +/** Минимальный YAML-frontmatter: `key: value` + список ` - item`. Без зависимостей. Чистая. */ +export function parseBrainConfig(md) { + const m = FM_RE.exec(String(md || '')); + if (!m) return {}; + const out = {}; + let curKey = null; + for (const line of m[1].split('\n')) { + const item = /^\s+-\s+(.*)$/.exec(line); + if (item && curKey) { + (out[curKey] ||= []).push(item[1].trim()); + continue; + } + const kv = /^([A-Za-z_]+):\s*(.*)$/.exec(line); + if (!kv) continue; + const [, k, v] = kv; + if (v === '') { + out[k] = []; + curKey = k; + } else { + out[k] = /^\d+$/.test(v) ? Number(v) : v; + curKey = null; + } + } + return out; +} + +/** Безопасные дефолты (§D3). Отсутствие ключа не отключает молча защиту/деньги. */ +const DEFAULTS = Object.freeze({ + state_dir: '.claude/brain-state', + evidence_archive: 'brain-state', + normative_files: [], + registry_path: '', + project_url_whitelist: [], + classifier_context: 'generic project (no profile configured)', + economy_default: '100', +}); + +/** Наложить дефолты + fail-safe направления (§D3). Чистая. */ +export function resolveConfig(raw) { + const c = { ...DEFAULTS, ...(raw || {}) }; + // project_url_whitelist: пусто/не-массив → fail-CLOSED (внешка закрыта), не «пускать всё». + c.project_url_whitelist_failClosed = !(Array.isArray(c.project_url_whitelist) && c.project_url_whitelist.length > 0); + return c; +} + +/** I/O-обёртка: прочитать .claude/brain.local.md проекта (нет файла → дефолты). */ +export function loadConfig(root = '.', fsImpl = fsDefault) { + let md = ''; + try { + md = fsImpl.readFileSync(`${root}/.claude/brain.local.md`, 'utf8'); + } catch { + md = ''; + } + return resolveConfig(parseBrainConfig(md)); +} diff --git a/tools/brain-config.test.mjs b/tools/brain-config.test.mjs new file mode 100644 index 0000000..1dc3839 --- /dev/null +++ b/tools/brain-config.test.mjs @@ -0,0 +1,69 @@ +import { parseBrainConfig, resolveConfig } from './brain-config.mjs'; + +describe('parseBrainConfig', () => { + it('читает YAML-frontmatter ключи', () => { + const md = '---\nconfig_version: 1\nregistry_path: docs/registry/nodes.yaml\nnormative_files:\n - docs/Pravila_raboty_Claude_v1_1.md\n---\nтело'; + const c = parseBrainConfig(md); + expect(c.config_version).toBe(1); + expect(c.registry_path).toBe('docs/registry/nodes.yaml'); + expect(c.normative_files).toEqual(['docs/Pravila_raboty_Claude_v1_1.md']); + }); + + it('читает список из нескольких элементов', () => { + const md = '---\nnormative_files:\n - a.md\n - b.md\n - c.md\n---'; + expect(parseBrainConfig(md).normative_files).toEqual(['a.md', 'b.md', 'c.md']); + }); + + it('нет файла/пусто → пустой объект (дефолты применит resolveConfig)', () => { + expect(parseBrainConfig('')).toEqual({}); + expect(parseBrainConfig(null)).toEqual({}); + }); + + it('текст без frontmatter → пустой объект', () => { + expect(parseBrainConfig('тело без разделителей')).toEqual({}); + }); +}); + +describe('resolveConfig fail-safe (§D3)', () => { + it('state_dir отсутствует → безопасный дефолт', () => { + expect(resolveConfig({}).state_dir).toBe('.claude/brain-state'); + }); + + it('evidence_archive отсутствует → безопасный дефолт', () => { + expect(resolveConfig({}).evidence_archive).toBe('brain-state'); + }); + + it('project_url_whitelist пуст → fail-closed маркер (внешка закрыта)', () => { + const r = resolveConfig({}); + expect(r.project_url_whitelist).toEqual([]); + expect(r.project_url_whitelist_failClosed).toBe(true); + }); + + it('project_url_whitelist непуст → fail-closed снят', () => { + const r = resolveConfig({ project_url_whitelist: ['liderra.ru'] }); + expect(r.project_url_whitelist).toEqual(['liderra.ru']); + expect(r.project_url_whitelist_failClosed).toBe(false); + }); + + it('normative_files пуст → [] (legitimate)', () => { + expect(resolveConfig({}).normative_files).toEqual([]); + }); + + it('registry_path пуст → пустая строка (зависимости off осознанно)', () => { + expect(resolveConfig({}).registry_path).toBe(''); + }); + + it('classifier_context пуст → generic-строка', () => { + expect(resolveConfig({}).classifier_context).toMatch(/generic|общий/i); + }); + + it('economy_default пуст → дефолт 100', () => { + expect(resolveConfig({}).economy_default).toBe('100'); + }); + + it('переданные значения переживают resolveConfig', () => { + const r = resolveConfig({ state_dir: 'docs/observer', registry_path: 'docs/registry/nodes.yaml' }); + expect(r.state_dir).toBe('docs/observer'); + expect(r.registry_path).toBe('docs/registry/nodes.yaml'); + }); +});