agent-ecosystem/docs/team-management/kanban-design.md

255 lines
11 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.

# Kanban Design
## Flow
```
TODO → IN PROGRESS → DONE ───────────────→ APPROVED
│ ↑
└→ REVIEW ───────────┘
└→ pending + needsFix
```
## Колонки
| Колонка | Source | Кто двигает | Описание |
|---------|--------|-------------|----------|
| **TODO** | task.status = pending | Автоматически | Задачи ожидающие исполнителя |
| **IN PROGRESS** | task.status = in_progress | Автоматически | Агент работает |
| **DONE** | task.status = completed | Автоматически | Агент завершил |
| **REVIEW** | kanban-state.json | Юзер/UI actions | На проверке |
| **APPROVED** | kanban-state.json | Юзер/UI actions | Одобрено |
---
## Kanban State Storage
### Почему свой файл, а не task metadata
Metadata в task-файлах может быть **перезаписан** агентом при TaskUpdate. Semantics (merge vs replace) НЕДОКУМЕНТИРОВАН. Если replace — наш kanbanColumn исчезнет.
### Формат kanban-state.json
Хранится в директории app config (не в `~/.claude/tasks/`).
```json
{
"teamName": "my-team",
"reviewers": ["agent-review-1"],
"tasks": {
"12": {
"column": "review",
"reviewStatus": "pending",
"reviewer": "agent-review-1",
"movedAt": "2026-02-17T15:30:00.000Z"
},
"15": {
"column": "approved",
"movedAt": "2026-02-17T16:00:00.000Z"
},
"18": {
"column": "review",
"reviewStatus": "error",
"reviewer": null,
"errorDescription": "Не обработан edge case с пустым массивом",
"movedAt": "2026-02-17T16:30:00.000Z"
}
}
}
```
### Column Mapping Logic
```
Task → Column:
1. kanban-state.tasks[task.id] exists? → use .column
2. task.status === "pending" → TODO
3. task.status === "in_progress" → IN PROGRESS
4. task.status === "completed" → DONE
5. task.status === "deleted" → не показываем
```
### GC kanban-state (важно: порядок операций)
При очистке устаревших записей (задачи которых больше не существуют) **ОБЯЗАТЕЛЬНО** сначала полностью загрузить все tasks, и только потом запускать GC:
```typescript
// ПРАВИЛЬНО:
const tasks = await getAllTasks(teamName); // 1. Сначала все tasks
const kanban = await getKanbanState(teamName); // 2. Затем kanban
const validIds = new Set(tasks.map(t => t.id));
const cleaned = Object.fromEntries(
Object.entries(kanban.tasks).filter(([id]) => validIds.has(id))
); // 3. Только потом GC
// НЕПРАВИЛЬНО (race condition при startup):
const kanban = await getKanbanState(teamName);
gcStaleEntries(kanban); // ← task-файлы ещё не прочитаны, удалим валидные записи
const tasks = await getAllTasks(teamName);
```
---
## Review Flow
⚠️ Этот файл описывает текущий продуктовый contract review flow. Исторические iteration-доки могут расходиться с ним.
### Manual actions from DONE
Из `DONE` сейчас есть два валидных пользовательских сценария:
1. **Request Review** - отправить задачу в `REVIEW`
2. **Approve** - сразу перевести задачу в `APPROVED` как manual shortcut
`REVIEW` нужен, когда пользователь хочет отдельный шаг проверки на доске, включая reviewer-driven flow или ручную проверку через UI. Но `REVIEW` не является обязательным промежуточным шагом для каждого manual approval.
### Перемещение DONE → REVIEW
1. Юзер переводит карточку из DONE в REVIEW через UI action
2. Проверяем `kanbanState.reviewers[]`
3. **Есть ревьюверы**:
- Берём первого свободного (round-robin с балансировкой по количеству активных ревью)
- Записываем в kanban-state: `{ column: "review", reviewStatus: "pending", reviewer: "agent-name" }`
- Отправляем inbox ревьюверу:
```json
{
"from": "user",
"text": "Please review task #12: Rename package in pubspec.yaml\n\nDescription: Change name: dartdoc to name: dartdoc_vitepress",
"summary": "Review request for task #12",
"timestamp": "...",
"read": false
}
```
4. **Нет ревьюверов**:
- Записываем в kanban-state: `{ column: "review", reviewStatus: "pending" }`
- Юзер сам ревьювит через UI (кнопки Approve / Request Changes)
### Прямое DONE → APPROVED
Юзер может сразу нажать **Approve** на карточке в `DONE`:
- kanban-state: `{ column: "approved" }`
- отдельный заход в `REVIEW` не требуется
- это manual shortcut и текущее допустимое поведение UI
### Review Result
В UI каждая карточка в REVIEW показывает ReviewBadge:
- **Pending** (yellow) — ждёт ревью
- **Approved** (green) — проверено, всё хорошо → кнопка Approve → переходит в APPROVED
- **Changes Requested** (red) — найдены замечания → кнопка Request Changes с опциональным комментарием
### Approve → APPROVED
Юзер нажимает кнопку **Approve** на карточке в REVIEW:
- kanban-state: `{ column: "approved" }`
### Request Changes → Fix
1. Юзер нажимает кнопку **Request Changes** на карточке в REVIEW
2. Появляется ReviewDialog — textarea для описания проблемы (опционально)
3. Юзер нажимает "Отправить"
4. Действия:
- kanban-state: удаляем запись для этой задачи
- task file: `status = "pending"`
- reviewState становится `needsFix`
- в UI задача возвращается в TODO/backlog path с маркером Needs Fixes
- Inbox к исходному owner:
```json
{
"from": "user",
"text": "Task #12 needs fixes:\n\nНе обработан edge case с пустым массивом\n\nPlease fix and mark as completed when done.",
"summary": "Fix request for task #12",
"timestamp": "...",
"read": false
}
```
**Примечание**: `reviewHistory` (история раундов ревью) и round-robin балансировка ревьюверов — в Phase 2, не MVP.
---
## MVP vs Phase 2
### MVP: Click-to-Move
Для текущего UI переходы между review-колонками делаются через **card actions** на карточке. Отдельный DnD сейчас используется для перестановки задач внутри колонки, а не для review state transitions.
```
[Task Card]
Subject: Rename package in pubspec.yaml
Owner: worker-1
[Approve] [Request review]
```
Разрешённые review-переходы через UI actions:
| Откуда → Куда | Действие |
|----------------|----------|
| DONE → REVIEW | kanban-state: review + reviewStatus: pending. Inbox ревьюверу если есть |
| DONE → APPROVED (Approve) | kanban-state: approved |
| REVIEW → APPROVED (Approve) | kanban-state: approved |
| REVIEW → TODO/Needs Fixes (Request Changes) | Dialog → task: pending + needsFix, kanban: remove, inbox к owner |
| APPROVED → DONE | kanban-state: remove (возвращается в DONE по status) |
Не разрешено:
- TODO → IN PROGRESS (агент берёт сам через TaskUpdate)
- IN PROGRESS → DONE (агент завершает сам через TaskUpdate)
### Phase 2: Полноценный D&D для state transitions
`@dnd-kit` уже используется для ordering. В Phase 2 можно добавить drag-and-drop и для самих state transitions, если это понадобится по UX.
---
## Синхронизация при изменении task status
Агент может поменять статус задачи пока мы показываем Kanban. Через file watcher мы узнаём об изменении.
### Сценарии
**Task в REVIEW, но agent поставил status = in_progress**:
- Конфликт: kanban-state говорит REVIEW, task file говорит in_progress
- Действие: показать warning badge на карточке
**Task в REVIEW, agent поставил status = completed**:
- Агент завершил повторно (после fix)
- Действие: обновить reviewStatus на pending (новый раунд ревью)
**Новая задача (status = pending)**:
- Нет записи в kanban-state
- Действие: автоматически в TODO
---
## Reviewer Assignment
### Конфигурация
Список ревьюверов хранится в `kanban-state.json``reviewers: string[]`.
Источники ревьюверов:
1. **Роль при создании команды**: юзер в промпте указывает "создай agent-review с ролью reviewer"
2. **В UI**: кнопка "Назначить ревьювером" рядом с участником в MemberList
### Round-Robin с балансировкой
```typescript
function pickReviewer(reviewers: string[], kanbanState: KanbanState): string | null {
if (reviewers.length === 0) return null;
// Считаем активные ревью у каждого
const reviewCounts = new Map<string, number>();
for (const reviewer of reviewers) {
reviewCounts.set(reviewer, 0);
}
for (const task of Object.values(kanbanState.tasks)) {
if (task.column === 'review' && task.reviewStatus === 'pending' && task.reviewer) {
const count = reviewCounts.get(task.reviewer) || 0;
reviewCounts.set(task.reviewer, count + 1);
}
}
// Берём того у кого меньше всего
return [...reviewCounts.entries()]
.sort((a, b) => a[1] - b[1])[0][0];
}
```