OpenSpec change-history: журнал изменений задач (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 09:10:06 +04:00
parent fa91e49d2d
commit 5a6cdecc8e
8 changed files with 634 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22

View File

@ -0,0 +1,218 @@
## Context
`main` 7911dc7, схема v4, агент v12. Устройство, на которое опираются правки:
- **Зеркало** — `mirror.apply_state` делает upsert строки `issue` (прежние значения теряются) и заменяет комментарии
задачи присланными (`issue_comment`, время `created`/`updated` — строка Jira как есть, например
`2026-10-22T13:05:11.000+0300`). Поле `jira_updated` (Jira `updated`) уже приходит в состоянии.
- **Полный прогон** — `refresh {jql: base_jql}` (`queue.is_full_refresh`); его ставят автообновление
(`queue.maybe_auto_refresh`, по умолчанию раз в 60 минут, `auto_refresh_minutes`), `POST /refresh {full}` и MCP
`refresh full`. Хвост пакета — `refresh {keys}` по ключам пакета. `base_jql` вычисляется из проектов и меняется при
создании проекта.
- **Выдача** — `queue.deliver_params` дописывает параметры при выдаче (у `refresh` — `boards`), в базе `params` без них.
`report_result` по `refresh` пишет `sync_run.finished_at` и типы досок из `result.boards`.
- **Агент** (`jira-agent/src/agent.py`, v12, Python 3.9 stdlib): `do_refresh` → `search_all(jql)` с полями зеркала
(включая `updated`), дочитывание соседей (`search_tolerant`, `key in (...)`), имена спринтов, отправка
`POST /agent/state` пачками по 100. Неизвестные параметры команды агент игнорирует (`p.get`).
- **Спринт в зеркале** — имя активного или будущего спринта доски проекта; у задач закрытых спринтов `sprint = null`.
Активный и будущий в зеркале не различаются.
- **MCP** — `mcp_server.py` ходит только через `client`; `issue` форматирует `GET /issues/{key}` (комментарии — 5
последних).
- **Время** — в базе ISO-8601 UTC (`db.now()`), кроме дат без времени и времени комментариев.
## Goals / Non-Goals
**Goals:** журнал изменений задач из Jira в хабе; лента событий за период в API и MCP; история задачи в
`GET /issues/{key}` и MCP `issue`; стартовая загрузка по незакрытым задачам текущего спринта; регулярная загрузка только
по изменившимся задачам; ни одно изменение не теряется молча.
**Non-Goals и Anti-Goals:** см. proposal.md.
## Decisions
1. **Таблица `issue_change`, схема v5.** Колонки: `history_id` (TEXT, id записи журнала Jira), `seq` (INTEGER,
порядковый номер поля в записи, с 0), `key`, `at` (ISO-8601 UTC — время записи, приведённое хабом), `created` (время
Jira как есть), `author` (отображаемое имя, иначе логин, иначе `NULL`), `field` (логическое имя, см. решение 3),
`field_id` (id поля Jira или `NULL`), `field_name` (имя поля Jira как есть), `from_value`, `from_string`, `to_value`,
`to_string` (как есть, могут быть `NULL`), `fetched_at`. PK `(history_id, seq)`; индексы `(at)` и `(key, at)`.
Запись — `INSERT OR IGNORE`: повтор записи журнала ничего не меняет. Пишет таблицу только приём журнала от агента;
API и MCP — только читают (как зеркало).
*Почему по строке на поле, а не на запись журнала:* лента фильтрует по виду поля, одна запись Jira часто меняет
статус и резолюцию разом.
2. **Время комментариев в UTC.** Колонка `issue_comment.created_at` (ISO-8601 UTC, приведённое из `created`; не
разобралось — `NULL`), заполняется в `apply_state`; миграция v4 → v5 заполняет её у имеющихся строк. Строка `created`
остаётся как есть (её показывают интерфейсы). Время журнала приводится так же (`at`). Разбор: ISO-8601 со смещением
`+0300` / `+03:00` / `Z`, с дробной частью или без; без смещения — UTC.
3. **Логическое имя поля.** Хаб вычисляет `field` при приёме: `status`, `description`, `summary`, `assignee`,
`priority`, `duedate`, `labels`, `resolution` — по `field_id`, а без него — по `field_name` (встроенные поля Jira
называются так же); `planned_start`, `planned_end`, `epic_link` — по `field_id`, совпавшему с `field_ids` /
`epic_link.field_id` настроек; остальное — `field_name` как есть. Хаб, а не агент: соответствие логических имён и id
полей уже живёт в настройках хаба, агент шлёт сырые данные.
4. **Виды событий ленты.** `status` — поле `status`; `description` — поле `description`; `dates` — поля `duedate`,
`planned_start`, `planned_end`; `comment` — комментарий с `created_at` в окне (из `issue_comment`, не из журнала);
`other` — прочие поля журнала. По умолчанию лента отдаёт `status`, `description`, `dates`, `comment`; `other` — только
по запросу (`kinds=all` или явно). Остальные поля журнала сохраняются всегда — приходят бесплатно, понадобятся вебу.
5. **Отметка «с какого момента» — из `sync_run`, без новой настройки.** Колонка `sync_run.history_at` (время приёма
итога журнала, `NULL` — журнал по прогону не принят). Хаб проставляет её только у полного прогона (`refresh` по
базовой JQL), завершившегося `done` с `result.history` без `error` (решение 11). Отметка — `started_at` последнего
`sync_run` с `history_at IS NOT NULL` **независимо от `query`** (новый проект не обнуляет отметку прежних). Параметр
`history` при выдаче `refresh`:
- полный прогон, отметка есть — `{since: <отметка − 15 минут>}` (UTC, ISO-8601 с `Z`); если `query` прогона-отметки
не равна текущей `base_jql` (после него заведён проект) — ещё и `jql` стартовой загрузки (решение 6): прежние
проекты дочитываются от отметки, новый получает стартовую историю;
- полный прогон, отметки нет — `{jql: <стартовая>}`;
- `refresh {keys}` и `refresh` по небазовой JQL — `{since}`, если отметка есть, иначе `history` не дописывается
(стартовую загрузку делает только полный прогон).
Запас 15 минут покрывает расхождение часов Mac mini и сервера Jira и время самого прогона; повторы отбрасываются по
PK. Отметка сдвигается **только** полным прогоном с принятым журналом: прогон упал, регулярная часть журнала не
дочитана, агент старый — следующий прогон возьмёт прежнюю отметку и дочитает пропущенное (анти-цель «молча теряет»).
*Отвергнуто:* отдельная настройка-отметка (её правят руками и через `settings_set`, сдвиг надо делать атомарно с
итогом прогона); сравнение `jira_updated` в хабе с ответом агенту списком изменившихся (хаб уже перезаписал строку, если
журнал потом не дочитан — изменение потеряно); отметка по `query` = текущая `base_jql` (после нового проекта прежние
проекты теряли изменения вне спринта между отметкой и прогоном — находка рецензента).
6. **Стартовая загрузка.** `history.jql = (<base_jql>) AND sprint in openSprints() AND statusCategory != Done`. Агент
тянет журнал этих задач целиком (без отсечения по времени). Решение владельца: «максимум незакрытые в текущем
спринте». `openSprints()` — активные спринты, будущие не входят. Категория статуса, а не настройка `closed_statuses`:
статус из настройки, которого нет в Jira, даёт 400 на всю JQL, и стартовая загрузка не прошла бы никогда. Стартовая
загрузка — разовая и «по возможности»: её ошибка не мешает отметке (решение 11) и не повторяется. Проект на
канбан-доске без спринтов в стартовую загрузку не попадает — его история начнётся с первого регулярного прогона.
7. **Агент v13: регулярный режим (`history.since`).** После отправки состояния (все пачки `POST /agent/state`) агент
отбирает из выгруженных задач (база и соседи) те, у кого Jira `updated >= since` (сравнение моментов времени с
учётом смещения), и по ним делает `search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей.
Из журнала каждой задачи берёт записи с `created >= since`. Изменившихся задач нет — ни одного лишнего вызова Jira.
Решение владельца: без второго «слепого» запроса по `updated >= …`, отбор по уже выгруженному.
8. **Агент v13: неполный журнал.** Если у задачи `changelog.total` больше числа записей в ответе `search`, агент дочитывает
журнал `GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и там неполный — передаёт что есть, пишет в
лог (ключ, сколько получено из скольких) и добавляет ключ в `result.history.incomplete`. Отметку неполный журнал не
держит: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда; неполнота видна в результате
команды. Jira Server/DC, по ожиданию, отдаёт журнал целиком; проверить на живом агенте (план, живая проверка).
Элемент, который хаб отверг бы (пустые `created` или `field_name`, запись без `id`), агент отбрасывает с записью в
лог — иначе один кривой элемент давал бы 422 на пачку в каждом прогоне и отметка не сдвинулась бы никогда.
9. **Агент v13: id поля.** `field_id` — `fieldId` элемента журнала, если Jira его отдаёт; иначе для `fieldtype = custom`
агент ищет id по имени поля в `GET /rest/api/2/field` (один вызов на `refresh`, только если такой элемент встретился);
для `fieldtype = jira` — `field` как есть. Не нашлось — `null`.
10. **Приём журнала — `POST /agent/history`.** Тело `{command_id, changes: [...]}`, элемент — `{history_id, seq, key,
created, author, field_id, field_name, from, from_string, to, to_string}`. Агент шлёт пачками по 500 элементов;
хаб проверяет форму всей пачки до записи (`history_id`, `key`, `created`, `field_name` — непустые строки, `seq` —
целое ≥ 0, остальные — строка или `null`; нарушение — 422 `bad_history` с номером элемента и полем, пачка не
применяется) и пишет `INSERT OR IGNORE`. Ответ `{accepted, duplicates}`. Отдельный маршрут, а не поле в
`/agent/state`: журнал тянется после состояния, у него свои пачки, контракт состояния не меняется.
11. **Итог журнала — в результате `refresh`.** `result.history = {issues: <задач с журналом>, changes: <элементов
отправлено>, incomplete: [ключи с неполным журналом]}` плюс, при ошибке, `error` (регулярная часть, `since`) или
`backfill_error` (стартовая загрузка, `jql`). Ответ не 2xx от Jira или хаба на шаге журнала (`HttpError`, у хаба — с
префиксом «хаб: ») не роняет `refresh`: агент сразу, без повтора команды, возвращает `done` с текстом ошибки — зеркало
уже обновлено, ломать и перекачивать его ради журнала нельзя (анти-цель); повторит следующий прогон, отметка ведь
не сдвинулась. Обрыв связи с Jira или хабом (`ConnectionError`) на шаге журнала — как в остальном `refresh` (повтор,
затем `failed`). Без `history` в параметрах агент журнал не читает и ключа `history` в результате нет. Хаб ставит
`sync_run.history_at` при `done` полного прогона и `history` без `error`; `backfill_error` и `incomplete` отметку не
держат (стартовая загрузка разовая — решение 6; неполнота — решение 8).
11a. **Журнал в `sync_log`.** `GET /sync-runs` отдаёт `history_at`; MCP `sync_log` выводит у прогона «журнал: принят
<время>» или «журнал: нет». Текст ошибки журнала — в результате команды (`batch_status`).
12. **Лента — `GET /changes`.** Параметры: `since`, `until` (ISO-8601 со смещением или `Z`; без `until` — сейчас; без
`since` — `until − 24 ч`), `project` (только свои задачи проекта; соседи — нет: в ленте проекта чужие задачи —
шум), `key` (одна задача, без ограничения проектом), `kinds` (через запятую из `status`, `description`, `dates`,
`comment`, `other`, или `all`; по умолчанию все, кроме `other`), `limit` (по умолчанию 500, до 2000). Ответ
`{since, until, changes: [...], truncated}`, элемент — `{at, key, summary, status, kind, field, author, from, to,
from_value, to_value}` (`from`/`to` — `from_string`/`to_string`, а если они `null` — `from_value`/`to_value`;
`from_value`/`to_value` — сырые значения журнала: у дат Jira строка журнала может быть локализованной, а значение —
ISO, им пользуется MCP; у комментария `from = null`, `to` — текст, `field = "comment"`, `*_value = null`); порядок — `at` по убыванию, затем `key`, `history_id`, `seq`. Неизвестный проект
— 404, неверное время или вид — 422 `bad_request` с именем параметра.
13. **История задачи — в `GET /issues/{key}`.** Поле `changes` — все элементы журнала задачи (все виды, включая `other`),
в формате элемента ленты без `summary`/`status`, новые сверху. Комментарии там уже есть отдельно.
14. **MCP `changes`.** Параметры `hours` (число), `since`, `until`, `kinds`, `key`. Без `hours` и `since` — с начала
сегодняшнего дня по местному времени машины MCP. Привязка: в сессии проекта P — `project = P`; с `key` — без
проекта; без привязки и без `key` — ответ «сессия не привязана к проекту…» (как `issues`). Вид ответа — по задачам
(задачи — по времени последнего события, новые сверху): строка «КЛЮЧ тема — статус», под ней события по времени
(«ЧЧ:ММ автор: статус «В работе» → «Готово»»; дата перед временем, если событие не сегодня; описание — «описание
изменено» без текста; срок — «срок 25.10 → 30.10» (дата — из начала `*_value` вида `ГГГГ-ММ-ДД`, иначе строка как
есть); комментарий — первые 200 символов с «…»). Пусто — «за период
изменений нет». `truncated` — строка «показаны последние N событий, сузьте период».
15. **MCP `issue`.** После комментариев — «изменения (N):» и 5 последних элементов `changes` в том же виде, «ещё M», если
их больше; без журнала — строки нет (у старых задач журнала может не быть).
16. **Миграция v4 → v5.** В той же транзакции с копией базы, что и прежние: `issue_change` и индексы создаёт `SCHEMA`;
шаг — `ALTER TABLE issue_comment ADD COLUMN created_at TEXT`, `ALTER TABLE sync_run ADD COLUMN history_at TEXT`,
заполнение `issue_comment.created_at` разбором `created` (Python-шаг, как `migrate_v3`). У имеющихся `sync_run`
`history_at = NULL` → первый `refresh` после выкатки хаба — стартовая загрузка.
17. **Порядок выкатки.** Хаб можно выкатить раньше агента: агент v12 игнорирует `history`, результат без `history` —
отметка не сдвигается, `history_at` пуст, каждый полный `refresh` выдаётся со стартовой JQL, но агент её не исполняет.
После публикации v13 первый полный прогон делает стартовую загрузку. Агент v13 со старым хабом (без `history` в
параметрах) журнал не читает.
## Risks / Trade-offs
- **Объём стартовой загрузки.** Журнал задач открытого спринта целиком; у задач с длинной историей описания — большие
тексты (`from_string`/`to_string` описаний), идут через общую папку. Пачки по 500 элементов; если пачка окажется
слишком тяжёлой для реле — уменьшить в агенте.
- **Расхождение часов > 15 минут** между Mac mini и сервером Jira даёт пропуски. Цена — потеря событий без сигнала;
принимаем, часы синхронизируются по NTP.
- **Удалённый комментарий** исчезает из ленты задним числом (лента читает `issue_comment`).
- **Журнал соседей.** Соседи, уже известные хабу, полным прогоном не перечитываются (поведение `refresh` v12), поэтому
их изменения попадают в журнал только при точечном обновлении. Принимаем: лента проекта показывает свои задачи.
## Migration Plan
1. PR хаба: схема v5, приём журнала, `history` в выдаче `refresh`, `GET /changes`, `changes` в `GET /issues/{key}`,
MCP. После merge — `git pull`, перезапуск хаба владельцем, `/mcp` → reconnect.
2. PR агента: v13. После merge — `jira-hub agent publish ../jira-agent/src/agent.py`; `agent_status` показывает v13;
`min_agent_version` не поднимать (хаб работает и со старым агентом).
3. Живая проверка: полный `refresh` (выдан с `history.jql`) → в `result.history` элементы, в ленте события задач
спринта; следующий полный `refresh` (выдан с `history.since`) → лишних вызовов Jira нет при отсутствии изменений; правка задачи в Jira → событие в
`changes` с автором и временем; лог агента — нет строк о неполном журнале (иначе — решение 8 под пересмотр).
Откат: агент — публикация v12 невозможна (только вверх), выкатывается v14 без журнала; хаб — предыдущий коммит,
таблица и колонки v5 остаются и не мешают (старый код их не читает), `schema_version = 5` старый хаб не понизит.
## Open Questions
Нет.
## Decisions made without discussion
1. 2026-10-22 — **строка журнала — одно поле одной записи Jira (`issue_change`), PK `(history_id, seq)`.** Обоснование:
фильтр ленты по виду поля; идемпотентность по id Jira. Цена ошибки: если `seq` у одной записи Jira окажется
неустойчивым между запросами, возможны дубли полей одной записи — видно в ленте, лечится сменой ключа на
`(history_id, field_name, to_value)`.
2. 2026-10-22 — **сохраняются все поля журнала, лента по умолчанию показывает четыре вида.** Обоснование: данные
приходят бесплатно, веб и история задачи покажут больше. Цена ошибки: рост базы — текстами описаний.
3. 2026-10-22 — **отметка «с какого момента» — из `sync_run.history_at` любого полного прогона с принятым журналом
(без сравнения `query`), сдвигается только им; запас 15 минут; после нового проекта — `since` и `jql` вместе;
неполные `refresh` без отметки журнала не получают.** Обоснование: анти-цель «молча теряет» (находки рецензента:
новый проект не должен обнулять отметку прежних; хвост пакета не должен тянуть стартовую загрузку). Цена ошибки:
при расхождении часов > 15 минут — пропуски; агенту два режима в одном `refresh`.
4. 2026-10-22 — **стартовая загрузка — JQL `sprint in openSprints() AND statusCategory != Done`, журнал целиком,
разово.** Обоснование: формулировка владельца; активный спринт в зеркале не отличим от будущего; `closed_statuses`
со статусом, которого нет в Jira, дал бы 400 навсегда. Цена ошибки: статус с категорией не Done, который владелец
считает закрытым, попадёт в стартовую загрузку (лишний журнал); канбан-проекты без стартовой истории; упавшая
стартовая загрузка не повторяется (видно в `backfill_error`).
5. 2026-10-22 — **ошибка Jira или хаба (не 2xx) при чтении журнала не роняет `refresh` и не повторяет команду**
(`done` с `history.error`, отметка не сдвигается). Обоснование: зеркало важнее ленты; повтор команды перекачал бы
состояние ради журнала; следующий прогон дочитает. Цена ошибки: при разовом 5xx лента отстаёт на один интервал
автообновления.
6. 2026-10-22 — **логическое имя поля вычисляет хаб, агент шлёт `field_id` и `field_name`.** Обоснование: соответствие id
полей — в настройках хаба. Цена ошибки: если Jira не отдаёт `fieldId` и агент не нашёл id по имени — плановые даты
попадут в `other`; видно на живой проверке.
7. 2026-10-22 — **комментарии в ленте — из `issue_comment` по новой колонке `created_at` (UTC), а не событиями
журнала.** Обоснование: комментариев нет в журнале Jira; они уже приходят с автором и временем. Цена ошибки:
удалённый в Jira комментарий пропадает из ленты.
8. 2026-10-22 — **лента проекта — только свои задачи, без соседей.** Обоснование: соседи — задачи других меток, в ленте
проекта это шум. Цена ошибки: изменение блокирующей задачи другого проекта не видно в ленте (видно через `key`).
9. 2026-10-22 — **MCP `changes` по умолчанию — с начала сегодняшнего дня по местному времени.** Обоснование: «что
закрылось сегодня» — первый сценарий владельца. Цена ошибки: лишний параметр в вопросе «за 2 часа» (`hours: 2`).
10. 2026-10-22 — **история задачи — полем `changes` в `GET /issues/{key}`, а не отдельным маршрутом.** Обоснование: так
уже отдаются комментарии; карточка и MCP `issue` получают всё одним запросом. Цена ошибки: тяжелее ответ карточки
у задач с длинной историей.
11. 2026-10-22 — **событие «задача создана» не входит в ленту.** Обоснование: владелец его не называл, в журнале Jira его
нет. Цена ошибки: новые задачи дня не видны в ленте; добавляется по `issue.created` без агента.
12. 2026-10-22 — **журнал приёмом `POST /agent/history` пачками по 500 элементов; ответ `{accepted, duplicates}`.**
Обоснование: не менять контракт `/agent/state`; размер пачки — по опыту реле с состоянием. Цена ошибки: пачки
описаний тяжелее пачек состояния — при сбоях реле уменьшить.
13. 2026-10-22 — **неполный журнал задачи не держит отметку; ключи — в `result.history.incomplete` и в логе.**
Обоснование: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда. Цена ошибки: потерянные
старые записи у задач с очень длинной историей; видно в результате команды и на живой проверке.
14. 2026-10-22 — **агент отбрасывает элементы, которые хаб отверг бы** (пустые `created`, `field_name`, нет `id`), с
записью в лог. Обоснование: один кривой элемент иначе давал бы 422 на пачку в каждом прогоне. Цена ошибки: такой
элемент не попадёт в ленту.
15. 2026-10-22 — **элемент ленты несёт и строки, и сырые значения (`from_value`/`to_value`); MCP показывает даты по
значению.** Обоснование: строка журнала у дат Jira может быть локализованной. Цена ошибки: ответ чуть тяжелее.
16. 2026-10-22 — **`sync_log` показывает, принят ли журнал прогона (`history_at`).** Обоснование: дизайн обещал видимость
ошибок журнала; текст ошибки — в результате команды. Цена ошибки: одна колонка и строка в MCP.

View File

@ -0,0 +1,50 @@
## Why
Зеркало хаба хранит только текущее состояние задачи: при каждом обновлении строка `issue` перезаписывается, прежние
значения теряются. Владелец хочет быстро видеть, **что произошло с задачами за последние часы или за сегодня** — что
закрылось, у чего поменялось описание, где появился комментарий, у чего сдвинулись сроки, — и **как менялась конкретная
задача**.
Решения владельца 22.10.2026 (brainstorming, jira-hub#70, jira-agent#39):
- **источник — журнал изменений Jira (changelog)**, который тянет агент: точное время и автор каждого изменения, а не
разница снимков хаба;
- **события ленты**: смена статуса (в том числе закрытие), правка описания, новый комментарий, изменение сроков;
- **регулярно — без отдельного «слепого» запроса**: агент из уже выгруженных задач отбирает изменившиеся с прошлого
раза (по `updated`) и только по ним тянет журнал;
- **стартовая загрузка — только незакрытые задачи текущего спринта**, не всё зеркало;
- **где смотреть**: MCP (спросить Claude «что изменилось за 2 часа») и, позже, веб-приложение — страница «Лента» и
история в карточке задачи;
- **веб-приложение в этом change не трогаем**: идёт редизайн (#69), веб-часть — отдельной задачей после него.
## What Changes
- **Журнал изменений в хабе.** Новая таблица `issue_change` (схема v5): одна строка — одно изменённое поле в одной
записи журнала Jira (id записи, порядковый номер поля в ней, ключ задачи, время, автор, поле, было/стало). Повторно
присланные записи отбрасываются по id, поэтому перекрытие окон безопасно. Время комментариев приводится к UTC
(`issue_comment.created_at`), чтобы ленту можно было резать по времени.
- **Отметка «с какого момента».** Хаб при выдаче `refresh` дописывает параметр `history`: `{since}` — время начала
последнего полного прогона, по которому журнал принят, минус запас; если такого прогона нет — `{jql}` стартовой
загрузки (незакрытые задачи открытых спринтов из базовой JQL).
- **Агент v13** (отдельный PR в jira-agent): после выгрузки состояния отбирает задачи с `updated >= since` и одним
`search` с `expand=changelog` на 100 ключей получает их журнал; в режиме стартовой загрузки — отдельный `search` по
`history.jql`. Записи отправляет хабу новым маршрутом `POST /agent/history`; итог — в результате `refresh`.
- **Лента в API.** `GET /changes` — события за период (журнал + новые комментарии), с разрезом по проекту, фильтром по
видам событий и по задаче.
- **MCP.** Новый инструмент `changes` (лента за период: по умолчанию — с начала сегодняшнего дня; `hours`, `since`,
`until`, `kinds`, `key`); `issue` показывает 5 последних изменений задачи.
## Non-Goals
- Веб-приложение: страница «Лента», история в карточке задачи — отдельной задачей после редизайна (#69).
- Событие «задача создана» в ленте — в журнале Jira его нет; можно добавить позже по `issue.created`.
- Правка и удаление комментария — их нет в журнале Jira; в ленте только новые комментарии, пока они есть в Jira.
- История до стартовой загрузки по задачам вне открытых спринтов и по закрытым задачам.
- Срок хранения, чистка журнала — пока храним всё.
- Отдельный запрос журнала без `refresh` или своя периодичность — журнал приходит только вместе с обновлением зеркала.
## Anti-Goals
- Лента молча теряет изменения: например, агент не смог дочитать журнал, а отметка «с какого момента» всё равно
сдвинулась вперёд.
- Каждое автообновление тянет журнал всех задач зеркала и заметно замедляет обновление или упирается в лимиты Jira.
- Старый агент (≤ v12) или сбой чтения журнала ломает обновление зеркала.

View File

@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: Журнал изменений
Если в параметрах `refresh` есть `history`, агент (с v13) SHALL после отправки всех пачек состояния прочитать журнал
изменений (changelog) Jira и отправить его хабу. `history` может содержать `since`, `jql` или оба:
- `since` (регулярная часть) — из задач, выгруженных этим `refresh` (база и соседи), агент SHALL отобрать те, у которых
Jira `updated` не раньше `since` (сравнение моментов времени с учётом смещения), и прочитать их журнал запросом
`search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей; из журнала каждой задачи взять
записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет.
- `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog`
(постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Задача, уже прочитанная регулярной частью,
повторно не читается.
Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал
`GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ
и «получено N из M» и добавить ключ в `history.incomplete` результата.
Элемент для хаба — одно поле записи журнала: `{history_id, seq, key, created, author, field_id, field_name, from,
from_string, to, to_string}`: `history_id` — id записи строкой; `seq` — номер элемента в `items` записи, с 0; `created` —
время записи как есть; `author` — отображаемое имя автора, иначе логин, иначе `null`; `field_name` — `field` элемента;
`field_id` — `fieldId` элемента, а если его нет: для `fieldtype = custom` — id поля, найденный по имени в
`GET /rest/api/2/field` (не больше одного такого вызова за `refresh`), для прочих — `field`; не найден — `null`; `from`,
`fromString`, `to`, `toString` — как есть, нестроковое значение — строкой. Запись без `id` или с пустым `created` и
элемент с пустым `field` агент SHALL отбросить с записью в лог (ключ задачи). Элементы SHALL отправляться
`POST /agent/history` пачками до 500; повторы при отправке — как у пачек состояния.
Результат `refresh` SHALL содержать `history = {issues: <задач с хотя бы одним элементом>, changes: <элементов
отправлено>, incomplete: [<ключи>]}`. Ответ не 2xx (`HttpError`) от Jira или хаба MUST NOT переводить `refresh` в
`failed` и MUST NOT вызывать повтор команды: агент SHALL записать ошибку в лог и вернуть `done`, добавив в `history`
текст ошибки (у ошибки хаба — префикс «хаб: ») — `error` при ошибке регулярной части или отправки элементов,
`backfill_error` при ошибке `search` стартовой загрузки (регулярная часть при этом выполняется). Обрыв связи с Jira или
хабом (`ConnectionError`) на шаге журнала обрабатывается, как в остальном `refresh`. Без `history` в параметрах агент
журнал не читает, ключа `history` в результате нет.
#### Scenario: Ничего не менялось
- **WHEN** `refresh {jql: base, history: {since: "2026-10-22T08:45:00Z"}}`, у всех 180 задач `updated` раньше `since`
- **THEN** вызовов `search` с `expand = changelog` нет; результат `history = {issues: 0, changes: 0, incomplete: []}`
#### Scenario: Две задачи изменились
- **WHEN** тот же `refresh`, у X и Y `updated` позже `since`; у X в журнале записи от вчера и от 09:10 UTC (смена статуса и
резолюции), у Y — от 09:30 UTC (правка описания)
- **THEN** один `search` `key in (X,Y)` с `expand = changelog`; хабу отправлены 3 элемента (у X — `seq` 0 и 1 одной записи,
вчерашняя запись не отправлена); результат `history = {issues: 2, changes: 3, incomplete: []}`
#### Scenario: Стартовая загрузка
- **WHEN** `refresh {jql: base, history: {jql: "(base) AND sprint in openSprints() AND statusCategory != Done"}}`, по ней
12 задач
- **THEN** `search` по `history.jql` с `expand = changelog`; журнал 12 задач отправлен целиком
#### Scenario: Плановая дата без fieldId
- **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим
именем id `customfield_10201`
- **THEN** у элемента `field_id = "customfield_10201"`; `GET /field` вызван один раз за `refresh`
#### Scenario: Ошибка Jira при чтении журнала
- **WHEN** `search` с `expand = changelog` для регулярной части ответил 500
- **THEN** состояние в хабе обновлено; `refresh` — `done` с `history.error`, повтора команды нет; в логе — ошибка
#### Scenario: Ошибка стартовой загрузки
- **WHEN** `history = {since: …, jql: …}`, `search` по `history.jql` ответил 400, у X `updated` позже `since`
- **THEN** журнал X отправлен; `refresh` — `done`, в `history` — `backfill_error` с текстом Jira, `error` нет
#### Scenario: Неполный журнал
- **WHEN** у X `changelog.total = 250`, и `search`, и `GET /issue/X?expand=changelog` вернули 100 записей
- **THEN** отправлены 100 записей X; в логе «X: получено 100 из 250»; `history.incomplete = ["X"]`
#### Scenario: Хаб без журнала
- **WHEN** `refresh {jql: base}` без `history`
- **THEN** журнал не читается; в результате нет ключа `history`

View File

@ -0,0 +1,43 @@
## ADDED Requirements
### Requirement: Журнал изменений в refresh
Отметка журнала — `started_at` последнего `sync_run` с `history_at IS NOT NULL` (любой `query`). JQL стартовой загрузки
— `(<базовая JQL>) AND sprint in openSprints() AND statusCategory != Done`. Хаб SHALL при каждой выдаче `refresh`
(первой и повторной) дописывать в параметры `history` (в базе `params` без него, как `boards`):
- полный прогон (`refresh` по базовой JQL), отметка есть — `{since: <отметка − 15 минут, ISO-8601 UTC с Z>}`; если
`query` прогона-отметки не равна текущей базовой JQL — также `jql: <JQL стартовой загрузки>`;
- полный прогон, отметки нет — `{jql: <JQL стартовой загрузки>}`;
- `refresh {keys}` и `refresh` по небазовой JQL — `{since: …}`, если отметка есть; иначе `history` не дописывается;
- без базовой JQL (нет проектов) — `history` не дописывается.
Результат `refresh` MAY содержать `history` (см. `agent`, «Журнал изменений»); хаб SHALL принимать результат и без
него. Отметку (`sync_run.history_at`) хаб SHALL проставлять по правилам `data-model`, «Отметка журнала в журнале
синхронизаций».
#### Scenario: Первый refresh после выкатки
- **WHEN** проект `AWPCA`, прогонов с `history_at` нет; выдаётся полный `refresh`
- **THEN** в выданных параметрах `history = {jql: '(project = AURORA AND labels in ("AWPCA")) AND sprint in
openSprints() AND statusCategory != Done'}`; в базе `params` без `history`
#### Scenario: Регулярный refresh
- **WHEN** последний полный прогон с `history_at` начат в `2026-10-22T09:00:00+00:00` по текущей базовой JQL; выдаётся
автообновление
- **THEN** `history = {since: "2026-10-22T08:45:00Z"}`
#### Scenario: Журнал не дочитан
- **WHEN** прогон в 10:00 завершился `done` с `history.error`, прогон в 09:00 — с принятым журналом; выдаётся следующий
полный `refresh`
- **THEN** `history.since` — от прогона 09:00
#### Scenario: Новый проект
- **WHEN** прогон с `history_at` в 09:00 по JQL только с `AWPCA`; затем создан проект `STOCKS_DRRK`; выдаётся полный
`refresh`
- **THEN** `history = {since: "…T08:45:00Z", jql: <стартовая по новой базовой JQL>}`
#### Scenario: Хвост пакета
- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, отметка есть
- **THEN** `history = {since: …}`; по завершении `done` с `result.history` у его `sync_run` `history_at = NULL`
#### Scenario: Хвост пакета до первой отметки
- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, прогонов с `history_at` нет
- **THEN** в выданных параметрах нет `history`

View File

@ -0,0 +1,127 @@
## MODIFIED Requirements
### Requirement: Схема базы хаба
База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_note`,
`batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при
старте по номеру версии в `setting.schema_version` (текущая — 5). Миграция существующей базы SHALL выполняться в одной
транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в
`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни
открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их
при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и
дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени
Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`) и значений полей журнала.
Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю
настройку `base_jql` вида `… labels = <M>` или `… labels in (<M1>, …)` и завести проекты с этими метками (название проекта
`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе
никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в
`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи
`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем
перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с
прежним значением.
Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и
`sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не
разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`.
#### Scenario: Первый запуск
- **WHEN** хаб стартует с несуществующим файлом базы
- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700`
#### Scenario: Миграция базы v1
- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed`
- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая
`issue_comment`; данные на месте; `schema_version = 5`
#### Scenario: Миграция базы v2
- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки
- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 5`
#### Scenario: Миграция базы v3
- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`,
в `sync_run` полный прогон с этой `query`
- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql`
и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки,
пакеты на месте; `schema_version = 5`
#### Scenario: Миграция базы v4
- **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`,
в `sync_run` полный прогон
- **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`,
`created` прежний; у прогона `history_at = NULL`; `schema_version = 5`
#### Scenario: refresh в очереди при миграции
- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}`
- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается
полным прогоном
#### Scenario: Два соединения к базе v1
- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения
- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1`
#### Scenario: Открытый каталог данных
- **WHEN** хаб стартует, каталог данных существует с правами `0755`
- **THEN** права каталога и `backups/` — `0700`
## ADDED Requirements
### Requirement: Журнал изменений задач
Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id
записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом),
`created` (время Jira как есть), `author`, `field` (логическое имя), `field_id`, `field_name`, `from_value`,
`from_string`, `to_value`, `to_string`, `fetched_at`; PK `(history_id, seq)`, индексы по `at` и по `(key, at)`.
Записывать в `issue_change` SHALL только приём журнала от агента (`POST /agent/history`); повторно присланная строка с
тем же `(history_id, seq)` MUST NOT менять сохранённую. HTTP API интерфейсов и MCP MUST NOT писать в `issue_change`.
Логическое имя `field` SHALL вычисляться хабом при приёме: `status`, `description`, `summary`, `assignee`, `priority`,
`duedate`, `labels`, `resolution` — если так называется `field_id`, а при его отсутствии — `field_name`; `planned_start`,
`planned_end` — если `field_id` равен id этого поля в настройке `field_ids`; `epic_link` — если `field_id` равен
`epic_link.field_id`; иначе — `field_name` как есть.
Время Jira (`created` журнала, `created` комментария) SHALL приводиться к UTC разбором ISO-8601 со смещением вида
`+0300`, `+03:00` или `Z`, с дробной частью секунд или без; время без смещения считается UTC; неразбираемое — `NULL` в
приведённой колонке (строка журнала с `at = NULL` в ленту не попадает).
Таблица `issue_comment` SHALL хранить также `created_at` — `created`, приведённое к UTC, заполняемое при каждой записи
комментариев.
#### Scenario: Повтор записи журнала
- **WHEN** агент дважды прислал элемент `{history_id: "5001", seq: 0, key: X, field_name: "status", …}`
- **THEN** в `issue_change` одна строка `(5001, 0)`; второй приём ничего не изменил
#### Scenario: Плановая дата по id поля
- **WHEN** в настройках `field_ids.planned_end = "customfield_10201"`, агент прислал элемент с `field_id =
"customfield_10201"`, `field_name = "Плановое окончание"`
- **THEN** у строки `field = "planned_end"`
#### Scenario: Неизвестное поле
- **WHEN** агент прислал элемент с `field_id = null`, `field_name = "Fix Version"`
- **THEN** у строки `field = "Fix Version"`
#### Scenario: Время со смещением
- **WHEN** элемент журнала с `created = "2026-10-22T09:00:00.000+0300"`
- **THEN** у строки `at = "2026-10-22T06:00:00+00:00"`, `created` — как прислано
### Requirement: Отметка журнала в журнале синхронизаций
Таблица `sync_run` SHALL хранить `history_at` — время, когда хаб принял итог журнала по этому прогону (`NULL` — журнал
по прогону не принят). Хаб SHALL проставлять `history_at` при отчёте полного прогона (`refresh` по базовой JQL) со
статусом `done` и `result.history` без `error`; у прочих `refresh`, при `failed`, `cancelled`, без `result.history` или с
`history.error` — оставлять `NULL`. `history.backfill_error` и непустой `history.incomplete` отметку не держат.
#### Scenario: Журнал принят
- **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 3, changes: 7, incomplete: []}`
- **THEN** у его `sync_run` проставлен `history_at`
#### Scenario: Ошибка стартовой загрузки не держит отметку
- **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 1, changes: 2, incomplete: [],
backfill_error: "HTTP 400 …"}`
- **THEN** у его `sync_run` проставлен `history_at`
#### Scenario: Ошибка журнала
- **WHEN** `refresh` завершился `done` с `result.history = {error: "HTTP 500 …"}`
- **THEN** у его `sync_run` `history_at = NULL`
#### Scenario: Старый агент
- **WHEN** агент v12 завершил `refresh` `done` без `result.history`
- **THEN** у его `sync_run` `history_at = NULL`

View File

@ -0,0 +1,80 @@
## ADDED Requirements
### Requirement: Приём журнала изменений
Хаб SHALL принимать от агента журнал изменений задач маршрутом `POST /agent/history` (агентский токен) с телом
`{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from,
from_string, to, to_string}`. До записи хаб SHALL проверить форму всей пачки: `changes` — список объектов;
`history_id`, `key`, `created`, `field_name` — непустые строки; `seq` — целое ≥ 0; `author`, `field_id`, `from`,
`from_string`, `to`, `to_string` — строка или `null`. Нарушение — 422 `bad_history` с номером элемента и именем поля,
пачка не применяется. Принятая пачка записывается в `issue_change` в одной транзакции (см. `data-model`, «Журнал
изменений задач»); ответ — `{accepted, duplicates}` (записано новых строк, отброшено повторов). Задача элемента может
отсутствовать в зеркале — элемент всё равно записывается.
#### Scenario: Пачка журнала
- **WHEN** `POST /agent/history` с тремя элементами, один из них уже есть в `issue_change`
- **THEN** 200 `{accepted: 2, duplicates: 1}`
#### Scenario: Неверный элемент
- **WHEN** `POST /agent/history` с двумя элементами, у второго `seq = "0"`
- **THEN** 422 `bad_history` с номером элемента 1 и полем `seq`; ни один элемент не записан
#### Scenario: Клиентский токен
- **WHEN** `POST /agent/history` клиентским токеном
- **THEN** 401
### Requirement: Лента изменений
Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами:
- `since`, `until` — ISO-8601 со смещением или `Z`; без `until` — текущее время; без `since` — `until` минус 24 часа;
- `project` — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404;
- `key` — события одной задачи (без ограничения проектом);
- `kinds` — через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию —
`status,description,dates,comment`;
- `limit` — по умолчанию 500, не больше 2000.
События: строки `issue_change` с `at` в `[since, until]` — вид `status` (поле `status`), `description` (поле
`description`), `dates` (поля `duedate`, `planned_start`, `planned_end`), `other` (прочие поля); и комментарии
`issue_comment` с `created_at` в `[since, until]` — вид `comment`. Ответ — `{since, until, truncated, changes: [...]}`,
элемент — `{at, key, summary, status, kind, field, author, from, to, from_value, to_value}`: `summary` и `status` —
текущие из зеркала (`null`, если задачи в зеркале нет); `from`/`to` — `from_string`/`to_string` строки журнала, а если
строка `null` — `from_value`/`to_value`; `from_value`/`to_value` — сырые значения строки журнала; у комментария
`field = "comment"`, `from = null`, `to` — текст комментария, `from_value = to_value = null`. Порядок — по `at`
по убыванию, затем `key`, `history_id`, `seq` (комментарии — `comment_id`). Событий больше `limit` — отдаются первые
`limit` по этому порядку и `truncated = true`. Неверное время, `since` позже `until`, неизвестный вид, `limit` вне
1..2000 — 422 `bad_request` с именем параметра.
#### Scenario: Что закрылось за два часа
- **WHEN** в 12:00 UTC `GET /changes?project=AWPCA&since=2026-10-22T10:00:00Z&kinds=status`; в журнале — X (метка
`AWPCA`) «В работе» → «Готово» в 11:15 UTC и Y (метка `STOCKS_DRRK`) в 11:20 UTC
- **THEN** одно событие: X, `kind = status`, `from = "В работе"`, `to = "Готово"`, автор и `at` из журнала
#### Scenario: Комментарий в ленте
- **WHEN** у X комментарий с `created = "2026-10-22T13:30:00.000+0300"` (10:30 UTC), запрос с `since` 10:00 UTC
- **THEN** в ленте событие `kind = comment`, `at = "2026-10-22T10:30:00+00:00"`, `to` — текст комментария
#### Scenario: Прочие поля по запросу
- **WHEN** в журнале X сменился исполнитель; запрос без `kinds`, затем с `kinds=all`
- **THEN** в первом ответе события нет; во втором — `kind = other`, `field = "assignee"`
#### Scenario: Сосед проекта не в ленте
- **WHEN** Z без метки `AWPCA` связана с задачей `AWPCA`; в журнале Z сменился статус; `GET /changes?project=AWPCA`
- **THEN** события Z нет; `GET /changes?key=Z` его отдаёт
#### Scenario: Неверный период
- **WHEN** `GET /changes?since=вчера`
- **THEN** 422 `bad_request` с параметром `since`
### Requirement: Журнал в журнале синхронизаций
`GET /sync-runs` SHALL отдавать у каждого прогона также `history_at` (`null` — журнал по прогону не принят).
#### Scenario: Прогон с журналом
- **WHEN** полный прогон завершился `done` с принятым журналом; `GET /sync-runs`
- **THEN** у прогона `history_at` — время приёма; у хвостового `refresh {keys}` — `null`
### Requirement: История задачи в карточке
`GET /issues/{key}` SHALL отдавать также `changes` — все строки `issue_change` задачи (все виды, включая `other`) в
формате элемента ленты без `summary` и `status`, по `at` по убыванию (строки с `at = NULL` — в конце); журнала нет —
`changes = []`.
#### Scenario: Задача с историей
- **WHEN** у X в журнале смена статуса вчера и правка описания сегодня; `GET /issues/X`
- **THEN** `changes` из двух элементов: первым — правка описания (`kind = description`), вторым — смена статуса

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Лента изменений в MCP
Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`,
`until`, `kinds`, `key`. `hours` и `since` вместе — ответ «либо hours, либо since». Без `hours` и `since` — с начала
текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. В сессии, привязанной к
проекту P, без `key` — `project = P`; с `key` — без проекта. Без привязки и без `key` — ответ «сессия не привязана к
проекту: запустите MCP с --project <метка> (список — projects)», в хаб не ходить; с `key` — работает.
Вид ответа: первая строка — период по местному времени («изменения с 22.10 00:00 по 22.10 14:05»); затем события,
сгруппированные по задаче; задачи — по времени последнего события, новые сверху; у задачи строка «КЛЮЧ тема (до 80
символов) — текущий статус», под ней события по времени, новые сверху, каждое — «ЧЧ:ММ автор: <что>», с датой «ДД.ММ»
перед временем, если событие не в текущих сутках. `<что>`: статус — «статус «было» → «стало»»; описание — «описание
изменено»; срок и плановые даты — «срок / плановое начало / плановое окончание ДД.ММ → ДД.ММ» (дата — из начала
`from_value`/`to_value` вида `ГГГГ-ММ-ДД`, иначе `from`/`to` как есть; пустое значение — «—»);
комментарий — «комментарий: » и первые 200 символов текста с «…»; прочие поля — «<поле>: «было» → «стало»» (значения до
80 символов с «…»). Событий нет — «за период изменений нет». `truncated` — последняя строка «показаны последние N
событий, сузьте период». Ошибка 422 хаба — её текст.
Инструмент `issue` SHALL после комментариев выводить «изменения (N):» и 5 последних элементов `changes` задачи в том же
виде событий (с датой), «ещё M», если их больше 5; при `changes = []` — строки нет.
Инструмент `sync_log` SHALL выводить у каждого прогона «журнал: принят ДД.ММ ЧЧ:ММ» (по `history_at`, местное время)
или «журнал: нет».
#### Scenario: Что закрылось сегодня
- **WHEN** сессия `AWPCA`, `changes {kinds: "status"}` в 14:05; сегодня X переведена в «Готово» в 11:15
- **THEN** ответ содержит строку X с темой и статусом «Готово» и «11:15 Петров Пётр: статус «В работе» → «Готово»»
#### Scenario: За два часа
- **WHEN** сессия `AWPCA`, `changes {hours: 2}`
- **THEN** запрос `GET /changes?project=AWPCA&since=<сейчас − 2 ч>`; в ответе события статусов, описаний, сроков и
комментариев
#### Scenario: Без привязки
- **WHEN** MCP без `--project`, `changes {}`
- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /changes` нет
#### Scenario: Пустой период
- **WHEN** `changes {hours: 1}`, событий нет
- **THEN** ответ — строка периода и «за период изменений нет»
#### Scenario: История в карточке
- **WHEN** `issue {key: X}`, у X 7 элементов журнала
- **THEN** ответ содержит «изменения (7):», 5 последних событий и «ещё 2»