agent-ecosystem/docs/team-management/implementation.md
iliya e7d9e82ce8 feat(team-management): implement initial Team Management feature with Kanban support
- Added core documentation for the Team Management feature, including iteration goals, implementation plans, and design specifications.
- Introduced new files for team configuration, IPC channels, and UI components for team listing and Kanban board.
- Established a clear definition of done and scope for the first iteration, focusing on visible results and graceful degradation in the UI.
- Documented the messaging and task file formats, ensuring robust communication between team members.
- Implemented a Kanban design with defined states and transitions for task management.
2026-02-17 21:10:15 +02:00

85 KiB
Raw Blame History

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
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 — дедупликация (без изменений из 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 дедупликация (без изменений)

Create src/shared/types/ipc.ts

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

Modify src/shared/types/index.tsexport 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

// === Типы с диска (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

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)

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

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

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)

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)

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)

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)

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)

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)

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 — добавить в конец файла:

// =============================================================================
// 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

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

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: автоматически поддержан (бесплатно)
// 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.tsv6 FIX: wiring ВНУТРЬ wireFileWatcherEvents() (баг #25)

v6 FIX: Standalone setupTeamChangeForwarding() сломается при SSH context switch, потому что wireFileWatcherEvents() перезапускается для нового context, а standalone — нет. Интеграция внутрь wireFileWatcherEvents() гарантирует переподключение при смене context.

// В 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 глобально:

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 как параметр:

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

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:259visibleSessionTab?.projectId → narrow first
  3. TabBar.tsx:213activeTab.projectId && activeTab.sessionId → narrow
  4. TabBar.tsx:239-244contextMenuTab?.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

// 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

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:

// В 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.tsxteams: Users, team: Users in TAB_ICONS (v7: два типа)
  • PaneContent.tsx:
    // 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)

// Данные текут: 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)

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 (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:

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)