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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-19T07:55:48.974Z
|
||||
Last updated: 2026-05-19T08:47:41.763Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,11 +8,11 @@ Last updated: 2026-05-19T07:55:48.974Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ✅ | 10 episode(s), 952 recent commit(s) · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ✅ | 18 episode(s), 954 recent commit(s) · Stop-hook + post-commit OK |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 10 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Observer evidence: 18 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
# 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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
cd app
|
||||
npx vitest run --config vitest.config.tools.mjs observer-choice-detector
|
||||
```
|
||||
|
||||
Expected: 23/23 PASS.
|
||||
|
||||
- [ ] **Step 1.13: Commit Task 1**
|
||||
|
||||
```bash
|
||||
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` добавить:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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. Импорт:
|
||||
|
||||
```javascript
|
||||
import { detectChoiceProvenance } from './observer-choice-detector.mjs';
|
||||
```
|
||||
|
||||
1. Реализовать helper `extractLastAssistantContent(entries, turnStartIdx)` рядом с `extractLastUserPromptText`:
|
||||
|
||||
```javascript
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
// БЫЛО (примерно строка 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**
|
||||
|
||||
```powershell
|
||||
cd app
|
||||
npx vitest run --config vitest.config.tools.mjs observer-transcript-parser
|
||||
```
|
||||
|
||||
Expected: все существующие + 3 новых = PASS.
|
||||
|
||||
- [ ] **Step 2.5: Commit Task 2**
|
||||
|
||||
```bash
|
||||
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` добавить:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
cd app
|
||||
npx vitest run --config vitest.config.tools.mjs observer-stop-hook
|
||||
```
|
||||
|
||||
Expected: все существующие + 1 новый = PASS.
|
||||
|
||||
- [ ] **Step 3.5: Commit Task 3**
|
||||
|
||||
```bash
|
||||
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` добавить:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```powershell
|
||||
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` (или эквивалент) — расширить:
|
||||
|
||||
```javascript
|
||||
const PROVENANCE_KINDS = ['autonomous', 'user_directed_method', 'user_chose_from_options'];
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4: Run test — verify GREEN**
|
||||
|
||||
```powershell
|
||||
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. Иначе:
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```bash
|
||||
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.32` → `v1.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.kind` ∈ `autonomous` | `user_directed_method`» на «`decision_provenance.kind` ∈ `autonomous` | `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.32` → `v1.33` + prepend короткая note.
|
||||
- §3.6 — в абзаце «Observer schema v2» добавить предложение про 3-й kind.
|
||||
- §9 история — `v2.20` entry.
|
||||
|
||||
- [ ] **Step 5.4: Run cross-ref-checker — verify 0 drift**
|
||||
|
||||
```bash
|
||||
node tools/cross-ref-checker.mjs
|
||||
```
|
||||
|
||||
Expected: 0 drift.
|
||||
|
||||
- [ ] **Step 5.5: Commit Task 5 — atomic normative sync**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```powershell
|
||||
cd app
|
||||
npx vitest run --config vitest.config.tools.mjs
|
||||
```
|
||||
|
||||
Expected: all GREEN.
|
||||
|
||||
- [ ] Cross-ref checker:
|
||||
|
||||
```bash
|
||||
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).
|
||||
Reference in New Issue
Block a user