Files
portal/docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md
T
Дмитрий 4069aaa775 docs(observer): implementation plan — phase 1.1 user_chose_from_options
5 tasks TDD plan with explicit code per step. Task 1 creates
observer-choice-detector.mjs pure module (23 tests). Task 2 wires
into transcript-parser. Task 3 extends routingGateDecision (no-block).
Task 4 extends brain-retro factor matrix. Task 5 normative sync
(Pravila §16.2 + CLAUDE.md §3.6 + spec cross-ref).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:53:53 +03:00

32 KiB
Raw Blame History

Observer factor-analysis — phase 1.1 (user_chose_from_options) 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: добавить 3-й kind decision_provenance.kind = user_chose_from_options к observer schema v2 (детектор, parser-integration, routing-gate, factor-matrix). Устраняет смешение «навязанный метод» vs «collaborative choice из моих предложенных вариантов» в факторном анализе.

Architecture: новый чистый модуль tools/observer-choice-detector.mjs (extract options + detect user reference). Подключается в observer-transcript-parser.mjs ПЕРЕД существующим detectMethodDirected. routingGateDecision в Stop-хуке расширяется — НЕ блокирует new kind (collaborative choice). brain-retro-analyzer factor-matrix получает 3-е значение оси provenance_kind.

Tech Stack: Node.js ESM, Vitest (через app/vitest.config.tools.mjs), pure functions без I/O.

Spec: docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md §11 (v1.1, accepted, commit 0c8d0fa).


File Structure

Файл Действие Ответственность
tools/observer-choice-detector.mjs Create Pure: extractOptions / detectReference / detectChoiceProvenance
tools/observer-choice-detector.test.mjs Create Unit-тесты для всех 3-х функций + negative cases
tools/observer-transcript-parser.mjs Modify Вызов detectChoiceProvenance ДО parseRoutingTag + сборка decision_provenance
tools/observer-transcript-parser.test.mjs Modify +тесты для user_chose_from_options ветки
tools/observer-stop-hook.mjs Modify routingGateDecision — return no-block для user_chose_from_options
tools/observer-stop-hook.test.mjs Modify +тест на no-block для new kind
tools/brain-retro-analyzer.mjs Modify Factor matrix — 3-значная ось provenance_kind (если whitelisted)
tools/brain-retro-analyzer.test.mjs Modify +тест на 3-значное распределение по оси
docs/Pravila_raboty_Claude_v1_1.md Modify §16.2 — расширить kind ∈ 3 значения; bump v1.32 → v1.33 + entry
docs/superpowers/specs/2026-05-19-brain-governance-design.md Modify Cross-ref на spec §11 (если ещё нет)
CLAUDE.md Modify (через /claude-md-management:claude-md-improver) §0 Pravila row v1.32 → v1.33; §3.6 +упоминание 3-го kind; §9 +entry

Task 1: observer-choice-detector.mjs — pure module + TDD

Файлы:

  • Create: tools/observer-choice-detector.mjs
  • Test: tools/observer-choice-detector.test.mjs

Контекст для subagent'а: Pure ESM-модуль без побочных эффектов. Подключается в observer-transcript-parser.mjs (Task 2). Дизайн — из spec §11.3.

  • Step 1.1: Failing tests — extractOptions

Создать tools/observer-choice-detector.test.mjs:

import { describe, it, expect } from 'vitest';
import { extractOptions, detectReference, detectChoiceProvenance } from './observer-choice-detector.mjs';

describe('extractOptions', () => {
  it('returns null when content has fewer than 2 options', () => {
    expect(extractOptions('Just one paragraph of text.')).toBeNull();
    expect(extractOptions('1. only one item')).toBeNull();
  });

  it('extracts numbered list (1. ... 2. ...)', () => {
    const text = '1. First option\n2. Second option\n3. Third option';
    expect(extractOptions(text)).toEqual(['First option', 'Second option', 'Third option']);
  });

  it('extracts numbered list (1) ... 2) ...)', () => {
    const text = '1) Alpha\n2) Beta';
    expect(extractOptions(text)).toEqual(['Alpha', 'Beta']);
  });

  it('extracts lettered list (Latin)', () => {
    const text = 'A. First choice\nB. Second choice\nC. Third choice';
    expect(extractOptions(text)).toEqual(['First choice', 'Second choice', 'Third choice']);
  });

  it('extracts lettered list (Cyrillic)', () => {
    const text = 'А. Первый\nБ. Второй\nВ. Третий';
    expect(extractOptions(text)).toEqual(['Первый', 'Второй', 'Третий']);
  });

  it('extracts bullet list with hyphens', () => {
    const text = '- Apple\n- Banana\n- Cherry';
    expect(extractOptions(text)).toEqual(['Apple', 'Banana', 'Cherry']);
  });

  it('extracts bullet list with asterisks', () => {
    const text = '* Red\n* Green\n* Blue';
    expect(extractOptions(text)).toEqual(['Red', 'Green', 'Blue']);
  });

  it('extracts AskUserQuestion options from tool_use block', () => {
    const askUser = {
      type: 'tool_use',
      name: 'AskUserQuestion',
      input: {
        questions: [{
          options: [
            { label: 'Option A' },
            { label: 'Option B' },
            { label: 'Option C' },
          ],
        }],
      },
    };
    expect(extractOptions(askUser)).toEqual(['Option A', 'Option B', 'Option C']);
  });

  it('returns null when AskUserQuestion has < 2 options', () => {
    const askUser = {
      type: 'tool_use',
      name: 'AskUserQuestion',
      input: { questions: [{ options: [{ label: 'Only one' }] }] },
    };
    expect(extractOptions(askUser)).toBeNull();
  });
});
  • Step 1.2: Run tests — verify FAIL
cd app
npx vitest run --config vitest.config.tools.mjs observer-choice-detector

Expected: FAIL with "Cannot find module" (observer-choice-detector.mjs ещё не создан).

  • Step 1.3: Implement extractOptions

Создать tools/observer-choice-detector.mjs:

const NUMBERED_RE = /^(\d+)[.\)]\s+(.+)$/;
const LETTERED_RE = /^([A-Za-zА-Яа-я])[.\)]\s+(.+)$/;
const BULLET_RE = /^[-*]\s+(.+)$/;

function extractFromText(text) {
  const lines = String(text).split(/\r?\n/);
  const numbered = [];
  const lettered = [];
  const bulleted = [];
  for (const line of lines) {
    const trimmed = line.trim();
    let m;
    if ((m = NUMBERED_RE.exec(trimmed))) numbered.push(m[2].trim());
    else if ((m = LETTERED_RE.exec(trimmed))) lettered.push(m[2].trim());
    else if ((m = BULLET_RE.exec(trimmed))) bulleted.push(m[1].trim());
  }
  if (numbered.length >= 2) return numbered;
  if (lettered.length >= 2) return lettered;
  if (bulleted.length >= 2) return bulleted;
  return null;
}

function extractFromAskUser(toolUse) {
  if (!toolUse || toolUse.type !== 'tool_use' || toolUse.name !== 'AskUserQuestion') return null;
  const questions = toolUse.input?.questions;
  if (!Array.isArray(questions) || questions.length === 0) return null;
  const options = questions[0]?.options;
  if (!Array.isArray(options) || options.length < 2) return null;
  return options.map((o) => o?.label).filter((l) => typeof l === 'string');
}

export function extractOptions(content) {
  if (content == null) return null;
  if (typeof content === 'string') return extractFromText(content);
  if (typeof content === 'object') {
    const fromTool = extractFromAskUser(content);
    if (fromTool && fromTool.length >= 2) return fromTool;
    if (Array.isArray(content)) {
      for (const block of content) {
        const r = extractOptions(block);
        if (r) return r;
      }
    }
    if (typeof content.text === 'string') return extractFromText(content.text);
  }
  return null;
}
  • Step 1.4: Run tests — verify GREEN
cd app
npx vitest run --config vitest.config.tools.mjs observer-choice-detector

Expected: 9/9 PASS for extractOptions.

  • Step 1.5: Failing tests — detectReference

Добавить в tools/observer-choice-detector.test.mjs:

describe('detectReference', () => {
  const options = ['First option label', 'Second option label', 'Third option label'];

  it('returns null on empty prompt', () => {
    expect(detectReference('', options)).toBeNull();
    expect(detectReference('   ', options)).toBeNull();
  });

  it('matches bare number at start', () => {
    expect(detectReference('1', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('2 экономия 5%', options)).toEqual({ index: 1, label: 'Second option label' });
    expect(detectReference('3, делаем', options)).toEqual({ index: 2, label: 'Third option label' });
  });

  it('matches verb-prefixed number', () => {
    expect(detectReference('делай 1', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('выбираю 2', options)).toEqual({ index: 1, label: 'Second option label' });
    expect(detectReference('беру 3', options)).toEqual({ index: 2, label: 'Third option label' });
    expect(detectReference('хочу 1', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('вариант 2', options)).toEqual({ index: 1, label: 'Second option label' });
  });

  it('matches bare letter at start (Latin)', () => {
    expect(detectReference('A', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('B делаем', options)).toEqual({ index: 1, label: 'Second option label' });
    expect(detectReference('c, идём', options)).toEqual({ index: 2, label: 'Third option label' });
  });

  it('matches bare letter at start (Cyrillic)', () => {
    expect(detectReference('А', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('б делаем', options)).toEqual({ index: 1, label: 'Second option label' });
    expect(detectReference('в, идём', options)).toEqual({ index: 2, label: 'Third option label' });
  });

  it('matches verb-prefixed letter', () => {
    expect(detectReference('делай A', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('выбираю Б', options)).toEqual({ index: 1, label: 'Second option label' });
  });

  it('matches label substring (first 2-4 words)', () => {
    expect(detectReference('First option уточни', options)).toEqual({ index: 0, label: 'First option label' });
    expect(detectReference('делаем Second option', options)).toEqual({ index: 1, label: 'Second option label' });
  });

  it('returns null when no signal matches', () => {
    expect(detectReference('какой-то текст без ссылки', options)).toBeNull();
    expect(detectReference('просто продолжай работу', options)).toBeNull();
  });

  it('returns null on out-of-range position', () => {
    expect(detectReference('99', options)).toBeNull();
    expect(detectReference('Z', options)).toBeNull();
    expect(detectReference('Я', options)).toBeNull();
  });
});
  • Step 1.6: Run tests — verify FAIL
cd app
npx vitest run --config vitest.config.tools.mjs observer-choice-detector

Expected: 9 PASS, 9 FAIL.

  • Step 1.7: Implement detectReference

Добавить в tools/observer-choice-detector.mjs:

const VERBS = ['делай', 'выбираю', 'выбери', 'беру', 'хочу', 'вариант', 'option', 'pick', 'choose'];
const VERBS_RE = new RegExp('^(?:' + VERBS.join('|') + ')\\s+', 'i');

const LATIN_TO_INDEX = {
  a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7,
};
const CYR_TO_INDEX = {
  'а': 0, 'б': 1, 'в': 2, 'г': 3, 'д': 4, 'е': 5, 'ж': 6, 'з': 7,
};

function tryPosition(prompt, options) {
  const lowered = prompt.toLowerCase();
  const stripped = lowered.replace(VERBS_RE, '');
  const m = stripped.match(/^(\d+|[a-zа-я])(?=[\s,\.\):;-]|$)/);
  if (!m) return null;
  const token = m[1];
  let idx;
  if (/^\d+$/.test(token)) {
    idx = parseInt(token, 10) - 1;
  } else {
    idx = LATIN_TO_INDEX[token] ?? CYR_TO_INDEX[token];
  }
  if (idx == null || idx < 0 || idx >= options.length) return null;
  return { index: idx, label: options[idx] };
}

function trySubstring(prompt, options) {
  const lowered = prompt.toLowerCase();
  for (let i = 0; i < options.length; i++) {
    const opt = String(options[i]).toLowerCase();
    const words = opt.split(/\s+/).filter(Boolean);
    if (words.length < 2) continue;
    for (let n = Math.min(4, words.length); n >= 2; n--) {
      const prefix = words.slice(0, n).join(' ');
      if (lowered.includes(prefix)) return { index: i, label: options[i] };
    }
  }
  return null;
}

export function detectReference(prompt, options) {
  const text = String(prompt || '').trim();
  if (!text || !Array.isArray(options) || options.length < 2) return null;
  return tryPosition(text, options) ?? trySubstring(text, options);
}
  • Step 1.8: Run tests — verify GREEN
cd app
npx vitest run --config vitest.config.tools.mjs observer-choice-detector

Expected: 18/18 PASS.

  • Step 1.9: Failing tests — detectChoiceProvenance

Добавить в tools/observer-choice-detector.test.mjs:

describe('detectChoiceProvenance', () => {
  it('returns null when no options', () => {
    expect(detectChoiceProvenance('1 делаем', 'just text without list')).toBeNull();
  });

  it('returns null when options exist but no reference', () => {
    const lastAsst = '1. First\n2. Second\n3. Third';
    expect(detectChoiceProvenance('расскажи как дела', lastAsst)).toBeNull();
  });

  it('returns full result on successful match (numbered)', () => {
    const lastAsst = '1. Phase A\n2. Phase B\n3. Phase C';
    expect(detectChoiceProvenance('2 делаем', lastAsst)).toEqual({
      kind: 'user_chose_from_options',
      node: 'Phase B',
      options_offered: ['Phase A', 'Phase B', 'Phase C'],
      claude_would_have_chosen: 'Phase A',
    });
  });

  it('returns full result on successful match (Cyrillic letter, keyboard layout)', () => {
    const lastAsst = 'А. Первый\nБ. Второй\nВ. Третий';
    expect(detectChoiceProvenance('в делаем', lastAsst)).toEqual({
      kind: 'user_chose_from_options',
      node: 'Третий',
      options_offered: ['Первый', 'Второй', 'Третий'],
      claude_would_have_chosen: 'Первый',
    });
  });

  it('returns full result with AskUserQuestion source', () => {
    const askUser = {
      type: 'tool_use',
      name: 'AskUserQuestion',
      input: { questions: [{ options: [{ label: 'Recommended' }, { label: 'Alternative' }] }] },
    };
    expect(detectChoiceProvenance('делай 2', askUser)).toEqual({
      kind: 'user_chose_from_options',
      node: 'Alternative',
      options_offered: ['Recommended', 'Alternative'],
      claude_would_have_chosen: 'Recommended',
    });
  });
});
  • Step 1.10: Run tests — verify FAIL
cd app
npx vitest run --config vitest.config.tools.mjs observer-choice-detector

Expected: 18 PASS, 5 FAIL.

  • Step 1.11: Implement detectChoiceProvenance

Добавить в tools/observer-choice-detector.mjs:

export function detectChoiceProvenance(promptText, lastAssistantContent) {
  const options = extractOptions(lastAssistantContent);
  if (!options) return null;
  const ref = detectReference(promptText, options);
  if (!ref) return null;
  return {
    kind: 'user_chose_from_options',
    node: ref.label,
    options_offered: options.slice(),
    claude_would_have_chosen: options[0],
  };
}
  • Step 1.12: Run tests — verify GREEN
cd app
npx vitest run --config vitest.config.tools.mjs observer-choice-detector

Expected: 23/23 PASS.

  • Step 1.13: Commit Task 1
git commit -m "feat(observer): choice detector — user_chose_from_options kind" -- tools/observer-choice-detector.mjs tools/observer-choice-detector.test.mjs

Full message body:

Pure module — extracts options (numbered/lettered/bullets/AskUserQuestion)
from last assistant message, detects user reference (position-based +
substring), returns decision_provenance for the 3rd kind.

23/23 tests GREEN.

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

Task 2: Integrate into observer-transcript-parser.mjs

Файлы:

  • Modify: tools/observer-transcript-parser.mjs
  • Test: tools/observer-transcript-parser.test.mjs

Контекст: parser строит эпизод из транскрипта. decision_provenance собирается из routing-тега (parseRoutingTag). Задача — ДО проверки тега вызвать detectChoiceProvenance с (a) текстом текущего user-prompt и (b) последним assistant-content перед ним.

  • Step 2.1: Failing test — parser produces user_chose_from_options episode

В tools/observer-transcript-parser.test.mjs добавить:

describe('parseTranscript — user_chose_from_options', () => {
  it('classifies as user_chose_from_options when last assistant offered options and user picked one', () => {
    const lines = [
      JSON.stringify({
        type: 'user',
        timestamp: '2026-05-19T10:00:00.000Z',
        sessionId: 's1',
        message: { role: 'user', content: 'continue task' },
      }),
      JSON.stringify({
        type: 'assistant',
        timestamp: '2026-05-19T10:00:01.000Z',
        sessionId: 's1',
        message: {
          role: 'assistant',
          content: [{ type: 'text', text: 'Choose:\n1. First option\n2. Second option\n3. Third option' }],
        },
      }),
      JSON.stringify({
        type: 'user',
        timestamp: '2026-05-19T10:00:10.000Z',
        sessionId: 's1',
        message: { role: 'user', content: '2 делаем' },
      }),
      JSON.stringify({
        type: 'assistant',
        timestamp: '2026-05-19T10:00:11.000Z',
        sessionId: 's1',
        message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
      }),
    ].join('\n');
    const ep = parseTranscript(lines, 'fallback');
    expect(ep.decision_provenance).toEqual({
      kind: 'user_chose_from_options',
      node: 'Second option',
      options_offered: ['First option', 'Second option', 'Third option'],
      claude_would_have_chosen: 'First option',
    });
  });

  it('falls back to autonomous when last assistant had no options', () => {
    const lines = [
      JSON.stringify({
        type: 'assistant',
        timestamp: '2026-05-19T10:00:00.000Z',
        sessionId: 's1',
        message: { role: 'assistant', content: [{ type: 'text', text: 'just a paragraph' }] },
      }),
      JSON.stringify({
        type: 'user',
        timestamp: '2026-05-19T10:00:10.000Z',
        sessionId: 's1',
        message: { role: 'user', content: '2 делаем' },
      }),
      JSON.stringify({
        type: 'assistant',
        timestamp: '2026-05-19T10:00:11.000Z',
        sessionId: 's1',
        message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
      }),
    ].join('\n');
    const ep = parseTranscript(lines, 'fallback');
    expect(ep.decision_provenance).toEqual({ kind: 'autonomous', claude_would_have_chosen: null });
  });

  it('routing-tag user_directed_method preserved when no choice detected', () => {
    const lines = [
      JSON.stringify({
        type: 'user',
        timestamp: '2026-05-19T10:00:00.000Z',
        sessionId: 's1',
        message: { role: 'user', content: 'запусти brainstorming' },
      }),
      JSON.stringify({
        type: 'assistant',
        timestamp: '2026-05-19T10:00:01.000Z',
        sessionId: 's1',
        message: {
          role: 'assistant',
          content: [{ type: 'text', text: 'ok\n<!-- routing: provenance=user_directed_method node=brainstorming counterfactual=writing-plans -->' }],
        },
      }),
    ].join('\n');
    const ep = parseTranscript(lines, 'fallback');
    expect(ep.decision_provenance.kind).toBe('user_directed_method');
  });
});
  • Step 2.2: Run tests — verify FAIL
cd app
npx vitest run --config vitest.config.tools.mjs observer-transcript-parser

Expected: existing tests PASS, 3 new tests FAIL.

  • Step 2.3: Implement parser integration

В tools/observer-transcript-parser.mjs:

  1. Импорт:
import { detectChoiceProvenance } from './observer-choice-detector.mjs';
  1. Реализовать helper extractLastAssistantContent(entries, turnStartIdx) рядом с extractLastUserPromptText:
function extractLastAssistantContent(entries, turnStartIdx) {
  for (let i = turnStartIdx - 1; i >= 0; i--) {
    const e = entries[i];
    if (e?.message?.role === 'assistant') {
      const content = e.message.content;
      if (Array.isArray(content)) return content;
      if (typeof content === 'string') return content;
    }
  }
  return null;
}
  1. В функции сборки эпизода — заменить блок сборки decision_provenance:
// БЫЛО (примерно строка 313):
const decision_provenance = tag
  ? { kind: 'user_directed_method', claude_would_have_chosen: tag.claude_would_have_chosen }
  : { kind: 'autonomous', claude_would_have_chosen: null };

// СТАЛО:
const userPromptText = extractLastUserPromptText(turnEntries) || '';
const lastAsstContent = extractLastAssistantContent(allEntries, turnStartIdx);
const choice = detectChoiceProvenance(userPromptText, lastAsstContent);
let decision_provenance;
if (choice) {
  decision_provenance = choice;
} else {
  decision_provenance = tag
    ? { kind: 'user_directed_method', claude_would_have_chosen: tag.claude_would_have_chosen }
    : { kind: 'autonomous', claude_would_have_chosen: null };
}

NB: имена turnEntries / allEntries / turnStartIdx могут отличаться — субагент адаптирует под реальную структуру parseTranscript. Ключевой инвариант: lastAsstContent — последняя assistant-message ДО текущего user-turn.

  • Step 2.4: Run tests — verify GREEN
cd app
npx vitest run --config vitest.config.tools.mjs observer-transcript-parser

Expected: все существующие + 3 новых = PASS.

  • Step 2.5: Commit Task 2
git commit -m "feat(observer): parser integration — user_chose_from_options before routing-tag" -- tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs

Full body:

detectChoiceProvenance runs BEFORE parseRoutingTag; if last assistant
turn offered options and user prompt references one, decision_provenance
becomes user_chose_from_options. Otherwise falls back to existing
routing-tag / autonomous logic.

3 new parser tests GREEN; all existing tests still GREEN.

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

Task 3: Extend routingGateDecision — no-block для new kind

Файлы:

  • Modify: tools/observer-stop-hook.mjs
  • Test: tools/observer-stop-hook.test.mjs

Контекст: routingGateDecision блокирует при detectMethodDirected + отсутствии user_directed_method тега. Для user_chose_from_options блокировать НЕ нужно — collaborative-choice не требует тега.

  • Step 3.1: Failing test

В tools/observer-stop-hook.test.mjs добавить:

describe('routingGateDecision — user_chose_from_options', () => {
  it('does NOT block when episode is user_chose_from_options even if prompt mentions a node', () => {
    const ep = v2Episode({
      decision_provenance: {
        kind: 'user_chose_from_options',
        node: 'brainstorming',
        options_offered: ['brainstorming', 'writing-plans'],
        claude_would_have_chosen: 'brainstorming',
      },
    });
    const decision = routingGateDecision(ep, 'запусти brainstorming', ['brainstorming', 'writing-plans'], false);
    expect(decision.block).toBe(false);
  });
});

NB: вспомогательная фабрика v2Episode уже есть в файле (используется в существующих тестах) — её используем без изменений.

  • Step 3.2: Run test — verify FAIL
cd app
npx vitest run --config vitest.config.tools.mjs observer-stop-hook

Expected: новый тест FAIL.

  • Step 3.3: Implement no-block branch

В tools/observer-stop-hook.mjs функция routingGateDecision — добавить одну строку после stopHookActive check:

export function routingGateDecision(episode, promptText, knownNodes, stopHookActive) {
  if (stopHookActive) return { block: false, reason: null };
  if (episode?.decision_provenance?.kind === 'user_chose_from_options') return { block: false, reason: null };
  // ... rest unchanged
}
  • Step 3.4: Run test — verify GREEN
cd app
npx vitest run --config vitest.config.tools.mjs observer-stop-hook

Expected: все существующие + 1 новый = PASS.

  • Step 3.5: Commit Task 3
git commit -m "feat(observer): routing-gate no-block for user_chose_from_options" -- tools/observer-stop-hook.mjs tools/observer-stop-hook.test.mjs

Task 4: Extend brain-retro-analyzer.mjs factor matrix

Файлы:

  • Modify: tools/brain-retro-analyzer.mjs
  • Test: tools/brain-retro-analyzer.test.mjs

Контекст: buildFactorMatrix строит распределение outcome по факторам. Ось decision_provenance.kind должна поддержать 3-е значение.

  • Step 4.1: Failing test

В tools/brain-retro-analyzer.test.mjs добавить:

describe('buildFactorMatrix — provenance kind 3 values', () => {
  it('counts all 3 kinds in provenance axis', () => {
    const episodes = [
      { decision_provenance: { kind: 'autonomous' }, outcome: 'success' },
      { decision_provenance: { kind: 'user_directed_method' }, outcome: 'rework' },
      { decision_provenance: { kind: 'user_chose_from_options' }, outcome: 'success' },
      { decision_provenance: { kind: 'user_chose_from_options' }, outcome: 'rework' },
    ];
    const matrix = buildFactorMatrix(episodes);
    const provenanceRow = matrix.find((row) => row.factor === 'decision_provenance.kind');
    expect(provenanceRow).toBeTruthy();
    expect(provenanceRow.buckets).toHaveProperty('autonomous');
    expect(provenanceRow.buckets).toHaveProperty('user_directed_method');
    expect(provenanceRow.buckets).toHaveProperty('user_chose_from_options');
    expect(provenanceRow.buckets.user_chose_from_options.success).toBe(1);
    expect(provenanceRow.buckets.user_chose_from_options.rework).toBe(1);
  });
});

NB: имена полей в matrix-структуре (factor / buckets) — субагент проверяет в реальном коде; если schema другая (например { axis, distribution }), test адаптирует под существующую форму.

  • Step 4.2: Run test — verify FAIL or already-PASS
cd app
npx vitest run --config vitest.config.tools.mjs brain-retro-analyzer

Если test PASS без изменений (dynamic bucketing) — Step 4.3 skip, переход к 4.5 (без commit). Если FAIL (whitelisted enum) — fix per 4.3.

  • Step 4.3: If FAIL — extend whitelist

Найти в tools/brain-retro-analyzer.mjs определение PROVENANCE_KINDS (или эквивалент) — расширить:

const PROVENANCE_KINDS = ['autonomous', 'user_directed_method', 'user_chose_from_options'];
  • Step 4.4: Run test — verify GREEN
cd app
npx vitest run --config vitest.config.tools.mjs brain-retro-analyzer

Expected: все PASS.

  • Step 4.5: Commit Task 4 (если был fix)

Если 4.3 был no-op — skip commit. Иначе:

git commit -m "feat(brain-retro): factor matrix — 3rd provenance kind in axis" -- tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs

Task 5: Normative sync — Pravila §16.2 + spec cross-ref + CLAUDE.md

Файлы:

  • Modify: docs/Pravila_raboty_Claude_v1_1.md
  • Modify: docs/superpowers/specs/2026-05-19-brain-governance-design.md
  • Modify: CLAUDE.md (через /claude-md-management:claude-md-improver)

Pre-flight sync (Pravila §15.2):

git fetch origin
git log HEAD..origin/main --oneline

Если есть un-merged коммиты на origin/main, затрагивающие 8-файловый список — pull/rebase ДО правок.

  • Step 5.1: Update Pravila §16.2

В docs/Pravila_raboty_Claude_v1_1.md:

  1. Header v1.32v1.33.
  2. §10 история версий — добавить entry v1.33:
- **v1.33 (2026-05-19):** §16.2 — расширен `decision_provenance.kind` до 3 значений
  (`autonomous` | `user_directed_method` | `user_chose_from_options`). Третий kind —
  collaborative-choice case (пользователь выбирает один из вариантов, предложенных Claude
  в предыдущем ходе). Routing-gate его НЕ блокирует. Spec §11:
  `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1.
  1. §16.2 — в абзаце «Схема эпизода v2» заменить «decision_provenance.kindautonomous | user_directed_method» на «decision_provenance.kindautonomous | user_directed_method | user_chose_from_options» + добавить одно предложение про 3-й kind.
  • Step 5.2: Update brain-governance-design.md cross-ref

В docs/superpowers/specs/2026-05-19-brain-governance-design.md — убедиться, что cross-ref на factor-analysis spec упоминает «§11 phase 1.1 (user_chose_from_options)». Если cross-ref общий — пропустить.

  • Step 5.3: Update CLAUDE.md via plugin

Через /claude-md-management:claude-md-improver:

  • §0 Pravila row: v1.32v1.33 + prepend короткая note.

  • §3.6 — в абзаце «Observer schema v2» добавить предложение про 3-й kind.

  • §9 история — v2.20 entry.

  • Step 5.4: Run cross-ref-checker — verify 0 drift

node tools/cross-ref-checker.mjs

Expected: 0 drift.

  • Step 5.5: Commit Task 5 — atomic normative sync
git commit -m "docs(brain): phase 1.1 normative sync — user_chose_from_options 3rd kind" -- docs/Pravila_raboty_Claude_v1_1.md docs/superpowers/specs/2026-05-19-brain-governance-design.md CLAUDE.md

Full body:

Pravila v1.32 → v1.33 (§16.2 — kind enum расширен до 3 значений,
+§10 entry). brain-governance-design.md cross-ref на factor-analysis
spec §11. CLAUDE.md §0 Pravila row + §3.6 + §9.

cross-ref-checker: 0 drift.

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

Verification — финальный прогон перед push

  • Full tools Vitest suite:
cd app
npx vitest run --config vitest.config.tools.mjs

Expected: all GREEN.

  • Cross-ref checker:
node tools/cross-ref-checker.mjs

Expected: 0 drift.

  • Smoke (опционально) — записать тестовый эпизод с user_chose_from_options через реальный транскрипт.

  • Pre-push: gitleaks-full-history + lychee (через lefthook pre-push) — отрабатывают автоматически.


Out of scope (документировать как backlog)

  • Refine routing-gate detector — исключение <system-reminder> blocks из scope. Отдельная задача (memory project_brain_governance_design.md).
  • Inline-проза «вариант A — короткий» без структурированного списка — низкая надёжность эвристики.
  • Распознавание «пользователь дополнил мою опцию» (выбрал B, но изменил параметр) — требует семантического понимания.

Execution

После approval плана — superpowers:subagent-driven-development (model: Sonnet per Pravila §15.1, fresh subagent per task + two-stage review). 5 tasks, ~5 commits (Task 4 может быть no-op).