# ADR-001: Единый формат status-коммента агентов в Plane

- **Work Item:** ORCH-016
- **Стадия:** architecture
- **Статус:** Accepted
- **Дата:** 2026-06-05
- **Автор:** architect

## Контекст

ТЗ ORCH-016 требует привести коммент-формат всех агентов (architect/developer/reviewer/tester/deployer + сохранение совместимости с analyst) к единому виду по эталону `src/stage_engine.py::_build_analyst_ready_comment` и дополнительно встроить **строку длительности работы агента**.

ТЗ оставил архитектору пять открытых вопросов (см. §2.2, §2.5, §2.7, §6):
1. Где живёт общий хелпер построения коммента (один файл vs. два).
2. Как ведём себя с usage-метрикой (tokens / $cost) в новом формате (Q-1 из ТЗ §2.7).
3. Локализация метки длительности — «Длительность:» vs «Duration:».
4. Парсинг frontmatter артефакта (verdict / deploy_status / staging_status) — переиспользовать `src/qg/checks.py` или дублировать.
5. Контракт хелпера БД-фоллбэка длительности и его форма.

Дополнительно: текущий `usage_comment(...)` — публичная (внутри проекта) функция, вызывается из `src/agents/launcher.py::_post_usage_comments`. Менять формат «на месте» без явного решения о судьбе старой сигнатуры рискованно.

## Решение

### 1. Архитектура хелперов

Вводим **ровно один публичный хелпер** в `src/usage.py`:

```python
def build_status_comment(
    agent: str,                          # "analyst" | "architect" | ... | "deployer"
    *,
    repo: str | None = None,
    branch: str | None = None,
    work_item_id: str | None = None,
    pr_number: int | None = None,
    stage: str | None = None,            # "deploy" vs "deploy-staging" (для deployer)
    usage: dict | None = None,           # tokens/cost (опционально)
    duration_s: int | None = None,       # если известно — иначе fallback по БД
    task_id: int | None = None,          # требуется ТОЛЬКО для DB-фоллбэка длительности
    worktree_root: str | None = None,    # для чтения артефактов; None → опускаем verdict
) -> str:
```

Что делает:
- Собирает заголовок `{ICON} {RoleName} — {описание}` (описание per-agent — см. §2 ниже).
- Опционально дописывает строку `Verdict: …` / `Status: …` (только для reviewer/tester/deployer и только если frontmatter артефакта присутствует и распознан).
- Всегда (если известна) дописывает строку `Длительность: …` через `fmt_duration(...)`.
- Дописывает блок `<b>Документы:</b><ul><li><a …>…</a></li>…</ul>`.
- Опционально дописывает технический хвост `<sub>{tokens}/{cost}</sub>` — см. §3.

`_build_analyst_ready_comment(...)` в `src/stage_engine.py` переписывается как **тонкая обёртка** над `build_status_comment(agent="analyst", ...)`. Аналитик-специфичный текст (инструкция «переведите в Approved/Rejected» + полный список 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan) добавляется ВНУТРИ `build_status_comment` через ветку `agent == "analyst"` — это единственное место, где per-agent текст шире одной строки. Альтернатива (передавать кастомный текст параметром) добавляет API-площадь без пользы.

**Старый `usage_comment(...)` удаляется**; единственный его внешний вызов — `src/agents/launcher.py::_post_usage_comments` — переписывается на `build_status_comment(...)`. Это упрощает дальнейшее сопровождение (один формат → одна функция); риск минимален, потому что `usage_comment` — внутренний API.

### 2. Per-agent описания (финализация ТЗ §2.2)

| Агент | Описание (HTML, без точки в конце) |
|-------|------------------------------------|
| analyst | «Подготовил BRD / ТЗ / Acceptance Criteria. Для продвижения переведите задачу в статус Approved» (плюс существующая инструкция про Approved/Rejected уходит как продолжение) |
| architect | «Завершил архитектурную проработку. См. ADR ниже» |
| developer | «Завершил разработку. См. PR / branch ниже» |
| reviewer | «Завершил ревью изменений» |
| tester | «Завершил прогон тестов» |
| deployer (deploy) | «Завершил прод-деплой» |
| deployer (deploy-staging) | «Завершил staging-деплой» |

### 3. Решение по Q-1 (usage-метрика)

**Сохраняем** usage-метрику как **техническую `<sub>`-строку в конце** коммента, объединённую с длительностью НЕ нужно — длительность остаётся ОТДЕЛЬНОЙ строкой нормального веса (требование ТЗ §2.5).

Конкретно:
```html
<sub>8.5M in (8.4M cached) / 45.8k out · $7.29</sub>
```

Почему НЕ удаляем:
- Тех-метрика полезна для оценки стоимости задачи на пост-мортеме (особенно для ORCH-задач, где orchestrator расходует свой же бюджет).
- `task_summary_comment` (Deployer end-of-task) суммирует по задаче, но не покрывает per-agent breakdown в момент завершения каждой стадии — для трассировки «кто сколько потратил» полезно видеть сразу.

Почему `<sub>`, а не обычная строка:
- Стейкхолдер (Слава) явно просил «без раздувания»; визуально приглушённый хвост не конкурирует за внимание с описанием/вердиктом/длительностью/ссылками.
- Plane корректно рендерит `<sub>` (проверено ранее на PR #13).

При `usage = None` или нулевых значениях — хвост опускается полностью.

### 4. Решение по Q-2 (локализация метки длительности)

Используем русский: **`Длительность: 4m 12s`**.
Обоснование: все человеческие тексты комментов уже на русском (заголовок «Документы:», описания стадий). Метка `4m 12s` сама по себе универсальна и понятна без перевода (стандарт CLI-инструментов: `time`, `gh`, `kubectl`).

### 5. Решение по Q-4 (парсинг frontmatter)

Создаём НОВЫЙ маленький утилитный модуль **`src/frontmatter.py`** с единственной функцией:

```python
def read_frontmatter_value(path: str, key: str) -> str | None:
    """Read a single key from leading YAML frontmatter. Never raises.

    Returns None if file missing, frontmatter absent/malformed, or key not set.
    """
```

Реализация — yaml.safe_load на блоке между двумя `---` строками; всё ловится одним `try/except` → `logger.debug` → `None`.

Этот модуль используют:
- `src/usage.py::build_status_comment` — для извлечения `verdict:` / `deploy_status:` / `staging_status:`.
- `src/qg/checks.py` — НЕ обязательно мигрировать в этом PR (out-of-scope ORCH-016); миграция может пройти отдельной задачей-рефакторингом. **В этом PR `qg/checks.py` НЕ трогаем** — снижает blast radius и риск регрессии гейтов.

Дублирование (~10 строк YAML-парсера в `qg/checks.py` остаётся) сознательно принято: scope discipline > DRY на одном переиспользовании.

### 6. Решение по Q-5 (DB-фоллбэк длительности)

Хелпер в `src/usage.py`:

```python
def get_agent_duration(task_id: int, agent: str) -> int | None:
    """Return last finished agent_runs duration (seconds) for (task, agent).
    Never raises. None on missing row / NULL finished_at / negative / error.
    """
```

SQL — ровно как в ТЗ §2.5 (фоллбэк):
```sql
SELECT CAST((julianday(finished_at) - julianday(started_at)) * 86400 AS INTEGER)
FROM agent_runs
WHERE task_id=? AND agent=?
  AND finished_at IS NOT NULL
ORDER BY id DESC LIMIT 1
```

Чтение через `get_db()` (стандартный путь модуля), обёрнутое в `try/except Exception` → `logger.debug(...)` → `None`. Соединение всегда закрывается в `finally`.

`build_status_comment` вызывает `get_agent_duration(...)` ТОЛЬКО когда:
- `duration_s is None`, И
- `task_id is not None` (вызывающая сторона согласилась оплатить лишний SELECT).

Если оба источника пусты → строка «Длительность:» опускается (AC-14).

### 7. Решение по HTML vs Markdown (ТЗ §6)

Целевой рендер — **HTML**, как у эталона аналитика. Конкретно:
- Заголовок и описание — plain text + emoji.
- Verdict / Длительность — отдельные строки, разделяются `<br>` (или `\n` если Plane корректно интерпретирует переводы строк; экспериментально подтвердить на staging — см. R-2 в `10-tech-risks.md`).
- Блок документов — `<b>Документы:</b><ul><li><a href="…">label</a></li></ul>`.
- Технический хвост — `<sub>…</sub>` отдельной строкой через `<br>`.

`artifact_links(...)` (сейчас возвращает markdown-строки `[label](url)`) — **переписывается на HTML-якоря** `<a href="...">label</a>`. Эмодзи-префиксы (📂/🔗/📐/📄) сохраняются. Возвращаемый тип меняется: `list[str]` остаётся, но содержимое — HTML-фрагменты (документировано в docstring).

Это breaking-change для внутреннего API `artifact_links`, но единственный внешний вызов был из `usage_comment`, который тоже удаляется. Других вызовов в `tests/`/`scripts/` нет (developer проверит grep'ом в development-стадии).

### 8. Контракт `fmt_duration` (полностью по AC-13)

```python
def fmt_duration(seconds: int | None) -> str:
    """0..59 → '{s}s'; 60..3599 → '{m}m {ss:02d}s'; >=3600 → '{h}h {mm:02d}m'.
    None / negative → '' (caller should drop the line)."""
```

Чистая функция, без I/O, easily unit-testable. Размещение: `src/usage.py` (рядом с `fmt_tokens` / `fmt_cost`).

## Альтернативы

1. **Два отдельных хелпера** (`build_analyst_status_comment` + `build_agent_status_comment`).
   Отклонено: ТЗ явно просит «единый эталонный формат»; дублирование шаблона расходится со временем.

2. **Оставить `usage_comment` как deprecated-обёртку.**
   Отклонено: один внутренний вызов, deprecation добавляет когнитивный шум без выигрыша.

3. **Перенести usage-метрику в `task_summary_comment` (вариант B из ТЗ §2.7).**
   Отклонено: теряем per-stage видимость затрат; финальный summary не отвечает на вопрос «сколько съел конкретно reviewer».

4. **Markdown вместо HTML.**
   Отклонено: эталон аналитика (PR #13) уже HTML; смена ломает визуальный паритет.

5. **Английская метка «Duration:».**
   Отклонено: ассиметрия с остальными русскими подписями в комменте.

6. **Рефакторить `qg/checks.py` на `src/frontmatter.py` в этом же PR.**
   Отклонено: расширяет blast radius на гейты; делаем отдельной задачей.

## Последствия

### Положительные
- Единая точка изменения формата комментов на будущее — `build_status_comment`.
- Удаление дубликата `usage_comment` уменьшает API-площадь модуля.
- `src/frontmatter.py` подготавливает почву для будущего рефактора `qg/checks.py` (DRY-победа в один заход следующей задачей).
- HTML-рендеринг даёт стейкхолдеру кликабельные ссылки и приглушённый тех-хвост.

### Отрицательные / ограничения
- Дублирование YAML-парсинга на ~10 строк (qg/checks.py остаётся со своим).
- Дополнительный SELECT к `agent_runs` на каждый коммент аналитика (1 запрос, по индексу `task_id`, ничтожно).
- HTML-разметка ломается визуально, если Plane изменит политику санитизации `<sub>` или `<ul>` (риск R-2).

### Self-hosting
- Хелперы — чистый код, без рестарта прод-контейнера. Изменения дойдут до прода через стандартный staging-гейт (`deploy-staging` → `deploy`).
- Если коммент сломается, ленту Plane задачи ORCH-016 первой и заметим — feedback loop коротко.

## Связи
- ТЗ §1, §2, §6 (`docs/work-items/ORCH-016/02-trz.md`)
- AC-1..AC-14 (`docs/work-items/ORCH-016/03-acceptance-criteria.md`)
- PR #13 (эталон аналитика — `_build_analyst_ready_comment`)
- PR #14 (`gitea_public_url` для кликабельных ссылок)
- `src/usage.py`, `src/stage_engine.py`, `src/agents/launcher.py`, `src/db.py`, `src/qg/checks.py`
