# Phase 3: Per-Task Change Scoping

## Цель
Точно определять какие файловые изменения принадлежат конкретной задаче (task). Текущий `findLogsForTask()` использует keyword search (~60% reliability). Phase 3 добавляет структурный парсинг `TaskUpdate` tool_use блоков для 95%+ reliability.

## Зависимости (npm)
Нет новых npm зависимостей. Используем только существующие: readline, fs/promises.

---

## Backend

### 1. Типы: `src/shared/types/review.ts` (MODIFY — дополнения к Phase 1+2)

```typescript
/** Обнаруженная граница задачи в JSONL */
export interface TaskBoundary {
  taskId: string;
  event: 'start' | 'complete';
  /** Номер строки в JSONL файле (для debug) */
  lineNumber: number;
  /** ISO timestamp из JSONL entry */
  timestamp: string;
  /** Каким механизмом обнаружено */
  mechanism: 'TaskUpdate' | 'teamctl'; // historical legacy mechanism
  /** tool_use id (для link к конкретному блоку) */
  toolUseId?: string;
}

/** Scope изменений для одной задачи */
export interface TaskChangeScope {
  taskId: string;
  /** Имя участника (owner) */
  memberName: string;
  /** Начало scope (строка JSONL или timestamp) */
  startLine: number;
  endLine: number;
  startTimestamp: string;
  endTimestamp: string;
  /** Все tool_use.id в пределах scope */
  toolUseIds: string[];
  /** Файлы затронутые в scope */
  filePaths: string[];
  /** Уровень уверенности */
  confidence: TaskScopeConfidence;
}

/** Детализированный уровень уверенности */
export interface TaskScopeConfidence {
  tier: 1 | 2 | 3 | 4;
  label: 'high' | 'medium' | 'low' | 'fallback';
  reason: string;
}

/** Результат парсинга всех границ задач из JSONL файла */
export interface TaskBoundariesResult {
  /** Все найденные границы, отсортированные по lineNumber */
  boundaries: TaskBoundary[];
  /** Scopes per task */
  scopes: TaskChangeScope[];
  /** True если сессия работала только с одной задачей */
  isSingleTaskSession: boolean;
  /** Механизм обнаружения (один на сессию — никогда не смешиваются!) */
  detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none'; // historical legacy mechanism in this design note
}

/** Расширенный TaskChangeSet с confidence деталями.
 *  TaskChangeSet определён в Phase 1 (review.ts) — backwards compatible extension.
 *  Все Phase 1 поля (teamName, taskId, files, confidence, computedAt) сохраняются.
 */
export interface TaskChangeSetV2 extends TaskChangeSet {
  scope: TaskChangeScope;
  /** Предупреждения для UI */
  warnings: string[];
}
```

### 2. Сервис: `src/main/services/team/TaskBoundaryParser.ts` (NEW)

**Задача**: Парсить JSONL файлы субагентов для извлечения `TaskUpdate` и исторических `teamctl` маркеров задач.

**Ключевой факт**: Механизмы НИКОГДА не смешиваются в одной сессии (0 из 351 проверенных). Это означает один pass по JSONL для определения механизма + extraction.

```typescript
import { createReadStream } from 'fs';
import * as readline from 'readline';

export class TaskBoundaryParser {
  private cache = new Map<string, { data: TaskBoundariesResult; mtime: number; expiresAt: number }>();
  private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе)

  /**
   * Парсит JSONL файл и извлекает все TaskUpdate/исторические teamctl маркеры.
   *
   * Один проход по файлу, O(n) по количеству строк.
   */
  async parseBoundaries(filePath: string): Promise<TaskBoundariesResult>;

  /**
   * Определяет scope изменений для конкретной задачи.
   *
   * Алгоритм:
   * 1. Найти все TaskBoundary для taskId
   * 2. Start boundary = TaskUpdate(in_progress) или historical teamctl(start)
   * 3. End boundary = TaskUpdate(completed) или historical teamctl(complete)
   * 4. Scope = все tool_use между start.lineNumber и end.lineNumber
   * 5. Если single-task session: scope = весь файл
   */
  async getTaskScope(filePath: string, taskId: string): Promise<TaskChangeScope | null>;
}
```

**Парсинг TaskUpdate (Mechanism A — 86% сессий):**

```typescript
// В assistant entry ищем tool_use блоки
// entry.message.content = ContentBlock[]
// где ContentBlock = { type: 'tool_use', name: 'TaskUpdate' | 'proxy_TaskUpdate', input: {...} }

private extractTaskUpdateBoundaries(
  content: unknown[],
  lineNumber: number,
  timestamp: string
): TaskBoundary[] {
  const boundaries: TaskBoundary[] = [];

  for (const block of content) {
    if (!block || typeof block !== 'object') continue;
    const b = block as Record<string, unknown>;

    if (b.type !== 'tool_use') continue;

    // Strip proxy_ prefix (паттерн из MemberStatsComputer)
    const rawName = typeof b.name === 'string' ? b.name : '';
    const toolName = rawName.replace(/^proxy_/, '');

    if (toolName !== 'TaskUpdate') continue;

    const input = b.input as Record<string, unknown> | undefined;
    if (!input) continue;

    const taskId = String(input.taskId ?? input.task_id ?? '');
    const status = String(input.status ?? '');

    if (!taskId) continue;

    // Map status → event
    let event: 'start' | 'complete' | null = null;
    if (status === 'in_progress') event = 'start';
    if (status === 'completed') event = 'complete';

    if (event) {
      boundaries.push({
        taskId,
        event,
        lineNumber,
        timestamp,
        mechanism: 'TaskUpdate',
        toolUseId: typeof b.id === 'string' ? b.id : undefined,
      });
    }
  }

  return boundaries;
}
```

**Парсинг teamctl Bash (Mechanism B — 12.5% сессий):**

```typescript
// В assistant entry ищем tool_use с name='Bash' или 'proxy_Bash'
// input.command содержит teamctl вызов

private readonly TEAMCTL_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;

private extractTeamctlBoundaries(
  content: unknown[],
  lineNumber: number,
  timestamp: string
): TaskBoundary[] {
  const boundaries: TaskBoundary[] = [];

  for (const block of content) {
    if (!block || typeof block !== 'object') continue;
    const b = block as Record<string, unknown>;
    if (b.type !== 'tool_use') continue;

    const rawName = typeof b.name === 'string' ? b.name : '';
    const toolName = rawName.replace(/^proxy_/, '');

    if (toolName !== 'Bash') continue;

    const input = b.input as Record<string, unknown> | undefined;
    const command = typeof input?.command === 'string' ? input.command : '';

    if (!command.includes('teamctl')) continue;

    const match = command.match(this.TEAMCTL_REGEX);
    if (!match) continue;

    const [, action, taskId] = match;
    let event: 'start' | 'complete' | null = null;
    if (action === 'start') event = 'start';
    if (action === 'complete') event = 'complete';
    // set-status может быть start или complete — нужно дополнительно парсить аргумент
    if (action === 'set-status') {
      if (command.includes('in_progress')) event = 'start';
      if (command.includes('completed')) event = 'complete';
    }

    if (event) {
      boundaries.push({
        taskId,
        event,
        lineNumber,
        timestamp,
        mechanism: 'teamctl',
        toolUseId: typeof b.id === 'string' ? b.id : undefined,
      });
    }
  }

  return boundaries;
}
```

**Основной проход парсинга:**

```typescript
async parseBoundaries(filePath: string): Promise<TaskBoundariesResult> {
  // Check cache (S2 fix: проверяем И TTL И mtime файла)
  const stat = await import('fs/promises').then(f => f.stat(filePath));
  const cached = this.cache.get(filePath);
  if (cached && cached.mtime === stat.mtimeMs && cached.expiresAt > Date.now()) {
    return cached.data;
  }

  const boundaries: TaskBoundary[] = [];
  const allToolUsesByLine = new Map<number, { toolUseId: string; toolName: string; filePath?: string }[]>();
  let lineNumber = 0;
  let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none' = 'none';

  const stream = createReadStream(filePath, { encoding: 'utf8' });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

  for await (const line of rl) {
    lineNumber++;
    const trimmed = line.trim();
    if (!trimmed) continue;

    try {
      const entry = JSON.parse(trimmed) as Record<string, unknown>;
      const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : '';

      // Extract content array
      const content = this.extractContent(entry);
      if (!Array.isArray(content)) continue;

      // Collect ALL tool_use blocks (for scope tracking)
      for (const block of content) {
        if (!block || typeof block !== 'object') continue;
        const b = block as Record<string, unknown>;
        if (b.type !== 'tool_use') continue;

        const rawName = typeof b.name === 'string' ? b.name : '';
        const toolName = rawName.replace(/^proxy_/, '');
        const toolUseId = typeof b.id === 'string' ? b.id : '';
        const input = b.input as Record<string, unknown> | undefined;
        const fp = typeof input?.file_path === 'string' ? input.file_path : undefined;

        if (!allToolUsesByLine.has(lineNumber)) allToolUsesByLine.set(lineNumber, []);
        allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
      }

      // Try TaskUpdate extraction
      const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp);
      if (taskUpdateBounds.length > 0) {
        detectedMechanism = 'TaskUpdate';
        boundaries.push(...taskUpdateBounds);
        continue; // Skip teamctl check (never mixed)
      }

      // Try teamctl extraction
      const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
      if (teamctlBounds.length > 0) {
        detectedMechanism = 'teamctl';
        boundaries.push(...teamctlBounds);
      }
    } catch {
      // Skip malformed lines
    }
  }

  rl.close();
  stream.destroy();

  // Determine scopes from boundaries
  const scopes = this.computeScopes(boundaries, allToolUsesByLine, lineNumber);
  const uniqueTaskIds = new Set(boundaries.map(b => b.taskId));
  const isSingleTaskSession = uniqueTaskIds.size <= 1;

  const result: TaskBoundariesResult = {
    boundaries,
    scopes,
    isSingleTaskSession,
    detectedMechanism,
  };

  this.cache.set(filePath, { data: result, mtime: stat.mtimeMs, expiresAt: Date.now() + this.CACHE_TTL });
  return result;
}
```

**Вычисление scopes:**

```typescript
private computeScopes(
  boundaries: TaskBoundary[],
  allToolUses: Map<number, { toolUseId: string; toolName: string; filePath?: string }[]>,
  totalLines: number
): TaskChangeScope[] {
  // Группируем по taskId
  const byTask = new Map<string, TaskBoundary[]>();
  for (const b of boundaries) {
    if (!byTask.has(b.taskId)) byTask.set(b.taskId, []);
    byTask.get(b.taskId)!.push(b);
  }

  const scopes: TaskChangeScope[] = [];

  for (const [taskId, taskBounds] of byTask) {
    const starts = taskBounds.filter(b => b.event === 'start').sort((a, b) => a.lineNumber - b.lineNumber);
    const ends = taskBounds.filter(b => b.event === 'complete').sort((a, b) => a.lineNumber - b.lineNumber);

    let startLine: number;
    let endLine: number;
    let confidence: TaskScopeConfidence;

    if (starts.length > 0 && ends.length > 0) {
      // Tier 1: Оба маркера найдены
      startLine = starts[0].lineNumber;
      endLine = ends[ends.length - 1].lineNumber;
      confidence = {
        tier: 1,
        label: 'high',
        reason: `Found ${starts.length} start + ${ends.length} complete markers via ${starts[0].mechanism}`,
      };
    } else if (starts.length > 0) {
      // Tier 2: Только start (задача ещё не завершена или маркер потерян)
      startLine = starts[0].lineNumber;
      endLine = totalLines;
      confidence = {
        tier: 2,
        label: 'medium',
        reason: `Found start marker but no completion. Using end of file.`,
      };
    } else if (ends.length > 0) {
      // Tier 3: Только end (start потерян)
      startLine = 1;
      endLine = ends[ends.length - 1].lineNumber;
      confidence = {
        tier: 3,
        label: 'low',
        reason: `Found completion marker but no start. Using beginning of file.`,
      };
    } else {
      // Tier 4: Нет маркеров (не должно случаться если boundaries найдены)
      continue;
    }

    // Collect tool_use IDs in range
    const toolUseIds: string[] = [];
    const filePaths = new Set<string>();

    for (const [line, tools] of allToolUses) {
      if (line >= startLine && line <= endLine) {
        for (const t of tools) {
          // Только file-modifying tools
          if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(t.toolName)) {
            toolUseIds.push(t.toolUseId);
            if (t.filePath) filePaths.add(t.filePath);
          }
        }
      }
    }

    scopes.push({
      taskId,
      memberName: '', // Заполняется вызывающим кодом из member attribution
      startLine,
      endLine,
      startTimestamp: starts[0]?.timestamp ?? ends[0]?.timestamp ?? '',
      endTimestamp: ends[ends.length - 1]?.timestamp ?? starts[starts.length - 1]?.timestamp ?? '',
      toolUseIds,
      filePaths: [...filePaths],
      confidence,
    });
  }

  return scopes;
}
```

**extractContent helper (паттерн из MemberStatsComputer + FIXED для всех JSONL форматов):**

```typescript
// JSONL файлы содержат СМЕШАННЫЕ форматы entries:
// 1. Subagent assistant: entry.message.content = ContentBlock[]
// 2. Main assistant: entry.content = ContentBlock[]
// 3. tool_result entries: entry.content = string | ContentBlock[]
// 4. Meta entries (file-history-snapshot): нет content с tool_use
//
// TaskUpdate блоки находятся ТОЛЬКО в assistant entries (1, 2),
// поэтому проверяем оба формата.

private extractContent(entry: Record<string, unknown>): unknown[] | null {
  // Subagent format: entry.message.content
  const message = entry.message as Record<string, unknown> | undefined;
  if (message && Array.isArray(message.content)) {
    return message.content;
  }
  // Main format: entry.content (array)
  if (Array.isArray(entry.content)) {
    return entry.content;
  }
  return null;
}
```

### 3. Модификация: `src/main/services/team/ChangeExtractorService.ts` (MODIFY)

Phase 1 создал `getTaskChanges()` с keyword-based scoping. Phase 3 заменяет на structure-based.

**CRITICAL FIX (C6+C7): `MemberLogSummary` НЕ имеет поля `filePath`!**

Реальный тип `MemberLogSummary` — это union `MemberSubagentLogSummary | MemberLeadSessionLogSummary`.
Базовые поля: `kind`, `sessionId`, `projectId`, `description`, `memberName` (string | null), `startTime`, `durationMs`, `messageCount`, `isOngoing`.
**НЕТ поля `filePath`!**

Также метод `getAllSessionLogs()` **НЕ существует** в `TeamMemberLogsFinder`.

**Решение**: Ввести внутренний тип `LogFileRef` и новый метод `resolveLogPaths()`:

```typescript
/** Внутренний тип — НЕ экспортируется из модуля */
interface LogFileRef {
  filePath: string;
  memberName: string;  // always string (fallback: 'unknown')
}

/**
 * Конвертирует MemberLogSummary → LogFileRef.
 * Используем существующий findMemberLogPaths() для получения файловых путей.
 */
private async resolveLogFileRefs(
  teamName: string,
  logs: MemberLogSummary[]
): Promise<LogFileRef[]> {
  const refs: LogFileRef[] = [];
  // Группируем по memberName для batch resolve
  const byMember = new Map<string, MemberLogSummary[]>();
  for (const log of logs) {
    const name = log.memberName ?? 'unknown';
    if (!byMember.has(name)) byMember.set(name, []);
    byMember.get(name)!.push(log);
  }

  for (const [memberName, memberLogs] of byMember) {
    const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
    // Match paths to logs by sessionId
    for (const log of memberLogs) {
      const matchedPath = paths.find(p =>
        log.kind === 'subagent'
          ? p.includes(log.sessionId) && p.includes(log.subagentId)
          : p.includes(log.sessionId) && p.endsWith('.jsonl')
      );
      if (matchedPath) {
        refs.push({ filePath: matchedPath, memberName });
      }
    }
  }
  return refs;
}
```

**Constructor (Phase 3 добавляет TaskBoundaryParser зависимость):**

```typescript
export class ChangeExtractorService {
  private cache = new Map<string, { data: AgentChangeSet; expiresAt: number }>();
  private readonly CACHE_TTL = 60 * 1000; // 1 мин (совпадает с TaskBoundaryParser)

  constructor(
    private readonly logsFinder: TeamMemberLogsFinder,
    private readonly boundaryParser: TaskBoundaryParser  // Phase 3 addition
  ) {}

  // ... Phase 1 methods (getAgentChanges, getChangeStats) remain unchanged ...
}
```

**Методы extractFilteredChanges и extractAllChanges (используют LogFileRef, НЕ MemberLogSummary):**

```typescript
/**
 * Извлечь изменения ТОЛЬКО для указанных tool_use IDs.
 * Парсит JSONL, находит Edit/Write/MultiEdit блоки, фильтрует по allowedIds.
 *
 * IMPORTANT: принимает LogFileRef[] (НЕ MemberLogSummary[]) — у MemberLogSummary нет filePath!
 */
private async extractFilteredChanges(
  logRefs: LogFileRef[],
  allowedToolUseIds: Set<string>
): Promise<FileChangeSummary[]> {
  const fileMap = new Map<string, FileChangeSummary>();
  const shouldFilter = allowedToolUseIds.size > 0;

  for (const ref of logRefs) {
    const stream = createReadStream(ref.filePath, { encoding: 'utf8' });
    const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

    try {
      for await (const line of rl) {
        const trimmed = line.trim();
        if (!trimmed) continue;

        let entry: Record<string, unknown>;
        try {
          entry = JSON.parse(trimmed) as Record<string, unknown>;
        } catch {
          continue; // Пропускаем повреждённые строки
        }

        const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : '';

        // Извлекаем content (тот же паттерн что в parseBoundaries — extractContent)
        const message = entry.message as Record<string, unknown> | undefined;
        let content: unknown[] | null = null;
        if (message && Array.isArray(message.content)) {
          content = message.content;
        } else if (Array.isArray(entry.content)) {
          content = entry.content;
        }
        if (!content) continue;

        // Ищем tool_use блоки с file-modifying tools
        for (const block of content) {
          if (!block || typeof block !== 'object') continue;
          const b = block as Record<string, unknown>;
          if (b.type !== 'tool_use') continue;

          const rawName = typeof b.name === 'string' ? b.name : '';
          const toolName = rawName.replace(/^proxy_/, '');

          // Только file-modifying tools
          if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) continue;

          const toolUseId = typeof b.id === 'string' ? b.id : '';

          // Фильтрация по allowedToolUseIds (если scope задан)
          if (shouldFilter && !allowedToolUseIds.has(toolUseId)) continue;

          const input = b.input as Record<string, unknown> | undefined;
          if (!input) continue;

          const filePath = typeof input.file_path === 'string' ? input.file_path : '';
          if (!filePath) continue;

          // Инициализируем FileChangeSummary если ещё нет
          if (!fileMap.has(filePath)) {
            fileMap.set(filePath, {
              filePath,
              relativePath: filePath.split('/').slice(-3).join('/'), // Последние 3 сегмента
              snippets: [],
              linesAdded: 0,
              linesRemoved: 0,
            });
          }
          const summary = fileMap.get(filePath)!;

          if (toolName === 'Edit') {
            const oldString = typeof input.old_string === 'string' ? input.old_string : '';
            const newString = typeof input.new_string === 'string' ? input.new_string : '';
            const replaceAll = input.replace_all === true;

            summary.snippets.push({
              toolUseId,
              toolName: 'Edit',
              oldString,
              newString,
              type: 'edit',
              timestamp,
              replaceAll,
            });

            // Подсчёт строк
            const addedLines = newString.split('\n').length;
            const removedLines = oldString.split('\n').length;
            summary.linesAdded += Math.max(0, addedLines - removedLines);
            summary.linesRemoved += Math.max(0, removedLines - addedLines);
          } else if (toolName === 'Write') {
            const content = typeof input.content === 'string' ? input.content : '';
            // Write: если файл уже встречался в fileMap — это update, иначе new
            const isNew = summary.snippets.length === 0;

            summary.snippets.push({
              toolUseId,
              toolName: 'Write',
              oldString: '',
              newString: content,
              type: isNew ? 'write-new' : 'write-update',
              timestamp,
              replaceAll: false,
            });

            summary.linesAdded += content.split('\n').length;
          } else if (toolName === 'MultiEdit') {
            const edits = Array.isArray(input.edits) ? input.edits : [];

            for (const edit of edits) {
              if (!edit || typeof edit !== 'object') continue;
              const e = edit as Record<string, unknown>;
              const oldString = typeof e.old_string === 'string' ? e.old_string : '';
              const newString = typeof e.new_string === 'string' ? e.new_string : '';

              summary.snippets.push({
                toolUseId,
                toolName: 'MultiEdit',
                oldString,
                newString,
                type: 'multi-edit',
                timestamp,
                replaceAll: false,
              });

              const addedLines = newString.split('\n').length;
              const removedLines = oldString.split('\n').length;
              summary.linesAdded += Math.max(0, addedLines - removedLines);
              summary.linesRemoved += Math.max(0, removedLines - addedLines);
            }
          }
        }
      }
    } finally {
      rl.close();
      stream.destroy();
    }
  }

  return [...fileMap.values()];
}

/**
 * Извлечь ВСЕ изменения из одного JSONL файла (без фильтрации).
 * Используется для single-task sessions и Tier 4 fallback.
 */
private async extractAllChanges(filePath: string, memberName: string = 'unknown'): Promise<FileChangeSummary[]> {
  return this.extractFilteredChanges(
    [{ filePath, memberName }],
    new Set() // empty set + shouldFilter=false → accept all
  );
}
```

**Phase 3 (новая реализация getTaskChanges):**

```typescript
async getTaskChanges(teamName: string, taskId: string): Promise<TaskChangeSetV2> {
  // 1. Найти MemberLogSummary через logsFinder (реальный API)
  const logs = await this.logsFinder.findLogsForTask(teamName, taskId);

  // 2. Конвертировать в LogFileRef (MemberLogSummary НЕ имеет filePath!)
  const logRefs = await this.resolveLogFileRefs(teamName, logs);
  if (logRefs.length === 0) {
    return this.emptyTaskChangeSet(teamName, taskId);
  }

  // 3. Для каждого JSONL — парсить boundaries через TaskBoundaryParser
  const allScopes: TaskChangeScope[] = [];
  for (const ref of logRefs) {
    const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);
    const scope = boundaries.scopes.find(s => s.taskId === taskId);
    if (scope) {
      // CRITICAL: НЕ мутируем scope напрямую — он из кеша TaskBoundaryParser!
      // Мутация scope.memberName = ... портит кешированный объект при повторных вызовах.
      const scopeCopy = { ...scope, memberName: ref.memberName };
      allScopes.push(scopeCopy);
    }
  }

  // 4. Если нет structural scopes → fallback на single-task assumption
  if (allScopes.length === 0) {
    return this.fallbackSingleTaskScope(teamName, taskId, logRefs);
  }

  // 5. Фильтровать snippets по tool_use IDs из scope
  const allowedToolUseIds = new Set(allScopes.flatMap(s => s.toolUseIds));
  const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds);

  // 5. Compute confidence (worst case across all scopes)
  const worstTier = Math.max(...allScopes.map(s => s.confidence.tier));

  const warnings: string[] = [];
  if (worstTier >= 3) {
    warnings.push('Some task boundaries could not be precisely determined.');
  }

  return {
    teamName,
    taskId,
    files,
    totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
    totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
    totalFiles: files.length,
    confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low',
    computedAt: new Date().toISOString(),
    scope: allScopes[0], // Primary scope
    warnings,
  };
}
```

**Fallback для single-task sessions (86% случаев):**

```typescript
private async fallbackSingleTaskScope(
  teamName: string,
  taskId: string,
  logRefs: LogFileRef[]  // C6 fix: LogFileRef вместо MemberLogSummary
): Promise<TaskChangeSetV2> {
  // Проверяем: если agent работал только над одной задачей,
  // ВСЕ изменения в сессии = изменения этой задачи

  for (const ref of logRefs) {
    const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath);

    if (boundaries.isSingleTaskSession) {
      // Весь файл = одна задача → extract все changes
      const files = await this.extractAllChanges(ref.filePath, ref.memberName);
      return {
        teamName,
        taskId,
        files,
        totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
        totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
        totalFiles: files.length,
        confidence: 'high',
        computedAt: new Date().toISOString(),
        scope: {
          taskId,
          memberName: ref.memberName,
          startLine: 1,
          endLine: Infinity,
          startTimestamp: '',
          endTimestamp: '',
          toolUseIds: [],
          filePaths: [],
          confidence: { tier: 1, label: 'high', reason: 'Single-task session (entire session = task)' },
        },
        warnings: [],
      };
    }
  }

  // No single-task session found → Tier 4 fallback
  const firstRef = logRefs[0];
  const files = firstRef ? await this.extractAllChanges(firstRef.filePath, firstRef.memberName) : [];
  return {
    teamName,
    taskId,
    files,
    totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
    totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
    totalFiles: files.length,
    confidence: 'low',
    computedAt: new Date().toISOString(),
    scope: {
      taskId,
      memberName: firstRef?.memberName ?? 'unknown',
      startLine: 1,
      endLine: Infinity,
      startTimestamp: '',
      endTimestamp: '',
      toolUseIds: [],
      filePaths: [],
      confidence: { tier: 4, label: 'fallback', reason: 'No task markers found. Showing all session changes.' },
    },
    warnings: ['Could not determine task boundaries. Showing all changes from this session.'],
  };
}

/** Empty result when no logs found at all */
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
  return {
    teamName, taskId, files: [],
    totalLinesAdded: 0, totalLinesRemoved: 0, totalFiles: 0,
    confidence: 'low', computedAt: new Date().toISOString(),
    scope: {
      taskId, memberName: 'unknown', startLine: 0, endLine: 0,
      startTimestamp: '', endTimestamp: '', toolUseIds: [], filePaths: [],
      confidence: { tier: 4, label: 'fallback', reason: 'No log files found for this task.' },
    },
    warnings: ['No log files found for this task.'],
  };
}
```

### 4. Модификация: `src/main/services/team/TeamMemberLogsFinder.ts` (MODIFY)

Добавляем новый метод для быстрого определения: есть ли TaskUpdate маркеры в файле.

```typescript
/**
 * Быстрая проверка: содержит ли JSONL файл TaskUpdate маркеры для задачи.
 * Быстрее чем полный parseBoundaries() — сканирует до первого совпадения.
 */
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
  const stream = createReadStream(filePath, { encoding: 'utf8' });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

  // H5 fix: экранируем taskId для безопасного использования в regex
  const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const pattern = new RegExp(`"taskId"\\s*:\\s*"${escapedTaskId}"`);

  for await (const line of rl) {
    if (line.includes('TaskUpdate') && pattern.test(line)) {
      rl.close();
      stream.destroy();
      return true;
    }
    if (line.includes('teamctl') && line.includes(`task`) && line.includes(taskId)) {
      rl.close();
      stream.destroy();
      return true;
    }
  }

  rl.close();
  stream.destroy();
  return false;
}
```

**НЕ модифицируем `findLogsForTask()`** — он уже работает корректно через `fileMentionsTaskId()` (keyword search по JSONL). `hasTaskUpdateMarker()` может использоваться **параллельно** как fast-path проверка ДО полного парсинга, но НЕ заменяет `findLogsForTask()`.

**CRITICAL: `getAllSessionLogs()` НЕ существует!** Реальный `findLogsForTask()` (строки 110-197 TeamMemberLogsFinder.ts) уже итерирует по всем сессиям через `discoverProjectSessions()` и `fileMentionsTaskId()`. Метод возвращает `MemberLogSummary[]`.

`hasTaskUpdateMarker()` — это вспомогательный метод для `TaskBoundaryParser`, НЕ для замены findLogsForTask.

```typescript
// hasTaskUpdateMarker() может вызываться напрямую из TaskBoundaryParser или из renderer
// для быстрой проверки "поддерживает ли сессия structural scoping"
// Но findLogsForTask() НЕ МЕНЯЕТСЯ.
```

### 5. Обновление `src/main/index.ts` (MODIFY)

Phase 3 создаёт `TaskBoundaryParser` и передаёт в `ChangeExtractorService`:

```typescript
// В initializeIpcHandlers() или рядом с ним:
import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser';

// Phase 1 было:
// const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder);

// Phase 3 →:
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);

// ReviewHandlerDeps не меняется (Phase 3 не добавляет новых deps в review.ts).
// TaskBoundaryParser используется ВНУТРИ ChangeExtractorService.
```

### 6. IPC (без изменений)

Phase 1 уже определил `REVIEW_GET_TASK_CHANGES`. Phase 3 не добавляет новых каналов — только улучшает backend точность.

### 6. Preload bridge и Store — обновление типов

Тип `TaskChangeSet` расширяется до `TaskChangeSetV2` (backwards compatible через extends).

**IMPORTANT: Обновить типы в 3 местах:**

1. **Preload bridge** (`src/preload/index.ts`): generic тип IPC-вызова обновить:
```typescript
// Phase 1:
getTaskChanges: (teamName: string, taskId: string) =>
  invokeIpcWithResult<TaskChangeSet>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
// Phase 3 → заменить на:
getTaskChanges: (teamName: string, taskId: string) =>
  invokeIpcWithResult<TaskChangeSetV2>(REVIEW_GET_TASK_CHANGES, teamName, taskId),
```

2. **Store type** (`src/renderer/store/slices/changeReviewSlice.ts`):
```typescript
// Phase 2 тип:
activeChangeSet: AgentChangeSet | TaskChangeSet | null;
// Phase 3 → расширить:
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
```

3. **ReviewAPI** (`src/shared/types/api.ts`): return type обновить:
```typescript
// Phase 1:
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSet>;
// Phase 3 →:
getTaskChanges: (teamName: string, taskId: string) => Promise<TaskChangeSetV2>;
```

Все три изменения backwards compatible: `TaskChangeSetV2 extends TaskChangeSet`, поэтому все Phase 2 компоненты продолжают работать. Phase 3 компоненты используют `isTaskChangeSetV2()` type guard для доступа к `.scope` и `.warnings`.

---

## Frontend

### 7. Компоненты

#### `src/renderer/components/team/review/ConfidenceBadge.tsx` (NEW)

Показывает уровень уверенности в scope задачи.

```typescript
interface ConfidenceBadgeProps {
  confidence: TaskScopeConfidence;
  /** Показать tooltip с деталями */
  showTooltip?: boolean;
}

export function ConfidenceBadge({ confidence, showTooltip = true }: ConfidenceBadgeProps) {
  const colors = {
    1: 'bg-green-500/20 text-green-400 border-green-500/30',   // High
    2: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', // Medium
    3: 'bg-orange-500/20 text-orange-400 border-orange-500/30', // Low
    4: 'bg-red-500/20 text-red-400 border-red-500/30',          // Fallback
  };

  const labels = {
    1: 'High confidence',
    2: 'Medium confidence',
    3: 'Low confidence',
    4: 'Best effort',
  };

  return (
    <span
      className={`inline-flex items-center px-2 py-0.5 rounded text-xs border ${colors[confidence.tier]}`}
      title={showTooltip ? confidence.reason : undefined}
    >
      {labels[confidence.tier]}
    </span>
  );
}
```

#### `src/renderer/components/team/review/ScopeWarningBanner.tsx` (NEW)

Баннер предупреждений для low-confidence scopes.

```typescript
interface ScopeWarningBannerProps {
  warnings: string[];
  confidence: TaskScopeConfidence;
  onDismiss?: () => void;
}

export function ScopeWarningBanner({ warnings, confidence, onDismiss }: ScopeWarningBannerProps) {
  if (warnings.length === 0 && confidence.tier <= 2) return null;

  return (
    <div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
      <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 shrink-0" />
      <div className="flex-1">
        <p className="font-medium text-yellow-300">
          {confidence.tier >= 3
            ? 'Task boundary detection is approximate'
            : 'Note about these changes'}
        </p>
        {warnings.map((w, i) => (
          <p key={i} className="text-text-secondary mt-1">{w}</p>
        ))}
        <p className="text-text-muted mt-1 text-xs">
          Detection: {confidence.reason}
        </p>
      </div>
      {onDismiss && (
        <button onClick={onDismiss} className="text-text-muted hover:text-text">
          <X className="w-4 h-4" />
        </button>
      )}
    </div>
  );
}
```

### 8. Модификация существующих компонентов

#### `ChangeReviewDialog.tsx` (MODIFY)

Добавляем scope information в header.

**Type guard (H2 fix — cross-phase compatibility):**
```typescript
// Phase 2 компоненты используют TaskChangeSet (Phase 1 тип).
// Phase 3 расширяет до TaskChangeSetV2 с полем .scope.
// ВСЕГДА проверять наличие .scope через 'in' guard:
function isTaskChangeSetV2(cs: TaskChangeSet): cs is TaskChangeSetV2 {
  return 'scope' in cs;
}
```

```typescript
// В header диалога (рядом с title)
{mode === 'task' && activeChangeSet && isTaskChangeSetV2(activeChangeSet) && (
  <div className="flex items-center gap-2">
    <ConfidenceBadge confidence={activeChangeSet.scope.confidence} />
    {activeChangeSet.warnings.length > 0 && (
      <ScopeWarningBanner
        warnings={activeChangeSet.warnings}
        confidence={activeChangeSet.scope.confidence}
      />
    )}
  </div>
)}
```

#### `KanbanTaskCard.tsx` (MODIFY)

Для задач в done/review/approved показываем confidence tier:

```typescript
// В footer карточки
{(columnId === 'done' || columnId === 'review' || columnId === 'approved') && (
  <div className="flex items-center gap-2 mt-2">
    <button
      onClick={(e) => {
        e.stopPropagation();
        onViewChanges?.(task.id);
      }}
      className="flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors"
    >
      <FileCode className="w-3.5 h-3.5" />
      View Changes
    </button>
    {/* ChangeStatsBadge уже из Phase 1 */}
    <ChangeStatsBadge stats={taskChangeStats} />
  </div>
)}
```

---

## Confidence Tiers — детальное описание

### Tier 1: High (95%+) — 86% сессий

**Условие**: Найдены оба маркера (start + end) ИЛИ single-task session.

**Сценарии:**
- `TaskUpdate(taskId=5, status=in_progress)` на строке 42 + `TaskUpdate(taskId=5, status=completed)` на строке 318
- Session имеет только 1 уникальный taskId → весь файл = одна задача

**Scope**: Строки [startLine, endLine] — все tool_use в этом диапазоне.

### Tier 2: Medium (90%) — ~8% сессий

**Условие**: Только start-маркер (задача ещё не завершена) ИЛИ batch completion.

**Сценарии:**
- Agent начал задачу 5, но crash/disconnect до completion
- Agent работает над 3 задачами последовательно, все complete в batch

**Scope**: [startLine, endOfFile] или [startLine, nextTaskStart].

### Tier 3: Low (80%) — ~4% сессий

**Условие**: Только end-маркер (start потерян).

**Сценарии:**
- Agent начал задачу до того как TeamCreate/TaskUpdate были доступны
- Начало было в другой сессии

**Scope**: [1, endLine] — от начала файла до completion marker.

### Tier 4: Fallback (70%) — ~2% сессий

**Условие**: Нет структурных маркеров. Используем keyword search + owner attribution.

**Сценарии:**
- Очень старые сессии без TaskUpdate support
- Agent использовал нестандартный workflow

**Scope**: Весь файл, с пометкой "best effort".

---

## Алгоритм multi-task sessions

Для сессий где agent работает над несколькими задачами последовательно:

```
JSONL Timeline:
  Line   1-30:   Setup, team init
  Line  31:      TaskUpdate(taskId=3, status=in_progress)    ← Task 3 START
  Line  32-150:  Edit, Write, Read operations for task 3
  Line 151:      TaskUpdate(taskId=3, status=completed)      ← Task 3 END
  Line 152:      TaskUpdate(taskId=7, status=in_progress)    ← Task 7 START
  Line 153-280:  Edit, Write operations for task 7
  Line 281:      TaskUpdate(taskId=7, status=completed)      ← Task 7 END
  Line 282-300:  Cleanup, idle
```

**Scope для Task 3**: Lines [31, 151] → tool_use IDs из строк 32-150
**Scope для Task 7**: Lines [152, 281] → tool_use IDs из строк 153-280

**Overlap handling**: Если границы перекрываются (редко), tool_use приписывается ближайшему start-маркеру.

---

## Файлы

| Файл | Тип | ~LOC |
|------|-----|---:|
| `src/shared/types/review.ts` | MODIFY | +80 |
| `src/main/services/team/TaskBoundaryParser.ts` | NEW | 350 |
| `src/main/services/team/ChangeExtractorService.ts` | MODIFY | +150 |
| `src/main/services/team/TeamMemberLogsFinder.ts` | MODIFY | +40 |
| `src/main/services/team/index.ts` | MODIFY | +1 |
| `src/main/index.ts` | MODIFY | +5 (см. ниже) |
| `src/preload/index.ts` | MODIFY | +1 (generic `<TaskChangeSetV2>`) |
| `src/shared/types/api.ts` | MODIFY | +1 (return type `TaskChangeSetV2`) |
| `src/renderer/store/slices/changeReviewSlice.ts` | MODIFY | +1 (union type) |
| `src/renderer/components/team/review/ConfidenceBadge.tsx` | NEW | 45 |
| `src/renderer/components/team/review/ScopeWarningBanner.tsx` | NEW | 50 |
| `src/renderer/components/team/review/ChangeReviewDialog.tsx` | MODIFY | +20 |
| `src/renderer/components/team/kanban/KanbanTaskCard.tsx` | MODIFY | +15 |
| **Итого** | 3 NEW + 9 MODIFY | ~760 |

---

## Edge Cases

1. **Задача работает в нескольких сессиях** — собираем scopes из всех JSONL файлов, merge tool_use IDs
2. **Один agent работает над 5+ задачами** — каждая задача имеет свой scope window, boundaries не перекрываются (confirmed на реальных данных)
3. **Agent делает TaskUpdate(in_progress) дважды подряд** — берём первый start, игнорируем повторный
4. **TaskUpdate(completed) без start** — Tier 3, scope от начала файла
5. **teamctl с set-status вместо start/complete** — парсим дополнительный аргумент (in_progress/completed)
6. **JSONL файл повреждён (обрезанные строки)** — try/catch skip, graceful degradation
7. **Очень длинные JSONL (>100MB)** — streaming readline, O(n) memory, no full-file load
8. **Numeric task IDs vs string** — всегда конвертируем в string для сравнения
9. **proxy_ prefix на tool names** — strip как в MemberStatsComputer (`.replace(/^proxy_/, '')`)
10. **tool_result с is_error: true** — пропускаем (Phase 1 rule), но boundary marker от tool_use всё равно учитываем

## Тестирование

- Unit test для `TaskBoundaryParser.parseBoundaries()` — mock JSONL с TaskUpdate markers
- Unit test для `TaskBoundaryParser.extractTeamctlBoundaries()` — различные teamctl formats
- Unit test для `computeScopes()` — single-task, multi-task, missing markers
- Unit test для Tier classification — все 4 тиера
- Unit test для `ChangeExtractorService.getTaskChanges()` — integration с boundary parser
- Unit test для `TeamMemberLogsFinder.hasTaskUpdateMarker()` — fast path detection
- Regression test: результаты Phase 3 должны быть superset Phase 1 (не потерять данные)
- Manual test с реальными сессиями из `~/.claude/projects/` — проверить Tier 1-4 distribution
