diff --git a/docs/iterations/README.md b/docs/iterations/README.md new file mode 100644 index 00000000..4f29b7bd --- /dev/null +++ b/docs/iterations/README.md @@ -0,0 +1,14 @@ +# Итерации реализации + +Здесь лежат планы реализации фич итерациями (vertical slices), чтобы двигаться **постепенно** и каждый шаг давал видимый результат. + +## Список + +- [Итерация 01 — Core Foundation + Team List](./iteration-01-core-team-list.md) + +## Принципы + +- **Vertical slice**: в каждой итерации доводим минимум “end-to-end” (types → main → IPC → preload → renderer → UI) +- **Чёткий scope**: у каждой итерации есть цели и не‑цели +- **Definition of Done**: заранее фиксируем критерии готовности и ручную проверку + diff --git a/docs/iterations/iteration-01-core-team-list.md b/docs/iterations/iteration-01-core-team-list.md new file mode 100644 index 00000000..3438f171 --- /dev/null +++ b/docs/iterations/iteration-01-core-team-list.md @@ -0,0 +1,350 @@ +# Итерация 01 — Core Foundation + Team List + +Эта итерация реализует **первый вертикальный срез** Team Management: от файлов `~/.claude/teams/*` до UI, где открывается вкладка со списком команд. + +База для решений и порядка работ: +- `docs/team-management/implementation.md` (v7) +- `~/.claude/plans/goofy-napping-river.md` (summary v7) +- `docs/team-management/research-*.md` + `docs/team-management/kanban-design.md` + +--- + +## Цель итерации + +Сделать **видимый результат** без “backend-first” провала: + +- В приложении есть **вкладка `Teams`** (тип таба `teams`, **singleton в рамках активной pane**, как `Notifications`) +- В ней отображается **список команд** из `~/.claude/teams/{teamName}/config.json` +- Если команд нет (или директория отсутствует) — корректный **empty state** +- Ошибки чтения/парсинга не валят UI: **graceful degradation** + +--- + +## Не-цели (строго вне scope) + +- Team detail (страница конкретной команды) +- Members/Tasks/Messages/Kanban UI +- Любые write-path (inbox write, kanban update, task status update) +- FileWatcher расширение (teamsWatcher/tasksWatcher) и live refresh по `team-change` +- Tests/fixtures (это отдельная итерация 5), кроме минимальных правок тестов, если они ломаются из‑за добавления нового `tab.type` + +--- + +## Контракт итерации (что именно считаем “готово”) + +### API-контракт (Main → Preload → Renderer) + +**IPC channel** +- `TEAM_LIST` = `'team:list'` (flat `export const`, без `IPC_CHANNELS.*`) + +**Response** +- Возвращает `TeamSummary[]` +- Никаких side effects (только чтение) +- Должен быть безопасен при отсутствии `~/.claude/teams` + +**TeamSummary (на итерации 1)** +- `name: string` — отображаемое имя команды (из `config.json`) +- `description: string` — описание (из `config.json` или `''`) +- `memberCount: number` — `config.members.length` или 0 +- `taskCount: number` — **0** (пока не читаем tasks) +- `lastActivity: string | null` — **null** (пока не читаем inbox) + +### Definition of Done + +- **UI**: вкладка `Teams` открывается, показывает список/empty state, без падений при: + - отсутствии `~/.claude/teams` + - наличии “мусорных” директорий без `config.json` (например `default/`) + - наличии битого `config.json` (пропускаем, не падаем) +- **IPC**: есть канал `TEAM_LIST` (`'team:list'`), который возвращает `TeamSummary[]` +- **Типы**: `IpcResult` дедуплицирован в `src/shared/types/ipc.ts` +- **Качество**: проходит `pnpm typecheck`, приложение запускается в dev, вкладка доступна из UI + +--- + +## Главные решения, которые сохраняем + +- **Vertical slice**: делаем end-to-end минимум (types → main → IPC → preload → renderer → UI) +- **Flat IPC constants**: только `export const TEAM_LIST = 'team:list'` и прямые импорты (никаких `IPC_CHANNELS.*`) +- **Graceful skip**: `listTeams()` пропускает директории без валидного `config.json` (v7 fix #37) +- **Global TeamDataService**: сервис создаётся глобально (по аналогии с UpdaterService), не привязан к ServiceContext (подготовка к следующим итерациям, v6/v7 решение) + +--- + +## Выходные изменения (файлы), только для итерации 1 + +Цель — чтобы по итогам итерации было понятно: **что добавили**, и что менять не пришлось. + +### Новые файлы (ожидаемо) + +- `src/shared/types/ipc.ts` +- `src/shared/types/team.ts` (минимальный набор типов для list) +- `src/main/services/team/TeamConfigReader.ts` +- `src/main/services/team/TeamDataService.ts` (минимум `listTeams()`; остальное — позже) +- `src/main/ipc/teams.ts` (минимум: `TEAM_LIST`) +- `src/renderer/utils/unwrapIpc.ts` +- `src/renderer/store/slices/teamSlice.ts` (минимум: `fetchTeams`) +- `src/renderer/components/team/TeamListView.tsx` +- `src/renderer/components/team/TeamEmptyState.tsx` + +### Изменяемые файлы (ожидаемо) + +- `src/shared/types/index.ts` (реэкспорты) +- `src/preload/constants/ipcChannels.ts` (добавить `TEAM_LIST`) +- `src/shared/types/api.ts` (добавить `TeamsAPI.list`) +- `src/preload/index.ts` (проброс `api.teams.list`) +- `src/main/ipc/handlers.ts` (wiring: init + register `TEAM_LIST`) +- `src/main/index.ts` (создание global `teamDataService` и проброс в `initializeIpcHandlers`) +- `src/renderer/types/tabs.ts` (добавить `type: 'teams'`) +- `src/renderer/store/types.ts`, `src/renderer/store/index.ts` (подключение teamSlice) +- layout-компоненты табов (`PaneContent`, `TabBar`, `SortableTab`) — чтобы появился вход в Teams tab + +--- + +## Порядок работ (runbook) + +Ниже — “как делаем” в точном порядке, чтобы на каждом шаге был компилируемый код и минимальный риск сломать существующие фичи. + +### Контрольные точки (чтобы не тащить поломки дальше) + +Контрольные точки привязаны к реально “широким” изменениям (типы/IPC/таб‑routing): + +- **CP0**: после 1.1 — `pnpm typecheck` +- **CP1**: после 2.5 — `pnpm typecheck` +- **CP2**: после 4.2 — `pnpm typecheck` +- **CP3**: после 4.4 — `pnpm dev` + ручная проверка + +--- + +### 1) Shared foundation (Phase 0) + +#### 1.1 Дедупликация `IpcResult` (Step 0.1) + +- Создать `src/shared/types/ipc.ts` с `export interface IpcResult { success; data?; error? }` +- Удалить дубликаты: + - `src/main/ipc/config.ts`: заменить `ConfigResult` → `IpcResult` из `@shared/types` + - `src/preload/index.ts`: заменить локальный `IpcResult` → импорт из `@shared/types` и обновить `invokeIpcWithResult()` на этот тип +- Обновить `src/shared/types/index.ts`: реэкспорт `IpcResult` + +**CP0:** `pnpm typecheck` (на этом шаге обязателен, чтобы не тащить дальше ошибки типов). + +--- + +### 2) Backend: чтение команд (Main Process) + +#### 2.1 Типы для Teams (Step 1 — частично) + +В этой итерации нужны минимум: + +- `TeamConfig` (чтобы распарсить `config.json`) +- `TeamSummary` (чтобы вернуть данные в UI) + +Создать/расширить `src/shared/types/team.ts` и реэкспорт в `src/shared/types/index.ts`. + +Важно: +- `TeamSummary.lastActivity` на итерации 1 можно держать `null` (не считаем активность без inbox/tasks). + +#### 2.2 Path helpers (Step 2 — по минимуму) + +Добавить `getTeamsBasePath()` в `src/main/utils/pathDecoder.ts` рядом с `getProjectsBasePath()` / `getTodosBasePath()`: + +- `getTeamsBasePath(): string` → `path.join(getClaudeBasePath(), 'teams')` + +Эта функция **обязательна** уже в итерации 1, чтобы не раскидывать `'teams'` как литерал по коду. + +**Важно (без двусмысленностей):** `TeamConfigReader` **не имеет права** “захардкодить” путь в конструкторе так, чтобы он перестал учитывать `setClaudeBasePathOverride()`. + +- `getClaudeBasePath()` в этом репо поддерживает override (через настройки Claude root). +- Поэтому путь к `~/.claude/teams` должен вычисляться **на каждый вызов** `listTeams()` через `getTeamsBasePath()`. + +#### 2.3 `TeamConfigReader.listTeams()` (Step 3 — частично) + +Создать `src/main/services/team/TeamConfigReader.ts` с поведением: + +- Если `~/.claude/teams` отсутствует → вернуть `[]` +- `readdir(teamsDir, { withFileTypes: true })` → обрабатывать **только директории** (Dirent.isDirectory), остальные entries игнорировать +- Для каждой директории: + - пытаться прочитать `{dir}/config.json` + - если файла нет / JSON битый / схема не подходит → **continue** (v7 fix #37) + - собрать `TeamSummary` (name, description, memberCount, taskCount=0, lastActivity=null) + +**Нюанс:** в логах можно оставлять `debug` (но не спамить и не падать). + +#### 2.4 `TeamDataService.listTeams()` (Step 5 — частично) + +Создать каркас `TeamDataService` так, чтобы в итерации 1 он поддерживал минимум: + +- `listTeams(): Promise` → делегирует в `TeamConfigReader` + +Остальные методы (`getTeamData`, kanban/inbox/tasks) — не трогаем или оставляем на следующую итерацию, чтобы не расползаться. + +#### 2.5 Глобальная инициализация сервиса (Step 11 — частично) + +В `src/main/index.ts` создать **один** instance `teamDataService` (global), не в `ServiceContext`. + +**Цель итерации 1:** чтобы IPC handler мог вызвать `teamDataService.listTeams()` без привязки к workspace. + +**CP1:** `pnpm typecheck` (до IPC лучше убедиться, что main компилируется). + +--- + +### 3) IPC + Preload: TeamsAPI.list() + +#### 3.1 IPC channels (Step 6) + +В `src/preload/constants/ipcChannels.ts` добавить: + +- `export const TEAM_LIST = 'team:list';` + +#### 3.2 IPC handler (Step 7 — частично) + +Создать `src/main/ipc/teams.ts` в стиле проекта: + +- module-level state + `initializeTeamHandlers(service)` +- `registerTeamHandlers(ipcMain)` регистрирует `TEAM_LIST` +- `wrapTeamHandler()` возвращает `IpcResult` +- `handleListTeams()` вызывает `service.listTeams()` + +Важно: +- Использовать **flat imports** каналов (`TEAM_LIST`) (v7 fix #36) +- Не завязываться на `ServiceContextRegistry` (данные Teams читаются из `~/.claude`, это локальный глобальный источник) + +#### 3.3 Wiring handlers.ts (v7 fix #48 — частично для list) + +В этом проекте единая точка регистрации IPC — `src/main/ipc/handlers.ts` (`initializeIpcHandlers()`): + +- Добавить импорт `initializeTeamHandlers/registerTeamHandlers/removeTeamHandlers` из `./teams` +- Расширить сигнатуру `initializeIpcHandlers(...)` новым параметром `teamDataService` +- Внутри `initializeIpcHandlers()`: + - вызвать `initializeTeamHandlers(teamDataService)` + - вызвать `registerTeamHandlers(ipcMain)` +- В `removeIpcHandlers()`: + - вызвать `removeTeamHandlers(ipcMain)` + +#### 3.4 Preload bridge (Step 9 — частично) + +В `src/shared/types/api.ts` добавить интерфейс `TeamsAPI` минимум с: + +- `list(): Promise` + +И в `ElectronAPI` добавить поле: + +- `teams: TeamsAPI` + +В `src/preload/index.ts`: + +- Реализовать `api.teams.list()` через существующий `invokeIpcWithResult()` (он уже используется для `config/ssh/context/httpServer`) +- Пробросить в `contextBridge.exposeInMainWorld()` + +**Быстрая проверка до UI (делаем всегда):** +- В renderer DevTools выполнить `await window.electronAPI.teams.list()` и убедиться, что вернулся массив. + +--- + +### 4) Renderer: Tabs + store + UI + +#### 4.1 `unwrapIpc()` (Step 12) + +Создать `src/renderer/utils/unwrapIpc.ts`: + +- оборачивает вызов `api.teams.list()` +- не делает “double unwrap” (preload уже бросает ошибку при `success: false`) + +#### 4.2 Tabs + UI entry (строго в рамках текущей модели табов) + +**Важно:** в текущем коде `Tab` — это один интерфейс с optional `sessionId/projectId` и `type`-switch в местах использования. Полная миграция на discriminated union — отдельная refactor‑итерация (иначе итерация 1 перестанет быть “тонкой” и потеряет фокус на Team List). + +В итерации 1 делаем **ровно** следующее: + +- В `src/renderer/types/tabs.ts`: + - расширить union `Tab['type']`, добавив `'teams'` + - ничего больше не меняем (никаких refactor’ов структуры Tab, никаких новых optional полей) + +- В `src/renderer/components/layout/PaneContent.tsx`: + - добавить ветку `tab.type === 'teams'` → рендер `TeamListView` + +- В `src/renderer/components/layout/SortableTab.tsx`: + - добавить иконку для `teams` в `TAB_ICONS` + +- В `src/renderer/components/layout/TabBar.tsx`: + - добавить кнопку “Teams” (иконка `Users` из `lucide-react`) в ряд action‑кнопок + - обработчик должен вызывать `openTeamsTab()` (см. следующий пункт) + +- В store: + - добавить action `openTeamsTab()` как **per-pane singleton** (строго по паттерну `openNotificationsTab()`): + - если в focused pane уже есть таб `type: 'teams'` → активируем его + - иначе → `openTab({ type: 'teams', label: 'Teams' })` (лейбл фиксируем как `Teams`, как у `Dashboard/Notifications`) + +**CP2:** `pnpm typecheck` после всех правок выше (до того, как писать TeamList UI). + +#### 4.3 `teamSlice.fetchTeams()` (Step 14 — частично) + +Добавить `src/renderer/store/slices/teamSlice.ts` с минимумом: + +- `teams: TeamSummary[]` +- `teamsLoading / teamsError` +- `fetchTeams()`: вызывает `unwrapIpc('team:list', () => api.teams.list())` +- `openTeamsTab()`: per-pane singleton (описано в Step 4.2) + +Никаких `selectedTeamData`, refresh generation и т.п. — это следующая итерация. + +Подключить slice в `src/renderer/store/types.ts` и `src/renderer/store/index.ts`. + +#### 4.4 Tab integration + Teams view (Step 15 + Step 16 — частично) + +- В `TabBar` добавить кнопку “Teams” → вызывает `openTeamsTab()` (per-pane singleton) +- В `PaneContent` добавить ветку `tab.type === 'teams'` → рендер `TeamListView` + +UI-компоненты (минимум для итерации 1): + +- `src/renderer/components/team/TeamListView.tsx` + - на mount вызывает `fetchTeams()` + - показывает loading/error/empty + - рендерит список карточек/строк команд +- `src/renderer/components/team/TeamEmptyState.tsx` + +--- + +## Ручная проверка (обязательная для итерации 1, CP3) + +1) Запуск: + +- `pnpm dev` +- Открыть вкладку `Teams` + +1.1) Singleton-поведение вкладки: + +- Нажать кнопку “Teams” 2–3 раза +- Ожидание: не создаются новые табы, фокус остаётся на существующем `Teams` табе (per-pane singleton) + +2) Empty state: + +- Переименовать (временно) `~/.claude/teams` или протестировать на чистой машине +- Ожидание: “Команды не найдены” (без ошибок в UI) + +3) Skip dirs без config: + +- Создать папку `~/.claude/teams/default/` без `config.json` +- Ожидание: она **не** появляется в списке, UI не падает + +4) Happy path: + +- Убедиться что реальная команда (где есть `config.json`) отображается + +--- + +## Риски и как их гасим в рамках итерации + +- **Добавление нового `tab.type`**: обязательно обновить `PaneContent` + `SortableTab.TAB_ICONS`, иначе получим runtime/TS ошибки. CP2 гарантирует, что всё “сшилось”. +- **Разный формат `config.json` / мусорные директории**: `TeamConfigReader.listTeams()` делает `try/catch` и `continue`, не кидает исключения наружу. +- **Отсутствие `~/.claude/teams`**: `listTeams()` возвращает `[]` без ошибок. +- **Стабильность UI**: в `TeamListView` должны быть 4 состояния: loading → data / empty / error. + +--- + +## Выходные артефакты (что появится после итерации) + +- Документированный канал `TEAM_LIST` и `TeamsAPI.list()` +- Минимальный backend reader для списка команд +- Вкладка `Teams` и список команд в UI (или empty state) +- База для следующих итераций без переархитектуривания + diff --git a/docs/team-management/README.md b/docs/team-management/README.md new file mode 100644 index 00000000..9ddc6371 --- /dev/null +++ b/docs/team-management/README.md @@ -0,0 +1,100 @@ +# Team Management Feature + +Интерфейс для управления командами тиммейтов Claude Code внутри claude-devtools (Electron). + +## Что делает + +- Видеть состав команды и роли участников +- Kanban-доска с 5 колонками (TODO → IN PROGRESS → DONE → REVIEW → APPROVED) +- Отправка сообщений тиммейтам через inbox-файлы +- Review flow: автоматическое назначение ревьюверов или ручное ревью +- Live updates через file watcher + +## Документация + +| Файл | Содержание | +|------|-----------| +| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений | +| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ | +| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox | +| [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) | + +## Ключевые решения + +### 1. Messaging: Inbox-файлы +Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md) + +### 2. Kanban Storage: Собственный файл +Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md) + +### 3. Review Flow: Approve / Request Changes +- Есть ревьюверы в команде → автоматическое назначение через inbox +- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI) +- При Request Changes → юзер описывает проблему (опционально) → задача к исходному owner + +### 4. Atomic Write +Все записи через tmp + rename для предотвращения corrupted JSON. + +### 5. Sender Identity +Отправляем `from: "user"`. Fallback на `from: "team-lead"` если не работает. + +## Финальные решения после ревью + +По итогам 3 раундов ревью (13 экспертов) приняты следующие решения: + +### Inbox: Atomic write + messageId verify +- Atomic write (tmp + rename) предотвращает corrupted JSON +- После записи читаем файл обратно и проверяем наличие нашего `messageId` +- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна +- Риск race condition с агентом реален, но вероятность низкая + +### Kanban: kanban-state.json с безопасным GC +- GC устаревших записей kanban-state выполняется ТОЛЬКО ПОСЛЕ полной загрузки tasks +- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан + +### Review Flow: Approve / Request Changes +- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error) +- Комментарий при Request Changes — опционален +- `reviewHistory` и round-robin балансировка → Phase 2, не MVP + +### Members: полный список через union +- `union(config members + inbox filenames + task owners)` — единственный способ получить полный список +- `owner` в task-файлах — опционален (агент может не иметь owner до назначения) + +### Graceful Degradation +- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты +- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED` + - `ACTIVE`: idle < 5 минут + - `IDLE`: idle > 5 минут + - `TERMINATED`: получен `shutdown_response` с `approve: true` + +### @dnd-kit: click-to-move для MVP +- MVP: выбор колонки через select/dropdown (click-to-move) — проще и надёжнее +- Phase 2: полноценный D&D через `@dnd-kit` + +--- + +## Открытые вопросы + +- **FileWatcher расширение**: FileWatcher.ts уже 900+ строк — добавление teams/tasks watchers нетривиально, требует отдельного спайка +- **Windows atomic rename**: `fs.renameSync` на Windows бросает `EXDEV`/`EBUSY` при кросс-устройственном rename — нужна обёртка +- **leadSessionId интеграция**: config.json содержит `leadSessionId`, но интеграция с session viewer (переход к сессии лида) — открытый вопрос +- **Hard Interrupt**: сообщения доставляются между turns (1-30с задержка). В будущем нужен способ прервать mid-turn +- **Архивация**: inbox не чистится автоматически, нужна кнопка "Архивировать" + +## Файловая структура Claude Code + +``` +~/.claude/ +├── teams/{teamName}/ +│ ├── config.json # Конфиг команды (members НЕПОЛНЫЙ!) +│ └── inboxes/{memberName}.json # Inbox каждого участника +└── tasks/{teamName}/ + ├── {id}.json # Файл задачи + ├── .lock # Lock-файл (0 байт) + └── .highwatermark # Последний ID задачи +``` + +**ВАЖНО**: config.json members содержит только team-lead. Полный список участников реконструируем из inbox-файлов. diff --git a/docs/team-management/implementation.md b/docs/team-management/implementation.md new file mode 100644 index 00000000..5cde66e3 --- /dev/null +++ b/docs/team-management/implementation.md @@ -0,0 +1,2145 @@ +# Implementation Plan (v7 — Production-Ready Architecture) + +## Обзор + +~34 новых файлов + 18 модификаций + 18 тестов. Vertical slices (не backend-first). + +### Изменения v6 → v7 (по результатам 3 deep-review тимлидов) + +| # | Баг/пробел в v6 | Исправление v7 | Severity | +|---|-----------------|----------------|----------| +| 35 | tasks/ — отдельная директория, watcher отсутствует (КРИТИЧНО) | ДВА watcher внутри FileWatcher: teamsWatcher + tasksWatcher | 9 | +| 36 | `IPC_CHANNELS.TEAM_LIST` — объект не существует, реальный паттерн: flat `export const` | Все ссылки исправлены на `TEAM_LIST`, `TEAM_GET_DATA`, etc. (flat imports) | 7 | +| 37 | `listTeams()` падает на dir без config.json (e.g. `default/`) | Graceful skip: `continue` при отсутствии config.json | 7 | +| 38 | Tasks: throw при ENOENT (`~/.claude/tasks/{team}` может не существовать) | Graceful fallback: ENOENT → return `[]` | 7 | +| 39 | `handleSendMessage`: `member as string` без валидации (path traversal) | Добавлен `validateMemberName()` guard | 8 | +| 40 | `requestReview()` — stub без реализации sendMessage reviewer'у | Полная реализация: updateKanban + sendMessage | 6 | +| 41 | Kanban: нет auto-review маппинга (completed → review) | Explicit маппинг: completed без kanban override → 'done' column | 5 | +| 42 | `atomicWriteAsync`: `fs.existsSync()` в async функции | Заменён на `await fs.promises.mkdir(dir, { recursive: true })` | 4 | +| 43 | Нет TeamsTab для list view (только TeamTab для individual teams) | Добавлен `TeamsTab` с `type: 'teams'` в discriminated union | 6 | +| 44 | httpServer.broadcast для team-change не реализован | Добавлен в wireFileWatcherEvents | 4 | +| 45 | Linux: fs.watch без recursive может пропускать events | Добавлен `recursive: true` (macOS native, Linux polyfill) | 3 | +| 46 | Множественные file-change → множественные refreshes | Throttle: 300ms coalesce для team-change events в store | 3 | +| 47 | Inbox `text` содержит serialized JSON (не plain text) | Документировано + UI отображает как текст (Claude Code сам парсит) | 2 | +| 48 | handlers.ts: signature не принимает teamDataService | Добавлен параметр + wiring | 5 | + +### Изменения v5 → v6 (по результатам 5 ревью-агентов + 4 тимлидов) + +| # | Баг/ошибка в v5 | Исправление v6 | Severity | +|---|-----------------|----------------|----------| +| 22 | `withInboxLock` cleanup: `.then()` creates new Promise, equality всегда false | Сохранять `myTurn` reference, сравнивать с ним | 7 | +| 23 | Tab migration: 8 файлов → реально 12+ (пропущены TabBar, notificationSlice, contextStorage) | Полная карта миграции: 12 файлов | 6 | +| 24 | BaseTab: `fromSearch`, `savedScrollTop`, `showContextPanel` — session-only, не shared | Перенести на SessionTab, оставить на BaseTab только shared поля | 6 | +| 25 | `setupTeamChangeForwarding` — standalone функция сломается при SSH context switch | Интегрировать ВНУТРЬ `wireFileWatcherEvents()` | 8 | +| 26 | TeamDataService в ServiceContext — НЕПРАВИЛЬНО (global, не per-workspace) | Global (как UpdaterService), передавать в initializeIpcHandlers | 8 | +| 27 | TeamMemberResolver дублирует I/O (re-reads config+tasks) | Принимать pre-loaded data: `resolveMembers(config, tasks, messages)` | 5 | +| 28 | kanban-state в `~/.claude/` root — namespace pollution | Хранить в `~/.claude/teams/{teamName}/kanban-state.json` | 5 | +| 29 | GC на каждый fetch — лишние writes | Dirty-check: писать только если entries удалены | 4 | +| 30 | `atomicWriteSync` в async методах KanbanManager | Использовать `atomicWriteAsync` | 5 | +| 31 | Порядок: backend-first → 50% работы без видимого результата | Vertical slices: 5 итераций, каждая end-to-end | — | +| 32 | TabInput: `Omit` с union → нужен distributive Omit | Explicit `SessionTabInput | TeamTabInput | ...` | 5 | +| 33 | Missing httpServer.broadcast для team-change events | Добавить в wireFileWatcherEvents | 3 | +| 34 | IPC handler channel strings hardcoded → должны быть из constants | Import из ipcChannels.ts | 3 | + +### Изменения v4 → v5 (по результатам 4 deep-research агентов) + +| # | Вопрос v4 | Результат исследования | Решение v5 | +|---|-----------|----------------------|------------| +| 18 | Tab union — сколько мест ломается? | 12+ файлов, 30-35 строк | Полная карта миграции всех 12 файлов | +| 19 | FileWatcher — риск 3-го watcher | ТРИВИАЛЬНЫЙ: copy-paste паттерн, ~60 LOC | Extend существующий FileWatcher (не отдельный) | +| 20 | Inbox race condition | In-process mutex решает IPC races | withInboxLock с ПРАВИЛЬНЫМ cleanup | +| 21 | End-to-end integration gaps | 12 точек интеграции полностью промаплены | Explicit checklist + exact file:line references | + +### Изменения v3 → v4 (по результатам 5 ревью-агентов) + +| # | Проблема v3 | Исправление v4 | Severity | +|---|-------------|----------------|----------| +| 1 | `openSync('r')` — fsync не работает | `openSync('r+')` + mkdir recursive | 8 | +| 2 | team:change event не прокинут | Полная wiring: FileWatcher → main → renderer → store | 9 | +| 3 | unwrapIpcResult double wrapping | Убран второй unwrap, оставлен только try/catch | 7 | +| 4 | Promise.all partial data loss | Promise.allSettled + graceful fallbacks | 7 | +| 5 | TeamDataService без интерфейсов | 5 интерфейсов + Factory для DI/тестов | 7 | +| 6 | Tab 'team' — optional fields | Discriminated union для Tab types | 5 | +| 7 | teamRefreshGeneration memory leak | Cleanup при close tab + Map.delete | 6 | +| 8 | setTimeout в store action | Заменён на `teamDeletedRedirect` flag в state | 3 | +| 9 | kanban-state без atomic write | atomicWriteSync для всех write-path | 7 | +| 10 | from: "user" не валидируется | validateFromField в guards.ts | 6 | +| 11 | Sync ops блокируют event loop | Async версия atomicWrite для sendMessage | 5 | +| 12 | Orphan .tmp cleanup отсутствует | cleanupOrphanTmpFiles() на startup | 6 | +| 13 | Retry logic отсутствует | appendToInboxWithRetry + exponential backoff | 5 | +| 14 | 1 тест vs 18-20 нужно | Полная тестовая стратегия: 18 файлов + fixtures | — | +| 15 | Нет empty states | Empty states для всех компонентов | — | +| 16 | ServiceContext не содержит team | ~~TeamDataService в ServiceContext~~ v6: global (баг #26) | 6 | +| 17 | KanbanBoard props flow не описан | Явный props flow + callbacks | 8 | + +### Архитектурные принципы (без изменений из v3) + +| Принцип | Что берём из проекта | Что улучшаем | +|---------|---------------------|--------------| +| **SRP** | Domain-driven services (analysis: 10 классов) | 5 backend классов вместо God-сервиса | +| **OCP** | FileSystemProvider (2 реализации) | Интерфейсы для read/write операций | +| **LSP** | Discriminated unions для chunks | Discriminated unions для Tab + MemberStatus | +| **ISP** | ElectronAPI разбит на субинтерфейсы | TeamsAPI — отдельный субинтерфейс | +| **DIP** | ServiceContext принимает deps через конструктор | Интерфейсы + Factory для всех 5 классов | + +### Паттерны: consistency с проектом (без изменений из v3) + +| Решение | Что было в v2 | Что стало (v3/v4) | Почему | +|---------|---------------|-------------------|--------| +| **IPC handler** | Class `TeamIpcHandler` | `let state` + `getService()` guard | 12+ модулей: module-level | +| **Renderer service** | Class `TeamService` | `unwrapIpc()` утилита | 15 slices вызывают api напрямую | +| **Line limits** | ≤100 строк/класс | Без строгих лимитов, избегать 300-400+ | Прагматизм | + +### IpcResult — дедупликация (без изменений из v3) + +Тип дублируется: `ConfigResult` в config.ts и `IpcResult` в preload/index.ts. +Вынести в `@shared/types/ipc.ts` — единый источник правды. + +--- + +## Порядок реализации + +### Справочник шагов (Steps) + +``` +Phase 0: Подготовка + 0.1 IpcResult дедупликация → @shared/types/ipc.ts + +Phase 1: Backend (Main Process) + 1 Shared Types (team.ts) — discriminated unions + 2 Path Helpers + 3 Backend Services — 5 интерфейсов + 5 классов + Factory + 4 atomicWrite.ts (ИСПРАВЛЕН: 'r+', mkdir, EXDEV, async, orphan cleanup) + 5 TeamDataService — Facade (Promise.allSettled, не Promise.all) + 6 IPC Channels + 7 IPC Handlers — module-level + guard + wrapTeamHandler + 8 Guards (validateTeamName, validateTaskId, validateFromField) + 9 Preload Bridge + TeamsAPI + 10 FileWatcher Extension + team:change wiring (v6: INSIDE wireFileWatcherEvents) + 11 Global TeamDataService (v6: НЕ в ServiceContext) + +Phase 2: Frontend (Renderer) + 12 unwrapIpc() (ИСПРАВЛЕН: без double wrapping) + 13 Tab Type — Discriminated Union (НЕ optional fields) + 14 teamSlice (ИСПРАВЛЕН: cleanup Map, без setTimeout, flag redirect) + 15 Tab Integration (SortableTab, PaneContent, TabBar) + 16 UI Components (14 шт) + Empty States + 17 KanbanBoard с явным props flow + 18 MessageComposer + inbox write (retry + delivery status) + 19 ReviewDialog + +Phase 3: Testing + 20 Test fixtures + mocks + 21 Backend tests (8 файлов) + 22 IPC tests (2 файла) + 23 Renderer tests (4 файла) +``` + +### v6: Порядок реализации — Vertical Slices + +> **v6 FIX (баг #31)**: Backend-first порядок означает, что 50% работы будет без видимого результата. +> Переход к vertical slices: каждая итерация даёт видимый результат (types → backend → IPC → UI → тест). + +**Iteration 1: Core Foundation + Team List (Steps 0.1, 1, 2, 3-partial, 5-partial, 6, 7-partial, 9, 11, 12, 13, 14-partial, 15, 16-partial)** +- Shared types, path helpers, IpcResult dedup +- ConfigReader (только listTeams, v7: skip dirs without config.json) + Factory (partial) +- IPC: team:list channel + handler + preload bridge (v7: flat export const) +- Tab discriminated union (12 files migration) + TeamsTab (v7 #43) + TeamTab +- teamSlice: fetchTeams only +- TeamView + TeamListView + TeamEmptyState +- **Результат**: открывается Teams tab, видно список команд (или empty state) + +**Iteration 2: Team Detail + Members (Steps 3-partial, 5-partial, 7-partial, 10, 14-partial, 16-partial)** +- ConfigReader.getConfig + TaskReader (v7: ENOENT → []) + MemberResolver +- TeamDataService.getTeamData (без kanban/inbox) +- IPC: team:getData handler +- FileWatcher: TWO watchers (teamsWatcher + tasksWatcher, v7 #35) + team:change wiring +- teamSlice: selectTeam + refreshTeamData + throttle (v7 #46) +- TeamDetailView + MemberList + MemberCard +- **Результат**: клик на команду → видно участников и задачи + +**Iteration 3: Kanban Board (Steps 3-partial, 4, 5-partial, 7-partial, 8, 16-partial, 17)** +- KanbanManager + atomicWrite (full) +- Guards (validateTeamName, validateTaskId) +- IPC: team:updateKanban handler +- KanbanBoard + KanbanColumn + KanbanTaskCard + ReviewBadge +- **Результат**: kanban доска с 5 колонками, click-to-move работает + +**Iteration 4: Messaging + Review (Steps 3-partial, 5-partial, 7-partial, 8, 14-partial, 16-partial, 18, 19)** +- InboxReader + sendMessage + withInboxLock + retry +- IPC: team:sendMessage + team:requestReview handlers + validateMemberName (v7 #39) +- requestReview: updateKanban + sendMessage to reviewer (v7 #40) +- teamSlice: sendTeamMessage + moveTaskToColumn +- ActivityTimeline + MessageComposer + ReviewDialog +- **Результат**: можно отправлять сообщения, запрашивать ревью + +**Iteration 5: Testing + Polish (Steps 20-23)** +- Test fixtures + mocks +- Backend tests (8 файлов), IPC tests (2), Renderer tests (4) +- Empty states для всех panels +- Error handling polish, loading states +- **Результат**: полное покрытие тестами, production-ready + +--- + +## Phase 0: Подготовка + +### Step 0.1: IpcResult дедупликация (без изменений) + +**Create** `src/shared/types/ipc.ts` + +```typescript +export interface IpcResult { + success: boolean; + data?: T; + error?: string; +} +``` + +**Modify** `src/shared/types/index.ts` — `export type { IpcResult } from './ipc';` +**Modify** `src/main/ipc/config.ts` — удалить `ConfigResult`, импортировать `IpcResult` из `@shared/types` +**Modify** `src/preload/index.ts` — удалить `IpcResult`, импортировать из `@shared/types` + +--- + +## Phase 1: Backend (Main Process) + +### Step 1: Shared Types + +**Create** `src/shared/types/team.ts` + +```typescript +// === Типы с диска (Claude Code format) === + +export interface TeamConfig { + name: string; + description: string; + createdAt: number; + leadAgentId: string; + leadSessionId: string; + members: TeamMember[]; +} + +export interface TeamMember { + name: string; + agentId: string; + agentType: string; + model?: string; + joinedAt?: number; + tmuxPaneId?: string; + cwd?: string; + subscriptions?: string[]; +} + +export interface InboxMessage { + from: string; + /** + * v7 NOTE (#47): `text` field contains SERIALIZED JSON (not plain text). + * Claude Code serializes message content as JSON string. + * UI should display as plain text — Claude Code itself handles parsing. + * Example: '{"type":"message","content":"Hello","summary":"Greeting"}' + */ + text: string; + summary?: string; + timestamp: string; + color?: string; + read: boolean; + /** v7 NOTE: old messages may lack messageId — field is optional */ + messageId?: string; +} + +export interface TeamTask { + id: string; + subject: string; + description?: string; + activeForm?: string; + owner?: string; // ОПЦИОНАЛЕН + status: 'pending' | 'in_progress' | 'completed' | 'deleted'; + blocks: string[]; + blockedBy: string[]; + metadata?: Record; +} + +// === Наши типы === + +export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; + +export interface TeamSummary { + name: string; + description: string; + memberCount: number; + taskCount: number; + lastActivity: string | null; +} + +export interface TeamData { + config: TeamConfig; + members: ResolvedTeamMember[]; + tasks: TeamTask[]; + messages: InboxMessage[]; + kanbanState: KanbanState; + /** Partial load warnings (e.g., "messages failed to load") */ + warnings?: string[]; +} + +export interface ResolvedTeamMember { + name: string; + agentId?: string; + agentType?: string; + color?: string; + currentTask?: TeamTask; + messageCount: number; + lastActive?: string; + status: MemberStatus; + role: 'worker' | 'reviewer'; +} + +// === Kanban === + +export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; + +export interface KanbanColumn { + id: KanbanColumnId; + label: string; +} + +export type ReviewAction = 'approve' | 'request_changes'; + +export interface KanbanTaskState { + column: KanbanColumnId; + reviewAction?: ReviewAction; + reviewer?: string; + comment?: string; + movedAt: string; +} + +export interface KanbanState { + teamName: string; + reviewers: string[]; + tasks: Record; +} + +export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [ + { id: 'todo', label: 'TODO' }, + { id: 'in_progress', label: 'IN PROGRESS' }, + { id: 'done', label: 'DONE' }, + { id: 'review', label: 'REVIEW' }, + { id: 'approved', label: 'APPROVED' }, +]; + +// === Events === + +export interface TeamChangeEvent { + type: 'config' | 'task' | 'inbox'; + teamName: string; + detail?: string; +} + +// === Message delivery === + +export interface SendMessageResult { + delivered: boolean; + messageId: string; +} +``` + +**Modify** `src/shared/types/index.ts` — add `export * from './team';` + +### Step 2: Path Helpers (без изменений) + +**Modify** `src/main/utils/pathDecoder.ts` + +```typescript +export function getTeamsBasePath(): string { + return path.join(getClaudeBasePath(), 'teams'); +} + +export function getTasksBasePath(): string { + return path.join(getClaudeBasePath(), 'tasks'); +} +``` + +### Step 3: Backend Services — 5 интерфейсов + 5 классов + Factory + +**NEW в v4**: Интерфейсы для DI и тестирования. + +``` +src/main/services/team/ +├── interfaces.ts — 5 интерфейсов (ITeamConfigReader, etc.) +├── TeamConfigReader.ts — implements ITeamConfigReader +├── TeamTaskReader.ts — implements ITeamTaskReader +├── TeamInboxReader.ts — implements ITeamInboxReader +├── TeamMemberResolver.ts — implements ITeamMemberResolver +├── TeamKanbanManager.ts — implements ITeamKanbanManager +├── TeamDataService.ts — Facade, принимает интерфейсы +├── TeamDataServiceFactory.ts — Composition root +├── atomicWrite.ts — Atomic write utils (sync + async) +└── index.ts — barrel export +``` + +#### interfaces.ts (NEW в v4) + +```typescript +import type { + InboxMessage, KanbanState, KanbanTaskState, + ResolvedTeamMember, SendMessageResult, TeamConfig, TeamSummary, TeamTask, +} from '@shared/types'; + +export interface ITeamConfigReader { + listTeams(): Promise; + getConfig(teamName: string): Promise; +} + +export interface ITeamTaskReader { + getTasks(teamName: string): Promise; +} + +export interface ITeamInboxReader { + getInboxNames(teamName: string): Promise; + getMessages(teamName: string): Promise; + getMessagesFor(teamName: string, member: string): Promise; + sendMessage( + teamName: string, + member: string, + msg: { from: string; text: string; summary?: string } + ): Promise; +} + +export interface ITeamMemberResolver { + /** v6 FIX: принимает pre-loaded data, не дублирует I/O */ + resolveMembers( + config: TeamConfig, + tasks: TeamTask[], + messages: InboxMessage[] + ): ResolvedTeamMember[]; +} + +export interface ITeamKanbanManager { + getState(teamName: string): Promise; + updateTaskState(teamName: string, taskId: string, state: Partial): Promise; + removeTaskState(teamName: string, taskId: string): Promise; + garbageCollect(teamName: string, existingTaskIds: Set): Promise; +} +``` + +#### TeamConfigReader + +```typescript +export class TeamConfigReader implements ITeamConfigReader { + constructor(private readonly teamsBasePath: string) {} + + async listTeams(): Promise { + const teamsDir = this.teamsBasePath; + let entries: string[]; + try { + entries = await fs.promises.readdir(teamsDir); + } catch { + return []; // ~/.claude/teams/ doesn't exist yet + } + + const summaries: TeamSummary[] = []; + for (const name of entries) { + const configPath = path.join(teamsDir, name, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + const config: TeamConfig = JSON.parse(raw); + summaries.push({ + name: config.name, + description: config.description ?? '', + memberCount: config.members?.length ?? 0, + taskCount: 0, // populated later if needed + lastActivity: null, + }); + } catch { + // v7 FIX (#37): skip dirs without config.json (e.g. "default/" has only inboxes/) + logger.debug(`Skipping team dir without valid config: ${name}`); + continue; + } + } + return summaries; + } + + async getConfig(teamName: string): Promise { + const configPath = path.join(this.teamsBasePath, teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + return JSON.parse(raw); + } catch { + return null; + } + } +} +``` + +#### TeamTaskReader + +```typescript +export class TeamTaskReader implements ITeamTaskReader { + constructor(private readonly tasksBasePath: string) {} + + async getTasks(teamName: string): Promise { + const tasksDir = path.join(this.tasksBasePath, teamName); + let entries: string[]; + try { + entries = await fs.promises.readdir(tasksDir); + } catch (error) { + // v7 FIX (#38): ~/.claude/tasks/{team}/ may not exist (graceful fallback) + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + const tasks: TeamTask[] = []; + for (const file of entries) { + if (!file.endsWith('.json') || file.startsWith('.') || file === '.lock' || file === '.highwatermark') continue; + try { + const raw = await fs.promises.readFile(path.join(tasksDir, file), 'utf8'); + const task: TeamTask = JSON.parse(raw); + if (task.status !== 'deleted') { + tasks.push(task); + } + } catch { + logger.debug(`Failed to parse task file: ${file}`); + } + } + return tasks; + } +} +``` + +#### TeamInboxReader (ИСПРАВЛЕН: async sendMessage) + +```typescript +export class TeamInboxReader implements ITeamInboxReader { + constructor(private readonly teamsBasePath: string) {} + + async getInboxNames(teamName: string): Promise { /* readdir inboxes/ */ } + async getMessages(teamName: string): Promise { /* merge all, sort by timestamp */ } + async getMessagesFor(teamName: string, member: string): Promise { /* one member */ } + + /** + * Пишет в MAIN inbox. Async версия (не блокирует event loop). + * Использует atomic write + messageId verify + retry. + */ + async sendMessage( + teamName: string, + member: string, + msg: { from: string; text: string; summary?: string } + ): Promise { + const inboxPath = path.join(this.teamsBasePath, teamName, 'inboxes', `${member}.json`); + const messageId = await appendToInboxWithRetry(inboxPath, { + from: msg.from, + text: msg.text, + summary: msg.summary, + timestamp: new Date().toISOString(), + }); + return { delivered: true, messageId }; + } +} +``` + +#### TeamMemberResolver (v6 FIX: принимает pre-loaded data) + +```typescript +export class TeamMemberResolver implements ITeamMemberResolver { + /** + * v6 FIX: принимает pre-loaded data вместо re-reading через readers. + * TeamDataService уже загрузил config, tasks, messages — не дублируем I/O. + * Стал СИНХРОННЫМ (pure transform, без async fs reads). + */ + resolveMembers( + config: TeamConfig, + tasks: TeamTask[], + messages: InboxMessage[] + ): ResolvedTeamMember[] { + // union(config.members + message senders + task owners) + // deduplicate by name (case-insensitive trim) + // extract colors from messages + // determine status via determineMemberStatus() + // match currentTask by owner field from tasks + } + + private determineMemberStatus( + lastMessageTime: Date | null, + hasActiveTask: boolean + ): MemberStatus { + if (!lastMessageTime) return 'unknown'; + const ageMs = Date.now() - lastMessageTime.getTime(); + const ACTIVE_WINDOW = 5 * 60 * 1000; // 5 min + const IDLE_WINDOW = 60 * 60 * 1000; // 1 hour + if (ageMs < ACTIVE_WINDOW || hasActiveTask) return 'active'; + if (ageMs < IDLE_WINDOW) return 'idle'; + return 'terminated'; + } +} +``` + +#### TeamKanbanManager (v6 FIX: path + async + GC dirty-check) + +```typescript +export class TeamKanbanManager implements ITeamKanbanManager { + /** v6: принимает teamsBasePath (не configDir) для хранения внутри team dir */ + constructor(private readonly teamsBasePath: string) {} + + async getState(teamName: string): Promise { + // read kanban-state.json, return default if missing + } + + async updateTaskState(teamName: string, taskId: string, state: Partial): Promise { + const current = await this.getState(teamName); + current.tasks[taskId] = { + ...current.tasks[taskId], + ...state, + movedAt: new Date().toISOString(), + }; + // v6 FIX: atomicWriteAsync вместо atomicWriteSync (async method не должен блокировать event loop) + await atomicWriteAsync(this.getStatePath(teamName), JSON.stringify(current, null, 2)); + } + + async removeTaskState(teamName: string, taskId: string): Promise { + const current = await this.getState(teamName); + delete current.tasks[taskId]; + // v6 FIX: atomicWriteAsync + await atomicWriteAsync(this.getStatePath(teamName), JSON.stringify(current, null, 2)); + } + + async garbageCollect(teamName: string, existingTaskIds: Set): Promise { + const current = await this.getState(teamName); + const toRemove = Object.keys(current.tasks).filter(id => !existingTaskIds.has(id)); + // v6 FIX: dirty-check — write ONLY if entries actually removed + if (toRemove.length === 0) return; + for (const id of toRemove) { + delete current.tasks[id]; + } + await atomicWriteAsync(this.getStatePath(teamName), JSON.stringify(current, null, 2)); + } + + private getStatePath(teamName: string): string { + // v6 FIX: хранить внутри team directory, не в ~/.claude/ root + return path.join(this.teamsBasePath, teamName, 'kanban-state.json'); + } +} +``` + +### Step 4: atomicWrite.ts (ПОЛНОСТЬЮ ПЕРЕПИСАН в v4) + +```typescript +import * as fs from 'fs'; +import * as path from 'path'; +import { randomUUID } from 'crypto'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('util:atomicWrite'); + +/** + * Atomic write (SYNC): tmp + fsync + rename. + * + * v4 исправления: + * - openSync('r+') вместо 'r' для корректного fsync + * - mkdir recursive перед write (первый write в новую team) + * - EXDEV handling (cross-mount rename fallback) + */ +export function atomicWriteSync(targetPath: string, data: string): void { + const dir = path.dirname(targetPath); + const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); + + try { + // Ensure parent directory exists (first write to new team) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(tmpPath, data, 'utf8'); + + // fsync с ПРАВИЛЬНЫМ флагом (v3 bug: 'r' → v4 fix: 'r+') + try { + const fd = fs.openSync(tmpPath, 'r+'); + fs.fsyncSync(fd); + fs.closeSync(fd); + } catch { + // fsync best effort — продолжаем + } + + // rename с EXDEV fallback + try { + fs.renameSync(tmpPath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + fs.copyFileSync(tmpPath, targetPath); + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + } else { + throw error; + } + } + } catch (error) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } + throw error; + } +} + +/** + * Atomic write (ASYNC): не блокирует event loop. + * Используется для sendMessage (inbox write может быть 50-100ms). + */ +export async function atomicWriteAsync(targetPath: string, data: string): Promise { + const dir = path.dirname(targetPath); + const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); + + try { + // v7 FIX (#42): no fs.existsSync in async function — mkdir recursive is idempotent + await fs.promises.mkdir(dir, { recursive: true }); + + await fs.promises.writeFile(tmpPath, data, 'utf8'); + + try { + const fd = await fs.promises.open(tmpPath, 'r+'); + await fd.sync(); + await fd.close(); + } catch { + // fsync best effort + } + + try { + await fs.promises.rename(tmpPath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EXDEV') { + await fs.promises.copyFile(tmpPath, targetPath); + try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ } + } else { + throw error; + } + } + } catch (error) { + try { await fs.promises.unlink(tmpPath); } catch { /* ignore */ } + throw error; + } +} + +/** + * v6: In-process write queue — serializes concurrent IPC writes to same inbox. + * Eliminates read-modify-write race condition within the Electron main process. + * + * Pattern source: FileWatcher.processingInProgress Set (same codebase). + * Note: does NOT protect against cross-process races (CLI writes). + * Cross-process safety via verify + retry (below). + * + * v6 FIX: v5 had a bug where `.then()` created a new Promise on each call, + * so the equality check `=== existing.then(() => next)` was ALWAYS false. + * Fixed by saving `myTurn` reference and comparing against it. + * Also made generic to avoid closure tricks for return values. + */ +const inboxWriteLocks = new Map>(); + +export async function withInboxLock( + inboxPath: string, + fn: () => Promise +): Promise { + // Wait for predecessor (or resolve immediately if no queue) + const predecessor = inboxWriteLocks.get(inboxPath) ?? Promise.resolve(); + + // Create our "done" signal + let release!: () => void; + const myTurn = new Promise(r => { release = r; }); + + // Register ourselves as the current tail of the queue + inboxWriteLocks.set(inboxPath, myTurn); + + // Wait for predecessor to finish + await predecessor; + + try { + return await fn(); + } finally { + release(); + // Cleanup Map only if we're still the last in queue + // v6 FIX: compare against saved `myTurn` reference (not `.then()` which creates new Promise) + if (inboxWriteLocks.get(inboxPath) === myTurn) { + inboxWriteLocks.delete(inboxPath); + } + } +} + +/** + * Append message to inbox JSON array with retry + verify. + * v4: async, exponential backoff, до 3 retry. + * v5: wrapped in withInboxLock to serialize concurrent writes. + */ +export async function appendToInboxWithRetry( + inboxPath: string, + message: Record, + maxRetries: number = 3 +): Promise { + let lastError: Error | null = null; + + let resultId = ''; + await withInboxLock(inboxPath, async () => { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + resultId = await appendToInboxWithVerify(inboxPath, message); + return; + } catch (error) { + lastError = error as Error; + logger.warn(`Inbox write attempt ${attempt + 1} failed: ${lastError.message}`); + + if (attempt < maxRetries) { + const delayMs = 10 * Math.pow(2, attempt); // 10ms, 20ms, 40ms + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + } + throw lastError ?? new Error('Failed to append to inbox after retries'); + }); + return resultId; +} + +/** + * Single attempt: read → append → atomic write → verify. + */ +async function appendToInboxWithVerify( + inboxPath: string, + message: Record +): Promise { + const messageId = randomUUID(); + const fullMessage = { ...message, messageId, read: false }; + + // 1. Read existing + let existing: unknown[] = []; + try { + const raw = await fs.promises.readFile(inboxPath, 'utf8'); + existing = JSON.parse(raw); + if (!Array.isArray(existing)) existing = []; + } catch { + // Start fresh if missing/broken + } + + // 2. Append + const updated = [...existing, fullMessage]; + + // 3. Atomic write (async — не блокирует event loop) + await atomicWriteAsync(inboxPath, JSON.stringify(updated, null, 2)); + + // 4. Verify — detect race condition + const written = JSON.parse( + await fs.promises.readFile(inboxPath, 'utf8') + ) as Array<{ messageId?: string }>; + const found = written.some(m => m.messageId === messageId); + if (!found) { + throw new Error(`Message ${messageId} lost (race condition detected)`); + } + + return messageId; +} + +/** + * Cleanup orphan .tmp files on startup. + * Called once in main/index.ts after TeamDataService creation. + */ +export async function cleanupOrphanTmpFiles(basePaths: string[]): Promise { + const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + const now = Date.now(); + + for (const basePath of basePaths) { + try { + if (!fs.existsSync(basePath)) continue; + const entries = await fs.promises.readdir(basePath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + // Scan subdirectories (inboxes/, etc.) + const subPath = path.join(basePath, entry.name); + const subEntries = await fs.promises.readdir(subPath).catch(() => []); + for (const file of subEntries) { + if (typeof file === 'string' && file.startsWith('.tmp.')) { + const filePath = path.join(subPath, file); + try { + const stat = await fs.promises.stat(filePath); + if (now - stat.mtimeMs > MAX_AGE_MS) { + await fs.promises.unlink(filePath); + logger.debug(`Cleaned orphan: ${filePath}`); + } + } catch { /* ignore */ } + } + } + } else if (entry.name.startsWith('.tmp.')) { + const filePath = path.join(basePath, entry.name); + try { + const stat = await fs.promises.stat(filePath); + if (now - stat.mtimeMs > MAX_AGE_MS) { + await fs.promises.unlink(filePath); + } + } catch { /* ignore */ } + } + } + } catch (error) { + logger.warn(`Orphan cleanup failed for ${basePath}:`, error); + } + } +} +``` + +### Step 5: TeamDataService — Facade (ИСПРАВЛЕН: интерфейсы + Promise.allSettled) + +```typescript +import type { + ITeamConfigReader, ITeamTaskReader, ITeamInboxReader, + ITeamMemberResolver, ITeamKanbanManager, +} from './interfaces'; +import type { TeamData, TeamSummary, SendMessageResult, KanbanTaskState } from '@shared/types'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Service:TeamData'); + +/** + * Facade: оркестрирует 5 reader-классов. + * v4: принимает ИНТЕРФЕЙСЫ (не конкретные классы) для DI/тестов. + * v4: Promise.allSettled для graceful degradation. + */ +export class TeamDataService { + constructor( + private readonly configReader: ITeamConfigReader, + private readonly taskReader: ITeamTaskReader, + private readonly inboxReader: ITeamInboxReader, + private readonly memberResolver: ITeamMemberResolver, + private readonly kanbanManager: ITeamKanbanManager, + ) {} + + async listTeams(): Promise { + return this.configReader.listTeams(); + } + + async getTeamData(teamName: string): Promise { + // 1. Config is required — fail fast if missing + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team not found: ${teamName}`); + } + + // 2. Load remaining data with partial failure tolerance + const [tasksResult, messagesResult, kanbanResult] = await Promise.allSettled([ + this.taskReader.getTasks(teamName), + this.inboxReader.getMessages(teamName), + this.kanbanManager.getState(teamName), + ]); + + const warnings: string[] = []; + + // v7 FIX (#38): Tasks — graceful degradation (tasks dir may not exist) + let tasks: TeamTask[] = []; + if (tasksResult.status === 'rejected') { + logger.warn(`Failed to load tasks for ${teamName}:`, tasksResult.reason); + warnings.push('Tasks failed to load'); + } else { + tasks = tasksResult.value; + } + + // Messages: graceful degradation → empty array + let messages: InboxMessage[] = []; + if (messagesResult.status === 'rejected') { + logger.warn(`Failed to load messages for ${teamName}:`, messagesResult.reason); + warnings.push('Messages failed to load'); + } else { + messages = messagesResult.value; + } + + // Kanban: graceful degradation → default state + let kanbanState: KanbanState; + if (kanbanResult.status === 'rejected') { + logger.warn(`Failed to load kanban state for ${teamName}:`, kanbanResult.reason); + kanbanState = { teamName, reviewers: [], tasks: {} }; + warnings.push('Kanban state failed to load'); + } else { + kanbanState = kanbanResult.value; + } + + // 3. GC kanban state AFTER loading tasks + const existingTaskIds = new Set(tasks.map(t => t.id)); + await this.kanbanManager.garbageCollect(teamName, existingTaskIds); + + // 4. Resolve members — v6 FIX: pass pre-loaded data (не дублируем I/O) + const members = this.memberResolver.resolveMembers(config, tasks, messages); + + return { config, members, tasks, messages, kanbanState, warnings }; + } + + async sendMessage( + teamName: string, + member: string, + msg: { from: string; text: string; summary?: string } + ): Promise { + return this.inboxReader.sendMessage(teamName, member, msg); + } + + async updateKanban( + teamName: string, + taskId: string, + state: Partial + ): Promise { + await this.kanbanManager.updateTaskState(teamName, taskId, state); + } + + /** v7 FIX (#40): полная реализация — kanban move + notify reviewer via inbox */ + async requestReview(teamName: string, taskId: string, reviewer?: string): Promise { + // 1. Move task to 'review' column in kanban + await this.kanbanManager.updateTaskState(teamName, taskId, { + column: 'review', + reviewer, + }); + + // 2. If reviewer specified, send inbox message to notify them + if (reviewer) { + const task = (await this.taskReader.getTasks(teamName)).find(t => t.id === taskId); + const subject = task?.subject ?? `Task #${taskId}`; + await this.inboxReader.sendMessage(teamName, reviewer, { + from: 'user', + text: JSON.stringify({ + type: 'review_request', + taskId, + subject, + message: `Please review: ${subject}`, + }), + summary: `Review requested: ${subject}`, + }); + } + } +} +``` + +#### TeamDataServiceFactory.ts (NEW в v4) + +```typescript +import { TeamConfigReader } from './TeamConfigReader'; +import { TeamTaskReader } from './TeamTaskReader'; +import { TeamInboxReader } from './TeamInboxReader'; +import { TeamMemberResolver } from './TeamMemberResolver'; +import { TeamKanbanManager } from './TeamKanbanManager'; +import { TeamDataService } from './TeamDataService'; + +/** + * Composition root: создаёт TeamDataService с конкретными реализациями. + * В тестах: можно создать TeamDataService с mock-реализациями интерфейсов. + * + * v6 FIX: MemberResolver больше не принимает readers (pure transform). + * v6 FIX: KanbanManager принимает teamsBasePath (хранит state в team dir). + */ +export function createTeamDataService( + teamsBasePath: string, + tasksBasePath: string +): TeamDataService { + const configReader = new TeamConfigReader(teamsBasePath); + const taskReader = new TeamTaskReader(tasksBasePath); + const inboxReader = new TeamInboxReader(teamsBasePath); + const memberResolver = new TeamMemberResolver(); + const kanbanManager = new TeamKanbanManager(teamsBasePath); + + return new TeamDataService(configReader, taskReader, inboxReader, memberResolver, kanbanManager); +} +``` + +### Step 6: IPC Channels (v7 FIX: flat export const) + +> **v7 FIX (#36)**: Проект использует flat `export const`, НЕ object namespace. +> Паттерн: `export const CONFIG_GET = 'config:get'`, а не `IPC_CHANNELS.CONFIG_GET`. + +**Modify** `src/preload/constants/ipcChannels.ts` — добавить в конец файла: + +```typescript +// ============================================================================= +// Team API Channels +// ============================================================================= + +/** List all teams */ +export const TEAM_LIST = 'team:list'; + +/** Get full team data */ +export const TEAM_GET_DATA = 'team:getData'; + +/** Send message to team member */ +export const TEAM_SEND_MESSAGE = 'team:sendMessage'; + +/** Update kanban task state */ +export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; + +/** Request review for a task */ +export const TEAM_REQUEST_REVIEW = 'team:requestReview'; + +/** Team change event channel (main -> renderer) */ +export const TEAM_CHANGE = 'team:change'; +``` + +### Step 7: IPC Handlers (v7 FIX: flat imports + validateMemberName) + +**Create** `src/main/ipc/teams.ts` + +```typescript +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import type { TeamDataService } from '@main/services/team'; +import type { IpcResult } from '@shared/types'; +// v7 FIX (#36): flat imports — project uses `export const`, NOT namespace object +import { + TEAM_LIST, TEAM_GET_DATA, TEAM_SEND_MESSAGE, + TEAM_UPDATE_KANBAN, TEAM_REQUEST_REVIEW, +} from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('IPC:team'); + +// Module-level state + guard (consistency с 12+ модулями) +interface TeamHandlerState { + service: TeamDataService; + initialized: boolean; +} + +const state: TeamHandlerState = { + service: null as unknown as TeamDataService, + initialized: false, +}; + +function getService(): TeamDataService { + if (!state.initialized) throw new Error('Team handlers not initialized'); + return state.service; +} + +export function initializeTeamHandlers(service: TeamDataService): void { + if (state.initialized) { + logger.warn('Team handlers already initialized'); + return; + } + state.service = service; + state.initialized = true; +} + +export function registerTeamHandlers(ipcMain: IpcMain): void { + // v7 FIX (#36): flat channel constants + ipcMain.handle(TEAM_LIST, handleListTeams); + ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); + ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); + ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); + logger.info('Team handlers registered'); +} + +export function removeTeamHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(TEAM_LIST); + ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_SEND_MESSAGE); + ipcMain.removeHandler(TEAM_UPDATE_KANBAN); + ipcMain.removeHandler(TEAM_REQUEST_REVIEW); +} + +/** + * v4: Helper для consistent error handling. + * Все handlers используют одинаковый pattern. + */ +async function wrapTeamHandler( + operation: string, + handler: () => Promise +): Promise> { + try { + const result = await handler(); + return { success: true, data: result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[team:${operation}] Error:`, error); + return { success: false, error: message }; + } +} + +async function handleListTeams(): Promise> { + return wrapTeamHandler('list', () => getService().listTeams()); +} + +async function handleGetData( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validation = validateTeamName(teamName); + if (!validation.valid) return { success: false, error: validation.error! }; + return wrapTeamHandler('getData', () => getService().getTeamData(validation.value)); +} + +async function handleSendMessage( + _event: IpcMainInvokeEvent, + teamName: unknown, + member: unknown, + text: unknown, + summary: unknown +): Promise> { + const teamValidation = validateTeamName(teamName); + if (!teamValidation.valid) return { success: false, error: teamValidation.error! }; + // v7 FIX (#39): validate member name to prevent path traversal + const memberValidation = validateMemberName(member); + if (!memberValidation.valid) return { success: false, error: memberValidation.error! }; + if (typeof text !== 'string' || text.trim().length === 0) { + return { success: false, error: 'text must be a non-empty string' }; + } + return wrapTeamHandler('sendMessage', () => + getService().sendMessage(teamValidation.value, memberValidation.value, { + from: 'user', + text: text as string, + summary: typeof summary === 'string' ? summary : undefined, + }) + ); +} + +// handleUpdateKanban, handleRequestReview — аналогично через wrapTeamHandler +``` + +### Step 8: Guards (v7: + validateMemberName для path traversal prevention) + +**Modify** `src/main/ipc/guards.ts` + +```typescript +const TEAM_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const TASK_ID_PATTERN = /^[0-9]{1,10}$/; +// v7 FIX (#39): member names are used in file paths (inboxes/{member}.json) +// Must prevent path traversal (e.g., "../../etc/passwd") +const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; + +export function validateTeamName(value: unknown): ValidationResult { + if (typeof value !== 'string') return { valid: false, error: 'teamName must be a string' }; + const trimmed = value.trim(); + if (!TEAM_NAME_PATTERN.test(trimmed)) { + return { valid: false, error: `Invalid team name: ${trimmed}` }; + } + return { valid: true, value: trimmed }; +} + +export function validateTaskId(value: unknown): ValidationResult { + if (typeof value !== 'string') return { valid: false, error: 'taskId must be a string' }; + if (!TASK_ID_PATTERN.test(value)) { + return { valid: false, error: `Invalid task ID: ${value}` }; + } + return { valid: true, value }; +} + +/** + * v7 FIX (#39): Validates member name used in inbox file paths. + * Critical for security: member is interpolated into file path: + * path.join(teamsBasePath, teamName, 'inboxes', `${member}.json`) + * Without validation, attacker could send member="../../etc/passwd" for path traversal. + */ +export function validateMemberName(value: unknown): ValidationResult { + if (typeof value !== 'string') return { valid: false, error: 'member must be a string' }; + const trimmed = value.trim(); + if (!MEMBER_NAME_PATTERN.test(trimmed)) { + return { valid: false, error: `Invalid member name: ${trimmed}` }; + } + return { valid: true, value: trimmed }; +} +``` + +### Step 9: Preload Bridge (без изменений) + +**Modify** `src/shared/types/api.ts` — add `TeamsAPI` + extend `ElectronAPI` +**Modify** `src/preload/index.ts` — teams bridge implementation + +### Step 10: FileWatcher Extension + team:change Wiring (v7: ДВА watcher'а) + +**Modify** `src/main/services/infrastructure/FileWatcher.ts` + +> **v5 research result**: FileWatcher уже имеет 2 параллельных watcher (projectsWatcher + todosWatcher). +> Copy-paste паттерн. ~60 LOC per watcher, нулевой риск для существующих watchers. +> +> **v7 CRITICAL FIX (#35)**: `~/.claude/tasks/` — ОТДЕЛЬНАЯ директория от `~/.claude/teams/`. +> Нужны ДВА новых watcher'а: +> - `teamsWatcher` для `~/.claude/teams/` (config changes, inbox changes) +> - `tasksWatcher` для `~/.claude/tasks/` (task file changes) +> Оба emit'ят `'team-change'` event с разным `type`. + +Добавить: +- `teamsWatcher: fs.FSWatcher | null = null` property +- `tasksWatcher: fs.FSWatcher | null = null` property +- `startTeamsWatcher()` method (копия todosWatcher pattern) +- `startTasksWatcher()` method (копия todosWatcher pattern) +- `handleTeamsChange()` → emit `'team-change'` с `type: 'config' | 'inbox'` +- `handleTasksChange()` → emit `'team-change'` с `type: 'task'` +- v7 (#45): `{ recursive: true }` option для fs.watch (macOS native support, Linux Node 19+) +- Update `stop()` и `dispose()` для cleanup обоих watcher'ов +- SSH polling: автоматически поддержан (бесплатно) + +```typescript +// Property declarations (alongside existing projectsWatcher, todosWatcher): +private teamsWatcher: fs.FSWatcher | null = null; +private tasksWatcher: fs.FSWatcher | null = null; + +// Start methods (called from start() alongside existing watchers): +private startTeamsWatcher(): void { + const teamsPath = getTeamsBasePath(); + try { + // v7 (#45): recursive:true — macOS native, Linux Node 19+ + this.teamsWatcher = fs.watch(teamsPath, { recursive: true }, (eventType, filename) => { + this.handleTeamsChange(eventType, filename); + }); + this.teamsWatcher.on('error', (err) => { + logger.warn('Teams watcher error, scheduling retry:', err.message); + this.teamsWatcher = null; + setTimeout(() => this.startTeamsWatcher(), WATCHER_RETRY_MS); + }); + } catch { + logger.debug('Teams dir not available, will retry'); + setTimeout(() => this.startTeamsWatcher(), WATCHER_RETRY_MS); + } +} + +private startTasksWatcher(): void { + const tasksPath = getTasksBasePath(); + try { + this.tasksWatcher = fs.watch(tasksPath, { recursive: true }, (eventType, filename) => { + this.handleTasksChange(eventType, filename); + }); + this.tasksWatcher.on('error', (err) => { + logger.warn('Tasks watcher error, scheduling retry:', err.message); + this.tasksWatcher = null; + setTimeout(() => this.startTasksWatcher(), WATCHER_RETRY_MS); + }); + } catch { + logger.debug('Tasks dir not available, will retry'); + setTimeout(() => this.startTasksWatcher(), WATCHER_RETRY_MS); + } +} + +// Change handlers with debounce (reuse existing debounce pattern): +private handleTeamsChange(eventType: string, filename: string | null): void { + // Debounce + determine team name from filename path + // filename = "my-team/config.json" or "my-team/inboxes/member.json" + const teamName = filename?.split(path.sep)[0] ?? 'unknown'; + const isInbox = filename?.includes('inboxes'); + this.emit('team-change', { + type: isInbox ? 'inbox' : 'config', + teamName, + detail: filename ?? undefined, + } satisfies TeamChangeEvent); +} + +private handleTasksChange(eventType: string, filename: string | null): void { + const teamName = filename?.split(path.sep)[0] ?? 'unknown'; + this.emit('team-change', { + type: 'task', + teamName, + detail: filename ?? undefined, + } satisfies TeamChangeEvent); +} + +// Cleanup (in stop() and dispose()): +if (this.teamsWatcher) { this.teamsWatcher.close(); this.teamsWatcher = null; } +if (this.tasksWatcher) { this.tasksWatcher.close(); this.tasksWatcher = null; } +``` + +Total: ~120 LOC в FileWatcher (60 per watcher) + 20 LOC wiring в main/index.ts + +**Modify** `src/main/index.ts` — **v6 FIX: wiring ВНУТРЬ `wireFileWatcherEvents()`** (баг #25) + +> **v6 FIX**: Standalone `setupTeamChangeForwarding()` сломается при SSH context switch, +> потому что `wireFileWatcherEvents()` перезапускается для нового context, а standalone — нет. +> Интеграция внутрь `wireFileWatcherEvents()` гарантирует переподключение при смене context. + +```typescript +// В wireFileWatcherEvents() (src/main/index.ts, ~line 105): +// ДОБАВИТЬ рядом с существующими file-change и todo-change handlers: + +function wireFileWatcherEvents(fileWatcher: FileWatcher, win: BrowserWindow): () => void { + // ... existing file-change handler ... + // ... existing todo-change handler ... + + // v6: team-change forwarding (ВНУТРИ wireFileWatcherEvents, не standalone) + // v7 FIX (#36): flat import, не IPC_CHANNELS object + const teamChangeHandler = (event: TeamChangeEvent) => { + if (win && !win.isDestroyed()) { + win.webContents.send(TEAM_CHANGE, event); + } + // v7 FIX (#44): broadcast to HTTP sidecar (browser mode support) + httpServer?.broadcast('team-change', event); + }; + fileWatcher.on('team-change', teamChangeHandler); + + // Return combined cleanup (existing + team) + return () => { + // ... existing cleanup ... + fileWatcher.off('team-change', teamChangeHandler); + }; +} +``` + +### Step 11: Global TeamDataService (v6 FIX: НЕ в ServiceContext) + +> **v6 FIX (баг #26)**: ServiceContext — per-workspace (создаётся заново при SSH context switch). +> TeamDataService читает `~/.claude/teams/` и `~/.claude/tasks/` — ЛОКАЛЬНЫЕ пути, +> не зависящие от workspace/SSH. Аналогично UpdaterService — создаётся один раз глобально. + +**Modify** `src/main/index.ts` — создать TeamDataService глобально: + +```typescript +import { createTeamDataService } from '@main/services/team'; +import { cleanupOrphanTmpFiles } from '@main/services/team/atomicWrite'; +import { getTeamsBasePath, getTasksBasePath } from '@main/utils/pathDecoder'; + +// Global — не зависит от ServiceContext/workspace +const teamDataService = createTeamDataService( + getTeamsBasePath(), + getTasksBasePath() +); + +// Orphan cleanup on startup +cleanupOrphanTmpFiles([getTeamsBasePath(), getTasksBasePath()]); +``` + +**Modify** `src/main/ipc/handlers.ts` — v7 FIX (#48): добавить teamDataService как параметр: + +```typescript +import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams'; +import type { TeamDataService } from '@main/services/team'; + +// v7 FIX (#48): signature расширен — teamDataService передаётся напрямую (global, не из context) +// Existing params сохранены, добавлен последний параметр +export function initializeIpcHandlers( + registry: ServiceContextRegistry, + updater: UpdaterService, + sshManager: SshConnectionManager, + contextCallbacks: { /* existing */ }, + teamDataService: TeamDataService // v7: global parameter, НЕ из ServiceContext +): void { + // ... existing initialize calls (projects, sessions, search, etc.) ... + + // Team handlers — global service + initializeTeamHandlers(teamDataService); + + // ... existing register calls ... + registerTeamHandlers(ipcMain); + + logger.info('All handlers registered'); +} + +export function removeIpcHandlers(): void { + // ... existing remove calls ... + removeTeamHandlers(ipcMain); + logger.info('All handlers removed'); +} +``` + +**НЕ модифицируем** `ServiceContext.ts` — TeamDataService туда НЕ добавляется. + +--- + +## Phase 2: Frontend (Renderer) + +### Step 12: unwrapIpc() (ИСПРАВЛЕН: без double wrapping) + +**Create** `src/renderer/utils/unwrapIpc.ts` + +```typescript +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('api:unwrap'); + +export class IpcError extends Error { + constructor( + public operation: string, + message: string, + public originalError?: unknown + ) { + super(message); + this.name = 'IpcError'; + } +} + +/** + * Единая обёртка для IPC вызовов. + * + * v4 FIX: invokeIpcWithResult() в preload УЖЕ throws на !success, + * поэтому НЕ нужен второй unwrap. Просто catch + wrap в IpcError. + * + * Использование: + * const teams = await unwrapIpc('team:list', () => api.teams.list()); + */ +export async function unwrapIpc( + operation: string, + fn: () => Promise +): Promise { + try { + return await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[IPC:${operation}] Failed: ${message}`); + throw new IpcError(operation, message, error); + } +} + +// v4: unwrapIpcResult УДАЛЁН — preload уже делает unwrap. +// Если handler возвращает IpcResult, preload/invokeIpcWithResult +// автоматически проверяет success и throws Error на failure. +// unwrapIpc достаточно для всех случаев. +``` + +### Step 13: Tab Type — Discriminated Union (v6: полная карта миграции 12 файлов) + +> **v6 FIX**: v5 насчитал 8 файлов. Реально 12+ файлов, 30-35 строк. +> Пропущены: TabBar.tsx (6+ unsafe accesses), notificationSlice.ts, contextStorage.ts. +> BaseTab поля исправлены: `fromSearch`, `savedScrollTop`, `showContextPanel` → session-only. +> TabInput тип требует distributive variant. + +**Полная карта миграции (12 файлов):** + +| Файл | Строк | Статус | +|------|-------|--------| +| `store/slices/tabSlice.ts` | 8 | 6 с guards, 2 в factory. TabInput нужен distributive | +| `store/slices/sessionDetailSlice.ts` | 3 | все с guards | +| `store/slices/contextSlice.ts` | 4 | все с guards | +| `store/slices/notificationSlice.ts` | 3 | **ПРОПУЩЕН в v5**: creates SessionTab via openTab, findTabBySessionAndProject | +| `store/slices/paneSlice.ts` | 1 | с guard | +| `store/index.ts` | 2 | 1 unsafe (projectId access) | +| `components/layout/PaneContent.tsx` | 4 | все с type switch, добавить 'team' case | +| `components/layout/SortableTab.tsx` | 3 | 2 с guards, 1 TAB_ICONS → добавить 'team' | +| `components/layout/SessionTabContent.tsx` | 2 | UNSAFE: нет type check | +| `components/layout/TabBar.tsx` | 6 | **ПРОПУЩЕН в v5**: activeTab.projectId, contextMenuTab?.sessionId (6+ accesses) | +| `services/contextStorage.ts` | 2 | **ПРОПУЩЕН в v5**: ContextSnapshot.openTabs: Tab[] → version migration | +| Test files | ~5 | Тесты конструирующие Tab objects | + +**Unsafe-места (нужен type narrowing):** +1. `SessionTabContent.tsx:65` — добавить `if (isSessionTab(tab))` +2. `store/index.ts:259` — `visibleSessionTab?.projectId` → narrow first +3. `TabBar.tsx:213` — `activeTab.projectId && activeTab.sessionId` → narrow +4. `TabBar.tsx:239-244` — `contextMenuTab?.sessionId` (4 accesses) → narrow +5. `notificationSlice.ts` — openTab input shape → SessionTabInput +6. `contextStorage.ts` — IndexedDB snapshot version bump (old Tab shape → new union) + +**Modify** `src/renderer/types/tabs.ts` + +```typescript +// v6: Discriminated union. Session-only поля на SessionTab, НЕ на BaseTab. +// 12 файлов миграции. TabInput — distributive variant. + +interface BaseTab { + id: string; + label: string; + createdAt: number; + // Shared UI fields (genuinely used by ALL tab types): + pendingNavigation?: TabNavigationRequest; + lastConsumedNavigationId?: string; +} + +export interface SessionTab extends BaseTab { + type: 'session'; + sessionId: string; + projectId: string; + // v6 FIX: session-only поля (НЕ на BaseTab): + fromSearch?: boolean; + savedScrollTop?: number; + showContextPanel?: boolean; +} + +/** v7 FIX (#43): List view — singleton tab для списка всех команд */ +export interface TeamsTab extends BaseTab { + type: 'teams'; +} + +/** Individual team detail tab */ +export interface TeamTab extends BaseTab { + type: 'team'; + teamName: string; +} + +export interface DashboardTab extends BaseTab { + type: 'dashboard'; +} + +export interface NotificationsTab extends BaseTab { + type: 'notifications'; +} + +export interface SettingsTab extends BaseTab { + type: 'settings'; +} + +export type Tab = SessionTab | TeamsTab | TeamTab | DashboardTab | NotificationsTab | SettingsTab; + +// Type guards +export function isSessionTab(tab: Tab): tab is SessionTab { + return tab.type === 'session'; +} + +export function isTeamsTab(tab: Tab): tab is TeamsTab { + return tab.type === 'teams'; +} + +export function isTeamTab(tab: Tab): tab is TeamTab { + return tab.type === 'team'; +} + +// v6 FIX: TabInput — distributive variant (Omit не дистрибутивен в TypeScript) +export type SessionTabInput = Omit; +export type TeamsTabInput = Omit; +export type TeamTabInput = Omit; +export type DashboardTabInput = Omit; +export type NotificationsTabInput = Omit; +export type SettingsTabInput = Omit; +export type TabInput = SessionTabInput | TeamsTabInput | TeamTabInput | DashboardTabInput | NotificationsTabInput | SettingsTabInput; +``` + +**NOTE**: Breaking change для 12 файлов. Все `tab.sessionId` → type narrowing: `if (isSessionTab(tab)) { tab.sessionId }`. +**NOTE**: `contextStorage.ts` — bump SNAPSHOT_VERSION, handle deserialization of old Tab shape. + +### Step 14: teamSlice (ИСПРАВЛЕН: cleanup, без setTimeout, delivery status) + +**Create** `src/renderer/store/slices/teamSlice.ts` + +```typescript +import { unwrapIpc, IpcError } from '@renderer/utils/unwrapIpc'; +import type { TeamData, TeamSummary, KanbanTaskState, SendMessageResult } from '@shared/types'; + +const { api } = window.electronAPI; + +// Generation pattern из sessionSlice +const teamRefreshGeneration = new Map(); + +export interface TeamSlice { + // State + teams: TeamSummary[]; + teamsLoading: boolean; + teamsError: string | null; + selectedTeamName: string | null; + selectedTeamData: TeamData | null; + selectedTeamLoading: boolean; + selectedTeamError: string | null; + /** v4: flag for component-level redirect (вместо setTimeout) */ + teamDeletedRedirect: boolean; + /** v4: message delivery state */ + sendingMessage: boolean; + lastSendResult: SendMessageResult | null; + sendError: string | null; + + // Actions + fetchTeams: () => Promise; + selectTeam: (teamName: string) => Promise; + refreshTeamData: (teamName: string) => Promise; + sendTeamMessage: (member: string, text: string, summary?: string) => Promise; + moveTaskToColumn: (taskId: string, state: Partial) => Promise; + openTeamTab: (teamName: string) => void; + openTeamsListTab: () => void; + /** v4: cleanup generation map при закрытии tab */ + cleanupTeamState: (teamName: string) => void; + clearTeamDeletedRedirect: () => void; +} + +export const createTeamSlice: StateCreator = (set, get) => ({ + teams: [], + teamsLoading: false, + teamsError: null, + selectedTeamName: null, + selectedTeamData: null, + selectedTeamLoading: false, + selectedTeamError: null, + teamDeletedRedirect: false, + sendingMessage: false, + lastSendResult: null, + sendError: null, + + fetchTeams: async () => { + set({ teamsLoading: true, teamsError: null }); + try { + const teams = await unwrapIpc('team:list', () => api.teams.list()); + set({ teams, teamsLoading: false }); + } catch (error) { + set({ + teamsError: error instanceof IpcError ? error.message : String(error), + teamsLoading: false, + }); + } + }, + + selectTeam: async (teamName: string) => { + set({ + selectedTeamName: teamName, + selectedTeamLoading: true, + selectedTeamError: null, + teamDeletedRedirect: false, + }); + try { + const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); + set({ selectedTeamData: data, selectedTeamLoading: false }); + } catch (error) { + if (error instanceof IpcError && error.message.includes('not found')) { + // v4: set flag, компонент обработает redirect в useEffect + set({ + selectedTeamData: null, + selectedTeamError: 'Team was deleted', + selectedTeamLoading: false, + teamDeletedRedirect: true, + }); + return; + } + set({ selectedTeamError: String(error), selectedTeamLoading: false }); + } + }, + + refreshTeamData: async (teamName: string) => { + const generation = (teamRefreshGeneration.get(teamName) ?? 0) + 1; + teamRefreshGeneration.set(teamName, generation); + + try { + const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName)); + if (teamRefreshGeneration.get(teamName) !== generation) return; + set({ selectedTeamData: data, selectedTeamLoading: false }); + } catch (error) { + if (teamRefreshGeneration.get(teamName) === generation) { + set({ selectedTeamError: String(error), selectedTeamLoading: false }); + } + } + }, + + sendTeamMessage: async (member: string, text: string, summary?: string) => { + const teamName = get().selectedTeamName; + if (!teamName) return; + + set({ sendingMessage: true, sendError: null, lastSendResult: null }); + try { + const result = await unwrapIpc('team:sendMessage', () => + api.teams.sendMessage(teamName, member, text, summary) + ); + set({ sendingMessage: false, lastSendResult: result }); + // Refresh data to show new message + get().refreshTeamData(teamName); + } catch (error) { + set({ + sendingMessage: false, + sendError: error instanceof IpcError ? error.message : String(error), + }); + } + }, + + // v4: cleanup при закрытии team tab + cleanupTeamState: (teamName: string) => { + teamRefreshGeneration.delete(teamName); + }, + + clearTeamDeletedRedirect: () => { + set({ teamDeletedRedirect: false }); + }, + + // ... moveTaskToColumn, openTeamTab, openTeamsListTab +}); +``` + +**Modify** `src/renderer/store/types.ts` — `& TeamSlice` +**Modify** `src/renderer/store/index.ts` — compose + team-change listener: + +```typescript +// В initializeNotificationListeners(): +// v7 FIX (#46): throttle/coalesce — multiple rapid file changes → single refresh +let teamRefreshTimer: ReturnType | null = null; +const TEAM_REFRESH_THROTTLE_MS = 300; + +api.teams.onTeamChange((event: TeamChangeEvent) => { + const state = useStore.getState(); + if (state.selectedTeamName !== event.teamName) return; + + // Coalesce rapid changes (e.g., multiple task files written in 100ms) + if (teamRefreshTimer) clearTimeout(teamRefreshTimer); + teamRefreshTimer = setTimeout(() => { + teamRefreshTimer = null; + const currentState = useStore.getState(); + if (currentState.selectedTeamName === event.teamName) { + currentState.refreshTeamData(event.teamName); + } + }, TEAM_REFRESH_THROTTLE_MS); +}); + +// Also refresh teams list on any team-change (for TeamListView updates) +api.teams.onTeamChange(() => { + // Simpler: just refetch teams list (lightweight operation) + useStore.getState().fetchTeams(); +}); +``` + +### Step 15: Tab Integration (v7: + TeamsTab) + +- `SortableTab.tsx` — `teams: Users, team: Users` in TAB_ICONS (v7: два типа) +- `PaneContent.tsx`: + ```typescript + // v7 FIX (#43): separate TeamsTab (list) vs TeamTab (detail) + {isTeamsTab(tab) && } + {isTeamTab(tab) && } + ``` + TeamView без teamName → renders TeamListView; с teamName → TeamDetailView +- `TabBar.tsx` — Teams button: `openTeamsListTab()` (singleton) +- При close team tab → вызвать `cleanupTeamState(tab.teamName)` +- TeamsTab — singleton: при повторном клике не создаёт новый tab, а переключает на существующий + +### Step 16: UI Components + Empty States + +``` +src/renderer/components/team/ +├── TeamView.tsx — Router: list vs detail +├── TeamListView.tsx — Grid of team cards +├── TeamDetailView.tsx — Members (left) + Kanban (center) + Activity (right) +├── TeamEmptyState.tsx — No teams (icon + message) +├── TeamDetailLoadingState.tsx — Skeleton for all 3 panels (NEW v4) +├── members/ +│ ├── MemberList.tsx — Left panel (240px) +│ ├── MemberCard.tsx — Color dot, name, status, current task +│ └── MemberListEmpty.tsx — "No members" (NEW v4) +├── kanban/ +│ ├── KanbanBoard.tsx — 5 columns, click-to-move +│ ├── KanbanColumn.tsx — Header + count + cards +│ ├── KanbanTaskCard.tsx — Owner badge, subject, column selector, blocked indicator +│ ├── ReviewBadge.tsx — Approve/RequestChanges badge +│ └── KanbanEmpty.tsx — "No tasks" (NEW v4) +├── activity/ +│ ├── ActivityTimeline.tsx — Right panel (320px) +│ ├── ActivityItem.tsx — Color dot, sender, time, summary +│ ├── MessageComposer.tsx — Recipient + textarea + send + delivery status +│ └── ActivityEmpty.tsx — "No messages" (NEW v4) +└── dialogs/ + └── ReviewDialog.tsx — Approve / Request Changes с комментарием +``` + +### Step 17: KanbanBoard с явным props flow (NEW в v4) + +```typescript +// Данные текут: teamSlice.selectedTeamData → TeamDetailView → KanbanBoard + +interface KanbanBoardProps { + tasks: TeamTask[]; + kanbanState: KanbanState; + onMoveTask: (taskId: string, column: KanbanColumnId) => Promise; + onRequestReview: (taskId: string) => void; +} + +// TeamDetailView пробрасывает: + moveTaskToColumn(taskId, { column })} + onRequestReview={(taskId) => setReviewDialogTaskId(taskId)} +/> + +// KanbanBoard группирует tasks по columns: +// 1. tasks с kanbanState → используем kanbanState.column +// 2. tasks БЕЗ kanbanState → v7 FIX (#41) маппинг по task.status: +// pending → todo, in_progress → in_progress, completed → done +// +// v7 NOTE: auto-review is Phase 2. MVP maps completed → 'done'. +// User can manually move tasks to 'review' column via click-to-move. +// Rationale: auto-mapping completed → review is opinionated and may confuse users +// who expect 'done' to mean 'done'. + +// KanbanTaskCard: +interface KanbanTaskCardProps { + task: TeamTask; + kanbanState?: KanbanTaskState; + onMoveToColumn: (column: KanbanColumnId) => void; + isBlocked: boolean; // task.blockedBy.length > 0 +} + +// Blocked tasks: полупрозрачный + иконка 🔒 + tooltip "Blocked by #X, #Y" +``` + +### Step 18: MessageComposer + delivery status (ДОПОЛНЕН в v4) + +```typescript +interface MessageComposerProps { + members: ResolvedTeamMember[]; + onSend: (member: string, text: string, summary?: string) => Promise; + sending: boolean; // from teamSlice.sendingMessage + sendError: string | null; // from teamSlice.sendError + lastResult: SendMessageResult | null; // from teamSlice.lastSendResult +} + +// UI states: +// 1. Idle: textarea + recipient select + "Send" button +// 2. Sending: "Sending..." + disabled button + spinner +// 3. Sent: "Delivered ✓" toast (3 sec) + clear textarea +// 4. Error: "Failed: {error}" toast (red) + "Retry" button +``` + +### Step 19: ReviewDialog (без изменений из v3) + +--- + +## Write-Path Safety (обновлён в v4) + +### Inbox Protocol (без изменений) + +- **Формат**: JSON array `[{from, text, timestamp, read, ...}, ...]` +- **Доставка**: между turns, 1-30 сек задержка +- **Поле `from`**: `"user"` (наше приложение всегда от имени юзера) +- **Сообщения НЕ удаляются** + +### Write Strategy (v4 — исправлена) + +``` +read JSON → parse → append message → + atomicWriteAsync(tmp → fsync → rename) → + verify(messageId) → + retry (до 3 раз, exponential backoff) +``` + +| Уровень защиты | v3 | v4 | Изменение | +|-----------------|----|----|-----------| +| Atomic write | tmp + fsync + rename | + mkdir + 'r+' flag + EXDEV | Исправлены 3 бага | +| Async write | Sync (блокирует event loop) | fs.promises (non-blocking) | Новое | +| Retry | "до 5 попыток в UI" (не реализовано) | appendToInboxWithRetry, 3 попытки, backoff | Реализовано | +| Orphan cleanup | "На startup" (не реализовано) | cleanupOrphanTmpFiles() в main/index.ts | Реализовано | +| Kanban write | Обычный writeFileSync | atomicWriteSync | Исправлено | +| messageId verify | Sync read-back | Async read-back | Обновлено | + +### Что НЕ нужно на MVP (без изменений) + +- File locking +- Append-only JSONL +- Separate .ui-inbox.json +- Compare-And-Swap + +--- + +## Phase 3: Testing (NEW в v4) + +### Тестовая стратегия + +``` +test/ +├── main/ +│ ├── services/team/ +│ │ ├── TeamConfigReader.test.ts — listTeams, getConfig, missing dirs +│ │ ├── TeamTaskReader.test.ts — getTasks, skip .lock/.highwatermark +│ │ ├── TeamInboxReader.test.ts — getMessages, sendMessage, verify +│ │ ├── TeamMemberResolver.test.ts — resolveMembers, status detection +│ │ ├── TeamKanbanManager.test.ts — CRUD, GC, atomic write +│ │ ├── TeamDataService.test.ts — orchestration, Promise.allSettled +│ │ └── atomicWrite.test.ts — sync, async, fsync, EXDEV, race +│ └── ipc/ +│ ├── teams.test.ts — guard, all handlers, wrapTeamHandler +│ └── guards.test.ts (extend) — validateTeamName, validateTaskId +├── renderer/ +│ ├── utils/ +│ │ └── unwrapIpc.test.ts — unwrapIpc, IpcError wrapping +│ ├── store/ +│ │ └── teamSlice.test.ts — actions, generation pattern, redirect +│ └── components/team/ +│ └── KanbanBoard.test.ts — column mapping, blocked tasks +├── fixtures/team/ +│ ├── config.json — sample team config +│ ├── task-001.json — sample task +│ ├── member-inbox.json — sample inbox (5 messages) +│ ├── kanban-state.json — sample kanban state +│ └── corrupted/ — invalid JSON samples +└── mocks/ + └── teamFixtures.ts — createMockTeamConfig, createMockTeamTask, etc. +``` + +### Приоритеты + +| Priority | Файлы | Что покрывают | +|----------|-------|---------------| +| P0 (must) | atomicWrite, TeamInboxReader, TeamDataService, teamSlice, teams.test | Core write-path + store | +| P1 (should) | ConfigReader, TaskReader, MemberResolver, KanbanManager, unwrapIpc | Все readers + утилиты | +| P2 (nice) | KanbanBoard, guards extension | UI + validation | + +--- + +## File Change Summary + +### New Files (~33) + +| # | File | Purpose | +|---|------|---------| +| 1 | `src/shared/types/ipc.ts` | IpcResult (deduplicated) | +| 2 | `src/shared/types/team.ts` | Shared types | +| 3 | `src/main/services/team/interfaces.ts` | 5 интерфейсов (NEW v4) | +| 4 | `src/main/services/team/TeamConfigReader.ts` | Read config.json | +| 5 | `src/main/services/team/TeamTaskReader.ts` | Read task files | +| 6 | `src/main/services/team/TeamInboxReader.ts` | Read/write inbox | +| 7 | `src/main/services/team/TeamMemberResolver.ts` | Resolve members | +| 8 | `src/main/services/team/TeamKanbanManager.ts` | Kanban state CRUD | +| 9 | `src/main/services/team/TeamDataService.ts` | Facade | +| 10 | `src/main/services/team/TeamDataServiceFactory.ts` | Composition root (NEW v4) | +| 11 | `src/main/services/team/atomicWrite.ts` | Atomic write utils | +| 12 | `src/main/services/team/index.ts` | Barrel | +| 13 | `src/main/ipc/teams.ts` | IPC handlers | +| 14 | `src/renderer/utils/unwrapIpc.ts` | IPC error utility | +| 15 | `src/renderer/store/slices/teamSlice.ts` | Store slice | +| 16-30 | `src/renderer/components/team/**` | 15 UI components (+3 empty states) | +| 31 | `test/mocks/teamFixtures.ts` | Test fixtures | +| 32 | `test/fixtures/team/*` | Sample data | + +### Modified Files (~18) + +| # | File | Change | +|---|------|--------| +| 1 | `src/shared/types/index.ts` | Re-export team types + IpcResult | +| 2 | `src/shared/types/api.ts` | TeamsAPI + ElectronAPI | +| 3 | `src/main/ipc/config.ts` | Replace ConfigResult → IpcResult | +| 4 | `src/main/utils/pathDecoder.ts` | getTeamsBasePath(), getTasksBasePath() | +| 5 | `src/main/services/index.ts` | Barrel export | +| 6 | ~~`src/main/services/infrastructure/ServiceContext.ts`~~ | ~~v4: + teamDataService~~ v6: НЕ модифицируем | +| 7 | `src/main/ipc/handlers.ts` | Wire team init/register/remove + teamDataService param (v7 #48) | +| 8 | `src/main/ipc/guards.ts` | validateTeamName, validateTaskId, validateMemberName (v7 #39) | +| 9 | `src/preload/constants/ipcChannels.ts` | TEAM_* channels (flat export const, v7 #36) | +| 10 | `src/preload/index.ts` | teams API bridge + IpcResult from @shared | +| 11 | `src/main/services/infrastructure/FileWatcher.ts` | TWO watchers: teamsWatcher + tasksWatcher (v7 #35) | +| 12 | `src/main/index.ts` | Forward events, create service, orphan cleanup, httpServer.broadcast (v7 #44) | +| 13 | `src/renderer/types/tabs.ts` | Discriminated union + TeamsTab (v7 #43) | +| 14 | `src/renderer/store/types.ts` | TeamSlice | +| 15 | `src/renderer/store/index.ts` | Compose + team-change listener + throttle (v7 #46) | +| 16 | `src/renderer/components/layout/PaneContent.tsx` | TeamsTab + TeamTab rendering | +| 17 | `src/renderer/api/httpClient.ts` | teams methods for browser mode | +| 18 | `test/mocks/electronAPI.ts` | + teams mock methods | + +--- + +## Architecture Diagram (v6) + +``` +┌──────────────────── RENDERER ────────────────────┐ +│ │ +│ Components ──→ teamSlice ──→ unwrapIpc() │ +│ (TeamView, (state + (catch+wrap, │ +│ KanbanBoard, generation NO double unwrap) │ +│ empty states) + cleanup) │ +│ │ │ +│ ↓ window.electronAPI.teams │ +├───────────────── PRELOAD BRIDGE ─────────────────┤ +│ │ +│ TeamsAPI interface ←→ IPC Channels │ +│ invokeIpcWithResult() already handles IpcResult │ +│ │ +├──────────────────── MAIN PROCESS ────────────────┤ +│ │ +│ teams.ts ──→ TeamDataService (Facade) │ +│ (module-level ┌──────────────────────────┐ │ +│ + wrapHandler) │ INTERFACES (DI/testing): │ │ +│ │ ITeamConfigReader │ │ +│ GLOBAL instance │ ITeamTaskReader │ │ +│ (v6: не в │ ITeamInboxReader │ │ +│ ServiceContext) │ ITeamMemberResolver │ │ +│ │ ITeamKanbanManager │ │ +│ └──────┬───────────────────┘ │ +│ │ atomicWrite │ +│ │ (async + retry) │ +│ │ +│ FileWatcher ──→ 'team-change' ──→ renderer │ +│ (v7: TWO new + httpServer.broadcast │ +│ watchers: v6: INSIDE wireFileWatcherEvents│ +│ teamsWatcher │ +│ + tasksWatcher) │ +│ │ +├──────────────────── SHARED ──────────────────────┤ +│ types/ipc.ts (IpcResult) │ +│ types/team.ts (interfaces, discriminated unions) │ +└───────────────────────────────────────────────────┘ +``` + +--- + +## Verification + +1. `pnpm typecheck` — типы компилируются (включая Tab discriminated union) +2. `pnpm dev` — Teams tab открывается, список / пустое состояние +3. Kanban: задачи по 5 колонкам из status + kanban-state +4. Click-to-move: select колонку → задача перемещается +5. Blocked tasks: визуальный индикатор + tooltip +6. Review: Approve/Request Changes badges +7. Messaging: отправка с delivery status (Sending → Sent/Error) +8. Live updates: изменение task-файла → UI обновляется (FileWatcher → team:change → store) +9. Team deletion: graceful redirect через flag (не setTimeout) +10. `pnpm lint:fix && pnpm format` +11. `pnpm test` — тесты не сломаны +12. `pnpm test:teams` — новые тесты проходят + +--- + +## Integration Checklist (v5 — по результатам end-to-end трассировки) + +> Агент протрассировал полный путь данных от файла до экрана. +> 12 точек интеграции с exact file:line references. + +### Existing Pattern (Session flow — reference): +``` +FileWatcher.emit('file-change') → src/main/services/infrastructure/FileWatcher.ts:552 +main/index.ts sends to renderer → src/main/index.ts:121 (webContents.send) +preload exposes onFileChange → src/preload/index.ts:334 +store listener triggers refresh → src/renderer/store/index.ts:208 +``` + +### Team Integration Points (ALL 12): + +| # | Что | Файл | Действие | +|---|-----|------|----------| +| 1 | IPC channel constants | `src/preload/constants/ipcChannels.ts` (EOF) | Добавить `TEAM_*` (6 flat `export const`, v7 #36) | +| 2 | Preload API methods | `src/preload/index.ts` (~line 461) | `teams: { getData, list, sendMessage, onTeamChange }` | +| 3 | Preload event listener | `src/preload/index.ts` (~line 463) | `ipcRenderer.on('team-change', ...)` | +| 4 | API TypeScript types | `src/shared/types/api.ts` (~line 416) | `TeamsAPI` interface + extend `ElectronAPI` | +| 5 | Handler module | `src/main/ipc/teams.ts` (NEW) | 3 functions: initialize, register, remove | +| 6 | Handler registration | `src/main/ipc/handlers.ts` (lines 19-98) | Import + initialize + register + remove | +| 7 | Event forwarding | `src/main/index.ts` (lines 105-139) | Wire 'team-change' like 'file-change' | +| 8 | FileWatcher emission | `src/main/services/infrastructure/FileWatcher.ts` | Emit 'team-change' events | +| 9 | Store slice creation | `src/renderer/store/slices/teamSlice.ts` (NEW) | Create TeamSlice | +| 10 | Store composition | `src/renderer/store/index.ts` (lines 32-48) | Import + compose | +| 11 | Store listener | `src/renderer/store/index.ts` (lines 208-349) | `api.teams?.onTeamChange()` | +| 12 | Store types | `src/renderer/store/types.ts` | Extend AppState with TeamSlice | + +### Critical Gotchas (из трассировки): +- Preload использует `contextBridge.exposeInMainWorld()` → все данные ДОЛЖНЫ быть JSON-serializable +- IPC channels — hardcoded strings, typos fail silently → использовать constants +- `registry.getActive()` в handlers → ServiceContext scope-aware +- Event broadcast: `.send()` (fire-forget), НЕ `.invoke()` (request-response) +- Cleanup функции onTeamChange → PUSH в cleanupFns array → return в useEffect + +### Verification Order: +```bash +1. grep -r 'teams:' src/preload/constants/ # channels exist +2. grep -r 'teams\.' src/preload/index.ts # API methods exist +3. grep -r 'team-change' src/main/ # event forwarded +4. grep -r 'onTeamChange' src/renderer/ # store listens +5. pnpm typecheck # types compile +6. pnpm dev → open team tab → change file # live update works +``` + +--- + +## Write-Path Safety: In-Process Mutex (v5) + +### Проблема +``` +Time IPC Call 1 IPC Call 2 File State +1 Read [A] - [A] +2 - Read [A] [A] +3 Write [A,B] - [A,B] +4 - Write [A,C] [A,C] ← B LOST! +``` + +### Решение: InboxWriteQueue (v5) +``` +Time IPC Call 1 IPC Call 2 File State +1 Acquire lock Wait... [A] +2 Read [A] Wait... [A] +3 Write [A,B] Wait... [A,B] +4 Release lock Acquire lock [A,B] +5 - Read [A,B] [A,B] +6 - Write [A,B,C] [A,B,C] ← OK! +``` + +### Scope: +- **In-process mutex**: Решает races между concurrent IPC calls (99% случаев) +- **Cross-process (CLI)**: НЕ решено, но verify + retry ловят потерянные messages +- **Future**: JSONL append-only формат (Phase 2) устраняет проблему полностью + +--- + +## Phase 2 (после MVP) + +- @dnd-kit drag-and-drop для kanban +- Auto-review mapping: completed tasks → 'review' column (v7 #41 deferred to Phase 2) +- reviewHistory + round-robin +- State machine для member status +- Inbox archival (>1000 сообщений) +- FileWatcher → generic WatcherRegistry (если 5-й watcher, v7 now has 4) +- File locking для inbox (если race >1/day) +- JSONL inbox format (eliminates read-modify-write entirely) +- Virtual scrolling для 50+ tasks (react-virtual) +- Keyboard shortcuts для kanban (Ctrl+M → move) +- Search/filter в kanban (by owner, status) +- SSH mode: "Last updated" timestamp + slower refresh +- Structured inbox message types (discriminated union для ActivityTimeline) +- Notifications: desktop + badge для новых сообщений +- Per-tab team data (Phase 2 — MVP uses global selectedTeamData) diff --git a/docs/team-management/kanban-design.md b/docs/team-management/kanban-design.md new file mode 100644 index 00000000..e0459668 --- /dev/null +++ b/docs/team-management/kanban-design.md @@ -0,0 +1,232 @@ +# Kanban Design + +## Flow + +``` +TODO → IN PROGRESS → DONE → [юзер] → REVIEW → [юзер] → APPROVED + ↑ | + └── Fix (error) ←──┘ +``` + +## Колонки + +| Колонка | Source | Кто двигает | Описание | +|---------|--------|-------------|----------| +| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя | +| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает | +| **DONE** | task.status = completed | Автоматически | Агент завершил | +| **REVIEW** | kanban-state.json | Юзер (drag-and-drop) | На проверке | +| **APPROVED** | kanban-state.json | Юзер (drag-and-drop) | Одобрено | + +--- + +## Kanban State Storage + +### Почему свой файл, а не task metadata + +Metadata в task-файлах может быть **перезаписан** агентом при TaskUpdate. Semantics (merge vs replace) НЕДОКУМЕНТИРОВАН. Если replace — наш kanbanColumn исчезнет. + +### Формат kanban-state.json + +Хранится в директории app config (не в `~/.claude/tasks/`). + +```json +{ + "teamName": "my-team", + "reviewers": ["agent-review-1"], + "tasks": { + "12": { + "column": "review", + "reviewStatus": "pending", + "reviewer": "agent-review-1", + "movedAt": "2026-02-17T15:30:00.000Z" + }, + "15": { + "column": "approved", + "movedAt": "2026-02-17T16:00:00.000Z" + }, + "18": { + "column": "review", + "reviewStatus": "error", + "reviewer": null, + "errorDescription": "Не обработан edge case с пустым массивом", + "movedAt": "2026-02-17T16:30:00.000Z" + } + } +} +``` + +### Column Mapping Logic + +``` +Task → Column: +1. kanban-state.tasks[task.id] exists? → use .column +2. task.status === "pending" → TODO +3. task.status === "in_progress" → IN PROGRESS +4. task.status === "completed" → DONE +5. task.status === "deleted" → не показываем +``` + +### GC kanban-state (важно: порядок операций) + +При очистке устаревших записей (задачи которых больше не существуют) **ОБЯЗАТЕЛЬНО** сначала полностью загрузить все tasks, и только потом запускать GC: + +```typescript +// ПРАВИЛЬНО: +const tasks = await getAllTasks(teamName); // 1. Сначала все tasks +const kanban = await getKanbanState(teamName); // 2. Затем kanban +const validIds = new Set(tasks.map(t => t.id)); +const cleaned = Object.fromEntries( + Object.entries(kanban.tasks).filter(([id]) => validIds.has(id)) +); // 3. Только потом GC + +// НЕПРАВИЛЬНО (race condition при startup): +const kanban = await getKanbanState(teamName); +gcStaleEntries(kanban); // ← task-файлы ещё не прочитаны, удалим валидные записи +const tasks = await getAllTasks(teamName); +``` + +--- + +## Review Flow + +### Перемещение DONE → REVIEW + +1. Юзер перетаскивает карточку из DONE в REVIEW +2. Проверяем `kanbanState.reviewers[]` +3. **Есть ревьюверы**: + - Берём первого свободного (round-robin с балансировкой по количеству активных ревью) + - Записываем в kanban-state: `{ column: "review", reviewStatus: "pending", reviewer: "agent-name" }` + - Отправляем inbox ревьюверу: + ```json + { + "from": "user", + "text": "Please review task #12: Rename package in pubspec.yaml\n\nDescription: Change name: dartdoc to name: dartdoc_vitepress", + "summary": "Review request for task #12", + "timestamp": "...", + "read": false + } + ``` +4. **Нет ревьюверов**: + - Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }` + - Юзер сам ревьювит через UI (кнопки OK / Error) + +### Review Result + +В UI каждая карточка в REVIEW показывает ReviewBadge: +- **Pending** (yellow) — ждёт ревью +- **Approved** (green) — проверено, всё хорошо → кнопка Approve → переходит в APPROVED +- **Changes Requested** (red) — найдены замечания → кнопка Request Changes с опциональным комментарием + +### Approve → APPROVED + +Юзер нажимает кнопку **Approve** на карточке в REVIEW: +- kanban-state: `{ column: "approved" }` + +### Request Changes → Fix + +1. Юзер нажимает кнопку **Request Changes** на карточке в REVIEW +2. Появляется ReviewDialog — textarea для описания проблемы (опционально) +3. Юзер нажимает "Отправить" +4. Действия: + - kanban-state: удаляем запись для этой задачи (вернётся в IN PROGRESS по status) + - task file: `status = "in_progress"` (atomic write) + - Inbox к исходному owner: + ```json + { + "from": "user", + "text": "Task #12 needs fixes:\n\nНе обработан edge case с пустым массивом\n\nPlease fix and mark as completed when done.", + "summary": "Fix request for task #12", + "timestamp": "...", + "read": false + } + ``` + +**Примечание**: `reviewHistory` (история раундов ревью) и round-robin балансировка ревьюверов — в Phase 2, не MVP. + +--- + +## MVP vs Phase 2 + +### MVP: Click-to-Move + +Для MVP вместо drag-and-drop используется **click-to-move**: каждая карточка имеет кнопку или select-dropdown для смены колонки. Это проще реализовать и достаточно для первой версии. + +``` +[Task Card] + Subject: Rename package in pubspec.yaml + Owner: worker-1 + [Move to: REVIEW ▼] ← dropdown или кнопка +``` + +Разрешённые переходы через click-to-move: +| Откуда → Куда | Действие | +|----------------|----------| +| DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть | +| REVIEW → APPROVED (Approve) | kanban-state: approved | +| REVIEW → DONE (Request Changes) | Dialog → task: in_progress, kanban: remove, inbox к owner | +| APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) | + +Не разрешено: +- TODO → IN PROGRESS (агент берёт сам через TaskUpdate) +- IN PROGRESS → DONE (агент завершает сам через TaskUpdate) + +### Phase 2: Полноценный D&D через @dnd-kit + +`@dnd-kit` уже есть в зависимостях проекта (используется для перетаскивания табов). В Phase 2 добавить drag-and-drop для всех разрешённых переходов. + +--- + +## Синхронизация при изменении task status + +Агент может поменять статус задачи пока мы показываем Kanban. Через file watcher мы узнаём об изменении. + +### Сценарии + +**Task в REVIEW, но agent поставил status = in_progress**: +- Конфликт: kanban-state говорит REVIEW, task file говорит in_progress +- Действие: показать warning badge на карточке + +**Task в REVIEW, agent поставил status = completed**: +- Агент завершил повторно (после fix) +- Действие: обновить reviewStatus на pending (новый раунд ревью) + +**Новая задача (status = pending)**: +- Нет записи в kanban-state +- Действие: автоматически в TODO + +--- + +## Reviewer Assignment + +### Конфигурация + +Список ревьюверов хранится в `kanban-state.json` → `reviewers: string[]`. + +Источники ревьюверов: +1. **Роль при создании команды**: юзер в промпте указывает "создай agent-review с ролью reviewer" +2. **В UI**: кнопка "Назначить ревьювером" рядом с участником в MemberList + +### Round-Robin с балансировкой + +```typescript +function pickReviewer(reviewers: string[], kanbanState: KanbanState): string | null { + if (reviewers.length === 0) return null; + + // Считаем активные ревью у каждого + const reviewCounts = new Map(); + for (const reviewer of reviewers) { + reviewCounts.set(reviewer, 0); + } + for (const task of Object.values(kanbanState.tasks)) { + if (task.column === 'review' && task.reviewStatus === 'pending' && task.reviewer) { + const count = reviewCounts.get(task.reviewer) || 0; + reviewCounts.set(task.reviewer, count + 1); + } + } + + // Берём того у кого меньше всего + return [...reviewCounts.entries()] + .sort((a, b) => a[1] - b[1])[0][0]; +} +``` diff --git a/docs/team-management/research-inbox.md b/docs/team-management/research-inbox.md new file mode 100644 index 00000000..b906545a --- /dev/null +++ b/docs/team-management/research-inbox.md @@ -0,0 +1,242 @@ +# Research: Inbox-файлы Claude Code + +## Формат + +**Путь**: `~/.claude/teams/{teamName}/inboxes/{memberName}.json` + +**Структура**: JSON-массив объектов (весь файл = массив) + +```json +[ + { + "from": "contracts-cleaner", + "text": "Готово. Вот что было удалено из packages/core/...", + "timestamp": "2026-02-09T17:12:32.316Z", + "color": "blue", + "read": true, + "summary": "Cleanup complete" + }, + { + "from": "team-lead", + "text": "{\"type\":\"shutdown_request\",\"from\":\"team-lead\",\"timestamp\":\"...\"}", + "timestamp": "2026-02-09T17:25:43.886Z", + "read": true + } +] +``` + +### Поля + +| Поле | Обязательно | Тип | Описание | +|------|:-----------:|-----|----------| +| `from` | YES | string | Имя отправителя (зарегистрированный agent) | +| `text` | YES | string | Текст сообщения или JSON-строка | +| `timestamp` | YES | string | ISO 8601 | +| `read` | YES | boolean | Прочитано ли Claude Code | +| `summary` | NO | string | Краткое описание для UI | +| `color` | NO | string | Цвет агента (blue, green, red, yellow, purple, cyan, orange, pink) | + +### text: plain vs structured + +`text` может быть: +- **Plain text**: обычное сообщение +- **JSON-строка**: структурированное сообщение (парсится из text) + +Типы структурированных: +``` +idle_notification — агент ушёл в idle (idleReason: "available") +shutdown_request — запрос на завершение работы +shutdown_approved — подтверждение завершения +message — обычное DM (content, summary) +task_completed — задача завершена (taskId) +``` + +### read поведение + +- Claude Code ставит `read: true` после обработки +- Сообщения НЕ УДАЛЯЮТСЯ — остаются навечно +- Нет автоматической чистки inbox +- 106 сообщений = ~256 KB + +--- + +## Race Condition (КРИТИЧНЫЙ РИСК) + +### Сценарий + +``` +T=0ms: App читает inbox [msg1, msg2] +T=1ms: Claude Code читает inbox [msg1, msg2] +T=5ms: App пишет [msg1, msg2, msg3_app] +T=6ms: Claude Code пишет [msg1, msg2, msg3_agent] +→ msg3_app ПОТЕРЯНО +``` + +### Почему происходит + +Inbox — JSON-массив. Append = read whole array → add element → write whole file. Два процесса читают одну версию, каждый добавляет своё, последний перезаписывает первого. + +### Вероятность + +**НИЗКАЯ** — записи в inbox происходят нечасто: +- Юзер шлёт 1-2 сообщения в минуту +- Агенты шлют idle_notification раз в 5-30 секунд +- Коллизия = оба пишут в ОДИН файл в пределах ~10ms + +### Митигация + +1. **Atomic write** (предотвращает partial writes): +```typescript +const tmpPath = targetPath + '.tmp.' + process.pid; +fs.writeFileSync(tmpPath, JSON.stringify(messages, null, 2)); +fs.renameSync(tmpPath, targetPath); // atomic на macOS/Linux +``` + +2. **Retry с проверкой** (обнаруживает потерю): +```typescript +// После записи — перечитать и проверить +const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); +const found = written.some(m => m.messageId === ourMessageId); +if (!found) { + // Наше сообщение потеряно — retry + await appendToInbox(inboxPath, message); +} +``` + +3. **Уникальный messageId**: добавлять `messageId: uuid()` в наши сообщения + +4. **Debounce**: не писать чаще раз в 500ms + +### Что НЕ решает + +Atomic write предотвращает corrupted JSON, но НЕ предотвращает overwrite race. Retry с проверкой — best effort, но не 100%. + +--- + +## Доставка сообщений + +### Между turns, НЕ real-time + +``` +Цикл тиммейта: +1. Читает inbox (видит новые сообщения с read: false) +2. Обрабатывает +3. Вызывает инструменты (Bash, Edit, Read...) +4. Turn заканчивается → шлёт idle_notification → IDLE +5. Ждёт... +6. Новый turn → читает inbox +``` + +**Задержка**: 1-30 секунд (зависит от длительности turn) + +### Нельзя прервать mid-turn + +Если агент уже вызвал Edit/Bash — инструмент будет выполнен. Сообщение "стоп, не трогай файл X" придёт ПОСЛЕ. + +### Idle → Active + +Сообщение idle-агенту пробуждает его при следующем цикле проверки inbox. + +### Hard Interrupt (будущее) + +Возможные подходы: +- `kill -SIGINT` процесса (жёсткое) +- Файловый flag `.interrupt-{member}` +- Ждать API от Anthropic + +--- + +## from: "user" — валидация + +### Факты + +- В реальных inbox-файлах видны ТОЛЬКО зарегистрированные agent names +- Нет примеров `from: "user"` в 256+ KB данных +- Неизвестно, валидирует ли Claude Code поле `from` по config.json members + +### Решение + +Пробуем `from: "user"`. Если агент не получает сообщение → fallback на `from: "team-lead"` (всегда есть в config.json). + +### Тест + +При первой реализации: +1. Запустить команду с 1 тиммейтом +2. Записать сообщение с `from: "user"` в inbox тиммейта +3. Проверить — получит ли тиммейт сообщение +4. Если нет — повторить с `from: "team-lead"` + +--- + +## Размер и масштабирование + +| Метрика | Значение | +|---------|----------| +| Размер сообщения | ~2.4 KB | +| 100 сообщений | ~240 KB | +| 1000 сообщений | ~2.4 MB | +| Парсинг 1000 сообщений | <10ms | +| Реальный inbox (106 msgs) | 256 KB | + +Проблема начнётся при 10000+ сообщений — JSON.parse будет заметно медленнее. Для долгоживущих команд нужна архивация. + +--- + +## Финальное решение (после 3 раундов ревью) + +### Подход: Atomic write + messageId verify + +Выбрана комбинация atomic write с постфактум-верификацией через `messageId`: + +```typescript +// 1. Генерируем уникальный ID для нашего сообщения +const messageId = crypto.randomUUID(); +const message: InboxMessage = { + from: 'user', + text, + timestamp: new Date().toISOString(), + read: false, + summary, + messageId, +}; + +// 2. Читаем текущий inbox +const existing = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + +// 3. Добавляем сообщение +const updated = [...existing, message]; + +// 4. Atomic write (tmp + rename) +const tmpPath = inboxPath + '.tmp.' + process.pid; +fs.writeFileSync(tmpPath, JSON.stringify(updated, null, 2)); +fs.renameSync(tmpPath, inboxPath); + +// 5. Verify: перечитываем и проверяем messageId +const written = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); +const found = written.some((m: InboxMessage) => m.messageId === messageId); +if (!found) { + // Потеря обнаружена — показать warning в UI, не silent fail + throw new Error(`Message ${messageId} lost during write`); +} +``` + +### Решения по итогам ревью + +- **Полный CAS не нужен на MVP**: verify при следующем read достаточен для обнаружения потерь +- **messageId проверяется сразу после записи** (а не только при следующем read) +- **Не silent fail**: если сообщение потеряно — UI показывает предупреждение пользователю +- **Retry не автоматический**: потеря крайне редка, ручная отправка достаточна на MVP + +### Риски + +| Риск | Вероятность | Митигация | +|------|-------------|-----------| +| Race condition: агент пишет одновременно | Низкая | Atomic write + verify | +| Потеря при race | Очень низкая | messageId verify → warning в UI | +| Corrupted JSON | Практически 0 | Atomic write (tmp + rename) | + +### Что не входит в MVP + +- Автоматический retry при потере (добавить в Phase 2 при необходимости) +- Debounce записи (не нужен при редкой записи) +- Полный CAS с блокировкой (избыточно для данной частоты записей) diff --git a/docs/team-management/research-messaging.md b/docs/team-management/research-messaging.md new file mode 100644 index 00000000..1410f44f --- /dev/null +++ b/docs/team-management/research-messaging.md @@ -0,0 +1,189 @@ +# Research: Подходы к отправке сообщений тиммейтам + +## Сравнение 3 подходов + +| Критерий | Inbox-файлы | Agent SDK | CLI subprocess | +|----------|:-----------:|:---------:|:--------------:| +| Скорость | ~5ms | ~12с | 10-15с | +| Стоимость | $0 | $0.01-0.08/msg | токены | +| Работает с запущенными | **YES** | NO | NO | +| Прерывает mid-turn | NO | NO | NO | +| Требует API ключ | NO | YES | NO | +| Расход памяти | 0 | 0 | 100-320MB | + +--- + +## 1. Inbox-файлы (ВЫБРАНО) + +### Как работает + +Прямая запись JSON в файл `~/.claude/teams/{team}/inboxes/{member}.json`. Claude Code мониторит эти файлы через fs.watch и доставляет сообщения агентам между turns. + +### Плюсы + +- **Мгновенная запись** (~5ms) +- **$0** — никаких API вызовов +- **Единственный** способ общаться с запущенными тиммейтами +- Работает с idle и active агентами (но доставка между turns) + +### Минусы + +- Race condition при одновременной записи (см. [research-inbox.md](./research-inbox.md)) +- Формат недокументирован (internal API) +- Доставка между turns, не real-time +- from: "user" может не работать + +### Формат сообщения + +```json +{ + "from": "user", + "text": "Не трогай файл auth.ts, я его сам изменю", + "timestamp": "2026-02-17T15:30:00.000Z", + "read": false, + "summary": "Do not modify auth.ts", + "messageId": "uuid-for-retry-check" +} +``` + +--- + +## 2. Agent SDK (ОТВЕРГНУТ) + +### Как работает + +```typescript +import Anthropic from '@anthropic-ai/sdk'; +const client = new Anthropic(); +const response = await client.messages.create({ + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'Send message to teammate...' }], + tools: [/* SendMessage, TaskUpdate, etc. */] +}); +``` + +### Почему отвергнут + +1. **Создаёт НОВУЮ сессию** — не подключается к работающему тиммейту. SendMessage и TaskCreate — это инструменты модели, не программные вызовы +2. **~12 секунд** на каждый вызов (полный API round-trip) +3. **Стоит токены** — $0.01-0.08 за сообщение +4. **Нужен API ключ** — отдельная оплата, а не подписка Claude + +### Когда может пригодиться + +- Создание новых команд программно +- Автоматизация workflow (вне real-time UI) + +--- + +## 3. CLI subprocess (ОТВЕРГНУТ) + +### Как работает + +```bash +claude --message "Send message to teammate-1: stop working on X" +``` + +### Почему отвергнут + +1. **Новый процесс** — не инжектится в работающего тиммейта +2. **10-15 секунд** холодный старт +3. **100-320MB памяти** на процесс +4. Каждый вызов стоит токены + +--- + +## Доставка: Timing и ограничения + +### Цикл тиммейта + +``` +Turn N: + 1. Читает inbox → видит новые (read: false) + 2. Обрабатывает сообщения/задачи + 3. Вызывает инструменты + 4. Reasoning + 5. Output + → idle_notification → IDLE + +... ожидание ... + +Turn N+1: + 1. Пробуждение (новое сообщение в inbox / назначение задачи) + 2. Читает inbox → видит новые + ... +``` + +### Задержка + +- **Idle agent**: получит при следующем пробуждении (доли секунды если inbox-change triggers) +- **Active agent (mid-turn)**: получит только после завершения текущего turn (1-30 секунд) + +### Нельзя прервать + +Если агент уже вызвал Edit/Bash — инструмент выполнится. Наше сообщение придёт ПОСЛЕ. + +**Пример**: +``` +17:12:30 — Agent начинает Edit на auth.ts +17:12:31 — Мы шлём "Не трогай auth.ts" +17:12:32 — Agent завершает Edit (auth.ts изменён) +17:12:33 — Agent читает inbox, видит наше сообщение +→ Поздно, файл уже изменён +``` + +### Hard Interrupt (будущее) + +Возможные подходы: +1. **kill -SIGINT** процесса тиммейта (жёсткое прерывание, потеря контекста) +2. **Файловый flag** `.interrupt-{member}` (нужна поддержка в Claude Code) +3. **API от Anthropic** (если появится) + +Текущее решение: задержка приемлема, hard interrupt — в будущем. + +--- + +## Финальное решение (после 3 раундов ревью) + +### Поле from + +- Используем `from: "user"` — интуитивно и описывает источник +- Fallback `from: "team-lead"` если агент не реагирует (team-lead всегда есть в config.json members) +- Практический тест необходим при первой реализации (см. [research-inbox.md](./research-inbox.md)) + +### messageId — обязателен в каждом сообщении + +Каждое исходящее сообщение включает `messageId: crypto.randomUUID()`: + +```json +{ + "from": "user", + "text": "Please review task #12", + "timestamp": "2026-02-17T15:30:00.000Z", + "read": false, + "summary": "Review request for task #12", + "messageId": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +### Verify: проверка сразу после записи + +- После atomic write читаем inbox и ищем наш `messageId` +- Если не найден — потеря обнаружена → warning в UI (не silent fail) +- Не автоматический retry на MVP + +### 3 состояния offline-участника + +| Состояние | Условие | Отображение | +|-----------|---------|-------------| +| `ACTIVE` | idle < 5 минут | Зелёный dot | +| `IDLE` | idle > 5 минут | Жёлтый dot | +| `TERMINATED` | Получен `shutdown_response` с `approve: true` | Серый dot, "Завершён" | + +Определение состояния по timestamp последнего события в inbox (idle_notification, любое сообщение). TERMINATED — исключительно по явному `shutdown_response`. + +### Что не входит в MVP + +- Автоматический retry при потере сообщения +- `from: "user"` validation через config.json members (проверяем практически) +- Hard Interrupt (kill -SIGINT, файловый flag) — Phase 2 diff --git a/docs/team-management/research-tasks.md b/docs/team-management/research-tasks.md new file mode 100644 index 00000000..cebfac57 --- /dev/null +++ b/docs/team-management/research-tasks.md @@ -0,0 +1,158 @@ +# Research: Task-файлы Claude Code + +## Формат + +**Путь**: `~/.claude/tasks/{teamName}/{id}.json` + +```json +{ + "id": "48", + "subject": "Rename package in pubspec.yaml", + "description": "Change name: dartdoc to name: dartdoc_vitepress", + "activeForm": "Updating pubspec.yaml", + "owner": "senior-1-rename", + "status": "completed", + "blocks": [], + "blockedBy": [], + "metadata": { "_internal": true } +} +``` + +### Поля + +| Поле | Обязательно | Тип | Описание | +|------|:-----------:|-----|----------| +| `id` | YES | string | Числовой ID в виде строки ("1", "48") | +| `subject` | YES | string | Краткий заголовок задачи | +| `description` | NO | string | Детальное описание | +| `activeForm` | NO | string | Present continuous для спиннера ("Updating...") | +| `owner` | NO | string | Имя агента-владельца | +| `status` | YES | string | pending / in_progress / completed / deleted | +| `blocks` | NO | string[] | ID задач, которые зависят от этой | +| `blockedBy` | NO | string[] | ID задач, от которых зависит эта | +| `metadata` | NO | object | Произвольный объект | + +### Статусы + +``` +pending — задача создана, ждёт исполнителя +in_progress — агент работает над задачей +completed — агент завершил +deleted — задача удалена +``` + +--- + +## .highwatermark + +**Путь**: `~/.claude/tasks/{teamName}/.highwatermark` +**Содержимое**: число (последний выданный ID) + +### Как используется + +``` +1. TaskCreate → читает .highwatermark (например, "48") +2. Новый ID = 49 +3. Создаёт 49.json +4. Записывает "49" в .highwatermark +``` + +### Риск при внешней записи + +Если мы создаём задачу с ID=50, но .highwatermark = 48: +- Следующий TaskCreate создаст 49.json (ОК) +- Ещё один — 50.json → **ПЕРЕЗАПИШЕТ нашу задачу** + +**Решение**: Не создавать задачи напрямую. Мы только ЧИТАЕМ задачи и модифицируем status/metadata. + +--- + +## .lock файл + +**Путь**: `~/.claude/tasks/{teamName}/.lock` +**Содержимое**: пустой файл (0 байт) + +### Поведение + +- Обновляется (touch) при обращении к tasks +- Механизм блокировки НЕДОКУМЕНТИРОВАН +- Вероятно используется как advisory lock (наличие = кто-то работает) + +### Наш подход + +Не трогаем .lock. Он для Claude Code. Мы делаем atomic write (tmp + rename) что безопасно без lock. + +--- + +## Видимость для агентов + +| Инструмент | Видит status | Видит metadata | Видит owner | Видит blockedBy | +|------------|:---:|:---:|:---:|:---:| +| TaskList | YES | **NO** | YES | YES | +| TaskGet | YES | YES | YES | YES | +| TaskUpdate | Меняет | Меняет | Меняет | Меняет | + +**Следствие**: Агент через TaskList НЕ увидит наш kanbanColumn в metadata. Поэтому хранить kanban-состояние в metadata бессмысленно для агентов, и рискованно (может быть перезаписано). + +--- + +## metadata: merge vs replace + +### Проблема + +**НЕДОКУМЕНТИРОВАНО.** Неизвестно что происходит при: +```typescript +// Текущее состояние: +{ metadata: { kanbanColumn: "REVIEW", _internal: true } } + +// Агент делает TaskUpdate: +TaskUpdate({ metadata: { owner_note: "done" } }) + +// Результат ??? +// MERGE: { kanbanColumn: "REVIEW", _internal: true, owner_note: "done" } +// REPLACE: { owner_note: "done" } ← kanbanColumn ПОТЕРЯНО +``` + +### Решение + +Не полагаемся на metadata. Kanban-состояние храним в собственном файле `kanban-state.json`. + +--- + +## Конкурентный доступ + +### Сценарий + +``` +T=0: App читает task.json: { status: "completed", metadata: {} } +T=1: Agent читает тот же файл: { status: "completed", metadata: {} } +T=5: App пишет: { status: "in_progress", metadata: {} } (Fix) +T=6: Agent пишет: { status: "completed", metadata: { note: "done" } } +→ Наш status: "in_progress" ПОТЕРЯН +``` + +### Вероятность + +**СРЕДНЯЯ** — мы пишем в task только при Fix (status → in_progress). Но агент может одновременно обновлять тот же файл. + +### Митигация + +1. **Atomic write** (tmp + rename) +2. **Verify after write**: перечитать файл, проверить что status = наш +3. **File watcher**: если status изменился обратно → показать warning в UI + +### Что пишем в task-файлы + +Минимум — только `status` при Fix: +```typescript +// Читаем текущий task +const task = JSON.parse(fs.readFileSync(taskPath)); +// Меняем только status +task.status = 'in_progress'; +// Atomic write +const tmp = taskPath + '.tmp.' + process.pid; +fs.writeFileSync(tmp, JSON.stringify(task, null, 2)); +fs.renameSync(tmp, taskPath); +``` + +Всё остальное (kanbanColumn, reviewStatus) — в нашем kanban-state.json. diff --git a/docs/team-management/research-worktrees.md b/docs/team-management/research-worktrees.md new file mode 100644 index 00000000..c0ae3d53 --- /dev/null +++ b/docs/team-management/research-worktrees.md @@ -0,0 +1,254 @@ +# Research: Git Worktrees + Agent Teams + Process Launch + +## Статус: Phase 2 (после базовой реализации Team Management) + +--- + +## ЧАСТЬ 1: Текущая ситуация + +### Agent Teams работают в ОДНОМ каталоге + +Все тиммейты шарят один `cwd` — нет встроенной поддержки worktrees для команд. + +``` +Lead + Teammate-1 + Teammate-2 → все в /project/ +→ Два агента могут редактировать один файл → перезапись +→ "Решение" Claude Code: лид вручную назначает разные файлы разным агентам +``` + +### Worktrees в Claude Code — отдельный механизм + +Worktrees существуют как способ **ручного параллелизма пользователя**: +```bash +git worktree add ../project-feature-a -b feature-a +cd ../project-feature-a +claude # Новая независимая сессия +``` + +Это НЕ связано с Agent Teams. Каждый worktree = отдельный процесс claude, никакой координации. + +### У Task tool нет параметра cwd + +При спавне тиммейта через Task нельзя указать рабочую директорию. Тиммейт наследует `cwd` лидера. + +--- + +## ЧАСТЬ 2: Существующая инфраструктура в claude-devtools + +### Уже реализовано (можно переиспользовать) + +| Компонент | Файл | Что делает | +|-----------|------|-----------| +| `GitIdentityResolver` | `src/main/services/parsing/GitIdentityResolver.ts` | Определяет worktree vs main repo, извлекает branch, remote URL | +| `WorktreeGrouper` | `src/main/services/discovery/WorktreeGrouper.ts` | Группирует проекты по git repository | +| `WorktreeBadge` | `src/renderer/components/common/WorktreeBadge.tsx` | UI badge для типа worktree (8 типов) | +| `repositorySlice` | `src/renderer/store/slices/repositorySlice.ts` | Store: repos, worktrees, grouped/flat view | +| `worktreePatterns` | `src/main/constants/worktreePatterns.ts` | 8 типов: vibe-kanban, conductor, auto-claude, 21st, claude-desktop, ccswitch, git, unknown | +| `SidebarHeader` | `src/renderer/components/layout/SidebarHeader.tsx` | Dropdown с repo → worktree навигацией | + +### config.json содержит cwd у members + +```json +{ + "members": [ + { + "name": "team-lead", + "cwd": "/Users/belief/dev/projects/modularity" // Все members имеют ОДИНАКОВЫЙ cwd + } + ] +} +``` + +Поле есть, но все members указывают одну директорию. + +--- + +## ЧАСТЬ 3: Идея — Запуск Claude Process из UI + +### Суть + +Пользователь хочет не просто **просматривать** команды, но и **запускать** Claude Code процессы прямо из Electron UI. С предконфигурацией: + +1. Выбрать рабочую директорию (может быть worktree) +2. Настроить agent teams заранее +3. Запустить claude процесс с нужными параметрами +4. Worktree создаётся автоматически перед запуском + +### Сценарии использования + +#### Сценарий 1: Запуск одиночного Claude + +``` +UI: [Выбрать директорию] → [Запустить Claude] +→ spawn('claude', [], { cwd: '/selected/path' }) +``` + +#### Сценарий 2: Запуск с предсозданным worktree + +``` +UI: [Выбрать репо] → [Создать worktree для branch X] → [Запустить Claude в worktree] +→ git worktree add ../project-branch-x -b branch-x +→ spawn('claude', [], { cwd: '../project-branch-x' }) +``` + +#### Сценарий 3: Запуск команды с worktrees для каждого тиммейта + +``` +UI: [Настроить команду] + - Lead: /project (main) + - Teammate-1: /project-wt1 (branch auth) + - Teammate-2: /project-wt2 (branch api) + +→ git worktree add ../project-wt1 -b auth +→ git worktree add ../project-wt2 -b api +→ spawn('claude', ['--prompt', 'Create team... Teammate-1 works in /project-wt1...'], { cwd: '/project' }) +``` + +#### Сценарий 4: Worktree per-task + +``` +UI: [Создать задачу] → [Auto-create worktree] → [Assign to teammate] +→ git worktree add ../project-task-15 -b task-15 +→ Prompt: "For task #15, work in /project-task-15" +``` + +### Технические вопросы + +#### Как запустить claude из Electron? + +```typescript +import { spawn } from 'child_process'; + +const claude = spawn('claude', ['--prompt', initialPrompt], { + cwd: workingDirectory, + env: { + ...process.env, + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', // Если нужны teams + }, + stdio: ['pipe', 'pipe', 'pipe'], +}); + +// Слушаем stdout для отслеживания прогресса? +claude.stdout.on('data', (data) => { /* ... */ }); +``` + +**Вопросы**: +- claude запускается как interactive CLI. Можно ли передать начальный промпт через args? +- Есть ли headless/non-interactive режим? (`claude --message "..."` — да, но одноразовый) +- Как мониторить процесс после запуска? +- Как связать запущенный процесс с нашим UI? + +#### Claude CLI аргументы (известные) + +```bash +claude # Interactive REPL +claude "prompt here" # One-shot (non-interactive? needs check) +claude --resume # Resume session +claude --continue # Continue last session +claude -p "prompt" # Print mode (non-interactive, output to stdout) +``` + +**Нужно исследовать**: +- Как запустить interactive сессию с начальным промптом +- Можно ли передать agent teams конфигурацию через аргументы +- Как получить session ID запущенного процесса + +#### Worktree lifecycle + +``` +Создание: git worktree add -b +Удаление: git worktree remove +Список: git worktree list +``` + +**Вопросы**: +- Когда удалять worktree? После завершения задачи/команды? +- Как мержить изменения из worktree обратно в main? +- Что если worktree "зависает" (процесс упал)? + +--- + +## ЧАСТЬ 4: Варианты архитектуры + +### A: Prompt Injection (простой, хрупкий) + +``` +1. UI создаёт worktrees +2. В .claude/rules/worktrees.md: "Teammate X must cd to /path" +3. Агент (надеемся) делает cd +``` + +**Compliance**: ~70-80% +**Сложность**: низкая +**Риск**: агент забудет cd, или cd обратно + +### B: Отдельные сессии (надёжный, без Teams) + +``` +1. UI создаёт worktrees +2. Запускает N отдельных claude процессов в разных worktrees +3. Координация через наш UI (Kanban, inbox messaging) +4. Нет встроенных Teams инструментов +``` + +**Isolation**: 100% +**Сложность**: высокая (наша координация вместо Teams) +**Плюс**: полный контроль + +### C: Гибрид (3 режима) + +``` +shared — один каталог (текущее поведение Teams) +per-teammate — worktree на каждого тиммейта +per-task — worktree на каждую задачу +``` + +Юзер выбирает режим при создании команды. UI создаёт worktrees и инжектирует правила. + +### D: Только визуализация (безопасный) + +``` +Не управляем worktrees +Только показываем какой тиммейт в каком каталоге +Группировка через repositorySlice +``` + +--- + +## ЧАСТЬ 5: Открытые вопросы для Phase 2 + +1. **Как запустить claude с начальным промптом из Electron?** + - Нужно исследовать CLI аргументы подробнее + - Есть ли способ передать initial team config? + +2. **Можно ли контролировать cwd тиммейта?** + - Через промпт lead'а: "spawn teammate in /path/to/worktree" + - Через config.json manipulation? + - Compliance? + +3. **Worktree cleanup** + - Автоматическое удаление при завершении команды? + - UI кнопка "Cleanup worktrees"? + - Что делать с uncommitted changes? + +4. **Merge strategy** + - Как мержить worktree branches обратно? + - Кнопка "Merge all" в UI? + - Conflict resolution? + +5. **Process monitoring** + - Как отслеживать запущенный claude процесс? + - Как получить его session ID? + - Как определить что процесс завершился? + +6. **Integration с Team Management** + - Worktree mode как часть team creation flow? + - Или отдельная фича? + - Как связать worktree с конкретным тиммейтом/задачей? + +--- + +## Решение + +**Phase 2** — после базовой реализации Team Management (Kanban, messaging, members). +Требует дополнительного ресёрча по claude CLI аргументам и process management.