feat: brain-config classifyFilePath стемы нормативки config-driven — greenfield #3 observer
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 —
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user