docs(router-mentor): plan blockB classes 1+2 (R-09/R-24/R-30/reconcile)
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
# Блок B (вшивка) Классы 1+2 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: эта сессия исполняет план ИНЛАЙН через
|
||||
> superpowers:executing-plans (субагенты/Task/Workflow ЗАПРЕЩЕНЫ — решение владельца).
|
||||
> Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Доставить 4 observation-only сигнала Блока B в read-only STATUS-генератор +
|
||||
инертный reconcile-reader, не трогая гейт-решения и hard-wall mains машин М1/М2/М4/М5/М6.
|
||||
|
||||
**Architecture:** Реюз паттерна `guardBoardBlock` — чистые `compute*Block(...)` (markdown из
|
||||
данных, ноль I/O) + fail-quiet wiring в `status-md-generator.main()` (try/catch→null, блок
|
||||
вставляется в `renderStatus` через `inputs.*Block`). Пункт 4 (reconcile) — чистый
|
||||
`reconcileEvent` + тонкая I/O-обёртка `enforce-reconcile.main`, **инертна** до регистрации
|
||||
хука владельцем (owner-шаг A3). Все примитивы готовы: `loadQueue`/`statusSignal` (R-09),
|
||||
`auditDoors`/`auditExempt` (R-24), `verifyChain`/`loadJournal` (R-30), `reconcileAction` (п.4).
|
||||
|
||||
**Tech Stack:** Node ESM (`tools/*.mjs`), vitest (tools-only config), TDD RED→GREEN инлайн.
|
||||
|
||||
**Границы (жёсткое):** п.1-3 — только `tools/status-md-generator.mjs` (вывод, не гейт) +
|
||||
read-only примитивы; п.4 — только `tools/enforce-reconcile.mjs` (инертен). Единственная правка
|
||||
в hard-wall-файле — **аддитивный `export` константы `SEED_TOOLS`** в
|
||||
`tools/enforce-supreme-gate.mjs` (Задача 2): экспорт уже-существующей `const` для read-only
|
||||
аудита R-24, гейт-решения **байт-в-байт неизменны** (пиннинг-тест инвариантности). Это НЕ
|
||||
ослабление стены М2. Стена/пол/судья/escape логически не трогаются.
|
||||
|
||||
**Регрессия-цель:** tools-only ≥ **3189 GREEN** (baseline после E/G warm-up).
|
||||
|
||||
**Quirks сессии (из handoff 2026-06-09):**
|
||||
- vitest: `npx vitest run --root "<wt>/app" --config "<wt>/app/vitest.config.tools.mjs" <фильтр> --reporter dot` — БЕЗ `cd app`, явный `--root`. После 1-го Bash cwd залипает в `app/`.
|
||||
- git — только PowerShell, `git -C "<wt>"`, литеральные пути.
|
||||
- PowerShell-гейт режет тело коммита (`>`/`>=` = redirect, редкие латинские слова = cmdlet) → короткие single `-m` ASCII-сообщения; стейдж отдельным `git add`.
|
||||
- `enforce-tdd-real-test-verifier`: каждая правка `*.test.mjs` через Edit обязана сама содержать `expect`+`it` со ссылкой на prod-файл → новый импорт + тесты ОДНОЙ правкой.
|
||||
- Список файлов — Grep (Glob ломается на junction worktree).
|
||||
- commit-not-push: пуш ТОЛЬКО по слову «пуш» через finishing-a-development-branch.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Ответственность | Действие |
|
||||
|---|---|---|
|
||||
| `tools/router-learning-queue.mjs` | R-09 примитивы очереди (`loadQueue`/`statusSignal`/`pendingCount`) | реюз (НЕ трогаем) |
|
||||
| `tools/door-coverage.mjs` | R-24 примитивы (`auditDoors`/`auditExempt`) + новые `extractGateMatcher`/`CANONICAL_MUTATING_TOOLS`/`isMutatingTool` | расширяем |
|
||||
| `tools/action-journal.mjs` | R-30 примитивы (`verifyChain`/`loadJournal`) | реюз (НЕ трогаем) |
|
||||
| `tools/receipt-key-config.mjs` | `resolveReceiptKey()` для R-30 | реюз (НЕ трогаем) |
|
||||
| `tools/enforce-supreme-gate.mjs` | M2 стена; `SEED_SKILLS` (экспортирован), `actionOf` (экспортирован), `SEED_TOOLS` | **+`export` у `SEED_TOOLS`** (аддитивно) |
|
||||
| `tools/status-md-generator.mjs` | 3 новых `compute*Block` (R-09/R-24/R-30) + fail-quiet wiring + renderStatus | расширяем |
|
||||
| `tools/enforce-reconcile.mjs` | `reconcileAction`/`findOrphanIntents` (готовы) + новый `reconcileEvent` + main-reader | расширяем |
|
||||
| `tools/status-md-generator.test.mjs` | тесты 3 новых блоков + renderStatus wiring | расширяем |
|
||||
| `tools/door-coverage.test.mjs` | тесты `extractGateMatcher`/`CANONICAL_MUTATING_TOOLS`/`isMutatingTool` | расширяем |
|
||||
| `tools/enforce-supreme-gate.test.mjs` (или существующий) | пиннинг-инвариантность `SEED_TOOLS` export | расширяем |
|
||||
| `tools/enforce-reconcile.test.mjs` | тесты `reconcileEvent` | расширяем |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: R-09 — блок «Очередь обучения роутера» в STATUS
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/status-md-generator.mjs` (импорт из router-learning-queue + новый `computeLearningQueueBlock` + wiring в main + renderStatus template)
|
||||
- Test: `tools/status-md-generator.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (pure + renderStatus wiring)**
|
||||
|
||||
Добавить в `tools/status-md-generator.test.mjs` (новый импорт `computeLearningQueueBlock` + тесты ОДНОЙ правкой — verifier требует expect+it):
|
||||
|
||||
```js
|
||||
import { computeLearningQueueBlock } from './status-md-generator.mjs';
|
||||
|
||||
describe('computeLearningQueueBlock (R-09 очередь обучения)', () => {
|
||||
it('пустая очередь → «очередь пуста»', () => {
|
||||
const md = computeLearningQueueBlock({ queue: [] });
|
||||
expect(md).toContain('## Очередь обучения роутера');
|
||||
expect(md).toContain('Очередь пуста');
|
||||
});
|
||||
it('нет аргумента (undefined) → не падает, очередь пуста', () => {
|
||||
const md = computeLearningQueueBlock({});
|
||||
expect(md).toContain('Очередь пуста');
|
||||
});
|
||||
it('есть pending → показывает «ждут одобрения: N» и список', () => {
|
||||
const queue = [
|
||||
{ id: 'c1', kind: 'example', summary: 'кейс A', why_proposed: 'p', status: 'pending' },
|
||||
{ id: 'c2', kind: 'example', summary: 'кейс B', why_proposed: 'p', status: 'approved' },
|
||||
];
|
||||
const md = computeLearningQueueBlock({ queue });
|
||||
expect(md).toContain('ждут одобрения: 1');
|
||||
expect(md).toContain('c1');
|
||||
expect(md).not.toContain('кейс B'); // approved не показываем
|
||||
});
|
||||
it('экранирует pipe в summary (anti-injection)', () => {
|
||||
const queue = [{ id: 'x', kind: 'k', summary: 'a | b', why_proposed: '', status: 'pending' }];
|
||||
const md = computeLearningQueueBlock({ queue });
|
||||
expect(md).toContain('a \\| b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStatus — learningQueueBlock (R-09)', () => {
|
||||
const base = {
|
||||
now: '2026-06-09T10:00:00Z',
|
||||
c1: { status: 'ok', detail: 'OK' }, c2: { status: 'ok', detail: 'OK' },
|
||||
c3: { status: 'ok', detail: 'OK' }, c5: { status: 'ok', detail: 'OK' },
|
||||
observer: { episodeCount: 0, observerErrors: 0, piiMatches: 0 },
|
||||
};
|
||||
it('вставляет блок строкой при наличии', () => {
|
||||
const md = renderStatus({ ...base, learningQueueBlock: '## Очередь обучения роутера\n\nОчередь пуста.' });
|
||||
expect(md).toContain('## Очередь обучения роутера');
|
||||
});
|
||||
it('опускает при отсутствии (backward compat)', () => {
|
||||
const md = renderStatus(base);
|
||||
expect(md).not.toContain('## Очередь обучения роутера');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — убедиться, что падает (RED)**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" status-md-generator --reporter dot`
|
||||
Expected: FAIL — `computeLearningQueueBlock is not a function` / `is not exported`.
|
||||
|
||||
- [ ] **Step 3: Реализация — импорт + функция + wiring + template**
|
||||
|
||||
В `tools/status-md-generator.mjs`:
|
||||
|
||||
(a) После существующих импортов (после строки 13 `import { judgeGateMode }...`) добавить:
|
||||
|
||||
```js
|
||||
import { loadQueue, statusSignal, pendingCount } from './router-learning-queue.mjs';
|
||||
```
|
||||
|
||||
(b) После `computeGuardBoardBlock` (после строки 82) добавить функцию (использует существующий `escapeCell`):
|
||||
|
||||
```js
|
||||
/**
|
||||
* R-09 (Блок B Класс 1) — read-only блок очереди обучения роутера в STATUS.
|
||||
* Реюз `statusSignal`/`pendingCount` из router-learning-queue. Наполнение фонда —
|
||||
* ТОЛЬКО по явному «да» владельца (hard-rule модуля); здесь только показываем pending.
|
||||
*/
|
||||
export function computeLearningQueueBlock({ queue } = {}) {
|
||||
const q = Array.isArray(queue) ? queue : [];
|
||||
if (pendingCount(q) === 0) {
|
||||
return `## Очередь обучения роутера\n\nОчередь пуста — нет кандидатов на одобрение.`;
|
||||
}
|
||||
const lines = q.filter((e) => e && e.status === 'pending')
|
||||
.map((e) => `- [${escapeCell(e.id)}] (${escapeCell(e.kind)}) ${escapeCell(e.summary)}`)
|
||||
.join('\n');
|
||||
return `## Очередь обучения роутера\n\n${statusSignal(q)} — наполнение фонда ТОЛЬКО по явному «да» владельца (см. \`/brain-retro\`).\n\n${lines}`;
|
||||
}
|
||||
```
|
||||
|
||||
(c) В `renderStatus` template (строка 439, длинная цепочка `${inputs.X ? ... : ''}`) — добавить в конец цепочки, перед `\n## Алерт-индикаторы`:
|
||||
|
||||
```js
|
||||
${inputs.learningQueueBlock ? `\n${inputs.learningQueueBlock.trim()}\n` : ''}
|
||||
```
|
||||
|
||||
(d) В main-блоке (`if (process.argv[1]...`), рядом с другими fail-quiet блоками (после строки 591 `inputs.systemHealthBlock = systemHealthBlock;`) добавить:
|
||||
|
||||
```js
|
||||
let learningQueueBlock = null;
|
||||
try {
|
||||
const qPath = join(homedir(), '.claude', 'runtime', 'router-learning-queue.json');
|
||||
const queue = existsSync(qPath) ? loadQueue({ path: qPath }) : [];
|
||||
learningQueueBlock = computeLearningQueueBlock({ queue });
|
||||
} catch (err) { console.warn('[status-md-generator] learningQueueBlock skipped:', err.message); learningQueueBlock = null; }
|
||||
inputs.learningQueueBlock = learningQueueBlock;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — GREEN**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" status-md-generator --reporter dot`
|
||||
Expected: PASS (все блоки status-md-generator зелёные).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" commit -m "feat(status): R-09 learning queue block (read-only)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: R-24 (часть 1) — door-coverage helpers + export SEED_TOOLS
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/door-coverage.mjs` (+`CANONICAL_MUTATING_TOOLS`, `isMutatingTool`, `extractGateMatcher`)
|
||||
- Modify: `tools/enforce-supreme-gate.mjs` (строка 25 — `const SEED_TOOLS` → `export const SEED_TOOLS`, аддитивно)
|
||||
- Test: `tools/door-coverage.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `tools/door-coverage.test.mjs` (новый импорт + тесты ОДНОЙ правкой):
|
||||
|
||||
```js
|
||||
import { CANONICAL_MUTATING_TOOLS, isMutatingTool, extractGateMatcher } from './door-coverage.mjs';
|
||||
import { SEED_TOOLS as GATE_SEED_TOOLS } from './enforce-supreme-gate.mjs';
|
||||
|
||||
describe('CANONICAL_MUTATING_TOOLS / isMutatingTool (R-24)', () => {
|
||||
it('канонический список включает ключевые мутирующие инструменты', () => {
|
||||
for (const t of ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Skill']) {
|
||||
expect(CANONICAL_MUTATING_TOOLS).toContain(t);
|
||||
}
|
||||
});
|
||||
it('isMutatingTool: мутирующий → true, observe-only → false', () => {
|
||||
expect(isMutatingTool('Write')).toBe(true);
|
||||
expect(isMutatingTool('Read')).toBe(false);
|
||||
expect(isMutatingTool('EnterPlanMode')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractGateMatcher (R-24)', () => {
|
||||
it('хук не найден → []', () => {
|
||||
expect(extractGateMatcher({}, 'enforce-supreme-gate.mjs')).toEqual([]);
|
||||
expect(extractGateMatcher({ hooks: { PreToolUse: [] } }, 'enforce-supreme-gate.mjs')).toEqual([]);
|
||||
});
|
||||
it('matcher "*" → ["*"]', () => {
|
||||
const settings = { hooks: { PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'node tools/enforce-supreme-gate.mjs' }] }] } };
|
||||
expect(extractGateMatcher(settings, 'enforce-supreme-gate.mjs')).toEqual(['*']);
|
||||
});
|
||||
it('matcher "Edit|Write|Bash" → ["Edit","Write","Bash"]', () => {
|
||||
const settings = { hooks: { PreToolUse: [{ matcher: 'Edit|Write|Bash', hooks: [{ command: 'node tools/enforce-supreme-gate.mjs' }] }] } };
|
||||
expect(extractGateMatcher(settings, 'enforce-supreme-gate.mjs')).toEqual(['Edit', 'Write', 'Bash']);
|
||||
});
|
||||
it('пустой matcher "" → [] (аудит требует явного *)', () => {
|
||||
const settings = { hooks: { PreToolUse: [{ matcher: '', hooks: [{ command: 'node tools/enforce-supreme-gate.mjs' }] }] } };
|
||||
expect(extractGateMatcher(settings, 'enforce-supreme-gate.mjs')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEED_TOOLS export (R-24 anti-drift, инвариантность)', () => {
|
||||
it('экспортирован и содержит seed-инструменты стены', () => {
|
||||
expect(GATE_SEED_TOOLS.has('EnterPlanMode')).toBe(true);
|
||||
expect(GATE_SEED_TOOLS.has('AskUserQuestion')).toBe(true);
|
||||
});
|
||||
it('seed-инструменты НЕ числятся мутирующими (иначе auditExempt их флагует)', () => {
|
||||
for (const t of GATE_SEED_TOOLS) expect(isMutatingTool(t)).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — RED**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" door-coverage --reporter dot`
|
||||
Expected: FAIL — `CANONICAL_MUTATING_TOOLS` / `extractGateMatcher` не экспортированы; `SEED_TOOLS` не экспортирован из enforce-supreme-gate (import undefined).
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
(a) В `tools/enforce-supreme-gate.mjs` строка 25 — добавить `export` (аддитивно, логика стены неизменна):
|
||||
|
||||
```js
|
||||
export const SEED_TOOLS = new Set(['EnterPlanMode', 'AskUserQuestion']);
|
||||
```
|
||||
|
||||
(b) В `tools/door-coverage.mjs` (после существующих `auditDoors`/`auditExempt`) добавить:
|
||||
|
||||
```js
|
||||
/** Канонический набор мутирующих инструментов (по способности, выровнен с supreme-gate
|
||||
* default-deny: всё, что не observe-only и не seed). MCP-писатели динамические — не статичны. */
|
||||
export const CANONICAL_MUTATING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'Task', 'Skill'];
|
||||
|
||||
export function isMutatingTool(name) {
|
||||
return CANONICAL_MUTATING_TOOLS.includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь, какие инструменты покрывает matcher хука <basename> в settings.PreToolUse.
|
||||
* '*' → ['*'] (покрывает всё); "A|B" → ['A','B']; хук не найден / пустой matcher → []
|
||||
* (пустой = аудит требует явного '*', иначе сигналим «дверь не покрыта»).
|
||||
*/
|
||||
export function extractGateMatcher(settings, hookBasename) {
|
||||
const pre = settings && settings.hooks && settings.hooks.PreToolUse;
|
||||
if (!Array.isArray(pre)) return [];
|
||||
for (const entry of pre) {
|
||||
const inner = entry && entry.hooks;
|
||||
if (!Array.isArray(inner)) continue;
|
||||
const has = inner.some((h) => h && typeof h.command === 'string' && h.command.includes(hookBasename));
|
||||
if (!has) continue;
|
||||
const m = typeof entry.matcher === 'string' ? entry.matcher : '';
|
||||
if (m === '*') return ['*'];
|
||||
return m.split('|').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — GREEN**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" door-coverage enforce-supreme-gate --reporter dot`
|
||||
Expected: PASS (door-coverage новые тесты + enforce-supreme-gate существующие тесты без регрессий — export не меняет поведение).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" add tools/door-coverage.mjs tools/door-coverage.test.mjs tools/enforce-supreme-gate.mjs
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" commit -m "feat(door-coverage): matcher extractor + canonical mutating tools; export SEED_TOOLS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: R-24 (часть 2) — блок «Покрытие дверей» в STATUS
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/status-md-generator.mjs` (импорты door-coverage + SEED_TOOLS + `computeDoorCoverageBlock` + wiring + template)
|
||||
- Test: `tools/status-md-generator.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `tools/status-md-generator.test.mjs`:
|
||||
|
||||
```js
|
||||
import { computeDoorCoverageBlock } from './status-md-generator.mjs';
|
||||
|
||||
describe('computeDoorCoverageBlock (R-24 покрытие дверей)', () => {
|
||||
it('matcher "*" → все двери покрыты (✅)', () => {
|
||||
const settings = { hooks: { PreToolUse: [{ matcher: '*', hooks: [{ command: 'node tools/enforce-supreme-gate.mjs' }] }] } };
|
||||
const md = computeDoorCoverageBlock({ settings });
|
||||
expect(md).toContain('## Покрытие дверей');
|
||||
expect(md).toContain('все двери покрыты');
|
||||
});
|
||||
it('хук не зарегистрирован → ⚠️ забытые двери (все мутирующие)', () => {
|
||||
const md = computeDoorCoverageBlock({ settings: {} });
|
||||
expect(md).toContain('забытые двери');
|
||||
expect(md).toContain('Write');
|
||||
expect(md).toContain('Bash');
|
||||
});
|
||||
it('частичный matcher → флагует непокрытые', () => {
|
||||
const settings = { hooks: { PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ command: 'node tools/enforce-supreme-gate.mjs' }] }] } };
|
||||
const md = computeDoorCoverageBlock({ settings });
|
||||
expect(md).toContain('забытые двери');
|
||||
expect(md).toContain('Bash'); // не покрыт
|
||||
expect(md).not.toMatch(/непокрыт[а-я]*:.*Edit/i); // Edit покрыт
|
||||
});
|
||||
it('не падает при пустом settings undefined', () => {
|
||||
const md = computeDoorCoverageBlock({});
|
||||
expect(md).toContain('## Покрытие дверей');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStatus — doorCoverageBlock (R-24)', () => {
|
||||
const base = {
|
||||
now: '2026-06-09T10:00:00Z',
|
||||
c1: { status: 'ok', detail: 'OK' }, c2: { status: 'ok', detail: 'OK' },
|
||||
c3: { status: 'ok', detail: 'OK' }, c5: { status: 'ok', detail: 'OK' },
|
||||
observer: { episodeCount: 0, observerErrors: 0, piiMatches: 0 },
|
||||
};
|
||||
it('вставляет блок при наличии', () => {
|
||||
const md = renderStatus({ ...base, doorCoverageBlock: '## Покрытие дверей\n\nвсе двери покрыты.' });
|
||||
expect(md).toContain('## Покрытие дверей');
|
||||
});
|
||||
it('опускает при отсутствии', () => {
|
||||
expect(renderStatus(base)).not.toContain('## Покрытие дверей');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — RED**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" status-md-generator --reporter dot`
|
||||
Expected: FAIL — `computeDoorCoverageBlock is not a function`.
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
(a) В `tools/status-md-generator.mjs` импорты (рядом с Task-1 импортом):
|
||||
|
||||
```js
|
||||
import { auditDoors, auditExempt, extractGateMatcher, CANONICAL_MUTATING_TOOLS, isMutatingTool } from './door-coverage.mjs';
|
||||
import { SEED_TOOLS } from './enforce-supreme-gate.mjs';
|
||||
```
|
||||
|
||||
(b) Функция (после `computeLearningQueueBlock`):
|
||||
|
||||
```js
|
||||
/**
|
||||
* R-24 (Блок B Класс 2) — read-only блок покрытия дверей. Инструмент, не покрытый matcher'ом
|
||||
* верховной стены и не семя = «забытая дверь» (урок F1: PowerShell мимо Bash). auditExempt
|
||||
* страхует: семя, чья способность мутирующая, = опасное исключение. Гейт не трогается.
|
||||
*/
|
||||
export function computeDoorCoverageBlock({ settings } = {}) {
|
||||
const s = settings || {};
|
||||
const matcher = extractGateMatcher(s, 'enforce-supreme-gate.mjs');
|
||||
const seeds = [...SEED_TOOLS];
|
||||
const doors = auditDoors({ tools: CANONICAL_MUTATING_TOOLS, matcher, seeds });
|
||||
const exempt = auditExempt({ exempt: seeds, isMutating: isMutatingTool });
|
||||
const matcherStr = matcher.includes('*') ? '*' : (matcher.join(', ') || '(хук НЕ зарегистрирован)');
|
||||
if (doors.ok && exempt.ok) {
|
||||
return `## Покрытие дверей\n\n✅ Все двери покрыты верховной стеной М2 (matcher: ${escapeCell(matcherStr)}).`;
|
||||
}
|
||||
const parts = [`## Покрытие дверей\n\n⚠️ Есть забытые двери (matcher: ${escapeCell(matcherStr)}).`];
|
||||
if (!doors.ok) {
|
||||
parts.push(`\nНепокрытые мутирующие инструменты: ${doors.uncovered.map(escapeCell).join(', ')}`);
|
||||
}
|
||||
if (!exempt.ok) {
|
||||
parts.push(`\n🔴 Опасные исключения (семя с мутирующей способностью): ${exempt.flagged.map(escapeCell).join(', ')}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
(c) В `renderStatus` template добавить (после `learningQueueBlock`):
|
||||
|
||||
```js
|
||||
${inputs.doorCoverageBlock ? `\n${inputs.doorCoverageBlock.trim()}\n` : ''}
|
||||
```
|
||||
|
||||
(d) В main-блоке добавить (после Task-1 wiring):
|
||||
|
||||
```js
|
||||
let doorCoverageBlock = null;
|
||||
try {
|
||||
let settings = {};
|
||||
try { settings = JSON.parse(readFileSync('.claude/settings.json', 'utf-8')); } catch { settings = {}; }
|
||||
doorCoverageBlock = computeDoorCoverageBlock({ settings });
|
||||
} catch (err) { console.warn('[status-md-generator] doorCoverageBlock skipped:', err.message); doorCoverageBlock = null; }
|
||||
inputs.doorCoverageBlock = doorCoverageBlock;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — GREEN**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" status-md-generator --reporter dot`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" commit -m "feat(status): R-24 door coverage block (read-only)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: R-30 — блок «Целостность журналов» (verifyChain live в read-only STATUS)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/status-md-generator.mjs` (импорты action-journal + receipt-key + `computeJournalIntegrityBlock` + sweep-wiring + template)
|
||||
- Test: `tools/status-md-generator.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `tools/status-md-generator.test.mjs`:
|
||||
|
||||
```js
|
||||
import { computeJournalIntegrityBlock } from './status-md-generator.mjs';
|
||||
|
||||
describe('computeJournalIntegrityBlock (R-30 целостность журналов)', () => {
|
||||
it('ключ не provisioned → проверка недоступна (не «битые»)', () => {
|
||||
const md = computeJournalIntegrityBlock({ keyAvailable: false });
|
||||
expect(md).toContain('## Целостность журналов действий');
|
||||
expect(md).toContain('Ключ подписанта не provisioned');
|
||||
expect(md).not.toContain('🔴');
|
||||
});
|
||||
it('журналов нет → так и пишем', () => {
|
||||
const md = computeJournalIntegrityBlock({ keyAvailable: true, results: [] });
|
||||
expect(md).toContain('Журналов сессий не найдено');
|
||||
});
|
||||
it('все цепи целы → ✅', () => {
|
||||
const md = computeJournalIntegrityBlock({ keyAvailable: true, results: [
|
||||
{ sessionId: 'a', ok: true, brokenAt: null },
|
||||
{ sessionId: 'b', ok: true, brokenAt: null },
|
||||
] });
|
||||
expect(md).toContain('✅');
|
||||
expect(md).toContain('2 сессий');
|
||||
});
|
||||
it('битая цепь → 🔴 с seq', () => {
|
||||
const md = computeJournalIntegrityBlock({ keyAvailable: true, results: [
|
||||
{ sessionId: 'a', ok: true, brokenAt: null },
|
||||
{ sessionId: 'bad', ok: false, brokenAt: 7 },
|
||||
] });
|
||||
expect(md).toContain('🔴');
|
||||
expect(md).toContain('bad');
|
||||
expect(md).toContain('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderStatus — journalIntegrityBlock (R-30)', () => {
|
||||
const base = {
|
||||
now: '2026-06-09T10:00:00Z',
|
||||
c1: { status: 'ok', detail: 'OK' }, c2: { status: 'ok', detail: 'OK' },
|
||||
c3: { status: 'ok', detail: 'OK' }, c5: { status: 'ok', detail: 'OK' },
|
||||
observer: { episodeCount: 0, observerErrors: 0, piiMatches: 0 },
|
||||
};
|
||||
it('вставляет при наличии', () => {
|
||||
const md = renderStatus({ ...base, journalIntegrityBlock: '## Целостность журналов действий\n\n✅ ок.' });
|
||||
expect(md).toContain('## Целостность журналов действий');
|
||||
});
|
||||
it('опускает при отсутствии', () => {
|
||||
expect(renderStatus(base)).not.toContain('## Целостность журналов действий');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — RED**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" status-md-generator --reporter dot`
|
||||
Expected: FAIL — `computeJournalIntegrityBlock is not a function`.
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
(a) В `tools/status-md-generator.mjs` импорты + добавить `readdirSync` к fs-импорту строки 2:
|
||||
|
||||
```js
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
|
||||
```
|
||||
И новые модульные импорты:
|
||||
```js
|
||||
import { loadJournal, verifyChain } from './action-journal.mjs';
|
||||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||||
```
|
||||
|
||||
(b) Функция (после `computeDoorCoverageBlock`):
|
||||
|
||||
```js
|
||||
/**
|
||||
* R-30 (Блок B Класс 3) — read-only блок целостности журналов действий. Требование
|
||||
* «verifyChain живьём» выполнено observation-only: sweep по ~/.claude/runtime/action-journal-*
|
||||
* делает main, сюда приходят уже посчитанные results. Без новых block-путей, fail-CLOSE
|
||||
* примитива verifyChain не понижается. keyAvailable=false → не «битые», а «проверка недоступна».
|
||||
*/
|
||||
export function computeJournalIntegrityBlock({ results, keyAvailable } = {}) {
|
||||
if (keyAvailable === false) {
|
||||
return `## Целостность журналов действий\n\nКлюч подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3).`;
|
||||
}
|
||||
const r = Array.isArray(results) ? results : [];
|
||||
if (r.length === 0) {
|
||||
return `## Целостность журналов действий\n\nЖурналов сессий не найдено (\`~/.claude/runtime/action-journal-*.jsonl\`).`;
|
||||
}
|
||||
const broken = r.filter((x) => x && !x.ok);
|
||||
if (broken.length === 0) {
|
||||
return `## Целостность журналов действий\n\n✅ Все цепочки целы (${r.length} сессий, verifyChain live).`;
|
||||
}
|
||||
const rows = broken.map((x) => `| \`${escapeCell(x.sessionId)}\` | ${x.brokenAt === null || x.brokenAt === undefined ? '—' : escapeCell(x.brokenAt)} |`).join('\n');
|
||||
return `## Целостность журналов действий\n\n🔴 Битые цепочки (${broken.length} из ${r.length}):\n\n| session | broken at seq |\n|---|---|\n${rows}`;
|
||||
}
|
||||
```
|
||||
|
||||
(c) В `renderStatus` template (после `doorCoverageBlock`):
|
||||
|
||||
```js
|
||||
${inputs.journalIntegrityBlock ? `\n${inputs.journalIntegrityBlock.trim()}\n` : ''}
|
||||
```
|
||||
|
||||
(d) В main-блоке (после Task-3 wiring):
|
||||
|
||||
```js
|
||||
let journalIntegrityBlock = null;
|
||||
try {
|
||||
const runtimeDir = join(homedir(), '.claude', 'runtime');
|
||||
const key = resolveReceiptKey();
|
||||
if (key === null || key === undefined) {
|
||||
journalIntegrityBlock = computeJournalIntegrityBlock({ keyAvailable: false });
|
||||
} else {
|
||||
const files = existsSync(runtimeDir) ? readdirSync(runtimeDir) : [];
|
||||
const results = [];
|
||||
for (const f of files) {
|
||||
const m = /^action-journal-(.+)\.jsonl$/.exec(f);
|
||||
if (!m) continue;
|
||||
const sessionId = m[1];
|
||||
try {
|
||||
const { entries, headSig } = loadJournal({ sessionId, runtimeDir });
|
||||
const v = verifyChain(entries, headSig, { key });
|
||||
results.push({ sessionId, ok: v.ok, brokenAt: v.brokenAt });
|
||||
} catch { results.push({ sessionId, ok: false, brokenAt: null }); }
|
||||
}
|
||||
journalIntegrityBlock = computeJournalIntegrityBlock({ results, keyAvailable: true });
|
||||
}
|
||||
} catch (err) { console.warn('[status-md-generator] journalIntegrityBlock skipped:', err.message); journalIntegrityBlock = null; }
|
||||
inputs.journalIntegrityBlock = journalIntegrityBlock;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — GREEN**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" status-md-generator --reporter dot`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" commit -m "feat(status): R-30 journal integrity block via live verifyChain (read-only)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: enforce-reconcile.main() reader (инертный)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/enforce-reconcile.mjs` (+`reconcileEvent` pure + main-wiring через loadJournal; остаётся WARN-only, инертен)
|
||||
- Test: `tools/enforce-reconcile.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
Добавить в `tools/enforce-reconcile.test.mjs`:
|
||||
|
||||
```js
|
||||
import { reconcileEvent } from './enforce-reconcile.mjs';
|
||||
|
||||
describe('reconcileEvent (R-30/reconcile reader, pure)', () => {
|
||||
it('действие совпало с пред-записью → null (нет WARN)', () => {
|
||||
const event = { tool_name: 'Write', tool_input: { file_path: '/x/y.txt' } };
|
||||
const journalEntries = [{ op: 'Write', object: '/x/y.txt' }];
|
||||
expect(reconcileEvent({ event, journalEntries })).toBe(null);
|
||||
});
|
||||
it('действие без пред-записи → WARN-строка', () => {
|
||||
const event = { tool_name: 'Write', tool_input: { file_path: '/x/y.txt' } };
|
||||
const w = reconcileEvent({ event, journalEntries: [] });
|
||||
expect(typeof w).toBe('string');
|
||||
expect(w).toContain('без журнальной пред-записи');
|
||||
});
|
||||
it('нет имени инструмента → null (не кричим)', () => {
|
||||
expect(reconcileEvent({ event: {}, journalEntries: [] })).toBe(null);
|
||||
expect(reconcileEvent({ event: null, journalEntries: [] })).toBe(null);
|
||||
});
|
||||
it('Bash action матчится по команде', () => {
|
||||
const event = { tool_name: 'Bash', tool_input: { command: 'git status' } };
|
||||
const journalEntries = [{ op: 'Bash', object: 'git status' }];
|
||||
expect(reconcileEvent({ event, journalEntries })).toBe(null);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — RED**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" enforce-reconcile --reporter dot`
|
||||
Expected: FAIL — `reconcileEvent is not a function`.
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `tools/enforce-reconcile.mjs`:
|
||||
|
||||
(a) Импорты (после `import { fileURLToPath }...`):
|
||||
|
||||
```js
|
||||
import { actionOf } from './enforce-supreme-gate.mjs';
|
||||
import { loadJournal } from './action-journal.mjs';
|
||||
import { resolveReceiptKey } from './receipt-key-config.mjs';
|
||||
import { homedir } from 'node:os';
|
||||
```
|
||||
|
||||
(b) Чистая функция (после `findOrphanIntents`):
|
||||
|
||||
```js
|
||||
/**
|
||||
* Pure: harness-событие PostToolUse + загруженные журнальные пред-записи (payload'ы намерений)
|
||||
* → WARN-строка (если действие исполнено без пред-записи) или null. Никогда не бросает, не блок.
|
||||
* actionOf реюзится из supreme-gate — единый критерий {op, object}.
|
||||
*/
|
||||
export function reconcileEvent({ event, journalEntries = [] }) {
|
||||
if (!event) return null;
|
||||
const name = event.tool_name || (event.tool_use && event.tool_use.name);
|
||||
if (!name) return null;
|
||||
const action = actionOf({ name, input: event.tool_input || (event.tool_use && event.tool_use.input) || {} });
|
||||
const r = reconcileAction({ action, journalEntries });
|
||||
return r.matched ? null : `[reconcile] ⚠️ ${r.reason}`;
|
||||
}
|
||||
```
|
||||
|
||||
(c) Заменить тело `main()` на реальный (но инертный — хук не зарегистрирован) reader:
|
||||
|
||||
```js
|
||||
async function main() {
|
||||
try {
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) input += chunk;
|
||||
let event; try { event = JSON.parse(input); } catch { process.exit(0); }
|
||||
// Загрузка журнала намерений сессии (пред-записи стены 8.1). Без ключа/файла — нет данных,
|
||||
// нет сигнала (fail-quiet). Реконсилер НИКОГДА не блокирует (PostToolUse не отменяет исполненное).
|
||||
const key = resolveReceiptKey();
|
||||
const sessionId = event.session_id || event.sessionId;
|
||||
let journalEntries = [];
|
||||
if (key && sessionId) {
|
||||
try {
|
||||
const runtimeDir = `${homedir()}/.claude/runtime`;
|
||||
const { entries } = loadJournal({ sessionId, runtimeDir });
|
||||
journalEntries = entries.map((e) => e && e.payload).filter(Boolean);
|
||||
} catch { journalEntries = []; }
|
||||
}
|
||||
const warn = reconcileEvent({ event, journalEntries });
|
||||
if (warn) process.stderr.write(warn + '\n');
|
||||
} catch { /* fail-quiet: реконсилер — сигнал, не блок */ }
|
||||
process.exit(0);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — GREEN**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" enforce-reconcile --reporter dot`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" add tools/enforce-reconcile.mjs tools/enforce-reconcile.test.mjs
|
||||
git -C "c:\моя\проекты\портал crm\Документация\.claude\worktrees\brainrepo" commit -m "feat(reconcile): wire reconcileEvent reader in main (inert, WARN-only)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Полная регрессия + verification
|
||||
|
||||
**Files:** нет правок (только прогон).
|
||||
|
||||
- [ ] **Step 1: Полный прогон tools-only**
|
||||
|
||||
Run: `npx vitest run --root "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app" --config "c:/моя/проекты/портал crm/Документация/.claude/worktrees/brainrepo/app/vitest.config.tools.mjs" --reporter dot`
|
||||
Expected: PASS, total ≥ **3189** (baseline) + новые тесты Задач 1-5. 0 failed.
|
||||
|
||||
- [ ] **Step 2: Smoke генератора STATUS (не падает с новыми блоками)**
|
||||
|
||||
Запуск генератора напрямую (через vitest нельзя — это CLI; альтернатива — убедиться, что main-блок покрыт тестами renderStatus). Если возможно безопасно: проверить, что `renderStatus` с пустыми inputs не содержит новых заголовков (backward compat — уже покрыто в Задачах 1/3/4 Step 1). Реальный запуск `status-md-generator.mjs` пишет `docs/observer/STATUS.md` — НЕ запускать вручную (чужая авто-регенерируемая грязь дерева). Достаточно зелёных unit-тестов wiring.
|
||||
|
||||
- [ ] **Step 3: Verification-before-completion**
|
||||
|
||||
Зафиксировать в ответе: точное число тестов (verbatim из vitest summary), 0 failed, дельта от 3189. Никаких claim'ов «готово» без verbatim-вывода.
|
||||
|
||||
- [ ] **Step 4: Commit (если остались незакоммиченные правки — иначе пропустить)**
|
||||
|
||||
Все правки уже закоммичены в Задачах 1-5. commit-not-push: НЕ пушить (пуш ТОЛЬКО по слову «пуш» через finishing-a-development-branch).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage** (4 пункта дизайна Блока B Классы 1+2):
|
||||
- R-09 очередь обучения → Task 1 ✅
|
||||
- R-24 покрытие дверей → Task 2 (helpers) + Task 3 (block) ✅
|
||||
- R-30 целостность журналов (verifyChain live в read-only STATUS) → Task 4 ✅
|
||||
- enforce-reconcile.main reader (инертный) → Task 5 ✅
|
||||
- Регрессия ≥ 3189 → Task 6 ✅
|
||||
|
||||
**2. Placeholder scan:** все code-steps содержат полный код; нет TBD/«handle edge cases»/«similar to».
|
||||
|
||||
**3. Type consistency:**
|
||||
- `computeLearningQueueBlock({queue})` / `computeDoorCoverageBlock({settings})` / `computeJournalIntegrityBlock({results, keyAvailable})` / `reconcileEvent({event, journalEntries})` — сигнатуры консистентны между определением (Step 3) и тестами (Step 1) каждой задачи.
|
||||
- `extractGateMatcher(settings, hookBasename)` → массив; `auditDoors({tools, matcher, seeds})` принимает массив matcher — согласовано.
|
||||
- `SEED_TOOLS` (Set) — `[...SEED_TOOLS]` для seeds-массива в Task 3; `GATE_SEED_TOOLS.has(...)` в тесте Task 2 — согласовано.
|
||||
- `verifyChain(entries, headSig, {key})` / `loadJournal({sessionId, runtimeDir})` / `resolveReceiptKey()` — сигнатуры из реального кода (action-journal.mjs:53/93, receipt-key-config.mjs:23) — согласовано.
|
||||
- `actionOf({name, input})` — реальная сигнатура (enforce-supreme-gate.mjs:65) — согласовано.
|
||||
|
||||
**4. Границы (жёсткое):** единственная правка в M2-файле — `export` у `SEED_TOOLS` (Task 2, аддитивно, инвариантность пинится тестом). Стена/пол/судья/escape логика не меняется. Все 4 пункта observation-only / инертны.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
План сохранён в `docs/superpowers/plans/2026-06-09-router-mentor-blockB-classes-1-2.md`.
|
||||
|
||||
**Исполнение — ИНЛАЙН через `superpowers:executing-plans`** (субагенты/Task/Workflow запрещены
|
||||
решением владельца). Batch execution с чекпоинтами после каждой задачи (наблюдаемый RED до
|
||||
prod-правки `tools/*.mjs`). НЕ requesting-code-review. commit-not-push.
|
||||
Reference in New Issue
Block a user