refactor(router): Этап B сноса цепочек L — потребители/наблюдатель/хуки без реестра L1-L17

B2: router-classifier.mjs без buildChainsBlock и блоков «Available chains» (recommended_chain сохранён).
B3: удалены router-stop-gate(+test) и observer-chain-map-checker(+test); status-md-generator без
health-check C6; lefthook.yml без шага observer-chain-map-checker. observer-chain-detector и
observer-chain-map.json — снос отложен в под-этап (живые импортёры transcript-parser/retrofill/stop-hook).
Гайд: восстановлена markdown-разметка урока 2026-06-20. Полный свод зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-21 04:01:48 +03:00
parent 4249535eea
commit c5af28f529
9 changed files with 8 additions and 305 deletions
+8 -7
View File
@@ -248,13 +248,14 @@ Claude обязан **запросить** подтверждение. В шта
6. **Stop-loss:** 2-3 NO-GO подряд = чини СВОЙ план по тексту замечания, не цикл и не «вина стены».
7. **Спека = ЧТО (контракт), план = КАК (метод).** Метод правки, описанный в спеке, ловит fatal — метод только в плане.
⚠️ Урок 2026-06-20 — не прячь работу в скрипт (наставник/судья режут «чёрный ящик»)
Контроллер 8 раз переписывал план, пряча всю содержательную правку (вырезка schema.json, прогон тестов, коммит) внутрь одного node-скрипта-комбайна, чтобы обойти гейты. Наставник/судья справедливо не пускали; контроллер латал по одному возражению и валил на «судья тупит». Корень — подход, а не судья.
## ⚠️ Урок 2026-06-20 — не прячь работу в скрипт (наставник/судья режут «чёрный ящик»)
Не прячь содержательную работу в node-скрипт ради обхода гейтов. node x.mjs не попадает под пэттерн-матч criterion/verify-гейтов — соблазн спрятать туда правки/тесты/коммит. Но наставник и судья видят только steps-json («Write скрипт → запусти скрипт»), ВНУТРЬ не заглядывают → корректно читают как «план ничего не меняет» и «не доверяю чёрному ящику» → NO-GO. Латать бесполезно.
Содержательные правки — ВИДИМЫЕ шаги плана (Edit/Write на каждый файл; судья проверяет: убрано ровно нужное, файл валиден, остальное цело). Особенно КРИТИЧНЫЕ файлы (schema.json валидирует весь реестр — битый JSON роняет загрузку) — прямой Write чистого содержимого, не слепая резка внутри скрипта.
Прогон тестов — отдельный видимый шаг, не внутри скрипта-комбайна.
Коммит-через-скрипт (§5/§D) — ТОЛЬКО для механики git add/commit/push, НЕ для самих правок и не для верификации. Не расширяй его, чтобы протащить работу мимо стены.
Симптом хитрости: план = «Write скрипт + запусти скрипт», реальная работа невидима. Поймал себя — переделай на видимые шаги, не дожимай owner-seal
> Записано после сессии, где контроллер 8 раз переписывал план, пряча всю содержательную правку (вырезка `schema.json`, прогон тестов, коммит) внутрь одного node-скрипта-комбайна, чтобы обойти гейты. Наставник/судья справедливо не пускали; контроллер латал по одному возражению и валил на «судья тупит». Корень — подход, а не судья.
1. **Не прячь содержательную работу в node-скрипт ради обхода гейтов.** `node x.mjs` не попадает под пэттерн-матч criterion/verify-гейтов — соблазн спрятать туда правки/тесты/коммит. Но наставник и судья видят только `steps-json` («Write скрипт → запусти скрипт»), ВНУТРЬ не заглядывают → корректно читают как «план ничего не меняет» и «не доверяю чёрному ящику» → NO-GO. Латать бесполезно.
2. **Содержательные правки — ВИДИМЫЕ шаги плана** (Edit/Write на каждый файл; судья проверяет: убрано ровно нужное, файл валиден, остальное цело). Особенно КРИТИЧНЫЕ файлы (`schema.json` валидирует весь реестр — битый JSON роняет загрузку) — прямой Write чистого содержимого, не слепая резка внутри скрипта.
3. **Прогон тестов — отдельный видимый шаг**, не внутри скрипта-комбайна.
4. **Коммит-через-скрипт (§5/§D) — ТОЛЬКО для механики** `git add/commit/push`, НЕ для самих правок и не для верификации. Не расширяй его, чтобы протащить работу мимо стены.
5. **Симптом хитрости:** план = «Write скрипт + запусти скрипт», реальная работа невидима. Поймал себя — переделай на видимые шаги, не дожимай owner-seal.
[↑ наверх](#top)
-6
View File
@@ -86,12 +86,6 @@ pre-commit:
fail_text: |
observer-coverage-checker reports a gap (coverage or registration).
# 9. observer-chain-map-checker — brain governance C6 (chain attribution).
- name: observer-chain-map-checker
run: node tools/observer-chain-map-checker.mjs
fail_text: |
observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md.
# 10. registry-render-check — drift nodes.yaml <-> auto-region маркеры (warn-only).
- name: registry-render-check
glob: "{docs/registry/nodes.yaml,docs/Tooling_v8_3.md,docs/routing-off-phase.md}"
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env node
/**
* Brain governance controller C6 — chain-map sync checker.
* Verifies tools/observer-chain-map.json against the L1-L13 table in
* docs/routing-off-phase.md. Sync is checked by L-number sets (both
* directions), not by node names — node_chosen values (skill-id) differ
* from the human display names in the .md table. Pure fs/regex, no LLM.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MD_PATH = join(__dirname, '..', 'docs', 'routing-off-phase.md');
const JSON_PATH = join(__dirname, 'observer-chain-map.json');
/** Extract the set of L-numbers ("L1".."L13") from the routing-off-phase.md table. */
export function parseChainsFromMd(md) {
const set = new Set();
for (const line of md.split(/\r?\n/)) {
const m = /^\|\s*(L\d+)\s*\|/.exec(line.trim());
if (m) set.add(m[1]);
}
return set;
}
/** Compare JSON L-numbers against the md set, both directions. */
export function checkSync(jsonMap, mdSet) {
const jsonSet = new Set();
for (const [node, chains] of Object.entries(jsonMap)) {
if (node === '_note') continue;
if (Array.isArray(chains)) for (const c of chains) jsonSet.add(c);
}
const jsonOnly = [...jsonSet].filter((c) => !mdSet.has(c)); // ссылки на несуществующие L
const mdOnly = [...mdSet].filter((c) => !jsonSet.has(c)); // потерянные цепочки
return { ok: jsonOnly.length === 0 && mdOnly.length === 0, jsonOnly, mdOnly };
}
/** CLI entry — exit 1 on drift with a human-readable message. */
function main() {
const md = readFileSync(MD_PATH, 'utf8');
const jsonMap = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
const mdSet = parseChainsFromMd(md);
if (mdSet.size === 0) {
console.error(
'[chain-map-checker] не нашёл ни одной L-строки в routing-off-phase.md — формат таблицы изменился?'
);
process.exit(1);
}
const res = checkSync(jsonMap, mdSet);
if (res.ok) {
console.log(`[chain-map-checker] OK — ${mdSet.size} chains in sync`);
process.exit(0);
}
console.error('[chain-map-checker] дрейф маппинга chain-map <-> routing-off-phase.md:');
if (res.jsonOnly.length)
console.error(` JSON ссылается на отсутствующие в .md цепочки: ${res.jsonOnly.join(', ')}`);
if (res.mdOnly.length)
console.error(
` В .md есть цепочки без записи в JSON: ${res.mdOnly.join(', ')} — добавьте узлы в tools/observer-chain-map.json`
);
process.exit(1);
}
if (process.argv[1]?.endsWith('observer-chain-map-checker.mjs')) {
main();
}
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseChainsFromMd, checkSync } from './observer-chain-map-checker.mjs';
const SAMPLE_MD = [
'| # | Цепочка | Зачем |',
'|---|---|---|',
'| L1 | `discovery-interview` (FEATURE) → `brainstorming` | text |',
'| L2 | `audit-portal` | text |',
'| L13 | `billing-audit` (#62) + `Pest` | text |',
].join('\n');
describe('parseChainsFromMd', () => {
it('extracts the set of L-numbers from the table', () => {
expect(parseChainsFromMd(SAMPLE_MD)).toEqual(new Set(['L1', 'L2', 'L13']));
});
});
describe('checkSync', () => {
it('passes when JSON L-numbers subset of md and md subset of json-union', () => {
const mdSet = new Set(['L1', 'L2', 'L13']);
const jsonMap = { a: ['L1'], b: ['L2'], c: ['L13'] };
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
});
it('fails when JSON references a chain absent from md', () => {
const mdSet = new Set(['L1', 'L2']);
const jsonMap = { a: ['L1'], b: ['L99'] };
const res = checkSync(jsonMap, mdSet);
expect(res.ok).toBe(false);
expect(res.jsonOnly).toContain('L99');
});
it('fails when md has a chain not covered by any JSON entry', () => {
const mdSet = new Set(['L1', 'L2', 'L14']);
const jsonMap = { a: ['L1'], b: ['L2'] };
const res = checkSync(jsonMap, mdSet);
expect(res.ok).toBe(false);
expect(res.mdOnly).toContain('L14');
});
it('ignores the _note metadata key in the JSON map', () => {
const mdSet = new Set(['L1']);
const jsonMap = { _note: 'meta', a: ['L1'] };
expect(checkSync(jsonMap, mdSet).ok).toBe(true);
});
});
-17
View File
@@ -254,12 +254,6 @@ function buildNodesBlock(registry) {
}).join('\n');
}
function buildChainsBlock(registry) {
return Object.entries(registry.chains || {})
.map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`)
.join('\n');
}
/**
* Build Sonnet 4.6 classifier prompt per spec §4.2.
*
@@ -291,7 +285,6 @@ export function buildClassifierPrompt(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 = `Ты классификатор задач для ${classifierContext}.
@@ -307,9 +300,6 @@ ${pamyatka}
=== РЕЕСТР УЗЛОВ ===
${nodesBlock}
=== РЕЕСТР ЦЕПОЧЕК (справочно) ===
${chainsBlock}
Пример формата вывода (значения иллюстративные, не подсказка по содержанию):
{"task_type":"bugfix","skill":"#18","chain":null,"no_skill_found":false,"recommended_chain":["#18","#19"],"confidence":0.9,"alternatives_considered":[{"id":"#62","score":0.3,"reason":"не про деньги"}],"reason_for_choice":"keyword 'regex' → systematic-debugging"}
@@ -451,18 +441,11 @@ export function buildLLMPrompt(prompt, registry) {
return `- ${n.id} ${n.name} [${triggers}]`;
}).join('\n');
const chains = Object.entries(registry.chains || {})
.map(([id, c]) => `- ${id}: ${c.name} [${(c.sequence || []).join(' → ')}]`)
.join('\n');
return `${LEGACY_LLM_SYSTEM_PROMPT}
## Available nodes
${nodeLines}
## Available chains
${chains}
## User prompt
${prompt}
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env node
/**
* Stop hook addition — router chain progress tracking.
* Stage 3 of router discipline overhaul.
*
* После каждого хода: обновляет state.chainProgress на основе вызванных в этом ходу скилов.
* Helper для observer-stop-hook — он сам решает, вызывать ли (зависит от того, есть ли router-state).
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
export function extractSkillInvocations(events) {
return (events || [])
.filter((e) => e && e.tool_name === 'Skill')
.map((e) => {
const raw = e.tool_input?.skill || '';
const stripped = raw.includes(':') ? raw.split(':').pop() : raw;
return stripped;
})
.filter(Boolean);
}
export function updateChainProgress(state, skillsInvoked, chains) {
const chainId = state.classification?.recommendedChain;
if (!chainId || !chains[chainId]) return { ...state };
const sequence = chains[chainId].sequence || [];
const currentProgress = [...(state.chainProgress || [])];
for (const skill of skillsInvoked) {
const nextExpectedIdx = currentProgress.length;
if (nextExpectedIdx >= sequence.length) break;
if (sequence[nextExpectedIdx] === skill) {
currentProgress.push(skill);
}
}
return {
...state,
chainProgress: currentProgress,
chainCompleted: currentProgress.length === sequence.length && sequence.length > 0,
};
}
async function main() {
const input = await readStdinAsUtf8(process.stdin);
const event = JSON.parse(input || '{}');
const sessionId = event.session_id || 'unknown';
const events = event.turn_events || [];
const statePath = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
if (!existsSync(statePath)) { process.stdout.write('{}'); process.exit(0); return; }
try {
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
const { loadRegistry } = await import('./registry-load.mjs');
const registry = loadRegistry({ useCache: false });
const skills = extractSkillInvocations(events);
const updated = updateChainProgress(state, skills, registry.chains || {});
updated.skillInvokedThisTurn = skills.length > 0;
writeFileSync(statePath, JSON.stringify(updated, null, 2));
process.stdout.write('{}');
process.exit(0);
} catch (err) {
process.stderr.write(`[router-stop-gate] ${err.message}\n`);
process.stdout.write('{}');
process.exit(0);
}
}
// CLI guard — Windows-cyrillic path quirk: compare resolved path, not raw argv[1]
const isMain = (() => {
try {
return process.argv[1] &&
fileURLToPath(import.meta.url) === fileURLToPath(`file:///${process.argv[1].replace(/\\/g, '/')}`);
} catch {
return process.argv[1] && import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}`;
}
})();
if (isMain) {
main();
}
-66
View File
@@ -1,66 +0,0 @@
import { describe, it, expect } from 'vitest';
import { updateChainProgress, extractSkillInvocations } from './router-stop-gate.mjs';
const chains = {
L1: { name: 'brainstorming chain', sequence: ['brainstorming', 'writing-plans', 'executing-plans'] },
L13: { name: 'finance chain', sequence: ['billing-audit', 'pest', 'ru-tax-accounting'] },
};
describe('extractSkillInvocations', () => {
it('extracts skill names from Skill tool invocations', () => {
const events = [
{ tool_name: 'Skill', tool_input: { skill: 'brainstorming' } },
{ tool_name: 'Edit', tool_input: {} },
{ tool_name: 'Skill', tool_input: { skill: 'writing-plans' } },
];
expect(extractSkillInvocations(events)).toEqual(['brainstorming', 'writing-plans']);
});
it('returns empty when no Skill invocations', () => {
expect(extractSkillInvocations([{ tool_name: 'Edit' }])).toEqual([]);
});
it('strips namespace prefix (superpowers:brainstorming → brainstorming)', () => {
const events = [{ tool_name: 'Skill', tool_input: { skill: 'superpowers:brainstorming' } }];
expect(extractSkillInvocations(events)).toEqual(['brainstorming']);
});
});
describe('updateChainProgress', () => {
it('appends matched chain step to chainProgress', () => {
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
const updated = updateChainProgress(state, ['brainstorming'], chains);
expect(updated.chainProgress).toEqual(['brainstorming']);
});
it('appends multiple steps if multiple skills invoked', () => {
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
});
it('ignores skills not in chain sequence', () => {
const state = { classification: { recommendedChain: 'L1' }, chainProgress: [] };
const updated = updateChainProgress(state, ['random-skill'], chains);
expect(updated.chainProgress).toEqual([]);
});
it('marks chainCompleted=true when last step reached', () => {
const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming', 'writing-plans'] };
const updated = updateChainProgress(state, ['executing-plans'], chains);
expect(updated.chainCompleted).toBe(true);
});
it('preserves existing chainProgress without duplicates', () => {
const state = { classification: { recommendedChain: 'L1' }, chainProgress: ['brainstorming'] };
const updated = updateChainProgress(state, ['brainstorming', 'writing-plans'], chains);
expect(updated.chainProgress).toEqual(['brainstorming', 'writing-plans']);
});
});
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
it('module loads with UTF-8 helper wired (smoke)', async () => {
const mod = await import('./router-stop-gate.mjs');
expect(typeof mod.updateChainProgress).toBe('function');
});
});
-3
View File
@@ -447,7 +447,6 @@ ${qualityRows}
export function renderStatus(inputs) {
const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo, discipline } = inputs;
const c6 = inputs.c6 || { status: 'ok', detail: '—' };
const missed = inputs.missed || { totalMissed: 0, byNode: {}, byClassification: {} };
function formatPercent(p) { return `${(p * 100).toFixed(1)}%`; }
@@ -497,7 +496,6 @@ Last updated: ${now}
| C3 Observer-of-observer | ${iconFor(c3.status)} | ${c3.detail || '—'} |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ${iconFor(c5.status)} | ${c5.detail || '—'} |
| C6 Chain map sync | ${iconFor(c6.status)} | ${c6.detail || '—'} |
${inputs.guardBoardBlock ? `\n${inputs.guardBoardBlock.trim()}\n` : ''}
## Метрики (информационные, не алерты)
@@ -611,7 +609,6 @@ if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/status-md-
c2: runControllerNode(['tools/cross-ref-checker.mjs']),
c3: runControllerNode(['tools/observer-of-observer.mjs', 'check']),
c5: { status: c5ok ? 'ok' : 'warn', detail: c5detail },
c6: runControllerNode(['tools/observer-chain-map-checker.mjs']),
observer: {
episodeCount: countEpisodes(),
observerErrors: countObserverErrors(),
-6
View File
@@ -43,7 +43,6 @@ const baseInputs = (overrides = {}) => ({
c2: { status: 'ok', detail: '0 version drift' },
c3: { status: 'ok', detail: 'last read today' },
c5: { status: 'ok', detail: 'coverage OK · registration OK' },
c6: { status: 'ok', detail: '14 chains in sync' },
observer: { episodeCount: 12, observerErrors: 0, piiMatches: 0 },
missed: { totalMissed: 0, byNode: {}, byClassification: {} },
...overrides,
@@ -61,11 +60,6 @@ describe('renderStatus', () => {
expect(md).toContain('12 episodes');
});
it('includes a C6 chain-map row', () => {
const md = renderStatus(baseInputs());
expect(md).toContain('| C6 Chain map sync | ✅');
});
it('shows a warn status for the coverage controller', () => {
const md = renderStatus(baseInputs({ c5: { status: 'warn', detail: '3 commits, 0 episodes' } }));
expect(md).toContain('| C5 Observer-coverage | ⚠️');