Compare commits

...

7 Commits

Author SHA1 Message Date
Дмитрий 1114cd1722 docs(brain): brain dashboard implementation plan
13 tasks across 3 phases — static server + topology extraction + 4 views
(Карта / Разбор / Лента / Агрегат). TDD on dashboard-core.js, smoke on UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:04:09 +03:00
Дмитрий 092f55829b docs(brain): brain dashboard design spec
Standalone HTML dashboard that visualises the observer episode log over
the automation-graph topology — 4 views (map / task-replay / session
feed / aggregate), graph as shared canvas, 3-phase build order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:22:05 +03:00
Дмитрий 21f1d7833b chore: .gitattributes — force LF for *.mjs (prevent CRLF/vitest breakage)
core.autocrlf=true rewrites .mjs to CRLF in the working tree on
checkout/rebase. vitest fails to load CRLF .mjs files with imports
(SyntaxError: Invalid or unexpected token, no location) — node --check
and esbuild tolerate it, only vitest's transform breaks. `*.mjs text
eol=lf` pins LF in the working tree regardless of autocrlf.

See memory quirk #100. Repo blobs were already LF — no content change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:05:36 +03:00
Дмитрий 9e1a07aad3 chore(observer): remove 5 empty unknown-* episode stubs + commit session episodes
unknown-<ts>, empty events, fake outcome:success) — zero information.
Removed; remaining episodes carry real data. One-time cleanup of
pre-extension garbage — append-only stays the operational rule.
STATUS.md regenerated by C4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:40:37 +03:00
Дмитрий b2b9a75731 feat(observer): AskUserQuestion in-turn choice + parallel_session narrowing
#1 — detectAskUserQuestionChoice: when a turn contains an AskUserQuestion
whose answer exactly matches an offered option label, classify as
user_chose_from_options. The answered entry carries a structured
toolUseResult (questions[].options[].label + answers map). A custom
"Other" free-text answer is NOT a pick — falls through. Wired into
parseTranscript after the text-list detector.

#3 — parallel_session: dropped broad word matches (параллельн /
"parallel session") that false-fired on any casual mention. Now only
strong collision evidence (foreign git index / чужой staged /
index.lock / another git process). Best-effort per spec R2 — prefer
false-negative over false-positive.

169/169 tools tests GREEN (+9 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:39:09 +03:00
Дмитрий 287332eddf docs: CLAUDE.md header version drift fix — 2.18 -> 2.20
Header «Версия» line lagged at 2.18 while §9 already carried v2.19
(factor-analysis extension) and v2.20 (phase 1.1) entries — pre-existing
drift from f7f37fb. Header now reflects actual latest version; v2.18
summary demoted to «v2.18 наследие». Full per-version detail stays in §9.

Через /claude-md-management:claude-md-improver (§5 п.10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:39:08 +03:00
Дмитрий 8550ba243d fix(observer): exclude synthetic user-role messages from turn detection
Root cause (systematic-debugging): isRealUserPrompt treated skill-content
("Base directory for this skill:"), local-command output
(<local-command-stdout>), and interrupt markers as genuine prompts.
findTurnStart then anchored a turn on the synthetic message — the turn
slice missed the genuine prompt's UserPromptSubmit hook_additional_context
attachment → economy_level: null, wrong prompt_signal/task_classification.
Same cause made extractLastUserPromptText return skill content, so the
Stop-hook routing-gate false-positive-blocked autonomous §12 skill
invocations (detectMethodDirected saw the node name in skill text).

Fix: SYNTHETIC_PROMPT_MARKERS + isSyntheticPrompt — isRealUserPrompt
returns false for synthetic messages. One fix closes both the
economy_level capture gap and the 2nd routing-gate FP class.

160/160 tools tests GREEN (+3 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:39:06 +03:00
11 changed files with 1957 additions and 11 deletions
+5
View File
@@ -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
+1 -1
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -1475,3 +1475,16 @@ DWC
инжектим
фикстурный
роута
# Brain dashboard design spec (2026-05-19)
визуализирующий
анимируются
неподсвеченными
полл
инференс
вендорено
# Brain dashboard implementation plan (2026-05-19)
visualises
AGD
agg
+3 -3
View File
@@ -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).
## Алерт-индикаторы
+16
View File
@@ -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 или вендорено) — уточнить в плане, дашборд переиспользует тот же способ.
+32
View File
@@ -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;
}
+54 -1
View File
@@ -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');
});
});
+32 -6
View File
@@ -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;
+160
View File
@@ -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);
});
});