## Context `main` 13e6c16. Устройство, на которое опираются правки: - **Агент** (`jira-agent/src/agent.py`, v9): `refresh` читает задачи `search` с `fields = STATE_FIELDS` (+ плановые даты и поле эпика), `to_state` собирает словарь задачи, пачки по 100 уходят в `POST /agent/state`. Комментарии агент читает только в `ensure_comment` (`GET /issue/{key}/comment`, постранично по 100). - **Хаб**: `mirror.apply_state` пишет в `issue` только столбцы `ISSUE_COLUMNS` — лишние поля задачи (в том числе будущее `comments`) молча пропускаются, поэтому агент v10 совместим с нынешним хабом. `mirror.get_issue` отдаёт задачу, связи (`target_key`/`source_key`, `link_type`), заметку, 10 последних команд. Схема базы — v2 (`db.SCHEMA_VERSION`), миграция с копией базы — `db.migrate`. Тело запроса ограничено 16 МБ (`api.MAX_BODY`). - **Веб-приложение**: `createBrowserRouter(routes, {basename: "/app"})`; `Layout` с ``; маршруты `board`, `issues`, `issues/:key` (страница `IssuePage`). Ссылки на карточку — ключ в `IssuesPage` и «ⓘ» в `board/components/ Card.tsx`. `NoteForm` — форма заметок с `useBlocker` (смена `pathname` при правках требует подтверждения) и `beforeunload`. `useSaveNote` после успеха обновляет кэш задачи и инвалидирует `["issues"]` и `board`. Прокрутка доски при возврате на `/app/board` восстанавливается (M-3 #29). - **Справочники**: `link_types` настроек — логическое имя → тип Jira (`блокирует начало` → `ASUPR_Блокер_Начала`, `блокирует завершение` → `ASUPR_Блокер_Завершения`, `связанные` → `ASUPR_Связанные`); `status_labels` — статус по факту → класс (`fact-done`, `fact-cancelled`, …). ## Goals / Non-Goals **Goals:** карточка открывается окном поверх списка и доски без потери их состояния; по шапке видно состояние задачи, по связям — что держит задачу и что ждёт её, с темой и статусом связанных; комментарии задачи из Jira в карточке со скрытием; агент v10 и хаб совместимы в любом порядке выкатки. **Non-Goals:** см. proposal.md. ## Decisions ### Агент v10 (решения агентской части — `jira-agent/openspec/changes/agent-v10-comments/design.md`) 1. **Поле `comment` в том же `search`.** `STATE_FIELDS` получает `comment`; новых вызовов Jira на обычную задачу нет. Jira Server отдаёт `fields.comment = {comments: [...], total, maxResults, startAt}`. 2. **Дочитывание.** Если `len(comments) < total`, агент читает комментарии этой задачи `GET /issue/{key}/comment` (`startAt`, `maxResults = 100`) до конца и берёт их вместо частичного списка. Вызовы учитываются в темпе и 429 как любые вызовы Jira. 3. **Форма.** `comments: [{id, author, created, updated, body}]` в порядке Jira (старые первыми): `id` — строка, `author` — `author.displayName`, иначе `author.name`, иначе `null`; `created`/`updated` — строки Jira как есть; `body` — текст как есть (`""`, если в Jira пусто). Поле `comments` есть у каждой задачи, прочитанной агентом v10 (пустой список — комментариев нет). 4. **Версия и выкатка.** `AGENT_VERSION = 10`; после merge PR агента — `jira-hub agent publish src/agent.py` (пишет в каталог живого хаба: выполняет владелец или агент по его отдельной команде). ### Хаб 5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`; выборка по `key` идёт по первичному ключу, отдельный индекс не нужен. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом). 6. **Запись.** `apply_state`: для задачи с полем `comments` (список) комментарии этой задачи удаляются и вставляются присланные — в одной транзакции с задачей; задача без поля `comments` своих комментариев не теряет (агент до v10). 7. **Проверка формы** в `h_agent_state` до записи: `comments` — список объектов; `id` — непустая строка; `body` — строка; `author`, `created`, `updated` — строка или `null`. Нарушение — 422 `bad_state` с ключом задачи и полем, пачка не применяется целиком (агент получает ошибку хаба в отчёте `refresh`). 8. **`GET /issues/{key}`**: добавляются `comments` (новые сверху: по `created` убыв., затем `comment_id` убыв.), `epic_summary` (тема задачи-эпика из зеркала или `null`), у каждой связи — `summary` и `status` связанной задачи (`LEFT JOIN issue`, `null`, если её нет в зеркале). Прежние поля и их форма не меняются. 9. **MCP `issue`**: после заметок — «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), затем «ещё M», если их больше 5; нет комментариев — «комментариев нет». ### Веб-приложение: окно 10. **Фоновая страница.** Ссылки на карточку (ключ в списке, «ⓘ» на доске, связи в карточке) передают в `location.state` `{background, depth, direct}`: `background` — адрес страницы под окном (`pathname + search`), `depth` — сколько записей истории занято карточками поверх неё (1 при открытии со страницы, +1 при переходе по связи), `direct` — окно открыто прямым адресом (переносится в переходы по связям). Маршрут верхнего уровня — `{path: "*", element: }`; `Layout` всегда рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с явным адресом: `background` при открытом окне, иначе текущий адрес. Явный адрес нужен всегда: так дерево компонентов страницы не меняется при открытии окна (страница не пересоздаётся), а `useSearchParams`/`useLocation` внутри фоновой страницы видят её собственный адрес, и фильтры списка не сбрасываются. Состояние страницы (фильтры, загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. `ErrorBoundary` страницы получает ключ по адресу страницы, у окна — свой. 11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка): `Layout` заменяет запись истории тем же адресом со `state = {background: /issues, depth: 0, direct: true}`; под окном — список задач без фильтров. Перезагрузка страницы с открытым окном сохраняет `state` (браузер хранит его в записи истории). 12. **Закрытие** (✕, Esc, щелчок по фону): `depth = 0` — `navigate("/issues", {replace: true})`; `depth ≥ 1` — `navigate(-depth)`, браузер возвращается к записи страницы под окном, и «назад» после закрытия ведёт на шаг раньше неё; если окно было открыто прямым адресом (`direct`), запись прямого адреса, на которую вернулись, заменяется на `/issues` (`replace`). Закрытие проходит через блокировщик заметок (п. 14). Вкладки «Доска»/«Задачи» в шапке подсвечиваются по адресу страницы под окном. 13. **Элемент ``** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем сами — закрытие по п. 12 с проверкой заметок; если `cancel` не отменяемый — браузер закрыл окно сам без активации пользователя, — по событию `close` окно открывается снова и закрытие идёт тем же путём). Щелчок по фону — щелчок по самому `` вне области содержимого. Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю ширину. 14. **Несохранённые заметки.** Подтверждение «Есть несохранённые правки заметки. Уйти без сохранения?» при закрытии окна, переходе по связи и уходе в раздел; условие блокировки — смена `pathname` при правках (как сейчас), закрытие по п. 12 проходит через тот же блокировщик. Отказ — окно остаётся с введённым текстом. 15. **Доска под окном.** Сохранение заметки уже инвалидирует `board` и `["issues"]` (`useSaveNote`): фоновая страница смонтирована, поэтому перечитывается и показывает новые «вид работы» и «статус по факту». Отдельного кода нет — поведение закрепляется тестом. ### Веб-приложение: раскладка карточки 16. **Шапка**: ключ; статус Jira (плашка); статус по факту (плашка цветом класса из `status_labels`, если есть); «Открыть в Jira ↗»; ✕; тема; строка «исполнитель · срок · спринт · эпик: <тема эпика>» — пустые части пропускаются, эпик без темы в зеркале — ключом. Срок — `planned_end`, иначе `duedate`; «просрочено» (красным) — срок раньше сегодняшней даты и статус по факту не относится к классам `fact-done`, `fact-cancelled`. 17. **Связи по смыслу.** По `link_types` настроек: входящие `блокирует начало` / `блокирует завершение` — группа «Зависит от», исходящие — «Блокирует»; у строки метка «начало» / «завершение». `связанные` в обе стороны — «Связана с». Тип, которого нет в `link_types`, — «Прочие» с именем типа Jira и направлением («→» исходящая, «←» входящая). Строка: ключ (ссылка — открывает связанную задачу в окне), тема, статус Jira (плашка); связанной задачи нет в зеркале — ключ и «нет в зеркале». Пустая группа не показывается; связей нет — «связей нет». Порядок групп: «Зависит от», «Блокирует», «Связана с», «Прочие»; внутри — по ключу. 18. **Описание** — `pre-wrap` без разбора разметки; выше 8 строк сворачивается с кнопкой «развернуть» / «свернуть» (кнопка только если текст не помещается). Нет описания — раздела нет. 19. **Комментарии** — «Комментарии (N)» с кнопкой «Скрыть» / «Показать»; новые сверху; автор, время (`fmtStamp`), текст `pre-wrap`. Выбор «скрыто» — один на все карточки, в `localStorage` `jira-hub-comments-hidden` (`"1"` — скрыты); недоступный `localStorage` — показаны. Нет комментариев — «комментариев нет» (кнопки нет). 20. **Заметки** — по умолчанию режим чтения: «Вид работы», «Статус по факту», «Сделано», «Осталось», «Заметка владельца» (пустые — «—»), строка «изменено <время>, <автор>»; кнопка «Править» показывает нынешнюю форму `NoteForm` (поля, «Сохранить», «Отмена»); после успешного сохранения — снова режим чтения; «Отмена» без правок — сразу, с правками — подтверждение «Отменить правки заметки?». Заметки ещё нет — режим чтения с «—» и «Править». 21. **Прочие поля**: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, «сосед по связям» — непустые. 22. **Команды моста (N)** — свёрнуты (`
`), внутри нынешняя таблица; команд нет — «команд по задаче не было» и раздел не сворачивается. 23. **Колонки**: под шапкой слева — «Связи», «Описание», «Комментарии»; справа (≈320 px) «Заметки», «Прочие поля», «Команды моста». Уже 900 px — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. 24. **Состояния окна**: загрузка — «загрузка …»; 404 — «Задачи нет в зеркале хаба.» и «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные остаются; поверх ошибки при данных — предупреждение в шапке окна. ### Проверки 25. **Тестовый хаб**: `web/scripts/seed-extras.json` — дополнения к фикстуре доски только для тестового хаба: у AURORA-9001 описание длиннее 8 строк, три комментария и две команды (пакет автора `seed`, команды завершены). `seed-hub.py` применяет их после фикстуры. Связи не добавляются (они меняют доску и её e2e); группа «Прочие» и связь на задачу вне зеркала проверяются в vitest, а связь на задачу вне зеркала (`lower-1`) уже есть в фикстуре. Фикстура доски и её golden-эталоны не меняются. 26. **e2e** (Playwright на :8791): открытие из списка (фильтр и прокрутка списка после закрытия на месте), по «ⓘ» с доски (прокрутка и раскладка доски на месте), переход по связи и «назад», Esc / ✕ / фон, «назад» после закрытия не открывает карточку, прямой адрес и перезагрузка с открытым окном, подтверждение при несохранённой заметке, ширина 600 px, «Скрыть» комментарии переживает перезагрузку. ## Risks / Trade-offs - **Фоновая страница в `Layout`** — нестандартный для data router приём (две отрисовки маршрутов). Риск: `useBlocker` только один на страницу — блокировщик заметок остаётся в окне, фоновая страница блокировщиков не имеет. Проверяется тестами п. 14 и e2e. - **`navigate(-depth)`** полагается на то, что все `depth` шагов истории — наши переходы. Ручное изменение адреса внутри окна сбрасывает `state` → `depth = 0`, закрытие ведёт в список задач (`replace`). Принято. - **Объём состояния**: комментарии увеличивают пачку `POST /agent/state`; при 100 задачах и типичных комментариях это сотни килобайт при пределе 16 МБ. Принято без разбиения. - **Просрочка по статусу по факту** (п. 16): задача, закрытая в Jira без заметки, покажется просроченной. Цена — лишняя красная пометка; списка «закрытых» статусов Jira в настройках нет. ## Migration Plan 1. PR jira-agent (v10) → merge → `jira-hub agent publish` → `agent_status` «обновление: ok v9 → v10». 2. PR jira-hub (#37) → merge → `git pull --ff-only`, `make web` в основном checkout → перезапуск хаба владельцем (`launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`): миграция v2 → v3 с копией базы → `/mcp` reconnect. 3. Ближайший `refresh` (час или «Обновить из Jira») заполняет комментарии. Откат хаба: копия `backups/<…>-schema-v2.sqlite` и предыдущий коммит; агент v10 со старым хабом работает (п. Context). ## Open Questions Нет. ## Decisions made without discussion | Маркер | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| | R-1 | 2026-10-15 | Требования к агенту v10 — дельтой `specs/agent` в этом change jira-hub; в jira-agent — change `agent-v10-comments` только с решениями агентской части (design.md) | Заведённый порядок: спека агента живёт в `jira-hub/openspec/specs/agent/`, так сделаны v9 (hub#30 + agent#24) и предыдущие; «один change на репозиторий» соблюдается — у агента свой change и свой PR | Низкая: перенос дельты в другой change — правка документов | | R-2 | 2026-10-15 | Просрочка — срок раньше сегодня и статус по факту не `fact-done`/`fact-cancelled`; статус Jira не учитывается | Списка «закрытых» статусов Jira в настройках нет, а свой словарь в приложении запрещён требованием «Справочники из настроек хаба» | Лишняя красная пометка у закрытой в Jira задачи без заметки | | R-3 | 2026-10-15 | В карточке показываются все комментарии задачи, без постраничности; в MCP — 5 последних | В задачах проекта комментариев единицы; окно прокручивается внутри | При десятках длинных комментариев — длинная прокрутка окна; лечится «Скрыть» | | R-4 | 2026-10-15 | «Команды моста» свёрнуты всегда при открытии, выбор не запоминается; «Скрыть» комментарии запоминается | Команды нужны редко и по конкретному поводу; комментарии владелец просил скрывать — это предпочтение | Лишний щелчок, если команды смотрят часто | | R-5 | 2026-10-15 | Отмена правки заметки — кнопка «Отмена» с подтверждением при правках (в разделе 1 дизайна был только «Править»/«Сохранить») | Из режима правки нужен выход без сохранения, не закрывая окно | Лишний вопрос при отмене | | R-6 | 2026-10-15 | В Task 4 тесты адаптированы минимально: `within(main)` вместо глобального `getByRole("table")` (в окне есть таблица команд); убран `{exact: true}` (нет в типах); тест ErrorBoundary в App.test.tsx добавляет Boom-маршрут в `PAGE_ROUTES` (unshift/splice в finally), т.к. Layout больше не рисует Outlet | Код плана не компилировался/находил две таблицы; поведение и проверяемые утверждения не изменены | Низкая: только тесты |