diff --git a/docs/superpowers/plans/2026-06-16-greenfield-observer-config-plan.md b/docs/superpowers/plans/2026-06-16-greenfield-observer-config-plan.md new file mode 100644 index 0000000..59f5fa3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-greenfield-observer-config-plan.md @@ -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` → `(?:^|/)[^/]*\.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. diff --git a/tools/observer-stop-hook.mjs b/tools/observer-stop-hook.mjs index 6b4c800..2088069 100644 --- a/tools/observer-stop-hook.mjs +++ b/tools/observer-stop-hook.mjs @@ -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 — diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 87829ab..a64cf16 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -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, diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 8102ba9..f6bde1e 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -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([