agent-ecosystem/docs/iterations/diff-view/phase-3-per-task-scoping.md
iliya 373d1a722b docs: update diff-view iteration plans (phases 2-4)
Update implementation details for accept/reject, per-task scoping,
and enhanced features phases with localStorage error handling and
expanded specifications.
2026-02-24 21:37:36 +02:00

1163 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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';
/** 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';
}
/** Расширенный 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) или teamctl(start)
* 3. End boundary = TaskUpdate(completed) или 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