From 1db7e501a07b4a62010d151bc98e2058f921b034 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 21 Apr 2026 16:44:18 +0300 Subject: [PATCH] feat(teams): introduce fast mode configuration for Anthropic provider and enhance related UI components --- CLAUDE.md | 1 + docs/team-management/README.md | 1 + .../task-queue-derived-agenda-plan.md | 3517 +++++++++++++++++ .../domain/resolveAnthropicRuntimeProfile.ts | 212 + .../anthropic-runtime-profile/main/index.ts | 12 + .../renderer/index.ts | 12 + src/main/http/teams.ts | 18 +- src/main/ipc/configValidation.ts | 28 +- src/main/ipc/teams.ts | 83 +- .../services/infrastructure/ConfigManager.ts | 2 + .../runtime/ClaudeMultimodelBridgeService.ts | 19 +- src/main/services/team/TeamDataService.ts | 1 + .../team/TeamMemberRuntimeAdvisoryService.ts | 8 +- src/main/services/team/TeamMetaStore.ts | 18 +- .../services/team/TeamProvisioningService.ts | 205 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 80 + .../settings/hooks/useSettingsHandlers.ts | 1 + .../dialogs/AnthropicFastModeSelector.tsx | 103 + .../team/dialogs/CreateTeamDialog.tsx | 144 +- .../team/dialogs/EffortLevelSelector.tsx | 12 +- .../team/dialogs/LaunchTeamDialog.tsx | 134 +- .../team/dialogs/launchDialogPrefill.ts | 7 + .../components/team/members/LeadModelRow.tsx | 28 +- .../team/members/MemberDraftRow.tsx | 3 + .../components/team/members/MemberList.tsx | 4 +- .../team/members/MembersEditorSection.tsx | 4 + .../team/members/TeamRosterEditorSection.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 3 + .../utils/__tests__/teamEffortOptions.test.ts | 87 +- ...teamModelAvailability.codexCatalog.test.ts | 213 + src/renderer/utils/teamEffortOptions.ts | 35 +- src/shared/types/cliInstaller.ts | 16 +- src/shared/types/notifications.ts | 1 + src/shared/types/team.ts | 10 +- src/shared/utils/cliArgsParser.ts | 1 + src/shared/utils/effortLevels.ts | 16 + src/shared/utils/rateLimitDetector.ts | 95 +- .../resolveAnthropicRuntimeProfile.test.ts | 307 ++ .../services/team/AutoResumeService.test.ts | 17 + .../TeamMemberRuntimeAdvisoryService.test.ts | 1 + .../team/dialogs/launchDialogPrefill.test.ts | 20 + test/renderer/store/extensionsSlice.test.ts | 1 + test/shared/utils/rateLimitDetector.test.ts | 20 + 43 files changed, 5450 insertions(+), 65 deletions(-) create mode 100644 docs/team-management/task-queue-derived-agenda-plan.md create mode 100644 src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts create mode 100644 src/features/anthropic-runtime-profile/main/index.ts create mode 100644 src/features/anthropic-runtime-profile/renderer/index.ts create mode 100644 src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx create mode 100644 test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2d7f0988..19a14f8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ Electron 40.x, React 19.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x ## Commands Always use pnpm (not npm/yarn) for this project. +Workspace membership is canonical in `pnpm-workspace.yaml`; do not re-add root `package.json.workspaces`, because npm subproject installs in Codex Cloud must treat nested packages as standalone projects. Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel. When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`). diff --git a/docs/team-management/README.md b/docs/team-management/README.md index 514fd0ae..f6b29dc7 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -20,6 +20,7 @@ | [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json | | [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification | | [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) | +| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync | ## Ключевые решения diff --git a/docs/team-management/task-queue-derived-agenda-plan.md b/docs/team-management/task-queue-derived-agenda-plan.md new file mode 100644 index 00000000..feaa3031 --- /dev/null +++ b/docs/team-management/task-queue-derived-agenda-plan.md @@ -0,0 +1,3517 @@ +# Task Queue / Agenda Rollout Plan + +**Date:** 2026-04-21 +**Status:** Proposed +**Scope:** Team Management, MCP task surfaces, agent operational queue + +--- + +## TL;DR + +✅ Базовое решение: + +- перед полноценным agenda rollout сначала делаем **Phase 0 hardening** для слабых сигналов +- source of truth остаётся в сырых task/review/kanban файлах +- в Phase 1 не заводим второй persisted projection-файл +- вместо этого строим **derived queue projection on read** через **controller-owned board snapshot** под **team-level lock** +- вводим явные derived-поля: `actionOwner`, `nextAction`, `queueCategory`, `watchers` +- разделяем 4 разных surface: + - `member_briefing` = только bootstrap + правила + роль + - `task_briefing` = каноническая очередь конкретного участника + - `lead_briefing` = каноническая очередь лида + - `task_list` = inventory/search surface, который постепенно уходит в filtered role +- Phase 2 добавляет `revision` и только потом, если реально нужно по профайлингу, `delta` + +Главная мысль: проблема не в том, что `task_list` "слишком длинный". Проблема в том, что он сейчас смешивает **inventory**, **workflow state**, **операционный приоритет** и **шум**, заставляя LLM самому угадывать "что мне делать сейчас". + +⚠️ После дополнительного code review важны 4 уточнения: + +- сейчас в коде нет общего board-level lock поверх task + kanban + review mutations, но Phase 0 надо ограничить controller snapshot/mutation scope, а не пытаться сразу перевести под него все main-side readers +- reviewer для review-задачи в Phase 0/1 надо выводить только из текущего review cycle в `historyEvents`, потому что оба write-path сегодня заводят kanban review entry с `reviewer: null` +- `needsClarification: "lead"` в коде сейчас auto-clear'ится шире, чем обещают prompt-инструкции, поэтому safest Phase 0 - explicit clear only +- `task_list` нельзя резко ломать сменой default semantics, потому что lead prompts уже используют его как full inventory entrypoint + +--- + +## 1. Почему вообще понадобился этот redesign + +Сейчас у системы есть 2 разных read-path, но ни один из них не задаёт правильную operational semantics: + +### `task_list` + +- технически возвращает почти весь список задач команды +- это не "очередь работы", а полу-сырой inventory dump +- он слишком тяжёлый по payload +- главное, он **не говорит явно**, кто сейчас должен действовать по задаче +- агенту приходится самому вычислять: + - моя ли это задача + - жду ли я ревью + - я ли ревьюер + - надо ли пинговать лида + - задача реально actionable или просто informational + +### `task_briefing` + +- даёт более компактное представление +- но сейчас завязан в основном на `owner === memberName` +- поэтому не покрывает важные сценарии: + - задача формально owned одним участником, но action сейчас у ревьюера + - задача зависла из-за отсутствующего reviewer + - задача ждёт решения лида + - участнику нужна awareness по своим задачам, даже если actionOwner временно не он + +Итог: + +- лид не получает нормальную operational queue +- тиммейт может не видеть "что от него реально требуется" +- LLM тратит токены и качество на вывод политики из сырых полей +- при росте команды и количества задач путаница становится системной + +--- + +## 2. Что именно не так в текущей модели + +### 2.1 `task_list` сегодня слишком близок к raw dump + +По факту текущий `task_list` делает почти "отдать все задачи, слегка урезав крупные поля". Это blocklist-подход: + +- убираются тяжёлые поля вроде комментариев и истории +- почти всё остальное сохраняется + +Такой подход плох не только по размеру, но и по смыслу: + +- агенту всё ещё видны почти все задачи команды +- инструмента нет opinionated workflow surface +- список не говорит, какие карточки именно должны попасть в текущую очередь участника + +### 2.2 `task_briefing` сегодня не отделяет action от awareness + +Сейчас логика ближе к "покажи мои assigned tasks". Но operational queue и assigned list не одно и то же. + +Примеры: + +- owner = `alice`, reviewer = `bob`, задача в review + `alice` должна понимать, что задача не пропала, но actionOwner уже `bob` +- задача completed, reviewer ещё не назначен + action нужен не owner, а лиду +- задача ждёт ответа от user + actionOwner = `user`, но лид должен видеть это как oversight item + +### 2.3 Raw task list не должен быть основным surface для LLM + +LLM лучше работает, когда система даёт: + +- кто сейчас должен действовать +- почему именно он +- какое следующее ожидаемое действие +- что informational, а что actionable + +LLM хуже работает, когда ему дают 50 карточек и ожидают, что он сам выведет workflow policy. + +### 2.4 Важные факты из текущего кода + +Ниже то, на что уже можно опираться в rollout, без изобретения новой подсистемы с нуля: + +- `task_list` сегодня реально отдаёт почти весь team inventory в урезанном виде, а не opinionated queue +- `task_briefing` сегодня в основном строится через `owner === memberName` +- `review_request` технически может отправить задачу в review даже без явно резолвленного reviewer +- active reviewer для agenda-safe routing в Phase 0/1 надо выводить только из текущего review cycle в `historyEvents`; per-task kanban reviewer сейчас не является надёжным signal +- runtime identity для участника уже умеет частично резолвиться из существующего runtime context +- UI и MCP уже в основном идут через `agent-teams-controller`, то есть rollout можно делать эволюционно вокруг текущего controller layer +- task и kanban сегодня пишутся atomically по файлам, но не transactionally как единый board update +- controller-side task/kanban write paths сейчас вообще не используют существующий `withFileLockSync(...)`; текущий lock primitive живёт отдельно, синхронный, busy-wait, с acquire timeout `5s` и stale timeout `30s` +- `review_request` сейчас делает multi-step sequence `kanban -> task history -> message`, значит queue read действительно может увидеть промежуточное состояние +- `review_request` при ошибке отправки уведомления сейчас делает только `kanban.clear`, но уже записанный `review_requested` history event не откатывает +- `needsClarification: "lead"` в task store сейчас auto-clear'ится при комментарии любого не-owner автора, а не только лида +- `task_list` использует blocklist-подход, поэтому payload может незаметно разрастаться при появлении новых task-полей +- текущий `task_list` в MCP напрямую вызывает `controller.tasks.listTasks()` без фильтров и затем прогоняет результат через `slimTaskForList(...)`, то есть filter-capable inventory contract пока даже не выбран +- `task_list` входит в teammate operational tool catalog, значит prompt-only migration недостаточна - нужен ещё catalog/access rollout +- в репо есть как минимум две локальные `.d.ts` декларации для `agent-teams-controller`, и они уже расходятся по полноте surface +- `mcp-server` тесты сейчас явно проверяют текущую blocklist-semantics `task_list`, значит migration должна обновлять и test contract, а не только runtime +- `owner` и `needsClarification` хранятся как текущие поля task payload, но не имеют такого же history-backed контракта, как review transitions +- task logs умеют видеть `task_set_owner` / `task_set_clarification`, но это отдельный observability слой, а не базовый queue source of truth +- в schema у `kanban-state.tasks[taskId]` есть `reviewer`, но официальный mutation patch сейчас не несёт reviewer как поддерживаемый per-task signal +- prompt знает правило вида "если есть reviewer/qa/tech-lead, отправляй на review", но это не равняется канонической machine-readable review policy в runtime state +- roster resolution уже существует минимум в двух вариантах: controller-side `resolveTeamMembers(...)` и main/UI-side `TeamMemberResolver`, и они нормализуют состав команды не полностью одинаково +- main-side `TeamDataService` compatibility layer сейчас резолвит `reviewer` как `kanbanTaskState.reviewer ?? history(review_approved|review_started|review_requested)`, а `reviewState` берёт из persisted field, что уже шире и слабее, чем queue-safe current-cycle contract +- `setTaskOwner` и `task_set_clarification` меняют task payload и `updatedAt`, но не добавляют workflow events в `historyEvents` +- stall-monitor уже использует более строгую review-window semantics, где открытое review окно начинается с `review_started`, а не просто с `review_requested` + +Это важно, потому что план не требует: + +- переписывать storage +- менять формат task-файлов +- вводить новый отдельный board runtime + +--- + +## 3. Цели redesign + +Нужно добиться одновременно 5 свойств: + +1. **Однозначность** + По каждой активной задаче должен существовать один primary `actionOwner` или явно `none/user`. + +2. **Компактность** + Обычный teammate не должен получать весь board dump только ради того, чтобы понять свои 2-4 задачи. + +3. **Надёжность** + Read model не должен становиться вторым нестабильным source of truth. + +4. **Понятная иерархия surfaces** + Bootstrap, operational queue, full context и inventory/search должны быть разными инструментами. + +5. **Эволюционность** + Phase 1 должен сильно улучшить поведение без большого риска stale projection bugs. + +--- + +## 3.1 Confidence Map After Code Review + +Ниже зоны, где после просмотра кода у нас разная степень уверенности. + +### A. Derived agenda как базовый read model + +`🎯 10 🛡️ 10 🧠 6` + +Почему уверенность высокая: + +- это не требует второго durable state +- current storage уже содержит достаточно сигналов для derived routing +- основной риск здесь не идея, а discipline around rollout + +### B. Controller-owned board snapshot + lock + +`🎯 9 🛡️ 9 🧠 5` + +Почему уверенность выросла: + +- после дополнительного просмотра видно, что main-side raw readers (`TeamTaskReader`, `TeamKanbanManager`) живут отдельно и не требуют немедленного включения в agenda path +- для agent-facing queue достаточно сначала сделать один канонический controller snapshot contract +- это сильно уже и безопаснее, чем пытаться в Phase 0 синхронно унифицировать весь repo вокруг одного lock API + +### C. Reviewer resolution + +`🎯 8 🛡️ 9 🧠 4` + +Почему уверенность стала выше: + +- и controller-side `kanbanStore.setKanbanColumn(...)`, и main-side `TeamKanbanManager.updateTask(...)` при переводе в `review` записывают `reviewer: null` +- значит ambiguity здесь на самом деле меньше, а не больше: Phase 0/1 просто не должны опираться на per-task kanban reviewer +- остаётся чётко определить только current-cycle precedence внутри `historyEvents` + +### D. Clarification semantics + +`🎯 8 🛡️ 10 🧠 3` + +Это всё ещё слабая зона текущего runtime, но у неё теперь есть понятный safe fallback. + +Причина: + +- prompts говорят одно +- код auto-clear'ит флаг шире +- но safest fix очень прямой: в Phase 0 убрать implicit auto-clear из routing contract и жить через explicit `task_set_clarification clear` + +### E. `task_list` migration без silent breakage + +`🎯 7 🛡️ 7 🧠 5` + +Почему: + +- целевая роль `task_list` понятна +- но менять его default резко опасно, потому что старые лид-промпты уже ожидают текущую семантику +- значит migration должна идти через новый canonical surface, а не через скрытую подмену смысла + +### F. Queue-side roster normalization + +`🎯 7 🛡️ 8 🧠 5` + +Почему это остаётся важной зоной: + +- controller-side `resolveTeamMembers(...)` и UI-side `TeamMemberResolver` до сих пор фильтруют roster не полностью одинаково +- для queue это критично, потому что invalid owner/reviewer routing зависит от того, кого мы вообще считаем валидным member +- но здесь достаточно зафиксировать минимальный queue contract и покрыть его тестами, без большого package refactor + +### G. Lead identity normalization for `lead_briefing` + +`🎯 6 🛡️ 8 🧠 4` + +Почему это остаётся слабее других зон: + +- controller-side `inferLeadName(...)` использует ad hoc heuristics: `agentType === "team-lead"`, `role` содержит `lead`, имя `team-lead`, иначе первый config member +- shared `leadDetection` utilities уже живут по чуть более другому контракту +- для `lead_briefing` и future ergonomic clarification clear нельзя опираться на "может быть это и есть lead" без явного phase rule + +### H. Tool-catalog scoping for `lead_briefing` + +`🎯 8 🛡️ 9 🧠 4` + +Почему это важно: + +- текущий `mcpToolCatalog` режет доступность по group-level `teammateOperational`, а не по per-tool policy +- если просто положить `lead_briefing` в existing task-group, он автоматически окажется в teammate operational tool set +- это не runtime bug, но это прямой путь к лишнему шуму и путанице у teammate agents + +--- + +## 3.2 Top 3 Options In The Least Certain Areas + +Ниже не "все возможные идеи", а именно те развилки, где реально можно ошибиться архитектурно. + +### A. Где должен жить shared board lock + +**1. Controller-owned board snapshot API + controller-local team lock** + +`🎯 10 🛡️ 9 🧠 5` + +Примерный объём: `80-160` строк изменений + +Суть: + +- внутри `agent-teams-controller` ввести один канонический `withTeamBoardLock(...)` или `getBoardSnapshot(...)` +- под него завести multi-file mutations review/task/kanban и agenda reads +- main-process не заставлять в той же фазе массово переходить на этот primitive, если ему не нужен именно agenda-grade snapshot + +Почему это лучший вариант: + +- один semantic owner для agent-facing queue +- не раздувает Phase 0 до cross-layer refactor всей файловой модели +- оставляет возможность позже экспортировать тот же snapshot наружу, если он реально понадобится UI + +**2. Exported generic shared primitive across controller + main** + +`🎯 7 🛡️ 8 🧠 6` + +Примерный объём: `100-180` строк изменений + +Суть: + +- сделать универсальный lock/snapshot contract, который одинаково используют controller и main + +Риск: + +- полезно в долгую, но для ближайшего rollout это уже больше работа, чем нужно +- есть риск начать чинить "архитектурную красоту" вместо реально агентского queue path + +**3. Пер-file lock orchestration без отдельного team lock** + +`🎯 3 🛡️ 3 🧠 6` + +Примерный объём: `60-120` строк изменений + +Суть: + +- пытаться добиться консистентности через набор локов на task/kanban файлы + +Риск: + +- сложнее reasoning +- выше риск partial interleaving +- хуже читается и хуже тестируется + +Выбор: + +- брать **вариант 1** +- вариант 2 рассматривать только если после rollout тот же snapshot contract реально понадобится non-controller consumers + +### B. Как резолвить reviewer для active review cycle + +**1. Pure history-first current-cycle resolver** + +`🎯 10 🛡️ 10 🧠 4` + +Примерный объём: `70-140` строк изменений + +Суть: + +- сканировать history backwards только в пределах текущего review cycle +- использовать `review_started.actor` и `review_requested.reviewer` как основные сигналы +- в Phase 0/1 вообще **не использовать** `kanban-state.tasks[taskId].reviewer` как routing signal + +Почему это лучший вариант: + +- максимально опирается на уже существующие durable events +- согласуется с уже существующей derivation логикой review state через history +- это полностью соответствует реальному коду: и controller, и main сейчас создают review-entry с `reviewer: null` + +**2. History-first now, kanban fallback only after writer hardening** + +`🎯 6 🛡️ 8 🧠 6` + +Примерный объём: `110-210` строк изменений + +Суть: + +- сначала расширить write-path так, чтобы per-task `reviewer` реально persist'ился +- только после этого разрешить kanban fallback в resolver + +Риск: + +- это уже не Phase 0 hardening, а изменение durable semantics +- если делать одновременно с agenda rollout, возрастает blast radius + +**3. Новый persisted reviewer slot в task schema** + +`🎯 7 🛡️ 8 🧠 7` + +Примерный объём: `120-240` строк изменений + +Суть: + +- добавить explicit reviewer прямо в task payload и синхронизировать его в review flow + +Плюсы: + +- проще future reads + +Минусы: + +- новая durable schema responsibility +- выше цена ошибок и миграции + +Выбор: + +- для Phase 0/1 брать **вариант 1** +- вариант 3 оставлять как later optimization, только если history-based resolver окажется недостаточным + +### C. Как harden'ить clarification clearing + +**1. Phase 0 explicit-clear-only semantics** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `25-60` строк изменений + +Суть: + +- не делаем никакой implicit auto-clear частью routing contract +- clarification снимается только через явный `task_set_clarification clear` +- prompts и briefing обновляются синхронно + +Почему это лучший вариант: + +- максимально надёжно +- убирает silent false negatives в очереди +- делает поведение легко тестируемым и объяснимым + +**2. Controller-layer lead-aware auto-clear after alias hardening** + +`🎯 8 🛡️ 9 🧠 6` + +Примерный объём: `70-140` строк изменений + +Суть: + +- после стабилизации lead identity вернуть удобство: + - `lead` clarification clear'ится на комментарий лида + - `user` clarification clear'ится на комментарий пользователя + +Риск: + +- нужно сначала жёстко определить: + - canonical lead identity + - alias policy (`team-lead`, фактическое имя лида, возможный `lead`) + - valid actor normalization + +**3. Протянуть explicit `canClearClarification` / `leadName` в taskStore API** + +`🎯 5 🛡️ 6 🧠 6` + +Примерный объём: `50-110` строк изменений + +Суть: + +- taskStore всё ещё делает auto-clear, но получает workflow policy сверху + +Риск: + +- API store начинает знать слишком много про workflow policy + +Выбор: + +- для Phase 0 брать **вариант 1** +- вариант 2 оставлять как optional ergonomics pass только после стабилизации lead alias semantics + +Отдельное уточнение: + +- это не двухстрочная правка в storage layer +- `taskStore.addTaskComment()` сам по себе не знает, кто является lead для команды + +Поэтому "clear only when lead answers" нельзя надёжно реализовать внутри store без: + +- переноса policy выше, в controller/context layer +- или явной передачи lead-aware policy signal в storage API + +### D. Как делать queue revision в Phase 2 + +**1. Stable hash of compact agenda DTO** + +`🎯 9 🛡️ 9 🧠 4` + +Примерный объём: `40-90` строк изменений + +Суть: + +- как в `feedRevision`, считать hash от уже нормализованного compact agenda payload + +Почему это лучший вариант: + +- не требует extra durable file +- легко проверять +- удобно для `unchanged` short-circuit + +**2. Team directory mtime / file timestamps** + +`🎯 4 🛡️ 4 🧠 3` + +Примерный объём: `20-40` строк изменений + +Риск: + +- платформенная хрупкость +- плохая детерминированность + +**3. Dedicated revision counter file** + +`🎯 7 🛡️ 8 🧠 6` + +Примерный объём: `70-140` строк изменений + +Плюсы: + +- дешёвые reads + +Минусы: + +- ещё одна durable write responsibility + +Выбор: + +- сначала **вариант 1** +- вариант 3 рассматривать только если hash on read реально станет bottleneck + +### E. Где должна жить canonical agenda derivation logic + +**1. Controller-owned single implementation** + +`🎯 9 🛡️ 9 🧠 5` + +Примерный объём: `90-170` строк изменений + +Суть: + +- agenda derivation живёт в `agent-teams-controller` +- MCP tools только оборачивают её +- main-process, если ему нужен тот же semantic surface, либо вызывает controller API, либо использует тот же exported helper + +Почему это лучший вариант: + +- один semantic owner +- меньше риска drift между MCP/runtime/UI +- уже сейчас controller является главным write-path для board operations + +**2. Отдельные реализации в controller и main-process** + +`🎯 4 🛡️ 4 🧠 4` + +Примерный объём: `120-220` строк изменений + +Риск: + +- review/clarification semantics почти гарантированно разъедутся со временем +- сложнее тестировать и объяснять различия + +**3. Новый общий workspace package / shared helper** + +`🎯 7 🛡️ 8 🧠 7` + +Примерный объём: `140-260` строк изменений + +Плюсы: + +- архитектурно чище в долгую + +Минусы: + +- это уже mini-refactor package boundaries +- для Phase 0/1 цена выше, чем польза + +Выбор: + +- для ближайшего rollout брать **вариант 1** +- вариант 3 оставлять как later cleanup, если agenda surface реально понадобится нескольким runtime слоям в одном виде + +### F. Как уводить `task_list` от teammate default workflow + +**1. Prompt + tool-catalog phased migration** + +`🎯 9 🛡️ 9 🧠 5` + +Примерный объём: `60-120` строк изменений + +Суть: + +- сначала вводим `lead_briefing` +- затем меняем prompts +- затем убираем `task_list` из teammate operational catalog или переводим его в less-prominent surface + +Почему это лучший вариант: + +- migration управляемая +- меньше surprising behavior для уже работающих flows + +**2. Prompt-only migration** + +`🎯 5 🛡️ 5 🧠 2` + +Примерный объём: `15-35` строк изменений + +Риск: + +- `task_list` всё равно остаётся у тиммейта "под рукой" +- модель будет продолжать иногда использовать его как shortcut + +**3. Instant hard removal from teammate catalog** + +`🎯 6 🛡️ 7 🧠 4` + +Примерный объём: `20-60` строк изменений + +Плюсы: + +- быстро убирает temptation + +Минусы: + +- выше шанс сломать существующие prompt assumptions и recovery workflows + +Выбор: + +- брать **вариант 1** + +### G. Как трактовать "review required" без явной policy-модели + +**1. Explicit-review-only semantics in Phase 0/1** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- queue считает review обязательным только когда есть явный review state / review cycle signal +- completed task без active review не получает auto-routing в `assign_reviewer` только потому, что "в команде есть reviewer" + +Почему это лучший вариант: + +- не выдумывает policy, которой в runtime state сейчас нет +- минимизирует ложные lead action items + +**2. Inference from free-form member roles** + +`🎯 3 🛡️ 3 🧠 3` + +Примерный объём: `20-40` строк изменений + +Суть: + +- пытаться читать `role` вроде `reviewer`, `qa`, `tech-lead` и решать, что completed task должна уйти в review + +Риск: + +- free-form role text не является надёжным policy contract +- легко получить false positives и странные queue jumps + +**3. Introduce explicit review policy field later** + +`🎯 8 🛡️ 9 🧠 7` + +Примерный объём: `90-180` строк изменений + +Суть: + +- в будущем добавить machine-readable team/task review policy +- только тогда можно safely строить routing вроде `completed -> assign_reviewer` без explicit review event + +Выбор: + +- для Phase 0/1 брать **вариант 1** +- вариант 3 держать как possible future enhancement + +### H. Какая roster resolution считается канонической для queue + +**1. Controller-owned roster normalization for queue** + +`🎯 8 🛡️ 9 🧠 5` + +Примерный объём: `50-110` строк изменений + +Суть: + +- queue в controller layer использует controller-side roster resolver +- но этот resolver сначала доводится до **минимально достаточного** predictable contract: + - removed members + - lead alias normalization + - suppression of phantom inbox-derived aliases where necessary + - ignore qualified external recipients + - ignore generated/internal pseudo-agent names, если они не пришли из explicit config/meta + +Плюсы: + +- queue остаётся рядом со своим runtime context +- не появляется cross-layer dependency на UI resolver + +**2. Reuse UI `TeamMemberResolver` semantics indirectly** + +`🎯 5 🛡️ 6 🧠 7` + +Примерный объём: `90-170` строк изменений + +Суть: + +- пытаться тащить roster rules из main/UI слоя обратно в controller path + +Риск: + +- package boundary усложняется +- для ближайшего rollout это слишком тяжело + +**3. Shared future roster package/helper** + +`🎯 7 🛡️ 9 🧠 8` + +Примерный объём: `120-220` строк изменений + +Суть: + +- вынести roster normalization в реально общий helper/package + +Плюсы: + +- меньше semantic drift в долгую + +Минусы: + +- для Phase 0/1 это уже дополнительный refactor + +Выбор: + +- в ближайшем rollout брать **вариант 1** +- но явно зафиксировать expected queue roster semantics тестами, чтобы drift от UI не был скрытым + +### I. Где должен жить `lead_briefing` в MCP tool catalog + +**1. Отдельная `lead` group с `teammateOperational: false`** + +`🎯 10 🛡️ 10 🧠 4` + +Примерный объём: `30-70` строк изменений + +Суть: + +- добавить отдельную tool group, например `lead` +- положить туда `lead_briefing` +- не смешивать этот tool с existing `task` group +- синхронно расширить `AgentTeamsMcpToolGroupId` в обеих локальных `.d.ts` + +Почему это лучший вариант: + +- текущий catalog already group-scoped, а не per-tool scoped +- это самый дешёвый способ гарантировать, что lead surface не попадёт в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- semantics остаётся прозрачной: teammate task tools отдельно, lead-only tool отдельно + +**2. Оставить `lead_briefing` в `task` group, но добавить per-tool denylist override** + +`🎯 5 🛡️ 7 🧠 6` + +Примерный объём: `40-90` строк изменений + +Суть: + +- сохраняем существующую `task` group +- поверх неё добавляем дополнительную per-tool политику, чтобы скрыть только `lead_briefing` + +Риск: + +- чинит symptom, а не модель +- делает catalog rules менее очевидными +- повышает шанс future drift между group semantics и effective availability + +**3. Оставить `lead_briefing` в `task` group и полагаться только на prompts** + +`🎯 2 🛡️ 2 🧠 1` + +Примерный объём: `5-20` строк изменений + +Суть: + +- tool физически доступен тиммейтам +- просто стараемся не упоминать его в teammate prompts + +Риск: + +- это не access policy, а надежда на prompt discipline +- модель всё равно сможет брать лишний tool как shortcut +- именно так и рождается путаница "почему у меня есть lead queue" + +Выбор: + +- для rollout брать **вариант 1** +- отдельную per-tool policy layer обсуждать только если позже реально появятся mixed-access tools внутри одной semantic group + +### J. Где должна жить semantics для filtered `task_list` + +**1. Dedicated controller-owned inventory method с явным allowlisted output contract** + +`🎯 9 🛡️ 10 🧠 5` + +Примерный объём: `50-110` строк изменений + +Суть: + +- raw `tasks.listTasks()` остаётся raw/read helper +- для `task_list` вводится отдельный semantic owner: + - либо public `tasks.listTaskInventory(filters?)` + - либо internal controller helper с тем же смыслом, если public width хотят держать узкой +- output contract фиксируется как explicit inventory row allowlist, а не `slimTaskForList(...)` blocklist passthrough + +Почему это лучший вариант: + +- фильтры и payload-shaping живут рядом с controller-owned queue/inventory semantics +- не приходится тихо переопределять смысл старого raw `listTasks()` +- migration к компактному output становится явной и тестируемой + +**2. Перегрузить существующий `tasks.listTasks(filters?)` и постепенно менять его смысл** + +`🎯 5 🛡️ 6 🧠 4` + +Примерный объём: `35-90` строк изменений + +Суть: + +- тот же метод начинает иногда означать raw list, а иногда filtered inventory + +Риск: + +- имя метода начинает врать о своей семантике +- выше шанс type/runtime drift и неожиданных побочных эффектов в existing callers + +**3. Оставить controller raw, а фильтрацию и shape собирать прямо в MCP tool** + +`🎯 4 🛡️ 5 🧠 3` + +Примерный объём: `25-70` строк изменений + +Суть: + +- `task_list` MCP tool сам вызывает raw `tasks.listTasks()` + kanban/review helpers и строит filtered inventory локально + +Риск: + +- semantics inventory начинает жить вне controller +- MCP и другие consumers почти гарантированно начнут drift'ить + +Выбор: + +- брать **вариант 1** +- raw `tasks.listTasks()` не надо тихо превращать в semantic inventory API + +### K. Как безопасно seed'ить lead-first tool access после появления `lead_briefing` + +**1. Отдельный explicit lead bootstrap seed list** + +`🎯 9 🛡️ 10 🧠 4` + +Примерный объём: `25-70` строк изменений + +Суть: + +- не переиспользовать `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` как proxy для lead-first surfaces +- завести отдельный explicit список initial lead bootstrap tools +- включить туда `lead_briefing` и другие реально нужные first-turn lead surfaces + +Почему это лучший вариант: + +- lead bootstrap и teammate operational workflow - разные вещи +- появление lead-only tool больше не ломает initial permission seed path +- semantics разрешений становится явной, а не побочной + +**2. Seed'ить лидеру все registered agent-teams tools** + +`🎯 4 🛡️ 6 🧠 2` + +Примерный объём: `10-30` строк изменений + +Суть: + +- чтобы не думать о разделении, лид получает сразу весь namespaced surface + +Риск: + +- слишком широкий blast radius по permissions +- теряется смысл role-scoped tool surfaces + +**3. Ничего не seed'ить отдельно, положиться на runtime permission prompt** + +`🎯 3 🛡️ 5 🧠 1` + +Примерный объём: `5-15` строк изменений + +Суть: + +- `lead_briefing` может быть доступен, но первый вызов пройдёт через ad hoc permission flow + +Риск: + +- первый ход лида становится менее детерминированным +- prompt говорит "сначала вызови lead_briefing", а runtime может отвечать permission friction + +Выбор: + +- брать **вариант 1** +- bootstrap-critical lead surfaces не должны зависеть от teammate permission list случайно + +### L. Как удержать agenda/inventory helpers internal при текущем `bindModule(...)` + +**1. Вынести их в отдельный internal module, который controller не bind'ит** + +`🎯 9 🛡️ 10 🧠 5` + +Примерный объём: `40-100` строк изменений + +Суть: + +- agenda/inventory derivation живёт в отдельном internal helper module +- `tasks.js` импортирует его, но не ре-экспортирует целиком +- `createController()` не видит этот helper module как public API + +Почему это лучший вариант: + +- accidental public export risk исчезает по самой структуре кода +- проще держать public controller contract узким + +**2. Оставить helpers в `tasks.js`, но не экспортировать их** + +`🎯 8 🛡️ 8 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- helpers остаются локальными функциями файла + +Риск: + +- работает, но `tasks.js` и так уже перегружен разными обязанностями +- выше шанс, что позже кто-то всё же экспортнет helper "для удобства" + +**3. Экспортнуть helper из `tasks.js`, но считать его internal по договорённости** + +`🎯 1 🛡️ 2 🧠 1` + +Примерный объём: `5-15` строк изменений + +Суть: + +- helper появляется в `module.exports`, но мы просто стараемся не использовать его как public API + +Риск: + +- при текущем `bindModule(...)` это уже не internal helper, а реальный public controller method +- это прямой путь к silent surface creep и type drift + +Выбор: + +- брать **вариант 1** +- вариант 2 допустим только если хотят минимальный diff и готовы жёстко держать export discipline + +### M. Как harden'ить lead bootstrap readiness для `lead_briefing` + +**1. Role-aware required-tool preflight после wiring stabilization** + +`🎯 9 🛡️ 10 🧠 5` + +Примерный объём: `40-100` строк изменений + +Суть: + +- после того как `lead` group, registration path и permission seed уже стабилизированы +- добавляем role-aware preflight invariant: + - teammate path требует `member_briefing` + - lead path требует `lead_briefing` +- validation делается через тот же `tools/list` / `tools/call` style readiness check + +Почему это лучший вариант: + +- если prompt делает `lead_briefing` canonical first action, runtime тоже должен это уметь проверять +- исчезает класс багов "prompt уже требует tool, а launch path его ещё не гарантирует" + +**2. Prompt-level canonical first call + explicit fallback until readiness proven** + +`🎯 7 🛡️ 7 🧠 2` + +Примерный объём: `15-40` строк изменений + +Суть: + +- lead prompt рекомендует `lead_briefing` +- но пока нет role-aware preflight, допускается fallback на inventory path при tool/permission failure + +Риск: + +- migration мягче, но менее детерминированна +- остаётся окно, где canonical surface ещё не является hard runtime invariant + +**3. Ничего не проверять дополнительно и надеяться на registration/permissions** + +`🎯 2 🛡️ 4 🧠 1` + +Примерный объём: `5-15` строк изменений + +Суть: + +- считаем, что если tool зарегистрирован где-то в системе, этого достаточно + +Риск: + +- код уже показывает, что explicit MCP readiness gate сейчас существует только для `member_briefing` +- значит для lead path это просто wishful thinking, а не контракт + +Выбор: + +- брать **вариант 1** +- до его внедрения prompt не должен делать `lead_briefing` hard bootstrap blocker без fallback + +### N. Где должен жить source of truth для lead bootstrap tool list + +**1. Exported controller/package constant рядом с existing teammate constants** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- если нужен lead bootstrap tool list, он живёт не локально в `TeamProvisioningService` +- а экспортируется из `mcpToolCatalog` рядом с: + - `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` + - `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- например как: + - `AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES` + - `AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES` + +Почему это лучший вариант: + +- bootstrap runtime не получает второй ручной список tool names +- catalog/permissions/prompts/tests смотрят в один и тот же source of truth +- migration для `lead_briefing` не размазывает naming policy по нескольким слоям + +**2. Локальный список прямо в `TeamProvisioningService`** + +`🎯 4 🛡️ 5 🧠 2` + +Примерный объём: `10-30` строк изменений + +Суть: + +- просто прописать lead bootstrap tools строками в runtime service + +Риск: + +- это создаёт второй source of truth рядом с catalog +- при следующем tool rename или regrouping drift почти неизбежен + +**3. Вычислять список динамически из `AGENT_TEAMS_MCP_TOOL_GROUPS` по эвристике** + +`🎯 5 🛡️ 6 🧠 5` + +Примерный объём: `20-60` строк изменений + +Суть: + +- runtime сам выводит lead bootstrap tools из group metadata или naming conventions + +Риск: + +- слишком неявно для bootstrap-critical path +- эвристика потом будет спорить с реальным prompt/runtime contract + +Выбор: + +- брать **вариант 1** +- bootstrap-critical tool lists должны экспортироваться явно, а не вычисляться по косвенным признакам + +--- + +## 3.3 Decision Freeze For Phase 0/1 + +Чтобы rollout не расползся и агенты не получили смесь полу-готовых правил, для Phase 0/1 фиксируем жёсткие ограничения: + +- per-task `kanban reviewer` не участвует в routing, пока write-path не начнёт реально его поддерживать +- `kanban.reviewers[]` трактуется только как reviewer pool / availability list, а не assignment на конкретную задачу +- clarification в routing contract живёт через explicit clear, а не через implicit "кто-то ответил" +- board lock в Phase 0 обязан покрыть controller agenda snapshot и multi-file board mutations, но не обязан сразу заменять все main-side raw readers +- queue roster использует controller-owned normalization c явно зафиксированными тестами, а не ad hoc смесь controller/UI эвристик +- `lead_briefing` в Phase 1 остаётся role-scoped surface без обязательного `leadName` параметра +- `lead_briefing` в catalog живёт в отдельной non-teammate-operational group, а не прячется внутри `task` group +- delivery уведомлений не входит в rollback boundary board-state mutation; board commit и message send надо разводить +- новая tool group не считается введённой, пока у неё нет явного registration path без duplicate registrations +- lead-first bootstrap не зависит только от teammate operational permission seed list +- source of truth для lead bootstrap tools экспортируется явно, а не живёт локальным списком в runtime service +- availability `lead_briefing` и permission seed для него трактуются как разные rollout concerns +- canonical first-turn `lead_briefing` не становится hard bootstrap invariant раньше, чем у lead path появляется явная readiness validation или безопасный fallback +- agenda/inventory helpers не утекают в public controller surface через случайный `module.exports` +- filtered `task_list` не должен вечно оставаться MCP-local blocklist wrapper над raw `tasks.listTasks()` +- generic board snapshot helper не становится public API, пока у него нет второго доказанного consumer + +--- + +## 3.4 Concrete Migration Blockers Found In Code + +Это не теоретические риски, а уже существующие места, которые диктуют rollout order. + +### A. Lead prompt still teaches `task_list` + +`🎯 10 🛡️ 10 🧠 2` + +Факт из кода: + +- `TeamProvisioningService` прямо содержит строку `List all tasks: task_list { teamName: "..." }` + +Следствие: + +- нельзя считать migration завершённым, пока lead prompt не будет переписан на `lead_briefing` как canonical first call +- одного нового MCP tool недостаточно +- member path сегодня жёстко валидирует именно `member_briefing`; lead path такого bootstrap gate пока не имеет +- поэтому у плана только 2 честных варианта: + - либо `lead_briefing` сначала canonical recommendation with fallback + - либо после stabilization wiring добавить явный lead-side preflight invariant +- prompt-only canonicalization без одного из этих двух путей здесь недостаточна + +### B. Member path already anchors on `member_briefing` -> `task_briefing` + +`🎯 10 🛡️ 10 🧠 2` + +Факт из кода: + +- member bootstrap сейчас жёстко валидирует наличие `member_briefing` +- member prompts уже учат использовать `task_briefing` как compact queue + +Следствие: + +- teammate migration почти не требует semantic renaming +- основной prompt migration blocker сейчас именно на lead path, а не на teammate path + +### C. Current `task_briefing` renderer lives in `taskStore` + +`🎯 9 🛡️ 9 🧠 4` + +Факт из кода: + +- `tasks.taskBriefing(...)` просто проксирует в `taskStore.formatTaskBriefing(...)` + +Следствие: + +- нельзя делать `lead_briefing` как второй независимый renderer рядом в store +- сначала нужен общий structured agenda DTO в controller-owned layer, и только потом два text renderer поверх него + +### D. Local `.d.ts` shims already drift in concrete ways + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- `src/types/agent-teams-controller.d.ts` и `mcp-server/src/agent-teams-controller.d.ts` уже расходятся не только "вообще", а по конкретным методам и формам +- примеры: + - main shim не знает `tasks.memberBriefing(...)` + - main shim не знает `tasks.getTaskComment(...)` + - main shim не знает `review.startReview(...)` + - main shim не знает `runtime` + - `lookupMessage(...)` типизирован по-разному между shim файлами + +Следствие: + +- любой rollout нового agenda/helper surface обязан идти вместе с type-sync gate +- иначе документ будет обещать безопасную миграцию, а монорепа получит тихий type/runtime drift + +### E. `lead_briefing` лучше, чем `lead_queue`, именно в Phase 1 + +`🎯 9 🛡️ 9 🧠 3` + +Причина выбора: + +- текущие briefing surfaces уже текстовые и хорошо ложатся в prompt workflow +- `lead_briefing` семантически ближе к уже существующим `member_briefing` и `task_briefing` +- JSON/queue-style surface можно добавить позже отдельным mode или отдельным tool, не мешая Phase 1 rollout + +Итог: + +- в Phase 1 canonical lead surface называем **`lead_briefing`** +- название `lead_queue` не используем как primary tool name в первой фазе + +### F. Lead name inference still drifts from shared lead detection + +`🎯 9 🛡️ 8 🧠 3` + +Факт из кода: + +- controller-side `inferLeadName(...)` ищет lead по ad hoc правилам и даже может упасть в `config.members[0]` +- shared `leadDetection` utilities живут по другому контракту и не должны тихо расходиться с queue semantics + +Следствие: + +- нельзя делать `lead_briefing` tool, который зависит от обязательного `leadName` input или от жёсткого "угадай одного lead actor" до того, как это выровнено +- role-scoped `lead_briefing` безопаснее, чем name-scoped surface + +### G. Как `lead_briefing` должен резолвить lead identity + +**1. Role-based lead surface without required `leadName` input** + +`🎯 10 🛡️ 10 🧠 3` + +Примерный объём: `20-40` строк изменений + +Суть: + +- `lead_briefing` - это role-scoped surface +- tool не требует `leadName` +- внутри response можно вернуть display header с каноническим lead name, если он надёжно резолвится, но routing не зависит от этого имени + +Почему это лучший вариант: + +- lead agenda одна на команду, а не на произвольного actor name +- не заставляет новый tool зависеть от слабого `inferLeadName(...)` как от hard requirement +- снижает шанс ложных ошибок из-за alias drift + +Edge cases, которые этот выбор должен переживать без падения: + +- в конфиге нет ни одного явно резолвимого lead candidate +- в конфиге несколько lead-like actors и single canonical name не выводится надёжно +- исторические task payload всё ещё содержат owner/reviewer alias `team-lead` + +Во всех этих случаях: + +- `lead_briefing` всё равно должен возвращать valid oversight queue +- допустим generic header уровня `Lead queue` +- ошибка из-за ambiguous lead identity здесь считается неправильным поведением Phase 1 + +**2. Require explicit `leadName` parameter** + +`🎯 4 🛡️ 6 🧠 4` + +Примерный объём: `20-50` строк изменений + +Суть: + +- сделать `lead_briefing(memberNameLikeLead)` по аналогии с `task_briefing(memberName)` + +Риск: + +- лишняя зависимость от prompt/runtime name correctness +- появится ещё один alias-sensitive contract там, где role-based surface и так достаточен + +**3. Infer lead name hard and fail if ambiguous** + +`🎯 5 🛡️ 7 🧠 5` + +Примерный объём: `40-80` строк изменений + +Суть: + +- заставить tool сначала выбрать один canonical lead name и падать при малейшей ambiguity + +Риск: + +- создаёт лишний bootstrap blocker для surface, которому имя лида не нужно для core routing + +Выбор: + +- для Phase 1 брать **вариант 1** +- отдельную canonical lead-name cleanup делать независимо от самого `lead_briefing` + +### H. Public API width in Phase 0/1 + +**1. Keep new snapshot/agenda helpers internal, expose only user-facing surfaces** + +`🎯 9 🛡️ 10 🧠 3` + +Примерный объём: `20-50` строк изменений + +Суть: + +- structured agenda DTO и board snapshot helper живут как internal controller implementation detail +- наружу в public controller/tool surface добавляются только: + - `tasks.taskBriefing(...)` + - `tasks.leadBriefing(...)` + - расширенный `task_list(...)` + +Почему это лучший вариант: + +- минимальный public blast radius +- меньше type-shim drift +- проще Phase 0 acceptance и rollback + +**2. Export generic `getBoardSnapshot(...)` immediately** + +`🎯 5 🛡️ 7 🧠 5` + +Примерный объём: `40-90` строк изменений + +Риск: + +- второй consumer для этого API ещё не доказан +- придётся сразу синхронно расширять runtime export surface и оба локальных `.d.ts` shims + +**3. Export both generic snapshot helper and structured agenda DTO** + +`🎯 3 🛡️ 5 🧠 6` + +Примерный объём: `70-140` строк изменений + +Риск: + +- слишком раннее раскрытие внутреннего abstraction surface +- потом намного сложнее будет менять внутренний shape без downstream breakage + +Выбор: + +- для Phase 0/1 брать **вариант 1** + +### I. `mcpToolCatalog` сейчас group-scoped и не умеет безопасно скрывать `lead_briefing` внутри `task` + +`🎯 10 🛡️ 10 🧠 3` + +Факт из кода: + +- `AGENT_TEAMS_MCP_TOOL_GROUPS` определяет teammate visibility на уровне whole group через `teammateOperational` +- `task` group сейчас teammate-operational +- `lead_briefing`, если просто добавить его в `AGENT_TEAMS_TASK_TOOL_NAMES`, автоматически попадёт в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- обе локальные `.d.ts` декларации сейчас знают только group ids: + - `task` + - `kanban` + - `review` + - `message` + - `process` + - `runtime` + - `crossTeam` + +Следствие: + +- rollout `lead_briefing` должен включать не только новый tool, но и отдельное catalog решение +- safest Phase 1 choice: + - новая `lead` group + - `teammateOperational: false` + - синхронное обновление обеих `AgentTeamsMcpToolGroupId` деклараций +- prompt migration без catalog migration здесь недостаточна + +### J. Board lock в controller path ещё не существует как реальный board primitive + +`🎯 10 🛡️ 9 🧠 4` + +Факт из кода: + +- task/kanban write paths сегодня не обёрнуты в общий board lock +- существующий `withFileLockSync(...)` живёт отдельно и используется не board path'ом, а cross-team inbox/outbox flow +- сам primitive синхронный, busy-wait и имеет фиксированные таймауты `5s` acquire / `30s` stale + +Следствие: + +- нельзя писать в плане просто "reuse existing lock" и считать, что board consistency уже почти есть +- нужен новый явный board contract: + - один lock на team board scope + - без silent unlocked fallback + - без ложного обещания, что low-level writes уже сериализованы сами по себе + +### K. `review_request` сейчас может оставить history/kanban drift при ошибке уведомления + +`🎯 10 🛡️ 10 🧠 4` + +Факт из кода: + +- `review_request(...)` делает `kanban.set -> task.history/reviewState update -> message send` +- если `message send` падает, catch делает только `kanban.clear` +- уже записанный `review_requested` history event не откатывается + +Следствие: + +- Phase 0 не должен строиться на предположении, что текущий rollback у review flow transactionally чистый +- safest fix: + - board mutation commit считать отдельной фазой + - уведомления отправлять post-commit как best-effort side effect + - queue semantics не должна зависеть от успеха inbox delivery + +### L. Main-side compatibility helpers уже расходятся с queue-safe reviewer contract + +`🎯 9 🛡️ 9 🧠 4` + +Факт из кода: + +- `TeamDataService.attachKanbanCompatibility(...)` берёт `reviewState` из persisted field +- `reviewer` там резолвится как `kanbanTaskState.reviewer ?? history(review_approved|review_started|review_requested)` + +Следствие: + +- agenda rollout нельзя строить через reuse этих compatibility semantics +- current-cycle queue resolver должен жить отдельно и явно +- если потом main/UI захочет тот же semantic surface, он должен звать официальный queue/inventory contract, а не копировать старую compatibility derivation + +### M. У `task_list` ещё нет настоящего backing contract для filters + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- MCP `task_list` сейчас делает только: + - `controller.tasks.listTasks()` + - `.map(slimTaskForList)` +- `ControllerTaskApi.listTasks()` типизирован без filters + +Следствие: + +- filters/limit для `task_list` требуют не только новую zod-схему в MCP tool +- нужно отдельно выбрать semantic owner: + - dedicated controller inventory method + - или internal controller inventory helper +- иначе filtered `task_list` получится полуручным MCP-side overlay без нормального contract owner + +### N. Новая MCP group требует отдельного registration wiring, а не только catalog entry + +`🎯 10 🛡️ 10 🧠 3` + +Факт из кода: + +- `mcp-server/src/tools/index.ts` строит registration через `REGISTRATION_BY_GROUP[group.id]` +- если добавить новый group id без этого map entry, `registerTools()` получит `undefined register` +- если наивно направить и `task`, и `lead` group на один и тот же `registerTaskTools`, легко получить duplicate registration того же tool set + +Следствие: + +- rollout новой `lead` group обязан синхронно решить registration strategy +- safest path: + - либо отдельный `registerLeadTools` + - либо другой явный one-time registration design без дублирования task tools +- одного изменения `mcpToolCatalog.js` здесь недостаточно + +### O. Lead permission seed path сейчас завязан на teammate operational tool set + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- `TeamProvisioningService` в lead bootstrap spec кладёт `permissionSeedTools` из `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- runtime suggestion expansion и teammate settings seeding тоже завязаны на этот же список +- этот permission seed path вообще активируется только когда `skipPermissions === false` +- при `skipPermissions !== false` launch идёт через `bypassPermissions`, и availability tool'а определяется registration/runtime wiring, а не seed list + +Следствие: + +- если `lead_briefing` уходит в новую non-teammate group, он не попадёт в lead bootstrap seed автоматически +- prompt может требовать `lead_briefing` как first call раньше, чем runtime permission path к нему стабилизирован +- rollout должен добавить отдельный lead-first seed contract, а не надеяться, что teammate list "и так почти подходит" +- при этом в плане надо различать 2 разных вопроса: + - tool **available** at runtime + - tool **pre-allowed/seeded** in permission-enabled mode +- смешивать availability и permission seeding в один пункт нельзя, иначе rollout обещает слишком много + +### Q. MCP preflight readiness сейчас зафиксирован только для `member_briefing` + +`🎯 10 🛡️ 9 🧠 3` + +Факт из кода: + +- `TeamProvisioningService` делает explicit `tools/list` + `tools/call` validation для `member_briefing` +- при отсутствии этого tool launch path падает как real bootstrap error +- аналогичного explicit readiness gate для `lead_briefing` сейчас нет + +Следствие: + +- если `lead_briefing` становится canonical first call, это пока ещё не равно hard-validated launch invariant +- Phase 1/1.5 должен выбрать один из честных путей: + - добавить role-aware required-tool preflight + - или сохранить explicit fallback до его появления +- нельзя писать в плане так, будто у lead path уже есть та же степень bootstrap гарантии, что и у teammate path + +### R. Для lead bootstrap list сейчас нет exported constant, аналогичного teammate constant + +`🎯 10 🛡️ 9 🧠 2` + +Факт из кода: + +- `mcpToolCatalog.js` экспортирует `AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- `TeamProvisioningService` импортирует именно этот exported constant +- аналогичного exported constant для lead bootstrap surfaces сейчас нет + +Следствие: + +- если lead bootstrap tools выбрать правильно, их всё равно легко положить "временным локальным массивом" в runtime service +- это создаст новый source of truth рядом с catalog и tests +- safest path: + - экспортировать dedicated lead bootstrap constants из controller package root + - и уже их использовать в runtime/bootstrap wiring + +### S. Unreadable task rows сейчас silently выпадают из raw reader + +`🎯 9 🛡️ 9 🧠 4` + +Факт из кода: + +- `taskStore.listRawTasks(...)` обходит все task JSON files +- при `normalizeTask(...)` error такая строка просто пропускается +- это значит, что malformed task row может исчезнуть из list-based views без явного сигнала + +Следствие: + +- derived queue не должна inherit'ить semantics "если задача не читается, притворимся, что её нет" +- особенно опасно это для lead surface, потому что board corruption превращается в silent omission +- safest Phase 0 path: + - queue-grade snapshot builder собирает `anomalies[]` + - `lead_briefing` поднимает такие случаи как repair bucket / warning summary + - `task_briefing` не делает вид, что board чист, если snapshot уже увидел unreadable rows + +### P. `createController()` автоматически публикует весь `module.exports` bound module'а + +`🎯 10 🛡️ 10 🧠 3` + +Факт из кода: + +- `controller.js` делает `bindModule(context, tasks)` поверх всего `module.exports` из `internal/tasks.js` +- значит любой новый export из `tasks.js` автоматически становится method на `controller.tasks` + +Следствие: + +- internal agenda/inventory helpers нельзя просто "временно экспортнуть" из `tasks.js` +- иначе public controller surface начнёт расползаться почти случайно +- safest path: + - отдельный internal helper module + - или local non-exported functions в `tasks.js` + +--- + +## 4. Что не является целью + +- не строим в Phase 1 полноценный event-sourced board engine +- не переносим source of truth в отдельный projection-файл +- не делаем сложный real-time delta protocol с первой итерации +- не пытаемся заменить `task_get` подробной очередью +- не делаем `task_list` основным рабочим API для тиммейта + +--- + +## 5. Рассмотренные варианты + +### Вариант 1. Derived agenda on read + team lock + role queues + +`🎯 10 🛡️ 10 🧠 6` + +Примерный объём: `250-420` строк изменений + +Суть: + +- не создаём persisted projection как новый durable слой +- строим derived queue прямо во время чтения +- читаем raw task/review/kanban состояние под общим team-level lock +- делаем канонические queue surfaces для лида и участника +- `task_list` превращаем в filtered inventory + +Плюсы: + +- минимальный риск двойного source of truth +- проще доказать корректность +- быстрее rollout +- меньше шансов получить stale queue + +Минусы: + +- нет built-in delta sync с первого дня +- каждое queue чтение требует пересчёта + +### Вариант 2. Persisted projection sidecar с первого дня + +`🎯 8 🛡️ 7 🧠 6` + +Примерный объём: `320-520` строк изменений + +Суть: + +- при каждом mutation пересчитывать и сохранять projection-файл рядом с board state + +Плюсы: + +- быстрые чтения +- удобная база для future delta + +Минусы: + +- второй durable слой +- выше риск расхождения raw state и projection +- нужно аккуратно закрывать partial write / multi-file atomicity + +### Вариант 3. Сразу agenda + delta + persisted projection + +`🎯 6 🛡️ 5 🧠 8` + +Примерный объём: `450-750` строк изменений + +Суть: + +- сразу строить полноценный snapshot/revision/delta протокол + +Плюсы: + +- самая "умная" архитектура на бумаге + +Минусы: + +- это лучший способ слишком рано усложнить систему +- высокий риск словить subtle bugs в очередях +- большая цена ошибок, потому что именно queue tool влияет на поведение агентов + +### Выбор + +Выбираем **Вариант 1 как Phase 1**, а delta/revision переносим в **Phase 2**. + +Это даёт правильную последовательность: + +1. сначала стабилизируем семантику и ownership +2. потом оптимизируем транспорт и payload + +--- + +## 6. Архитектурный принцип + +Правильный порядок слоёв такой: + +1. **Raw source of truth** + - task files + - kanban-state + - review history / reviewState + +2. **Derived decision layer** + - `actionOwner` + - `nextAction` + - `queueCategory` + - `watchers` + - `reasonCode` + +3. **Role-based queue surfaces** + - teammate agenda + - lead agenda via `lead_briefing` + +4. **Inventory/search surface** + - filtered `task_list` + +5. **Full-detail surface** + - `task_get` + +⚠️ Важно: политика работы должна жить в layer 2, а не в голове LLM. + +--- + +## 7. Канонические tool surfaces + +### 7.1 `member_briefing` + +Назначение: + +- identity +- роль +- рабочие правила +- как пользоваться board tooling + +Не должно включать: + +- полный живой список задач +- большие live queue payload + +Причина: + +- bootstrap-инструмент не должен разрастаться вместе с board state +- иначе он станет одновременно и prompt bootstrap, и live queue, и будет быстро протухать + +### 7.2 `task_briefing` + +Назначение: + +- каноническая compact operational queue для конкретного участника + +Новый смысл: + +- не просто "мои assigned tasks" +- а "что мне сейчас делать и что мне важно знать" + +Структура ответа: + +- identity / actor +- `actionable` +- `awareness` +- компактные counters +- `revision` в future phase + +⚠️ Важное уточнение после code review: + +- в текущем runtime `task_briefing` возвращает текст, а не JSON +- поэтому safest Phase 0/1 path такой: + - внутри строим **structured agenda DTO** + - наружу по-прежнему рендерим компактный текст для совместимости + - JSON-variant можно добавлять позже как отдельный surface или новый output mode + +Так мы не блокируем future delta/revision, но и не делаем лишний breaking change в самом начале. + +Exact Phase 1 contract: + +- input: + - `teamName` + - `memberName` - обязателен +- output: + - text briefing +- semantics: + - actor-specific + - primary teammate operational surface + - built from internal structured agenda DTO, а не напрямую из legacy formatter logic + +Дополнительное архитектурное правило: + +- `taskStore.formatTaskBriefing(...)` в нынешнем виде не должен становиться permanent semantic owner новой agenda logic +- правильный direction такой: + - controller-owned structured agenda DTO + - текстовый renderer для `task_briefing` + - текстовый renderer для `lead_briefing` + +### 7.3 `lead_briefing` + +Нужен отдельный lead surface. + +Выбор имени для Phase 1: + +- canonical tool name: `lead_briefing` + +Почему не `lead_queue`: + +- текущие briefings уже текстовые +- prompt ecosystem уже заточен на human-readable briefing surfaces +- это уменьшает объём breaking changes в MCP descriptions и lead prompts +- future structured/JSON variant можно добавить позже без конфликта имён + +Почему не надо использовать raw `task_list` как lead queue: + +- лид тоже не должен разгребать весь inventory каждый ход +- lead queue должна показывать именно: + - unassigned + - reviewer missing + - clarification needed + - dependency repair + - review routing anomalies + - waiting on user + - aggregate board pressure + +Рекомендация по rollout: + +- вводим новый lead-specific surface отдельно +- в первой фазе это именно `lead_briefing`, а не новый JSON queue contract +- только после этого перестаём учить lead использовать `task_list` как first stop +- не делаем silent redefinition `task_list` в тот же самый момент + +Exact Phase 1 contract: + +- input: + - `teamName` + - без обязательного `leadName` +- controller contract: + - `tasks.leadBriefing(): Promise` +- MCP contract: + - `lead_briefing { teamName, claudeDir? }` +- output: + - text briefing +- semantics: + - role-scoped lead operational surface + - не зависит от точного caller alias + - может отображать resolved lead name в header, но routing не должен зависеть от него +- ambiguity handling: + - unique lead name найден -> можно показать его в header + - unique lead name не найден -> вернуть generic `Lead queue` header без ошибки +- catalog placement: + - отдельная `lead` group + - `teammateOperational: false` + - `lead_briefing` не должен попадать в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` + +### 7.3.1 Bootstrap sequencing for `lead_briefing` + +Здесь нельзя делать rollout "в любом порядке". + +Безопасная последовательность такая: + +1. сначала зарегистрировать `lead_briefing` как отдельный tool surface: + - новая `lead` group + - явный registration path в `mcp-server/src/tools/index.ts` + - синхронные `.d.ts` updates +2. потом экспортировать отдельный lead bootstrap tool list рядом с teammate constants +3. потом wired'ить lead permission seed path для режима, где `skipPermissions === false` +4. потом выбрать один честный runtime contract для first-turn availability: + - либо role-aware preflight hard-check + - либо явный documented fallback +5. только после этого переписывать lead prompt так, чтобы `lead_briefing` был canonical first action + +No-go rule: + +- нельзя сначала написать в lead prompt "сначала вызови `lead_briefing`", а потом уже разбираться, видит ли его runtime вообще +- нельзя считать `bypassPermissions` mode доказательством того, что отдельный lead bootstrap path не нужен +- availability, registration и permission seeding - это три разных слоя rollout + +### 7.4 `task_list` + +Новая роль: + +- filtered inventory/search +- fallback browse tool +- не основной operational queue + +Желаемое поведение: + +- additive filters только по стабильным dimension'ам: + - `owner` + - `status` + - `reviewState` + - `kanbanColumn` + - `relatedTo` + - `blockedBy` + - `limit` +- для routine workflow агентам в prompt рекомендуем не `task_list`, а `task_briefing` + +Что сознательно **не** добавляем в первой фазе: + +- `member` filter +- `reviewer` filter + +Причина: + +- `member` слишком двусмысленный: owner, actionOwner, watcher или просто "как-то связан" +- `reviewer` в текущем runtime не является стабильным raw inventory field +- actor-centric queries должны идти через `task_briefing` и `lead_briefing`, а не через перегруженный `task_list` + +Уточнение по фильтрам: + +- `status` фильтрует raw `task.status` +- `reviewState` фильтрует effective review state +- `kanbanColumn` в первой фазе допустим только как alias для review overlay columns: + - `review` + - `approved` +- не надо вводить generic `column=todo|in_progress|done`, будто inventory layer уже знает full kanban model для всех задач +- `relatedTo` и `blockedBy` должны принимать обычный task ref в том же формате, что и `task_get` / `resolveTaskRef` + +Правила фильтрации: + +- фильтры conjunctive, а не "любой из" +- `limit` применяется после фильтрации +- отсутствие фильтров сохраняет совместимую unfiltered semantics +- filtered `task_list` не сортирует по agenda-priority и не подменяет `actionOwner` +- output остаётся slim inventory JSON, а не agenda DTO +- если filter требует kanban knowledge (`reviewState`, `kanbanColumn`), он реализуется в controller-owned inventory layer, а не через raw `taskStore.listTasks()` напрямую + +Exact Phase 1 MCP contract: + +- `task_list { teamName, claudeDir?, owner?, status?, reviewState?, kanbanColumn?, relatedTo?, blockedBy?, limit? }` + +Exact backing rule: + +- raw `tasks.listTasks()` не надо тихо переопределять как filtered inventory API +- safest path: + - dedicated controller inventory contract + - или internal controller-owned inventory helper, который является semantic owner для MCP tool +- MCP layer не должен сам становиться permanent owner review/kanban-aware filtering logic + +Output-shape migration rule: + +- текущий `slimTaskForList(...)` blocklist-подход приемлем только как legacy compatibility starting point +- целевой compact contract для `task_list` должен быть explicit allowlist inventory row +- migration на allowlist делается явно и с contract tests, а не тихо "когда-нибудь потом" + +Public-surface rule for Phase 0/1: + +- `task_briefing` +- `lead_briefing` +- `task_list` + +это public MCP/controller surfaces. + +А вот: + +- structured agenda DTO +- generic board snapshot helper + +остаются internal implementation details, пока у них не появится второй доказанный consumer. + +### 7.4.1 Target inventory row for `task_list` + +Чтобы migration away from blocklist была однозначной, у `task_list` нужен целевой public row contract уже в плане. + +Target V1 row: + +```ts +type TaskInventoryRow = { + id: string; + displayId: string; + subject: string; + status: 'pending' | 'in_progress' | 'completed' | 'deleted'; + owner?: string; + reviewState: 'none' | 'review' | 'needsFix' | 'approved'; + needsClarification?: 'lead' | 'user'; + blockedBy?: string[]; + blocks?: string[]; + related?: string[]; + commentCount: number; + createdAt?: string; + updatedAt?: string; +}; +``` + +Важно: + +- здесь `reviewState` - это effective inventory review state, а не слепой passthrough сырого `task.reviewState` +- inventory row deliberately не включает: + - `comments` + - `historyEvents` + - `workIntervals` + - `attachments` + - `prompt` + - `sourceMessage` + - произвольные future fields по blocklist-инерции +- allowlist должен быть закрытым и тестируемым +- если позже реально понадобится ещё одно поле, его добавляют осознанно через contract change, а не "оно само просочилось" + +Уточнение по migration safety: + +- в раннем rollout лучше сначала добавить фильтры и новый `lead_briefing` +- а default unfiltered semantics `task_list` оставить совместимой, пока prompts и callers не переедут +- teammate catalog можно урезать раньше, чем глобально менять meaning самого инструмента + +### 7.5 `task_get` + +Роль остаётся прежней: + +- полный контекст одной конкретной задачи +- history/comments/dependencies/files/clarifications + +Новая практика: + +- queue surface выдаёт компактные карточки и ссылки на task ids +- детали читаются через `task_get` + +--- + +## 8. Derived read model + +### 8.1 Raw входы + +Derived layer должен смотреть на: + +- `task.status` +- `task.owner` +- `task.dependencies` +- `task.reviewState` +- history events по review / status / clarification +- kanban column +- reviewer pool availability +- runtime identity actor +- queue roster snapshot + +### 8.1.1 History-derived vs field-derived signals + +Это важное уточнение после code review. + +Не все workflow-сигналы в текущей системе одинаково "историчны": + +- **history-derived** + - review state + - review cycle transitions + - work status transitions +- **field-derived** + - current owner + - current `needsClarification` + - current dependency lists + - current related links + +Практическое следствие: + +- review resolver можно и нужно строить на `historyEvents` +- owner/clarification resolver нельзя делать так, будто у него такой же append-only history contract внутри task JSON +- task logs и transcript activity могут быть полезны для диагностики, но не должны становиться required input для queue derivation в Phase 0/1 +- actor identity normalization обязательна: + - реальное имя лида + - `team-lead` + должны считаться одним logical actor там, где речь идёт о valid ownership/reply semantics +- roster validity должна проверяться по одной канонической queue-side нормализации, а не по смешению controller и UI resolver heuristics + +Ещё одно важное следствие: + +- queue semantics и stall-monitor semantics не обязаны совпадать 1-в-1 +- для queue review может быть actionable уже с `review_requested` +- для stall-monitor "started review" может начинаться только с `review_started` + +Это не конфликт, а разные вопросы: + +- queue отвечает "кто должен сделать следующий шаг" +- stall-monitor отвечает "есть ли доказательство, что review реально начали" + +### 8.1.2 Hard exclusions for Phase 0/1 + +Чтобы derived queue не строилась на сигналах, которые код сегодня не поддерживает как надёжные: + +- не используем `kanban-state.tasks[taskId].reviewer` для routing +- не используем `kanban.reviewers[]` как assignment конкретного reviewer +- не используем task logs / transcript activity как required input +- не выводим mandatory review из free-form role текста вроде `reviewer`, `qa`, `tech-lead` + +### 8.1.3 Signal trust matrix + +Важное правило: derived queue не "усредняет" все сигналы. У каждого сигнала должен быть свой trust level. + +| Signal | Role in queue | Trust level in Phase 0/1 | +| --- | --- | --- | +| `historyEvents.review_*` текущего цикла | active review routing | authoritative | +| `task.reviewState` | fallback review signal | advisory fallback | +| `kanban-state.tasks[taskId].column` | overlay hint for review/approved | advisory fallback | +| `task.owner` | current accountable implementer | authoritative after roster normalization | +| `task.needsClarification` | current wait reason | authoritative after explicit-clear hardening | +| `task.blockedBy` / `blocks` / `related` | current dependency graph | authoritative but target refs must be revalidated | +| `kanban.reviewers[]` | reviewer pool / candidate set | advisory only, never per-task routing | +| `kanban-state.tasks[taskId].reviewer` | per-task reviewer | forbidden for routing in Phase 0/1 | +| task logs / transcript activity | diagnostics | debug-only | + +Практическое правило: + +- если два сигнала противоречат друг другу, выигрывает более trusted source +- weaker signal можно использовать только как fallback, но не как "доказательство против" stronger signal + +### 8.1.4 Conflict resolution when sources disagree + +Ниже не просто примеры, а конкретные implementation guardrails: + +| Conflict | Winner | Why | +| --- | --- | --- | +| `historyEvents` говорит, что review активен, а `task.reviewState === none` | history | review cycle уже durable в истории | +| `review_approved` или `review_changes_requested` уже записан, но kanban всё ещё выглядит как `review` | history | stale kanban overlay не должен держать review открытым | +| `task.reviewState === review`, но current-cycle reviewer не резолвится | lead `assign_reviewer` | open review без валидного reviewer хуже, чем false confident routing | +| `needsClarification` выставлен и одновременно есть owner/reviewer routing | clarification | вопрос/блокер сильнее обычного execution flow | +| owner невалиден, но review reviewer валиден | lead `assign_owner` | потерян accountable owner, Phase 0/1 чинит ownership раньше обычного routing | +| `requestReview` durable commit прошёл, а notification send упал | committed board state | queue truth не должна зависеть от доставки сообщения | + +Дополнительный guardrail: + +- derived queue не пытается "склеивать" conflicting truth из `task.reviewState` и `kanban` в новый synthetic state +- если resolver не может безопасно доказать ownership/reviewer path, он обязан деградировать в lead repair bucket + +### 8.2 Derived поля + +Минимальный набор: + +```ts +type DerivedActionOwner = + | { kind: 'member'; memberName: string } + | { kind: 'lead' } + | { kind: 'user' } + | { kind: 'none' }; + +type DerivedNextAction = + | 'execute' + | 'review' + | 'apply_changes' + | 'assign_owner' + | 'assign_reviewer' + | 'clarify_with_user' + | 'clarify_with_lead' + | 'repair_dependencies' + | 'wait_dependency' + | 'wait_review' + | 'none'; + +type DerivedQueueCategory = 'actionable' | 'waiting' | 'oversight' | 'done'; +``` + +### 8.2.1 Minimal internal agenda DTO for Phase 0/1 + +Чтобы text renderers не собирали семантику каждый по-своему, у внутреннего DTO должен быть один минимальный контракт: + +```ts +type AgendaItem = { + taskId: string; + displayId: string; + subject: string; + status: 'pending' | 'in_progress' | 'completed' | 'deleted'; + reviewState: 'none' | 'review' | 'needsFix' | 'approved'; + actionOwner: DerivedActionOwner; + nextAction: DerivedNextAction; + queueCategory: DerivedQueueCategory; + reasonCode: string; + owner?: string; + reviewer?: string | null; + blockedBy?: string[]; + watchers?: string[]; + needsClarification?: 'lead' | 'user'; + lastMeaningfulEventAt?: string; + derivedFrom?: string[]; +}; + +type AgendaAnomaly = { + code: + | 'unreadable_task' + | 'invalid_dependency_ref' + | 'invalid_owner_ref' + | 'invalid_reviewer_ref'; + detail: string; + taskId?: string; +}; + +type AgendaSnapshot = { + actor: + | { kind: 'member'; memberName: string } + | { kind: 'lead' }; + actionable: AgendaItem[]; + awareness: AgendaItem[]; + anomalies: AgendaAnomaly[]; + counters: { + actionable: number; + awareness: number; + blocked: number; + waitingOnUser: number; + waitingOnLead: number; + reviewNeeded: number; + anomalies: number; + }; +}; +``` + +Важно: + +- это internal contract для derivation + rendering +- `task_briefing` и `lead_briefing` рендерятся из него +- `task_list` из него не рендерится и не обязан совпадать с ним по shape + +Дополнительно полезно: + +- `reasonCode` +- `watchers` +- `relatedMemberNames` +- `blockedBy` +- `reviewer` +- `lastMeaningfulEventAt` +- `derivedFrom` + +Где `derivedFrom` - это внутренний debug/verification след: + +- `history_review_requested` +- `history_review_started` +- `kanban_state` +- `clarification_flag` +- `dependency_graph` +- `owner_status` + +Он не обязателен в финальном публичном payload, но сильно помогает тестировать resolver и объяснять спорные queue decisions. + +Ограничение по `lastMeaningfulEventAt`: + +- это поле нельзя делать опорой для routing +- его можно использовать для sorting / summaries / tie-breaks + +Причина: + +- часть важных изменений живёт только в current task fields и `updatedAt` +- а не в полноценном append-only workflow event stream + +### 8.2.2 `reasonCode` should use a closed v1 taxonomy + +Если `reasonCode` оставить "любой строкой", resolver и renderer очень быстро начнут drift'ить. + +Минимальный закрытый v1 набор лучше зафиксировать сразу: + +- `waiting_user_clarification` +- `waiting_lead_clarification` +- `owner_missing` +- `owner_invalid` +- `review_reviewer_missing` +- `review_requested_waiting_pickup` +- `review_in_progress` +- `dependency_broken` +- `dependency_waiting` +- `needs_fix` +- `owner_executing` +- `owner_ready` +- `completed_no_followup` +- `terminal_approved` +- `terminal_deleted` +- `anomaly_unreadable_task` + +Это не значит, что список никогда не расширится. + +Это значит: + +- расширение должно быть явным +- snapshot tests и text renderers должны работать от одной allowlist reason codes +- новая строка reasonCode считается contract change, а не случайной внутренней деталью + +### 8.3 Что такое `actionOwner` + +`actionOwner` - это **один primary actor**, который должен сделать следующий meaningful workflow шаг. + +Это не: + +- просто owner +- просто reviewer +- просто последний писавший комментарий + +Это именно ответ на вопрос: + +> кто сейчас должен сдвинуть задачу вперёд + +### 8.4 Что такое `watchers` + +`watchers` - это **не primary routing**, а вспомогательная visibility-модель. + +Их роль: + +- awareness +- summaries +- future notifications +- optional context in lead queue + +Их нельзя использовать как главный способ строить operational queue, иначе лид или участник снова начнут видеть почти всё подряд. + +--- + +## 9. Базовый precedence для `actionOwner` + +Ниже канонический порядок принятия решения. Более раннее правило сильнее позднего. + +### 9.1 Terminal + +Если задача: + +- deleted +- approved +- окончательно завершена без ожидаемого follow-up + +Тогда: + +- `actionOwner = none` +- `nextAction = none` +- в operational queue не попадает +- остаётся доступной через inventory/search + +### 9.2 Clarification from user + +Если задача явно ждёт ответа от пользователя: + +- `actionOwner = user` +- `nextAction = clarify_with_user` +- лид получает oversight item +- owner получает awareness item, если задача его + +### 9.3 Clarification from lead + +Если задача ждёт решения/уточнения от лида: + +- `actionOwner = lead` +- `nextAction = clarify_with_lead` + +Phase 0 guardrail: + +- пока clarification semantics стабилизированы через explicit clear, комментарий лида сам по себе не считается достаточным для снятия wait-state +- это сознательная консервативность: лучше лишний lead oversight item, чем скрытый неочищенный blocker + +### 9.4 Invalid owner + +Если owner отсутствует, пустой, удалён или не резолвится в roster: + +- `actionOwner = lead` +- `nextAction = assign_owner` + +Уточнение: + +- `owner === "team-lead"` нельзя автоматически считать invalid +- alias `team-lead` и canonical lead name должны проходить через одну lead-normalization логику + +### 9.5 Review requested, reviewer unresolved + +Если задача ушла в review, но reviewer не удалось надёжно определить: + +- `actionOwner = lead` +- `nextAction = assign_reviewer` + +### 9.6 Review requested, reviewer resolved + +Если review активен и reviewer валиден: + +- `actionOwner = member(reviewer)` +- `nextAction = review` +- owner получает awareness item `wait_review` + +Дополнительная проверка валидности reviewer обязательна: + +- если reviewer помечен как removed member +- или reviewer больше не резолвится в текущий roster + +то задача должна деградировать в: + +- `actionOwner = lead` +- `nextAction = assign_reviewer` + +Уточнение: + +- reviewer нельзя выводить только из `kanban-state.reviewers[0]` как будто это стабильное назначение на конкретную задачу +- для queue нужен отдельный resolver активного reviewer именно для **текущего review cycle** +- safest precedence: + - latest `review_started.actor` внутри текущего review cycle + - latest `review_request.reviewer` внутри текущего review cycle + - иначе unresolved -> `assign_reviewer` + +Здесь есть важное ужесточение после повторного code review: + +- в Phase 0/1 **не надо** делать fallback в `kanban-state.tasks[taskId].reviewer` +- текущие write-path на входе в review всё равно пишут туда `null` +- значит такой fallback только создаст ложное ощущение надёжности, но не даст реальной пользы + +Практическая реализация должна быть совместима с уже существующей history-based derivation review state: + +```ts +function resolveCurrentCycleReviewer(task, validMembers) { + const events = [...(task.historyEvents ?? [])]; + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + + if (event.type === 'review_started' && isValidQueueMember(event.actor, validMembers)) { + return { reviewer: event.actor, source: 'history_review_started_actor' }; + } + + if ( + event.type === 'review_requested' && + isValidQueueMember(event.reviewer, validMembers) + ) { + return { reviewer: event.reviewer, source: 'history_review_requested_reviewer' }; + } + + if (event.type === 'review_approved' || event.type === 'review_changes_requested') { + break; + } + + if (event.type === 'status_changed' && event.to === 'in_progress') { + break; + } + + if (event.type === 'task_created') { + break; + } + } + + return { reviewer: null, source: 'none' }; +} +``` + +Консервативное правило: + +- если resolver сомневается, задача идёт в lead queue как `assign_reviewer` +- лучше false positive в lead queue, чем false confident routing на не того reviewer + +И ещё один важный guardrail: + +- даже если в `kanban-state` schema есть поле `reviewer`, Phase 0/1 не должны считать его canonical signal +- пока официальный mutation surface не поддерживает reviewer как стабильно обновляемое per-task поле, history-first resolver остаётся единственно правильной базой + +Дополнительные edge rules: + +- если `review_started.actor` и `review_requested.reviewer` расходятся, выигрывает `review_started.actor` как более сильное доказательство того, кто реально взял review +- если reviewer совпадает с owner, queue не пытается "исправить" это сама - self-review сегодня не запрещён runtime-контрактом, значит это отдельная policy-задача, а не routing-эвристика +- если последний `review_requested.reviewer` или `review_started.actor` больше не проходит queue roster normalization, routing деградирует в `assign_reviewer`, а не в попытку взять "первого доступного reviewer из пула" + +### 9.7 Broken dependencies + +Если есть dependency на несуществующую, deleted или испорченную задачу: + +- не делаем auto-unblock +- `actionOwner = lead` +- `nextAction = repair_dependencies` + +### 9.8 Healthy blocking dependency + +Если задача блокируется живой dependency: + +- `actionOwner = none` +- `nextAction = wait_dependency` +- owner получает awareness +- лид может видеть это только в aggregate summary или при stall/escalation + +### 9.9 Needs fixes after review + +Если был `review_request_changes` или equivalent state: + +- `actionOwner = member(owner)` +- `nextAction = apply_changes` + +### 9.10 In progress + +Если задача реально исполняется и ничего выше не сработало: + +- `actionOwner = member(owner)` +- `nextAction = execute` + +### 9.11 Pending with owner + +Если задача pending и готова к работе: + +- `actionOwner = member(owner)` +- `nextAction = execute` + +### 9.12 Completed without active review + +Если задача completed, reviewer не нужен или ещё не инициирован: + +- в Phase 0/1 по умолчанию `actionOwner = none` +- completed task не должна автоматически превращаться в `assign_reviewer` только из-за reviewer pool или free-form reviewer role в команде + +Важно: + +- пока в системе нет explicit machine-readable review policy, queue не должна придумывать обязательность review из эвристик +- review routing начинается только с явного review signal: + - `review_requested` + - `review_started` + - `reviewState === review` + - future explicit review policy field, если он когда-то появится + +--- + +## 10. Критические edge cases + +### 10.1 Self-review + +Если `reviewer === owner`, это плохой routing. + +Правильное поведение: + +- не давать такой задаче стать нормальной review-card для owner +- отправлять её в lead queue как `assign_reviewer` +- reasonCode: `self_review_invalid` + +### 10.2 Несколько reviewers + +Если когда-то появится multi-review: + +- Phase 1 не должен пытаться распределить actionOwner между несколькими людьми +- если активных reviewers > 1, это lead attention item, пока не появится формальная multi-review модель + +Иначе будет неочевидный primary owner. + +### 10.3 Missing reviewer после `review_request` + +Даже если текущий runtime допускает `review_request` без reviewer, queue layer не должен делать вид, что всё нормально. + +Правильное поведение: + +- lead action item +- не "висит у owner" +- не "висит ни у кого без объяснения" + +### 10.3.1 Previous review cycle bleed-through + +Отдельный риск: + +- если reviewer выводится из history слишком грубо, можно случайно подтянуть reviewer из предыдущего review cycle + +Поэтому queue resolver должен быть cycle-aware: + +- смотреть на последние review events +- учитывать reset после возврата задачи в `in_progress` / нового рабочего цикла +- не использовать старого approver как текущего reviewer только потому, что это последнее известное review-событие + +### 10.3.2 Review requested but not started yet + +Это отдельный важный sub-state. + +Если: + +- review уже request'нут +- reviewer валиден +- но `review_started` ещё не было + +то для queue это всё равно reviewer-owned actionable item: + +- `actionOwner = member(reviewer)` +- `nextAction = review` +- `reasonCode` стоит различать, например `review_requested_waiting_pickup` + +Но при этом нельзя механически переносить сюда stall-monitor semantics: + +- для stall policy review окно может считаться ещё не "started" +- для queue reviewer уже является тем, кто должен сделать следующий шаг + +### 10.4 Missing/deleted dependency + +Это не wait-state. Это broken workflow. + +Правильное поведение: + +- лид получает repair item +- owner не должен автоматически считать задачу разблокированной + +### 10.5 Комментарий на completed/review/approved задаче + +Новый комментарий сам по себе не меняет `actionOwner`. + +Иначе будут ложные reopening-сигналы. + +Комментарий может: + +- обновить `lastMeaningfulEventAt` +- появиться в awareness + +Но не должен автоматически переводить задачу в actionable queue другого актёра. + +### 10.6 Owner changed mid-review + +Если owner поменяли, пока review активен: + +- actionOwner остаётся reviewer, пока review открыт +- новый owner видит awareness +- после `review_request_changes` или `review_approve` уже действует обычная пост-review логика + +### 10.6.1 Removed member as owner/reviewer + +Если owner или reviewer указывает на участника, который уже в `removedNames`: + +- queue не должна считать такого actor valid action owner +- это не waiting state, а routing repair case + +Правильное поведение: + +- removed owner -> lead queue `assign_owner` +- removed reviewer -> lead queue `assign_reviewer` + +Причина: + +- `resolveTeamMembers(...)` уже умеет исключать removed members из активного roster +- значит derived agenda должна использовать ту же реальность, а не слепо верить старому имени в task field/history + +### 10.7 Runtime identity не определился + +После code review это не такой большой риск для `task_briefing`, как казалось сначала, потому что текущий MCP tool уже требует явный `memberName`. + +Поэтому правильная формулировка такая: + +- для `task_briefing` ambiguity runtime identity не должна быть primary problem +- для `member_briefing` fallback по runtime identity остаётся полезным bootstrap-path +- если появится future tool без явного `memberName`, он уже не должен молча опираться на неуверенную runtime identity + +Если system не смогла надёжно вывести `memberName` там, где identity всё же нужна: + +- queue tool не должен молча вернуть `No tasks` +- нужно: + - либо требовать `memberName` + - либо возвращать явную ошибку identity resolution + +Тихой пустой очереди быть не должно. + +### 10.8 Внешние записи мимо controller + +Если кто-то пишет в raw файлы вне контроллера: + +- derived read path всё равно должен уметь читать tolerant snapshot +- но team-level lock гарантирует consistency только для controller-driven mutations + +Это важно явно зафиксировать, чтобы не переоценить силу lock. + +### 10.9 Race при multi-file update + +Самый опасный баг в этой зоне: + +- reader увидел уже обновлённый task +- но ещё старый kanban/review state + +или наоборот. + +Именно поэтому Phase 1 лучше строить вокруг: + +- общего mutation coordination +- общего snapshot чтения под одним team-level lock + +### 10.10 Unreadable / corrupt task row + +Это отдельный failure mode, который нельзя трактовать как "задачи просто нет". + +Если queue-grade reader не может нормализовать task row: + +- snapshot builder должен записать anomaly +- `lead_briefing` должен показать repair/warning signal +- `task_briefing` не должен молча превращать board problem в "No tasks" + +Важное уточнение: + +- tolerant snapshot допустим +- silent omission - нет + +Самая опасная версия бага здесь такая: + +- damaged task исчезает из inventory +- никто не видит, что board уже частично испорчен +- derived queue выглядит "чище", чем реальное состояние команды + +--- + +## 11. Как должны выглядеть очереди + +### 11.1 Очередь участника + +Участник должен получать не "все свои задачи", а такой shape: + +```json +{ + "actor": { "kind": "member", "memberName": "alice" }, + "actionable": [ + { + "taskId": "task-12", + "displayId": "12", + "subject": "Implement sync retries", + "actionOwner": { "kind": "member", "memberName": "alice" }, + "nextAction": "execute", + "queueCategory": "actionable", + "reasonCode": "owner_ready", + "reviewer": null, + "blockedBy": [] + } + ], + "awareness": [ + { + "taskId": "task-9", + "displayId": "9", + "subject": "API auth refactor", + "actionOwner": { "kind": "member", "memberName": "bob" }, + "nextAction": "review", + "queueCategory": "waiting", + "reasonCode": "waiting_review" + } + ], + "anomalies": [], + "counters": { + "actionable": 1, + "awareness": 1, + "blocked": 0, + "waitingOnUser": 0, + "waitingOnLead": 0, + "reviewNeeded": 0, + "anomalies": 0 + } +} +``` + +Ключевая идея: + +- **actionable** должно быть коротким и операционным +- **awareness** должно быть ещё компактнее и не засорять reasoning + +### 11.2 Очередь лида + +Лид должен получать не full board, а priority bucket: + +- needs owner assignment +- needs reviewer assignment +- needs clarification from lead +- broken dependency graph +- waiting on user + +Важно: + +- `stalled review / stalled work` не должны становиться primary lead bucket самого agenda resolver в Phase 0/1 +- stall-сигналы можно потом добавить как отдельное summary/enrichment, если они уже приходят из существующего stall-monitor surface + +Плюс summary: + +- сколько задач у кого actionable +- сколько pending review +- сколько blocked +- сколько orphaned / anomalous + +Лид потом уже по id прицельно открывает `task_get` или filtered `task_list`. + +### 11.3 Inventory + +`task_list` остаётся полезным, но только как: + +- browse/search +- audit/debug +- filtered discovery + +Не как "starting point for every turn". + +--- + +## 12. Почему `watchers` нужны, но не должны править миром + +Полезные watcher-кейсы: + +- owner должен знать, что его задача ушла на review +- лид должен знать, что задача ждёт user input +- reviewer может быть watcher до момента formal review assignment + +Но если строить queue по watchers, получится: + +- лидер снова видит почти весь board +- участник получает слишком много secondary state +- LLM начинает путать "надо делать" и "полезно знать" + +Поэтому правило такое: + +- **routing делается по `actionOwner`** +- **watchers идут только во вторичный слой visibility** + +--- + +## 13. Locking и consistency model + +### 13.1 Что вводим + +В Phase 0 вводим controller-owned team-level board lock для операций, которые меняют: + +- task files +- review routing +- kanban state +- derived queue snapshot reads + +Но lock должен жить на правильном уровне: + +- не внутри каждой мелкой low-level функции +- а вокруг **композитных board operations** +- и вокруг queue snapshot read, который хочет увидеть консистентный cross-file state + +Рекомендуемая реализация: + +- новый controller-owned primitive вида `withTeamBoardLock(paths, fn)` +- lock file рядом с team state, например в `teamDir` +- reuse можно брать только как low-level идею file-based exclusivity, но не как слепое обещание, что текущий primitive уже подходит без contract hardening + +После дополнительного code review это важно уточнить жёстче: + +- существующий `withFileLockSync(...)` sync-only и busy-wait +- его текущие таймауты `5s` acquire / `30s` stale сами по себе ещё не являются доказанным board contract +- Phase 0 не должен молча падать обратно на unlocked read/write, если board lock не взят + +Минимальный board-lock contract для плана: + +- один lock на board scope команды, а не набор несвязанных task-file lock'ов +- lock timeout должен отдавать явную ошибку mutation/read caller'у, а не скрытый unlocked fallback +- lock держим только вокруг canonical board files и derived snapshot build +- уведомления и прочие non-board side effects не должны бесконечно удерживать board lock + +### 13.1.1 Mutation classes and exact commit boundary + +Чтобы rollout не был расплывчатым, полезно разделить board operations на 3 класса: + +**Class A - routing-affecting task mutations** + +- `task_set_owner` +- `task_set_status` +- `task_start` +- `task_complete` +- `task_set_clarification` +- `task_add_comment`, если комментарий меняет queue-visible state или `lastMeaningfulEventAt` +- dependency link/unlink, если оно меняет blocking graph + +Правило: + +- даже если durable write происходит в один task file, mutation всё равно должна проходить под board lock +- иначе queue snapshot под тем же contract не имеет единого serialization point + +**Class B - multi-file board mutations** + +- `review_request` +- `review_start` +- `review_approve` +- `review_request_changes` +- любые операции, которые меняют и task state, и kanban overlay, и возможно несколько task rows + +Правило: + +- весь board commit происходит под одним board lock +- lock release только после того, как durable board files уже записаны + +**Class C - post-commit side effects** + +- inbox/system notifications +- observability warnings +- expensive attachment copy/link work, если оно не влияет на queue routing + +Правило: + +- эти шаги идут после board commit +- они не имеют права отменять уже committed board mutation + +### 13.2 Зачем + +Чтобы queue read видел: + +- либо старый консистентный state +- либо новый консистентный state + +Но не невозможную смесь. + +### 13.3 Почему этого достаточно для Phase 1 + +Потому что Phase 1 не добавляет второй durable projection. + +Следовательно: + +- нечему отдельно "протухать" +- нет projection write, который надо атомарно коммитить рядом с raw state + +### 13.4 Ограничение + +Если кто-то пишет в raw файлы вообще в обход controller, lock этого не предотвратит. + +Но это допустимое ограничение, если: + +- UI +- MCP tools +- planned task workflow + +маршрутизируются через controller. + +После дополнительного просмотра кода это допущение выглядит разумным: + +- основные UI mutation paths уже идут через `getController(...).tasks/review/kanban` +- но это не значит, что в Phase 0 надо сразу переписать все main-side read paths под тот же lock + +Отдельно важно: + +- board читают не только MCP tools +- main-process сервисы вроде `TeamTaskReader`, `TeamKanbanManager` и stall-monitor snapshots тоже строят picture of truth из тех же файлов + +Более правильный scope для первой фазы: + +- queue projector читает state под controller-owned lock +- review/task multi-file mutations используют тот же controller-owned lock +- если позже какой-то non-controller consumer реально потребует agenda-grade snapshot, он должен идти через официальный snapshot API, а не копировать сырой read path + +Иначе можно случайно раздуть rollout до общего I/O refactor и потерять фокус на агентском operational surface. + +### 13.4.1 Notification boundary must be post-commit + +Это отдельное ужесточение после просмотра `review_request(...)`. + +Для queue correctness опасно, когда board mutation и inbox notification живут в одном pseudo-transaction, но rollback умеет откатить только часть шагов. + +Phase 0 rule: + +- canonical board mutation commit завершается до отправки inbox/system notifications +- notification send считается best-effort side effect +- неуспешная доставка уведомления не должна оставлять board в "откаченном наполовину" состоянии + +Практический смысл: + +- queue semantics не зависит от того, дошло ли служебное сообщение +- если уведомление упало, нужен warning/observability signal, а не partial rollback board state + +### 13.4.2 Failure contract must be explicit + +У board path должен быть не только lock, но и понятная failure semantics. + +Минимальный contract: + +- lock acquire timeout -> explicit retryable error, никакого unlocked fallback +- board commit failed до durable write -> mutation error, side effects не запускаются +- board commit succeeded, side effect failed -> mutation считается committed, side effect failure попадает в warning/diagnostics +- snapshot reader встретил unreadable row -> snapshot помечает anomaly, а не делает вид, что board полностью здоров + +Это особенно важно для `review_request(...)`-подобных flows: + +- если task/history уже committed +- а notification не ушла + +то queue обязана видеть committed review state, а не fictional rollback state + +--- + +## 13.5 Почему clarification надо стабилизировать до agenda rollout + +Это отдельный guardrail, потому что здесь уже есть расхождение между "как система себя описывает" и "как она реально работает". + +Сейчас: + +- prompts говорят, что `needsClarification: "lead"` auto-clear'ится, когда отвечает lead +- код в task store снимает этот флаг, когда комментирует **любой не-owner автор** + +Риск: + +- derived queue может решить, что лидер ответил и задача больше не ждёт clarification +- хотя фактически мог прокомментировать другой teammate или reviewer + +Рекомендуемая нормализация перед полноценным agenda rollout: + +- **Phase 0 safe mode** + - любой clarification clear'ится только через явный `task_set_clarification clear` + - queue не верит implicit auto-clear вообще +- **Phase 1 optional ergonomics** + - после стабилизации lead identity можно вернуть: + - `lead` clarification clear на комментарий лида + - `user` clarification clear на комментарий `user` + +Это важное изменение по сравнению с предыдущей версией плана: + +- раньше мы пытались сразу сохранить удобство auto-clear +- теперь приоритет сдвинут в пользу надёжности и объяснимости + +Практический вывод для rollout: + +- в Phase 0 `needsClarification` становится надёжным routing-сигналом именно потому, что clear происходит только явно +- ambiguous clarification больше не должен зависеть от того, кто случайно оставил комментарий + +Минимальный implementation note: + +- Phase 0: убрать implicit clear из routing expectation и синхронно обновить prompts/briefings +- если потом возвращать ergonomic auto-clear, делать это только на controller layer, а не внутри storage + +No-go rule: + +- нельзя оставлять situation, где prompt обещает "lead comment closes clarification", а queue layer всё ещё зависит от текущего store behavior "любой non-owner comment clears it" + +### 13.6 Почему task logs не должны становиться queue dependency в Phase 0/1 + +После дополнительного code review видно, что task log / transcript слой уже умеет видеть: + +- `task_set_owner` +- `task_set_clarification` +- reviewer details в board activity + +Но это **не** означает, что queue надо строить поверх логов. + +Почему это плохая идея для первой фазы: + +- логи тяжелее и дороже для read path +- это observability layer, а не canonical board state +- появится второй semantic source рядом с task/kanban state +- при расхождении будет очень сложно объяснить, почему board показывает одно, а queue решила другое + +Правило для Phase 0/1: + +- queue derivation строится только из canonical board state: + - task files + - kanban state + - roster resolution +- task logs допускаются только как: + - debug aid + - diagnostics + - future validation tooling + +--- + +## 14. Phase rollout + +### Phase 0 - Hardening the weak signals first + +Это новая обязательная фаза после дополнительного code review. + +Её цель: + +- не начинать agenda rollout поверх уже известных semantic cracks + +Что делаем: + +1. вводим controller-owned `withTeamBoardLock(...)` / `getBoardSnapshot(...)` для agenda reads и multi-file board mutations +2. выделяем отдельный queue-grade reviewer resolver, который работает только по текущему review cycle history +3. выносим inbox/system notifications за board-state commit boundary, чтобы message delivery не ломала queue semantics частичным rollback'ом +4. в Phase 0 переводим clarification semantics в explicit-clear-only mode и синхронно обновляем prompts/briefings +5. фиксируем canonical queue roster normalization в controller layer, а не в нескольких runtime местах сразу +6. создаём внутренний structured agenda DTO + text renderers для backward-compatible `task_briefing` и нового `lead_briefing` +7. выбираем настоящий semantic owner для filtered `task_list` и не оставляем это MCP-local ad hoc логикой +8. фиксируем registration strategy для новой `lead` group, чтобы `registerTools()` не получил missing или duplicate registration path +9. экспортируем явный source of truth для lead bootstrap tool list рядом с existing teammate constants +10. добавляем отдельный lead bootstrap permission seed path для `lead_briefing` и других first-turn lead surfaces +11. выбираем lead bootstrap strategy честно: + - role-aware preflight + - или explicit fallback, пока preflight ещё не внедрён +12. держим agenda/inventory helpers вне accidental public `controller.tasks` surface +13. добавляем filters/limit в `task_list`, не ломая пока его default meaning +14. синхронизируем local `.d.ts` contracts для `agent-teams-controller`, чтобы новые exports/tools не жили только в runtime без type surface +15. явно фиксируем phase rule: no inferred mandatory review without explicit policy signal +16. переписываем lead prompt snippets в `TeamProvisioningService`, чтобы canonical first call стал `lead_briefing`, а не raw `task_list` +17. вводим queue-grade anomaly reporting, чтобы unreadable task rows не исчезали silently из board views + +Критерий завершения Phase 0: + +- у нас есть набор слабых сигналов, которые либо стабилизированы, либо явно помечены как unreliable +- после этого derived agenda уже можно строить на нормальной базе, а не на wishful thinking + +Phase 0 exit gates: + +- agenda resolver не использует per-task kanban reviewer +- review/task multi-file operations и agenda snapshot используют один controller lock contract +- board-state mutations не откатываются частично из-за ошибки inbox/system notification +- clarification больше не может тихо исчезнуть из queue из-за комментария произвольного teammate +- queue roster normalization зафиксирован тестами на `team-lead`/`lead`, removed members, external recipients и generated ids +- новая `lead` group имеет реальный MCP registration path без duplicate tool registration +- exported lead bootstrap constant существует и используется как source of truth +- lead bootstrap permission seed включает `lead_briefing`, если prompt делает его first action +- lead path либо валидирует `lead_briefing` как required tool, либо сохраняет явный fallback до такой валидации +- agenda/inventory helper не утёк в public `controller.tasks` surface случайным export'ом +- filtered `task_list` больше не висит на неопределённой MCP-only semantics +- `task_list` default unfiltered semantics остаётся совместимой, несмотря на добавление filters/limit +- lead prompt больше не рекламирует `task_list` как primary board entrypoint +- `lead_briefing` contract зафиксирован как role-scoped tool без обязательного `leadName` +- generic board snapshot helper остаётся internal implementation detail +- queue snapshot больше не может silently терять unreadable task rows без warning/anomaly signal + +### Phase 1 - Semantic cleanup without persisted projection + +Цель: + +- правильно определить action routing +- сократить payload +- убрать путаницу между inventory и queue + +Что делаем: + +1. добавляем derived resolver для `actionOwner` / `nextAction` / `reasonCode` +2. строим новый queue projector поверх raw state +3. используем controller-owned team-level lock для queue snapshot и board mutations +4. перерабатываем `task_briefing` под operational agenda +5. добавляем отдельный `lead_briefing` +6. ужимаем и фильтруем `task_list`, но без слишком раннего silent semantic break +7. обновляем prompts, чтобы: + - teammates стартовали с `task_briefing` + - lead стартовал с `lead_briefing` + - `task_list` использовался только при необходимости browse/search +8. поэтапно меняем teammate operational tool catalog, чтобы `task_list` перестал быть default teammate shortcut +9. переводим `task_list` output с legacy blocklist semantics на explicit allowlisted inventory contract, когда callers уже готовы к явному переходу +10. только если когда-то появится explicit review policy, рассматриваем более сильный post-complete review routing + +Ожидаемый результат: + +- агент сразу видит свой реальный action set +- лид видит routing issues и pressure points, а не весь board dump +- исчезает большая часть лишней token-нагрузки + +### Phase 1.5 - Compatibility and prompt hardening + +Что делаем: + +- сохраняем backward compatibility имени `task_briefing` +- если нужно, старый формат прячем за флагом или мягким переходом +- обновляем tool descriptions +- обновляем team provisioning instructions +- меняем lead prompts так, чтобы `lead_briefing` стал canonical first call, а `task_list` остался inventory/audit tool +- teammate prompts дополнительно поджимаем, чтобы они не тянули `task_list` без явной причины +- если role-aware lead preflight выбрали не сразу, сохраняем temporary explicit fallback path до завершения readiness hardening +- обновляем tests, которые сегодня закрепляют blocklist-semantics `task_list`, чтобы transition был явным и осознанным + +### Phase 2 - Revision first, delta second + +Важно: Phase 2 не должен начинаться, пока Phase 1 не покажет стабильную queue semantics. + +### Phase 2A - Revision / no-change short-circuit + +Добавляем: + +- stable queue `revision` +- возможность ответа `unchanged` + +Это уже даст экономию токенов и ускорение без сложного diff protocol. + +Лучший practical path: + +- повторить pattern, уже используемый в `feedRevision` +- строить `revision` как stable hash от уже нормализованного compact agenda DTO +- обязательно сортировать items детерминированно перед hashing + +Что именно должно входить в revision payload: + +- actor +- actionable task refs + minimal derived fields +- awareness task refs + minimal derived fields +- counters +- critical summary buckets for lead queue + +Что не должно влиять на revision в первой версии: + +- декоративный текст renderer +- локальные wording changes +- поля, которые не меняют queue semantics + +### Phase 2B - Optional delta sync + +Делать только если реально нужно по профайлингу. + +Возможный контракт: + +- клиент передаёт `sinceRevision` +- если сервер может корректно отдать delta, отдаёт delta +- если нет, отдает full compact queue + +Правило: + +- delta должен быть **оптимизацией транспорта** +- а не единственным способом понять board state + +--- + +## 15. Миграция и backward compatibility + +### Что не ломаем + +- `task_get` остаётся source of full details +- `member_briefing` остаётся bootstrap tool +- старые raw task files остаются валидными + +### Что меняется в поведении + +- `task_briefing` перестаёт быть простым owner list +- lead перестаёт использовать full `task_list` как первую точку входа +- teammate больше не зависит от общего списка задач команды +- reviewer routing становится более явным и меньше зависит от "угадай reviewer из косвенных полей" +- clarification queue semantics в первой фазе становятся deliberately explicit, а не "магически auto-cleared" + +### Совместимость + +Если где-то старый runtime ещё вызывает `task_briefing` с ожиданием owner-only semantics, это обычно безопасно: + +- новая agenda всё ещё покажет owned actionable items +- просто дополнительно добавит правильный awareness + +`task_list` compatibility надо трактовать отдельно и жёстче: + +- Phase 0/1: + - можно сохранить совместимую unfiltered semantics + - но filters/limit уже должны идти через выбранный semantic owner, а не через случайный MCP overlay +- Phase 1/1.5: + - переход к explicit allowlisted inventory row делается как осознанный contract change + - tests и prompt/docs migration должны идти в той же фазе, а не постфактум + +Отдельный compatibility note: + +- новые controller exports / tool names / helper contracts нельзя добавлять только в runtime код +- нужно синхронно обновлять: + - `src/types/agent-teams-controller.d.ts` + - `mcp-server/src/agent-teams-controller.d.ts` + - runtime export surface + +После повторного code review здесь уже есть конкретные известные расхождения, а не абстрактный риск: + +- main shim не знает `tasks.memberBriefing(...)` +- main shim не знает `tasks.getTaskComment(...)` +- main shim не знает `review.startReview(...)` +- main shim не знает `runtime` +- `lookupMessage(...)` типизирован по-разному в двух shim файлах + +Именно поэтому новый public controller contract в этой задаче должен быть узким: + +- добавить `tasks.leadBriefing(): Promise` +- сохранить `tasks.taskBriefing(memberName): Promise` +- не добавлять generic public `getBoardSnapshot(...)` в ту же фазу + +Иначе получится неприятный класс ошибок: + +- код работает локально в одном слое +- но типы и другой слой monorepo продолжают жить со старым контрактом + +--- + +## 16. Что конкретно важно протестировать + +### 16.1 Unit tests на resolver + +Нужны table-driven тесты для сценариев: + +- pending + owner +- in_progress + owner +- completed + reviewer missing +- review + reviewer resolved +- review + reviewer missing +- review + stale reviewer from previous cycle +- review requested but not started yet +- review column entry exists but per-task kanban reviewer is null +- self-review +- removed owner +- removed reviewer +- canonical lead name vs `team-lead` alias +- zero explicit lead candidates still yields valid lead queue +- multiple lead-like members do not make `lead_briefing` fail +- external inbox recipient does not become valid queue member +- generated/internal pseudo-agent id does not become valid queue member +- completed task + reviewer pool configured + no explicit review event +- waiting on user clarification +- waiting on lead clarification +- clarification cleared by wrong actor +- healthy dependency block +- broken dependency +- needsFix after review +- approved/deleted terminal +- unreadable task row produces anomaly instead of silent omission + +### 16.2 Snapshot tests на queue surfaces + +Проверить отдельно: + +- teammate actionable/awareness +- lead action buckets +- filtered inventory outputs +- anomaly summary surfaces in lead-facing outputs when board rows are unreadable +- `lead_briefing` text output не дублирует весь board и не превращается в disguised `task_list` +- `lead_briefing` output не зависит от обязательного `leadName` input + +### 16.3 Concurrency tests + +Проверить: + +- multi-file mutation не даёт невозможного mixed snapshot +- read под lock не ломает корректность +- stale lock cleanup не создаёт ложных успешных чтений +- board lock timeout не приводит к silent unlocked fallback +- `review_request` и `review_request_changes` не оставляют queue в промежуточном ambiguity state +- ошибка inbox/system notification после board commit не оставляет history/kanban drift +- unreadable task row не теряется silently и поднимается как anomaly +- новая `lead` group не ломает `registerTools()` и не вызывает duplicate registration существующих task tools +- exported lead bootstrap constant совпадает с реальным runtime usage path, а не дублируется локальным списком +- если lead path делает `lead_briefing` hard first action, readiness preflight действительно валидирует его наличие +- текстовый renderer `task_briefing` не расходится со structured agenda DTO + +### 16.4 Prompt-path tests + +Проверить, что team prompts реально подталкивают: + +- member -> `task_briefing` +- lead -> `lead_briefing` +- details -> `task_get` + +И отдельно зафиксировать: + +- `TeamProvisioningService` больше не содержит lead hint `List all tasks: task_list ...` как primary recommendation +- новый lead hint рекомендует `lead_briefing` раньше, чем `task_list` +- если lead prompt делает `lead_briefing` hard-first, launch/preflight path тоже проверяет этот tool, а не только prompt text + +### 16.5 Contract tests and type sync + +Проверить: + +- local `.d.ts` shim declarations синхронны с реальным runtime surface +- `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` отражает новый teammate access policy +- `lead_briefing` явно отсутствует в `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` +- если вводится новая `lead` group, обе локальные `AgentTeamsMcpToolGroupId` декларации знают `lead` +- если выбран public controller inventory method, обе локальные `.d.ts` декларации знают и его точный contract +- у каждого `AgentTeamsMcpToolGroupId` есть валидный registration path в MCP layer +- exported lead bootstrap tool constants синхронны между runtime package surface, `.d.ts` и фактическим bootstrap usage +- lead bootstrap permission seed contract отдельно зафиксирован: + - `lead_briefing` доступен для first-turn lead flow + - seed path не зависит только от teammate operational list + - seed path трактуется отдельно от general runtime availability при `bypassPermissions` +- lead bootstrap readiness contract отдельно зафиксирован: + - teammate path hard-checks `member_briefing` + - lead path не объявляется equally strict, пока не добавлен explicit preflight или documented fallback +- accidental helper exports не появляются в `createController(...).tasks`, если они не объявлены частью public contract +- `task_list` tests меняются осознанно и явно фиксируют transition semantics, а не ломаются побочным эффектом +- queue-side roster normalization behaviour зафиксирован явно и не drift'ит незаметно относительно UI expectations +- `task_list` filter contract зафиксирован отдельно: + - only stable filters + - conjunctive semantics + - `limit` after filtering + - no hidden agenda ordering +- `task_list` output contract зафиксирован отдельно: + - explicit allowlisted inventory row as target contract + - migration away from legacy blocklist is explicit and tested +- queue anomaly contract зафиксирован отдельно: + - unreadable task rows surface as anomaly + - lead-facing outputs do not silently hide board corruption +- `TeamProvisioningService` lead prompt contract зафиксирован отдельно: + - `lead_briefing` рекомендуют раньше `task_list` + - строка `List all tasks: task_list ...` больше не используется как primary recommendation +- `lead_briefing` public contract зафиксирован отдельно: + - no required `leadName` + - text surface in Phase 1 + - role-scoped semantics, not name-scoped semantics + +### 16.6 Non-goals enforcement tests + +Проверить: + +- queue derivation не зависит от task logs/transcript activity в Phase 0/1 +- owner/clarification semantics не подменяются history heuristics там, где canonical signal - это текущее task field +- queue не копирует бездумно stall-monitor правило "open review window only after review_started" + +--- + +## 17. Признаки того, что rollout удался + +### Хорошие сигналы + +- lead перестал регулярно вызывать полный `task_list` для рутинной навигации +- teammate получает короткий actionable набор +- меньше "забытых" задач в review без reviewer +- меньше orphaned tasks +- меньше случаев, где LLM делает неверный workflow шаг из-за неоднозначного контекста + +### Плохие сигналы + +- queue стала слишком "умной" и начала скрывать важные задачи +- разные инструменты дают разное понимание одного и того же task state +- lead queue снова разрастается до pseudo-full-board +- delta/revision добавлены слишком рано и ломают простые сценарии +- clarification queue decisions всё ещё расходятся с реальным поведением board +- reviewer остаётся "магическим" и не объясняется понятным resolver precedence + +--- + +## 17.1 Conservative Bias Rules + +Если сигнал неоднозначен, queue system должна ошибаться в сторону безопасности: + +1. ambiguous reviewer -> lead queue, а не speculative reviewer routing +2. ambiguous clarification clear -> lead oversight, а не скрытие wait-state +3. ambiguous dependency integrity -> repair bucket у лида, а не auto-unblock +4. ambiguous ownership -> `assign_owner`, а не молчаливая подстановка watcher/last actor +5. unreadable task row -> anomaly / lead repair signal, а не тихое исчезновение из queue + +Это очень важное правило для LLM-facing surface: + +- false positive в lead queue обычно терпим +- false negative в actionable queue часто приводит к реально потерянной работе + +--- + +## 18. Чего делать не надо + +### Не надо 1 + +Не надо сразу вводить persisted `board-projection.json` как обязательную истину. + +Это красивее на схеме, но опаснее в реальности. + +### Не надо 2 + +Не надо строить primary queue по `watchers`. + +Это почти гарантированный путь обратно к информационному шуму. + +### Не надо 3 + +Не надо считать, что фильтрованный `task_list` уже равен agenda. + +Даже хороший фильтрованный inventory не заменяет явный `actionOwner`. + +### Не надо 4 + +Не надо молча возвращать пустую очередь, если runtime identity не определился. + +Такие silent failures очень дорогие. + +### Не надо 5 + +Не надо строить Phase 1 на предположении, что текущие clarification и reviewer signals уже идеально надёжны. + +Сначала их нужно harden или честно ограничить область применения. + +### Не надо 6 + +Не надо добавлять новый queue surface только в runtime код, забыв про tool catalog, local `.d.ts` и тестовый контракт. + +Для этой зоны "почти работает" особенно опасно, потому что баг всплывает не сразу и не в одном месте. + +### Не надо 7 + +Не надо соблазняться task logs как "богаче сигналами" и тихо тащить их в основной queue resolver первой фазы. + +Это почти гарантированный способ получить вторую, более дорогую и менее объяснимую source-of-truth плоскость. + +### Не надо 8 + +Не надо делать `lead_briefing` как второй независимый renderer рядом с `taskStore.formatTaskBriefing(...)`. + +Если teammate и lead surfaces будут собираться разными ad hoc formatter'ами, drift почти неизбежен. + +### Не надо 9 + +Не надо преждевременно экспортировать generic `getBoardSnapshot(...)` как public controller API. + +Пока у него нет второго доказанного consumer, это только увеличит surface area и type-sync burden. + +### Не надо 10 + +Не надо вводить в `task_list` misleading filter вроде `column=todo|in_progress|done`, будто inventory layer уже стал полным kanban projection. + +Для actor-centric и agenda-centric views есть `task_briefing` и `lead_briefing`. + +### Не надо 11 + +Не надо класть `lead_briefing` в existing `task` group и надеяться, что prompts сами спрячут его от тиммейтов. + +Если доступность инструмента определяется catalog'ом, то и ограничение должно быть в catalog, а не только в тексте подсказок. + +### Не надо 12 + +Не надо оставлять `task_list` навсегда на blocklist-подходе только потому, что так проще пережить первую миграцию. + +Иначе payload снова будет незаметно пухнуть при каждом новом task field, а inventory surface опять начнёт вести себя как полу-сырой dump. + +### Не надо 13 + +Не надо завязывать rollback board-state mutation на успех inbox/system notification. + +Если notification упала, надо сигнализировать о delivery problem, а не оставлять history и kanban в полурасходящемся состоянии. + +### Не надо 14 + +Не надо добавлять новую `lead` group только в catalog и `.d.ts`, забыв про `mcp-server/src/tools/index.ts`. + +Иначе rollout сломается не на semantics, а на missing registration path. + +### Не надо 15 + +Не надо считать teammate operational permission seed list автоматически подходящей базой для lead-first surfaces. + +Как только появляется lead-only tool вроде `lead_briefing`, это предположение перестаёт быть надёжным. + +### Не надо 16 + +Не надо экспортить agenda/inventory helper из `internal/tasks.js` "временно", если он не должен быть public API. + +При текущем `bindModule(...)` это уже не временный helper, а новый `controller.tasks.*` method. + +### Не надо 17 + +Не надо писать в prompt, что `lead_briefing` является обязательным first step для лида, если runtime path ещё не умеет это валидировать или честно fallback'ить. + +Иначе получится фальшивая bootstrap-гарантия: текст обещает одно, а launch contract её ещё не держит. + +### Не надо 18 + +Не надо заводить lead bootstrap tool list как локальный массив в `TeamProvisioningService`, если catalog уже является source of truth для MCP surface. + +Иначе первый же rename/regrouping даст тихий drift между catalog, permissions и bootstrap runtime. + +### Не надо 19 + +Не надо inherit'ить из текущего raw reader правило "unreadable task row можно просто пропустить". + +Для queue это не tolerant behavior, а скрытая потеря board truth. + +--- + +## 19. Рекомендуемый implementation order + +1. ввести controller-owned board lock primitive / snapshot API +2. вынести board notifications в post-commit best-effort path +3. перевести clarification routing в explicit-clear-only semantics и обновить prompts +4. выделить queue-grade reviewer resolver для текущего review cycle +5. формализовать queue roster normalization +6. выделить общий derived resolver `resolveTaskActionState(...)` +7. покрыть resolver table-driven тестами +8. собрать structured agenda DTO + text renderers +9. обновить `task_briefing` +10. добавить `lead_briefing` +11. завести отдельную `lead` tool group и синхронно обновить local `.d.ts` group unions +12. завести для `lead` group отдельный MCP registration path без duplicate task registrations +13. экспортировать dedicated lead bootstrap tool constants из controller package surface +14. добавить explicit lead bootstrap permission seed contract +15. выбрать lead bootstrap readiness path: + - explicit preflight + - или temporary documented fallback +16. удержать agenda/inventory helpers вне accidental public controller surface +17. выбрать backing contract для filtered `task_list` +18. добавить filters/limit в `task_list`, не ломая его default слишком рано +19. после migration readiness перевести `task_list` на allowlisted inventory row contract +20. обновить tool descriptions и provisioning prompts +21. после стабилизации добавить `revision` +22. только потом решать, нужен ли `delta` + +## 19.1 Likely Change Surface + +Наиболее вероятные точки изменения, если реализовывать этот план без лишнего расползания: + +- `agent-teams-controller/src/internal/tasks.js` + - новый `lead_briefing` surface + - controller-level agenda snapshot orchestration + - agenda renderer wiring +- `agent-teams-controller/src/internal/agenda.js` или аналогичный internal helper module + - safest home для derived agenda/inventory helpers, которые не должны стать public controller methods автоматически +- `agent-teams-controller/src/internal/taskStore.js` + - storage-only cleanup + - legacy formatting only + - убрать из store implicit clarification policy как routing assumption +- `agent-teams-controller/src/internal/review.js` + - queue-grade reviewer resolution hooks + - board commit vs notification side-effect boundary + - при необходимости явнее фиксировать signals текущего review cycle +- `agent-teams-controller/src/internal/fileLock.js` + - база для controller-owned board lock primitive +- `agent-teams-controller/src/internal/runtimeHelpers.js` + - queue roster normalization + - lead alias normalization helpers + - `inferLeadName(...)` scope reduction so role-scoped `lead_briefing` does not depend on weak name inference +- `src/shared/utils/leadDetection.ts` + - only if controller-side lead normalization is aligned with shared rules instead of ad hoc heuristics +- `src/shared/types/team.ts` + - only if future explicit review policy is introduced + - until then, no invented review-required field in Phase 0/1 +- `mcp-server/src/tools/taskTools.ts` + - новый lead tool + - optional filters/limit для `task_list` + - совместимая эволюция `task_briefing` + - явный transition away from `slimTaskForList(...)` blocklist semantics +- `mcp-server/src/tools/index.ts` + - registration wiring for new `lead` group + - защита от missing/duplicate group registration +- `agent-teams-controller/src/mcpToolCatalog.js` + - phased teammate access policy for `task_list` + - отдельная `lead` group + - registration of `lead_briefing` + - exported lead bootstrap tool constants +- `src/main/services/team/TeamProvisioningService.ts` + - prompt migration для lead/member flows + - lead bootstrap permission seed split from teammate operational seed + - role-aware MCP readiness validation or explicit fallback for `lead_briefing` +- `agent-teams-controller/src/controller.js` + - only if public bind surface needs extra guardrails around what becomes `controller.tasks.*` +- `src/types/agent-teams-controller.d.ts` + - local type shim sync for main app +- `mcp-server/src/agent-teams-controller.d.ts` + - local type shim sync for MCP package +- `mcp-server/test/tools.test.ts` + - explicit transition of `task_list` expectations +- `agent-teams-controller/test/controller.test.js` + - reviewer resolver and clarification semantics hardening +- `src/shared/utils/taskHistory.ts` + - при необходимости helper для current review cycle traversal + +Принцип: + +- сначала добавлять новые explicit surfaces +- только потом снижать роль старых ambiguous surfaces + +--- + +## 20. Финальная формулировка решения + +Итоговое решение, к которому пришли: + +- **Phase 0:** hardening слабых сигналов (`lock`, `reviewer resolver`, `clarification semantics`, compatible renderer) +- **Phase 1:** derived projection on read как база +- **Primary routing:** через `actionOwner` +- **Secondary visibility:** через `watchers` +- **Main teammate surface:** `task_briefing` +- **Main lead surface:** отдельный `lead_briefing` +- **Search/inventory:** `task_list` с filters/limit и постепенным уходом из роли default queue +- **Inventory contract:** `task_list` идёт к explicit allowlisted `TaskInventoryRow`, а не остаётся вечным blocklist dump +- **Consistency:** controller-owned team-level lock around board mutations and queue snapshot reads +- **Reviewer source:** only current-cycle history in Phase 0/1 +- **Clarification source:** explicit clear semantics first, convenience auto-clear only later if really needed +- **Signal trust:** history/task/kanban signals имеют явный priority order, queue не усредняет conflicting inputs +- **Lead contract:** `lead_briefing` role-scoped, without required `leadName` +- **Lead plumbing:** отдельная `lead` group, отдельный MCP registration path и отдельный lead bootstrap permission seed +- **Bootstrap sequencing:** lead prompt становится hard-first only after registration + seed + readiness path are real +- **Public API discipline:** snapshot/DTO helpers stay internal until a second real consumer exists +- **Export discipline:** agenda/inventory helpers не должны случайно становиться `controller.tasks.*` methods через `bindModule(...)` +- **Board anomalies:** unreadable task rows surface as anomalies, not as silent omissions +- **Phase 2:** сначала `revision`, потом только при реальной необходимости `delta` + +Это самый оптимальный баланс между: + +- надёжностью +- предсказуемостью для агентов +- понятностью инструментария +- умеренной сложностью rollout + +Если сформулировать совсем коротко: + +> Не надо учить агента самому вычислять board policy из сырых задач. Надо один раз правильно вывести `actionOwner` и отдать каждому актёру его компактную очередь. diff --git a/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts b/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts new file mode 100644 index 00000000..1d4b5f52 --- /dev/null +++ b/src/features/anthropic-runtime-profile/core/domain/resolveAnthropicRuntimeProfile.ts @@ -0,0 +1,212 @@ +import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; + +import type { + CliProviderModelCatalog, + CliProviderModelCatalogItem, + CliProviderRuntimeCapabilities, + EffortLevel, + TeamFastMode, +} from '@shared/types'; + +export interface AnthropicRuntimeProfileSource { + modelCatalog?: CliProviderModelCatalog | null; + runtimeCapabilities?: CliProviderRuntimeCapabilities | null; +} + +export interface AnthropicRuntimeSelection { + resolvedLaunchModel: string | null; + catalogModel: CliProviderModelCatalogItem | null; + displayName: string | null; + catalogSource: CliProviderModelCatalog['source'] | 'unavailable'; + catalogStatus: CliProviderModelCatalog['status'] | 'unavailable'; + catalogFetchedAt: string | null; + supportedEfforts: EffortLevel[]; + defaultEffort: EffortLevel | null; + supportsFastMode: boolean; + providerFastModeSupported: boolean; + providerFastModeAvailable: boolean; + providerFastModeReason: string | null; +} + +export interface AnthropicFastModeResolution { + selectedFastMode: TeamFastMode; + requestedFastMode: boolean; + resolvedFastMode: boolean; + showFastModeControl: boolean; + selectable: boolean; + disabledReason: string | null; +} + +export interface AnthropicRuntimeReconciliation { + nextEffort: EffortLevel | ''; + effortResetReason: string | null; + nextFastMode: TeamFastMode; + fastModeResetReason: string | null; +} + +function getAnthropicCatalog( + source: AnthropicRuntimeProfileSource +): CliProviderModelCatalog | null { + return source.modelCatalog?.providerId === 'anthropic' ? source.modelCatalog : null; +} + +function normalizeEffortLevel(value: string | null | undefined): EffortLevel | null { + return value === 'none' || + value === 'minimal' || + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'xhigh' || + value === 'max' + ? value + : null; +} + +function normalizeEffortLevels(values: readonly string[] | undefined): EffortLevel[] { + const normalized = new Set(); + for (const value of values ?? []) { + const effort = normalizeEffortLevel(value); + if (effort) { + normalized.add(effort); + } + } + return Array.from(normalized); +} + +function hasCatalogTruth(selection: AnthropicRuntimeSelection): boolean { + return selection.catalogSource !== 'unavailable' && selection.catalogStatus !== 'unavailable'; +} + +export function resolveAnthropicRuntimeSelection(params: { + source: AnthropicRuntimeProfileSource; + selectedModel?: string | null; + limitContext: boolean; +}): AnthropicRuntimeSelection { + const catalog = getAnthropicCatalog(params.source); + const resolvedLaunchModel = + resolveAnthropicLaunchModel({ + selectedModel: params.selectedModel, + limitContext: params.limitContext, + availableLaunchModels: catalog?.models.map((model) => model.launchModel), + defaultLaunchModel: catalog?.defaultLaunchModel ?? null, + }) ?? null; + + const catalogModel = + resolvedLaunchModel && catalog + ? (catalog.models.find( + (model) => + model.launchModel.trim() === resolvedLaunchModel || + model.id.trim() === resolvedLaunchModel + ) ?? null) + : null; + + return { + resolvedLaunchModel, + catalogModel, + displayName: catalogModel?.displayName?.trim() ?? null, + catalogSource: catalog?.source ?? 'unavailable', + catalogStatus: catalog?.status ?? 'unavailable', + catalogFetchedAt: catalog?.fetchedAt ?? null, + supportedEfforts: normalizeEffortLevels(catalogModel?.supportedReasoningEfforts), + defaultEffort: normalizeEffortLevel(catalogModel?.defaultReasoningEffort ?? null), + supportsFastMode: catalogModel?.supportsFastMode === true, + providerFastModeSupported: params.source.runtimeCapabilities?.fastMode?.supported === true, + providerFastModeAvailable: params.source.runtimeCapabilities?.fastMode?.available === true, + providerFastModeReason: params.source.runtimeCapabilities?.fastMode?.reason ?? null, + }; +} + +export function resolveAnthropicFastMode(params: { + selection: AnthropicRuntimeSelection; + selectedFastMode?: TeamFastMode | null; + providerFastModeDefault?: boolean; +}): AnthropicFastModeResolution { + const selectedFastMode = params.selectedFastMode ?? 'inherit'; + const requestedFastMode = + selectedFastMode === 'on' + ? true + : selectedFastMode === 'off' + ? false + : params.providerFastModeDefault === true; + + const selectable = + params.selection.providerFastModeSupported && + params.selection.providerFastModeAvailable && + params.selection.supportsFastMode; + + let disabledReason: string | null = null; + if (!hasCatalogTruth(params.selection) && !params.selection.providerFastModeSupported) { + disabledReason = 'Anthropic runtime capability data is still loading.'; + } else if (!params.selection.providerFastModeSupported) { + disabledReason = + params.selection.providerFastModeReason ?? + 'Fast mode is not supported by this Anthropic runtime.'; + } else if (!params.selection.supportsFastMode) { + disabledReason = params.selection.displayName + ? `Fast mode is available only for Opus 4.6. Selected model resolves to ${params.selection.displayName}.` + : 'Fast mode is available only for Opus 4.6.'; + } else if (!params.selection.providerFastModeAvailable) { + disabledReason = + params.selection.providerFastModeReason ?? 'Fast mode is currently unavailable.'; + } + + return { + selectedFastMode, + requestedFastMode, + resolvedFastMode: requestedFastMode && selectable, + showFastModeControl: + params.selection.providerFastModeSupported || + selectedFastMode !== 'inherit' || + params.providerFastModeDefault === true, + selectable, + disabledReason, + }; +} + +export function reconcileAnthropicRuntimeSelections(params: { + selection: AnthropicRuntimeSelection; + selectedEffort?: string | null; + selectedFastMode?: TeamFastMode | null; + providerFastModeDefault?: boolean; +}): AnthropicRuntimeReconciliation { + const selectedEffort = normalizeEffortLevel(params.selectedEffort ?? null); + if (!hasCatalogTruth(params.selection)) { + return { + nextEffort: selectedEffort ?? '', + effortResetReason: null, + nextFastMode: params.selectedFastMode ?? 'inherit', + fastModeResetReason: null, + }; + } + + const nextEffort = + selectedEffort && !params.selection.supportedEfforts.includes(selectedEffort) + ? '' + : (selectedEffort ?? ''); + const effortResetReason = + selectedEffort && nextEffort === '' + ? `${selectedEffort} effort is not available for the currently selected Anthropic model. Reset to Default.` + : null; + + const fastResolution = resolveAnthropicFastMode({ + selection: params.selection, + selectedFastMode: params.selectedFastMode, + providerFastModeDefault: params.providerFastModeDefault, + }); + const nextFastMode = + fastResolution.selectedFastMode === 'on' && !fastResolution.selectable + ? 'inherit' + : fastResolution.selectedFastMode; + const fastModeResetReason = + fastResolution.selectedFastMode === 'on' && nextFastMode !== 'on' + ? (fastResolution.disabledReason ?? + 'Fast mode is not available for the currently selected Anthropic model. Reset to Default.') + : null; + + return { + nextEffort, + effortResetReason, + nextFastMode, + fastModeResetReason, + }; +} diff --git a/src/features/anthropic-runtime-profile/main/index.ts b/src/features/anthropic-runtime-profile/main/index.ts new file mode 100644 index 00000000..2ef91e4d --- /dev/null +++ b/src/features/anthropic-runtime-profile/main/index.ts @@ -0,0 +1,12 @@ +export { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; + +export type { + AnthropicFastModeResolution, + AnthropicRuntimeProfileSource, + AnthropicRuntimeReconciliation, + AnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; diff --git a/src/features/anthropic-runtime-profile/renderer/index.ts b/src/features/anthropic-runtime-profile/renderer/index.ts new file mode 100644 index 00000000..2ef91e4d --- /dev/null +++ b/src/features/anthropic-runtime-profile/renderer/index.ts @@ -0,0 +1,12 @@ +export { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; + +export type { + AnthropicFastModeResolution, + AnthropicRuntimeProfileSource, + AnthropicRuntimeReconciliation, + AnthropicRuntimeSelection, +} from '../core/domain/resolveAnthropicRuntimeProfile'; diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index afebd5d1..c02e5ccc 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -9,7 +9,7 @@ import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { isAbsolute } from 'path'; import type { HttpServices } from './index'; -import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team'; +import type { EffortLevel, TeamFastMode, TeamLaunchRequest } from '@shared/types/team'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:teams'); @@ -95,6 +95,18 @@ function assertOptionalEffort( return value; } +function assertOptionalFastMode(value: unknown): TeamFastMode | undefined { + if (value == null) { + return undefined; + } + + if (value !== 'inherit' && value !== 'on' && value !== 'off') { + throw new HttpBadRequestError('fastMode must be one of: inherit, on, off'); + } + + return value; +} + function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { const payload = body && typeof body === 'object' ? (body as Record) : {}; const providerId = @@ -117,6 +129,7 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest } const model = assertOptionalString(payload.model, 'model'); const effort = assertOptionalEffort(payload.effort, providerId); + const fastMode = assertOptionalFastMode(payload.fastMode); const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); const worktree = assertOptionalString(payload.worktree, 'worktree'); @@ -138,6 +151,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest ...(effort && { effort, }), + ...(fastMode && { + fastMode, + }), ...(clearContext !== undefined && { clearContext, }), diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index e10b5653..95a93fb5 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -497,25 +497,37 @@ function validateProviderConnectionsSection( const anthropicUpdate: Partial = {}; for (const [connectionKey, connectionValue] of Object.entries(value)) { - if (connectionKey !== 'authMode') { + if (connectionKey !== 'authMode' && connectionKey !== 'fastModeDefault') { return { valid: false, error: `providerConnections.anthropic.${connectionKey} is not a valid setting`, }; } - if ( - connectionValue !== 'auto' && - connectionValue !== 'oauth' && - connectionValue !== 'api_key' - ) { + if (connectionKey === 'authMode') { + if ( + connectionValue !== 'auto' && + connectionValue !== 'oauth' && + connectionValue !== 'api_key' + ) { + return { + valid: false, + error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key', + }; + } + + anthropicUpdate.authMode = connectionValue; + continue; + } + + if (typeof connectionValue !== 'boolean') { return { valid: false, - error: 'providerConnections.anthropic.authMode must be one of: auto, oauth, api_key', + error: 'providerConnections.anthropic.fastModeDefault must be a boolean', }; } - anthropicUpdate.authMode = connectionValue; + anthropicUpdate.fastModeDefault = connectionValue; } result.anthropic = anthropicUpdate as ProviderConnectionsConfig['anthropic']; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 11f34910..1a272c0f 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -190,6 +190,7 @@ import type { TeamLaunchResponse, TeamMemberActivityMeta, TeamMessageNotificationData, + TeamFastMode, TeamProviderBackendId, TeamProviderId, TeamProvisioningPrepareResult, @@ -1198,6 +1199,21 @@ function parseOptionalTeamEffort( }; } +function parseOptionalTeamFastMode( + value: unknown +): { valid: true; value: TeamFastMode | undefined } | { valid: false; error: string } { + if (value === undefined || value === null || value === '') { + return { valid: true, value: undefined }; + } + if (value === 'inherit' || value === 'on' || value === 'off') { + return { valid: true, value }; + } + return { + valid: false, + error: 'fastMode must be one of inherit, on, or off', + }; +} + async function validateProvisioningRequest( request: unknown ): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> { @@ -1224,12 +1240,15 @@ async function validateProvisioningRequest( if (!Array.isArray(payload.members)) { return { valid: false, error: 'members must be an array' }; } - const providerId = + const explicitProviderId = payload.providerId === 'codex' ? 'codex' : payload.providerId === 'gemini' ? 'gemini' - : 'anthropic'; + : payload.providerId === 'anthropic' + ? 'anthropic' + : undefined; + const providerId = explicitProviderId ?? 'anthropic'; const seenNames = new Set(); const members: TeamCreateRequest['members'] = []; @@ -1304,6 +1323,10 @@ async function validateProvisioningRequest( if (!effortValidation.valid) { return { valid: false, error: effortValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { valid: false, error: fastModeValidation.error }; + } try { await fs.promises.mkdir(cwd, { recursive: true }); @@ -1359,6 +1382,7 @@ async function validateProvisioningRequest( providerBackendId: providerBackendValidation.value, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: effortValidation.value, + fastMode: fastModeValidation.value, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, worktree: @@ -1512,6 +1536,10 @@ async function handleLaunchTeam( if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } const createRequest: TeamCreateRequest = { teamName: tn, @@ -1527,6 +1555,7 @@ async function handleLaunchTeam( ), model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, effort: effortValidation.value, + fastMode: fastModeValidation.value ?? meta?.fastMode, limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, @@ -1558,10 +1587,44 @@ async function handleLaunchTeam( ); } - const effortValidation = parseOptionalTeamEffort(payload.effort, providerId); + const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null); + const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId; + const rawLaunchProviderBackendId = + payload.providerBackendId ?? + persistedMeta?.providerBackendId ?? + persistedMeta?.launchIdentity?.providerBackendId ?? + undefined; + const launchProviderBackendValidation = parseOptionalProviderBackendId( + rawLaunchProviderBackendId, + launchProviderId + ); + if (!launchProviderBackendValidation.valid) { + return { success: false, error: launchProviderBackendValidation.error }; + } + const rawLaunchEffort = + payload.effort ?? + persistedMeta?.effort ?? + persistedMeta?.launchIdentity?.selectedEffort ?? + undefined; + const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId); if (!effortValidation.valid) { return { success: false, error: effortValidation.error }; } + const rawLaunchFastMode = + payload.fastMode ?? + persistedMeta?.fastMode ?? + persistedMeta?.launchIdentity?.selectedFastMode ?? + undefined; + const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } + const rawLaunchModel = + typeof payload.model === 'string' && payload.model.trim().length > 0 + ? payload.model.trim() + : (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined); + const launchLimitContext = + typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext; return wrapTeamHandler('launch', () => { addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); @@ -1570,10 +1633,12 @@ async function handleLaunchTeam( teamName: validatedTeamName.value!, cwd, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, - providerId, - providerBackendId: providerBackendValidation.value, - model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, + providerId: launchProviderId, + providerBackendId: launchProviderBackendValidation.value, + model: rawLaunchModel, effort: effortValidation.value, + fastMode: fastModeValidation.value, + limitContext: launchLimitContext, clearContext: payload.clearContext === true ? true : undefined, skipPermissions: typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, @@ -2660,6 +2725,10 @@ async function handleCreateConfig( if (!providerBackendValidation.valid) { return { success: false, error: providerBackendValidation.error }; } + const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode); + if (!fastModeValidation.valid) { + return { success: false, error: fastModeValidation.error }; + } const seenNames = new Set(); const members: TeamCreateConfigRequest['members'] = []; @@ -2721,6 +2790,7 @@ async function handleCreateConfig( members, cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, providerBackendId: providerBackendValidation.value, + fastMode: fastModeValidation.value, }) ); } @@ -4023,6 +4093,7 @@ async function handleGetSavedRequest( ), model: meta.model, effort: meta.effort as TeamCreateRequest['effort'], + fastMode: meta.fastMode as TeamCreateRequest['fastMode'], skipPermissions: meta.skipPermissions, worktree: meta.worktree, extraCliArgs: meta.extraCliArgs, diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index e4f324fe..3cf0e470 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -237,6 +237,7 @@ export type ProviderConnectionAuthMode = 'auto' | 'oauth' | 'api_key'; export interface ProviderConnectionsConfig { anthropic: { authMode: ProviderConnectionAuthMode; + fastModeDefault: boolean; }; codex: { preferredAuthMode: CodexAccountAuthMode; @@ -333,6 +334,7 @@ const DEFAULT_CONFIG: AppConfig = { providerConnections: { anthropic: { authMode: 'auto', + fastModeDefault: false, }, codex: { preferredAuthMode: 'auto', diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 0cf3f7ea..c4bd3ab1 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -40,6 +40,12 @@ interface RuntimeProviderCapabilitiesResponse { values?: string[]; configPassthrough?: boolean; }; + fastMode?: { + supported?: boolean; + available?: boolean; + reason?: string | null; + source?: 'runtime'; + }; } interface RuntimeProviderModelCatalogItemResponse { @@ -49,6 +55,7 @@ interface RuntimeProviderModelCatalogItemResponse { hidden?: boolean; supportedReasoningEfforts?: string[]; defaultReasoningEffort?: string | null; + supportsFastMode?: boolean; inputModalities?: string[]; supportsPersonality?: boolean; isDefault?: boolean; @@ -279,7 +286,8 @@ function normalizeRuntimeReasoningEffort( value === 'low' || value === 'medium' || value === 'high' || - value === 'xhigh' + value === 'xhigh' || + value === 'max' ? value : null; } @@ -347,6 +355,7 @@ function mapRuntimeProviderModelCatalog( hidden: model.hidden === true, supportedReasoningEfforts, defaultReasoningEffort, + supportsFastMode: model.supportsFastMode === true, inputModalities: model.inputModalities?.filter((value) => value.trim().length > 0) ?? [], supportsPersonality: model.supportsPersonality === true, isDefault: model.isDefault === true, @@ -477,6 +486,14 @@ export class ClaudeMultimodelBridgeService { runtimeStatus.runtimeCapabilities.reasoningEffort.configPassthrough === true, } : undefined, + fastMode: runtimeStatus.runtimeCapabilities.fastMode + ? { + supported: runtimeStatus.runtimeCapabilities.fastMode.supported === true, + available: runtimeStatus.runtimeCapabilities.fastMode.available === true, + reason: runtimeStatus.runtimeCapabilities.fastMode.reason ?? null, + source: 'runtime', + } + : undefined, } : null, }; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9bdf17b9..a0413574 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -2384,6 +2384,7 @@ export class TeamDataService { color: request.color, cwd: request.cwd?.trim() || '', providerBackendId: request.providerBackendId, + fastMode: request.fastMode, createdAt: joinedAt, }); diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 4e221bc2..86ad0465 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -15,7 +15,13 @@ const QUOTA_EXHAUSTED_TOKENS = [ 'quota exceeded', 'quota exhausted', ]; -const RATE_LIMITED_TOKENS = ['rate limit', 'too many requests', '429']; +const RATE_LIMITED_TOKENS = [ + 'rate limit', + 'too many requests', + '429', + 'model cooldown', + 'cooling down', +]; const AUTH_ERROR_TOKENS = [ 'unauthorized', 'forbidden', diff --git a/src/main/services/team/TeamMetaStore.ts b/src/main/services/team/TeamMetaStore.ts index a4b41eb8..57f727a5 100644 --- a/src/main/services/team/TeamMetaStore.ts +++ b/src/main/services/team/TeamMetaStore.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; -import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types'; +import type { ProviderModelLaunchIdentity, TeamFastMode, TeamProviderId } from '@shared/types'; /** * Persisted team-level metadata saved by the UI before CLI provisioning. @@ -25,6 +25,7 @@ export interface TeamMetaFile { providerBackendId?: string; model?: string; effort?: string; + fastMode?: TeamFastMode; skipPermissions?: boolean; worktree?: string; extraCliArgs?: string; @@ -51,6 +52,10 @@ function normalizeOptionalString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } +function normalizeFastMode(value: unknown): TeamFastMode | null { + return value === 'inherit' || value === 'on' || value === 'off' ? value : null; +} + function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -80,7 +85,8 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | raw.selectedEffort === 'low' || raw.selectedEffort === 'medium' || raw.selectedEffort === 'high' || - raw.selectedEffort === 'xhigh' + raw.selectedEffort === 'xhigh' || + raw.selectedEffort === 'max' ? raw.selectedEffort : null; const resolvedEffort = @@ -89,7 +95,8 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | raw.resolvedEffort === 'low' || raw.resolvedEffort === 'medium' || raw.resolvedEffort === 'high' || - raw.resolvedEffort === 'xhigh' + raw.resolvedEffort === 'xhigh' || + raw.resolvedEffort === 'max' ? raw.resolvedEffort : null; @@ -105,6 +112,9 @@ function normalizeLaunchIdentity(value: unknown): ProviderModelLaunchIdentity | catalogFetchedAt: normalizeOptionalString(raw.catalogFetchedAt), selectedEffort, resolvedEffort, + selectedFastMode: normalizeFastMode(raw.selectedFastMode), + resolvedFastMode: typeof raw.resolvedFastMode === 'boolean' ? raw.resolvedFastMode : null, + fastResolutionReason: normalizeOptionalString(raw.fastResolutionReason), }; } @@ -173,6 +183,7 @@ export class TeamMetaStore { ), model: typeof file.model === 'string' ? file.model.trim() || undefined : undefined, effort: typeof file.effort === 'string' ? file.effort.trim() || undefined : undefined, + fastMode: normalizeFastMode(file.fastMode) ?? undefined, skipPermissions: typeof file.skipPermissions === 'boolean' ? file.skipPermissions : undefined, worktree: typeof file.worktree === 'string' ? file.worktree.trim() || undefined : undefined, extraCliArgs: @@ -198,6 +209,7 @@ export class TeamMetaStore { ), model: data.model?.trim() || undefined, effort: data.effort?.trim() || undefined, + fastMode: normalizeFastMode(data.fastMode) ?? undefined, skipPermissions: data.skipPermissions, worktree: data.worktree?.trim() || undefined, extraCliArgs: data.extraCliArgs?.trim() || undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5fd6504a..1f1e069b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2,6 +2,10 @@ import { killTmuxPaneForCurrentPlatformSync, listTmuxPanePidsForCurrentPlatform, } from '@features/tmux-installer/main'; +import { + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -170,6 +174,7 @@ interface RelayInboxMessageView { } import type { + CliProviderModelCatalog, ActiveToolCall, CliProviderRuntimeCapabilities, CrossTeamSendResult, @@ -190,6 +195,7 @@ import type { TeamConfig, TeamCreateRequest, TeamCreateResponse, + TeamFastMode, TeamLaunchAggregateState, TeamLaunchRequest, TeamLaunchResponse, @@ -328,6 +334,7 @@ interface RuntimeStatusCommandResponse { providers?: Record< string, { + modelCatalog?: CliProviderModelCatalog | null; runtimeCapabilities?: CliProviderRuntimeCapabilities | null; } >; @@ -336,6 +343,7 @@ interface RuntimeStatusCommandResponse { interface RuntimeProviderLaunchFacts { defaultModel: string | null; modelIds: Set; + modelCatalog: CliProviderModelCatalog | null; runtimeCapabilities: CliProviderRuntimeCapabilities | null; } @@ -416,6 +424,47 @@ function isCodexEffortRuntimeSupported( return reasoning?.configPassthrough === true && reasoning.values.includes(effort); } +function getAnthropicFastModeDefault(): boolean { + return ( + ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true + ); +} + +function resolveAnthropicSelectionFromFacts(params: { + selectedModel?: string; + limitContext?: boolean; + facts: Pick; +}) { + return resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: params.facts.modelCatalog, + runtimeCapabilities: params.facts.runtimeCapabilities, + }, + selectedModel: params.selectedModel, + limitContext: params.limitContext === true, + }); +} + +function buildAnthropicSettingsArgs( + providerId: TeamProviderId, + launchIdentity?: ProviderModelLaunchIdentity | null +): string[] { + if (providerId !== 'anthropic' || typeof launchIdentity?.resolvedFastMode !== 'boolean') { + return []; + } + + const settings = launchIdentity.resolvedFastMode + ? { + fastMode: true, + fastModePerSessionOptIn: false, + } + : { + fastMode: false, + }; + + return ['--settings', JSON.stringify(settings)]; +} + function isProbeTimeoutMessage(message: string): boolean { const lower = message.toLowerCase(); return ( @@ -522,7 +571,10 @@ function mergeProvisioningWarnings( } function buildRuntimeLaunchWarning( - request: Pick, + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' + >, env: NodeJS.ProcessEnv, options?: { geminiRuntimeAuth?: GeminiRuntimeAuthState | null; @@ -534,6 +586,10 @@ function buildRuntimeLaunchWarning( const providerLabel = getTeamProviderLabel(providerId); const modelLabel = request.model?.trim() || 'default'; const effortLabel = request.effort ?? 'default'; + const fastLabel = + providerId === 'anthropic' + ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}` + : ''; const backend = migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || getConfiguredRuntimeBackend(providerId); @@ -564,14 +620,17 @@ function buildRuntimeLaunchWarning( typeof options?.expectedMembersCount === 'number' ? `, members ${options.expectedMembersCount}` : ''; - return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; + return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`; } function logRuntimeLaunchSnapshot( teamName: string, claudePath: string, args: string[], - request: Pick, + request: Pick< + TeamCreateRequest, + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' + >, env: NodeJS.ProcessEnv, options?: { geminiRuntimeAuth?: GeminiRuntimeAuthState | null; @@ -586,6 +645,7 @@ function logRuntimeLaunchSnapshot( providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null, model: request.model ?? null, effort: request.effort ?? null, + fastMode: request.fastMode ?? null, configuredBackend: migrateProviderBackendId(providerId, request.providerBackendId?.trim()) || getConfiguredRuntimeBackend(providerId), @@ -2984,12 +3044,16 @@ export class TeamProvisioningService { } ); const runtimeStatusPromise = - params.providerId === 'codex' - ? execCli(params.claudePath, ['runtime', 'status', '--json', '--provider', 'codex'], { - cwd: params.cwd, - env: params.env, - timeout: 8_000, - }) + params.providerId === 'codex' || params.providerId === 'anthropic' + ? execCli( + params.claudePath, + ['runtime', 'status', '--json', '--provider', params.providerId], + { + cwd: params.cwd, + env: params.env, + timeout: 8_000, + } + ) : null; const [modelListResult, runtimeStatusResult] = await Promise.allSettled([ @@ -3020,6 +3084,7 @@ export class TeamProvisioningService { } let runtimeCapabilities: CliProviderRuntimeCapabilities | null = null; + let modelCatalog: CliProviderModelCatalog | null = null; if ( runtimeStatusResult.status === 'fulfilled' && runtimeStatusResult.value && @@ -3029,7 +3094,12 @@ export class TeamProvisioningService { const parsed = extractJsonObjectFromCli( runtimeStatusResult.value.stdout ); - runtimeCapabilities = parsed.providers?.[params.providerId]?.runtimeCapabilities ?? null; + const providerStatus = parsed.providers?.[params.providerId]; + runtimeCapabilities = providerStatus?.runtimeCapabilities ?? null; + modelCatalog = + providerStatus?.modelCatalog?.providerId === params.providerId + ? providerStatus.modelCatalog + : null; } catch (error) { logger.warn( `[${params.providerId}] Failed to parse runtime capabilities for launch validation: ${ @@ -3039,16 +3109,32 @@ export class TeamProvisioningService { } } + if (modelCatalog) { + for (const model of modelCatalog.models ?? []) { + const launchModel = model.launchModel?.trim(); + if (launchModel) { + modelIds.add(launchModel); + } + const catalogId = model.id?.trim(); + if (catalogId) { + modelIds.add(catalogId); + } + } + defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel; + } + return { defaultModel: params.providerId === 'anthropic' ? resolveAnthropicLaunchModel({ limitContext: params.limitContext === true, - availableLaunchModels: modelIds, + availableLaunchModels: + modelCatalog?.models.map((model) => model.launchModel) ?? modelIds, defaultLaunchModel: defaultModel, }) : defaultModel, modelIds, + modelCatalog, runtimeCapabilities, }; } @@ -3056,7 +3142,7 @@ export class TeamProvisioningService { private buildProviderModelLaunchIdentity(params: { request: Pick< TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext' + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' >; facts: RuntimeProviderLaunchFacts; }): ProviderModelLaunchIdentity { @@ -3068,6 +3154,39 @@ export class TeamProvisioningService { limitContext: params.request.limitContext, facts: params.facts, }); + if (providerId === 'anthropic') { + const selection = resolveAnthropicSelectionFromFacts({ + selectedModel: params.request.model, + limitContext: params.request.limitContext, + facts: params.facts, + }); + const fastResolution = resolveAnthropicFastMode({ + selection, + selectedFastMode: params.request.fastMode, + providerFastModeDefault: getAnthropicFastModeDefault(), + }); + + return { + providerId, + providerBackendId: + migrateProviderBackendId(providerId, params.request.providerBackendId) ?? null, + selectedModel: explicitModel ?? null, + selectedModelKind: explicitModel ? 'explicit' : 'default', + resolvedLaunchModel: selection.resolvedLaunchModel ?? resolvedLaunchModel, + catalogId: + selection.catalogModel?.id?.trim() || + selection.resolvedLaunchModel || + resolvedLaunchModel, + catalogSource: selection.catalogSource, + catalogFetchedAt: selection.catalogFetchedAt, + selectedEffort: params.request.effort ?? null, + resolvedEffort: params.request.effort ?? selection.defaultEffort ?? null, + selectedFastMode: params.request.fastMode ?? 'inherit', + resolvedFastMode: fastResolution.resolvedFastMode, + fastResolutionReason: fastResolution.disabledReason, + }; + } + const resolvedEffort = params.request.effort ?? null; return { @@ -3090,10 +3209,51 @@ export class TeamProvisioningService { providerId: TeamProviderId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; + limitContext?: boolean; facts: RuntimeProviderLaunchFacts; }): void { const explicitModel = getExplicitLaunchModelSelection(params.model); + if (params.providerId === 'anthropic') { + const selection = resolveAnthropicSelectionFromFacts({ + selectedModel: params.model, + limitContext: params.limitContext, + facts: params.facts, + }); + const resolvedLaunchModel = selection.resolvedLaunchModel?.trim() || null; + if (!resolvedLaunchModel) { + throw new Error( + `${params.actorLabel} could not resolve the selected Anthropic model against the current runtime catalog.` + ); + } + if (params.facts.modelIds.size > 0 && !params.facts.modelIds.has(resolvedLaunchModel)) { + throw new Error( + `${params.actorLabel} resolves to Anthropic model "${resolvedLaunchModel}", but the current runtime does not list it as launchable.` + ); + } + if (params.effort && !selection.supportedEfforts.includes(params.effort)) { + const modelLabel = selection.displayName ?? resolvedLaunchModel; + throw new Error( + `${params.actorLabel} uses Anthropic effort "${params.effort}", but ${modelLabel} does not support it in the current runtime.` + ); + } + + const fastResolution = resolveAnthropicFastMode({ + selection, + selectedFastMode: params.fastMode, + providerFastModeDefault: getAnthropicFastModeDefault(), + }); + if ((params.fastMode ?? 'inherit') === 'on' && !fastResolution.selectable) { + throw new Error( + `${params.actorLabel} enables Anthropic Fast mode, but ${ + fastResolution.disabledReason ?? 'it is unavailable for the selected runtime or model.' + }` + ); + } + return; + } + if (params.providerId !== 'codex') { if (params.effort && !isLegacySafeEffort(params.effort)) { throw new Error( @@ -3133,7 +3293,7 @@ export class TeamProvisioningService { env: NodeJS.ProcessEnv; request: Pick< TeamCreateRequest, - 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'limitContext' + 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext' >; effectiveMembers: TeamCreateRequest['members']; }): Promise { @@ -3161,6 +3321,8 @@ export class TeamProvisioningService { providerId: leadProviderId, model: params.request.model, effort: params.request.effort, + fastMode: params.request.fastMode, + limitContext: params.request.limitContext, facts: leadFacts, }); @@ -3172,6 +3334,7 @@ export class TeamProvisioningService { providerId: memberProviderId, model: member.model, effort: member.effort, + limitContext: params.request.limitContext, facts: memberFacts, }); } @@ -4867,6 +5030,7 @@ export class TeamProvisioningService { run?.request.providerId ?? persistedTeamMeta?.providerId, run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId ), + fastMode: run?.request.fastMode ?? persistedTeamMeta?.fastMode, members: snapshotMembers, }; @@ -6728,6 +6892,8 @@ export class TeamProvisioningService { request.model, launchIdentity ); + const resolvedProviderId = resolveTeamProviderId(request.providerId); + const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity); const spawnArgs = [ '--input-format', 'stream-json', @@ -6751,7 +6917,8 @@ export class TeamProvisioningService { ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), ...(launchModelArg ? ['--model', launchModelArg] : []), - ...(request.effort ? ['--effort', request.effort] : []), + ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), + ...anthropicSettingsArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), ...parseCliArgs(request.extraCliArgs), ...providerArgs, @@ -6784,6 +6951,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, + fastMode: request.fastMode, skipPermissions: request.skipPermissions, worktree: request.worktree, extraCliArgs: request.extraCliArgs, @@ -7185,6 +7353,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, + fastMode: request.fastMode, skipPermissions: request.skipPermissions, }; @@ -7389,12 +7558,15 @@ export class TeamProvisioningService { request.model, launchIdentity ); + const resolvedProviderId = resolveTeamProviderId(request.providerId); + const anthropicSettingsArgs = buildAnthropicSettingsArgs(resolvedProviderId, launchIdentity); if (launchModelArg) { launchArgs.push('--model', launchModelArg); } - if (request.effort) { - launchArgs.push('--effort', request.effort); + if (launchIdentity.resolvedEffort) { + launchArgs.push('--effort', launchIdentity.resolvedEffort); } + launchArgs.push(...anthropicSettingsArgs); if (request.worktree) { launchArgs.push('--worktree', request.worktree); } @@ -7423,6 +7595,7 @@ export class TeamProvisioningService { providerBackendId: request.providerBackendId, model: request.model, effort: request.effort, + fastMode: request.fastMode, skipPermissions: request.skipPermissions, worktree: request.worktree, extraCliArgs: request.extraCliArgs, diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index c7e210b5..970a0b4c 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -722,6 +722,19 @@ export const ProviderRuntimeSettingsDialog = ({ const codexActionBusy = disabled || selectedProviderLoading || connectionSaving || codexAccount.loading; const runtimeBusy = disabled || selectedProviderLoading || runtimeSaving; + const anthropicFastModeCapability = + selectedProvider?.providerId === 'anthropic' + ? (selectedProvider.runtimeCapabilities?.fastMode ?? null) + : null; + const anthropicFastModeEnabled = + appConfig?.providerConnections?.anthropic.fastModeDefault === true; + const anthropicFastModeSupported = anthropicFastModeCapability?.supported === true; + const anthropicFastModeAvailable = anthropicFastModeCapability?.available === true; + const anthropicFastModeDisabledReason = + anthropicFastModeCapability?.reason ?? + (anthropicFastModeSupported + ? 'Fast mode is currently unavailable for this Anthropic runtime.' + : 'This Anthropic runtime does not expose Fast mode.'); const connectionMethodCardsHint = selectedProvider ? getConnectionMethodCardsHint(selectedProvider) : null; @@ -969,6 +982,29 @@ export const ProviderRuntimeSettingsDialog = ({ } }; + const handleAnthropicFastModeDefaultChange = async (enabled: boolean): Promise => { + if (selectedProvider?.providerId !== 'anthropic' || anthropicFastModeEnabled === enabled) { + return; + } + + setConnectionSaving(true); + setConnectionError(null); + try { + await updateConfig('providerConnections', { + anthropic: { + fastModeDefault: enabled, + }, + }); + await onRefreshProvider?.('anthropic'); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : 'Failed to update Anthropic Fast mode' + ); + } finally { + setConnectionSaving(false); + } + }; + return ( @@ -1188,6 +1224,50 @@ export const ProviderRuntimeSettingsDialog = ({ ) : null} + {selectedProvider.providerId === 'anthropic' ? ( +
+
+ Fast mode default +
+
+ Apply Claude Code Fast mode by default for new Anthropic team launches when the + resolved model and runtime allow it. +
+ {anthropicFastModeSupported ? ( +
+ {[ + { enabled: false, label: 'Default Off' }, + { enabled: true, label: 'Prefer Fast' }, + ].map((option) => ( + + ))} +
+ ) : null} +
+ {anthropicFastModeSupported && anthropicFastModeAvailable + ? anthropicFastModeEnabled + ? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.' + : 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.' + : anthropicFastModeDisabledReason} +
+
+ ) : null} + {selectedProvider.providerId === 'codex' ? (
void; + providerFastModeDefault: boolean; + model?: string; + limitContext: boolean; + id?: string; +} + +export const AnthropicFastModeSelector: React.FC = ({ + value, + onValueChange, + providerFastModeDefault, + model, + limitContext, + id, +}) => { + const { providerStatus } = useEffectiveCliProviderStatus('anthropic'); + + const selection = useMemo( + () => + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: providerStatus?.modelCatalog, + runtimeCapabilities: providerStatus?.runtimeCapabilities, + }, + selectedModel: model, + limitContext, + }), + [limitContext, model, providerStatus?.modelCatalog, providerStatus?.runtimeCapabilities] + ); + + const resolution = useMemo( + () => + resolveAnthropicFastMode({ + selection, + selectedFastMode: value, + providerFastModeDefault, + }), + [providerFastModeDefault, selection, value] + ); + + if (!resolution.showFastModeControl) { + return null; + } + + const defaultLabel = providerFastModeDefault ? 'Default (Fast)' : 'Default (Off)'; + const helperText = + value === 'inherit' + ? `Default currently resolves to ${resolution.resolvedFastMode ? 'Fast' : 'Off'}.` + : (resolution.disabledReason ?? + 'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.'); + + return ( +
+ +
+ +
+ {[ + { value: 'inherit' as const, label: defaultLabel, disabled: false }, + { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable }, + { value: 'off' as const, label: 'Off', disabled: false }, + ].map((option) => ( + + ))} +
+
+

{helperText}

+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 5ebd5c8b..34285df3 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1,5 +1,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/renderer'; import { mergeCodexCliStatusWithSnapshot, useCodexAccountSnapshot, @@ -100,6 +105,7 @@ import type { EffortLevel, Project, TeamCreateRequest, + TeamFastMode, TeamProviderId, TeamProvisioningMemberInput, } from '@shared/types'; @@ -121,6 +127,11 @@ function getStoredTeamModel(providerId: TeamProviderId): string { return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } +function getStoredTeamFastMode(): TeamFastMode { + const stored = localStorage.getItem('team:lastSelectedFastMode'); + return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit'; +} + function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { const normalized = normalizePath(projectPath ?? '').toLowerCase(); return ( @@ -313,6 +324,9 @@ export const CreateTeamDialog = ({ }: CreateTeamDialogProps): React.JSX.Element => { const { isLight } = useTheme(); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const anthropicProviderFastModeDefault = useStore( + (s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false + ); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); @@ -396,6 +410,8 @@ export const CreateTeamDialog = ({ const stored = localStorage.getItem('team:lastSelectedEffort'); return stored === null ? 'medium' : stored; }); + const [selectedFastMode, setSelectedFastModeRaw] = useState(getStoredTeamFastMode); + const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState(null); // Advanced CLI section state (use teamName-derived key for localStorage) const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_'; @@ -456,6 +472,11 @@ export const CreateTeamDialog = ({ localStorage.setItem('team:lastSelectedEffort', value); }; + const setSelectedFastMode = (value: TeamFastMode): void => { + setSelectedFastModeRaw(value); + localStorage.setItem('team:lastSelectedFastMode', value); + }; + const setWorktreeEnabled = (value: boolean): void => { setWorktreeEnabledRaw(value); localStorage.setItem(`team:lastWorktreeEnabled:${advancedKey}`, String(value)); @@ -1003,6 +1024,84 @@ export const CreateTeamDialog = ({ ), [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] ); + const anthropicRuntimeSelection = useMemo( + () => + selectedProviderId === 'anthropic' + ? resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog, + runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities, + }, + selectedModel, + limitContext, + }) + : null, + [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + ); + const anthropicFastModeResolution = useMemo( + () => + selectedProviderId === 'anthropic' && anthropicRuntimeSelection + ? resolveAnthropicFastMode({ + selection: anthropicRuntimeSelection, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }) + : null, + [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + selectedFastMode, + selectedProviderId, + ] + ); + + useEffect(() => { + if (selectedProviderId !== 'anthropic') { + setAnthropicRuntimeNotice(null); + return; + } + + const reconciliation = reconcileAnthropicRuntimeSelections({ + selection: + anthropicRuntimeSelection ?? + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel, + limitContext, + }), + selectedEffort, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }); + + const notices: string[] = []; + if (reconciliation.nextEffort !== selectedEffort) { + setSelectedEffortRaw(reconciliation.nextEffort); + localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort); + if (reconciliation.effortResetReason) { + notices.push(reconciliation.effortResetReason); + } + } + if (reconciliation.nextFastMode !== selectedFastMode) { + setSelectedFastModeRaw(reconciliation.nextFastMode); + localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode); + if (reconciliation.fastModeResetReason) { + notices.push(reconciliation.fastModeResetReason); + } + } + setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null); + }, [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + limitContext, + selectedEffort, + selectedFastMode, + selectedModel, + selectedProviderId, + ]); const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const teamNameInlineError = validateTeamNameInline(teamName); @@ -1026,6 +1125,7 @@ export const CreateTeamDialog = ({ ) ?? undefined, model: effectiveModel, effort: (selectedEffort as EffortLevel) || undefined, + fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined, limitContext, skipPermissions, worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined, @@ -1043,6 +1143,7 @@ export const CreateTeamDialog = ({ runtimeProviderStatusById, effectiveModel, selectedEffort, + selectedFastMode, limitContext, skipPermissions, worktreeEnabled, @@ -1132,18 +1233,49 @@ export const CreateTeamDialog = ({ args.push('--mcp-config', '', '--disallowedTools', APP_TEAM_RUNTIME_DISALLOWED_TOOLS); if (skipPermissions) args.push('--dangerously-skip-permissions'); if (effectiveModel) args.push('--model', effectiveModel); - if (selectedEffort) args.push('--effort', selectedEffort); + const effectiveEffort = + selectedProviderId === 'anthropic' + ? selectedEffort || anthropicRuntimeSelection?.defaultEffort || '' + : selectedEffort; + if (effectiveEffort) args.push('--effort', effectiveEffort); + if (selectedProviderId === 'anthropic') { + const fastSettings = anthropicFastModeResolution?.resolvedFastMode + ? { fastMode: true, fastModePerSessionOptIn: false } + : { fastMode: false }; + args.push('--settings', JSON.stringify(fastSettings)); + } return args; - }, [skipPermissions, effectiveModel, selectedEffort]); + }, [ + anthropicFastModeResolution?.resolvedFastMode, + anthropicRuntimeSelection?.defaultEffort, + effectiveModel, + selectedEffort, + selectedProviderId, + skipPermissions, + ]); const launchOptionalSummary = useMemo(() => { const summary: string[] = []; if (prompt.trim()) summary.push('Lead prompt'); if (skipPermissions) summary.push('Auto-approve tools'); + if (selectedProviderId === 'anthropic') { + if (selectedFastMode === 'on') summary.push('Fast mode'); + else if (selectedFastMode === 'off') summary.push('Fast disabled'); + else if (anthropicProviderFastModeDefault) summary.push('Fast default'); + } if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`); if (customArgs.trim()) summary.push('Custom CLI args'); return summary; - }, [prompt, skipPermissions, worktreeEnabled, worktreeName, customArgs]); + }, [ + anthropicProviderFastModeDefault, + customArgs, + prompt, + selectedFastMode, + selectedProviderId, + skipPermissions, + worktreeEnabled, + worktreeName, + ]); const teamDetailsSummary = useMemo(() => { const summary: string[] = []; @@ -1212,6 +1344,8 @@ export const CreateTeamDialog = ({ color: request.color, members: request.members, cwd: effectiveCwd || undefined, + providerBackendId: request.providerBackendId, + fastMode: request.fastMode, }); onOpenTeam(request.teamName, effectiveCwd || undefined); resetFormState(); @@ -1389,11 +1523,15 @@ export const CreateTeamDialog = ({ onProviderChange={setSelectedProviderId} onModelChange={setSelectedModel} onEffortChange={setSelectedEffort} + fastMode={selectedFastMode} + providerFastModeDefault={anthropicProviderFastModeDefault} + onFastModeChange={setSelectedFastMode} onLimitContextChange={setLimitContext} syncModelsWithTeammates={syncModelsWithLead} onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} disableGeminiOption={isGeminiUiFrozen()} leadModelIssueText={leadModelIssueText} + leadFastModeNotice={anthropicRuntimeNotice} memberModelIssueById={memberModelIssueById} headerTop={
diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index 53a07636..c63a5f5c 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -14,6 +14,7 @@ export interface EffortLevelSelectorProps { id?: string; providerId?: TeamProviderId; model?: string; + limitContext?: boolean; } export const EffortLevelSelector: React.FC = ({ @@ -22,9 +23,12 @@ export const EffortLevelSelector: React.FC = ({ id, providerId, model, + limitContext, }) => { const { providerStatus } = useEffectiveCliProviderStatus(providerId); - const effortOptions = getTeamEffortOptions({ providerId, model, providerStatus }); + const effortOptions = getTeamEffortOptions({ providerId, model, limitContext, providerStatus }); + const showsAnthropicMax = + providerId === 'anthropic' && effortOptions.some((option) => option.value === 'max'); return (
@@ -56,6 +60,12 @@ export const EffortLevelSelector: React.FC = ({ Controls how much reasoning the selected provider invests before responding. Default uses the provider's standard behavior for the selected model.

+ {showsAnthropicMax ? ( +

+ Max is Anthropic's heavier reasoning mode and only appears when the resolved launch + model supports it. +

+ ) : null}
); }; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 4dd79acc..413080f1 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1,5 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/renderer'; import { mergeCodexCliStatusWithSnapshot, useCodexAccountSnapshot, @@ -115,6 +120,7 @@ import type { Schedule, ScheduleLaunchConfig, TeamCreateRequest, + TeamFastMode, TeamLaunchRequest, TeamProviderId, UpdateSchedulePatch, @@ -216,6 +222,11 @@ function getStoredTeamModel(providerId: TeamProviderId): string { return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored); } +function getStoredTeamFastMode(): TeamFastMode { + const stored = localStorage.getItem('team:lastSelectedFastMode'); + return stored === 'on' || stored === 'off' || stored === 'inherit' ? stored : 'inherit'; +} + function getProviderLabel(providerId: TeamProviderId): string { return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; } @@ -254,6 +265,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const { open, onClose } = props; const { isLight } = useTheme(); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); + const anthropicProviderFastModeDefault = useStore( + (s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false + ); const cliStatus = useStore((s) => s.cliStatus); const cliStatusLoading = useStore((s) => s.cliStatusLoading); const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus); @@ -341,6 +355,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const stored = localStorage.getItem('team:lastSelectedEffort'); return stored === null ? 'medium' : stored; }); + const [selectedFastMode, setSelectedFastModeRaw] = useState(getStoredTeamFastMode); + const [anthropicRuntimeNotice, setAnthropicRuntimeNotice] = useState(null); // --------------------------------------------------------------------------- // Launch-only state @@ -535,6 +551,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen localStorage.setItem('team:lastSelectedEffort', value); }; + const setSelectedFastMode = (value: TeamFastMode): void => { + setSelectedFastModeRaw(value); + localStorage.setItem('team:lastSelectedFastMode', value); + }; + // --------------------------------------------------------------------------- // localStorage migration: schedule → team namespace (one-time) // --------------------------------------------------------------------------- @@ -625,6 +646,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ); setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); + setSelectedFastModeRaw(getStoredTeamFastMode()); } else { // Create mode — reset to defaults setSchedLabel(''); @@ -641,6 +663,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSelectedProviderIdRaw(storedProviderId); setSelectedModelRaw(getStoredTeamModel(storedProviderId)); setSelectedEffortRaw('medium'); + setSelectedFastModeRaw(getStoredTeamFastMode()); } setLocalError(null); @@ -690,6 +713,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen multimodelEnabled, storedProviderId, storedEffort: storedEffort === null ? 'medium' : storedEffort, + storedFastMode: getStoredTeamFastMode(), storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true', getStoredModel: getStoredTeamModel, }); @@ -709,6 +733,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen setSelectedProviderIdRaw(launchPrefill.providerId); setSelectedModelRaw(launchPrefill.model); setSelectedEffortRaw(launchPrefill.effort); + setSelectedFastModeRaw(launchPrefill.fastMode); setLimitContextRaw(launchPrefill.limitContext); setSkipPermissionsRaw( savedRequest?.skipPermissions ?? @@ -753,6 +778,85 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ) ?? '', [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] ); + const anthropicRuntimeSelection = useMemo( + () => + selectedProviderId === 'anthropic' + ? resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: runtimeProviderStatusById.get('anthropic')?.modelCatalog, + runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities, + }, + selectedModel, + limitContext, + }) + : null, + [limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId] + ); + const anthropicFastModeResolution = useMemo( + () => + selectedProviderId === 'anthropic' && anthropicRuntimeSelection + ? resolveAnthropicFastMode({ + selection: anthropicRuntimeSelection, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }) + : null, + [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + selectedFastMode, + selectedProviderId, + ] + ); + + useEffect(() => { + if (selectedProviderId !== 'anthropic') { + setAnthropicRuntimeNotice(null); + return; + } + + const reconciliation = reconcileAnthropicRuntimeSelections({ + selection: + anthropicRuntimeSelection ?? + resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel, + limitContext, + }), + selectedEffort, + selectedFastMode, + providerFastModeDefault: anthropicProviderFastModeDefault, + }); + + const notices: string[] = []; + if (reconciliation.nextEffort !== selectedEffort) { + setSelectedEffortRaw(reconciliation.nextEffort); + localStorage.setItem('team:lastSelectedEffort', reconciliation.nextEffort); + if (reconciliation.effortResetReason) { + notices.push(reconciliation.effortResetReason); + } + } + if (reconciliation.nextFastMode !== selectedFastMode) { + setSelectedFastModeRaw(reconciliation.nextFastMode); + localStorage.setItem('team:lastSelectedFastMode', reconciliation.nextFastMode); + if (reconciliation.fastModeResetReason) { + notices.push(reconciliation.fastModeResetReason); + } + } + setAnthropicRuntimeNotice(notices.length > 0 ? notices.join(' ') : null); + }, [ + anthropicProviderFastModeDefault, + anthropicRuntimeSelection, + limitContext, + selectedEffort, + selectedFastMode, + selectedModel, + selectedProviderId, + ]); + const selectedModelChecksByProvider = useMemo(() => { const modelsByProvider = new Map(); const defaultSelectionByProvider = new Map(); @@ -1237,17 +1341,30 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen runtimeProviderStatusById.get(selectedProviderId) ); if (model) args.push('--model', model); - if (selectedEffort) args.push('--effort', selectedEffort); + const effectiveEffort = + selectedProviderId === 'anthropic' + ? selectedEffort || anthropicRuntimeSelection?.defaultEffort || '' + : selectedEffort; + if (effectiveEffort) args.push('--effort', effectiveEffort); + if (selectedProviderId === 'anthropic') { + const fastSettings = anthropicFastModeResolution?.resolvedFastMode + ? { fastMode: true, fastModePerSessionOptIn: false } + : { fastMode: false }; + args.push('--settings', JSON.stringify(fastSettings)); + } if (!clearContext) args.push('--resume', ''); return args; }, [ + anthropicFastModeResolution?.resolvedFastMode, + anthropicRuntimeSelection?.defaultEffort, isLaunchMode, skipPermissions, selectedModel, limitContext, selectedEffort, - clearContext, selectedProviderId, + clearContext, + runtimeProviderStatusById, ]); const launchOptionalSummary = useMemo(() => { @@ -1258,6 +1375,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`); if (selectedModel) summary.push(`Model: ${selectedModel}`); if (selectedEffort) summary.push(`Effort: ${selectedEffort}`); + if (selectedProviderId === 'anthropic') { + if (selectedFastMode === 'on') summary.push('Fast mode'); + else if (selectedFastMode === 'off') summary.push('Fast disabled'); + else if (anthropicProviderFastModeDefault) summary.push('Fast default'); + } if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context'); if (skipPermissions) summary.push('Auto-approve tools'); if (clearContext) summary.push('Fresh session'); @@ -1270,6 +1392,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen selectedModel, selectedProviderId, selectedEffort, + selectedFastMode, + anthropicProviderFastModeDefault, limitContext, skipPermissions, clearContext, @@ -1478,6 +1602,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen runtimeProviderStatusById.get(selectedProviderId) ), effort: (selectedEffort as EffortLevel) || undefined, + fastMode: selectedProviderId === 'anthropic' ? selectedFastMode : undefined, limitContext, clearContext: clearContext || undefined, skipPermissions, @@ -1869,14 +1994,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen providerId={selectedProviderId} model={selectedModel} effort={(selectedEffort as EffortLevel) || undefined} + fastMode={selectedFastMode} + providerFastModeDefault={anthropicProviderFastModeDefault} limitContext={limitContext} onProviderChange={setSelectedProviderId} onModelChange={setSelectedModel} onEffortChange={setSelectedEffort} + onFastModeChange={setSelectedFastMode} onLimitContextChange={setLimitContext} syncModelsWithTeammates={syncModelsWithLead} onSyncModelsWithTeammatesChange={setSyncModelsWithLead} leadWarningText={leadRuntimeWarningText} + leadFastModeNotice={anthropicRuntimeNotice} memberWarningById={memberRuntimeWarningById} leadModelIssueText={leadModelIssueText} memberModelIssueById={memberModelIssueById} @@ -2029,6 +2158,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen id="dialog-effort" providerId={selectedProviderId} model={selectedModel} + limitContext={false} /> string; } @@ -31,6 +33,7 @@ interface LaunchDialogPrefillResult { providerBackendId?: string; model: string; effort: string; + fastMode: 'inherit' | 'on' | 'off'; limitContext: boolean; } @@ -62,6 +65,7 @@ export function resolveLaunchDialogPrefill({ multimodelEnabled, storedProviderId, storedEffort, + storedFastMode, storedLimitContext, getStoredModel, }: LaunchDialogPrefillInput): LaunchDialogPrefillResult { @@ -99,6 +103,8 @@ export function resolveLaunchDialogPrefill({ const effort = currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort; + const fastMode = + savedRequest?.fastMode ?? previousLaunchParams?.fastMode ?? storedFastMode ?? 'inherit'; const limitContext = previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext; @@ -113,6 +119,7 @@ export function resolveLaunchDialogPrefill({ ? normalizeExplicitTeamModelForUi(providerId, matchingModel) : getStoredModel(providerId), effort, + fastMode, limitContext, }; } diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx index c9c84a94..8f91c89e 100644 --- a/src/renderer/components/team/members/LeadModelRow.tsx +++ b/src/renderer/components/team/members/LeadModelRow.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; +import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector'; import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox'; @@ -20,20 +21,24 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react'; import { Button } from '../../ui/button'; -import type { EffortLevel, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types'; interface LeadModelRowProps { providerId: TeamProviderId; model: string; effort?: EffortLevel; + fastMode?: TeamFastMode; + providerFastModeDefault?: boolean; limitContext: boolean; onProviderChange: (providerId: TeamProviderId) => void; onModelChange: (model: string) => void; onEffortChange: (effort: string) => void; + onFastModeChange?: (fastMode: TeamFastMode) => void; onLimitContextChange: (value: boolean) => void; syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; warningText?: string | null; + fastModeNotice?: string | null; disableGeminiOption?: boolean; modelIssueText?: string | null; } @@ -42,14 +47,18 @@ export const LeadModelRow = ({ providerId, model, effort, + fastMode = 'inherit', + providerFastModeDefault = false, limitContext, onProviderChange, onModelChange, onEffortChange, + onFastModeChange, onLimitContextChange, syncModelsWithTeammates, onSyncModelsWithTeammatesChange, warningText, + fastModeNotice, disableGeminiOption = false, modelIssueText, }: LeadModelRowProps): React.JSX.Element => { @@ -157,7 +166,18 @@ export const LeadModelRow = ({ id="lead-effort" providerId={providerId} model={model} + limitContext={limitContext} /> + {providerId === 'anthropic' && onFastModeChange ? ( + + ) : null} {providerId === 'anthropic' ? ( ) : null} + {fastModeNotice ? ( +
+ +

{fastModeNotice}

+
+ ) : null}

diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 498a483e..51b67cfe 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -46,6 +46,7 @@ interface MemberDraftRowProps { inheritedProviderId?: TeamProviderId; inheritedModel?: string; inheritedEffort?: EffortLevel; + limitContext?: boolean; draftKeyPrefix?: string; projectPath?: string | null; mentionSuggestions?: MentionSuggestion[]; @@ -91,6 +92,7 @@ export const MemberDraftRow = ({ inheritedProviderId = 'anthropic', inheritedModel = '', inheritedEffort, + limitContext = false, draftKeyPrefix, projectPath, mentionSuggestions = [], @@ -448,6 +450,7 @@ export const MemberDraftRow = ({ id={`member-${member.id}-effort`} providerId={effectiveProviderId} model={effectiveModel} + limitContext={limitContext} /> {lockProviderModel && (

diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index a9785ecd..d1d4ead5 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -168,7 +168,9 @@ function areLaunchParamsEquivalent( left.providerId === right.providerId && left.providerBackendId === right.providerBackendId && left.model === right.model && - left.effort === right.effort + left.effort === right.effort && + left.fastMode === right.fastMode && + left.limitContext === right.limitContext ); } diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index e7fb826e..06c34474 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -101,6 +101,7 @@ export interface MembersEditorSectionProps { inheritedProviderId?: TeamProviderId; inheritedModel?: string; inheritedEffort?: EffortLevel; + limitContext?: boolean; inheritModelSettingsByDefault?: boolean; forceInheritedModelSettings?: boolean; modelLockReason?: string; @@ -134,6 +135,7 @@ export const MembersEditorSection = ({ inheritedProviderId, inheritedModel, inheritedEffort, + limitContext = false, inheritModelSettingsByDefault = false, forceInheritedModelSettings = false, modelLockReason, @@ -331,6 +333,7 @@ export const MembersEditorSection = ({ inheritedProviderId={inheritedProviderId} inheritedModel={inheritedModel} inheritedEffort={inheritedEffort} + limitContext={limitContext} forceInheritedModelSettings={forceInheritedModelSettings} draftKeyPrefix={draftKeyPrefix} projectPath={projectPath} @@ -374,6 +377,7 @@ export const MembersEditorSection = ({ inheritedProviderId={inheritedProviderId} inheritedModel={inheritedModel} inheritedEffort={inheritedEffort} + limitContext={limitContext} forceInheritedModelSettings={forceInheritedModelSettings} draftKeyPrefix={draftKeyPrefix} projectPath={projectPath} diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index 5b6480ba..e3d423d6 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection'; import type { MemberDraft } from './membersEditorTypes'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { EffortLevel, TeamProviderId } from '@shared/types'; +import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types'; interface TeamRosterEditorSectionProps { members: MemberDraft[]; @@ -31,10 +31,13 @@ interface TeamRosterEditorSectionProps { providerId: TeamProviderId; model: string; effort?: EffortLevel; + fastMode?: TeamFastMode; + providerFastModeDefault?: boolean; limitContext: boolean; onProviderChange: (providerId: TeamProviderId) => void; onModelChange: (model: string) => void; onEffortChange: (effort: string) => void; + onFastModeChange?: (fastMode: TeamFastMode) => void; onLimitContextChange: (value: boolean) => void; syncModelsWithTeammates: boolean; onSyncModelsWithTeammatesChange: (value: boolean) => void; @@ -42,6 +45,7 @@ interface TeamRosterEditorSectionProps { headerBottom?: React.ReactNode; softDeleteMembers?: boolean; leadWarningText?: string | null; + leadFastModeNotice?: string | null; memberWarningById?: Record; disableGeminiOption?: boolean; leadModelIssueText?: string | null; @@ -72,10 +76,13 @@ export const TeamRosterEditorSection = ({ providerId, model, effort, + fastMode, + providerFastModeDefault, limitContext, onProviderChange, onModelChange, onEffortChange, + onFastModeChange, onLimitContextChange, syncModelsWithTeammates, onSyncModelsWithTeammatesChange, @@ -83,6 +90,7 @@ export const TeamRosterEditorSection = ({ headerBottom, softDeleteMembers = false, leadWarningText, + leadFastModeNotice, memberWarningById, disableGeminiOption = false, leadModelIssueText, @@ -106,6 +114,7 @@ export const TeamRosterEditorSection = ({ inheritedProviderId={inheritedProviderId} inheritedModel={inheritedModel} inheritedEffort={inheritedEffort} + limitContext={limitContext} inheritModelSettingsByDefault={inheritModelSettingsByDefault} lockProviderModel={lockProviderModel} forceInheritedModelSettings={forceInheritedModelSettings} @@ -120,14 +129,18 @@ export const TeamRosterEditorSection = ({ providerId={providerId} model={model} effort={effort} + fastMode={fastMode} + providerFastModeDefault={providerFastModeDefault} limitContext={limitContext} onProviderChange={onProviderChange} onModelChange={onModelChange} onEffortChange={onEffortChange} + onFastModeChange={onFastModeChange} onLimitContextChange={onLimitContextChange} syncModelsWithTeammates={syncModelsWithTeammates} onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} warningText={leadWarningText} + fastModeNotice={leadFastModeNotice} disableGeminiOption={disableGeminiOption} modelIssueText={leadModelIssueText} /> diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 1a86a712..8bb11d0d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1526,6 +1526,7 @@ export interface TeamLaunchParams { providerBackendId?: string; model?: string; // 'opus' | 'sonnet' | 'haiku' effort?: EffortLevel; + fastMode?: 'inherit' | 'on' | 'off'; limitContext?: boolean; } @@ -4426,6 +4427,7 @@ export const createTeamSlice: StateCreator = (set, providerBackendId: request.providerBackendId, model: baseModel || 'default', effort: request.effort, + fastMode: request.fastMode, limitContext: request.limitContext ?? false, }; saveLaunchParams(request.teamName, params); @@ -4598,6 +4600,7 @@ export const createTeamSlice: StateCreator = (set, providerBackendId: request.providerBackendId, model: baseModel || 'default', effort: request.effort, + fastMode: request.fastMode, limitContext: request.limitContext ?? false, }; saveLaunchParams(request.teamName, params); diff --git a/src/renderer/utils/__tests__/teamEffortOptions.test.ts b/src/renderer/utils/__tests__/teamEffortOptions.test.ts index 4ed51753..91c268b0 100644 --- a/src/renderer/utils/__tests__/teamEffortOptions.test.ts +++ b/src/renderer/utils/__tests__/teamEffortOptions.test.ts @@ -93,15 +93,16 @@ describe('team effort options', () => { ); }); - it('shows only supported low/medium/high efforts for Anthropic and never leaks max', () => { + it('keeps Anthropic aliases conservative when the resolved runtime model does not support effort', () => { const providerStatus = createProviderStatus('anthropic', { - id: 'opus', - launchModel: 'opus', - displayName: 'Opus 4.7', - hidden: false, - supportedReasoningEfforts: ['low', 'medium', 'high'], + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], defaultReasoningEffort: null, inputModalities: ['text', 'image'], + supportsFastMode: false, supportsPersonality: false, isDefault: true, upgrade: false, @@ -110,11 +111,83 @@ describe('team effort options', () => { expect( getTeamEffortOptions({ providerId: 'anthropic', model: 'opus', providerStatus }) + ).toEqual([{ value: '', label: 'Default' }]); + }); + + it('shows Anthropic max only for the exact resolved model that supports it', () => { + const providerStatus = { + ...createProviderStatus('anthropic', { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }), + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic' as const, + source: 'anthropic-models-api' as const, + status: 'ready' as const, + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api' as const, + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api' as const, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + } satisfies CliProviderStatus; + + expect( + getTeamEffortOptions({ + providerId: 'anthropic', + model: 'claude-opus-4-6', + providerStatus, + }) ).toEqual([ - { value: '', label: 'Default' }, + { value: '', label: 'Default (Medium)' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, ]); }); diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index af312422..ac4f6def 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -5,6 +5,8 @@ import { getAvailableTeamProviderModelOptions, getAvailableTeamProviderModels, getTeamModelSelectionError, + isTeamModelAvailableForUi, + normalizeTeamModelForUi, } from '../teamModelAvailability'; import type { CliProviderStatus } from '@shared/types'; @@ -62,6 +64,58 @@ function createCodexProviderStatus( }; } +function createAnthropicProviderStatus( + models: NonNullable['models'] +): CliProviderStatus { + return { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + models: ['opus', 'claude-opus-4-6', 'sonnet', 'haiku'], + modelCatalog: { + schemaVersion: 1, + providerId: 'anthropic', + source: 'anthropic-models-api', + status: 'ready', + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: 'opus[1m]', + defaultLaunchModel: 'opus[1m]', + models, + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + modelAvailability: [], + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api', + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high'], + configPassthrough: false, + }, + }, + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }; +} + describe('team model availability Codex catalog integration', () => { it('uses app-server catalog models even when the static Codex list has not learned a new model yet', () => { const providerStatus = createCodexProviderStatus( @@ -158,4 +212,163 @@ describe('team model availability Codex catalog integration', () => { expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.4']); }); + + it('keeps the curated Anthropic picker surface while using runtime-backed labels', () => { + const providerStatus = createAnthropicProviderStatus([ + { + id: 'opus', + launchModel: 'opus', + displayName: 'Opus 4.8', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.8', + }, + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.8 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Opus 4.6', + }, + { + id: 'sonnet', + launchModel: 'sonnet', + displayName: 'Sonnet 4.7', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Sonnet 4.7', + }, + { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku 4.6', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + badgeLabel: 'Haiku 4.6', + }, + { + id: 'claude-sonnet-4-6[1m]', + launchModel: 'claude-sonnet-4-6[1m]', + displayName: 'Sonnet 4.6 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'static-fallback', + }, + ]); + + expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([ + 'haiku', + 'opus', + 'claude-opus-4-6', + 'sonnet', + ]); + expect(getAvailableTeamProviderModelOptions('anthropic', providerStatus)).toEqual([ + { + value: '', + label: 'Default', + badgeLabel: 'Default', + availabilityStatus: undefined, + availabilityReason: undefined, + }, + { + value: 'opus', + label: 'Opus 4.8', + badgeLabel: 'Opus 4.8', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'claude-opus-4-6', + label: 'Opus 4.6', + badgeLabel: 'Opus 4.6', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'sonnet', + label: 'Sonnet 4.7', + badgeLabel: 'Sonnet 4.7', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'haiku', + label: 'Haiku 4.6', + badgeLabel: 'Haiku 4.6', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + + it('keeps persisted hidden Anthropic compatibility values valid when runtime catalog supplies them', () => { + const providerStatus = createAnthropicProviderStatus([ + { + id: 'claude-sonnet-4-6[1m]', + launchModel: 'claude-sonnet-4-6[1m]', + displayName: 'Sonnet 4.6 (1M)', + hidden: true, + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'static-fallback', + }, + ]); + + expect(isTeamModelAvailableForUi('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe( + true + ); + expect(normalizeTeamModelForUi('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe( + 'claude-sonnet-4-6[1m]' + ); + expect(getTeamModelSelectionError('anthropic', 'claude-sonnet-4-6[1m]', providerStatus)).toBe( + null + ); + }); }); diff --git a/src/renderer/utils/teamEffortOptions.ts b/src/renderer/utils/teamEffortOptions.ts index 5c6767c0..c3465d70 100644 --- a/src/renderer/utils/teamEffortOptions.ts +++ b/src/renderer/utils/teamEffortOptions.ts @@ -1,3 +1,5 @@ +import { resolveAnthropicRuntimeSelection } from '@features/anthropic-runtime-profile/renderer'; + import type { CliProviderStatus, EffortLevel, TeamProviderId } from '@shared/types'; const BASE_EFFORT_OPTIONS = [{ value: '', label: 'Default' }] as const; @@ -9,6 +11,7 @@ export const TEAM_EFFORT_LABELS: Record = { low: 'Low', medium: 'Medium', high: 'High', + max: 'Max', xhigh: 'XHigh', }; @@ -59,6 +62,7 @@ function normalizeEfforts( export function getTeamEffortOptions(params: { providerId?: TeamProviderId; model?: string; + limitContext?: boolean; providerStatus?: CliProviderStatus | null; }): readonly TeamEffortOption[] { const providerId = params.providerId; @@ -66,6 +70,27 @@ export function getTeamEffortOptions(params: { return BASE_EFFORT_OPTIONS; } + if (providerId === 'anthropic') { + const selection = resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: params.providerStatus?.modelCatalog, + runtimeCapabilities: params.providerStatus?.runtimeCapabilities, + }, + selectedModel: params.model, + limitContext: params.limitContext === true, + }); + const defaultLabel = selection.defaultEffort + ? `Default (${TEAM_EFFORT_LABELS[selection.defaultEffort]})` + : 'Default'; + return [ + { value: '', label: defaultLabel }, + ...selection.supportedEfforts.map((effort) => ({ + value: effort, + label: TEAM_EFFORT_LABELS[effort], + })), + ]; + } + const runtimeCapability = params.providerStatus?.runtimeCapabilities?.reasoningEffort; const catalogModel = getCatalogModel(providerId, params.providerStatus, params.model); const catalogEfforts = catalogModel?.supportedReasoningEfforts ?? []; @@ -82,16 +107,6 @@ export function getTeamEffortOptions(params: { ? `Default (${TEAM_EFFORT_LABELS[catalogModel.defaultReasoningEffort]})` : 'Default'; - if (providerId === 'anthropic') { - return [ - { value: '', label: defaultLabel }, - ...efforts.map((effort) => ({ - value: effort, - label: TEAM_EFFORT_LABELS[effort], - })), - ]; - } - if (providerId === 'codex') { const fallbackEfforts = efforts.length > 0 ? efforts : (['low', 'medium', 'high'] as EffortLevel[]); diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 12d556cf..d5309f8f 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -117,7 +117,14 @@ export interface CliProviderModelAvailability { checkedAt?: string | null; } -export type CliProviderReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +export type CliProviderReasoningEffort = + | 'none' + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max'; export type CliProviderModelCatalogSource = | 'anthropic-models-api' @@ -132,6 +139,7 @@ export interface CliProviderModelCatalogItem { hidden: boolean; supportedReasoningEfforts: CliProviderReasoningEffort[]; defaultReasoningEffort: CliProviderReasoningEffort | null; + supportsFastMode?: boolean; inputModalities: string[]; supportsPersonality: boolean; isDefault: boolean; @@ -169,6 +177,12 @@ export interface CliProviderRuntimeCapabilities { values: CliProviderReasoningEffort[]; configPassthrough?: boolean; }; + fastMode?: { + supported: boolean; + available: boolean; + reason?: string | null; + source: 'runtime'; + }; } export interface CliProviderStatus { diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 3adcda4f..3c39c08e 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -327,6 +327,7 @@ export interface AppConfig { providerConnections: { anthropic: { authMode: 'auto' | 'oauth' | 'api_key'; + fastModeDefault: boolean; }; codex: { preferredAuthMode: 'auto' | 'chatgpt' | 'api_key'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 449838ec..7bd7820f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -782,9 +782,10 @@ export interface TeamViewSnapshot { isAlive?: boolean; } -export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini'; export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; +export type TeamFastMode = 'inherit' | 'on' | 'off'; export interface ProviderModelLaunchIdentity { providerId: TeamProviderId; @@ -802,6 +803,9 @@ export interface ProviderModelLaunchIdentity { catalogFetchedAt: string | null; selectedEffort: EffortLevel | null; resolvedEffort: EffortLevel | null; + selectedFastMode?: TeamFastMode | null; + resolvedFastMode?: boolean | null; + fastResolutionReason?: string | null; } export interface TeamLaunchRequest { @@ -812,6 +816,7 @@ export interface TeamLaunchRequest { providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; /** When true, context window is limited to 200K tokens instead of the default. */ limitContext?: boolean; /** When true, skip --resume and start a fresh session (clears context memory). */ @@ -949,6 +954,7 @@ export interface TeamAgentRuntimeSnapshot { updatedAt: string; runId: string | null; providerBackendId?: TeamProviderBackendId; + fastMode?: TeamFastMode; members: Record; } @@ -1061,6 +1067,7 @@ export interface TeamCreateRequest { providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; + fastMode?: TeamFastMode; /** When true, context window is limited to 200K tokens instead of the default. */ limitContext?: boolean; /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ @@ -1079,6 +1086,7 @@ export interface TeamCreateConfigRequest { members: TeamProvisioningMemberInput[]; cwd?: string; providerBackendId?: TeamProviderBackendId; + fastMode?: TeamFastMode; } export interface TeamCreateResponse { diff --git a/src/shared/utils/cliArgsParser.ts b/src/shared/utils/cliArgsParser.ts index bd7ddaa9..66ebd0ec 100644 --- a/src/shared/utils/cliArgsParser.ts +++ b/src/shared/utils/cliArgsParser.ts @@ -28,6 +28,7 @@ export const PROTECTED_CLI_FLAGS = new Set([ '--effort', '--teammate-mode', '--resume', + '--settings', '--permission-mode', '--permission-prompt-tool', '--dangerously-skip-permissions', diff --git a/src/shared/utils/effortLevels.ts b/src/shared/utils/effortLevels.ts index 958d9c03..63ee24f1 100644 --- a/src/shared/utils/effortLevels.ts +++ b/src/shared/utils/effortLevels.ts @@ -7,6 +7,7 @@ export const TEAM_EFFORT_LEVELS = [ 'medium', 'high', 'xhigh', + 'max', ] as const satisfies readonly EffortLevel[]; export const LEGACY_TEAM_EFFORT_LEVELS = [ @@ -23,8 +24,16 @@ export const CODEX_TEAM_EFFORT_LEVELS = [ 'xhigh', ] as const satisfies readonly EffortLevel[]; +export const ANTHROPIC_TEAM_EFFORT_LEVELS = [ + 'low', + 'medium', + 'high', + 'max', +] as const satisfies readonly EffortLevel[]; + const LEGACY_TEAM_EFFORT_LEVEL_SET = new Set(LEGACY_TEAM_EFFORT_LEVELS); const CODEX_TEAM_EFFORT_LEVEL_SET = new Set(CODEX_TEAM_EFFORT_LEVELS); +const ANTHROPIC_TEAM_EFFORT_LEVEL_SET = new Set(ANTHROPIC_TEAM_EFFORT_LEVELS); export function isTeamEffortLevel(value: unknown): value is EffortLevel { return typeof value === 'string' && TEAM_EFFORT_LEVELS.includes(value as EffortLevel); @@ -46,6 +55,10 @@ export function isTeamEffortLevelForProvider( return CODEX_TEAM_EFFORT_LEVEL_SET.has(value); } + if (providerId === 'anthropic') { + return ANTHROPIC_TEAM_EFFORT_LEVEL_SET.has(value); + } + return LEGACY_TEAM_EFFORT_LEVEL_SET.has(value); } @@ -53,5 +66,8 @@ export function formatEffortLevelListForProvider(providerId?: TeamProviderId | n if (providerId === 'codex') { return CODEX_TEAM_EFFORT_LEVELS.join(', '); } + if (providerId === 'anthropic') { + return ANTHROPIC_TEAM_EFFORT_LEVELS.join(', '); + } return LEGACY_TEAM_EFFORT_LEVELS.join(', '); } diff --git a/src/shared/utils/rateLimitDetector.ts b/src/shared/utils/rateLimitDetector.ts index 2d1252a1..7fa87922 100644 --- a/src/shared/utils/rateLimitDetector.ts +++ b/src/shared/utils/rateLimitDetector.ts @@ -3,12 +3,24 @@ */ const RATE_LIMIT_SUBSTRING = "You've hit your limit"; +const MODEL_COOLDOWN_CODE = 'model_cooldown'; + +interface StructuredRateLimitPayload { + code: string | null; + message: string | null; + resetSeconds: number | null; + resetTime: string | null; +} /** * Returns true if the message text contains the rate limit indicator. */ export function isRateLimitMessage(text: string): boolean { - return text.includes(RATE_LIMIT_SUBSTRING); + if (!text) return false; + if (text.includes(RATE_LIMIT_SUBSTRING)) return true; + + const structured = extractStructuredRateLimitPayload(text); + return structured ? isStructuredRateLimitPayload(structured) : false; } // --------------------------------------------------------------------------- @@ -63,6 +75,14 @@ export function parseRateLimitResetTime(text: string, now: Date = new Date()): D // words like "reset" (e.g. "reset the 5pm meeting"). if (!isRateLimitMessage(text)) return null; + const structured = extractStructuredRateLimitPayload(text); + if (structured && isStructuredRateLimitPayload(structured)) { + const structuredReset = parseStructuredResetTime(structured, now); + if (structuredReset) { + return structuredReset; + } + } + const relative = parseRelativeResetDuration(text); if (relative !== null) { return new Date(now.getTime() + relative); @@ -120,6 +140,79 @@ function parseRelativeResetDuration(text: string): number | null { return null; } +function extractStructuredRateLimitPayload(text: string): StructuredRateLimitPayload | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + const prefixedMatch = /^(?:API Error:\s*\d+\s+|\d+\s+)?(\{[\s\S]*\})$/i.exec(trimmed); + const jsonCandidate = prefixedMatch?.[1] ?? (trimmed.startsWith('{') ? trimmed : null); + if (!jsonCandidate) return null; + + try { + const parsed = JSON.parse(jsonCandidate) as { + error?: { + code?: unknown; + message?: unknown; + reset_seconds?: unknown; + reset_time?: unknown; + }; + code?: unknown; + message?: unknown; + reset_seconds?: unknown; + reset_time?: unknown; + }; + const errorPayload = parsed.error; + + return { + code: readStringField(errorPayload?.code) ?? readStringField(parsed.code), + message: readStringField(errorPayload?.message) ?? readStringField(parsed.message), + resetSeconds: + readNumberField(errorPayload?.reset_seconds) ?? readNumberField(parsed.reset_seconds), + resetTime: readStringField(errorPayload?.reset_time) ?? readStringField(parsed.reset_time), + }; + } catch { + return null; + } +} + +function isStructuredRateLimitPayload(payload: StructuredRateLimitPayload): boolean { + const code = payload.code?.trim().toLowerCase(); + if (code === MODEL_COOLDOWN_CODE) { + return true; + } + + const message = payload.message?.trim().toLowerCase() ?? ''; + return ( + (message.includes('cooling down') || message.includes('model cooldown')) && + (payload.resetSeconds !== null || payload.resetTime !== null) + ); +} + +function parseStructuredResetTime(payload: StructuredRateLimitPayload, now: Date): Date | null { + if (payload.resetSeconds !== null) { + return new Date(now.getTime() + Math.max(0, payload.resetSeconds) * 1000); + } + + const resetTime = payload.resetTime?.trim(); + if (!resetTime) return null; + + const relative = parseRelativeResetDuration(`Resets in ${resetTime}`); + if (relative !== null) { + return new Date(now.getTime() + relative); + } + + const absolute = Date.parse(resetTime); + return Number.isFinite(absolute) ? new Date(absolute) : null; +} + +function readStringField(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function readNumberField(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; +} + // --------------------------------------------------------------------------- // Absolute clock times: "resets at 3pm (PST)", "resets at 15:30 UTC" // --------------------------------------------------------------------------- diff --git a/test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts b/test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts new file mode 100644 index 00000000..c806d70b --- /dev/null +++ b/test/features/anthropic-runtime-profile/resolveAnthropicRuntimeProfile.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest'; + +import { + reconcileAnthropicRuntimeSelections, + resolveAnthropicFastMode, + resolveAnthropicRuntimeSelection, +} from '@features/anthropic-runtime-profile/renderer'; +import type { CliProviderModelCatalog, CliProviderRuntimeCapabilities } from '@shared/types'; + +import type { AnthropicRuntimeProfileSource } from '@features/anthropic-runtime-profile/renderer'; + +function createAnthropicSource(options: { + models: CliProviderModelCatalog['models']; + defaultLaunchModel?: string; + fastMode?: CliProviderRuntimeCapabilities['fastMode']; +}): AnthropicRuntimeProfileSource { + return { + modelCatalog: { + schemaVersion: 1 as const, + providerId: 'anthropic' as const, + source: 'anthropic-models-api' as const, + status: 'ready' as const, + fetchedAt: '2026-04-21T00:00:00.000Z', + staleAt: '2026-04-21T00:10:00.000Z', + defaultModelId: options.defaultLaunchModel ?? options.models[0]?.id ?? 'opus[1m]', + defaultLaunchModel: options.defaultLaunchModel ?? options.models[0]?.launchModel ?? 'opus[1m]', + models: options.models, + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'anthropic-models-api' as const, + }, + reasoningEffort: { + supported: true, + values: ['low', 'medium', 'high', 'max'], + configPassthrough: true, + }, + fastMode: options.fastMode ?? { + supported: true, + available: true, + reason: null, + source: 'runtime' as const, + }, + }, + }; +} + +describe('resolveAnthropicRuntimeProfile', () => { + it('uses the resolved launch model, not the alias family, for effort and fast capability truth', () => { + const source = createAnthropicSource({ + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }); + + const aliasSelection = resolveAnthropicRuntimeSelection({ + source, + selectedModel: 'opus', + limitContext: false, + }); + const explicit46Selection = resolveAnthropicRuntimeSelection({ + source, + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect(aliasSelection.resolvedLaunchModel).toBe('opus[1m]'); + expect(aliasSelection.supportedEfforts).toEqual([]); + expect(aliasSelection.supportsFastMode).toBe(false); + + expect(explicit46Selection.resolvedLaunchModel).toBe('claude-opus-4-6'); + expect(explicit46Selection.supportedEfforts).toEqual(['low', 'medium', 'high', 'max']); + expect(explicit46Selection.defaultEffort).toBe('medium'); + expect(explicit46Selection.supportsFastMode).toBe(true); + }); + + it('resolves inherited fast mode from the provider default only when the exact model supports it', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + models: [ + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }), + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect( + resolveAnthropicFastMode({ + selection, + selectedFastMode: undefined, + providerFastModeDefault: true, + }) + ).toMatchObject({ + selectedFastMode: 'inherit', + requestedFastMode: true, + resolvedFastMode: true, + selectable: true, + disabledReason: null, + }); + }); + + it('resets only the invalid fast selection when an alias resolves to a non-fast model', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + defaultLaunchModel: 'opus[1m]', + models: [ + { + id: 'opus[1m]', + launchModel: 'opus[1m]', + displayName: 'Opus 4.7 (1M)', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }), + selectedModel: 'opus', + limitContext: false, + }); + + expect( + reconcileAnthropicRuntimeSelections({ + selection, + selectedEffort: '', + selectedFastMode: 'on', + providerFastModeDefault: false, + }) + ).toEqual({ + nextEffort: '', + effortResetReason: null, + nextFastMode: 'inherit', + fastModeResetReason: + 'Fast mode is available only for Opus 4.6. Selected model resolves to Opus 4.7 (1M).', + }); + }); + + it('resets invalid max effort without mutating unrelated fast intent', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + models: [ + { + id: 'haiku', + launchModel: 'haiku', + displayName: 'Haiku 4.5', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text', 'image'], + supportsFastMode: false, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + }), + selectedModel: 'haiku', + limitContext: false, + }); + + expect( + reconcileAnthropicRuntimeSelections({ + selection, + selectedEffort: 'max', + selectedFastMode: 'off', + providerFastModeDefault: true, + }) + ).toEqual({ + nextEffort: '', + effortResetReason: + 'max effort is not available for the currently selected Anthropic model. Reset to Default.', + nextFastMode: 'off', + fastModeResetReason: null, + }); + }); + + it('does not reset explicit max or fast while runtime catalog truth is still unavailable', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: { + modelCatalog: null, + runtimeCapabilities: null, + }, + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect( + reconcileAnthropicRuntimeSelections({ + selection, + selectedEffort: 'max', + selectedFastMode: 'on', + providerFastModeDefault: false, + }) + ).toEqual({ + nextEffort: 'max', + effortResetReason: null, + nextFastMode: 'on', + fastModeResetReason: null, + }); + + expect( + resolveAnthropicFastMode({ + selection, + selectedFastMode: 'on', + providerFastModeDefault: false, + }).disabledReason + ).toBe('Anthropic runtime capability data is still loading.'); + }); + + it('keeps the fast control visible in degraded states and surfaces the provider reason', () => { + const selection = resolveAnthropicRuntimeSelection({ + source: createAnthropicSource({ + models: [ + { + id: 'claude-opus-4-6', + launchModel: 'claude-opus-4-6', + displayName: 'Opus 4.6', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'max'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsFastMode: true, + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'anthropic-models-api', + }, + ], + fastMode: { + supported: true, + available: false, + reason: 'Fast mode status is degraded right now.', + source: 'runtime', + }, + }), + selectedModel: 'claude-opus-4-6', + limitContext: false, + }); + + expect( + resolveAnthropicFastMode({ + selection, + selectedFastMode: 'inherit', + providerFastModeDefault: true, + }) + ).toMatchObject({ + showFastModeControl: true, + selectable: false, + requestedFastMode: true, + resolvedFastMode: false, + disabledReason: 'Fast mode status is degraded right now.', + }); + }); +}); diff --git a/test/main/services/team/AutoResumeService.test.ts b/test/main/services/team/AutoResumeService.test.ts index c7ca7d6d..76992148 100644 --- a/test/main/services/team/AutoResumeService.test.ts +++ b/test/main/services/team/AutoResumeService.test.ts @@ -6,6 +6,8 @@ import type { ConfigManager } from '../../../../src/main/services/infrastructure const TEAM = 'test-team'; const RATE_LIMIT_MSG = "You've hit your limit. Resets in 5 minutes."; +const MODEL_COOLDOWN_API_ERROR = + 'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}'; describe('AutoResumeService', () => { const mockConfig = { autoResumeOnRateLimit: false }; @@ -60,6 +62,21 @@ describe('AutoResumeService', () => { expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); }); + it('schedules auto-resume from model_cooldown API errors', async () => { + mockConfig.autoResumeOnRateLimit = true; + provisioningService.isTeamAlive.mockReturnValue(true); + provisioningService.sendMessageToTeam.mockResolvedValue(undefined); + const now = new Date('2026-04-17T12:00:00Z'); + + service.handleRateLimitMessage(TEAM, MODEL_COOLDOWN_API_ERROR, now); + + await vi.advanceTimersByTimeAsync(41 * 1000 + 29 * 1000); + expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledTimes(1); + }); + it('reschedules when a later rate-limit message changes the reset time', async () => { mockConfig.autoResumeOnRateLimit = true; provisioningService.isTeamAlive.mockReturnValue(true); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index 2e6e5098..d5ae6870 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -152,6 +152,7 @@ describe('TeamMemberRuntimeAdvisoryService', () => { it.each([ ['rate_limited', 'Provider returned 429 rate limit for this request.'], + ['rate_limited', 'All credentials for model claude-opus-4-6 are cooling down via provider claude.'], ['auth_error', 'Authentication failed due to invalid API key.'], ['network_error', 'Fetch failed because the network connection timed out.'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts index a0b197fe..59664344 100644 --- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts +++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts @@ -38,6 +38,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -50,6 +51,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -81,6 +83,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -93,6 +96,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -110,6 +114,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -122,6 +127,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.3-codex', effort: 'high', + fastMode: 'inherit', limitContext: false, }); }); @@ -142,6 +148,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -154,6 +161,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -174,6 +182,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'codex', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ codex: 'gpt-5.4', @@ -185,6 +194,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'gpt-5.4', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -207,6 +217,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -216,8 +227,10 @@ describe('resolveLaunchDialogPrefill', () => { expect(result).toEqual({ providerId: 'anthropic', + providerBackendId: undefined, model: 'haiku', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -235,6 +248,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -243,8 +257,10 @@ describe('resolveLaunchDialogPrefill', () => { expect(result).toEqual({ providerId: 'anthropic', + providerBackendId: undefined, model: 'opus', effort: 'high', + fastMode: 'inherit', limitContext: true, }); }); @@ -261,6 +277,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -273,6 +290,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'custom-model[1m]', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); @@ -289,6 +307,7 @@ describe('resolveLaunchDialogPrefill', () => { multimodelEnabled: true, storedProviderId: 'anthropic', storedEffort: 'medium', + storedFastMode: 'inherit', storedLimitContext: false, getStoredModel: createStoredModelGetter({ anthropic: 'haiku', @@ -301,6 +320,7 @@ describe('resolveLaunchDialogPrefill', () => { providerBackendId: 'codex-native', model: 'custom-model[1m]', effort: 'medium', + fastMode: 'inherit', limitContext: false, }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index f3c3f474..aa48f8f5 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -246,6 +246,7 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig { providerConnections: { anthropic: { authMode: 'auto', + fastModeDefault: false, }, codex: { preferredAuthMode: 'auto', diff --git a/test/shared/utils/rateLimitDetector.test.ts b/test/shared/utils/rateLimitDetector.test.ts index ecdcfb9d..81eb3ae4 100644 --- a/test/shared/utils/rateLimitDetector.test.ts +++ b/test/shared/utils/rateLimitDetector.test.ts @@ -8,6 +8,10 @@ import { // Helper: every production rate-limit message starts with this substring. // Prefix test inputs so they clear the parser's rate-limit-context gate. const RL = "You've hit your limit. "; +const MODEL_COOLDOWN_API_ERROR = + 'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}'; +const MODEL_COOLDOWN_NO_SECONDS_API_ERROR = + 'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_time":"40s"}}'; describe('isRateLimitMessage', () => { it('detects the canonical substring', () => { @@ -22,6 +26,10 @@ describe('isRateLimitMessage', () => { expect(isRateLimitMessage('hit the limit')).toBe(false); // missing "You've" expect(isRateLimitMessage('')).toBe(false); }); + + it('detects structured model_cooldown API errors as rate limits', () => { + expect(isRateLimitMessage(MODEL_COOLDOWN_API_ERROR)).toBe(true); + }); }); describe('parseRateLimitResetTime', () => { @@ -41,6 +49,18 @@ describe('parseRateLimitResetTime', () => { expect(parseRateLimitResetTime('Resets in 2 hours.', now)).toBeNull(); }); + it('parses model_cooldown reset_seconds from structured API errors', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(MODEL_COOLDOWN_API_ERROR, now); + expect(result?.toISOString()).toBe('2026-04-17T12:00:41.000Z'); + }); + + it('falls back to structured reset_time when reset_seconds is missing', () => { + const now = new Date('2026-04-17T12:00:00Z'); + const result = parseRateLimitResetTime(MODEL_COOLDOWN_NO_SECONDS_API_ERROR, now); + expect(result?.toISOString()).toBe('2026-04-17T12:00:40.000Z'); + }); + // --------------------------------------------------------------------- // Relative durations // ---------------------------------------------------------------------