Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1114cd1722 | |||
| 092f55829b | |||
| 21f1d7833b | |||
| 9e1a07aad3 | |||
| b2b9a75731 | |||
| 287332eddf | |||
| 8550ba243d |
@@ -0,0 +1,5 @@
|
||||
# Normalize line endings for Node ESM tooling files.
|
||||
# Keep LF in the working tree regardless of core.autocrlf — CRLF .mjs files
|
||||
# break vitest module loading (SyntaxError: Invalid or unexpected token,
|
||||
# no file:line). See memory quirk #100 (2026-05-19).
|
||||
*.mjs text eol=lf
|
||||
@@ -1475,3 +1475,16 @@ DWC
|
||||
инжектим
|
||||
фикстурный
|
||||
роута
|
||||
|
||||
# Brain dashboard design spec (2026-05-19)
|
||||
визуализирующий
|
||||
анимируются
|
||||
неподсвеченными
|
||||
полл
|
||||
инференс
|
||||
вендорено
|
||||
|
||||
# Brain dashboard implementation plan (2026-05-19)
|
||||
visualises
|
||||
AGD
|
||||
agg
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-19T08:47:41.763Z
|
||||
Last updated: 2026-05-19T11:22:16.708Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,11 +8,11 @@ Last updated: 2026-05-19T08:47:41.763Z
|
||||
| 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 | ✅ | 18 episode(s), 954 recent commit(s) · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ✅ | 23 episode(s), 982 recent commit(s) · Stop-hook + post-commit OK |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 18 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Observer evidence: 23 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,16 @@
|
||||
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T05:18:16.342Z","ended_at":"2026-05-19T06:05:55.439Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"refactor"},"events":[{"kind":"tool_summary","counts":{"TodoWrite":2,"AskUserQuestion":5}}]}
|
||||
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:07:06.499Z","ended_at":"2026-05-19T06:08:21.424Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[]}
|
||||
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:10:13.713Z","ended_at":"2026-05-19T06:16:11.406Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Write":1,"Bash":2,"Edit":3,"TodoWrite":1}},{"kind":"error","message":"tool_result reported is_error"}]}
|
||||
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:20:40.404Z","ended_at":"2026-05-19T06:23:08.962Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":2,"Read":1,"Edit":2}}]}
|
||||
{"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T06:32:15.034Z","ended_at":"2026-05-19T06:57:02.675Z"},"path_type":"improvised","outcome":"success","primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix"},"events":[{"kind":"tool_summary","counts":{"Read":17,"ToolSearch":1,"Glob":5,"TodoWrite":4,"Grep":14,"Write":1}}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:06:30.059Z","ended_at":"2026-05-19T08:10:43.437Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":82,"parallel_session":true},"task_size":{"tool_calls":12,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"bugfix"},"events":[{"kind":"tool_summary","counts":{"Edit":5,"Read":1,"Bash":4,"TodoWrite":2}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Bash":8,"PostToolUse:Bash":4,"PreToolUse:TodoWrite":2,"PostToolUse:TodoWrite":2},"errors":0},{"kind":"retry"}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:10:44.073Z","ended_at":"2026-05-19T08:13:14.644Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":83,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:13:37.924Z","ended_at":"2026-05-19T08:15:57.442Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"subagent-driven-development"},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":84,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":2,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Read":2,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Read":2,"PostToolUse:Read":2,"PreToolUse:Edit":3,"PostToolUse:Edit":3},"errors":0}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:21:19.146Z","ended_at":"2026-05-19T08:25:57.307Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":86,"parallel_session":false},"task_size":{"tool_calls":1,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"refactor"},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":1}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":1,"PostToolUse:AskUserQuestion":1},"errors":0}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:25:58.145Z","ended_at":"2026-05-19T08:28:19.676Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_directed_method","claude_would_have_chosen":"brainstorming"},"environment":{"economy_level":null,"model":"claude-opus-4-7","post_compaction":true,"session_turn":87,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:29:06.419Z","ended_at":"2026-05-19T08:30:06.086Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":true,"session_turn":88,"parallel_session":false},"task_size":{"tool_calls":2,"files_touched":1,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":1,"Edit":1}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":1,"PostToolUse:Bash":1,"PreToolUse:Edit":1,"PostToolUse:Edit":1},"errors":0}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:34:18.924Z","ended_at":"2026-05-19T08:40:38.461Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":132,"parallel_session":true},"task_size":{"tool_calls":2,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"AskUserQuestion":2}},{"kind":"hook_fired","counts":{"PreToolUse:AskUserQuestion":2,"PostToolUse:AskUserQuestion":2},"errors":0}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T08:43:39.664Z","ended_at":"2026-05-19T08:46:16.416Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"approval","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":133,"parallel_session":true},"task_size":{"tool_calls":6,"files_touched":1,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\superpowers\\specs\\2026-05-19-observer-factor-analysis-design.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Read":1,"Edit":4,"Grep":1}},{"kind":"hook_fired","counts":{"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":8,"PostToolUse:Edit":4,"PreToolUse:Grep":1,"PostToolUse:Grep":1},"errors":0}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T09:21:50.135Z","ended_at":"2026-05-19T09:27:09.498Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":139,"parallel_session":true},"task_size":{"tool_calls":11,"files_touched":3,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\episodes-2026-05.jsonl","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\project_brain_governance_design.md","C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md"]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"feature"},"events":[{"kind":"tool_summary","counts":{"Bash":3,"Read":4,"Edit":4}},{"kind":"error","message":"tool_result reported is_error"},{"kind":"error","message":"tool_result reported is_error"},{"kind":"hook_fired","counts":{"PreToolUse:Bash":6,"PostToolUse:Bash":2,"PreToolUse:Read":4,"PostToolUse:Read":3,"PreToolUse:Edit":8,"PostToolUse:Edit":4},"errors":0},{"kind":"retry"},{"kind":"retry"}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:11:19.381Z","ended_at":"2026-05-19T10:12:06.880Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":140,"parallel_session":true},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"primary_rationale":{"step":1,"node_chosen":"direct","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"question"},"events":[{"kind":"hook_fired","counts":{"Stop":1},"errors":0}]}
|
||||
{"schema_version":2,"task_id":"553717ec-bf55-43dc-8b9c-b9812711023a","task_ref":"553717ec-bf55-43dc-8b9c-b9812711023a","timestamps":{"started_at":"2026-05-19T10:13:02.977Z","ended_at":"2026-05-19T10:24:02.234Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":5,"model":"claude-opus-4-7","post_compaction":true,"session_turn":91,"parallel_session":true},"task_size":{"tool_calls":19,"files_touched":4,"files":["C:\\Users\\Administrator\\.claude\\projects\\c---------------------crm-------------\\553717ec-bf55-43dc-8b9c-b9812711023a.jsonl","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.test.mjs","c:\\моя\\проекты\\портал crm\\Документация\\tools\\observer-transcript-parser.mjs","c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md"]},"primary_rationale":{"step":1,"node_chosen":"superpowers:systematic-debugging","triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"other"},"events":[{"kind":"skill_invoked","skill":"superpowers:systematic-debugging"},{"kind":"skill_invoked","skill":"claude-md-management:claude-md-improver"},{"kind":"tool_summary","counts":{"Skill":2,"Grep":2,"Read":5,"Bash":7,"Edit":3}},{"kind":"hook_fired","counts":{"PreToolUse:Skill":2,"PostToolUse:Skill":2,"PreToolUse:Grep":2,"PostToolUse:Grep":2,"PreToolUse:Read":5,"PostToolUse:Read":5,"PreToolUse:Bash":14,"PostToolUse:Bash":7,"PreToolUse:Edit":6,"PostToolUse:Edit":3},"errors":0}]}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
# Дашборд мозга — дизайн
|
||||
|
||||
**Дата:** 2026-05-19
|
||||
**Статус:** согласован в brainstorming-сессии, готов к writing-plans
|
||||
**Источник:** brainstorming-сессия Claude + Дмитрий
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
`docs/automation-graph.html` («карта мозга») — статический снимок: 124 узла, 130 рёбер, 11 размеченных конфликтов, плюс ручная теплокарта `NODE_META` за фиксированное окно. Карта показывает *топологию на момент*, но не показывает *работу*: как решались задачи, какие узлы задействованы, где возникли ошибки и ретраи, куда перенаправлялось, где сталкивались узлы.
|
||||
|
||||
Журнал исполнения уже существует — `docs/observer/episodes-YYYY-MM.jsonl`: наблюдатель (ADR-011) пишет одну запись на каждый ход (Stop-событие). В записи — выбранный узел, инструменты, ошибки, ретраи, перенаправления, hard-floor, классификация задачи, окружение. **Данные есть — нет визуализации.** Карта и журнал между собой не связаны.
|
||||
|
||||
## 2. Цель и не-цели
|
||||
|
||||
**Цель:** standalone-дашборд, визуализирующий журнал эпизодов поверх топологии карты — три способа смотреть на работу мозга плюс сама карта.
|
||||
|
||||
**В scope:**
|
||||
|
||||
- Чтение `episodes-*.jsonl` (схема v1 и v2).
|
||||
- Четыре view: Карта, Разбор задачи, Лента сессии, Агрегат.
|
||||
- Граф как общий холст для траекторий и тепла.
|
||||
- Три слоя «конфликтов» (см. §6).
|
||||
|
||||
**Не-цели (YAGNI):**
|
||||
|
||||
- Не меняем формат эпизодов и логику наблюдателя (исключение — отдельная задача, §13).
|
||||
- Не правим `/brain-retro` и контролёры.
|
||||
- Не пиксель-полировка — Forest накладывается на этапе реализации (frontend-design).
|
||||
- Нет истории до запуска наблюдателя — её физически нет.
|
||||
- Не Vue/Vuetify-приложение — это dev-инструмент, zero-build (см. §3).
|
||||
|
||||
## 3. Зафиксированные решения
|
||||
|
||||
**Тех-модель: standalone HTML + локальный статик-сервер.** Один HTML-файл без сборки; читает свежий JSONL через статик-сервер (`fetch` с `file://` браузер блокирует). Сервер — ~20 строк на `node:http`, запуск npm-командой, гасится по Ctrl-C. Ноль новых npm-зависимостей, ноль постоянных демонов. Отвергнуты: «запекаемый файл» (генератор вшивает данные — лента не живая) и «Vue/Vuetify-приложение» (сборка + стек портала в dev-инструменте; vis.js всё равно не Vue).
|
||||
|
||||
**Раскладка: граф баннером сверху.** Постоянная полоса графа сверху, рабочая зона view снизу; переключатель view меняет нижнюю зону. Граф всегда виден; широкие таблицы и ленты снизу.
|
||||
|
||||
## 4. Архитектура
|
||||
|
||||
### 4.1. Слой данных
|
||||
|
||||
Источник — `docs/observer/episodes-YYYY-MM.jsonl`, append-only, одна строка = один эпизод.
|
||||
|
||||
Сервер `tools/brain-dashboard-server.mjs`:
|
||||
|
||||
- статика из корня репо (HTML, JS, JSONL, `automation-graph.html`, vis.js);
|
||||
- эндпоинт `GET /api/episodes` → JSON-список имён файлов `docs/observer/episodes-*.jsonl` (дашборд не угадывает имена);
|
||||
- больше ничего; только localhost.
|
||||
|
||||
Парсер (JS, внутри дашборда):
|
||||
|
||||
- читает каждую строку JSONL → объект эпизода;
|
||||
- нормализует **v1** (строки без `schema_version` — нет `decision_provenance` / `environment` / `task_size` / `prompt_signal`, `outcome` уже проставлен) и **v2** (`schema_version: 2`);
|
||||
- битые строки и строки-маркеры `observer_error` — пропускаются, ведётся счётчик «N пропущено»;
|
||||
- результат — нормализованный массив эпизодов единой формы (отсутствующие v1-поля → `null`).
|
||||
|
||||
Производные данные (тепло, кластеры, агрегаты) считаются в браузере при загрузке. Ноль вшитых данных → всегда свежо.
|
||||
|
||||
### 4.2. Карта = общий холст
|
||||
|
||||
Сейчас топология зашита константами внутри `automation-graph.html` (один файл ≈2900 строк): `NODES` (стр. 229), `EDGES` (стр. 418), секции, `CONFLICT`-данные (стр. ≈406–614), `NODE_META` (стр. 1898), `NODE_SECTION` (стр. 2155).
|
||||
|
||||
**Рефактор-вынос:** константы топологии (`NODES`, `EDGES`, `SECTIONS`, `CONFLICT`-данные, `NODE_SECTION`) выносятся в `docs/automation-graph-data.js`. Старая карта `<script src>`-ит его — поведение и вид не меняются (подтвердить визуальным smoke-тестом). `NODE_META` (ручная теплокарта) **остаётся в старой карте** — дашборд её не использует, он считает тепло из эпизодов.
|
||||
|
||||
Дашборд импортирует `automation-graph-data.js` и строит **свой** экземпляр vis.js-графа в баннере. Этот граф управляемый — на нём анимируются траектории (Разбор) и красится тепло (Агрегат). Iframe старой карты отвергнут: чужой iframe нельзя анимировать снаружи.
|
||||
|
||||
Вкладка «Карта» = тот же граф дашборда в режиме «без оверлея». Файл `automation-graph.html` продолжает существовать как самостоятельная голая карта.
|
||||
|
||||
### 4.3. Атрибуция узлов (честная)
|
||||
|
||||
Граф — 124 узла (MCP-серверы, плагины, скилы, инструменты, секции). Эпизод даёт сигналы об узлах:
|
||||
|
||||
- `primary_rationale.node_chosen` — чаще всего `"direct"`, иногда id скила (`"superpowers:systematic-debugging"`);
|
||||
- события `skill_invoked` — id скилов;
|
||||
- `tool_summary.counts` — имена встроенных инструментов Claude (`Read`, `Edit`, `Bash`, `Grep`, …) и `Skill` / `ToolSearch`.
|
||||
|
||||
Маршрутизируемый словарь наблюдателя — `tools/observer-known-nodes.txt` (~22 имени: 13 superpowers-скилов + 7 проектных + 2 плагина/команды). Это **меньше** 124 узлов графа и пересекается с ними частично.
|
||||
|
||||
**Решение:** дашборд держит таблицу соответствия `сигнал эпизода → id узла графа`. Подсвечивает узлы, для которых соответствие есть. Узлы без соответствия (встроенные инструменты Claude, большинство MCP/плагинов, которые эпизоды пока не называют) **остаются неподсвеченными** — это ожидаемо и подписано в UI («атрибутировано N из M сигналов»). Полнее станет, когда роутер начнёт писать `node_chosen` детальнее — вне scope этой задачи.
|
||||
|
||||
## 5. Четыре view
|
||||
|
||||
Общий каркас (раскладка из §3): сверху переключатель view + граф-баннер; снизу — рабочая зона.
|
||||
|
||||
### 5.1. Карта
|
||||
|
||||
Граф без оверлея: топология + 11 размеченных дизайн-конфликтов (цвета RED / BLACK / GREEN из `CONFLICT_TYPES`). Фильтры по секциям/типам — переносятся из старой карты по возможности (или минимальный набор). Это «нулевое состояние» холста.
|
||||
|
||||
### 5.2. Разбор задачи (ретроспектива)
|
||||
|
||||
Нижняя зона: слева список эпизодов (фильтр: дата, `task_classification`, `outcome`, `path_type`, наличие ошибок); справа — детали выбранного.
|
||||
|
||||
Выбор эпизода → траектория:
|
||||
|
||||
- на графе подсвечиваются атрибутированные узлы (§4.3);
|
||||
- справа — упорядоченный список событий эпизода (`skill_invoked` / `error` / `retry` / `hook_fired` / `interrupt` / `time_burn`), плюс шапка: классификация, `path_type`, `decision_provenance` (если `user_directed_method` — «перенаправление: выбран X, автономно был бы Y»), `hard_floor`, окружение (`economy_level`, `model`, `post_compaction`, `session_turn`, `parallel_session`), `task_size`.
|
||||
|
||||
Честно: внутри хода есть упорядоченный список событий, но не каждый tool-вызов по порядку — `tool_summary` даёт только счётчики. «Траектория» = последовательность событий + сводка инструментов.
|
||||
|
||||
### 5.3. Лента сессии (живая)
|
||||
|
||||
Нижняя зона: одноколоночный поток эпизодов, сгруппированных по `task_id` / `task_ref`, новый ход сверху. Карточка хода: время, классификация, `path_type`, атрибутированный узел, ошибки/ретраи (бейджи), длительность (`ended_at − started_at`), флаг перенаправления.
|
||||
|
||||
Автоопрос: дашборд раз в N секунд (по умолчанию 5) перезапрашивает `/api/episodes` + текущий месячный JSONL, дописывает новые строки. Полл — только в этом view. Кнопка пауза/возобновить.
|
||||
|
||||
### 5.4. Агрегат (тренды)
|
||||
|
||||
Нижняя зона: плитки метрик по всем эпизодам:
|
||||
|
||||
- тепло узлов (авто — сколько раз каждый атрибутированный узел встречался); красит граф-баннер;
|
||||
- горячие точки ошибок/ретраев (узлы и классы задач с наибольшей долей `error` / `retry`);
|
||||
- доля перенаправлений (`decision_provenance.kind == "user_directed_method"`);
|
||||
- распределения `economy_level`, `path_type` (improvised/regulated), `task_classification`, `outcome`;
|
||||
- счётчик `observer_error` и пропущенных строк.
|
||||
|
||||
Опциональный reuse: для v2-эпизодов с `outcome: "unknown"` — переиспользовать детерминированный inference из `tools/brain-retro-analyzer.mjs`, если он оформлен импортируемым модулем; иначе показывать `unknown`. Решается в плане.
|
||||
|
||||
## 6. Конфликты — три слоя
|
||||
|
||||
Запрос — «где конфликты среди узлов». Эпизоды не пишут «узел A столкнулся с узлом B» — только `error` / `retry` / `hook_fired.errors`. Дашборд отдаёт три явно подписанных слоя:
|
||||
|
||||
1. **Дизайн-конфликты** — 11 размеченных `CONFLICT`-рёбер карты (факт, из топологии).
|
||||
2. **Трение** — эпизоды с `error`/`retry`, привязанные к атрибутированным в них узлам. Это инференс («во время хода с этим узлом была ошибка»), не доказанный конфликт. Подписано.
|
||||
3. **Корреляция** — эпизод с ошибкой, где атрибутированы два узла, между которыми есть `CONFLICT`-ребро → «конфликт мог реализоваться». Эвристика. Подписано.
|
||||
|
||||
Настоящего лога «узел×узел» нет. См. §13.
|
||||
|
||||
## 7. Раскладка
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [Карта] [Разбор] [Лента] [Агрегат] │ переключатель view
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ГРАФ — баннер (vis.js) │ ~40% высоты, всегда виден
|
||||
│ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ рабочая зона view (меняется) │ ~60% высоты
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Граф-баннер общий для всех view; рабочая зона своя у каждого. Forest-палитра (Teal `#0F6E56`, ivory `#F6F3EC`, теало-нуар `#012019`), Inter / JetBrains Mono — накладываются на этапе реализации как CSS-переменные.
|
||||
|
||||
## 8. Файлы и компоненты
|
||||
|
||||
| Файл | Назначение | Статус |
|
||||
|---|---|---|
|
||||
| `docs/observer/dashboard.html` | каркас дашборда (раскладка из §3/§7, vis.js-граф) | новый |
|
||||
| `docs/observer/dashboard.js` | парсер JSONL + агрегатор + 4 view + рендер графа | новый |
|
||||
| `docs/automation-graph-data.js` | вынесенная топология (`NODES` / `EDGES` / `SECTIONS` / `CONFLICT` / `NODE_SECTION`) | новый (вынос) |
|
||||
| `docs/automation-graph.html` | `<script src>` на data-файл; остальное без изменений | правка |
|
||||
| `tools/brain-dashboard-server.mjs` | статик-сервер + `/api/episodes` | новый |
|
||||
| `package.json` | скрипт `brain:dashboard` (запуск сервера + открытие браузера) | правка |
|
||||
| `tools/brain-dashboard-*.test.mjs` | тесты парсера / агрегатора / сервера | новый |
|
||||
|
||||
`dashboard.js` при росте можно разбить на модули (`parser.js`, `aggregate.js`, `graph.js`, `views/*.js`) — решается в плане по фактическому размеру.
|
||||
|
||||
## 9. Обработка ошибок и граничные случаи
|
||||
|
||||
- Битая JSONL-строка / `observer_error`-маркер → пропуск, инкремент счётчика, показ счётчика в UI.
|
||||
- Месячный файл отсутствует или пуст → не ошибка.
|
||||
- Эпизодов нет вообще → дружелюбное пустое состояние.
|
||||
- v1-эпизод (нет v2-полей) → недостающие поля `null`, UI показывает «—».
|
||||
- Сервер не запущен → дашборд физически не откроется (он отдаётся этим же сервером); пустой `/api/episodes` → пустое состояние.
|
||||
- `automation-graph-data.js` не загрузился → пустой граф + явное сообщение.
|
||||
|
||||
## 10. Тестирование
|
||||
|
||||
- **TDD** на чистую логику (`dashboard.js` — парсер, нормализация v1/v2, агрегатор, атрибуция узлов, инференс конфликтов): `tools/brain-dashboard-*.test.mjs` на `node:test`, failing-first → GREEN. Паттерн — как существующие `tools/*.test.mjs`.
|
||||
- Сервер `brain-dashboard-server.mjs` — smoke-тест (поднять, дёрнуть `/api/episodes`, проверить отдачу статики).
|
||||
- Вынос топологии — визуальный smoke старой карты (Playwright или ручной): карта выглядит и фильтруется как до выноса.
|
||||
- Рендер view и графа — ручной визуальный smoke в браузере.
|
||||
|
||||
## 11. Честные ограничения
|
||||
|
||||
1. **Гранулярность — один эпизод на ход.** Внутри хода есть упорядоченный список событий, но не каждый tool-вызов по порядку (только счётчики `tool_summary`).
|
||||
2. **«Живость» — после Stop, не в процессе.** Лента обновляется после завершения хода (+ задержка автоопроса), не пока ход идёт.
|
||||
3. **Атрибуция узлов частичная** — `node_chosen` чаще `direct`; словарь наблюдателя ~22 имени против 124 узлов графа. Бóльшая часть графа не подсвечивается. См. §4.3.
|
||||
4. **Конфликты узел×узел не логируются** — даётся инференс (§6), не факт.
|
||||
5. **История — только с запуска наблюдателя (~19.05.2026).** Раньше эпизодов нет.
|
||||
|
||||
## 12. Порядок сборки (3 фазы, один спек)
|
||||
|
||||
- **Фаза 1 — фундамент.** Статик-сервер + `/api/episodes`; вынос топологии в `automation-graph-data.js` + правка старой карты + визуальный smoke; каркас `dashboard.html`; парсер v1/v2 + атрибуция узлов (TDD); граф-баннер; view «Карта» + view «Разбор задачи»; npm-скрипт `brain:dashboard`.
|
||||
- **Фаза 2 — живость.** View «Лента сессии» + автоопрос/пауза.
|
||||
- **Фаза 3 — агрегат.** View «Агрегат» + тепло на граф-баннер + три слоя конфликтов (§6); опц. reuse `brain-retro-analyzer.mjs` для outcome-инференса.
|
||||
|
||||
## 13. Открытые вопросы / отложено
|
||||
|
||||
- **Настоящий лог конфликтов узел×узел** — потребует нового типа события в наблюдателе (`tools/observer-transcript-parser.mjs` + схема эпизода). Отдельная задача, не входит в этот спек.
|
||||
- **Двойной клик без сервера** — если когда-нибудь понадобится: добавить генератору запекание данных, файл выродится в тех-модель «запекаемый файл» без переписывания. Сейчас YAGNI.
|
||||
- **Forest-полировка** — этап реализации (frontend-design).
|
||||
- **vis.js** — откуда его берёт старая карта (CDN или вендорено) — уточнить в плане, дашборд переиспользует тот же способ.
|
||||
@@ -116,3 +116,35 @@ export function detectChoiceProvenance(promptText, lastAssistantContent) {
|
||||
claude_would_have_chosen: options[0],
|
||||
};
|
||||
}
|
||||
|
||||
// ── detectAskUserQuestionChoice ───────────────────────────────────────────────
|
||||
// In-turn choice via the AskUserQuestion tool. The answered entry carries a
|
||||
// structured `toolUseResult` with questions[].options[].label + an answers map.
|
||||
// Only an exact label match counts — a custom "Other" free-text answer is the
|
||||
// user steering, not a pick from offered options.
|
||||
|
||||
export function detectAskUserQuestionChoice(turnEntries) {
|
||||
if (!Array.isArray(turnEntries)) return null;
|
||||
for (let i = turnEntries.length - 1; i >= 0; i--) {
|
||||
const tur = turnEntries[i] && turnEntries[i].toolUseResult;
|
||||
if (!tur || !Array.isArray(tur.questions) || !tur.answers || typeof tur.answers !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const q = tur.questions[0];
|
||||
if (!q || !Array.isArray(q.options)) continue;
|
||||
const options = q.options.map((o) => o && o.label).filter((l) => typeof l === 'string');
|
||||
if (options.length < 2) continue;
|
||||
const answer = tur.answers[q.question];
|
||||
if (typeof answer !== 'string') continue;
|
||||
const a = answer.trim();
|
||||
const matched = options.find((o) => o.trim() === a);
|
||||
if (!matched) continue;
|
||||
return {
|
||||
kind: 'user_chose_from_options',
|
||||
node: matched,
|
||||
options_offered: options.slice(),
|
||||
claude_would_have_chosen: options[0],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractOptions, detectReference, detectChoiceProvenance } from './observer-choice-detector.mjs';
|
||||
import {
|
||||
extractOptions,
|
||||
detectReference,
|
||||
detectChoiceProvenance,
|
||||
detectAskUserQuestionChoice,
|
||||
} from './observer-choice-detector.mjs';
|
||||
|
||||
describe('extractOptions', () => {
|
||||
it('returns null when content has fewer than 2 options', () => {
|
||||
@@ -164,3 +169,51 @@ describe('detectChoiceProvenance', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAskUserQuestionChoice', () => {
|
||||
const answeredEntry = (answer) => ({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: [{ type: 'tool_result', content: 'User has answered...' }] },
|
||||
toolUseResult: {
|
||||
questions: [
|
||||
{
|
||||
question: 'Каким режимом?',
|
||||
options: [
|
||||
{ label: 'Subagent-Driven (recommended)' },
|
||||
{ label: 'Inline execution' },
|
||||
{ label: 'Ничего не исполнять' },
|
||||
],
|
||||
},
|
||||
],
|
||||
answers: { 'Каким режимом?': answer },
|
||||
},
|
||||
});
|
||||
|
||||
it('returns null for non-array input', () => {
|
||||
expect(detectAskUserQuestionChoice(null)).toBeNull();
|
||||
expect(detectAskUserQuestionChoice('x')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when turn has no AskUserQuestion toolUseResult', () => {
|
||||
expect(detectAskUserQuestionChoice([{ type: 'assistant', message: { role: 'assistant' } }])).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies a genuine option click (answer exactly equals a label)', () => {
|
||||
expect(detectAskUserQuestionChoice([answeredEntry('Inline execution')])).toEqual({
|
||||
kind: 'user_chose_from_options',
|
||||
node: 'Inline execution',
|
||||
options_offered: ['Subagent-Driven (recommended)', 'Inline execution', 'Ничего не исполнять'],
|
||||
claude_would_have_chosen: 'Subagent-Driven (recommended)',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when answer is custom free-text (Other), not an offered label', () => {
|
||||
expect(detectAskUserQuestionChoice([answeredEntry('давай обсудим все 3 варианта подробнее')])).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the last answered AskUserQuestion in the turn', () => {
|
||||
const first = answeredEntry('Subagent-Driven (recommended)');
|
||||
const last = answeredEntry('Inline execution');
|
||||
expect(detectAskUserQuestionChoice([first, { type: 'assistant' }, last]).node).toBe('Inline execution');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* Per ADR-011 §6 + spec v1.1 §5.2.1.
|
||||
*/
|
||||
|
||||
import { detectChoiceProvenance } from './observer-choice-detector.mjs';
|
||||
import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs';
|
||||
|
||||
const SUPERPOWERS_PREFIX = 'superpowers:';
|
||||
|
||||
@@ -36,16 +36,40 @@ function parseLines(text) {
|
||||
return { entries, broken, total };
|
||||
}
|
||||
|
||||
// A genuine user prompt (turn boundary) — not a tool_result carrier message.
|
||||
// Synthetic user-role messages — NOT genuine prompts, must not be turn boundaries.
|
||||
// Skill invocation content, local slash-command output/invocation, interrupt markers
|
||||
// are recorded with role:'user' but carry no UserPromptSubmit hook context.
|
||||
const SYNTHETIC_PROMPT_MARKERS = [
|
||||
'Base directory for this skill:',
|
||||
'<local-command-stdout>',
|
||||
'<local-command-caveat>',
|
||||
'<command-name>',
|
||||
'[Request interrupted by user]',
|
||||
];
|
||||
|
||||
function isSyntheticPrompt(text) {
|
||||
const t = String(text || '').trimStart();
|
||||
return SYNTHETIC_PROMPT_MARKERS.some((m) => t.startsWith(m));
|
||||
}
|
||||
|
||||
// A genuine user prompt (turn boundary) — not a tool_result carrier nor a
|
||||
// synthetic skill/command/interrupt message.
|
||||
function isRealUserPrompt(entry) {
|
||||
const msg = entry && entry.message;
|
||||
if (!msg || msg.role !== 'user') return false;
|
||||
const c = msg.content;
|
||||
if (typeof c === 'string') return c.trim().length > 0;
|
||||
if (typeof c === 'string') {
|
||||
return c.trim().length > 0 && !isSyntheticPrompt(c);
|
||||
}
|
||||
if (Array.isArray(c)) {
|
||||
const hasToolResult = c.some((b) => b && b.type === 'tool_result');
|
||||
const hasText = c.some((b) => b && b.type === 'text');
|
||||
return hasText && !hasToolResult;
|
||||
if (!hasText || hasToolResult) return false;
|
||||
const text = c
|
||||
.filter((b) => b && b.type === 'text')
|
||||
.map((b) => b.text || '')
|
||||
.join(' ');
|
||||
return !isSyntheticPrompt(text);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -136,7 +160,9 @@ export function extractEnvironment(allEntries, turnStartIdx) {
|
||||
if (isRealUserPrompt(allEntries[i])) session_turn += 1;
|
||||
}
|
||||
|
||||
const parallel_session = /параллельн|parallel session|чужой staged|foreign git index/i.test(rawTurn);
|
||||
// Only strong collision evidence — a bare mention of "parallel sessions" is
|
||||
// not a signal (best-effort per spec R2; prefer false-negative over false-positive).
|
||||
const parallel_session = /чужой staged|foreign git index|index\.lock|another git process/i.test(rawTurn);
|
||||
|
||||
return { economy_level, model, post_compaction, session_turn, parallel_session };
|
||||
}
|
||||
@@ -326,7 +352,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) {
|
||||
const prompt = promptText(entries[start]);
|
||||
|
||||
const lastAsstContent = extractLastAssistantContent(entries, start);
|
||||
const choice = detectChoiceProvenance(prompt, lastAsstContent);
|
||||
const choice = detectChoiceProvenance(prompt, lastAsstContent) || detectAskUserQuestionChoice(turn);
|
||||
let decision_provenance;
|
||||
if (choice) {
|
||||
decision_provenance = choice;
|
||||
|
||||
@@ -502,3 +502,163 @@ describe('parseTranscript — user_chose_from_options', () => {
|
||||
expect(ep.decision_provenance.kind).toBe('user_directed_method');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscript — synthetic user messages skipped', () => {
|
||||
it('captures economy_level from genuine prompt even when skill-content message follows', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:00.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: 'почини баг в парсере' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'attachment',
|
||||
timestamp: '2026-05-19T10:00:00.500Z',
|
||||
sessionId: 's1',
|
||||
attachment: {
|
||||
type: 'hook_additional_context',
|
||||
hookName: 'UserPromptSubmit',
|
||||
content: ['=== ECONOMY MODE: 5% (тест) ===\nинструкции режима...'],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-05-19T10:00:01.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'смотрю' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:02.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: 'Base directory for this skill: C:\\path\\skill\n\n# Some Skill\nbody' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-05-19T10:00:03.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] },
|
||||
}),
|
||||
].join('\n');
|
||||
const ep = parseTranscript(lines, 'fallback');
|
||||
expect(ep.environment.economy_level).toBe(5);
|
||||
expect(ep.primary_rationale.task_classification).toBe('bugfix');
|
||||
});
|
||||
|
||||
it('extractLastUserPromptText skips skill-content and returns genuine prompt', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:00.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: 'добавь колонку Город' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:02.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: 'Base directory for this skill: C:\\x\n# Skill body' },
|
||||
}),
|
||||
].join('\n');
|
||||
expect(extractLastUserPromptText(lines)).toBe('добавь колонку Город');
|
||||
});
|
||||
|
||||
it('extractLastUserPromptText skips local-command output and interrupt markers', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:00.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: 'сделай X' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:01.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: '[Request interrupted by user]' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:02.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: '<local-command-stdout>some output</local-command-stdout>' },
|
||||
}),
|
||||
].join('\n');
|
||||
expect(extractLastUserPromptText(lines)).toBe('сделай X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTranscript — AskUserQuestion in-turn choice', () => {
|
||||
it('classifies user_chose_from_options when an AskUserQuestion option was clicked', () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:00.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: 'построй фичу' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-05-19T10:00:01.000Z',
|
||||
sessionId: 's1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', name: 'AskUserQuestion', input: {} }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: '2026-05-19T10:00:05.000Z',
|
||||
sessionId: 's1',
|
||||
message: { role: 'user', content: [{ type: 'tool_result', content: 'User has answered...' }] },
|
||||
toolUseResult: {
|
||||
questions: [
|
||||
{
|
||||
question: 'Каким режимом?',
|
||||
options: [{ label: 'Subagent-Driven' }, { label: 'Inline execution' }],
|
||||
},
|
||||
],
|
||||
answers: { 'Каким режимом?': 'Inline execution' },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: '2026-05-19T10:00:06.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: 'Inline execution',
|
||||
options_offered: ['Subagent-Driven', 'Inline execution'],
|
||||
claude_would_have_chosen: 'Subagent-Driven',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEnvironment — parallel_session narrowed', () => {
|
||||
const wrap = (text) => [
|
||||
{
|
||||
message: { role: 'user', content: text },
|
||||
timestamp: '2026-05-19T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
it('is false when text only casually mentions parallel sessions', () => {
|
||||
const env = extractEnvironment(wrap('давай обсудим фичу параллельные сессии и parallel session coordination'), 0);
|
||||
expect(env.parallel_session).toBe(false);
|
||||
});
|
||||
|
||||
it('is true on a real collision marker (foreign git index)', () => {
|
||||
const env = extractEnvironment(wrap('git commit поймал foreign git index'), 0);
|
||||
expect(env.parallel_session).toBe(true);
|
||||
});
|
||||
|
||||
it('is true on a real collision marker (чужой staged)', () => {
|
||||
const env = extractEnvironment(wrap('в коммит попал чужой staged-файл'), 0);
|
||||
expect(env.parallel_session).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user