# 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<Tab, 'id' | 'createdAt'>` с 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<T>()` утилита | 15 slices вызывают api напрямую |
| **Line limits** | ≤100 строк/класс | Без строгих лимитов, избегать 300-400+ | Прагматизм |

### IpcResult<T> — дедупликация (без изменений из v3)

Тип дублируется: `ConfigResult<T>` в config.ts и `IpcResult<T>` в preload/index.ts.
Вынести в `@shared/types/ipc.ts` — единый источник правды.

---

## Порядок реализации

### Справочник шагов (Steps)

```
Phase 0: Подготовка
  0.1  IpcResult<T> дедупликация → @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<T>() (ИСПРАВЛЕН: без 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<T> дедупликация (без изменений)

**Create** `src/shared/types/ipc.ts`

```typescript
export interface IpcResult<T = void> {
  success: boolean;
  data?: T;
  error?: string;
}
```

**Modify** `src/shared/types/index.ts` — `export type { IpcResult } from './ipc';`
**Modify** `src/main/ipc/config.ts` — удалить `ConfigResult<T>`, импортировать `IpcResult<T>` из `@shared/types`
**Modify** `src/preload/index.ts` — удалить `IpcResult<T>`, импортировать из `@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<string, unknown>;
}

// === Наши типы ===

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<string, KanbanTaskState>;
}

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<TeamSummary[]>;
  getConfig(teamName: string): Promise<TeamConfig | null>;
}

export interface ITeamTaskReader {
  getTasks(teamName: string): Promise<TeamTask[]>;
}

export interface ITeamInboxReader {
  getInboxNames(teamName: string): Promise<string[]>;
  getMessages(teamName: string): Promise<InboxMessage[]>;
  getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]>;
  sendMessage(
    teamName: string,
    member: string,
    msg: { from: string; text: string; summary?: string }
  ): Promise<SendMessageResult>;
}

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<KanbanState>;
  updateTaskState(teamName: string, taskId: string, state: Partial<KanbanTaskState>): Promise<void>;
  removeTaskState(teamName: string, taskId: string): Promise<void>;
  garbageCollect(teamName: string, existingTaskIds: Set<string>): Promise<void>;
}
```

#### TeamConfigReader

```typescript
export class TeamConfigReader implements ITeamConfigReader {
  constructor(private readonly teamsBasePath: string) {}

  async listTeams(): Promise<TeamSummary[]> {
    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<TeamConfig | null> {
    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<TeamTask[]> {
    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<string[]> { /* readdir inboxes/ */ }
  async getMessages(teamName: string): Promise<InboxMessage[]> { /* merge all, sort by timestamp */ }
  async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> { /* 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<SendMessageResult> {
    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<KanbanState> {
    // read kanban-state.json, return default if missing
  }

  async updateTaskState(teamName: string, taskId: string, state: Partial<KanbanTaskState>): Promise<void> {
    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<void> {
    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<string>): Promise<void> {
    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<void> {
  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 <T> to avoid closure tricks for return values.
 */
const inboxWriteLocks = new Map<string, Promise<void>>();

export async function withInboxLock<T>(
  inboxPath: string,
  fn: () => Promise<T>
): Promise<T> {
  // 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<void>(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<string, unknown>,
  maxRetries: number = 3
): Promise<string> {
  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<string, unknown>
): Promise<string> {
  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<void> {
  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<TeamSummary[]> {
    return this.configReader.listTeams();
  }

  async getTeamData(teamName: string): Promise<TeamData> {
    // 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<SendMessageResult> {
    return this.inboxReader.sendMessage(teamName, member, msg);
  }

  async updateKanban(
    teamName: string,
    taskId: string,
    state: Partial<KanbanTaskState>
  ): Promise<void> {
    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<void> {
    // 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<T>(
  operation: string,
  handler: () => Promise<T>
): Promise<IpcResult<T>> {
  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<IpcResult<TeamSummary[]>> {
  return wrapTeamHandler('list', () => getService().listTeams());
}

async function handleGetData(
  _event: IpcMainInvokeEvent,
  teamName: unknown
): Promise<IpcResult<TeamData>> {
  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<IpcResult<SendMessageResult>> {
  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<string> {
  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<string> {
  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<string> {
  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<T>() (ИСПРАВЛЕН: без 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<T>(
  operation: string,
  fn: () => Promise<T>
): Promise<T> {
  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<T>, 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<union> не дистрибутивен в TypeScript)
export type SessionTabInput = Omit<SessionTab, 'id' | 'createdAt'>;
export type TeamsTabInput = Omit<TeamsTab, 'id' | 'createdAt'>;
export type TeamTabInput = Omit<TeamTab, 'id' | 'createdAt'>;
export type DashboardTabInput = Omit<DashboardTab, 'id' | 'createdAt'>;
export type NotificationsTabInput = Omit<NotificationsTab, 'id' | 'createdAt'>;
export type SettingsTabInput = Omit<SettingsTab, 'id' | 'createdAt'>;
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<string, number>();

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<void>;
  selectTeam: (teamName: string) => Promise<void>;
  refreshTeamData: (teamName: string) => Promise<void>;
  sendTeamMessage: (member: string, text: string, summary?: string) => Promise<void>;
  moveTaskToColumn: (taskId: string, state: Partial<KanbanTaskState>) => Promise<void>;
  openTeamTab: (teamName: string) => void;
  openTeamsListTab: () => void;
  /** v4: cleanup generation map при закрытии tab */
  cleanupTeamState: (teamName: string) => void;
  clearTeamDeletedRedirect: () => void;
}

export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<typeof setTimeout> | 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) && <TeamView />}
  {isTeamTab(tab) && <TeamView teamName={tab.teamName} />}
  ```
  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<void>;
  onRequestReview: (taskId: string) => void;
}

// TeamDetailView пробрасывает:
<KanbanBoard
  tasks={selectedTeamData.tasks}
  kanbanState={selectedTeamData.kanbanState}
  onMoveTask={(taskId, column) => 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<void>;
  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<T> (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<T>)                     │
│  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)
