diff --git a/personal/plans/balda-callee-prism-plan.md b/personal/plans/balda-callee-prism-plan.md new file mode 100644 index 00000000..dd0f4281 --- /dev/null +++ b/personal/plans/balda-callee-prism-plan.md @@ -0,0 +1,396 @@ +# Балда → Callee + Prism: план перестройки делегирования + +> Создан 2026-09-28. Статус: **ПЛАН, работа не начата.** +> Заменяет собой схему «мост :8790 → `claude -p` по подписке» из +> [[personal/plans/balda-mm-phase4-agents]] (Фаза 4). +> Причина: подписочный CLI требует ручного логина в контейнере (`Not logged in`, +> проверено 2026-09-28) — блокер снимается переходом на документированную +> архитектуру callee, где провайдер **рендерится из состояния**, а не логинится. + +--- + +## 0. Что решено (и на чём основано) + +Alex 2026-09-28: «Давай пока на deepseek с обоих концов соберём, потом будем с +колексом решать» + «надо приколбасить prism для таск-трекинга» + «Ты скажи план». + +| # | Решение | Источник | +|---|---|---| +| 1 | Оба конца — DeepSeek | команда Alex | +| 2 | Оркестрация — **callee**, не самодельная | документация callee (см. §1) | +| 3 | Таск-трекинг — **prism** + Beads (`bd`) | задание Alex | +| 4 | Мост `:8790` — сохранить, перевести с `claude -p` на `callee agent run` | мой выбор, обоснован в §4 | +| 5 | Host coding agent — **`callee` CLI в контейнере `balda-executor`** | мой выбор, обоснован в §4 | + +--- + +## 1. Что говорит документация (факты, проверено 2026-09-28) + +Источники: `baldaworks/callee` (README + `docs/concepts/architecture.md`, +`docs/guides/acp-providers.md`, `docs/agent-kinds/dynamic-role.md`), +`baldaworks/prism`, `baldaworks/prism-callee`. Версия callee — **0.22.2** (npm). + +### 1.1 Кто чем владеет — разделение жёсткое + +``` +Markdown/YAML ресурсы + → decode + schema/semantic/template validation + → registry + static graph resolution ← отвергает циклы, дубли ID, битые ссылки + → resolved root tree + required parameter keys + → workflow runner + ОДИН общий ephemeral state-объект + ├→ Role / DynamicRole → Norma Runtime → ACP-процесс → свежая сессия + ├→ Script → локальный shell + ├→ Human → управляющий терминал + └→ TypeSafeJev / OpenRouterDecision → HTTP API + → ОДИН корневой артефакт в stdout +``` + +| Слой | Владелец | Цитата | +|---|---|---| +| Граф воркфлоу, валидация, state | **callee** | «validates the whole graph before execution, carries one explicit state object, returns one final artifact» | +| Durable-состояние + одобрение | **coding agent** | «The coding agent owns durable state and approval» (prism-callee) | +| Исполнение модели | ACP-процесс | «Role/DynamicRole → Norma Runtime → ACP provider process → fresh session» | + +### 1.2 🔴 DeepSeek в списке ACP-провайдеров ОТСУТСТВУЕТ + +Поддерживаемые типы — **закрытый список**: + +| `type` | Команда по умолчанию | Предусловие | +|---|---|---| +| `codex` | сам callee + `bridge codex` | установленный и авторизованный Codex CLI | +| `claude` | `npx -y @zed-industries/claude-code-acp@latest` | Node + npx + креды адаптера | +| `opencode` | `opencode acp` | установленный OpenCode | +| `copilot` | `copilot --acp --stdio` | установленный Copilot CLI | +| `grok` | `grok agent stdio` | Grok CLI или `XAI_API_KEY` | +| `cursor` | `agent acp` | установленный Cursor CLI | +| `generic_acp` | **нет — требует непустой `cmd`** | любой ACP-исполняемый файл | + +> «The supported type list is closed. **Gemini is not a valid provider type.**» + +**Следствие:** «DeepSeek с обоих концов» напрямую через ACP-тип **не выражается**. +Рабочий путь — **`generic_acp` + свой ACP-адаптер к DeepSeek** в `cmd`. +Это ключевая развилка плана и главный риск (§7). + +### 1.3 DynamicRole — механизм, который снимает наш блокер + +Все поля провайдера — **шаблоны**, рендерятся из состояния в момент визита: + +```yaml +kind: DynamicRole +spec: + provider: + type: '{{ .State.provider }}' + model: '{{ .State.model }}' + reasoning: '{{ .State.reasoning }}' + timeout: '{{ .State.timeout }}' + state: + provider: codex + model: gpt-5.6-sol + reasoning: high + timeout: 20m +``` + +| Факт | Значение | +|---|---| +| Что рендерится | `type`, `cmd`, `model`, `reasoning`, `mode`, `timeout`, каждый `extraArgs` | +| Из чего | `.Prompt`, текущий `.Input`, **один** согласованный post-node снапшот `.State`, `.Params` | +| Чего **нельзя** | `.Output`, env, файловая система, сеть, часы, рандом, криптография, мутаторы | +| Когда валидируется | до поиска и старта процесса; ошибка → визит падает, провайдер не стартует | +| Периметр визита | конфиг фиксирован на весь визит, включая REPL-ходы; новый визит Loop рендерит заново | +| Переиспользование процесса | одинаковые effective type+cmd → один процесс; **сессия всегда новая** | + +🔴 **Безопасность (цитата):** «State-driven `cmd` and `extraArgs` can select executable +code. Do not copy untrusted model output into them.» — `cmd` это **одно имя исполняемого +файла**, `extraArgs` — argv-список; shell не участвует. + +### 1.4 Callee и prism — НЕЗАВИСИМЫЕ установки + +> «Each repository installs and validates independently — installing one plugin does +> not install the other.» + +| | `baldaworks/prism` | `baldaworks/prism-callee` | +|---|---|---| +| Что это | плагин в coding agent (Codex/Claude/Grok/Copilot/Cursor) | callee-агенты для тех же воркфлоу | +| Требования | coding agent + `bd` (Beads) | coding agent + `bd` + **callee ≥ 0.19.0** | +| Команды | `$prism:story`, `$prism:epic`, `$prism:lifecycle` | `$prism-callee:lifecycle` | +| Состояние | **Beads (`bd`)** | **Beads (`bd`)** | +| Импорт агентов | — | `callee agent import baldaworks/prism-callee --path pack/callee/prism --prefix prism` | + +**Проверка валидности импорта:** `callee agent view prism/lifecycle --json` должен +вернуть **kind `Router`**. Если `Sequential` — импорт устарел, обновлять `--force`. + +### 1.5 Prism — жизненный цикл Story/Epic (это и есть таск-трекинг) + +**Story:** Change intent → Specify → (clarify/ready) → Design → Breakdown → +**Human approval** → Apply and review → Verify → Verified change. +Фазы: `phase:story:specify` → `design` → `breakdown` → `human` → `apply` → `verify`. + +**Epic:** Frame → Architecture → Roadmap → **approval** → Deliver Stories → Validation. +Фазы: `phase:epic:frame` → `architecture` → `roadmap` → `approval` → `delivery` → `validation`. + +| Правило | Значение | +|---|---| +| Дети Epic | только **Stories** | +| Дети Story | только **Tasks** | +| Вложенные Epic / Task напрямую в Epic | **невалидно** | +| Фиксированного диапазона числа детей | **нет** — оценка качественная | +| `Apply` | 🔴 **каждый переход требует явной авторизации человека** | +| Одобрение Epic → Story | **не переносится** («approval never transfers from an Epic to a Story») | + +--- + +## 2. Целевая архитектура + +``` +Mattermost (Alex) + │ «добавь CSV-экспорт в отчёт» + ▼ +Балда-оркестратор (Kraken, DeepSeek, balda-mm:0.1.13) + │ верхний уровень: приём сообщения, чтение Obsidian, контекст, ответ в чат + │ MCP-инструмент executor_run + ▼ +MCP-сервер balda-executor-mcp (Kraken, :8890, сеть mattermost_mm-net) + │ HTTP + X-Bridge-Secret + ▼ +Мост agents-bridge (Mac, :8790, launchd com.agents.bridge) + │ 🔴 МЕНЯЕТСЯ: было `claude -p --output-format text` + │ стало `callee agent run --message ` + ▼ +callee CLI (контейнер balda-executor, Mac) + │ компилирует граф → валидирует ЦЕЛИКОМ до запуска + │ общий state-объект через весь прогон → ОДИН артефакт в stdout + ├→ Role / DynamicRole → ACP-процесс → DeepSeek (generic_acp + адаптер) + ├→ Script → локальные детерминированные проверки + └→ Human → точка одобрения + ▼ +Beads (`bd`) — durable-состояние Story/Epic, переживает сессии + ▼ +артефакт → Балда → Mattermost +``` + +### Разделение расходов + +| Роль | Где | Провайдер | Стоимость | +|---|---|---|---| +| Оркестратор верхнего уровня | Kraken | DeepSeek `deepseek-chat` | дёшево | +| Исполнитель-роли callee | Mac, контейнер | DeepSeek (через адаптер) | дёшево | +| Позже | — | Codex (подписка) | решается после | + +📌 Формулировка «DeepSeek с обоих концов» = **обе стороны на DeepSeek**, подписочный +Codex подключается **позже** заменой `State.provider` в DynamicRole — без правки графа. + +--- + +## 3. Фазы работ + +### Фаза C0 — предварительная проверка (30-40 мин) + +Цель: понять, существует ли готовый ACP-адаптер к DeepSeek. Если да — план короче +на целую фазу. + +1. Разведка: есть ли ACP-адаптер, принимающий OpenAI-совместимый endpoint. + Кандидаты для проверки: `@normahq/*acp*`, `@zed-industries/*acp*`, npm-поиск по `acp`. +2. Если готового нет — оценить объём своего (см. C2). +3. Проверить актуальную версию callee и что `0.22.2` умеет `generic_acp` + (документация говорит «да», версия из npm — свежая). + +**Гейт:** решение «адаптер готов / пишем свой» зафиксировать в этом доке до C2. + +### Фаза C1 — установка на Mac (1-2 ч) + +Всё ставится **в контейнер `balda-executor`** (там Node 24 — родная среда callee). +Проверено сейчас: ни `callee`, ни `bd` нет ни в контейнере, ни на хосте. + +| # | Шаг | Проверка | +|---|---|---| +| 1 | `npm i -g @baldaworks/callee` в контейнере | `callee --version` | +| 2 | `callee setup codex` (или иной host agent) → 6 стартовых ресурсов | `callee agent list` содержит `workflows/investigate` | +| 3 | Установить Beads `bd` | `bd where` | +| 4 | Импорт prism-пака | `callee agent import baldaworks/prism-callee --path pack/callee/prism --prefix prism` | +| 5 | 🔴 Проверить импорт | `callee agent view prism/lifecycle --json` → **kind `Router`** (не `Sequential`!) | +| 6 | Прогнать стартовый `investigate` | `callee agent run workflows/investigate --message "..."` | + +📌 Персистентность: установки в контейнере живут в его слое. Если понадобится +переживать пересборку — вынести `npm`-глобаль в том (решение принять на C1, п.1). + +### Фаза C2 — провайдер DeepSeek для callee (главная работа, 4-8 ч) + +Два варианта, выбор по результату C0: + +**Вариант C2-A — готовый адаптер найден.** Описать `Role` со `spec.provider.type: +generic_acp`, `cmd: <адаптер>`, `extraArgs` с endpoint DeepSeek. Проверить один +`Role`-визит вживую. + +**Вариант C2-B — пишем адаптер.** Минимальный ACP-процесс, транслирующий ACP-сессии +в OpenAI-совместимый `chat/completions` DeepSeek. Опорный материал — наш прошлый +опыт: патч `tool_calls` в `norma-local` (`pkg/runtime/hostedagent/openai.go`, +коммит `91c2c0a`) и PR `normahq/runtime#4`. 🔴 **Там же грабли:** DeepSeek отдаёт +`function.arguments` **двойной сериализацией** (JSON-строкой внутри JSON-строки). +См. [[personal/projects/balda/balda-valera]]. + +**Гейт C2:** один `Role`-визит к DeepSeek отрабатывает вживую, в логе виден +`role_model`, `role_reasoning` (документация: до успешного рендера эти поля +опускаются — значит их появление = рендер прошёл). + +### Фаза C3 — подключение воркфлоу к мосту (2-3 ч) + +1. Бэкап `~/Developer/agents-bridge/executor.py`. +2. Замена argv-таблицы `PROVIDERS`: + +```python +PROVIDERS = { + # было: "claude": ["claude", "-p", "--output-format", "text"], + # стало: + "callee": ["callee", "agent", "run", "prism/lifecycle", "--message"], +} +``` + +3. Промпт передаётся последним аргументом (как сейчас). +4. 🔴 `EXECUTOR_PROVIDER` в `~/Docker/balda-executor/.env` меняется на `callee`. +5. Перезапуск моста: `launchctl bootout` + `bootstrap` (не `kickstart` — см. питфолл §6). +6. Прогон задачи через мост (`POST /task` + `Idempotency-Key`), проверка `exit_code`. + +### Фаза C4 — таск-трекинг prism/Beads (1-2 ч) + +1. `bd` инициализируется в рабочей области `/agents`. +2. Прогон `prism:story` на реальной задаче: ожидаемый поток + `specify → design → breakdown → human → apply → verify`. +3. 🔴 Проверить, что пауза на `phase:story:human` **доходит до Mattermost** — + иначе точка одобрения недостижима и воркфлоу встанет намертво. Это ограничение + известно: `ask` в Балде поддержан **только** для Telegram и Slackagent, для + Mattermost фейлится закрыто. См. §6 «Питфолл 1» и + `docs/architecture/interactive-questions.md`. +4. Проверить durable: прервать сессию, возобновить Story — состояние в Beads на месте. + +### Фаза C5 — сквозная приёмка (1 ч) + +По правилам приёмки этого проекта — **от учётки Alex**, не подставным аккаунтом: + +``` +@balda добавь CSV-экспорт в отчёт +→ Specify (вопросы, если нужны) +→ Design → Breakdown +→ Human approval ← 🔴 Alex подтверждает +→ Apply → Verify +→ ответ в Mattermost + артефакт +``` + +Доказательство — лента канала `qentra:balda-test`, автор поста = `cbwsorzwoigbtrdnteugnx4aqo`. +Метод чтения ленты и постинга от владельца — §17 в +[[personal/plans/balda-mm-phase4-agents]]. + +--- + +## 4. Обоснование моих решений (Alex делегировал выбор) + +**Почему host coding agent — callee CLI в контейнере `balda-executor`:** +там уже Node 24 (родная среда callee), там же монтируется `~/agents` (рабочая +область), и оттуда мост уже умеет запускать команды через `docker exec`. Ставить +callee на хост Mac — значит плодить вторую среду и разбираться с PATH в launchd. + +**Почему мост сохраняем, а не выбрасываем:** +документация callee не требует HTTP-прослойки — Балда могла бы вызывать `callee +agent run` и через свой MCP. Но MCP-сервер `balda-executor-mcp` уже работает и +принят (§19.5), туннель `agents-bridge.qentra.top` уже заведён, идемпотентность, +таймауты и лимит в 1 задачу уже реализованы и проверены (6 проверок). Выбросить их +во имя «чище по доке» — значит потерять работающий слой ради эстетики. Мост +остаётся транспортом, меняется **одна таблица argv**. + +**Почему не «вторая Балда»:** +дублирование оркестратора даёт два источника истины по состоянию сессии и +конфликты локаторов. Документация к такому не призывает. + +--- + +## 5. Что снимается этим планом + +| Дефект | Как снимается | +|---|---| +| 🔴 `Not logged in · Please run /login` в контейнере-исполнителе (найден 2026-09-28) | DeepSeek-провайдер рендерится из состояния — логина в подписочный CLI вообще нет | +| Ручной OAuth-логин Alex'а в контейнер | не нужен | +| Потеря логина при `docker compose up -d` (`.claude.json` не в хост-монтировании) | неактуально | +| Самодельная оркестрация в промптах | заменяется валидируемым графом callee | +| Нет таск-трекинга | prism + Beads | + +--- + +## 6. Питфоллы + +1. 🔴 **`Human`-узел против Mattermost.** Документация callee/`prism:story` требует + пауз на одобрение (`phase:story:human`), а в Балде `ask` для Mattermost фейлится + закрыто. Пока не решено — воркфлоу может встать намертво на первой же точке + одобрения. Варианты: (а) прокинуть человеческий ответ через мой мост как + отдельный HTTP-вызов; (б) на первом этапе использовать воркфлоу без `Human`; + (в) чинить `ask` в MM-адаптере (отдельный PR). **Решать до C4.** +2. 🔴 **`PROVIDERS` в мосте — argv-список, замена «в лоб» рвёт передачу промпта.** + Сейчас: `["docker","exec","-w","/agents","@container", *base, prompt]`. Промпт + должен остаться **одним** последним элементом. +3. 🔴 **`.env` не перебивает plist.** `--host` жёстко в `ProgramArguments`; смена + аргументов требует `bootout` + `bootstrap`. Проверка — `lsof -nP -iTCP:8790 + -sTCP:LISTEN` (должно быть `*:8790`). +4. ⚠️ **`callee agent view prism/lifecycle --json` должен вернуть `Router`.** + `Sequential` = устаревший импорт, лечится `--force`. +5. ⚠️ **Двойная сериализация `function.arguments` у DeepSeek** — повторится в любом + своём адаптере (C2-B). Готовая логика разбора есть в `norma-local`. +6. ⚠️ **Установки в контейнере не переживают пересборку образа** — если callee/bd + окажутся нужны после `docker compose build`, их надо либо заложить в `Dockerfile`, + либо вынести в том. +7. ⚠️ **`docker-compose` (дефис) на Mac, `docker compose` (пробел) на Kraken** — + проверено, легко перепутать. +8. ⚠️ **Provider `cmd` — одно имя исполняемого файла, не shell-строка.** Любые + аргументы — только в `extraArgs`, иначе провайдер не стартует. +9. ⚠️ **Никогда не маршрутизировать вывод модели в `cmd`/`extraArgs`** — прямой + путь к исполнению произвольного кода (предупреждение документации). + +--- + +## 7. Риски + +| Риск | Оценка | Митигация | +|---|---|---| +| 🔴 **ACP-адаптера к DeepSeek может не существовать** | **высокий** | Фаза C0 решает это до основных работ. Если писать свой — 4-8 ч; опора на готовый патч `norma-local` | +| 🔴 `Human`-узел не доходит через Mattermost | **высокий** | Решить до C4 (три варианта в §6.1) | +| Импорт `prism/*` устаревает при обновлении | средний | проверять `kind: Router` после каждого импорта | +| callee обновляется (0.22.2 → выше) и меняет контракт | средний | пиннить версию в ресурсах; следить за `docs/` | +| Мост теряет работоспособность после смены argv | низкий | бэкап `executor.py`, откат одной правкой | +| Стоимость DeepSeek растёт (обе стороны на нём) | низкий | оркестратор уже на DeepSeek; исполнитель-роли — короткие визиты | +| Установка в контейнер не переживёт пересборку | низкий | решается томом или `Dockerfile` (§6.6) | + +--- + +## 8. Порядок и что НЕ делать + +**Порядок:** C0 → (решение) → C1 → C2 → C3 → C4 → C5. +C0 и C1 независимы — можно параллелить. + +**НЕ трогать в `~/Developer/balda`:** +`go.mod`, `Dockerfile.release`, `go.sum`, `internal/apps/balda/app.go`, +`internal/apps/balda/preflight.go` — это вне PR-дерева, нужны для сборки стенда +Kraken. `git checkout -- .` / `stash` / `reset --hard` их снесёт. См. +[[personal/plans/balda-mm-pr-plan]] §5. + +**НЕ начинать работу** без явного «поехали» — правило проекта: план → апрув → работа. +**Ничего не удалять** — образы для отката, диагностические контейнеры и старые теги +не трогать без отдельного подтверждения. + +--- + +## 9. Открытые вопросы + +1. 🔴 **Есть ли готовый ACP-адаптер к DeepSeek** — Фаза C0, блокирует C2. +2. 🔴 **Как проводить `Human`-одобрение через Mattermost** — §6.1, блокирует C4. +3. Нужен ли подписочный Codex в контуре вообще, или после Colex'а схема меняется + целиком (Alex: «потом будем с колексом решать»). + +--- + +## Связанные заметки + +- [[personal/plans/balda-mm-phase4-agents]] — Фаза 4 (предыдущая схема, мост :8790) +- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план Фаз 1-5 +- [[personal/plans/balda-mm-pr-plan]] — Фаза 2 (PR #8) +- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3 (✅ бот отвечает) +- [[personal/projects/balda/balda-valera]] — патч `tool_calls` для DeepSeek (опора для C2-B) +- [[personal/tech/agents-bridge]] — мост :8790 +- [[personal/tech/balda-executor-mcp]] — MCP-сервер делегирования +- [[personal/projects/balda/balda-mattermost-connector]] — MM-коннектор diff --git a/personal/plans/balda-mm-pr8-review-fixes.md b/personal/plans/balda-mm-pr8-review-fixes.md new file mode 100644 index 00000000..0c6cc3ad --- /dev/null +++ b/personal/plans/balda-mm-pr8-review-fixes.md @@ -0,0 +1,219 @@ +# Balda — PR #8 review fixes: slash commands, /locator, group DM session + +> Создан 2026-09-28. Статус: **ПЛАН — ожидает подтверждения Alex.** +> Источник: ревью Alex на `baldaworks/balda#8` (Mattermost transport). +> Часть [[personal/plans/balda-mm-pr-plan]] (PR #8). Ветка **`feat/mattermost-channel`**. +> Ветки / кода ещё не трогал — только чтение и сверка. + +--- + +## 0. TL;DR + +Ревью право по всем трём пунктам — **проверено в коде**, не на слово. + +| # | Дефект | Подтверждение в коде (коммит `9f1e6c9c`) | +|---|---|---| +| Б1 | Slash-команды не доходят: `/start` и прочие обрабатываются только из WS-события `posted` | `channel/mattermost/ingress.go` — `switch event.Event` знает только `posted`; отдельного HTTP-приёмника slash нет вовсе. У Slack он есть: `slackagent/server.go` → `mux.HandleFunc(commandsPath, h.handleCommands)` | +| Б2 | `/locator` всегда падает: `locatorref.Parse` не знает `mattermost` | `locatorref/locatorref.go:52-62` — `switch channelType` покрывает только `telegram` / `zulip` / `slackagent`; ветки `mattermost` нет → `unsupported channel type` | +| В3 | Групповой DM расщепляется по отправителям | `locator_codec.go:102-118` — `dmAddressKey` = `d:` (один чат = один ключ), а `dmSessionID` при непустом `UserID` = `mm-dm-` (**автор**, не чат) | + +🔴 **Ключевой вопрос по Б1 — не «как», а «что именно».** См. §2: две реализации, они +архитектурно разные. Нужно решение Alex **до** написания кода. + +--- + +## 1. Сверка с докой (что именно было заявлено и оказалось неправдой) + +`docs/commands.md:20-22` (коммит `58f27120`) утверждает: + +> Mattermost uses one slash command per action, such as `/locator`, and accepts +> them in direct messages and in channels where the bot is mentioned. + +И таблица (`docs/commands.md:26+`) ставит **`/help`, `/topic`, `/goalkeeper`, `/auto`, +`/usage`, `/close`, `/cancel`, `/user` → «yes»** для Mattermost. + +⚠️ Всё это **ложь текущей реализации** — ни одна slash-команда в MM не обрабатывается. +`/start` в таблице стоит `no`, но это тоже неверно: в MM `/start` — единственный +документированный путь первичной привязки владельца, и он не работает. + +📌 **Правка доков — обязательная часть фикса, а не «потом».** Колонка Mattermost в +таблице доступности должна стать следствием кода, который реально есть. + +--- + +## 2. Б1 — slash-команды. Решение: вариант A (HTTP-приёмник). Вопрос снят. + +> **Решение Alex 2026-09-28:** «Тебе что, не очевидно в прод-качестве?» — выбора нет. +> B (убрать slash из доки) = **откат функциональности**, а не фикс. Прод-качество = A. +> Спрашивать было нечего. Ниже сохранён разбор обоих путей как обоснование. + +Mattermost **не публикует** slash-команды как посты (в отличие от Telegram/Zulip) — поэтому +текущий WS-путь их физически не видит. Есть два штатных механизма, и они взаимоисключающие +по архитектуре: + +📌 **Факт, подтверждённый докой MM** (`docs.mattermost.com/integrations-guide/slash-commands`): +«if you type in a slash command it will execute **without posting a message**, +whereas an outgoing webhook is only triggered by posted messages». То есть WS-события +`posted` для slash **не будет** — ревью право на 100%. + +🔴 **Что уже работает и что отсутствует — точно:** +| Компонент | Статус | +|---|---| +| `mattermostInboundHandler.HandleCommand` (`handlersfx/mattermost_ingress.go`) — публикация в `commandcmd` | ✅ **написан и корректен** | +| `ParseCommand` (`inbound.go:223`) — разбор `/name args` из текста | ✅ написан | +| вызов `ParseCommand` из `processPosted` (`ingress.go`) — **mention-путь** | ✅ работает | +| **HTTP-путь для slash** (MM POST → наш эндпоинт) | ❌ **отсутствует целиком** | + +Значит фикс Б1 — **не переписывать command-машину**, а добавить к ней **второй вход**. +Command-машина исправна; нет только способа донести до неё slash-запрос. + +### Вариант A — Slack-style: исходящий webhook + HTTP-приёмник (ВЫБРАН) + +Slash-команда MM настраивается на **Request URL** — MM делает `POST` в наш HTTP-эндпоинт. +Это ровно то, что уже сделано для Slack, значит есть рабочий образец в репо. + +Что дублируется из `slackagent/`: +- HTTP-сервер с отдельным путём `/mattermost/commands` (`server.go:129`-аналог); +- `handleCommands` → `decodeCommandRequestWithRegistry` → `commandcmd.Request` → common handler; +- ответ в MM — JSON `{"response_type":"ephemeral"|"in_channel","text":"..."}` (MM-специфично, + отличается от Slack `{"text":...}`); +- **верификация**: у MM slash-команда подписывается полем `token` в теле запроса + (не HMAC-подпись, как у Slack) — сравнение с токеном из конфига; +- lifeзycle: тот же `appports.TransportLifecycleStage`, что у WS-ingress (либо расширить + существующий `Ingress`, либо отдельный `Server` — см. §6). + +Плюсы: `/start`, `/topic`, `/goalkeeper`, `/auto`, `/usage`, `/close`, `/cancel`, `/user` +работают **в полном объёме**, как в доке и таблице. Минусы: нужен публичный HTTP-эндпоинт +для MM и **новый ключ конфига** (slash-токен) + правки доков по настройке. + +### Вариант B — «mention-форма»: команды только текстом в посте (ОТКЛОНЁН) + +Оставить один WS-путь, а из доки/таблицы убрать slash-обещания: писать команды как +`@balda /locator` (текст поста, `ParseCommand` их уже разбирает — `ingress.go`/`inbound.go`). + +Почему отклонён: это **не фикс, а удаление уже обещанной функциональности**. `/start owner=` +в MM-развёртывании — штатный вход владельца, и `docs/mattermost.md:117-119` прямо описывает его +как slash-команду в DM. Вариант B = переписать доку под урезанный код. Для «прод-качества» — +неприемлемо. + +### Итог + +**A.** В доке уже обещаны все slash-команды, у Slack есть готовый образец +(`slackagent/server.go:129` → `mux.HandleFunc(commandsPath, h.handleCommands)` + `handleCommands` +→ `decodeCommandRequestWithRegistry` → `commandcmd.Request`), а `/start owner=` — штатный +вход владельца в MM-развёртывании. + +--- + +## 3. Б2 — `/locator`: добавить `mattermost` в `locatorref` + +Механически, по образцу `slackagentLocatorFromAddressKey` (там та же форма `c:`/`t:`): + +| Что | Куда | +|---|---| +| ветка `case baldamattermost.ChannelType:` в `switch channelType` | `locatorref/locatorref.go:52-62` | +| `mattermostLocatorFromAddressKey(addressKey string)` — разбор `c:`, `c::`, `d:` | рядом с `slackAgentLocatorFromAddressKey` | +| конструкторы `NewMattermostChannelLocator` / `NewMattermostDMLocator` (по образцу `NewSlackAgent*`) | там же | +| **импорт** `channel/mattermost` — ⚠️ проверить архитектурное правило | см. ниже | + +🔴 **Питфолл архитектуры.** `AGENTS.md` прямо требует: «Keep `locatorref` **independent** +from concrete transport adapter packages». Импорт `channel/mattermost` в `locatorref` это +**нарушает**. Как это решено сейчас: `locatorref` импортирует `telegramref` (отдельный +тонкий пакет-ref), а для zulip/slack — **дублирует константы строкой**: +```go +channelTypeZulip = "zulip" +channelTypeSlackAgent = slackagentref.ChannelType // ← тонкий ref-пакет +``` +📌 **Значит правильная форма для MM — тонкий пакет `mattermostref`** (или константа-строка +в `locatorref`), **не** импорт транспорта. Решение сверить с `slackagentref`/`telegramref`, +прежде чем писать код. Это тот случай, где `go-arch-lint` даст красное на наивной реализации. + +**Ещё два следствия Б2, которые надо закрыть тем же изменением:** +- `internalmcp/manager.go:409` — `locatorref.Parse(channelType + ":" + addressKey)`. Любой + MM-таргет там сейчас падает. +- `envelopetarget/resolve.go:62` — `locatorref.Parse(target.Key)`. То же самое → + `balda.scheduler.jobs` с `target: locator` для MM **не работает**. + +⚠️ Это и есть вторая половина фразы ревью «также исключает Mattermost из config targets, +использующих тот же парсер». Правка только `handler.go` задачу не закрывает. + +--- + +## 4. В3 — групповой DM: ключ чата вместо ключа автора + +Сейчас: +```go +func dmAddressKey(a LocatorAddress) string { return fmt.Sprintf("d:%s", url.PathEscape(a.ChannelID)) } // ЧАТ +func dmSessionID(a LocatorAddress) string { + if a.UserID != "" { return fmt.Sprintf("%s-dm-%s", prefix, shortHash(a.UserID)) } // АВТОР ← баг + return fmt.Sprintf("%s-dm-%s", prefix, shortHash(a.ChannelID)) +} +``` +DM (`D`) приходит с `UserID` → сессия по автору. **Групповой DM (`G`) канал общий, а авторов +много** → у каждого участника своя сессия. Ревью право. + +Правильная форма — **различать `D` и `G` явно**, а не «если есть UserID»: + +| Тип канала MM | AddressKey | SessionID | +|---|---|---| +| `D` (1:1) | `d:` | `mm-dm-` (чат = 2 участника, стабильно) | +| `G` (групповой) | `d:` | `mm-gdm-` — **по чату** | + +⚠️ Соображения, которые надо учесть при реализации: +- **Обратная совместимость:** существующие 1:1 DM-сессии на Kraken имеют id + `mm-dm-` (перепривязка сломает их → новые сессии, старые «осиротеют»). + Либо принять (тестовая инсталляция), либо оставить для `D` форму по `UserID`. +- `IsDirectChannelType` (`inbound.go`) уже знает `D`/`G` — переиспользовать. +- Точка входа — `LocatorForPost` (`inbound.go`), она и должна различать `D`/`G`. +- Правка ведёт к обновлению `locator_codec_test.go` (тест на изоляцию thread↔channel + придётся дополнить тестом «два автора в `G` → одна сессия»). + +--- + +## 5. Порядок работ (порядок важен: сначала решается A/B) + +| # | Шаг | Файл | Тип | +|---|---|---|---| +| 0 | **Решение Alex: вариант A или B для Б1** | — | ⛔ блокирует всё остальное | +| 1 | Б2: `mattermost` в `locatorref` (через ref-пакет, не импорт транспорта) | `locatorref/`, новый `mattermostref`? | код | +| 2 | Б2-хвост: тесты на `internalmcp/manager.go` и `envelopetarget/resolve.go` путях | тесты | код | +| 3 | В3: `D`/`G` различать в `dmSessionID` + `LocatorForPost` | `locator_codec.go`, `inbound.go` | код | +| 4 | В3: тест «два автора в групповом DM → одна сессия» | `locator_codec_test.go` | тест | +| 5 | Б1 (если A): HTTP-приёмник slash + `token`-верификация + DI + конфиг-ключ | `channel/mattermost/`, `mattermostfx/`, `app.go`, `balda.yaml` | код | +| 6 | Б1 (если A): `docs/mattermost.md` — настройка slash Request URL и токена | доки | доки | +| 7 | Правка таблицы команд `docs/commands.md` — колонка Mattermost = **реальность**, не обещание | доки | доки | +| 8 | Гейты: `go build` / `vet` / `golangci-lint` / `go-arch-lint` / `go test -race` | — | проверка | +| 9 | Push в `feat/mattermost-channel` → **дождаться CI 8/8** | — | проверка | +| 10 | Обновить Obsidian: [[personal/plans/balda-mm-pr-plan]], [[personal/projects/balda/balda-mattermost-connector]] | доки | доки | + +📌 Коммиты — Conventional Commits, правки — **дельта-апплаером** (`tools/run-project-deltas.sh`, +`check` → `apply`), не sed/in-place (правило Alex). + +📌 Ветка та же (`feat/mattermost-channel`) — правки уедут в открытый PR #8 и **сбросят +пройденный CI**. Это ожидаемо: PR не смержен, ревью просит исправлений. + +--- + +## 6. Риски / питфоллы, которые уже видны + +1. 🔴 **`locatorref` не должен импортировать `channel/mattermost`** — `go-arch-lint` даст + `composition_root`/boundary-ошибку. Форма фикса — тонкий ref-пакет, как `slackagentref`. +2. 🔴 **Slash-команды MM требуют публичного HTTPS** для Request URL. Если стенд на Kraken + без публичного адреса — нужен туннель либо вариант B. **Проверить до выбора A.** +3. ⚠️ **MM slash-запросы возвращаются в течение 3 с**, иначе MM показывает timeout. Тяжёлые + команды (`/goalkeeper`) обязаны отвечать сразу, а результат доставлять постом — как у Slack. +4. ⚠️ **`token` в slash-запросе — не HMAC.** Другая модель верификации, чем у Slack; копировать + `signedSlackRequest`-логику нельзя. +5. ⚠️ **Обратная совместимость DM-сессий** (§4) — смена id осиротит существующие сессии. +6. ⚠️ Изменение Б3 меняет поведение `transport_user_id` в persisted-метаданных сессии — + перепроверить тест `TestLocatorForPostScopesReplyToThreadRoot` и «чужой user_id» из + §«Два не-блокера» [[personal/plans/balda-mm-pr-plan]]. + +--- + +## Связанные заметки + +- [[personal/plans/balda-mm-pr-plan]] — PR #8, состояние, деплой, приёмка +- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2 (код коннектора) +- [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы, дельта-апплаер, arch-lint +- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план