# Observer instrument expansion v2 — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Реализовать 19 улучшений наблюдателя из ретро-ноты 2026-05-20 (классификация задач, token-usage, PII-счётчик, reasoning capture, hot-file двухуровневый, и ещё 14), сохраняя 232/232 GREEN existing tests, по одному атомарному коммиту на пункт.

**Architecture:** Расширения локализованы в 6 модулях `tools/observer-*.mjs` + `tools/brain-retro-analyzer.mjs` + `tools/status-md-generator.mjs` + 1 SKILL.md + 1 spec-файл. Schema v2 эпизода расширяется опциональным полем `task_cost` (backward-compatible — старые v2-эпизоды без поля по-прежнему валидны). Все новые поля парсера — TDD: failing test → minimal impl → green → commit. Subagent-driven-development per task; controller (Opus) ревьюит каждый task, два cycle review при необходимости.

**Tech Stack:** Node 20+ ESM, Vitest 4.1.5, pure-fs/regex (Security Guidance #40 — никакого exec/execSync в parser/hook). Раннер тестов: `npm run test:tools` (B3-1, applied).

**Scope:** 19 рекомендаций (P0-P19) + spec sync. **Out-of-scope** (phase 2): LLM-agent-судья, `confusion_marker`/`chain_divergence` как настоящие суждения, real-time in-session friction-флаги, авто-правки нормативки.

**Источник:** [docs/observer/notes/2026-05-20-brain-retro.md](../../observer/notes/2026-05-20-brain-retro.md) — секции «19 пунктов с file:line пинами» + «АДДЕНДУМ B».

---

## REVISION v1.1 (2026-05-20 ~13:00 MSK) — пост-ребейз на origin/main `2476dd3`

После создания плана `feat/observer-v2-expansion` worktree был создан из `origin/main 2476dd3` (НЕ из устаревшего `feat/project-migration-redesign 48b0e35`). Параллельная сессия успела закрыть 7 атомарных observer-фиксов F1-F7 (push `d1ddd28..2476dd3` 16 commits FF, регрессия 331/331 vitest GREEN).

| F-фикс | Файл/строки | Влияние |
|---|---|---|
| **F1** narrow `parallel_session`→tool_result-only | parser.mjs:180-188 | **Task 13 PIVOT** — расширение через ДОП. OR-clause, не overwrite |
| **F2** uuid-dedup `parseLines` | parser.mjs:32-47 | Bonus (вне 19) |
| **F3** `unrecovered_error` + `blocked` outcome | analyzer.mjs:30-51 + parser.mjs:328-347 | Task 16 (soft_success) комплементарен |
| **F4** `HOT_FILE_PATTERNS` excl. memory/*.md | analyzer.mjs:92-103 | **Task 5 SKIP** |
| **F5** strict validate `prompt_signal`+`events` | stop-hook.mjs:24-35 | Task 2 V2_FIELDS остаётся БЕЗ task_cost (optional) |
| **F6** C5 coverage hook-driven | coverage-checker.mjs:26-34 | Bonus (вне 19) |
| **F7** +6 PII паттернов | pii-filter.mjs:25-37 | **Task 3 SIMPLIFIED** — только counter |

**Bonus-поля #2** от B2 верификации (real shape `message.usage`): кроме 4 базовых + `server_tool_use.{web_search,web_fetch}_requests` добавить `iterations` (extended-thinking detector). Остальные (`cache_creation` object / `inference_geo` / `service_tier` / `speed`) — observability/region/latency, не factor сейчас.

### Revised план — 17 рабочих + Task 0 + 2 финал = **20 атомарных коммитов**

- **Task 0 (NEW):** B3-1 — добавить `"test:tools": "cd app && npx vitest run --config vitest.config.tools.mjs"` в корневой `package.json`. Verify `npm run test:tools` → 331/331 GREEN.
- **Tasks 1, 2, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19** — исполнять.
- **Task 5: SKIP** (F4 done).
- **Task 3: SIMPLIFIED** — counter + STATUS surface; паттерны не трогать (F7 done).
- **Task 13: PIVOT** — добавить второй OR-сигнал «git fetch && git log HEAD..origin/main», не overwrite F1.
- **Task 20-21** — spec sync + verify.

**Базовая регрессия:** 331/331 vitest GREEN на `2476dd3`. После КАЖДОЙ task — `npm run test:tools` должен вернуть **≥ 331** (новые тесты добавляют, ни один существующий не должен сломаться).

**Worktree:** `.claude/worktrees/observer-v2-expansion/` off `2476dd3`. Ветка: `feat/observer-v2-expansion`. Финальный push: `git push origin feat/observer-v2-expansion:main`.

---

## File Structure

**Touched files (создание/правка):**

| Файл | Задачи | Роль |
|---|---|---|
| `tools/observer-transcript-parser.mjs` | #1, #2, #4, #6, #7, #8, #9, #12, #13 | Главный парсер транскрипта — расширения классификатора, reasoning, token-usage, system-reminder фильтр |
| `tools/observer-transcript-parser.test.mjs` | (соответственно) | Vitest unit tests |
| `tools/observer-stop-hook.mjs` | #2 (V2_FIELDS) | Валидатор schema v2.1 (task_cost optional) |
| `tools/observer-stop-hook.test.mjs` | (соответственно) | |
| `tools/observer-pii-filter.mjs` | #3 | `sanitizeWithCount` + persistent counter |
| `tools/observer-pii-filter.test.mjs` | (соответственно) | |
| `tools/brain-retro-analyzer.mjs` | #5, #14, #16 | Hot-file 2-tier, session_segment_turn, outcome neutral=soft_success |
| `tools/brain-retro-analyzer.test.mjs` | (соответственно) | |
| `tools/status-md-generator.mjs` | #3, #10, #18 | PII счётчик (real), last brain-retro, v1 episodes counter |
| `tools/status-md-generator.test.mjs` | (соответственно) | |
| `.claude/skills/brain-retro/SKILL.md` | #15, #19 | Шаги: `recordRead` + auto-refresh STATUS.md |
| `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` | sync | v1.1 → v1.2: task_cost optional поле, session_segment_turn |
| `tools/glob-latency-investigator.mjs` (NEW) | #17 | Разовый investigation script |
| `docs/observer/.pii-counters.json` (NEW) | #3 | Persistent counter (PII per month) |
| `package.json` (корень) | — (B3-1 applied) | `test:tools` уже добавлен в этом ретро |

**Не трогаем:**

- `tools/observer-routing-detector.mjs` — нет рекомендаций
- `tools/observer-choice-detector.mjs` — нет рекомендаций
- `tools/observer-coverage-checker.mjs` — нет рекомендаций
- `tools/observer-of-observer.mjs` — нет правок (используется через `recordRead` из SKILL.md в #15)
- `tools/l1-watcher.mjs` — нет рекомендаций
- `tools/cross-ref-checker.mjs` — нет рекомендаций
- Pravila / CLAUDE.md / PSR_v1 / Tooling — `task_cost` это optional расширение схемы, не нормативный сдвиг; правок не требует. (Если заказчик решит зафиксировать schema v2.1 явно в Pravila §16.2 — отдельная micro-правка через claude-md-management.)

---

## Phases & Sequence

**Phase 0 — 🔴 High priority (атомарные коммиты #1-#5):**

- #1 classifier-словарь
- #2 token-usage capture (с B1 bonus полями `server_tool_use.web_search/web_fetch`, `iterations`)
- #3 PII-counter
- #4 AskUserQuestion answer kind
- #5 hot-file двухуровневый

**Phase 1 — 🟡 Medium (#6-#10):**

- #6 reasoning capture (heuristic)
- #7 tool failure differentiation
- #8 `<system-reminder>` фильтр в promptText
- #9 promptSignal словарь
- #10 STATUS.md last brain-retro

**Phase 2 — 🟢 Low (#11-#19):**

- #11 reasoning-tag opt-in extension
- #12 subagent_invoked event
- #13 parallel_session pre-flight heuristic
- #14 session_turn bucket rename → session_segment_turn
- #15 recordRead интеграция в /brain-retro SKILL.md
- #16 outcome inference: neutral → soft_success
- #17 Glob latency investigation (создание investigator + run)
- #18 v1 episodes surface в STATUS.md
- #19 STATUS.md auto-refresh в /brain-retro SKILL.md

**Phase 3 — Sync & verify:**

- Spec sync (v1.1 → v1.2)
- Full pre-commit (pint + larastan + pest + gitleaks protect --staged)
- Final `npm run test:tools` GREEN

Каждая Phase 0/1/2 задача = один атомарный коммит. Phase 3 = ещё 1 коммит для sync.

**Итого: 19 + 1 = 20 атомарных коммитов.**

---

## Phase 0 — 🔴 High priority

### Task 1: classifier-словарь расширить

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:110-118`
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Write failing tests**

В конец `observer-transcript-parser.test.mjs` (после `describe('classifyPromptSignal')`) добавить:

```js
import { classifyTask } from './observer-transcript-parser.mjs';

describe('classifyTask — extended dictionary (Task 1)', () => {
  it('classifies analysis prompts', () => {
    expect(classifyTask('проанализируй данные наблюдателя')).toBe('analysis');
    expect(classifyTask('review the recent changes')).toBe('analysis');
    expect(classifyTask('посмотри что в логах')).toBe('analysis');
  });
  it('classifies memory-sync prompts', () => {
    expect(classifyTask('обнови эталон проекта')).toBe('memory-sync');
    expect(classifyTask('sync memory с последним push')).toBe('memory-sync');
    expect(classifyTask('обнови MEMORY.md')).toBe('memory-sync');
  });
  it('classifies regulatory-bump prompts', () => {
    expect(classifyTask('обнови CLAUDE.md после правки')).toBe('regulatory-bump');
    expect(classifyTask('правка Pravila §16')).toBe('regulatory-bump');
    expect(classifyTask('обнови PSR_v1 v3.18')).toBe('regulatory-bump');
  });
  it('classifies release prompts', () => {
    expect(classifyTask('push origin main')).toBe('release');
    expect(classifyTask('merge feature-branch')).toBe('release');
    expect(classifyTask('сделай release v2')).toBe('release');
  });
  it('classifies cleanup prompts', () => {
    expect(classifyTask('убери временные файлы')).toBe('cleanup');
    expect(classifyTask('cleanup тулчейн')).toBe('cleanup');
  });
  it('classifies monitoring prompts', () => {
    expect(classifyTask('проверь состояние портала')).toBe('monitoring');
    expect(classifyTask('status of CI')).toBe('monitoring');
  });
  it('classifies planning prompts', () => {
    expect(classifyTask('план рефакторинга биллинга')).toBe('planning');
    expect(classifyTask('design новую фичу')).toBe('planning');
    expect(classifyTask('обсудим архитектуру')).toBe('planning');
  });
  it('preserves existing classes — refactor stays refactor', () => {
    expect(classifyTask('рефактор биллинг-сервиса')).toBe('refactor');
  });
  it('preserves existing classes — bugfix stays bugfix', () => {
    expect(classifyTask('почини баг в logger')).toBe('bugfix');
  });
});
```

- [ ] **Step 2: Run test — verify it fails**

```bash
npm run test:tools -- -t "classifyTask — extended"
```

Expected: 9 tests fail (functions return `other` instead of new classes).

- [ ] **Step 3: Implement — расширить `classifyTask`**

В `tools/observer-transcript-parser.mjs:110-118` заменить тело `classifyTask`:

```js
export function classifyTask(text) {
  const t = String(text || '').toLowerCase();
  if (/рефактор|refactor/.test(t)) return 'refactor';
  if (/баг|bug|почини|исправ|fix\b|сломан|broken/.test(t)) return 'bugfix';
  if (/фич|feature|добав|implement|реализ|создай|create|новый|new /.test(t)) return 'feature';
  if (/докум|readme|\bdocs?\b/.test(t)) return 'docs';
  if (/проанализ|анализ|оцени|review|examine|разбор|посмотри что/.test(t)) return 'analysis';
  if (/обнови эталон|sync memory|обнови (?:память|memory|MEMORY)/.test(t)) return 'memory-sync';
  if (/обнови claude|правк[аи] pravila|update pravila|обнови psr|обнови tooling|нормативка/.test(t)) return 'regulatory-bump';
  if (/\bpush\b|\bmerge\b|\bdeploy\b|\bcommit\b|\brelease\b|релиз|тегни/.test(t)) return 'release';
  if (/убери|удали|почисть|cleanup|очисти|drop\s/.test(t)) return 'cleanup';
  if (/^\s*статус\b|\bstatus\b|проверь состояни|health/.test(t)) return 'monitoring';
  if (/план|plan\b|спроектируй|design\b|brainstorm|обсудим/.test(t)) return 'planning';
  if (/\?|как |что |почему|зачем|why|how |what /.test(t)) return 'question';
  return 'other';
}
```

NB: важно, что новые классы стоят **до** `question` (`\?|как |что |...`) — иначе «проверь что в логах» поглотится `question` через `что`.

- [ ] **Step 4: Run tests — verify they pass**

```bash
npm run test:tools -- -t "classifyTask"
```

Expected: 9 new + existing 5 (от `classifyPromptSignal` block, не пересекаются) — все pass.

Полный прогон:

```bash
npm run test:tools
```

Expected: 232 + 9 = 241 tests pass, 0 fail.

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): extend classifyTask with 7 new classes

Closes brain-retro #1 — analysis/memory-sync/regulatory-bump/release/
cleanup/monitoring/planning. 'other' падает с 59% до ожидаемых ~20%
по 17 v2-эпизодам ретро 20.05.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §1
9 new vitest tests, 241/241 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: token-usage capture (`task_cost` поле в схеме v2.1)

**Files:**

- Modify: `tools/observer-transcript-parser.mjs` (добавить `extractTokenUsage` + использовать в `parseTranscript`)
- Modify: `tools/observer-stop-hook.mjs:24-35` (НЕ добавлять `task_cost` в `V2_FIELDS` — optional поле, backward-compat с старыми v2-эпизодами)
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Write failing test**

В `observer-transcript-parser.test.mjs` добавить новый describe:

```js
import { extractTokenUsage, parseTranscript } from './observer-transcript-parser.mjs';

describe('extractTokenUsage (Task 2)', () => {
  it('sums input/output/cache fields across multiple assistant messages', () => {
    const turn = [
      { message: { usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100, cache_creation_input_tokens: 50 } } },
      { message: { usage: { input_tokens: 8, output_tokens: 3, cache_read_input_tokens: 80, cache_creation_input_tokens: 20 } } },
    ];
    expect(extractTokenUsage(turn)).toEqual({
      input_tokens: 18,
      output_tokens: 8,
      cache_read_input_tokens: 180,
      cache_creation_input_tokens: 70,
      web_search_requests: 0,
      web_fetch_requests: 0,
      iterations: 0,
    });
  });
  it('captures server_tool_use bonus fields (web_search/web_fetch)', () => {
    const turn = [
      { message: { usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
        server_tool_use: { web_search_requests: 2, web_fetch_requests: 3 } } } },
    ];
    const r = extractTokenUsage(turn);
    expect(r.web_search_requests).toBe(2);
    expect(r.web_fetch_requests).toBe(3);
  });
  it('captures iterations (extended-thinking detector)', () => {
    const turn = [
      { message: { usage: { input_tokens: 1, output_tokens: 1, iterations: 7 } } },
    ];
    expect(extractTokenUsage(turn).iterations).toBe(7);
  });
  it('returns zero-filled object when no usage present', () => {
    expect(extractTokenUsage([{ message: {} }, { not_a_message: true }])).toEqual({
      input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0,
      cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0,
    });
  });
});

describe('parseTranscript — task_cost integration (Task 2)', () => {
  it('attaches task_cost to a v2 episode', () => {
    const transcript = [
      JSON.stringify({ sessionId: 's1' }),
      JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай задачу' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
      JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }],
        usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 1000, cache_creation_input_tokens: 200,
          server_tool_use: { web_search_requests: 1 } } }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }),
    ].join('\n');
    const ep = parseTranscript(transcript);
    expect(ep.task_cost).toBeDefined();
    expect(ep.task_cost.input_tokens).toBe(100);
    expect(ep.task_cost.output_tokens).toBe(50);
    expect(ep.task_cost.cache_read_input_tokens).toBe(1000);
    expect(ep.task_cost.web_search_requests).toBe(1);
  });
});
```

- [ ] **Step 2: Run test — verify fails**

```bash
npm run test:tools -- -t "extractTokenUsage"
```

Expected: 4 tests fail (`extractTokenUsage is not a function`).

- [ ] **Step 3: Implement `extractTokenUsage` и интеграция в `parseTranscript`**

В `tools/observer-transcript-parser.mjs` после `extractTaskSize` (около строки 234) добавить:

```js
/** Token-usage aggregation across all assistant messages in the turn.
 *  Σ-strategy is safe — verified 6265/6265 assistant messages have usage,
 *  0 partial-stream (B1 brain-retro verification 2026-05-20). */
export function extractTokenUsage(turn) {
  let input = 0, output = 0, cache_read = 0, cache_creation = 0;
  let web_search = 0, web_fetch = 0, iterations = 0;
  for (const e of turn) {
    const u = e && e.message && e.message.usage;
    if (!u) continue;
    input          += u.input_tokens || 0;
    output         += u.output_tokens || 0;
    cache_read     += u.cache_read_input_tokens || 0;
    cache_creation += u.cache_creation_input_tokens || 0;
    iterations     += u.iterations || 0;
    if (u.server_tool_use) {
      web_search += u.server_tool_use.web_search_requests || 0;
      web_fetch  += u.server_tool_use.web_fetch_requests  || 0;
    }
  }
  return {
    input_tokens: input,
    output_tokens: output,
    cache_read_input_tokens: cache_read,
    cache_creation_input_tokens: cache_creation,
    web_search_requests: web_search,
    web_fetch_requests: web_fetch,
    iterations,
  };
}
```

В `parseTranscript` (около строк 439-462) после `task_size: extractTaskSize(turn),` добавить:

```js
    task_cost: extractTokenUsage(turn),
```

(Поле optional — Stop-hook validator не требует его, см. step 5.)

- [ ] **Step 4: Verify Stop-hook validator не ломается**

В `tools/observer-stop-hook.mjs:24-35` — НЕ добавлять `'task_cost'` в `V2_FIELDS` массив. Существующая validation требует только 7 полей; новое опциональное поле проходит без проверки, что обеспечивает backward-compat с старыми v2-эпизодами (где `task_cost` отсутствует).

- [ ] **Step 5: Run tests — verify pass**

```bash
npm run test:tools
```

Expected: 232 (orig) + 9 (Task 1) + 5 (Task 2) = 246 pass.

- [ ] **Step 6: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): capture task_cost (token usage) in v2 episode

Closes brain-retro #2 — input/output/cache_read/cache_creation tokens
+ B1 bonus fields server_tool_use.{web_search,web_fetch}_requests + iterations.
Optional field — Stop-hook validator не требует, backward-compat сохранена.

Verified B1 ретро 20.05: 6265/6265 assistant messages have usage,
0 partial-stream — Σ-стратегия безопасна.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §2 + АДДЕНДУМ B1
5 new vitest tests, 246/246 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: PII-counter (реальный) + STATUS.md перестаёт врать

**Files:**

- Modify: `tools/observer-pii-filter.mjs` — добавить `sanitizeWithCount`
- Modify: `tools/observer-stop-hook.mjs:95` — вызывать `sanitizeWithCount`, дописывать `.pii-counters.json`
- Modify: `tools/status-md-generator.mjs:54-83` — читать `.pii-counters.json`
- Create: `docs/observer/.pii-counters.json` (генерируется при первом write)
- Tests: `observer-pii-filter.test.mjs`, `status-md-generator.test.mjs`

- [ ] **Step 1: Write failing test для `sanitizeWithCount`**

В `tools/observer-pii-filter.test.mjs` после блока `sanitize`:

```js
import { sanitizeWithCount } from './observer-pii-filter.mjs';

describe('sanitizeWithCount (Task 3)', () => {
  it('counts matches per pattern type alongside sanitizing', () => {
    const input = 'Phone +71234567890 and email user@example.com';
    const { sanitized, counts } = sanitizeWithCount(input);
    expect(sanitized).toBe('Phone +7XXXXXXXXXX and email ***@***');
    expect(counts.RU_PHONE).toBe(1);
    expect(counts.EMAIL).toBe(1);
  });
  it('counts zero for absent patterns', () => {
    const { counts } = sanitizeWithCount('no PII here');
    expect(counts.RU_PHONE).toBe(0);
    expect(counts.EMAIL).toBe(0);
    expect(counts.JWT).toBe(0);
  });
  it('counts recursively over objects', () => {
    const input = { msg: 'call +71112223344', meta: { contact: 'a@b.co' } };
    const { counts } = sanitizeWithCount(input);
    expect(counts.RU_PHONE).toBe(1);
    expect(counts.EMAIL).toBe(1);
  });
  it('matches all 12 pattern types in counts', () => {
    const { counts } = sanitizeWithCount('');
    expect(Object.keys(counts).sort()).toEqual(
      ['AWS_KEY','EMAIL','GENERIC_BEARER','IPV4','JWT','OPENAI_TOKEN','POSIX_HOME','RU_PHONE','SENTRY_TOKEN','WIN_USER_PATH','YC_OAUTH','YC_SESSION','YC_STATIC'].sort()
    );
  });
});
```

- [ ] **Step 2: Run — verify fails**

```bash
npm run test:tools -- -t sanitizeWithCount
```

Expected: 4 tests fail.

- [ ] **Step 3: Implement `sanitizeWithCount` в `tools/observer-pii-filter.mjs`**

В конец файла (после `export function sanitize`):

```js
const PATTERNS = {
  RU_PHONE, EMAIL, JWT, AWS_KEY, YC_STATIC, YC_SESSION, YC_OAUTH,
  SENTRY_TOKEN, OPENAI_TOKEN, GENERIC_BEARER, IPV4, WIN_USER_PATH, POSIX_HOME,
};

function countString(s, counts) {
  if (typeof s !== 'string') return;
  for (const [name, re] of Object.entries(PATTERNS)) {
    const re2 = new RegExp(re.source, re.flags); // fresh state for matchAll
    const matches = s.match(re2);
    counts[name] = (counts[name] || 0) + (matches ? matches.length : 0);
  }
}

function walkAndCount(input, counts) {
  if (typeof input === 'string') return countString(input, counts);
  if (input === null || input === undefined) return;
  if (Array.isArray(input)) { input.forEach((v) => walkAndCount(v, counts)); return; }
  if (typeof input === 'object') for (const v of Object.values(input)) walkAndCount(v, counts);
}

export function sanitizeWithCount(input) {
  const counts = {};
  // Pre-initialise all 13 patterns to 0
  for (const k of Object.keys(PATTERNS)) counts[k] = 0;
  walkAndCount(input, counts);
  return { sanitized: sanitize(input), counts };
}
```

- [ ] **Step 4: Wire-in в Stop-hook (append к counter file)**

В `tools/observer-stop-hook.mjs` импорт расширить:

```js
import { sanitize, sanitizeWithCount } from './observer-pii-filter.mjs';
```

В `appendEpisode` (~stop-hook.mjs:63-96) заменить `appendFileSync(file, JSON.stringify(sanitize(episode)) + '\n', 'utf-8');` (две оккуренции — для observer_error и для обычного episode) на функцию:

```js
function writeEpisodeWithCounter(file, episode, baseDir) {
  const { sanitized, counts } = sanitizeWithCount(episode);
  appendFileSync(file, JSON.stringify(sanitized) + '\n', 'utf-8');
  // Update persistent monthly counter
  const counterPath = join(baseDir, 'docs', 'observer', '.pii-counters.json');
  let counters = {};
  if (existsSync(counterPath)) {
    try { counters = JSON.parse(readFileSync(counterPath, 'utf-8')); } catch {}
  }
  const month = currentMonth();
  counters[month] = counters[month] || {};
  for (const [k, n] of Object.entries(counts)) {
    counters[month][k] = (counters[month][k] || 0) + n;
  }
  appendFileSync(counterPath, '', 'utf-8'); // ensure exists
  require('fs').writeFileSync(counterPath, JSON.stringify(counters, null, 2));
}
```

(NB: используем dynamic `require('fs')` чтобы не править import block; альтернатива — добавить `writeFileSync` в существующий `fs` import.)

Альтернативный (предпочтительный) вариант — расширить import:

```js
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
```

и использовать `writeFileSync` напрямую.

Затем в `appendEpisode` — оба места `appendFileSync(...)` заменить на `writeEpisodeWithCounter(file, episode, baseDir);`.

- [ ] **Step 5: Write failing test для status-md generator**

В `tools/status-md-generator.test.mjs`:

```js
import { renderStatus } from './status-md-generator.mjs';
// (renderStatus уже импортируется — добавить тест в существующий describe)

it('shows real PII match count in the metrics block (Task 3)', () => {
  const md = renderStatus({
    now: '2026-05-20T12:00:00Z',
    c1: { status: 'ok', detail: '0 drift' },
    c2: { status: 'ok', detail: '0 drift' },
    c3: { status: 'ok', detail: 'ok' },
    c5: { status: 'ok', detail: '24 episodes' },
    observer: { episodeCount: 24, observerErrors: 0, piiMatches: 7 },
  });
  expect(md).toContain('7 PII matches before filter');
});
```

- [ ] **Step 6: Wire `piiMatches` в `status-md-generator.mjs` CLI section**

В `tools/status-md-generator.mjs:54-83` добавить функцию:

```js
function countPiiMatches() {
  const file = join('docs', 'observer', '.pii-counters.json');
  if (!existsSync(file)) return 0;
  try {
    const data = JSON.parse(readFileSync(file, 'utf-8'));
    const month = new Date().toISOString().slice(0, 7);
    const monthCounts = data[month] || {};
    return Object.values(monthCounts).reduce((s, n) => s + n, 0);
  } catch { return 0; }
}
```

И в `if (process.argv[1]...)` блоке заменить `piiMatches: 0` (строка 81) на:

```js
      piiMatches: countPiiMatches(),
```

- [ ] **Step 7: Run all tests**

```bash
npm run test:tools
```

Expected: previous count + 4 (PII counter tests) + 1 (status-md test) tests pass.

- [ ] **Step 8: Commit**

```bash
git add tools/observer-pii-filter.mjs tools/observer-pii-filter.test.mjs tools/observer-stop-hook.mjs tools/status-md-generator.mjs tools/status-md-generator.test.mjs
git commit -m "feat(observer): real PII counter — STATUS.md перестаёт врать

Closes brain-retro #3 — sanitizeWithCount + persistent counter в
docs/observer/.pii-counters.json (per month). status-md-generator
читает реальное число, не хардкод 0.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §3
5 new vitest tests, 251/251 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: AskUserQuestion answer kind event

**Files:**

- Modify: `tools/observer-transcript-parser.mjs` — добавить `extractAskUserQuestionEvents` + интеграция в `parseTranscript`
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Write failing test**

```js
import { extractAskUserQuestionEvents } from './observer-transcript-parser.mjs';

describe('extractAskUserQuestionEvents (Task 4)', () => {
  it('records option pick when user matched a label', () => {
    const turn = [
      { message: { content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
      { toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }], answers: { q1: 'A' } } },
    ];
    const evs = extractAskUserQuestionEvents(turn);
    expect(evs).toHaveLength(1);
    expect(evs[0].kind).toBe('ask_user_question');
    expect(evs[0].answer_kind).toBe('option');
  });
  it('records custom kind when user wrote free-text Other', () => {
    const turn = [
      { message: { content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }] } }] } },
      { toolUseResult: { questions: [{ question: 'q1', options: [{ label: 'A' }, { label: 'B' }] }], answers: { q1: 'C — мой вариант' } } },
    ];
    const evs = extractAskUserQuestionEvents(turn);
    expect(evs[0].answer_kind).toBe('custom');
  });
  it('returns empty for a turn without AskUserQuestion', () => {
    expect(extractAskUserQuestionEvents([{ message: { content: [] } }])).toEqual([]);
  });
});
```

- [ ] **Step 2: Run — fails**

```bash
npm run test:tools -- -t extractAskUserQuestionEvents
```

- [ ] **Step 3: Implement**

В `tools/observer-transcript-parser.mjs` после `extractTokenUsage` (или там, где удобно):

```js
/** Per-AskUserQuestion event — captures whether user picked an option or wrote custom. */
export function extractAskUserQuestionEvents(turn) {
  const events = [];
  for (const e of turn || []) {
    const tur = e && e.toolUseResult;
    if (!tur || !Array.isArray(tur.questions) || !tur.answers) continue;
    for (const q of tur.questions) {
      const labels = (q.options || []).map((o) => o && o.label).filter((l) => typeof l === 'string');
      const answer = tur.answers[q.question];
      let answer_kind;
      if (typeof answer !== 'string') answer_kind = 'no_answer';
      else if (labels.includes(answer.trim())) answer_kind = 'option';
      else answer_kind = 'custom';
      events.push({ kind: 'ask_user_question', question_count: tur.questions.length, answer_kind });
    }
  }
  return events;
}
```

И в `parseTranscript` после `events.push(...extractProcessEvents(...));` добавить:

```js
events.push(...extractAskUserQuestionEvents(turn));
```

- [ ] **Step 4: Run all tests**

```bash
npm run test:tools
```

Expected: previous + 3 pass.

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): record ask_user_question events (option|custom)

Closes brain-retro #4 — каждая AskUserQuestion фиксируется как event
с answer_kind (option|custom|no_answer). Сигнал на качество предложенных
вариантов: процент 'custom' = доля моих option'ов, которые не покрыли
пространство выбора.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §4
3 new vitest tests, 254/254 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: hot-file двухуровневый — **SKIP per REVISION v1.1**

> F4 параллельной сессии уже добавил полный exclude `memory/*.md` в [analyzer.mjs:92-103](../../../tools/brain-retro-analyzer.mjs#L92-L103). Двухуровневая (warm-tier adjacent) — diminishing return, не делаем. Переходим к Task 6.

### ~~Task 5 (skipped): hot-file двухуровневый (ALWAYS_HOT + WARM)~~

**Files:**

- ~~Modify: `tools/brain-retro-analyzer.mjs:92-103` — заменить `HOT_FILE_PATTERNS` на два списка + расширить `findCausalChains`~~
- Test: `tools/brain-retro-analyzer.test.mjs`

- [ ] **Step 1: Failing test**

В `tools/brain-retro-analyzer.test.mjs` после блока `findCausalChains`:

```js
import { findCausalChains } from './brain-retro-analyzer.mjs';

describe('findCausalChains — two-tier hot/warm filter (Task 5)', () => {
  it('keeps memory/*.md when episodes are close in time (warm-file chain)', () => {
    const eps = [
      { task_id: 's', timestamps: { started_at: '2026-05-20T00:00:00Z' },
        events: [{ kind: 'error' }], task_size: { files: ['memory/feedback_x.md'] } },
      { task_id: 's', timestamps: { started_at: '2026-05-20T00:01:00Z' },
        events: [], task_size: { files: ['memory/feedback_x.md'] } },
    ];
    const chains = findCausalChains(eps);
    expect(chains).toHaveLength(1);
    expect(chains[0].sharedFiles).toContain('memory/feedback_x.md');
  });
  it('still excludes CLAUDE.md / MEMORY.md / STATUS.md / episodes-*.jsonl', () => {
    const eps = [
      { task_id: 's', timestamps: { started_at: '2026-05-20T00:00:00Z' },
        events: [{ kind: 'error' }], task_size: { files: ['CLAUDE.md'] } },
      { task_id: 's', timestamps: { started_at: '2026-05-20T00:01:00Z' },
        events: [], task_size: { files: ['CLAUDE.md'] } },
    ];
    expect(findCausalChains(eps)).toEqual([]);
  });
});
```

- [ ] **Step 2: Run — fails**

```bash
npm run test:tools -- -t "two-tier hot/warm"
```

- [ ] **Step 3: Replace hot-file logic**

В `tools/brain-retro-analyzer.mjs:92-103` заменить блок:

```js
const ALWAYS_HOT = [
  /(?:^|[\\/])CLAUDE\.md$/i,
  /(?:^|[\\/])MEMORY\.md$/i,
  /(?:^|[\\/])STATUS\.md$/i,
  /[\\/]episodes-\d{4}-\d{2}\.jsonl$/i,
];
const WARM_PATTERNS = [
  /[\\/]memory[\\/][^\\/]+\.md$/i,
];

export function isHotFile(path) {
  const s = String(path || '');
  return ALWAYS_HOT.some((re) => re.test(s));
}
export function isWarmFile(path) {
  const s = String(path || '');
  return WARM_PATTERNS.some((re) => re.test(s));
}
```

И в `findCausalChains` (около строк 105-136) — оставить логику в основном как есть, но при поиске пересечения файлов для warm-файлов требовать «episodes adjacent» (i+1):

```js
export function findCausalChains(episodes) {
  const sorted = episodes
    .filter((e) => !e.observer_error)
    .slice()
    .sort((a, b) =>
      String((a.timestamps || {}).started_at).localeCompare(String((b.timestamps || {}).started_at))
    );
  const chains = [];
  for (let i = 0; i < sorted.length - 1; i++) {
    const a = sorted[i];
    const hasError = Array.isArray(a.events) && a.events.some((e) => e.kind === 'error');
    if (!hasError) continue;
    const filesA = (((a.task_size || {}).files) || []).filter((f) => !isHotFile(f));
    if (filesA.length === 0) continue;
    const setA = new Set(filesA);
    for (let j = i + 1; j < sorted.length; j++) {
      const b = sorted[j];
      const filesB = (((b.task_size || {}).files) || []).filter((f) => !isHotFile(f));
      const shared = filesB.filter((f) => setA.has(f));
      // Warm-file shared только если j == i+1 (соседние эпизоды)
      const filteredShared = shared.filter((f) => !isWarmFile(f) || j === i + 1);
      if (filteredShared.length > 0) {
        chains.push({
          from: `${a.task_id}|${(a.timestamps || {}).started_at}`,
          to:   `${b.task_id}|${(b.timestamps || {}).started_at}`,
          sharedFiles: filteredShared,
        });
        break;
      }
    }
  }
  return chains;
}
```

- [ ] **Step 4: Run all tests**

```bash
npm run test:tools
```

Expected: previous + 2 pass.

- [ ] **Step 5: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(observer): two-tier hot/warm file filter for causal chains

Closes brain-retro #5 — ALWAYS_HOT (CLAUDE/MEMORY/STATUS/episodes JSONL)
исключаются всегда; WARM (memory/*.md) — только если эпизоды соседние
(i,i+1). Реальные цепочки правок одного memory-файла теперь видны;
дальние совпадения по hot-файлам всё ещё гасятся.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §5
2 new vitest tests, 256/256 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Phase 1 — 🟡 Medium

### Task 6: reasoning capture (heuristic)

**Files:**

- Modify: `tools/observer-transcript-parser.mjs` — три новые функции + интеграция
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing tests**

```js
import { extractTriggers, extractCandidates, extractBoundaries } from './observer-transcript-parser.mjs';

describe('reasoning capture heuristic (Task 6)', () => {
  const mkTurn = (txt) => [{ message: { role: 'assistant', content: [{ type: 'text', text: txt }] } }];

  it('extractTriggers — finds Pravila §N references', () => {
    expect(extractTriggers(mkTurn('per Pravila §12.2 hard-rule'))).toContain('Pravila §12.2');
  });
  it('extractTriggers — finds ADR references', () => {
    expect(extractTriggers(mkTurn('see ADR-011'))).toContain('ADR-011');
  });
  it('extractTriggers — empty array on no markers', () => {
    expect(extractTriggers(mkTurn('plain text'))).toEqual([]);
  });

  it('extractCandidates — extracts numbered options as candidates', () => {
    const turn = mkTurn('варианты:\n1. brainstorming\n2. subagent-driven\n3. direct');
    const c = extractCandidates(turn);
    expect(c).toContain('brainstorming');
    expect(c.length).toBeGreaterThanOrEqual(2);
  });
  it('extractCandidates — empty when no options', () => {
    expect(extractCandidates(mkTurn('просто текст'))).toEqual([]);
  });

  it('extractBoundaries — finds ADR + PSR + Pravila refs', () => {
    const b = extractBoundaries(mkTurn('per ADR-011 + PSR_v1 R16 + Pravila §16.2'));
    expect(b).toContain('ADR-011');
    expect(b).toContain('PSR_v1 R16');
  });
});
```

- [ ] **Step 2: Run — fails**

- [ ] **Step 3: Implement в `observer-transcript-parser.mjs`**

```js
function assistantTextOfTurn(turn) {
  const parts = [];
  for (const e of turn || []) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
    }
  }
  return parts.join('\n');
}

const TRIGGER_PATTERNS = [
  /\bPravila\s+§\d+(?:\.\d+)?/g,
  /\bADR-\d+/g,
  /\bPSR_v1\s+R\d+(?:\.\d+)?/g,
  /\brouting-off-phase\s+L\d+/g,
  /\bhard-(?:floor|rule)\b/gi,
];

export function extractTriggers(turn) {
  const text = assistantTextOfTurn(turn);
  const out = new Set();
  for (const re of TRIGGER_PATTERNS) {
    const matches = text.match(re);
    if (matches) for (const m of matches) out.add(m);
  }
  return [...out];
}

const CANDIDATE_NUMBERED_RE = /^\s*\d+[.\)]\s+([^\n]+)$/gm;
const CANDIDATE_BULLET_RE   = /^\s*[-*]\s+([^\n]+)$/gm;

export function extractCandidates(turn) {
  const text = assistantTextOfTurn(turn);
  const numbered = [...text.matchAll(CANDIDATE_NUMBERED_RE)].map((m) => m[1].trim());
  const bulleted = [...text.matchAll(CANDIDATE_BULLET_RE)].map((m) => m[1].trim());
  if (numbered.length >= 2) return numbered;
  if (bulleted.length >= 2) return bulleted;
  return [];
}

const BOUNDARY_PATTERNS = [
  /\bADR-\d+(?:\s+§\d+(?:\.\d+)?)?/g,
  /\bPSR_v1\s+R\d+(?:\.\d+)?/g,
  /\bPravila\s+§\d+(?:\.\d+)?/g,
  /\brouting-off-phase\s+L\d+/g,
];

export function extractBoundaries(turn) {
  const text = assistantTextOfTurn(turn);
  const out = new Set();
  for (const re of BOUNDARY_PATTERNS) {
    const matches = text.match(re);
    if (matches) for (const m of matches) out.add(m);
  }
  return [...out];
}
```

В `parseTranscript` (`primary_rationale`):

```js
primary_rationale: {
  step: 1,
  node_chosen: skills.length > 0 ? skills[0] : 'direct',
  triggers_matched: extractTriggers(turn),
  candidates_considered: extractCandidates(turn),
  boundaries_applied: extractBoundaries(turn),
  hard_floor: usedSuperpowers
    ? { invoked: true, rules: ['Pravila §12'] }
    : { invoked: false, rules: [] },
  task_classification: classifyTask(prompt),
},
```

- [ ] **Step 4: Run все tests**

```bash
npm run test:tools
```

Expected: previous + 7 pass. **NB:** существующие тесты, проверяющие `triggers_matched: []` (если такие есть в `parseTranscript — v2 episode`), могут сломаться — это ожидаемо. Поправить ожидания на «может быть непустым» (не строгое `[]`).

Если сломались — обновить assertions: `expect(ep.primary_rationale.triggers_matched).toEqual(expect.any(Array));` вместо `toEqual([])`.

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): heuristic reasoning capture — triggers/candidates/boundaries

Closes brain-retro #6 — три новые pure-функции extractTriggers /
extractCandidates / extractBoundaries сканируют assistant.text на
маркеры (Pravila §N, ADR-N, PSR_v1 R10.1, numbered lists). Превращает
три всегда-пустых массива primary_rationale в реальные данные.

Conservative-broad: false-positives accepted (упоминание ≠ применение);
заказчик решает в /brain-retro что серьёзно.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §6
7 new vitest tests, 263/263 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 7: tool failure differentiation

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:120-140` (`collectToolUse` — добавить idToTool map, расширить error events)
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('error event differentiation (Task 7)', () => {
  it('attributes error to the failing tool and captures summary', () => {
    const transcript = [
      JSON.stringify({ sessionId: 's' }),
      JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
      JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
        { type: 'tool_use', id: 'tu1', name: 'Bash', input: {} }
      ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
      JSON.stringify({ type: 'user', message: { role: 'user', content: [
        { type: 'tool_result', tool_use_id: 'tu1', is_error: true, content: 'ENOENT: no such file' }
      ] }, uuid: 'u3', timestamp: '2026-05-20T00:00:02Z' }),
    ].join('\n');
    const ep = parseTranscript(transcript);
    const errorEvents = ep.events.filter((e) => e.kind === 'error');
    expect(errorEvents).toHaveLength(1);
    expect(errorEvents[0].tool).toBe('Bash');
    expect(errorEvents[0].summary).toContain('ENOENT');
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Implement**

В `tools/observer-transcript-parser.mjs:120-140` заменить `collectToolUse`:

```js
function collectToolUse(entries) {
  const skills = [];
  const counts = {};
  const errors = [];
  const idToTool = {};
  // First pass — build idToTool
  for (const e of entries) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const block of content) {
      if (!block || typeof block !== 'object') continue;
      if (block.type === 'tool_use') idToTool[block.id] = block.name || 'unknown';
    }
  }
  // Second pass — accumulate
  for (const e of entries) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const block of content) {
      if (!block || typeof block !== 'object') continue;
      if (block.type === 'tool_use') {
        const name = block.name || 'unknown';
        counts[name] = (counts[name] || 0) + 1;
        if (name === 'Skill') {
          skills.push((block.input && block.input.skill) || 'unknown');
        }
      } else if (block.type === 'tool_result' && block.is_error === true) {
        const tool = idToTool[block.tool_use_id] || 'unknown';
        const c = block.content;
        const text = typeof c === 'string' ? c
          : (Array.isArray(c) ? c.map((b) => (b && b.text) || '').join(' ') : '');
        errors.push({ tool, summary: text.slice(0, 80) });
      }
    }
  }
  return { skills, counts, errors };
}
```

И в `parseTranscript` (около строк 418-420) заменить:

```js
for (let i = 0; i < errorCount; i++) {
  events.push({ kind: 'error', message: 'tool_result reported is_error' });
}
```

на:

```js
for (const err of errors) {
  events.push({ kind: 'error', tool: err.tool, summary: err.summary });
}
```

Также там же выше: вместо `const { skills, counts, errorCount } = collectToolUse(turn);` написать `const { skills, counts, errors } = collectToolUse(turn);` и `errorCount` использования убрать (его больше не существует).

- [ ] **Step 4: Run tests**

NB: Существующий тест «emits error events for tool_result with is_error» может сломаться (assertion на `message: 'tool_result reported is_error'`). Обновить — проверять `kind: 'error'` + `tool` + `summary` shape.

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): differentiate error events by tool + summary

Closes brain-retro #7 — каждый error event теперь несёт tool (Bash/Edit/...)
+ first 80 chars причины. Агрегация по tool становится возможной;
'ENOENT' / 'timeout' / 'is_error' больше неразличимы.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §7
1 new vitest test, existing error test updated. 264/264 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 8: `<system-reminder>` фильтр в `promptText`

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:98-108`
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('promptText strips system-reminder blocks (Task 8)', () => {
  it('removes <system-reminder>...</system-reminder> tags', () => {
    const transcript = [
      JSON.stringify({ sessionId: 's' }),
      JSON.stringify({ type: 'user', message: { role: 'user',
        content: '<system-reminder>economy 0%</system-reminder>\nделай рефактор биллинга'
      }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
    ].join('\n');
    const ep = parseTranscript(transcript);
    expect(ep.primary_rationale.task_classification).toBe('refactor');
  });
});
```

- [ ] **Step 2: Fails** (потому что system-reminder содержит «economy», но classifyTask по «делай рефактор» должно вернуть 'refactor'; **если без фильтра** — текст содержит и «economy» и «рефактор», и пускай regex match → результат может быть «refactor» уже сейчас если regex не reorder'нут; **гарантированный fail** — добавить content с словом которое перебивает: `'<system-reminder>что почему</system-reminder>\nделай рефактор'` — тогда без фильтра question regex (`что |почему`) поймает «что почему» из system-reminder перед `рефактор`).

Скорректировать test fixture:

```js
content: '<system-reminder>почему как что</system-reminder>\nрефактор'
```

Это **точно** fail без фильтрации.

- [ ] **Step 3: Implement в parser.mjs:98-108**

```js
function stripSystemReminders(text) {
  return String(text || '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
}

function promptText(entry) {
  const c = entry && entry.message && entry.message.content;
  if (typeof c === 'string') return stripSystemReminders(c);
  if (Array.isArray(c)) {
    return stripSystemReminders(
      c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join(' ')
    );
  }
  return '';
}
```

- [ ] **Step 4: Tests pass**

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): strip <system-reminder> blocks from promptText

Closes brain-retro #8 — UserPromptSubmit hook инжектит system-reminder
блоки в user.content. Они попадали в classifyTask/classifyPromptSignal
и искажали классификацию. Теперь фильтруются перед anal'изом.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §8
1 new vitest test, 265/265 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 9: `classifyPromptSignal` — расширить словарь

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:237-251`
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing tests**

```js
describe('classifyPromptSignal — extended dictionary (Task 9)', () => {
  it('detects "не совсем" as correction', () => {
    expect(classifyPromptSignal('не совсем то')).toBe('correction');
  });
  it('detects "wrong direction" as correction', () => {
    expect(classifyPromptSignal('wrong direction')).toBe('correction');
  });
  it('detects "класс" as approval', () => {
    expect(classifyPromptSignal('класс')).toBe('approval');
  });
  it('detects "well done" as approval', () => {
    expect(classifyPromptSignal('well done')).toBe('approval');
  });
  it('detects "теперь" prompts as new_task', () => {
    expect(classifyPromptSignal('теперь сделай билинг')).toBe('new_task');
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Extend dictionary in `classifyPromptSignal`**

```js
export function classifyPromptSignal(text) {
  const t = String(text || '').toLowerCase().trim();
  if (
    /не совсем|другое\b|другая\b|не сходится|wrong direction|не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не |не работает|не правильн|сломал|опять|снова не|всё ещё|все ещё|все еще|верни как|откат|\brevert\b|\bundo\b|still not|doesn'?t work|does not work|\bwrong\b/.test(
      t
    )
  ) {
    return 'correction';
  }
  if (/^(ок|окей|ok|спасибо|супер|отлично|готово|дальше|идеально|класс|хорошо|принято|well done|\bnice\b)([,\s]|$)/.test(t)) {
    return 'approval';
  }
  if (/^теперь\b|^далее\b|^следующее\b|^\bnext\b|^\bnow\b/.test(t)) return 'new_task';
  if (classifyTask(t) !== 'other' && t.length > 15) return 'new_task';
  return 'neutral';
}
```

- [ ] **Step 4: Tests pass**

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): extend classifyPromptSignal vocabulary

Closes brain-retro #9 — добавлены маркеры 'не совсем' / 'wrong direction'
(correction); 'класс' / 'well done' / 'nice' (approval); 'теперь' /
'далее' / 'next' / 'now' (new_task).

Refs: docs/observer/notes/2026-05-20-brain-retro.md §9
5 new vitest tests, 270/270 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 10: STATUS.md — last brain-retro tracking

**Files:**

- Modify: `tools/status-md-generator.mjs:11-33` (renderStatus + новая функция читающая counter)
- Test: `tools/status-md-generator.test.mjs`

- [ ] **Step 1: Failing test**

```js
it('renderStatus shows last /brain-retro days-ago (Task 10)', () => {
  const md = renderStatus({
    now: '2026-05-20T00:00:00Z',
    c1: { status: 'ok', detail: 'ok' },
    c2: { status: 'ok', detail: 'ok' },
    c3: { status: 'ok', detail: 'ok' },
    c5: { status: 'ok', detail: 'ok' },
    observer: { episodeCount: 24, observerErrors: 0, piiMatches: 0 },
    lastRetroDaysAgo: 3,
  });
  expect(md).toMatch(/Last \/brain-retro:\s*3 day/);
});
it('renderStatus shows "never" when no retro recorded (Task 10)', () => {
  const md = renderStatus({
    now: '2026-05-20T00: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 },
    lastRetroDaysAgo: null,
  });
  expect(md).toMatch(/Last \/brain-retro:\s*never/);
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Modify `renderStatus` в status-md-generator.mjs:11-33**

Добавить параметр `lastRetroDaysAgo` и строку в Metrics:

```js
export function renderStatus(inputs) {
  const { now, c1, c2, c3, c5, observer, lastRetroDaysAgo } = inputs;
  const retroLine = lastRetroDaysAgo === null || lastRetroDaysAgo === undefined
    ? 'never'
    : `${lastRetroDaysAgo} day(s) ago`;
  return `# Brain Status (auto-generated)

Last updated: ${now}

| Контролёр | Состояние | Детали |
|---|---|---|
| C1 L1-watcher | ${iconFor(c1.status)} | ${c1.detail || '—'} |
| C2 Cross-ref consistency | ${iconFor(c2.status)} | ${c2.detail || '—'} |
| C3 Observer-of-observer | ${iconFor(c3.status)} | ${c3.detail || '—'} |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | ${iconFor(c5.status)} | ${c5.detail || '—'} |

## Метрики (информационные, не алерты)

- Observer evidence: ${observer.episodeCount} episodes this month, ${observer.observerErrors} observer_error markers, ${observer.piiMatches} PII matches before filter
- Last /brain-retro: ${retroLine}
- Использование узлов: см. \`/brain-retro\` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory \`feedback_brain_unused_tools_not_problem\` — outside-repo memory store).

## Алерт-индикаторы

✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
`;
}
```

В CLI части (`if (process.argv[1]...`) добавить:

```js
function lastRetroDaysAgo() {
  const file = join('docs', 'observer', '.read-counter.json');
  if (!existsSync(file)) return null;
  try {
    const data = JSON.parse(readFileSync(file, 'utf-8'));
    if (!data.last_read_at) return null;
    return Math.floor((Date.now() - new Date(data.last_read_at).getTime()) / 86400000);
  } catch { return null; }
}
```

И в `inputs`:

```js
lastRetroDaysAgo: lastRetroDaysAgo(),
```

- [ ] **Step 4: Tests pass**

- [ ] **Step 5: Commit**

```bash
git add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
git commit -m "feat(status-md): show last /brain-retro days-ago

Closes brain-retro #10 — STATUS.md теперь сообщает, когда последний
раз был прочитан observer (через .read-counter.json). Помогает не
забыть про ретро между sprint-кадансами.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §10
2 new vitest tests, 272/272 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Phase 2 — 🟢 Low

### Task 11: reasoning-tag opt-in (расширение routing-tag)

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:352-367` — добавить `parseReasoningTag` + интеграция
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('parseReasoningTag (Task 11)', () => {
  it('parses an opt-in reasoning tag from assistant text', () => {
    const turn = [{ message: { role: 'assistant', content: [
      { type: 'text', text: '<!-- reasoning: triggers="Pravila §12.2" candidates="brain-retro;brainstorming" boundaries="Pravila §16" -->\nAnswer.' }
    ] } }];
    const tag = parseReasoningTag(turn);
    expect(tag).toEqual({
      triggers: ['Pravila §12.2'],
      candidates: ['brain-retro', 'brainstorming'],
      boundaries: ['Pravila §16'],
    });
  });
  it('returns null when no tag', () => {
    expect(parseReasoningTag([{ message: { content: [{ type: 'text', text: 'plain' }] } }])).toBeNull();
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Implement**

В `tools/observer-transcript-parser.mjs` (рядом с `parseRoutingTag`):

```js
const REASONING_TAG_RE =
  /<!--\s*reasoning:\s*triggers="([^"]*)"\s+candidates="([^"]*)"\s+boundaries="([^"]*)"\s*-->/;

export function parseReasoningTag(turn) {
  for (const e of turn) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'text' && typeof b.text === 'string') {
        const m = b.text.match(REASONING_TAG_RE);
        if (m) {
          const split = (s) => s.split(';').map((x) => x.trim()).filter(Boolean);
          return { triggers: split(m[1]), candidates: split(m[2]), boundaries: split(m[3]) };
        }
      }
    }
  }
  return null;
}
```

В `parseTranscript` — где формируется `primary_rationale`, обогащать массивы тегом если он есть:

```js
const reasoningTag = parseReasoningTag(turn);
const tagTriggers = reasoningTag ? reasoningTag.triggers : [];
const tagCandidates = reasoningTag ? reasoningTag.candidates : [];
const tagBoundaries = reasoningTag ? reasoningTag.boundaries : [];

// ...
primary_rationale: {
  ...
  triggers_matched:    [...new Set([...extractTriggers(turn), ...tagTriggers])],
  candidates_considered: [...new Set([...extractCandidates(turn), ...tagCandidates])],
  boundaries_applied:    [...new Set([...extractBoundaries(turn), ...tagBoundaries])],
  ...
},
```

- [ ] **Step 4: Tests pass**

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): opt-in reasoning-tag for triggers/candidates/boundaries

Closes brain-retro #11 — добавлен parseReasoningTag (semicolon-separated
поля). Если Claude эмитит тег <!-- reasoning: triggers='...' ... -->,
он сливается с heuristic результатом #6 через Set-dedupe.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §11
2 new vitest tests, 274/274 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 12: subagent_invoked event

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:120-140` — расширить `collectToolUse` для Agent tool_use
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('subagent_invoked events (Task 12)', () => {
  it('emits subagent_invoked when Agent tool_use is present', () => {
    const transcript = [
      JSON.stringify({ sessionId: 's' }),
      JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
      JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
        { type: 'tool_use', id: 't1', name: 'Agent',
          input: { subagent_type: 'general-purpose', model: 'sonnet', description: 'check files' } }
      ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
    ].join('\n');
    const ep = parseTranscript(transcript);
    const sub = ep.events.filter((e) => e.kind === 'subagent_invoked');
    expect(sub).toHaveLength(1);
    expect(sub[0].subagent_type).toBe('general-purpose');
    expect(sub[0].model).toBe('sonnet');
    expect(sub[0].description).toBe('check files');
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Implement**

В `collectToolUse` после `if (name === 'Skill') {...}` блока добавить:

```js
        if (name === 'Agent' && block.input) {
          // ВНИМАНИЕ: events создаются позже, тут только подготовка.
          // Используем отдельный массив, возвращаемый из collectToolUse.
        }
```

Лучше — отдельная функция `extractAgentInvocations`:

```js
export function extractAgentInvocations(turn) {
  const out = [];
  for (const e of turn || []) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use' && b.name === 'Agent') {
        const inp = b.input || {};
        out.push({
          kind: 'subagent_invoked',
          subagent_type: inp.subagent_type || 'unknown',
          model: inp.model || null,
          description: typeof inp.description === 'string' ? inp.description.slice(0, 80) : '',
        });
      }
    }
  }
  return out;
}
```

В `parseTranscript`:

```js
events.push(...extractAgentInvocations(turn));
```

- [ ] **Step 4: Tests pass**

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): emit subagent_invoked event for Agent tool_use

Closes brain-retro #12 — каждый Agent tool_use в turn'е генерирует
subagent_invoked event с subagent_type / model / description (первые 80
chars). Базовая видимость субагентских вызовов из верхнего Claude.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §12
1 new vitest test, 275/275 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 13: parallel_session pre-flight heuristic — **PIVOT per REVISION v1.1**

> F1 параллельной сессии сузил `parallel_session` к tool_result-only (parser.mjs:180-188). Task 13 — **аддитивная** правка: второй OR-сигнал «Bash-команда содержит `git fetch && git log HEAD..origin/main`» (Pravila §15.2 pre-flight). Не overwrite F1. Все F1 tests должны остаться GREEN.

### Task 13: parallel_session pre-flight heuristic (revised)

**Files:**

- Modify: `tools/observer-transcript-parser.mjs:180-190` (`extractEnvironment` — расширить через OR; добавить helper `hasPreFlightFetch`)
- Test: `tools/observer-transcript-parser.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('parallel_session — pre-flight heuristic (Task 13)', () => {
  it('is true when Bash ran "git fetch && git log HEAD..origin/main"', () => {
    const transcript = [
      JSON.stringify({ sessionId: 's' }),
      JSON.stringify({ type: 'user', message: { role: 'user', content: 'pre-flight sync' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }),
      JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [
        { type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'git fetch && git log HEAD..origin/main --oneline' } }
      ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }),
    ].join('\n');
    const ep = parseTranscript(transcript);
    expect(ep.environment.parallel_session).toBe(true);
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Implement**

В `extractEnvironment` после блока `parallel_session = /чужой staged|.../i.test(...)`, добавить scan на Bash-команды:

```js
function hasPreFlightFetch(turn) {
  for (const e of turn) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use' && b.name === 'Bash' && b.input) {
        const cmd = String(b.input.command || '');
        if (/git\s+fetch[^|&;]*&&[^|&;]*git\s+log\s+HEAD\.\.origin\//.test(cmd)) return true;
      }
    }
  }
  return false;
}

// ...
const parallel_session =
  /чужой staged|foreign git index|index\.lock|another git process/i.test(collectToolResultText(turn))
  || hasPreFlightFetch(turn);
```

- [ ] **Step 4: Tests pass**

- [ ] **Step 5: Commit**

```bash
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
git commit -m "feat(observer): detect parallel_session via pre-flight git fetch

Closes brain-retro #13 — Pravila §15.2 pre-flight 'git fetch && git log
HEAD..origin/main' — сильный сигнал параллельной сессии. Добавлено
вторым OR к существующему детектору collision markers.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §13
1 new vitest test, 276/276 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 14: session_turn → session_segment_turn

**Files:**

- Modify: `tools/brain-retro-analyzer.mjs:144-150` (`sessionTurnBucket`)
- Modify: `tools/brain-retro-analyzer.mjs:152-162` (`FACTOR_FNS` key переименование)
- Test: `tools/brain-retro-analyzer.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('factorMatrix — session_segment_turn bucket (Task 14)', () => {
  it('axis is named session_segment_turn (post-compaction-aware)', () => {
    const eps = [
      { schema_version: 2, task_id: 's', task_ref: 's', _inferredOutcome: 'success',
        timestamps: { started_at: '2026-05-20T00:00:00Z' }, events: [],
        environment: { economy_level: null, model: 'opus', post_compaction: false, session_turn: 5, parallel_session: false },
        task_size: { tool_calls: 0 },
        primary_rationale: { node_chosen: 'direct', task_classification: 'other' },
        decision_provenance: { kind: 'autonomous' } },
    ];
    const result = analyze(eps);
    expect(result.factorMatrix).toHaveProperty('session_segment_turn');
    expect(result.factorMatrix).not.toHaveProperty('session_turn');
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Rename**

В `tools/brain-retro-analyzer.mjs:152-162` заменить ключ:

```js
const FACTOR_FNS = {
  decision_provenance: (e) => (e.decision_provenance || {}).kind || 'unknown',
  economy_level: (e) => String((e.environment || {}).economy_level ?? 'null'),
  model: (e) => (e.environment || {}).model || 'null',
  post_compaction: (e) => String((e.environment || {}).post_compaction ?? false),
  session_segment_turn: (e) => sessionTurnBucket((e.environment || {}).session_turn),
  parallel_session: (e) => String((e.environment || {}).parallel_session ?? false),
  task_size: (e) => sizeBucket((e.task_size || {}).tool_calls),
  node_chosen: (e) => (e.primary_rationale || {}).node_chosen || 'direct',
  task_classification: (e) => (e.primary_rationale || {}).task_classification || 'other',
};
```

NB: семантика поля парсера `environment.session_turn` НЕ меняется (это уже «turns since last compaction» в parser.mjs:170-178); только переименовываем ось факторной матрицы для ясности — старое имя «session_turn» создавало путаницу с глобальным turn-номером.

- [ ] **Step 4: Tests pass**

NB: если в существующих brain-retro-analyzer тестах есть строка `session_turn` (тест от 2026-05-19 — `buildFactorMatrix > includes session_turn (bucketed) and parallel_session factors`), обновить assertion на `session_segment_turn`.

- [ ] **Step 5: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "refactor(observer): rename factor axis session_turn → session_segment_turn

Closes brain-retro #14 — поле environment.session_turn уже значит
'turns since last compaction' (parser.mjs:170-178), но ось матрицы
называлась 'session_turn' — путаница с глобальным turn-номером.
Семантика данных не меняется, только имя оси.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §14
1 new vitest test + existing test renamed. 277/277 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 15: recordRead в /brain-retro SKILL.md (шаг 4)

**Files:**

- Modify: `.claude/skills/brain-retro/SKILL.md` — заменить «bump» на конкретную команду

- [ ] **Step 1: Read current SKILL.md**

```bash
cat ".claude/skills/brain-retro/SKILL.md"
```

(Контент уже в контексте сессии из первого turn'а — шаг 4 «Update read-counter»; команда не указана.)

- [ ] **Step 2: Modify SKILL.md шаг 4**

В разделе «## Procedure» шаг 4 заменить на:

```markdown
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer.)
```

- [ ] **Step 3: Commit**

```bash
git add .claude/skills/brain-retro/SKILL.md
git commit -m "docs(skill): /brain-retro step 4 uses observer-of-observer record

Closes brain-retro #15 — заменена абстрактная инструкция 'bump' на
конкретную команду 'node tools/observer-of-observer.mjs record'.
Атомарная (read-modify-write через fs), переиспользует тот же
модуль, что C3-isStale.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §15

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 16: outcome inference — neutral → soft_success

**Files:**

- Modify: `tools/brain-retro-analyzer.mjs:30-51` (`inferOutcome`)
- Test: `tools/brain-retro-analyzer.test.mjs`

- [ ] **Step 1: Failing test**

```js
describe('inferOutcome — soft_success on neutral (Task 16)', () => {
  it('infers soft_success when next prompt is neutral', () => {
    const a = { events: [] };
    const b = { prompt_signal: 'neutral' };
    expect(inferOutcome(a, b)).toBe('soft_success');
  });
  it('still returns unknown when there is no next episode', () => {
    expect(inferOutcome({ events: [] }, null)).toBe('unknown');
  });
});
```

- [ ] **Step 2: Fails**

- [ ] **Step 3: Implement**

В `tools/brain-retro-analyzer.mjs:30-51` `inferOutcome`:

```js
export function inferOutcome(episode, nextEpisode) {
  const events = episode && Array.isArray(episode.events) ? episode.events : [];
  if (events.some((e) => e.kind === 'interrupt')) return 'partial';
  if (events.some((e) => e.kind === 'unrecovered_error')) return 'blocked';
  if (!nextEpisode) return 'unknown';
  if (nextEpisode.prompt_signal === 'correction') return 'rework';
  if (nextEpisode.prompt_signal === 'approval' || nextEpisode.prompt_signal === 'new_task') return 'success';
  if (nextEpisode.prompt_signal === 'neutral') return 'soft_success';
  return 'unknown';
}
```

- [ ] **Step 4: Tests pass**

NB: Update spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` §6 в Task 20.

- [ ] **Step 5: Commit**

```bash
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs
git commit -m "feat(observer): infer soft_success from neutral next-prompt

Closes brain-retro #16 — если следующий prompt в той же сессии
нейтрален (next instruction без слова correction/approval), это
сильный сигнал молчаливого success — заказчик бы поправил при фейле.
Уменьшает 65% unknown в текущей выборке.

Spec sync — в финальной задаче Task 20.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §16
2 new vitest tests, 279/279 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 17: Glob latency investigation (создать investigator)

**Files:**

- Create: `tools/glob-latency-investigator.mjs`
- (нет .test.mjs — investigation script, не production code)

- [ ] **Step 1: Создать investigator**

```js
#!/usr/bin/env node
/**
 * Glob latency investigator — разовый замер.
 * Берёт transcript-JSONL, выбирает все Glob tool_use'ы с round-trip,
 * сортирует по latency desc, печатает топ-10 с pattern + latency.
 * Цель: понять, какие patterns занимают >12.7s p50 (brain-retro #17).
 *
 * Usage: node tools/glob-latency-investigator.mjs <path-to-transcript.jsonl>
 */
import { readFileSync } from 'fs';

const path = process.argv[2];
if (!path) {
  console.error('Usage: node tools/glob-latency-investigator.mjs <transcript.jsonl>');
  process.exit(1);
}

const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
const starts = new Map();
const events = [];
for (const l of lines) {
  let o; try { o = JSON.parse(l); } catch { continue; }
  const ts = o.timestamp ? new Date(o.timestamp).getTime() : null;
  if (!ts) continue;
  if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
    for (const c of o.message.content) {
      if (c && c.type === 'tool_use' && c.name === 'Glob') {
        starts.set(c.id, { pattern: c.input && c.input.pattern, path: c.input && c.input.path, ts });
      }
    }
  }
  if (o.type === 'user' && o.message && Array.isArray(o.message.content)) {
    for (const c of o.message.content) {
      if (c && c.type === 'tool_result' && starts.has(c.tool_use_id)) {
        const s = starts.get(c.tool_use_id);
        events.push({ pattern: s.pattern, path: s.path, latency: ts - s.ts });
      }
    }
  }
}
events.sort((a, b) => b.latency - a.latency);
console.log('Top 10 Glob round-trips by latency:');
console.log('latency_ms'.padStart(10), 'pattern (path)');
for (const e of events.slice(0, 10)) {
  console.log(String(e.latency).padStart(10), `${e.pattern}${e.path ? ` (${e.path})` : ''}`);
}
console.log(`\nTotal: ${events.length} Glob calls`);
```

- [ ] **Step 2: Run на реальной сессии**

```bash
node tools/glob-latency-investigator.mjs "C:/Users/Administrator/.claude/projects/c---------------------crm-------------/553717ec-bf55-43dc-8b9c-b9812711023a.jsonl"
```

Expected: распечатает топ-10 паттернов; в финдингах будет ясно — `**/*` патчи / большие subagent-каталоги / иное.

- [ ] **Step 3: Записать findings в README investigator + commit**

В конец `tools/glob-latency-investigator.mjs` добавить блок с findings (как комментарий) — будет финал-output Step 2.

```bash
git add tools/glob-latency-investigator.mjs
git commit -m "tools: add Glob latency investigator (one-off script)

Closes brain-retro #17 — разовый инвестигатор для аномалии Glob p50=12.7s
из ретро 20.05. Не часть production code — script для ad-hoc анализа.
Парсит transcript-JSONL, печатает топ-10 Glob roundtrip'ов по latency.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §17

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 18: v1 episodes surface в STATUS.md

**Files:**

- Modify: `tools/status-md-generator.mjs:54-83`
- Modify: `tools/status-md-generator.test.mjs`

- [ ] **Step 1: Failing test**

```js
it('renderStatus shows v1 episodes count (Task 18)', () => {
  const md = renderStatus({
    now: 'now',
    c1: { status: 'ok', detail: '' }, c2: { status: 'ok', detail: '' },
    c3: { status: 'ok', detail: '' }, c5: { status: 'ok', detail: '' },
    observer: { episodeCount: 24, observerErrors: 0, piiMatches: 0, v1Episodes: 5 },
  });
  expect(md).toMatch(/Legacy v1 episodes.*5/);
});
```

- [ ] **Step 2: Implement**

В `tools/status-md-generator.mjs:54-64` добавить:

```js
function countV1Episodes() {
  const dir = 'docs/observer';
  if (!existsSync(dir)) return 0;
  const month = new Date().toISOString().slice(0, 7);
  const file = join(dir, `episodes-${month}.jsonl`);
  if (!existsSync(file)) return 0;
  return readFileSync(file, 'utf-8')
    .trim().split('\n')
    .filter((l) => l && !l.includes('"schema_version":2') && !l.includes('"observer_error":true')).length;
}
```

В `renderStatus` добавить в Metrics строку:

```js
- Legacy v1 episodes (not in factor analysis): ${observer.v1Episodes || 0}
```

И в CLI inputs:

```js
v1Episodes: countV1Episodes(),
```

- [ ] **Step 3: Tests pass**

- [ ] **Step 4: Commit**

```bash
git add tools/status-md-generator.mjs tools/status-md-generator.test.mjs
git commit -m "feat(status-md): surface legacy v1 episode count

Closes brain-retro #18 — v1 эпизоды (без schema_version=2) теперь
видны в STATUS.md метриках. На 20.05 их 5 — bootstrap-эпоха
до раскатки v2 schema (08:06 19.05).

Refs: docs/observer/notes/2026-05-20-brain-retro.md §18
1 new vitest test, 280/280 GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 19: STATUS.md auto-refresh в /brain-retro SKILL.md

**Files:**

- Modify: `.claude/skills/brain-retro/SKILL.md` — после step 8

- [ ] **Step 1: Modify SKILL.md**

После «8. **Save retro note**:» вставить (вместо или дополнением):

```markdown
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (lastRetroDaysAgo=0, fresh episode count).
9. **Report to user**: high-signal summary.
```

- [ ] **Step 2: Commit**

```bash
git add .claude/skills/brain-retro/SKILL.md
git commit -m "docs(skill): /brain-retro step 8a refreshes STATUS.md

Closes brain-retro #19 — после save retro-note запускается status-md
generator, STATUS.md становится свежим (lastRetroDaysAgo=0 / новый
episode count). Без этого STATUS обновлялся только при post-commit.

Refs: docs/observer/notes/2026-05-20-brain-retro.md §19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

## Phase 3 — Spec sync + final verification

### Task 20: Spec v1.1 → v1.2 (factor-analysis расширения)

**Files:**

- Modify: `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`

- [ ] **Step 1: Update header version**

В шапке `**Версия:**` → `1.2`, **Дата:** `2026-05-20`.

- [ ] **Step 2: Update §3 Слой 1 — добавить `task_cost`**

После строки `| events[] | расширенные виды | детерминированный парсинг + тег |` добавить:

```markdown
| `task_cost` (opt.) | `{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, web_search_requests, web_fetch_requests, iterations}` | детерминированный парсинг (extractTokenUsage) — v1.2 (#2) |
```

В конце §3 добавить:

```markdown
- **`task_cost`** — опциональный поле (backward-compat: старые v2-эпизоды без него по-прежнему валидны). Σ по `message.usage` всех assistant-сообщений turn'а. Дополнительный фактор «cost-per-outcome».
```

- [ ] **Step 3: Update §6 Слой 4 — добавить soft_success + session_segment_turn**

После `outcome ∈ success | partial | failure | rework | blocked | unknown`:

```markdown
- **`outcome`** в v1.2 расширен до `success` | `soft_success` | `partial` | `failure` | `rework` | `blocked` | `unknown`. `soft_success` — next prompt `neutral` (молчаливое одобрение).
```

В описании факторной матрицы заменить «`session_turn`» на «`session_segment_turn`» с уточнением «turns since last compaction; bucket early/mid/late».

- [ ] **Step 4: Add §12 changelog entry**

В конце spec добавить:

```markdown
---

## 12. Phase 1.2 amendment — instrument expansion (2026-05-20)

Реализованы 19 рекомендаций из brain-retro 2026-05-20:

- **Схема v2 → v2.1** (расширения): опциональное `task_cost` (#2); три осей факторной матрицы обогащены — `triggers_matched`/`candidates_considered`/`boundaries_applied` через heuristic + opt-in reasoning-tag (#6, #11).
- **Парсер расширен:** classifyTask +7 классов (#1), PII-counter (#3), AskUserQuestion answer kind events (#4), system-reminder фильтр (#8), promptSignal vocabulary (#9), tool failure differentiation (#7), subagent_invoked event (#12), parallel_session pre-flight heuristic (#13).
- **Анализатор:** hot-file двухуровневый (#5), session_turn → session_segment_turn rename (#14), outcome neutral→soft_success (#16).
- **STATUS.md:** real PII count (#3), lastRetroDaysAgo (#10), v1 episodes count (#18).
- **/brain-retro SKILL.md:** explicit recordRead step (#15), STATUS.md auto-refresh (#19).
- **Investigation:** Glob latency investigator (#17, ad-hoc).

Phase 2 (LLM-agent-судья / confusion_marker / chain_divergence как суждения / real-time friction) остаётся out-of-scope.
```

- [ ] **Step 5: Commit**

```bash
git add docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
git commit -m "docs(spec): observer factor-analysis v1.1 → v1.2 instrument expansion

Sync after brain-retro #1-#19 implementation:
- Schema v2.1: optional task_cost
- Outcome adds soft_success
- session_turn → session_segment_turn axis rename
- §12 changelog entry summarises 19 atomic commits

Refs: docs/observer/notes/2026-05-20-brain-retro.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 21: Final verification

- [ ] **Step 1: Полный `npm run test:tools`**

```bash
npm run test:tools
```

Expected: ~280+ tests GREEN, 0 failed.

Если красное — найти конкретный failure (file:line), починить, не делать новых commits до зелёного.

- [ ] **Step 2: Pre-commit на all changed files**

```bash
npx lefthook run pre-commit
```

Expected: все jobs pass (markdownlint, cspell, gitleaks, larastan-N/A — нет PHP правок, deptrac — нет PHP правок, l1-watcher — должен пройти, cross-ref-checker — должен пройти).

- [ ] **Step 3: Smoke /brain-retro mini-cycle**

Ручной запуск analyzer на нынешнем episodes-2026-05.jsonl чтобы убедиться, что новые поля парсятся / factor matrix перестроилась:

```bash
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | grep -E "session_segment_turn|task_cost"
```

Expected: оба поля присутствуют. (NB: `task_cost` будет видим только в новых эпизодах, записанных после Task 2 commit — старые эпизоды его не имеют.)

- [ ] **Step 4: Push (если пользователь подтвердил)**

```bash
git push origin <ветка>:main
```

Перед push — pre-push (gitleaks-full-history + lychee). NB: согласие на push — отдельное «применяй» от заказчика после finish.

---

## Self-Review (выполнено мной как автором плана)

**1. Spec coverage:** 19 рекомендаций из ретро-ноты → 19 + 1 (Task 20 spec sync) + 1 (Task 21 final verify) = 21 task. ✅ Все 19 пунктов имеют task. B3-1 — applied вне scope этого плана (1 строка в package.json + verified npm run test:tools на 232 теста).

**2. Placeholder scan:** Каждый task имеет конкретный код в Step 1 (test) и Step 3 (implementation). Никаких «TBD», «similar to Task N», «add error handling». ✅

**3. Type consistency:**

- `task_cost` shape повторяется идентично в Task 2 + Task 20 spec.
- `subagent_invoked` event shape идентичен в Task 12 описании + использует ту же конвенцию `kind` что и существующие events.
- `extractTriggers`/`extractCandidates`/`extractBoundaries` имеют одинаковую сигнатуру `(turn) → Array`.
- `sanitizeWithCount` shape: `{ sanitized, counts }` — повторяется в test (Task 3) + implementation (Task 3) + status-md-generator usage.

**4. Backward-compatibility:** `task_cost` — optional (V2_FIELDS не расширяется). Старые v2-эпизоды без `task_cost` остаются валидны. ✅

**5. Test infrastructure:** Все тесты используют `vitest`, раннер `npm run test:tools` (B3-1, applied). Существующие 232 теста — не ломаются (только обновляются ожидания в Task 6/7/14 где они эволюционируют).

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md`.**

**Два варианта исполнения:**

1. **Subagent-Driven (recommended)** — controller (Opus, я) диспатчу свежего субагента (Sonnet/Haiku per task complexity) на каждый task, между задачами — двухстадийное review. Атомарные коммиты per задача. Tasks 6, 11 — сложнее, потребуют careful review; Tasks 15, 19, 17 — самые простые, можно Haiku.

2. **Inline Execution** — выполнять задачи прямо в текущей сессии (executing-plans skill), batch-execution с checkpoints для review.

**Какой подход?**
