feat: brain-config classifyFilePath стемы нормативки config-driven — greenfield #3 observer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-16 12:23:40 +03:00
parent c19941aea0
commit 318add8fa0
4 changed files with 128 additions and 11 deletions
@@ -0,0 +1,75 @@
# План: greenfield #3-observer — `classifyFilePath` стемы нормативки из config
> **Канон:** дизайн `2026-06-16-greenfield-regex-names-config-design.md` §5 (Компонент 3, Подход A),
> design v6 §3.3, план Фазы 1. Последний кусок greenfield-hardening #3. Исполнение — ИНЛАЙН под стеной
> (escape-per-step; production-правки `tools/*.mjs` под owner-override `ремонт инфраструктуры`).
## Цель
Захардкоженные стемы нормативки в `observer-transcript-parser.classifyFilePath` (стр. 371-373:
`Pravila_raboty_Claude` / `Plugin_stack_rules` / `Tooling`) вывести из настройки `normative_files`
(через `docStem`), чтобы наблюдатель распознавал нормативку **чужого** проекта как `'norm'`. Дефолт =
текущие 3 стема → `claude-brain` (Лидерра) ведёт себя байт-в-байт. Универсальные
(`CLAUDE.md`/`MEMORY.md`/`memory/`) + `Открытые_вопросы` — остаются хардкодом (YAGNI). §/R
citation-паттерны — НЕ в этом файле (их здесь нет), не трогаем.
## Контракт (из §5 дизайна)
- `classifyFilePath(path, normativeStems = DEFAULT_NORMATIVE_STEMS)` — строки 371-373 заменяются циклом:
для каждого `stem` из `normativeStems``(?:^|/)<escaped-stem>[^/]*\.md$` (i) → `'norm'`.
- `DEFAULT_NORMATIVE_STEMS = ['Pravila_raboty_Claude','Plugin_stack_rules','Tooling']` (Object.freeze) —
ровно текущие 3 паттерна как file-стемы.
- `extractFileTypeDistribution(files, normativeStems = DEFAULT_NORMATIVE_STEMS)` — прокидывает в `classifyFilePath`.
- `parseTranscript(text, fallbackSessionId, options = {})``extractFileTypeDistribution(ts.files, options.normativeStems)`
(undefined → param-default = Лидерра 3).
- `observer-stop-hook.buildEpisodeFromContext(ctx, transcriptText, options = {})` и
`buildEpisode({…, options})` — протягивают `options` в `parseTranscript`.
- `main()`: `loadConfig().normative_files.map(docStem)` (import `docStem` из cross-ref-checker) →
`{ normativeStems }` в `buildEpisodeFromContext`; try/catch → fallback parser-default.
## Fail-safe (дизайн §6)
| Случай | normativeStems | Поведение |
|---|---|---|
| `claude-brain` brain.local.md (3 файла) | 3 стема = текущие | байт-в-байт |
| greenfield с brain.local.md | его стемы | его доки → 'norm' |
| нет brain.local.md → resolveConfig `[]` | `[]` | norm только universal (safe degrade §6) |
| import brain-config/cross-ref упал | undefined → parser-default | Лидерра 3 (backward-compat) |
## Шаги (TDD)
### Шаг 1 — RED: тесты `classifyFilePath` с кастомными стемами (`observer-transcript-parser.test.mjs`)
В блок `describe('classifyFilePath …')` (≈1863) добавить:
- кастомный стем матчит greenfield-док: `classifyFilePath('docs/MyRules_v2.md', ['MyRules'])``'norm'`;
- кастомные стемы НЕ включают Лидерра-док: `classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md', ['MyRules'])``'other'`;
- пустой список → Лидерра-док НЕ norm, но `CLAUDE.md` всё ещё `'norm'` (хардкод):
`classifyFilePath('docs/Tooling_v8_3.md', [])``'other'`; `classifyFilePath('CLAUDE.md', [])``'norm'`;
- дефолт (без 2-го арг) сохраняет 3 (уже пинят 1883-1885 — добавочный explicit не обязателен).
В блок `extractFileTypeDistribution` (≈1903): `extractFileTypeDistribution(['docs/MyRules_v2.md'], ['MyRules']).norm` === 1.
### Шаг 2 — RED-прогон (terminal владельца / на бумаге под стеной — harness-collapse)
`npx vitest run --config vitest.config.tools.mjs tools/observer-transcript-parser.test.mjs` → FAIL
(2-й арг игнорируется текущей сигнатурой).
### Шаг 3 — GREEN: правка `observer-transcript-parser.mjs`
- перед `classifyFilePath` (≈357): `export const DEFAULT_NORMATIVE_STEMS = Object.freeze([...])` + локальный `_esc`;
- сигнатура `classifyFilePath(path, normativeStems = DEFAULT_NORMATIVE_STEMS)`;
- строки 371-373 → цикл по `normativeStems`;
- `extractFileTypeDistribution(files, normativeStems = DEFAULT_NORMATIVE_STEMS)` + вызов 401 `classifyFilePath(f, normativeStems)`;
- стр. 936 `extractFileTypeDistribution(ts.files, options.normativeStems)`.
### Шаг 4 — GREEN: правка `observer-stop-hook.mjs`
- `buildEpisodeFromContext(ctx = {}, transcriptText = null, options = {})``parseTranscript(transcriptText, sid, options)`;
- `buildEpisode({ state = null, transcriptText = null, ctx = {}, options = {} } = {})``buildEpisodeFromContext(ctx, transcriptText, options)`;
- `main()` (≈393): прочитать `normative_files → docStem` (await import brain-config + cross-ref-checker, try/catch), `buildEpisodeFromContext(ctx, transcriptText, stopOpts)`.
### Шаг 5 — GREEN-прогон + регрессия (terminal владельца)
`npx vitest run --config vitest.config.tools.mjs tools/observer-transcript-parser.test.mjs tools/observer-stop-hook.test.mjs`
→ PASS (новые + существующие; дефолт сохранил поведение). Полный свод + коммит — терминал владельца.
## Исполнение под стеной
Production-файлы (`observer-transcript-parser.mjs`, `observer-stop-hook.mjs`) → `enforce-tdd-gate`
(floor-escape НЕ снимает) + harness-collapse vitest-RED → нужен owner-override `ремонт инфраструктуры`
+ `ремонт: <причина>`. Тест-файл (`.test.mjs`) — не production, хватает floor-escape per-edit. Коммит +
полный свод — терминал владельца. Печать H4 не встаёт → весь impl escape-per-step.
+14 -3
View File
@@ -125,9 +125,9 @@ export function appendEpisode(episode, baseDir = process.cwd(), month = currentM
* @param {string|null} transcriptText - Raw transcript JSONL, if readable.
* @returns {object} v2 episode.
*/
export function buildEpisodeFromContext(ctx = {}, transcriptText = null) {
export function buildEpisodeFromContext(ctx = {}, transcriptText = null, options = {}) {
if (transcriptText) {
return parseTranscript(transcriptText, ctx.session_id || ctx.sessionId || ctx.task_id);
return parseTranscript(transcriptText, ctx.session_id || ctx.sessionId || ctx.task_id, options);
}
const sid = ctx.session_id || ctx.sessionId || ctx.task_id || `unknown-${Date.now()}`;
const now = new Date().toISOString();
@@ -391,7 +391,18 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-s
}
}
try {
const ep = buildEpisodeFromContext(ctx, transcriptText);
// Greenfield #3-observer: derive project-normative stems from config so the
// file-type classifier recognises a consumer project's own normative docs.
// Fail-safe (design §6): config empty → [] (universal-only); brain-config/cross-ref
// unavailable → parser default (Лидерра 3, backward-compat).
let stopOpts = {};
try {
const { loadConfig } = await import('./brain-config.mjs');
const { docStem } = await import('./cross-ref-checker.mjs');
const nf = loadConfig(process.cwd()).normative_files;
stopOpts = { normativeStems: (Array.isArray(nf) ? nf : []).map(docStem).filter(Boolean) };
} catch { /* fallback → parser default */ }
const ep = buildEpisodeFromContext(ctx, transcriptText, stopOpts);
// Bug fix 2026-05-26: resolve the real user prompt before calling
// downstream consumers. ctx.prompt is never set by Stop-event stdin —
+18 -8
View File
@@ -357,7 +357,16 @@ function collectToolResultText(turn) {
// Pass 3 — path-pattern classifier (project-brain-factor-analysis-4passes).
// Returns one of: test / config / spec / norm / data / src / other.
// Priority order matters (test before src, norm before src, etc).
export function classifyFilePath(path) {
//
// Greenfield #3-observer: project-normative stems are config-driven (design
// 2026-06-16-greenfield-regex-names-config-design §5). Default = the three Лидерра
// stems → byte-identical to the previous hardcoded patterns; a greenfield project
// supplies its own via loadConfig().normative_files → docStem (wired in observer-stop-hook).
export const DEFAULT_NORMATIVE_STEMS = Object.freeze(['Pravila_raboty_Claude', 'Plugin_stack_rules', 'Tooling']);
const _escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export function classifyFilePath(path, normativeStems = DEFAULT_NORMATIVE_STEMS) {
if (!path) return 'other';
const p = String(path).replace(/\\/g, '/');
const base = p.split('/').pop() || p;
@@ -366,11 +375,12 @@ export function classifyFilePath(path) {
if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base)) return 'test';
if (/(?:^|\/)(?:tests?|spec)\//i.test(p)) return 'test';
// 2. normative documents (CLAUDE.md / Pravila / PSR / Tooling / Открытые_вопросы / memory store).
// 2. normative documents. Universal docs (CLAUDE.md / Открытые_вопросы / MEMORY.md / memory
// store) stay hardcoded; project-normative docs match config stems (default = Лидерра quintet).
if (/(?:^|\/)CLAUDE\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Pravila_raboty_Claude[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Plugin_stack_rules[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)Tooling[^/]*\.md$/i.test(p)) return 'norm';
for (const stem of (normativeStems || [])) {
if (stem && new RegExp(`(?:^|/)${_escRe(stem)}[^/]*\\.md$`, 'i').test(p)) return 'norm';
}
if (/(?:^|\/)Открытые_вопросы[^/]*\.md$/i.test(p)) return 'norm';
if (/(?:^|\/)MEMORY\.md$/i.test(p)) return 'norm';
if (/\/memory\/[^/]+\.md$/i.test(p)) return 'norm';
@@ -395,10 +405,10 @@ export function classifyFilePath(path) {
const FILE_TYPE_CATEGORIES = ['src', 'test', 'config', 'spec', 'norm', 'data', 'other'];
export function extractFileTypeDistribution(files) {
export function extractFileTypeDistribution(files, normativeStems = DEFAULT_NORMATIVE_STEMS) {
const dist = Object.fromEntries(FILE_TYPE_CATEGORIES.map((c) => [c, 0]));
for (const f of files || []) {
dist[classifyFilePath(f)] += 1;
dist[classifyFilePath(f, normativeStems)] += 1;
}
return dist;
}
@@ -933,7 +943,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option
return {
prompt_length_chars: typeof prompt === 'string' ? prompt.length : 0,
mcp_servers_used: extractMcpServers(turn),
file_type_distribution: extractFileTypeDistribution(ts.files),
file_type_distribution: extractFileTypeDistribution(ts.files, options.normativeStems),
};
})(),
classifier_output: _classifierOutput,
+21
View File
@@ -1900,6 +1900,27 @@ describe('classifyFilePath — Pass 3 path-pattern bucketing (project-brain-fact
});
});
describe('classifyFilePath — config-driven normative stems (greenfield #3-observer)', () => {
it('custom stem matches a greenfield normative doc', () => {
expect(classifyFilePath('docs/MyRules_v2.md', ['MyRules'])).toBe('norm');
});
it('custom stems excluding Лидерра docs → those are not norm', () => {
expect(classifyFilePath('docs/Pravila_raboty_Claude_v1_1.md', ['MyRules'])).toBe('other');
});
it('empty stems → Лидерра doc not norm, universal CLAUDE.md/memory still norm', () => {
expect(classifyFilePath('docs/Tooling_v8_3.md', [])).toBe('other');
expect(classifyFilePath('CLAUDE.md', [])).toBe('norm');
expect(classifyFilePath('C:\\Users\\x\\.claude\\projects\\p\\memory\\f.md', [])).toBe('norm');
});
it('default (no stems arg) keeps current 3 Лидерра stems as norm', () => {
expect(classifyFilePath('docs/Plugin_stack_rules_v1.md')).toBe('norm');
});
it('extractFileTypeDistribution threads custom normativeStems', () => {
expect(extractFileTypeDistribution(['docs/MyRules_v2.md'], ['MyRules']).norm).toBe(1);
expect(extractFileTypeDistribution(['docs/MyRules_v2.md'], []).norm).toBe(0);
});
});
describe('extractFileTypeDistribution — Pass 3 (project-brain-factor-analysis-4passes)', () => {
it('counts each path bucket and zero-fills missing categories', () => {
const dist = extractFileTypeDistribution([