diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1004f56..d8908ec 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -18,7 +18,7 @@
| `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` |
| `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib |
| `registry` | реестр модулей: ленивая сборка, `modules()`, `module(name)`, `module_of(тип_команды)`, `agent_modules()`, `DEFAULT_AGENT`, проверка конфликтов (типы команд, маршруты, ключи настроек, поля проекта) | `modules`; модули — ленивым импортом внутри функции |
-| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v8; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) |
+| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v9; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) |
| `settings` | настройки (`setting`), токены (клиентский + по агенту на модуль: `setting.tokens = {client, agents: {<модуль>: <токен>}}`), умолчания модулей (`ensure_defaults`) | `registry` (база — через `conn` вызывающего) |
| `projects` | проекты-метки: таблица `project` (`label`, `name`, `created_at`), проверка метки, создание и правка (`PATCH`) с полями модулей, список = поля ядра + `project_view` модулей | `db`, `registry` |
| `queue` | механика очереди: пакеты, выдача (`claim`), отчёты, отмена, повтор, временные ключи `new:`; смысл типов команд берёт у модуля через `CommandHooks` (`registry.module_of`) | `backup`, `db`, `registry`, `settings`, `errors` |
@@ -37,7 +37,7 @@
| Модуль | Ответственность | Зависит от |
|---|---|---|
-| `schema` | таблицы модуля: `issue`, `issue_link`, `issue_comment`, `issue_attachment`, `attachment_file`, `issue_change`, `issue_note`, `sync_run` и др. | stdlib |
+| `schema` | таблицы модуля: `issue`, `issue_link`, `issue_comment`, `issue_attachment`, `attachment_file`, `issue_change`, `issue_note`, `sync_run`, `jira_board`, `jira_sprint`, `issue_sprint` и др. | stdlib |
| `settings` | умолчания настроек модуля, ключи для агента, проверка значений, темп (`pacing`), перенос из `config.example.json` (`init_from_config`) | `core.settings`, `core.projects`, `projects` |
| `mirror` | зеркало задач, связей, комментариев и вложений; пишет его только `apply_state` (с проверками `check_comments`, `check_attachments`), разрез по проекту, заметки, фильтры, снимок доски `board`, карточка `get_issue` | `core.db`, `history`, `attachments` |
| `history` | журнал изменений `issue_change`: время Jira → UTC (`jira_utc`), проверка и запись пачек агента, `delivery_param` для выдачи `refresh`, лента `feed`, история задачи | `core.db`, `core.settings`, `projects` |
@@ -46,9 +46,10 @@
| `commands` | контракт типов команд jira (`ensure_*`, `refresh`, `fetch_attachment`), валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` |
| `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` |
-| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` |
-| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` |
-| `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` |
+| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` |
+| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `history` |
+| `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` |
+| `mcp` | инструменты MCP: `issues` (колонка SP), `issue`, `sprints`, `sprint`, `backlog` (читают `GET /agile`), `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` |
| `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` |
### Модуль `wiki` (`modules/wiki/`)
@@ -77,7 +78,7 @@
- `Module(name, schema, settings_defaults, settings_obsolete, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз.
- Поля проекта: ядро знает только `label`, `name`, `created_at`. Остальное объявляет модуль: `ProjectField(name, validate, editable)`; значения для `GET /projects` (в т.ч. вычисляемые) отдаёт `project_view(conn, labels)`; `on_project_created(conn, project, fields, author)` и `on_project_updated(...)` пишут свои поля и возвращают пакеты. `POST /projects` принимает поля всех модулей (неизвестное — 422), `PATCH /projects/{label}` — только изменяемые (`editable`). `registry.check` падает при совпадении имени поля у двух модулей или с полем ядра.
-- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`.
+- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail(conn, cmds, project)`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`.
- `before_result(conn, cmd, status, result, files_dir)` ядро вызывает в `report_result` до транзакции отчёта, только для выданной (`claimed`) команды. Нужен, чтобы тяжёлая подготовка (сборка копии вики, запись файлов) шла вне `BEGIN IMMEDIATE` и не держала блокировку базы, в которой ждут `claim` другого агента и веб (рулинг R-6). `ValueError` из него ядро превращает в `RuntimeError` (500, агент повторит отчёт), чтобы не спутать с неверным статусом отчёта.
- `abort_result(conn, cmd)` ядро вызывает, если отчёт не дошёл до `after_result` (поздний или идемпотентный выход, исключение в транзакции): модуль снимает подготовку `before_result` и её блокировку. При успехе подготовку снимает `after_result`.
@@ -97,15 +98,20 @@
его ядро: трекер пакетов и агентов — `web/src/shell/activity/` (`ActivityProvider`: подхват идущего пакета и `GET /agents` раз в 60 с,
опрос пакета каждые 3 с, `useModuleActivity(module)`, тексты `events.ts`/`agentText.ts`), блок состояния панели — `ModuleStatusBlock`, а модуль
даёт только `Summary` — карточку на «Обзоре» (`OverviewPage`: карточки модулей и «Последние пакеты проекта»). Адреса: «Обзор» — `/app/`,
-страницы модуля — `/app/<модуль>/…` (`/app/jira/board`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`,
-`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`.
+страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`,
+`/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные.
+
+Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных
+`boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три
+состояния данных), `BoardColumns` и страницы `BacklogPage` (`/jira/backlog`), `SprintPage` (`/jira/sprint`), `KanbanPage`
+(`/jira/kanban`). Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят.
Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница
(`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:`
скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`),
сводка для «Обзора» (`Summary`) и `status` для ядра; поле «Корень вики» в окне «Новый проект» — через `projectFields`.
-Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Доска связей» (`/app/jira/board`), «Задачи» (`/app/jira/issues`), «Лента»
+Модуль `jira` (`web/src/modules/jira/index.tsx`): страницы «Связи» (`/app/jira/links`, заголовок страницы — «Доска связей»), «Задачи» (`/app/jira/issues`), «Лента»
(`/app/jira/feed`), карточка `/app/jira/issues/{key}` окном поверх страницы (`issues/overlay.tsx`, `IssueModal.tsx`; оболочка рисует
страницу по `location.state.background`). Внутри: `board/` (чистые `model`, `analysis`, `layout`, `storage.ts` — ключи
`localStorage` прежней страницы, у проекта AWPCA прежние, у остальных с суффиксом `@<метка>`; отрисовка `components/`;
@@ -120,7 +126,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
Цепочки зависимостей (Python):
- ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`;
- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`;
-- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `importer → mirror`;
+- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`;
- веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта).
## Потоки данных
@@ -152,10 +158,11 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `core/api`, `modules/*/api`, `client` и `relay` (реле — клиент своего же HTTP).
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`.
-## Выкатка и откат версии с новой схемой (v7, v8)
+## Выкатка и откат версии с новой схемой (v7, v8, v9)
- Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить.
- До kickstart не запускать MCP и CLI `sdlc-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её, а хаб на прежнем коде с такой базой не работает (v7 — `agent_status`, токены; v8 — нет колонки `issue_note.fact_status`: 500 на `GET /issues`, `/board`, запись заметки).
- После kickstart MCP-сессии со старым кодом до reconnect открывают базу своими умолчаниями: ключи, удалённые миграцией, они могут вернуть — их убирает `ensure_defaults` нового кода (`Module.settings_obsolete`).
+- v9 (sprints-backlog): колонки `issue.rank`, `issue.story_points` и таблицы `jira_board`, `jira_sprint`, `issue_sprint` добавляются миграцией; прежний код с базой v9 работает — новые колонки и таблицы он не читает, поэтому откат кода делается без возврата копии базы.
- Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит.
## Открытые вопросы
diff --git a/CLAUDE.md b/CLAUDE.md
index 8c66522..91af62d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА:
- `uv sync` — установка окружения.
- `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба.
-- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, доска — `/app/jira/board?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira.
+- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`).
- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур).
- `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль).
- `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`).
diff --git a/README.md b/README.md
index a012c99..d3dbb30 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,17 @@ MCP вики — отдельный сервер `sdlc-wiki` (`sdlc-hub mcp wiki
Инструменты: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`. Пока у проекта нет корня
вики (`root_set DOC-…` или `wiki_root` в `project_create`), они отвечают «у проекта <метка> нет корня вики».
+Спринты и бэклог (нужна привязка к проекту; читают `GET /agile` проекта сессии): `sprints` — спринты доски с суммами
+задач и SP «выполнено/всего»; `sprint [name]` — спринт (по умолчанию активный) с задачами по колонкам доски;
+`backlog [limit]` — бэклог по рангу (по умолчанию 50 строк). В `issues` есть колонка SP. Команды спринтов и ранга ставятся
+через `batch_create`, например смена состояния спринта с переносом открытых задач (хвост пакета хаб заменит на `refresh` по JQL проекта):
+
+```json
+{"title": "Закрыть спринт 2", "commands": [
+ {"type": "ensure_sprint_meta", "params": {"sprint": "S2", "goal": "Цель", "start_date": "2026-10-14", "end_date": "2026-10-28"}},
+ {"type": "ensure_sprint_state", "params": {"sprint": "S2", "state": "closed", "move_open_to": "S3"}}]}
+```
+
Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта
(метка сравнивается точно, с учётом регистра):
@@ -207,7 +218,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `GET /agent/settings` | агент | настройки для агента (без токенов) |
| `GET /agent/commands/{id}` | агент | команда по id |
| `POST /agent/commands/{id}/result` | агент | отчёт о выполнении (идемпотентно) |
-| `POST /agent/state` | агент | загрузка состояния задач пачками |
+| `POST /agent/state` | агент | загрузка состояния задач пачками; агент v15 — у задач `rank`, `story_points`, `sprint_ids` (заменяет спринты задачи), в пачке `boards` (доска заменяет свою строку и, если `sprints` — список, свои спринты; тип доски пишется проектам с этой доской); без полей — данные прежние; неверная форма — 422 `bad_state` |
| `POST /agent/history` | агент | журнал изменений Jira пачками (агент v13+, по `history` в `refresh`) → `{accepted, duplicates}`; повторы отбрасываются, неверная пачка — 422 `bad_history` |
| `GET /agent/known-keys` | агент | ключи зеркала |
| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` |
@@ -217,6 +228,7 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) |
| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` |
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
+| `GET /agile` | клиент | `?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`, `unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки, выполненные — за 14 дней); списки — по рангу |
| `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») |
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) |
@@ -234,8 +246,8 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
## Веб-приложение
-Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), доска `/app/jira/board?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
-`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira. Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
+Адреса: `http://127.0.0.1:8790/app/` — «Обзор» `/app/?project=<метка>` (карточки модулей Jira и «Вики» с состоянием агента, свежестью данных и обновлением, последние пакеты проекта), «Связи» (доска связей) `/app/jira/links?project=<метка>`, задачи `/app/jira/issues?project=<метка>`, лента изменений `/app/jira/feed?project=<метка>` (период и виды событий, история — и в карточке задачи), вики `/app/wiki?project=<метка>`, карточка `/app/jira/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
+`/links` перенаправляют на «Обзор»; прежние адреса `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` ведут на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). Вход по клиентскому токену (`uv run sdlc-hub tokens`), токен хранится в браузере;
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
```
@@ -250,6 +262,15 @@ make web
переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` —
вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
+Экраны доски (только просмотр: ни перетаскивания, ни кнопок спринтов, ни смены статуса или ранга): «Бэклог»
+`/app/jira/backlog?project=<метка>` (активные и будущие спринты, бэклог, «Прошлые спринты (N)»), «Спринт»
+`/app/jira/sprint?project=<метка>[&sprint=]` (колонки доски активного спринта; устаревший `sprint` — первый активный),
+«Kanban» `/app/jira/kanban?project=<метка>` (колонки доски, «Бэклог (N)» при включённом бэклоге Jira, выполненное за 14 дней).
+Пункты меню зависят от типа доски проекта: у scrum-проекта «Бэклог» и «Спринт», у kanban-проекта «Kanban»; пока тип не известен,
+пунктов нет. Адрес страницы чужого типа показывает «У проекта нет scrum-доски» / «У проекта нет kanban-доски» со ссылкой
+«Связи». Три состояния данных: «Нужен агент v15 и обновление из Jira» (доска ещё не прочитана), «Не удалось прочитать настройки
+доски из Jira» (колонок нет), сами данные. Карточка задачи открывается окном поверх экрана.
+
Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно
(метка, название, необязательно номер доски Jira): после создания приложение переходит в новый проект и показывает ход
пакета обновления. Текущий проект — параметр `project` в адресе; без него приложение подставляет последний выбранный
@@ -289,13 +310,16 @@ make web
| Тип | Параметры |
|---|---|
| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
-| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` |
+| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee`, `story_points` (число ≥ 0 или `null` — очистить) |
| `ensure_comment` | `key`, `marker`, `body` |
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается |
| `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту |
| `refresh` | `keys` или `jql`; при выдаче агенту хаб дописывает `boards` — доски проектов |
+| `ensure_sprint_meta` | `sprint` (имя), необязательные `rename_to`, `start_date`, `end_date` (`ГГГГ-ММ-ДД`), `goal`; `board_id` хаб дописывает при постановке; результат `{sprint_id, created}` |
+| `ensure_sprint_state` | `sprint`, `state` (`active` или `closed`), `move_open_to` (имя спринта или `backlog`, обязателен при `closed`, при `active` — 422); результат `{sprint_id, moved: [ключ…]}` |
+| `ensure_rank` | `key` и ровно одно из `before`, `after` (ключ другой задачи); результат `{changed}` |
Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть
`issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из
@@ -303,7 +327,7 @@ make web
целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`.
Ключ в параметре может быть временным `new:`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же
-пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`):
+пакета. В хвост пакета хаб сам добавляет `refresh`: пакет с `ensure_sprint_meta`/`ensure_sprint_state` получает хвост по JQL проекта пакета; иначе — по затронутым ключам. Пример пакета (`POST /batches`):
```json
{"title": "1538 в спринт 2", "author": "claude", "commands": [
@@ -314,7 +338,13 @@ make web
```
Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с,
-`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`).
+`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`
+30 с, `refresh` 2 с (настройка `pacing`).
+
+Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) ставятся только в пакете проекта (поле `project`); `ensure_rank` — в любом пакете.
+У команд спринта `board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»;
+доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. В пакете проекта у `ensure_rank` `key` должен быть своей задачей.
+Даты проверяются хабом: настоящая дата, начало не позже конца.
`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
@@ -328,6 +358,12 @@ make web
## Эксплуатация
+- Выкатка спринтов и бэклога (порядок обязателен): `git pull` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub`
+ (владелец; миграция схемы v9) → `make web` → `/mcp` reconnect → `agent publish` агента v15 (`uv run sdlc-hub agent publish
+ ../jira-agent/src/agent.py`) → проверка полей Jira банка (ранг, оценка, спринты) → `field_ids.story_points` в настройках,
+ если агент поле оценки не нашёл. `min_agent_version` поднимает владелец после выкатки v15 и после того, как `agent_status`
+ покажет новую версию. Ограничение (R-33): до публикации агента v15 `story_points` в `ensure_fields` агент v14 выполнит как
+ `done` с пустым `changed`; v15 публикуется сразу после перезапуска хаба.
- Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite`
и хранит последние `backup_keep` копий (по умолчанию 30).
- Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый
diff --git a/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md b/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md
new file mode 100644
index 0000000..1de53f9
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md
@@ -0,0 +1,1269 @@
+# Спринты и бэклог (хаб) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Хаб хранит доски, спринты, ранг и оценки задач от агента v15, отдаёт их `GET /agile`, ставит команды
+спринтов и ранга, показывает спринты и бэклог в MCP и на трёх страницах веба (только просмотр); «Доска связей» → «Связи».
+
+**Architecture:** Схема v9 модуля jira (`issue.rank`, `issue.story_points`, `jira_board`, `jira_sprint`,
+`issue_sprint`); запись — только `mirror.apply_state` по `POST /agent/state`; чтение — новый `modules/jira/agile.py`
+(`view`) за маршрутом `GET /agile`, его читают MCP и веб. Команды — расширение `commands.SPEC` и хуков очереди
+(`hooks.py`), хвостовой `refresh` по JQL проекта требует проекта пакета в хуке `tail` (правка контракта ядра).
+Веб — модуль jira: общая часть `web/src/modules/jira/agile/` и страницы «Бэклог», «Спринт», «Kanban»; пункты меню
+с условием по проекту (`NavItem.when`).
+
+**Tech Stack:** Python 3.13 (uv), stdlib HTTP, SQLite, pytest, пакет `mcp` (FastMCP); React 19 + TypeScript + Vite,
+TanStack Query, react-router 8, Vitest, Playwright.
+
+**Spec:** `openspec/changes/sprints-backlog/` (proposal.md, design.md — решения 1–26 и «Контракт данных»,
+specs/{data-model,hub-api,commands,mcp,web-app,agent}/spec.md). Агентская часть (jira-agent v15) — отдельный план в
+репозитории jira-agent; в этом плане её нет.
+
+## Global Constraints
+
+- Комментарии, docstring, тексты интерфейса, документация и сообщения коммитов — на русском; идентификаторы — английские.
+- Коммит: первая строка заканчивается «(#108)», последняя строка — `Co-Authored-By: Claude Opus 5.5 `. Не пушить.
+- Зелёным считается только `make check` целиком (`uv run pytest -q`, затем `cd web && npm run lint && npm test`); перед коммитом каждой задачи — `make check`. В задачах 8 и 12 дополнительно `cd web && npm run e2e` (тестовый хаб :8791; живой хаб :8790 и `~/.local/share/sdlc-hub` не трогать).
+- Перед правкой области — `mcp__codegraph__codegraph_explore` с `projectPath` = `/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/sprints`; пустой ответ — один раз отметить в ledger и дальше Read.
+- Новых зависимостей (Python и npm) нет.
+- Хаб не ходит в Jira. Зеркало (`issue`, `issue_sprint`, `jira_board`, `jira_sprint`) пишет только `mirror.apply_state` по `POST /agent/state`; API интерфейсов и MCP в эти таблицы не пишут.
+- Ядро (`core/`) не получает терминов модулей (`tests/test_registry.py::test_core_has_no_jira_terms`); исключение — история миграций в `core/db.py`.
+- MCP (`modules/jira/mcp.py`) ходит только через `core.client()`; `mirror`, `history`, `db`, `queue`, `agile` не импортирует.
+- Совместимость с агентом v14: пачка без `boards`, `rank`, `story_points`, `sprint_ids` ничего не меняет в этих данных; `min_agent_version` не поднимать (design #14).
+- Веб только смотрит (design #1): на новых страницах нет перетаскивания, кнопок спринтов, смены статуса или ранга.
+- Цвета — только токены `web/src/styles/tokens.css` (проверяет `tokens.test.ts`); стиль страниц — скилл `aura-design` (задачи 9–11).
+- Эталоны `tests/fixtures/baseline/*.expected.json` перезаписываются только `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py`, затем `git diff tests/fixtures/baseline` — в diff только изменения этой задачи.
+- Темп по умолчанию новых типов — 30 с: `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` (и в `DEFAULT_PACING`, и в `pacing_from_config` — как у `ensure_comment`).
+- Тексты из спеки — дословно: «команды спринта — только в пакете проекта»; «у проекта P нет доски Jira»; «у проекта P канбан-доска, спринтов нет»; «тип доски проекта P ещё не известен — дождитесь обновления из Jira»; «У проекта нет scrum-доски» / «У проекта нет kanban-доски»; «Нужен агент v15 и обновление из Jira»; «Не удалось прочитать настройки доски из Jira»; «Нет задач»; «Прошлые спринты (N)»; «не выполнено»; «Нет активного спринта»; «Без колонки»; «за 14 дней»; «Бэклог (N)»; «осталось N дн.»; «срок прошёл»; «у проекта нет доски»; «ещё N закрытых»; «ещё N».
+- Решения, принятые без обсуждения (раздел «Rulings при планировании» и новые при исполнении), дописываются в `openspec/changes/sprints-backlog/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки).
+
+## Review Focus
+
+1. **Время Jira со смещением без двоеточия** (`2026-10-14T09:00:00.000+0300` у полей задачи, `+03:00` у Agile REST) в датах спринтов и `jira_updated`: веб показывает «ДД.ММ», а не «—»/`NaN`; хаб сортирует спринты и отсекает 14 дней kanban по UTC при смешанных смещениях. Тесты: задача 9 (`model.test.ts`, «±HHMM»), задача 3 (`test_sprint_order_mixed_offsets`, `test_kanban_done_cutoff_with_offsets`).
+2. **Устаревший `?sprint=`** в адресе «Спринта» (спринт закрыт, чужой id, мусор) → показан первый активный спринт, а не пустая доска. Тест: задача 10 (`«устаревший ?sprint= — первый активный»`).
+3. **Доска, общая для двух проектов** (метки `AWPCA` и `C` на доске 4216): тип из `boards` пишется обоим проектам, `GET /agile` каждого — свои задачи, спринты общие. Тесты: задача 2 (`test_state_board_type_to_all_projects_of_board`), задача 3 (`test_shared_board_own_issues_only`).
+4. **Повтор команды спринта после смены доски** проекта (другая доска или kanban) → новый `board_id` или 422 «канбан-доска», не старый `board_id` из прежнего пакета. Тест: задача 5 (`test_retry_sprint_state_after_board_became_kanban`).
+5. **Нечисловые оценки** (`NaN`, `Infinity`, `true`, строка `"3"`) в состоянии и в `ensure_fields` → 422, пачка/пакет не применены. Тесты: задача 2 (параметры `test_agent_state_bad_agile_fields_422`), задача 4 (параметры `test_new_types_errors`).
+
+## Rulings при планировании
+
+Нумерация продолжает design.md (R-1…R-12). Задача 1 переносит их в design.md.
+
+- **R-13 (контракт ядра):** `CommandHooks.tail` получает `(conn, cmds, project)` вместо `(cmds)`: хвост пакета со
+ спринтовыми командами — `refresh` по JQL проекта пакета (design #13), а без соединения и проекта хук JQL не построит.
+ Правится ядро (`core/modules.py`, `core/queue.py`), wiki (`tail=lambda conn, cmds, project: None`) и две заглушки в
+ тестах. Цена ошибки — вернуть сигнатуру и строить хвост в `check_batch` (дописывая `refresh` в конец пакета).
+- **R-14 (форма `boards`):** у доски в `boards` поле `sprints` отсутствует или `null` — спринты доски в зеркале не
+ меняются (агент не смог их прочитать, agent spec «прежние правила для спринтов»); `[]` — у доски спринтов нет, строки
+ удаляются. `columns` отсутствует или `null` — пишется `NULL`. `boards: null` в пачке — как отсутствие. Цена ошибки —
+ устаревшие спринты на экране до следующего `refresh` с `sprints: []`.
+- **R-15 (проверки постановки):** хаб проверяет `start_date`/`end_date` (`ГГГГ-ММ-ДД`, настоящая дата, начало не позже
+ конца), непустые `sprint`/`rename_to`, `state` ∈ {`active`, `closed`}, `move_open_to` (обязателен при `closed`, 422
+ при `active`, не равен `sprint`), у `ensure_rank` ровно одно из `before`/`after` и не сама задача, `story_points` —
+ конечное число ≥ 0 или `null`. Цена ошибки — правка проверок.
+- **R-16 (подтверждение по зеркалу):** `ensure_fields` со `story_points` подтверждается, только если
+ `issue.story_points == params.story_points` точно (`null` = `null`, `0` ≠ `null`); прочие поля — как раньше.
+ Цена ошибки — лишняя довыдача.
+- **R-17 (`GET /agile`):** `board_type = null` → `sprints: []`, `backlog: null`, `board_issues: null`; scrum-бэклог
+ исключает задачи в активном/будущем спринте любой доски из `jira_sprint` (спринт, которого нет в `jira_sprint`, не
+ учитывается); kanban: выполненная задача без `jira_updated` не показывается; `kanban_backlog` в ответе — `true`/
+ `false`/`null`; суммы `points`/`points_done` — сумма чисел (без оценок — `0`). Цена ошибки — правка выборки.
+- **R-18 (Kanban с бэклогом):** при `kanban_backlog = true` первая колонка доски не рисуется — её задачи в «Бэклог (N)»
+ (как kanban-бэклог Jira). Цена ошибки — вернуть колонку.
+- **R-19 (три состояния):** «Нужен агент v15 и обновление из Jira» — `board = null` или `board.fetched_at = null`;
+ «Не удалось прочитать настройки доски из Jira» — `fetched_at` есть, `columns = null` (design #23 вместо
+ противоречивой фразы web-app «`board.columns = null` или ответ без доски»). Цена ошибки — правка условия.
+- **R-20 (кнопка «Обновить»):** на экранах — существующая `RefreshButton` («Обновить из Jira», в шапке и в сообщениях
+ трёх состояний). Правило «кнопок нет, кроме раскрытия секций и карточки» к ней не относится: она читает Jira, а не
+ пишет. Цена ошибки — убрать кнопку из шапки.
+- **R-21 (переименование):** «Связи» — подпись пункта меню, заголовок вкладки и ссылка в карточке jira на «Обзоре»;
+ заголовок `h1` страницы остаётся «Доска связей» (спека говорит о меню и вкладке). Цена ошибки — правка `BoardHeader`
+ и e2e `geometry.spec.ts`.
+- **R-22 (смена проекта):** фраза «Проект в приложении» («смена проекта на странице, которой нет у нового проекта,
+ открывает «Обзор»») не меняется в коде: других условных страниц нет, а для экранов доски действует design #21
+ (адрес остаётся, сообщение «У проекта нет …-доски») — `Layout.select` уже сохраняет раздел. Цена ошибки — переход
+ на «Обзор» для новых страниц.
+- **R-23 (NotFound):** «не найдено» перечисляет только безусловные пункты (без `when`). Цена ошибки — нет.
+- **R-24 (склонение):** «N задач» — с русским склонением (1 задача, 2 задачи, 5 задач). Цена ошибки — нет.
+- **R-25 (тексты MCP):** шапки таблиц и отказы `sprints`/`sprint`/`backlog` — по задаче 7. Цена ошибки — правка текста.
+- **R-26 (даты):** хаб сортирует спринты по `history.jira_utc(дата)`; веб перед `new Date` приводит `±HHMM` к `±HH:MM`.
+ Цена ошибки — нет.
+- **R-27 (e2e):** тестовый хаб засевает второй проект `KANBAN_E2E` (доска 5120, kanban, задачи AURORA-9101…9103);
+ доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки — правка сида.
+
+---
+
+### Task 1: Схема v9 и миграция v8 → v9
+
+**Files:**
+- Modify: `src/sdlc_hub/modules/jira/schema.py` (таблица `issue`, новые таблицы в конце `SQL`)
+- Modify: `src/sdlc_hub/core/db.py:11` (`SCHEMA_VERSION`), `:119-124` (`_add_column`), `:128-139` (`MIGRATIONS`)
+- Modify: `tests/test_db.py` (новый `_v8_database`, тесты, `== 8` → `== db.SCHEMA_VERSION` в строках 405, 419, 431)
+- Modify: `tests/fixtures/baseline/schema.expected.json` (перезапись)
+- Modify: `ARCHITECTURE.md` (строка `db` — «v9»; строка `schema` модуля jira — новые таблицы; раздел «Выкатка и откат версии с новой схемой» — абзац v9)
+- Modify: `openspec/changes/sprints-backlog/design.md` (раздел «Decisions made without discussion» — R-13…R-27 из этого плана)
+
+**Interfaces:**
+- Produces: колонки `issue.rank TEXT`, `issue.story_points REAL`; таблицы
+ `jira_board(board_id INTEGER PRIMARY KEY, type TEXT, columns TEXT, kanban_backlog INTEGER, fetched_at TEXT NOT NULL)`,
+ `jira_sprint(sprint_id INTEGER PRIMARY KEY, board_id INTEGER NOT NULL, name TEXT NOT NULL, state TEXT NOT NULL, start_date TEXT, end_date TEXT, complete_date TEXT, goal TEXT)` + индекс `ix_jira_sprint_board(board_id)`,
+ `issue_sprint(key TEXT NOT NULL, sprint_id INTEGER NOT NULL, PRIMARY KEY (key, sprint_id))` + индекс `ix_issue_sprint_sprint(sprint_id)`;
+ `db.SCHEMA_VERSION == 9`; `db._add_column(table: str, column: str, decl: str = "TEXT")`.
+
+- [ ] **Step 1: Write the failing tests** (`tests/test_db.py`)
+
+```python
+def _v8_database(path):
+ """База v8: задача X со спринтом «Спринт 2», без колонок rank/story_points и таблиц досок (sprints-backlog)."""
+ conn = db.connect(path)
+ settings.ensure_defaults(conn)
+ conn.execute("INSERT INTO issue(key, summary, sprint, labels, fetched_at) VALUES ('X', 'т', 'Спринт 2', '[]', ?)", (db.now(),))
+ for col in ("rank", "story_points"):
+ conn.execute(f"ALTER TABLE issue DROP COLUMN {col}")
+ for t in ("jira_board", "jira_sprint", "issue_sprint"):
+ conn.execute(f"DROP TABLE {t}")
+ conn.execute("UPDATE setting SET value = '8' WHERE key = 'schema_version'")
+ conn.close()
+
+def test_migrates_v8_adds_agile_columns(tmp_path): # сценарий «Миграция базы v8»
+ path = tmp_path / "hub.sqlite"; _v8_database(path)
+ conn = db.connect(path)
+ assert db._schema_version(conn) == 9
+ row = conn.execute("SELECT rank, story_points, sprint FROM issue WHERE key = 'X'").fetchone()
+ assert (row["rank"], row["story_points"], row["sprint"]) == (None, None, "Спринт 2")
+ types = {r["name"]: r["type"] for r in conn.execute("PRAGMA table_info(issue)")}
+ assert types["rank"] == "TEXT" and types["story_points"] == "REAL"
+ for t in ("jira_board", "jira_sprint", "issue_sprint"):
+ assert conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] == 0
+ assert len(list((tmp_path / "backups").glob("*-schema-v8.sqlite"))) == 1
+ conn.close()
+
+def test_v9_reconnect_is_stable(tmp_path): # повторный connect не мигрирует и не копирует
+ path = tmp_path / "hub.sqlite"; _v8_database(path)
+ db.connect(path).close(); db.connect(path).close()
+ assert len(list((tmp_path / "backups").glob("*-schema-v*.sqlite"))) == 1
+```
+
+В `test_connect_creates_schema` добавить: `{"jira_board", "jira_sprint", "issue_sprint"} <= names` и
+`{"rank", "story_points"} <= {r["name"] for r in conn.execute("PRAGMA table_info(issue)")}`.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_db.py -q`
+Expected: FAIL — `no such column: rank` в `_v8_database` / `schema_version == 8`.
+
+- [ ] **Step 3: Implement**
+
+`schema.py`: в `issue` после `sprint` — `rank TEXT`, `story_points REAL`; новые таблицы и индексы из Interfaces.
+Индексов по новым колонкам `issue` в `SQL` не заводить: `executescript(schema())` идёт до миграции, на базе v8 колонок
+ещё нет. `db.py`: `_add_column(table, column, decl="TEXT")` (`ALTER TABLE … ADD COLUMN {column} {decl}`, если колонки
+нет); `MIGRATIONS[8] = [_add_column("issue", "rank"), _add_column("issue", "story_points", "REAL")]` с комментарием
+«v8 → v9: ранг и оценка задачи; таблицы досок и спринтов создаются схемой (sprints-backlog)»; `SCHEMA_VERSION = 9`.
+Старые сборщики баз в тестах (`_v1_database`… `_v7_database`) не трогать: шаг идемпотентен.
+
+- [ ] **Step 4: Run tests, rewrite schema baseline**
+
+Run: `uv run pytest tests/test_db.py -q` → PASS.
+Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_schema -q && git diff tests/fixtures/baseline/schema.expected.json`
+Expected: в diff — `schema_version` 9, колонки `rank`/`story_points` у `issue`, три таблицы и два индекса; больше ничего.
+
+- [ ] **Step 5: Docs, design.md, `make check`, commit**
+
+ARCHITECTURE: `db` — «миграции … (v9 …)»; `schema` jira — `jira_board`, `jira_sprint`, `issue_sprint`; в разделе выкатки
+(заголовок → «(v7, v8, v9)») абзац: v9 добавляет колонки и таблицы, прежний код с базой v9 работает (новые колонки
+и таблицы не читает), откат кода — без возврата копии базы. design.md — R-13…R-27 (дата 24.10.2026, по строке, с ценой).
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/core/db.py src/sdlc_hub/modules/jira/schema.py tests/test_db.py tests/fixtures/baseline/schema.expected.json ARCHITECTURE.md openspec/changes/sprints-backlog/design.md
+git commit -m "Схема v9: ранг, оценка, доски и спринты (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 2: Приём досок, спринтов, ранга и оценок в `POST /agent/state`
+
+**Files:**
+- Modify: `src/sdlc_hub/modules/jira/mirror.py` (новые `check_agile_fields`, `check_boards`; `apply_state`)
+- Modify: `src/sdlc_hub/modules/jira/api.py:23-35` (`h_agent_state`)
+- Test: `tests/test_mirror.py`, `tests/test_api.py`
+- Modify: `README.md` (строка `POST /agent/state` таблицы «HTTP API»)
+
+**Interfaces:**
+- Consumes: таблицы задачи 1; `projects.record_board_types(conn, {"": type}) -> int`.
+- Produces:
+ - `mirror.SPRINT_STATES = ("active", "future", "closed")`
+ - `mirror.check_agile_fields(issues: list[dict]) -> None` — `ValueError` с текстом `": rank — строка или null"`, `": story_points — число или null"` (bool, `NaN`, `±Infinity` — нарушение), `": sprint_ids — список целых"` (`null`, bool, строки — нарушение). Поле не прислано — не проверяется.
+ - `mirror.check_boards(boards) -> None` — `None` → ничего; не список → `"boards — список досок"`; элемент не объект или `id` не целое > 0 → `"boards[]: …"`; дальше префикс `"доска : "` и поле: `type — scrum, kanban или null`, `columns — список колонок или null`, `columns[].name — строка`, `columns[].statuses — список строк`, `kanban_backlog — true, false или null`, `sprints — список спринтов или null`, `sprints[].id — целое > 0`, `sprints[].name — непустая строка`, `sprints[].state — active, future или closed`, `sprints[]. — строка или null`.
+ - `mirror.apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict` (возврат прежний `{"issues", "links"}`).
+
+- [ ] **Step 1: Write the failing tests** (`tests/test_mirror.py`; в фикстуре `conn` добавить нужный `add_project`, где он используется)
+
+```python
+COLS = [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}]
+def board(id=4216, type="scrum", sprints=(), **over):
+ return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over}
+def sprint(id, state, name=None, **over):
+ return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None,
+ "complete_date": None, "goal": None, **over}
+def sprint_rows(conn, key):
+ return sorted(r[0] for r in conn.execute("SELECT sprint_id FROM issue_sprint WHERE key = ?", (key,)))
+
+def test_state_issue_in_two_sprints(conn): # сценарий «Задача прошла два спринта»
+ mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [], boards=[board(sprints=[sprint(101, "closed"), sprint(102, "active")])])
+ assert sprint_rows(conn, "X") == [101, 102]
+
+def test_state_sprint_ids_replace_keep_clear(conn):
+ mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [])
+ mirror.apply_state(conn, [issue("X", sprint_ids=[102])], []); assert sprint_rows(conn, "X") == [102]
+ mirror.apply_state(conn, [issue("X")], []); assert sprint_rows(conn, "X") == [102] # агент v14
+ mirror.apply_state(conn, [issue("X", sprint_ids=[])], []); assert sprint_rows(conn, "X") == []
+
+def test_state_rank_and_points_set_keep_clear(conn):
+ mirror.apply_state(conn, [issue("X", rank="0|i0001:", story_points=3)], [])
+ mirror.apply_state(conn, [issue("X", summary="другая")], []) # без полей — прежние значения
+ row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone()
+ assert (row["rank"], row["story_points"]) == ("0|i0001:", 3.0)
+ mirror.apply_state(conn, [issue("X", rank=None, story_points=None)], [])
+ row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone()
+ assert (row["rank"], row["story_points"]) == (None, None)
+
+def test_state_board_replaces_row_and_sprints(conn): # сценарий «Пачка с досками»
+ mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active", "старое"), sprint(102, "future")]),
+ board(id=5120, type="kanban", sprints=[sprint(201, "active")])])
+ new = [sprint(101, "closed", complete_date="2026-10-14T18:00:00.000+03:00"), sprint(102, "active"), sprint(103, "future")]
+ mirror.apply_state(conn, [], [], boards=[board(columns=COLS[:1], sprints=new)])
+ rows = {r["sprint_id"]: dict(r) for r in conn.execute("SELECT * FROM jira_sprint WHERE board_id = 4216")}
+ assert set(rows) == {101, 102, 103} and rows[101]["state"] == "closed" and rows[101]["name"] == "S101"
+ assert rows[101]["complete_date"] == "2026-10-14T18:00:00.000+03:00"
+ b = conn.execute("SELECT * FROM jira_board WHERE board_id = 4216").fetchone()
+ assert json.loads(b["columns"]) == COLS[:1] and b["kanban_backlog"] is None and b["fetched_at"]
+ assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint WHERE board_id = 5120")] == [201] # не в пачке — не тронута
+
+def test_state_board_without_sprints_keeps_them(conn): # R-14
+ mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active")])])
+ mirror.apply_state(conn, [], [], boards=[{**board(), "sprints": None, "columns": None}])
+ assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint")] == [101]
+ assert conn.execute("SELECT columns FROM jira_board").fetchone()[0] is None
+ mirror.apply_state(conn, [], [], boards=[board(sprints=[])])
+ assert conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 0
+
+def test_state_board_type_to_all_projects_of_board(conn, add_project): # Review Focus 3
+ add_project(conn, board_type=None); add_project(conn, "C", board_id=4216, board_type=None)
+ mirror.apply_state(conn, [], [], boards=[board(type="kanban", kanban_backlog=True)])
+ assert {r["label"]: r["board_type"] for r in conn.execute("SELECT label, board_type FROM jira_project")} == {"AWPCA": "kanban", "C": "kanban"}
+ assert tuple(conn.execute("SELECT type, kanban_backlog FROM jira_board").fetchone()) == ("kanban", 1)
+ mirror.apply_state(conn, [], [], boards=[board(type=None)]) # null тип проекта не меняет
+ assert conn.execute("SELECT board_type FROM jira_project WHERE label = 'AWPCA'").fetchone()[0] == "kanban"
+
+def test_state_v14_keeps_agile_data(conn): # сценарий «Агент v14»
+ mirror.apply_state(conn, [issue("X", rank="r", story_points=2, sprint_ids=[101])], [], boards=[board(sprints=[sprint(101, "active")])])
+ mirror.apply_state(conn, [issue("X")], [])
+ assert sprint_rows(conn, "X") == [101] and conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
+ assert tuple(conn.execute("SELECT rank, story_points FROM issue").fetchone()) == ("r", 2.0)
+```
+
+`tests/test_api.py`:
+
+```python
+@pytest.mark.parametrize("over, detail", [
+ ({"sprint_ids": None}, "A-1: sprint_ids"), ({"sprint_ids": ["1"]}, "A-1: sprint_ids"), ({"sprint_ids": [True]}, "A-1: sprint_ids"),
+ ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"),
+ ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5
+ ({"rank": 5}, "A-1: rank"),
+])
+def test_agent_state_bad_agile_fields_422(hub, over, detail):
+ with pytest.raises(HubError) as e:
+ hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": [], **over}], "links": []})
+ assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail
+ assert hub["client"].get("/issues")["issues"] == [] # пачка не применена
+
+@pytest.mark.parametrize("boards, detail", [
+ ("x", "boards — список досок"), ([{"id": "4216"}], "boards[0].id"),
+ ([{"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "sprints": [{"id": 1, "name": "S", "state": "closing"}]}],
+ "доска 4216: sprints[0].state — active, future или closed"),
+ ([{"id": 4216, "type": "sprint"}], "доска 4216: type"),
+ ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"),
+])
+def test_agent_state_bad_boards_422(hub, boards, detail): ... # тот же вызов с issues: [], links: [], boards — 422 bad_state, detail
+
+def test_agent_state_empty_batch_with_boards(hub): ... # {"issues": [], "links": [], "boards": [board]} → 200; GET /agile (задача 3) ещё нет — проверка строкой jira_board через hub["hub"].conn()
+```
+
+(Вызов `HubClient.post` сериализует `NaN`/`Infinity` литералами — сервер их разбирает `json.loads`, поэтому проверка
+конечности нужна в `check_agile_fields`.)
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_mirror.py tests/test_api.py -q -k "state"`
+Expected: FAIL — `apply_state() got an unexpected keyword argument 'boards'`, у задачи нет колонки в upsert.
+
+- [ ] **Step 3: Implement**
+
+`apply_state`: `rank` и `story_points` попадают в upsert только если ключ есть в словаре задачи (строка `cols`
+собирается по задаче; `ISSUE_COLUMNS` не расширять — иначе агент v14 затрёт значения `NULL`); `sprint_ids` — список →
+`DELETE FROM issue_sprint WHERE key = ?` и вставка. `boards` (список) — в той же транзакции: `INSERT OR REPLACE INTO
+jira_board` (`columns` — `json.dumps(..., ensure_ascii=False)` или `NULL`, `kanban_backlog` — 1/0/`NULL`,
+`fetched_at = fetched_at`); `sprints` — список → `DELETE FROM jira_sprint WHERE board_id = ?` и `INSERT OR REPLACE`
+по каждому, иначе не трогать (R-14); `type in projects.BOARD_TYPES` → `projects.record_board_types(conn, {str(id):
+type})`. `h_agent_state`: `boards = body.get("boards")`; до транзакции `mirror.check_agile_fields(issues)` и
+`mirror.check_boards(boards)` (в том же `try` → `ApiError(422, "bad_state", str(e))`); передать `boards=boards`.
+Docstring `apply_state` дополнить: поля v15 и правило «без поля — прежнее значение».
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest tests/test_mirror.py tests/test_api.py -q` → PASS.
+
+- [ ] **Step 5: README, `make check`, commit**
+
+README, строка `POST /agent/state`: «загрузка состояния задач пачками; агент v15 — у задач `rank`, `story_points`,
+`sprint_ids` (заменяет спринты задачи), в пачке `boards` (доска заменяет свою строку и, если `sprints` — список, свои
+спринты; тип доски пишется проектам с этой доской); без полей — данные прежние; неверная форма — 422 `bad_state`».
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/modules/jira/mirror.py src/sdlc_hub/modules/jira/api.py tests/test_mirror.py tests/test_api.py README.md
+git commit -m "Приём досок, спринтов, ранга и оценок от агента v15 (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 3: `GET /agile` — доска, спринты, бэклог
+
+**Files:**
+- Create: `src/sdlc_hub/modules/jira/agile.py`
+- Modify: `src/sdlc_hub/modules/jira/api.py` (обработчик `h_agile`, строка в `ROUTES`)
+- Test: `tests/test_agile.py` (новый)
+- Modify: `tests/fixtures/baseline/routes.expected.json` (перезапись)
+- Modify: `README.md` (строка `GET /agile` в «HTTP API»), `ARCHITECTURE.md` (строка `agile` в таблице модуля jira, `api` — `/agile`)
+
+**Interfaces:**
+- Consumes: `mirror.apply_state(..., boards=)` (задача 2) — в тестах для данных; `history.jira_utc(value) -> str | None`.
+- Produces:
+ - `agile.KANBAN_DONE_DAYS = 14`
+ - `agile.view(conn, label: str, now: datetime | None = None) -> dict` — `{board, sprints, backlog, board_issues}` по «Контракту данных» design.md; строка задачи — `{key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done}`; спринт — `{id, name, state, start_date, end_date, complete_date, goal, count, count_done, unestimated, points, points_done, issues}`; `board` — `{id, type, columns, kanban_backlog: bool | None, fetched_at}` (тип — из `jira_project.board_type`).
+ - `api.h_agile(hub, conn, m, q, body)`: без `project` — `ApiError(422, "bad_request", "project — метка проекта")`; неизвестный — 404 (`project_param`). Маршрут `("GET", r"^/agile$", h_agile, "client")`.
+
+- [ ] **Step 1: Write the failing tests** (`tests/test_agile.py`)
+
+Фикстура `conn(open_db, add_project)`: база в памяти, `settings.ensure_defaults`, `add_project(conn)` (AWPCA 4216 scrum).
+Хелперы `issue(key, status, **over)` (метки `["AWPCA"]`), `load(conn, issues, boards)` через `mirror.apply_state`,
+`keys(rows) -> list[str]`. Колонки `COLS = [К выполнению: Backlog, Ожидает анализа; В работе: В работе; Готово: Готово,
+Протестировано, Снято]`.
+
+```python
+def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог»
+ load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"),
+ issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])],
+ [board(sprints=[sprint(102, "active")])])
+ v = agile.view(conn, "AWPCA")
+ s = v["sprints"][0]
+ assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1)
+ assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None
+
+def test_done_is_last_column(conn): # сценарий «Колонки доски»
+ load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
+ assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False}
+
+def test_project_without_board(conn, add_project): # сценарий «Проект без доски»
+ add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None)
+ assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None}
+
+def test_board_without_agent_data(conn): # design #23, состояние 1
+ load(conn, [issue("A", "Готово")], [])
+ v = agile.view(conn, "AWPCA")
+ assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None}
+ assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет
+
+def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1
+ load(conn, [], [board(sprints=[
+ sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"),
+ sprint(9, "future"), sprint(7, "future"),
+ sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])])
+ assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC
+
+def test_rank_order_nulls_last_then_key(conn):
+ load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"),
+ issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()])
+ assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"]
+
+def test_only_own_issues_and_empty_sprint(conn): # design #8
+ load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
+ s = agile.view(conn, "AWPCA")["sprints"][0]
+ assert s["issues"] == [] and s["count"] == 0
+
+def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3
+ add_project(conn, "C", board_id=4216)
+ load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
+ assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"]
+
+def test_closed_sprint_done_by_current_status(conn): # design #7
+ load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])])
+ assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1
+
+def test_backlog_keeps_issue_of_closed_sprint(conn):
+ load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])],
+ [board(sprints=[sprint(101, "closed"), sprint(103, "future")])])
+ assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"]
+
+def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных»
+ add_project(conn, "K", board_id=5120, board_type="kanban")
+ now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
+ load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"),
+ kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"),
+ kissue("K6", "Готово", jira_updated=None)],
+ [board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]},
+ {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])])
+ v = agile.view(conn, "K", now=now)
+ assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"]
+ assert v["board"]["kanban_backlog"] is True
+
+def test_kanban_without_backlog(conn, add_project): # design #17
+ # та же доска с kanban_backlog=False → backlog is None, K1 в board_issues
+
+def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC
+ # now = 2026-10-24T12:00Z; jira_updated "2026-10-10T14:30:00.000+0300" (11:30Z, старше 14 дней) — нет;
+ # "2026-10-10T15:30:00.000+0300" (12:30Z) — есть
+
+def test_agile_route(hub_server, add_project):
+ # без project → 422 bad_request; ?project=NOPE → 404; ?project=AWPCA → 200 и ключи {"board", "sprints", "backlog", "board_issues"}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_agile.py -q`
+Expected: FAIL — `ImportError: cannot import name 'agile'`.
+
+- [ ] **Step 3: Implement `agile.view` and `h_agile`**
+
+Чтение в одной транзакции чтения (как `mirror.board`: `BEGIN` … `COMMIT`, если транзакции нет). Свои задачи — метка
+проекта (`json_each(labels)`), как `own` в `SCOPE_CTE`. Членство — `issue_sprint` своих задач; спринты доски —
+`jira_sprint WHERE board_id`. «Выполнено» — `status` в `statuses` последней колонки (`columns` NULL — никто).
+Сортировка задач — ключ `(rank is None, rank or "", key)`; спринтов — `active` по `jira_utc(start_date)` (None — в
+конце), `future` по id, `closed` по `jira_utc(complete_date)` по убыванию (None — в конце), при равенстве — id.
+Scrum-бэклог — свои задачи, не состоящие в спринте с `state` `active`/`future` (по `jira_sprint` любой доски, R-17), и
+не выполненные. Kanban: `sprints = []`; бэклог — статусы первой колонки при `kanban_backlog = 1`, иначе `None`;
+`board_issues` — остальные свои задачи; выполненная — только если `jira_utc(jira_updated)` не старше
+`now - 14 дней` (без `jira_updated` — нет). `board_type = NULL` → `sprints: []`, `backlog: None`, `board_issues: None`.
+
+- [ ] **Step 4: Run tests, rewrite routes baseline**
+
+Run: `uv run pytest tests/test_agile.py -q` → PASS.
+Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_routes -q && git diff tests/fixtures/baseline/routes.expected.json`
+Expected: одна новая строка `["GET", "^/agile$", "client", "h_agile"]`.
+
+- [ ] **Step 5: Docs, `make check`, commit**
+
+README: строка `GET /agile` — «`?project=<метка>` (без — 422, неизвестная — 404): доска проекта (`board`, тип из
+проекта; `null` — доски нет), все спринты доски со своими задачами и суммами оценок (`count`, `count_done`,
+`unestimated`, `points`, `points_done`), `backlog` (scrum — свои задачи вне активных и будущих спринтов, не
+выполненные; kanban — первая колонка, если бэклог доски включён), `board_issues` (kanban: остальные колонки,
+выполненные — за 14 дней); списки — по рангу». ARCHITECTURE: строка `agile` («чтение для `GET /agile`: доска,
+спринты, бэклог, выполнено по последней колонке; зависит от `core.db`, `history`»), в `api` — `/agile`, в цепочке
+модуля jira — `api → agile → history`.
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/modules/jira/agile.py src/sdlc_hub/modules/jira/api.py tests/test_agile.py tests/fixtures/baseline/routes.expected.json README.md ARCHITECTURE.md
+git commit -m "GET /agile: доска, спринты и бэклог проекта (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 4: Контракт команд: новые типы, `story_points`, темп
+
+**Files:**
+- Modify: `src/sdlc_hub/modules/jira/commands.py` (`SPEC`, `FIELD_HINTS`, `_type_error`, `validate`, `check_confirmed`)
+- Modify: `src/sdlc_hub/modules/jira/settings.py:5-6` (`DEFAULT_PACING`), `:74-78` (`pacing_from_config`)
+- Test: `tests/test_commands.py`, `tests/test_settings.py`
+- Modify: `tests/fixtures/baseline/settings.expected.json`, `tests/fixtures/baseline/mcp_tools.expected.json` (перезапись: описание `batch_create` собирается из `SPEC`)
+- Modify: `README.md` (раздел «Контракт команд»: строки таблицы и абзац о зазорах)
+
+**Interfaces:**
+- Produces:
+ - `commands.SPEC["ensure_sprint_meta"] = {"required": ["sprint"], "optional": ["rename_to", "start_date", "end_date", "goal"], "keys": [], "hints": {"sprint": "имя спринта", "rename_to": "новое имя", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD", "goal": "цель спринта"}}`
+ - `commands.SPEC["ensure_sprint_state"] = {"required": ["sprint", "state"], "optional": ["move_open_to"], "keys": [], "hints": {"state": "active|closed", "move_open_to": "имя спринта или backlog; обязателен при closed"}}`
+ - `commands.SPEC["ensure_rank"] = {"required": ["key"], "optional": ["before", "after"], "keys": ["key", "before", "after"], "hints": {"before": "ключ задачи, выше которой", "after": "ключ задачи, ниже которой"}, "note": "ровно одно из before, after"}`
+ - `SPEC["ensure_fields"]["optional"]` += `"story_points"`, hint `"число ≥ 0 или null"`.
+ - `commands.SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state")`; `commands.NUMBER_PARAMS = {"story_points"}`.
+ - `settings.DEFAULT_PACING` += `"ensure_sprint_meta": 30, "ensure_sprint_state": 30, "ensure_rank": 30`; `pacing_from_config` — то же значение, что `ensure_comment`.
+
+- [ ] **Step 1: Write the failing tests** (`tests/test_commands.py`, `tests/test_settings.py`)
+
+```python
+META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}}
+def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}}
+def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}}
+
+def test_new_types_validate_ok():
+ out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"),
+ {"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}},
+ {"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}])
+ assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"]
+
+@pytest.mark.parametrize("cmd, msg", [
+ ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
+ ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"),
+ ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"),
+ ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"),
+ ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"),
+ ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
+ (state(state="open"), "state — active или closed"),
+ (state(state="closed"), "move_open_to обязателен при state = closed"),
+ (state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"),
+ (state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"),
+ (rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"),
+ (rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
+])
+def test_new_types_errors(cmd, msg):
+ with pytest.raises(CommandError, match=re.escape(msg)):
+ commands.validate([cmd])
+
+def test_rank_temp_keys():
+ ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")]
+ assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"]
+ with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"):
+ commands.validate([rank(before="new:2")])
+
+def test_new_types_not_confirmed_by_state(conn): # design #18
+ for cmd in (META, state(state="active"), rank(before="A-2")):
+ assert commands.check_confirmed(conn, cmd, {}) == (False, None)
+
+def test_story_points_confirmation(conn): # R-16
+ mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], [])
+ sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}}
+ assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True
+ assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False
+ assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True
+```
+
+`tests/test_settings.py`:
+
+```python
+def test_sprint_types_pacing(conn):
+ for t in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"):
+ assert jira_settings.DEFAULT_PACING[t] == 30 and jira_settings.pacing(conn)[t] == 30
+ assert jira_settings.pacing_from_config({"comment_seconds": 45})[t] == 45
+ settings.set_(conn, "pacing", {"ensure_fields": 5}) # тип не в сохранённой настройке — умолчание
+ assert jira_settings.pacing(conn)["ensure_rank"] == 30
+ settings.validate("pacing", {"ensure_rank": 10})
+ assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_commands.py tests/test_settings.py -q`
+Expected: FAIL — `неизвестный тип «ensure_sprint_meta»`, `KeyError: 'ensure_rank'`.
+
+- [ ] **Step 3: Implement**
+
+`_type_error`: `NUMBER_PARAMS` — `int`/`float`, не `bool`, `math.isfinite`, `≥ 0`, иначе `"story_points — число ≥ 0 или
+null"`. Проверки R-15 — в `validate` после проверки типов, сообщения `f"команда {i} ({ctype}): <текст>"` (тексты —
+из теста). Дата — `^\d{4}-\d{2}-\d{2}$` и `date.fromisoformat`. `check_confirmed`: новые типы — в кортеж «не
+подтверждается»; для `ensure_fields` со `story_points` — точное сравнение (R-16), до цикла `FIELD_PARAMS`.
+
+- [ ] **Step 4: Run tests, rewrite baselines**
+
+Run: `uv run pytest tests/test_commands.py tests/test_settings.py -q` → PASS.
+Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff tests/fixtures/baseline`
+Expected: `settings.expected.json` — три новых ключа `pacing`; `mcp_tools.expected.json` — в описании `batch_create`
+строки `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points (число ≥ 0 или null)` у `ensure_fields`.
+
+- [ ] **Step 5: README, `make check`, commit**
+
+README «Контракт команд»: у `ensure_fields` — `story_points` (число ≥ 0 или `null` — очистить); строки
+`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` (параметры и смысл по спеке commands, результаты
+`{sprint_id, created}`, `{sprint_id, moved}`, `{changed}`); абзац о зазорах — новые типы 30 с.
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/modules/jira/commands.py src/sdlc_hub/modules/jira/settings.py tests/test_commands.py tests/test_settings.py tests/fixtures/baseline/settings.expected.json tests/fixtures/baseline/mcp_tools.expected.json README.md
+git commit -m "Команды спринта и ранга, оценка в ensure_fields (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 5: Постановка команд спринта: доска пакета, проект, повтор
+
+**Files:**
+- Modify: `src/sdlc_hub/modules/jira/hooks.py:54-76` (тексты ошибок доски), `:79-123` (`_check_project`), `:126-130` (`check_batch`), `:251-253` (`retry_params`)
+- Test: `tests/test_queue.py` (раздел «доски»)
+- Modify: `README.md` («Контракт команд»: `board_id` у команд спринта), `ARCHITECTURE.md` (строка `hooks`)
+
+**Interfaces:**
+- Consumes: `commands.SPRINT_TYPES` (задача 4); `jira_projects.light_list(conn) -> list[dict]` (`label`, `board_id`, `board_type`).
+- Produces:
+ - `hooks._board_error(label: str, board_id: int | None, board_type: str | None) -> str | None` — общие тексты для `ensure_sprint` и команд спринта: «у проекта {label} нет доски Jira», «у проекта {label} канбан-доска, спринтов нет», «тип доски проекта {label} ещё не известен — дождитесь обновления из Jira»; `None` — доска годится.
+ - `hooks._assign_project_board(conn, cmds: list[dict], project: str | None) -> None` — у `SPRINT_TYPES` новый словарь `params` с `board_id` доски проекта пакета; без проекта — `CommandError(f"команда {i} ({type}): команды спринта — только в пакете проекта")`; ошибка доски — `CommandError(f"команда {i}: {текст}")`.
+ - `hooks.retry_params(cmd) -> dict` — снимает `board_id` у `ensure_sprint`, `ensure_sprint_meta`, `ensure_sprint_state`.
+
+- [ ] **Step 1: Write the failing tests** (`tests/test_queue.py`, после `test_retry_premigration_sprint_without_board`)
+
+```python
+def meta(name="AWPCA_Sprint_4"):
+ return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}}
+def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"):
+ return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}}
+
+def test_sprint_meta_gets_batch_project_board(conn, add_project):
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA")
+ cmds = queue.batch_view(conn, b["id"])["commands"]
+ assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216
+
+def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета»
+ add_project(conn)
+ with pytest.raises(CommandError) as e:
+ queue.create_batch(conn, "x", "claude", [meta()])
+ assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта"
+ assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
+
+@pytest.mark.parametrize("setup, msg", [
+ (dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"),
+ (dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"),
+ (dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"),
+])
+def test_sprint_state_board_rejected(conn, add_project, setup, msg):
+ add_project(conn, **setup)
+ with pytest.raises(CommandError) as e:
+ queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
+ assert str(e.value) == msg
+
+def test_rank_belongs_to_project_by_key(conn, add_project): # design #19
+ add_project(conn); add_project(conn, "C", board_id=5120)
+ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], [])
+ queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
+ with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"):
+ queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA")
+
+def test_retry_sprint_meta_recomputes_board(conn, add_project):
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
+ cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
+ conn.execute("UPDATE jira_project SET board_id = 5120")
+ r = queue.retry_command(conn, cid, "claude")
+ assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
+
+def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
+ cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
+ conn.execute("UPDATE jira_project SET board_type = 'kanban'")
+ with pytest.raises(CommandError, match="канбан-доска, спринтов нет"):
+ queue.retry_command(conn, cid, "claude")
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_queue.py -q -k "sprint_meta or sprint_state or rank_belongs"`
+Expected: FAIL — `KeyError: 'key'` в `_check_project`, нет `board_id` в params.
+
+- [ ] **Step 3: Implement**
+
+`_check_project`: у `SPRINT_TYPES` проверка `own(p["key"])` не делается (команда не про задачу); `ensure_rank` идёт
+общей веткой (`own(key)`, `before`/`after` не проверяются). `_assign_boards` берёт тексты из `_board_error`.
+`check_batch(conn, cmds, project, files_dir)`: после `check_setup` — `_assign_project_board(conn, cmds, project)`
+(входные `params` не мутируются, как в `_assign_boards`). `retry_params` — по множеству из трёх типов.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest tests/test_queue.py -q` → PASS.
+
+- [ ] **Step 5: Docs, `make check`, commit**
+
+README «Контракт команд»: у `ensure_sprint_meta`/`ensure_sprint_state` — «`board_id` дописывает хаб по проекту пакета;
+пакет без проекта — 422 «команды спринта — только в пакете проекта»; доски нет, kanban или тип не известен — 422,
+как у `ensure_sprint`»; `ensure_rank` в пакете проекта — `key` своей задачи. ARCHITECTURE `hooks`: «`board_id` у
+`ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)».
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/modules/jira/hooks.py tests/test_queue.py README.md ARCHITECTURE.md
+git commit -m "Команды спринта: доска проекта пакета, повтор с новой доской (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 6: Хвостовой `refresh` по JQL проекта (контракт `tail`)
+
+**Files:**
+- Modify: `src/sdlc_hub/core/modules.py:18` (тип `tail`)
+- Modify: `src/sdlc_hub/core/queue.py:79` (`h.tail(conn, cmds, project)`)
+- Modify: `src/sdlc_hub/modules/wiki/commands.py:97` (`tail=lambda conn, cmds, project: None`)
+- Modify: `src/sdlc_hub/modules/jira/projects.py` (новая `project_jql`)
+- Modify: `src/sdlc_hub/modules/jira/hooks.py:133-140` (`tail`)
+- Modify: `tests/conftest.py:59`, `tests/test_queue_core.py:24` (лямбды заглушек под новую сигнатуру)
+- Test: `tests/test_queue.py`, `tests/test_projects.py`
+- Modify: `ARCHITECTURE.md` (контракт `CommandHooks`: «`tail(conn, cmds, project)`»), `README.md` («Контракт команд»: хвост пакета)
+
+**Interfaces:**
+- Consumes: `commands.SPRINT_TYPES` (задача 4).
+- Produces:
+ - `CommandHooks.tail: Callable[[Connection, list[dict], str | None], dict | None]` — комментарий «(conn, cmds, проект пакета): хвостовая команда пишущего пакета».
+ - `jira_projects.project_jql(conn, label: str) -> str` — `project = AND labels in ("<метка>")` в формате `base_jql`.
+ - `hooks.tail(conn, cmds, project) -> dict | None` — есть команда из `SPRINT_TYPES` и `project` → `{"type": "refresh", "params": {"jql": project_jql(conn, project)}}`; иначе прежнее правило по ключам.
+
+- [ ] **Step 1: Write the failing tests**
+
+`tests/test_projects.py`:
+
+```python
+def test_project_jql(open_db, add_project):
+ conn = open_db(); settings.ensure_defaults(conn)
+ add_project(conn)
+ assert jira_projects.project_jql(conn, "AWPCA") == 'project = AURORA AND labels in ("AWPCA")' == jira_projects.base_jql(conn)
+ add_project(conn, "C", board_id=None)
+ assert jira_projects.project_jql(conn, "C") == 'project = AURORA AND labels in ("C")'
+```
+
+`tests/test_queue.py`:
+
+```python
+def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10
+ add_project(conn); add_project(conn, "C", board_id=None)
+ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], [])
+ b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
+ cmds = queue.batch_view(conn, b["id"])["commands"]
+ assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"]
+ assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
+
+def test_rank_batch_tail_by_keys(conn, add_project):
+ add_project(conn)
+ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], [])
+ b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA")
+ assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]}
+
+def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
+ # повтор команды из пакета проекта — тоже хвост по JQL проекта (retry_command передаёт project пакета)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_queue.py tests/test_projects.py -q -k "tail or project_jql"`
+Expected: FAIL — `AttributeError: project_jql`; хвоста у пакета со спринтом нет.
+
+- [ ] **Step 3: Implement**
+
+Сигнатура и вызов в ядре; лямбды wiki и заглушек тестов — `lambda conn, cmds, project: …`. `project_jql` использует
+тот же `project_key` и кавычки, что `base_jql`. `hooks.tail` — по Interfaces; docstring: «пакет с командами спринта —
+`refresh` по JQL проекта пакета: у них нет ключей, а перенесённые при закрытии задачи заранее неизвестны (design #13)».
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest -q` → PASS (весь набор: сигнатура ядра).
+
+- [ ] **Step 5: Docs, `make check`, commit**
+
+ARCHITECTURE, «Контракт модуля»: `tail(conn, cmds, project)`. README «Контракт команд»: «пакет с
+`ensure_sprint_meta`/`ensure_sprint_state` получает хвост `refresh` по JQL проекта пакета; иначе — по затронутым ключам».
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/core/modules.py src/sdlc_hub/core/queue.py src/sdlc_hub/modules/wiki/commands.py src/sdlc_hub/modules/jira/projects.py src/sdlc_hub/modules/jira/hooks.py tests/conftest.py tests/test_queue_core.py tests/test_queue.py tests/test_projects.py ARCHITECTURE.md README.md
+git commit -m "Хвост пакета со спринтом — refresh по JQL проекта (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 7: MCP: `sprints`, `sprint`, `backlog`, колонка SP
+
+**Files:**
+- Modify: `src/sdlc_hub/modules/jira/mcp.py` (`fmt_issues`, новые форматтеры и инструменты, текст `batch_create`)
+- Test: `tests/test_mcp.py`
+- Modify: `tests/fixtures/baseline/mcp_tools.expected.json` (перезапись)
+- Modify: `README.md` (раздел «MCP в Claude»: новые инструменты), `ARCHITECTURE.md` (строка `mcp` модуля jira)
+
+**Interfaces:**
+- Consumes: ответ `GET /agile` (задача 3).
+- Produces:
+ - `mcp.fmt_sp(v) -> str` — `None` → «—», иначе `f"{v:g}"` (`3.0` → «3», `2.5` → «2.5»).
+ - `mcp.fmt_day(value: str | None) -> str` — время Jira (`±HHMM`, `±HH:MM`, `Z`) → «ДД.ММ» в местном поясе; пусто/неразбираемо — «—».
+ - `mcp.fmt_sprints(data: dict) -> str`, `mcp.fmt_sprint(data: dict, name: str) -> str`, `mcp.fmt_backlog(data: dict, limit: int) -> str`.
+ - инструменты (`@mcp.tool() @core.guard @core.scoped(True)`): `sprints() -> str`, `sprint(name: str = "") -> str`, `backlog(limit: int = 50) -> str` — `core.client().get("/agile", project=core.PROJECT)`.
+
+Тексты (R-25):
+- общие отказы (все три): `board is None` → «у проекта нет доски»; `board.fetched_at is None` → «данных доски нет — нужен агент v15 и обновление из Jira»; `sprints`/`sprint` у `board.type == "kanban"` → «у проекта канбан-доска, спринтов нет».
+- `fmt_sprints`: шапка «спринт | состояние | даты | задачи | SP | без оценки»; строка `f"{name} | {активный|будущий|закрыт} | {fmt_day(start)}–{fmt_day(end)} | {count_done}/{count} | {fmt_sp(points_done)}/{fmt_sp(points)} | {unestimated}"`; закрытые — первые 10 из ответа (новые сверху) и «ещё N закрытых»; спринтов нет — «спринтов нет».
+- `fmt_sprint`: по `name` — первый с этим именем, иначе «спринта «» нет на доске»; без `name` — первый активный, иначе «активного спринта нет». Шапка: `f"{name} — {состояние}; {d1}–{d2}; цель: {goal|—}"` и `f"задачи {count_done}/{count}; SP {pd}/{p}; без оценки: {u}"`; затем по колонкам доски `f"{колонка} ({n}):"` и строки `f" {key} {core.cut(summary, 80)} — {assignee|—}, SP {fmt_sp}"`; статусы вне колонок — блок «Без колонки (n):» (только если есть).
+- `fmt_backlog`: `backlog is None` → «у доски нет бэклога»; пусто — «бэклог пуст»; шапка «# | ключ | тема | статус | исполнитель | SP», позиции с 1, не больше `max(1, limit)`, в конце «ещё N».
+- `fmt_issues`: шапка «ключ | тема | статус | срок | исполнитель | спринт | SP», последняя ячейка `fmt_sp(story_points)`.
+- `batch_create`: фраза «board_id для ensure_sprint выставляет хаб.» → «board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.»
+
+- [ ] **Step 1: Write the failing tests** (`tests/test_mcp.py`)
+
+```python
+def _sp(id, name, state, **over):
+ return {"id": id, "name": name, "state": state, "start_date": "2026-10-14T09:00:00.000+0300", "end_date": "2026-10-28T18:00:00.000+0300",
+ "complete_date": None, "goal": None, "count": 0, "count_done": 0, "unestimated": 0, "points": 0, "points_done": 0, "issues": [], **over}
+BOARD = {"id": 4216, "type": "scrum", "columns": [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}],
+ "kanban_backlog": None, "fetched_at": "2026-10-24T10:00:00+00:00"}
+
+def test_fmt_issues_sp_column():
+ out = jmcp.fmt_issues([{"key": "A-1", "story_points": 3.0}, {"key": "A-2", "story_points": 2.5}, {"key": "A-3", "story_points": None}])
+ assert out.splitlines()[0].endswith("| SP") and [l.rsplit("| ", 1)[1] for l in out.splitlines()[1:]] == ["3", "2.5", "—"]
+
+def test_fmt_sprints_order_and_sums(): # сценарий «Спринты проекта»
+ data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints":
+ [_sp(3, "AWPCA_Sprint_3_Corrections", "active", count=37, count_done=25, points=40, points_done=25), _sp(4, "AWPCA_Sprint_4", "future")]
+ + [_sp(100 - n, f"S{n}", "closed") for n in range(12)]}
+ lines = jmcp.fmt_sprints(data).splitlines()
+ assert lines[1].startswith("AWPCA_Sprint_3_Corrections | активный | ") and "| 25/37 | 25/40 |" in lines[1]
+ assert lines[2].startswith("AWPCA_Sprint_4 | будущий")
+ assert len([l for l in lines if "| закрыт |" in l]) == 10 and lines[-1] == "ещё 2 закрытых"
+
+@pytest.mark.parametrize("data, text", [
+ ({"board": None, "sprints": [], "backlog": None, "board_issues": None}, "у проекта нет доски"),
+ ({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "у проекта канбан-доска, спринтов нет"),
+ ({"board": {**BOARD, "fetched_at": None, "columns": None}, "sprints": [], "backlog": [], "board_issues": None}, "данных доски нет — нужен агент v15 и обновление из Jira"),
+])
+def test_fmt_sprints_refusals(data, text):
+ assert jmcp.fmt_sprints(data) == text
+
+def test_fmt_sprint_by_columns():
+ # активный спринт с задачами в «Backlog», «Готово» и статусе «Странный» → блоки «К выполнению (1):», «Готово (1):», «Без колонки (1):»;
+ # тема длиннее 80 обрезана core.cut; fmt_sprint(data, "S9") → «спринта «S9» нет на доске»; без активного → «активного спринта нет»
+
+def test_fmt_backlog_limit(): # сценарий «Бэклог с лимитом»
+ rows = [{"key": f"A-{n}", "summary": "т", "status": "Backlog", "assignee": None, "story_points": None} for n in range(188)]
+ out = jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": rows, "board_issues": None}, 2).splitlines()
+ assert out[1].startswith("1 | A-0 |") and out[2].startswith("2 | A-1 |") and out[-1] == "ещё 186" and len(out) == 4
+
+def test_fmt_day_offsets():
+ assert jmcp.fmt_day("2026-10-14T09:00:00.000+0300") == jmcp.fmt_day("2026-10-14T09:00:00.000+03:00") != "—"
+ assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—"
+
+def test_agile_tools_live(live):
+ live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Backlog", "labels": ["AWPCA"],
+ "rank": "0|a", "story_points": 2, "sprint_ids": [102]}], "links": [],
+ "boards": [{"id": 4216, "type": "scrum", "columns": BOARD["columns"], "kanban_backlog": None, "sprints": [_sp(102, "S2", "active")]}]})
+ assert "S2 | активный" in jmcp.sprints() and "A-1 Тема" in jmcp.sprint() and "бэклог пуст" == jmcp.backlog()
+
+def test_agile_tools_unbound(live, monkeypatch):
+ monkeypatch.setattr(core_mcp, "PROJECT", None)
+ assert jmcp.sprints() == jmcp.sprint() == jmcp.backlog() == jmcp.issues() # тот же отказ, что у прочих
+
+def test_batch_create_description_lists_sprint_types():
+ doc = jmcp.batch_create.__doc__
+ for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
+ assert word in doc
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/test_mcp.py -q -k "sp_column or sprints or sprint_ or backlog or fmt_day or agile or sprint_types"`
+Expected: FAIL — `AttributeError: fmt_sprints`.
+
+- [ ] **Step 3: Implement** форматтеры, инструменты и текст `batch_create` по Interfaces; docstring инструментов:
+`sprints` — «Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних»;
+`sprint` — «Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски»; `backlog` — «Бэклог проекта по
+рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)».
+
+- [ ] **Step 4: Run tests, rewrite MCP baseline**
+
+Run: `uv run pytest tests/test_mcp.py -q` → PASS.
+Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py::test_mcp_tools -q && git diff tests/fixtures/baseline/mcp_tools.expected.json`
+Expected: три новых инструмента, изменённая фраза в `batch_create`, больше ничего.
+
+- [ ] **Step 5: Docs, `make check`, commit**
+
+README «MCP в Claude»: `sprints`, `sprint [name]`, `backlog [limit]` (чтение `GET /agile` проекта сессии), колонка SP в
+`issues`, команды спринтов и ранга — через `batch_create` (пример пакета `ensure_sprint_meta` + `ensure_sprint_state`).
+ARCHITECTURE `mcp`: «…`sprints`, `sprint`, `backlog`…».
+Run: `make check` → PASS.
+
+```bash
+git add src/sdlc_hub/modules/jira/mcp.py tests/test_mcp.py tests/fixtures/baseline/mcp_tools.expected.json README.md ARCHITECTURE.md
+git commit -m "MCP: спринты, спринт и бэклог проекта, колонка SP (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 8: Веб: «Связи» вместо «Доски связей», условные пункты меню
+
+**Files:**
+- Modify: `web/src/modules/types.ts:6-11` (`NavItem.when`)
+- Modify: `web/src/shell/nav.tsx` (`visibleNav`)
+- Modify: `web/src/shell/Sidebar.tsx:76-127` (фильтр пунктов по текущему проекту)
+- Modify: `web/src/shell/NotFound.tsx` (только безусловные пункты, R-23)
+- Modify: `web/src/modules/jira/index.tsx` (адрес `/jira/links`, перенаправления `/jira/board` и `/board`, пункт «Связи»)
+- Modify: `web/src/modules/jira/issues/modal.ts:12-13` (`BOARD_CARD_STATE` → `/jira/links`), `web/src/modules/jira/Summary.tsx:27` (ссылка «Связи» на `/jira/links`)
+- Create: `web/src/shell/nav.test.ts`
+- Modify tests: `web/src/App.test.tsx`, `web/src/shell/Sidebar.test.tsx`, `web/src/projects/projects.test.tsx`, `web/src/modules/modules.test.tsx`, `web/src/shell/OverviewPage.test.tsx`, `web/src/modules/jira/issues/IssueModal.test.tsx`, `web/src/modules/jira/issues/modal.test.ts`
+- Modify e2e: `web/e2e/hub.ts` (`login` по умолчанию `/app/jira/links`, ссылка «Связи»), `web/e2e/layout.spec.ts`, `web/e2e/board.spec.ts`, `web/e2e/login.spec.ts`, `web/e2e/screens.spec.ts`, `web/e2e/projects.spec.ts`
+- Modify: `CLAUDE.md:29`, `README.md` (раздел «Веб-приложение», адреса), `ARCHITECTURE.md` (раздел «Веб», адреса и «Связи»)
+
+**Interfaces:**
+- Produces:
+ - `NavItem.when?: (p: Project) => boolean` — комментарий «пункт только для проектов, где условие верно; пока проект не определён (проекты не загружены) — скрыт».
+ - `visibleNav(items: NavItem[], project: Project | null): NavItem[]` в `web/src/shell/nav.tsx`.
+ - Страница доски связей — `/jira/links`; `/jira/board` и `/board` — `LegacyRedirect to="/jira/links"` (с `search`, `replace`); пункт `{ to: "/jira/links", label: "Связи", icon: }`. Порядок `pages`: `/jira/links`, `/jira/issues`, `/jira/feed`, затем перенаправления `/jira/board`, `/board`, `/issues`, `/issues/:key`, `/feed`. Заголовок `h1` доски — прежний «Доска связей» (R-21).
+
+- [ ] **Step 1: Write the failing tests**
+
+`web/src/shell/nav.test.ts`:
+
+```ts
+const A = { label: "AWPCA", name: "", created_at: "", board_type: "scrum" };
+const items = [{ to: "/a", label: "A", icon: null }, { to: "/b", label: "B", icon: null, when: (p: Project) => p.board_type === "scrum" }];
+it("условный пункт скрыт без проекта и при ложном условии", () => {
+ expect(visibleNav(items, null).map((n) => n.to)).toEqual(["/a"]);
+ expect(visibleNav(items, { ...A, board_type: null }).map((n) => n.to)).toEqual(["/a"]);
+ expect(visibleNav(items, A).map((n) => n.to)).toEqual(["/a", "/b"]);
+});
+```
+
+`web/src/App.test.tsx` (новые тесты; прежние «Доска связей» → «Связи», `/app/jira/board` → `/app/jira/links`):
+
+```ts
+it("прежний адрес доски связей", async () => { // сценарий «Прежний адрес доски связей»
+ const router = at("/app/jira/board?project=AWPCA");
+ await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA"));
+ expect(await screen.findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page");
+ await waitFor(() => expect(document.title).toBe("Связи · AWPCA · SDLC Hub"));
+});
+it("/app/board → сразу /app/jira/links", async () => { // design #26
+ const router = at("/app/board?project=AWPCA");
+ await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links"));
+ expect(router.state.location.search).toBe("?project=AWPCA");
+});
+```
+
+`modules.test.tsx`: `PAGE_ROUTES` — `["/", "/jira/links", "/jira/issues", "/jira/feed", "/jira/board", "/board", "/issues", "/issues/:key", "/feed", "/wiki", "*"]`; пункты — `[["Связи", "/jira/links"], ["Задачи", "/jira/issues"], ["Лента", "/jira/feed"], ["Вики", "/wiki"]]`.
+`Sidebar.test.tsx`: порядок ссылок `["Обзор", "Связи", "Задачи", "Лента", "Вики"]`, в группе «Jira» — `["Связи", "Задачи", "Лента"]`, href `/app/jira/links?project=AWPCA`.
+`OverviewPage.test.tsx`: ссылка «Связи» → `/app/jira/links?project=AWPCA`. `modal.test.ts`: фон `/jira/links`.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd web && npm test`
+Expected: FAIL — нет `visibleNav`, ссылки «Связи» нет, адрес остаётся `/app/jira/board`.
+
+- [ ] **Step 3: Implement**
+
+`Panel` берёт текущий проект из `useProjectContext().state` (`kind === "ok"` → `state.project`, иначе `null`) и рисует
+`visibleNav(g.nav, current)`. Остальное — по Interfaces. Тексты e2e и тестов, где встречается «Доска связей» как
+ссылка, — «Связи» (`exact: true`); `geometry.spec.ts` с `h1` «Доска связей» не меняется.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd web && npm run lint && npm test` → PASS.
+Run: `cd web && npm run e2e` → PASS (в `layout.spec.ts` тест перенаправления — `/app/board?project=AWPCA` →
+`/app/jira/links?project=AWPCA`, и новый — `/app/jira/board?project=AWPCA` → `/app/jira/links?project=AWPCA`).
+
+- [ ] **Step 5: Docs, `make check`, commit**
+
+CLAUDE.md:29, README «Веб-приложение», ARCHITECTURE «Веб»: «Связи» — `/app/jira/links?project=<метка>`; прежние
+`/app/board` и `/app/jira/board` перенаправляют на него; пункты меню могут зависеть от проекта (`NavItem.when`).
+Run: `make check` → PASS.
+
+```bash
+git add web/src web/e2e CLAUDE.md README.md ARCHITECTURE.md
+git commit -m "Веб: «Связи» на /app/jira/links, условные пункты меню (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 9: Веб: данные доски, общие части экранов и страница «Бэклог»
+
+Перед задачей — скилл `aura-design` (вёрстка секций, строк, сообщений).
+
+**Files:**
+- Modify: `web/src/modules/jira/api/types.ts` (типы ответа `GET /agile`)
+- Modify: `web/src/modules/jira/api/queries.ts` (`qk.agile`, `useAgile`), `web/src/modules/jira/status.ts` (`invalidate` + `["agile"]`)
+- Modify: `web/src/modules/jira/projectFields.ts` (`boardType`)
+- Modify: `web/vite.config.ts` (`API` += `"/agile"` — прокси разработки)
+- Modify: `web/src/ui/icons.tsx` (`IconBacklog`)
+- Create: `web/src/modules/jira/agile/model.ts`, `model.test.ts`
+- Create: `web/src/modules/jira/agile/AgileGate.tsx`, `TypeIcon.tsx`, `IssueRow.tsx`, `BacklogPage.tsx`, `agile.css`
+- Create: `web/src/modules/jira/agile/BacklogPage.test.tsx`
+- Modify: `web/src/modules/jira/index.tsx` (страница `/jira/backlog`, пункт «Бэклог»), `web/src/modules/modules.test.tsx`
+
+**Interfaces:**
+- Consumes: `GET /agile` (задача 3); `visibleNav`/`NavItem.when` (задача 8); `RefreshButton`; `useOpenIssue`, `IssueLink`.
+- Produces:
+ - типы `AgileIssue`, `BoardColumn {name; statuses: string[]}`, `AgileBoard {id; type: "scrum" | "kanban" | null; columns: BoardColumn[] | null; kanban_backlog: boolean | null; fetched_at: string | null}`, `SprintState`, `AgileSprint`, `AgileResponse {board: AgileBoard | null; sprints: AgileSprint[]; backlog: AgileIssue[] | null; board_issues: AgileIssue[] | null}` — поля как в ответе хаба.
+ - `qk.agile(project: string) = ["agile", project] as const`; `useAgile(project: string)`.
+ - `boardType(p: Project): "scrum" | "kanban" | null`.
+ - `model.ts`: `jiraDate(iso: string | null): Date | null` (`±HHMM` → `±HH:MM`; негодное — `null`); `fmtDay(iso: string | null): string` («ДД.ММ» в поясе браузера, иначе «—»); `fmtRange(start, end): string` («ДД.ММ–ДД.ММ»); `daysLeft(endIso: string | null, now: Date): number | null` (разница календарных дат в поясе браузера); `fmtSp(n: number | null): string` («—» или число); `tasksWord(n: number): string` («задача»/«задачи»/«задач», R-24); `boardState(d: AgileResponse): "no-data" | "no-columns" | "ok"` (R-19).
+ - `AgileGate({ project, kind, children }: { project: Project; kind: "scrum" | "kanban"; children: (d: AgileResponse) => ReactNode })` — тип доски проекта не `kind` → `Notice` «У проекта нет scrum-доски» / «У проекта нет kanban-доски» со ссылкой «Связи» (`/jira/links?project=…`), запроса `/agile` нет; загрузка/ошибка — `Notice`; `no-data` → «Нужен агент v15 и обновление из Jira» + `RefreshButton`; `no-columns` → «Не удалось прочитать настройки доски из Jira» + `RefreshButton`; `ok` → `children`.
+ - `TypeIcon({ type }: { type: string | null })` — значок с `aria-label`/`title` = тип (эпик — `/^(Эпик|Epic|Фича)$/i`, ошибка — `/^(Ошибка|Bug)$/i`, прочее — задача).
+ - `AgileIssueRow({ issue, mark }: { issue: AgileIssue; mark?: string })` — ключ (`IssueLink`), `TypeIcon`, тема, исполнитель или «—», `fmtSp`; выполненная — класс `is-done` (приглушена); щелчок по строке (не по ссылке и не при выделении текста) — `useOpenIssue()(key)`; `mark` — пометка справа.
+ - `BacklogPage({ project }: { project: Project })` — `data-testid="page-backlog"`, `PageHeader` «Бэклог» с `RefreshButton`.
+ - пункт `{ to: "/jira/backlog", label: "Бэклог", icon: , padded: true, when: (p) => boardType(p) === "scrum" }` после «Связи»; страница `/jira/backlog` — после `/jira/links`.
+
+Состав «Бэклога» (спека web-app): секции активных, затем будущих спринтов — `` с заголовком: имя, «активный»/
+«будущий», `fmtRange`, цель (если есть), `${count} ${tasksWord(count)}`, `${fmtSp(points)} SP (выполнено
+${fmtSp(points_done)})`, «без оценки: U» при `U > 0`; задачи по порядку ответа; пусто — «Нет задач». Затем секция
+«Бэклог» (`data.backlog`), пусто — «Нет задач». Внизу `` «Прошлые спринты (N)»: по `` на закрытый
+спринт, `` — `${name} · закрыт ${fmtDay(complete_date)} · выполнено ${count_done} из ${count} ·
+${fmtSp(points)} SP`, внутри строки задач с `mark="не выполнено"` у `!done`. Ни `draggable`, ни кнопок, кроме
+`` и `RefreshButton` (R-20).
+
+- [ ] **Step 1: Write the failing tests**
+
+`agile/model.test.ts`:
+
+```ts
+it("время Jira с ±HHMM и ±HH:MM — один день", () => { // Review Focus 1
+ expect(fmtDay("2026-10-14T09:00:00.000+0300")).toBe(fmtDay("2026-10-14T09:00:00.000+03:00"));
+ expect(fmtDay("2026-10-14T09:00:00.000+0300")).toMatch(/^\d{2}\.\d{2}$/);
+ expect(fmtDay(null)).toBe("—"); expect(fmtDay("мусор")).toBe("—");
+});
+it("daysLeft — календарные дни до конца", () => {
+ const now = new Date(2026, 9, 24, 23, 30);
+ expect(daysLeft(new Date(2026, 9, 27, 1, 0).toISOString(), now)).toBe(3);
+ expect(daysLeft(new Date(2026, 9, 23, 12, 0).toISOString(), now)).toBe(-1);
+ expect(daysLeft(null, now)).toBeNull();
+});
+it("fmtSp, tasksWord", () => {
+ expect([fmtSp(null), fmtSp(3), fmtSp(2.5)]).toEqual(["—", "3", "2.5"]);
+ expect([1, 2, 5, 11, 21, 73].map(tasksWord)).toEqual(["задача", "задачи", "задач", "задач", "задача", "задачи"]);
+});
+it("boardState — три состояния (design #23)", () => {
+ expect(boardState({ board: null, sprints: [], backlog: null, board_issues: null })).toBe("no-data");
+ expect(boardState(resp({ fetched_at: null, columns: null }))).toBe("no-data");
+ expect(boardState(resp({ fetched_at: "2026-10-24T10:00:00+00:00", columns: null }))).toBe("no-columns");
+ expect(boardState(resp({}))).toBe("ok");
+});
+```
+
+`agile/BacklogPage.test.tsx` (через `App` и `mockFetch`, как `FeedPage.test.tsx`; проекты `A` — AWPCA scrum,
+`S` — STOCKS без доски; ответ `/agile` — фабрика с активным, будущим и закрытым спринтами):
+
+```ts
+it("меню scrum-проекта: «Связи», «Бэклог», «Задачи», «Лента»", ...) // задача 10 добавит «Спринт»
+it("проект без доски: пунктов нет, «У проекта нет scrum-доски» со ссылкой «Связи», запроса /agile нет", ...) // сценарий «Проект без доски»
+it("пока проекты не загружены, «Бэклог» скрыт", ...) // GET /projects не отвечает: в группе «Jira» — «Связи», «Задачи», «Лента»
+it("секции: активный, будущий, «Бэклог», «Прошлые спринты (1)»; заголовок спринта с суммами", ...)
+ // «12 задач», «25 SP (выполнено 8)», «без оценки: 3»; пустой будущий спринт — «Нет задач»
+it("прошлый спринт: строка и пометки «не выполнено»", ...) // сценарий «Прошлый спринт»: 73 задачи, 61 done, 25 SP →
+ // summary `AWPCA_Sprint_2_VGO · закрыт ${fmtDay(c)} · выполнено 61 из 73 · 25 SP`; после раскрытия 73 строки, 12 с «не выполнено»
+it("только смотрит: нет draggable, кнопки — только summary и «Обновить из Jira»", ...) // сценарий «Бэклог только смотрит»
+ // container.querySelectorAll('[draggable="true"]').length === 0;
+ // within(page).queryAllByRole("button").every((b) => b.tagName === "SUMMARY" || /Обновить из Jira/.test(b.textContent ?? ""))
+it("щелчок по строке открывает карточку поверх «Бэклога»", ...) // адрес /app/jira/issues/AURORA-1, dialog; закрытие → /app/jira/backlog
+it("нет данных доски — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", ...)
+it("колонок нет — «Не удалось прочитать настройки доски из Jira»", ...)
+it("смена проекта на STOCKS оставляет /app/jira/backlog и показывает «У проекта нет scrum-доски»", ...) // design #21
+it("заголовок вкладки «Бэклог · AWPCA · SDLC Hub»", ...)
+```
+
+`modules.test.tsx`: `PAGE_ROUTES` — `/jira/backlog` вторым после `/jira/links`; пункты — `["Бэклог", "/jira/backlog"]` после «Связи».
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd web && npm test -- src/modules/jira/agile src/modules/modules.test.tsx`
+Expected: FAIL — модулей `agile/*` нет.
+
+- [ ] **Step 3: Implement** по Interfaces; стили — `agile.css` только на токенах; `status.invalidate` перечитывает и `["agile"]`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd web && npm run lint && npm test` → PASS.
+
+- [ ] **Step 5: `make check`, commit**
+
+Run: `make check` → PASS.
+
+```bash
+git add web/src web/vite.config.ts
+git commit -m "Веб: страница «Бэклог» и общие части экранов доски (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 10: Веб: страница «Спринт»
+
+Перед задачей — скилл `aura-design` (колонки доски, карточки).
+
+**Files:**
+- Create: `web/src/modules/jira/agile/columns.ts`, `columns.test.ts`
+- Create: `web/src/modules/jira/agile/BoardColumns.tsx`, `AgileCard.tsx`, `SprintPage.tsx`, `SprintPage.test.tsx`
+- Modify: `web/src/modules/jira/agile/agile.css`, `web/src/ui/icons.tsx` (`IconSprint`)
+- Modify: `web/src/modules/jira/index.tsx` (страница `/jira/sprint`, пункт «Спринт»), `web/src/modules/modules.test.tsx`, `web/src/modules/jira/agile/BacklogPage.test.tsx` (меню с «Спринт»)
+
+**Interfaces:**
+- Consumes: `AgileGate`, `model.ts`, `TypeIcon`, `useOpenIssue` (задача 9).
+- Produces:
+ - `type ColumnView = { name: string; issues: AgileIssue[]; extra: boolean }`.
+ - `columnsOf(columns: BoardColumn[] | null, issues: AgileIssue[]): ColumnView[]` — колонки доски по порядку, задача — в колонку своего статуса, порядок задач — как во входе (ранг); статусы вне колонок — последняя `{ name: "Без колонки", extra: true }`, только если в ней есть задачи; `columns = null` — одна «Без колонки».
+ - `BoardColumns({ columns, lastCaption }: { columns: ColumnView[]; lastCaption?: string })` — контейнер `.agile-columns` с горизонтальной прокруткой внутри (`overflow-x: auto`), у колонки — имя и число задач, `lastCaption` — подпись последней не-`extra` колонки.
+ - `AgileCard({ issue }: { issue: AgileIssue })` — те же поля, что `AgileIssueRow`; щелчок открывает карточку задачи; выполненная приглушена.
+ - `SprintPage({ project }: { project: Project })` — `data-testid="page-sprint"`; активные спринты из `data.sprints`; выбор — `?sprint=`, нет такого активного (закрыт, чужой, мусор) — первый активный (Review Focus 2); при двух и более — `Segmented` с `href` (`label="Спринты"`); заголовок: имя, `fmtRange`, «осталось N дн.» (`daysLeft ≥ 0`) или «срок прошёл» (`< 0`), цель, `${count_done} из ${count} ${tasksWord(count)}`, `${fmtSp(points_done)} из ${fmtSp(points)} SP`; ниже `BoardColumns(columnsOf(board.columns, sprint.issues))`. Активного нет — «Нет активного спринта» и ссылка «Бэклог».
+ - пункт `{ to: "/jira/sprint", label: "Спринт", icon: , padded?: нет — во всю ширину, when: scrum }` после «Бэклог»; страница после `/jira/backlog`.
+
+- [ ] **Step 1: Write the failing tests**
+
+`agile/columns.test.ts`:
+
+```ts
+const cols = [{ name: "К выполнению", statuses: ["Backlog"] }, { name: "В работе", statuses: ["В работе"] }, { name: "Готово", statuses: ["Готово"] }];
+it("по колонкам в порядке ранга, «Без колонки» — только с задачами", () => {
+ const v = columnsOf(cols, [iss("A", "Готово"), iss("B", "Backlog"), iss("C", "Странный"), iss("D", "Backlog")]);
+ expect(v.map((c) => [c.name, c.issues.map((i) => i.key)])).toEqual([["К выполнению", ["B", "D"]], ["В работе", []], ["Готово", ["A"]], ["Без колонки", ["C"]]]);
+ expect(columnsOf(cols, [iss("A", "Готово")]).map((c) => c.name)).toEqual(["К выполнению", "В работе", "Готово"]);
+});
+```
+
+`agile/SprintPage.test.tsx`:
+
+```ts
+it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", ...) // сценарий «Меню scrum-проекта»
+it("колонки доски с числом задач и «Без колонки»", ...)
+it("«осталось 3 дн.» и «срок прошёл»", ...) // end_date = сейчас + 3 дня / вчера (ISO с +0300)
+it("два активных: переключатель «Спринты», выбор — ?sprint= и его задачи", ...)
+it("устаревший ?sprint= — первый активный", ...) // Review Focus 2: ?sprint=101 (закрыт) и ?sprint=abc
+it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", ...)
+it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", ...) // сценарий «Агент без досок»
+it("щелчок по карточке открывает окно задачи поверх «Спринта»", ...)
+```
+
+`modules.test.tsx` и меню в `BacklogPage.test.tsx` — с «Спринт» после «Бэклог».
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd web && npm test -- src/modules/jira/agile src/modules/modules.test.tsx`
+Expected: FAIL — нет `columnsOf`, `SprintPage`.
+
+- [ ] **Step 3: Implement** по Interfaces.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd web && npm run lint && npm test` → PASS.
+
+- [ ] **Step 5: `make check`, commit**
+
+Run: `make check` → PASS.
+
+```bash
+git add web/src
+git commit -m "Веб: страница «Спринт» с колонками доски (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 11: Веб: страница «Kanban»
+
+Перед задачей — скилл `aura-design`.
+
+**Files:**
+- Create: `web/src/modules/jira/agile/KanbanPage.tsx`, `KanbanPage.test.tsx`
+- Modify: `web/src/modules/jira/agile/agile.css`, `web/src/ui/icons.tsx` (`IconKanban`)
+- Modify: `web/src/modules/jira/index.tsx` (страница `/jira/kanban`, пункт «Kanban»), `web/src/modules/modules.test.tsx`
+
+**Interfaces:**
+- Consumes: `AgileGate` (`kind="kanban"`), `columnsOf`, `BoardColumns`, `AgileIssueRow` (задачи 9–10).
+- Produces:
+ - `KanbanPage({ project }: { project: Project })` — `data-testid="page-kanban"`, `PageHeader` «Kanban» с `RefreshButton`; при `kanban_backlog === true` над колонками `` «Бэклог (N)» со строками `data.backlog` по рангу, а колонки — `columnsOf(board.columns.slice(1), board_issues)` (R-18); иначе `columnsOf(board.columns, board_issues)`; `lastCaption="за 14 дней"`.
+ - пункт `{ to: "/jira/kanban", label: "Kanban", icon: , when: (p) => boardType(p) === "kanban" }` после «Спринт»; страница после `/jira/sprint`.
+
+- [ ] **Step 1: Write the failing tests** (`agile/KanbanPage.test.tsx`)
+
+```ts
+it("меню kanban-проекта: «Связи», «Kanban», «Задачи», «Лента»", ...)
+it("колонки из board_issues, у последней — «за 14 дней», «Без колонки» при статусе вне доски", ...)
+it("бэклог включён: «Бэклог (2)» над колонками, первая колонка не рисуется", ...) // R-18
+it("бэклог выключен или неизвестен: раздела «Бэклог» нет, первая колонка на доске", ...) // design #17
+it("scrum-проект на /app/jira/kanban — «У проекта нет kanban-доски» со ссылкой «Связи»", ...)
+```
+
+`modules.test.tsx`: `/jira/kanban` после `/jira/sprint`; пункт `["Kanban", "/jira/kanban"]` после «Спринт».
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd web && npm test -- src/modules/jira/agile/KanbanPage.test.tsx src/modules/modules.test.tsx`
+Expected: FAIL — нет `KanbanPage`.
+
+- [ ] **Step 3: Implement** по Interfaces.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd web && npm run lint && npm test` → PASS.
+
+- [ ] **Step 5: `make check`, commit**
+
+Run: `make check` → PASS.
+
+```bash
+git add web/src
+git commit -m "Веб: страница «Kanban» (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+### Task 12: Тестовый хаб с досками, e2e новых страниц, документация веба
+
+**Files:**
+- Modify: `web/scripts/seed-hub.py` (доски, спринты, ранг, оценки; проект `KANBAN_E2E`)
+- Create: `web/e2e/agile.spec.ts`
+- Modify: `README.md` («Веб-приложение»: три страницы, меню по типу доски, только просмотр; «Эксплуатация» — порядок выкатки), `CLAUDE.md:29` (адреса `/app/jira/backlog`, `/app/jira/sprint`, `/app/jira/kanban`), `ARCHITECTURE.md` («Веб», модуль jira: `agile/`)
+
+**Interfaces:**
+- Consumes: `mirror.apply_state(..., boards=)` (задача 2), `jira_projects.create` — в сиде; страницы задач 9–11.
+- Produces (данные тестового хаба, R-27):
+ - доска AWPCA 4216 `scrum`, колонки «К выполнению» [`Backlog`, `К выполнению`, `Ожидание`, `Ожидание разработки`], «В работе» [`В работе`], «Готово» [`Готово`]; спринты 101 «AWPCA_Спринт 1» `closed`, 102 «AWPCA_Спринт 2» `active` (конец — через 7 дней от сида), 103 «AWPCA_Спринт 3» `future`; у задач фикстуры `sprint_ids` по имени `sprint`, AURORA-9001 — `[101, 102]`; `rank` — `0|i:` по порядку фикстуры; `story_points` у части задач.
+ - проект `KANBAN_E2E` «Канбан e2e», доска 5120 `kanban`, `kanban_backlog: true`, колонки «Бэклог» [`Backlog`], «В работе» [`В работе`], «Готово» [`Готово`]; задачи AURORA-9101 (`Backlog`), AURORA-9102 (`В работе`), AURORA-9103 (`Готово`, `jira_updated` — время сида) с меткой `KANBAN_E2E`.
+
+- [ ] **Step 1: Write the failing e2e** (`web/e2e/agile.spec.ts`)
+
+```ts
+test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", ...)
+ // login(page, "/app/jira/backlog?project=AWPCA"); видны «AWPCA_Спринт 2», «AWPCA_Спринт 3», «Бэклог», «Прошлые спринты (1)»;
+ // раскрытие прошлого спринта показывает AURORA-9001; щелчок по строке → /app/jira/issues/AURORA-9001, dialog; Esc → /app/jira/backlog
+test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", ...)
+test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", ...)
+test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", ...)
+test("меню по проекту: у AWPCA «Бэклог» и «Спринт», у KANBAN_E2E — «Kanban»", ...)
+test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", ...)
+ // page.setViewportSize({ width: 900, height: 900 }); scrollWidth документа ≤ clientWidth; у .agile-columns scrollWidth > clientWidth
+```
+
+- [ ] **Step 2: Run e2e to verify it fails**
+
+Run: `cd web && npm run e2e -- agile.spec.ts`
+Expected: FAIL — у AWPCA нет данных доски («Нужен агент v15 и обновление из Jira»), проекта `KANBAN_E2E` нет.
+
+- [ ] **Step 3: Implement seed** — в `seed-hub.py` после загрузки фикстуры: проект `KANBAN_E2E` через
+`jira_projects.create`, затем `mirror.apply_state` с `boards` и полями задач (тип досок проектам пишет сам
+`apply_state`). Даты спринтов — строки ISO со смещением `+03:00` от времени сида.
+
+- [ ] **Step 4: Run e2e to verify it passes**
+
+Run: `cd web && npm run e2e` → PASS (весь набор: второй проект не ломает прежние spec).
+
+- [ ] **Step 5: Docs, `make check`, commit**
+
+README «Веб-приложение»: страницы «Бэклог» `/app/jira/backlog`, «Спринт» `/app/jira/sprint` (`?sprint=`), «Kanban»
+`/app/jira/kanban` — только просмотр, пункты меню по типу доски проекта, три состояния данных; «Эксплуатация» — порядок
+выкатки: `git pull` → kickstart (владелец) → `make web` → `/mcp` reconnect → `agent publish` v15 → проверка полей
+Jira банка → `field_ids.story_points`, если агент поле не нашёл; `min_agent_version` — владелец после выкатки v15.
+CLAUDE.md:29 — адреса трёх страниц. ARCHITECTURE «Веб» — модуль jira: `agile/` (`model`, `columns`, `AgileGate`,
+страницы `BacklogPage`, `SprintPage`, `KanbanPage`), данные — `GET /agile`.
+Run: `make check` → PASS.
+
+```bash
+git add web/scripts/seed-hub.py web/e2e/agile.spec.ts README.md CLAUDE.md ARCHITECTURE.md
+git commit -m "e2e спринтов и бэклога, тестовый хаб с досками, документация (#108)" -m "Co-Authored-By: Claude Opus 5.5 "
+```
+
+---
+
+## Self-review
+
+- **Покрытие спеки:** data-model (схема v9, миграция v8 — задача 1; доски, спринты, членство, «выполнено» — задачи 2–3);
+ hub-api (приём — 2, `GET /agile` — 3); commands (контракт, `story_points`, ошибки 422 — 4; доска по проекту пакета,
+ `ensure_rank` по `key`, `command_retry` — 5; хвост по JQL — 6; темп — 4); mcp (`sprints`, `sprint`, `backlog`, SP,
+ `batch_create` — 7); web-app (адреса и «Связи» — 8; меню по типу доски — 8–11; «Бэклог» — 9; «Спринт» — 10;
+ «Kanban» — 11; карточка окном — 9–10; даты и «осталось N дн.» — 9–10; горизонтальная прокрутка — 10, e2e 12);
+ agent — вне плана (jira-agent v15), здесь только приём его данных. Сценарий «Создание спринта повтором не дублируется»
+ и «Завершение спринта с переносом», «Ранг уже такой» — поведение агента; со стороны хаба — темп, `board_id`, хвост
+ (задачи 4–6) и отсутствие подтверждения по зеркалу (задача 4).
+- **Типы и имена:** `apply_state(..., boards=None)` (2) → сид (12); `agile.view` (3) → `h_agile` (3); `SPRINT_TYPES`
+ (4) → `hooks` (5, 6); `tail(conn, cmds, project)` (6) — ядро, wiki, заглушки; `visibleNav`/`NavItem.when` (8) → 9–11;
+ `AgileGate`, `fmtDay`, `fmtSp`, `tasksWord`, `boardState` (9) → 10–11; `columnsOf`, `BoardColumns` (10) → 11.
+- **Пропорция:** код — только тесты и интерфейсы; тела функций не расписаны, кроме правил выборки `GET /agile`, которые
+ тестом однозначно не определяются.
diff --git a/openspec/changes/sprints-backlog/.openspec.yaml b/openspec/changes/sprints-backlog/.openspec.yaml
new file mode 100644
index 0000000..1f5d280
--- /dev/null
+++ b/openspec/changes/sprints-backlog/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-24
diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md
new file mode 100644
index 0000000..ea19afd
--- /dev/null
+++ b/openspec/changes/sprints-backlog/design.md
@@ -0,0 +1,235 @@
+## Context
+
+Основа — `main` `a2bf3fc` (схема v8 после fact-status-removal #104). Issue #108, ветка `feat/108-sprints-backlog`.
+Агент — jira-agent v14 (`../jira-agent`), его решения — в design-only change `agent-v15-sprints` репозитория jira-agent.
+
+Что есть сейчас (разведка 24.10.2026):
+- у задачи в зеркале `issue.sprint` — имя одного спринта строкой; агент собирает имена со спринтов досок `scrum`
+ (активные и будущие, `GET /rest/agile/1.0/board/{id}/sprint?state=active,future` и `/sprint/{id}/issue`); у задач
+ закрытых спринтов `sprint = NULL` — принятое ограничение;
+- спринтов как сущностей (id, состояние, даты, цель), ранга задач, оценок и колонок доски хаб не знает;
+- `ensure_sprint {key, sprint}` переносит задачу в спринт или бэклог, `board_id` хаб дописывает по проекту задачи;
+- доска проекта — `jira_project.board_id`, `board_type` (`scrum`/`kanban`/`NULL`), тип пишет только результат `refresh`;
+- живые данные: доска только у `AWPCA` (4216, scrum), спринты `AWPCA_Sprint_2_VGO` (73 задачи) и
+ `AWPCA_Sprint_3_Corrections` (37), 188 своих задач вне спринтов; 11 статусов; у `STOCKS_DRRK` и `sfm_kedr` досок нет;
+- оценок в AURORA пока нет (`field_ids` без story points); веб — `/app/jira/board` («Доска» — доска связей),
+ `/app/jira/issues`, `/app/jira/feed`.
+
+## Goals / Non-Goals
+
+**Goals:** в вебе — бэклог, доска спринта и kanban по образцу Jira, только просмотр; история закрытых спринтов; оценки
+(story points) в зеркале, на экранах и в MCP; из чата — создание и правка спринта, запуск и завершение, ранжирование,
+оценка.
+
+**Non-Goals и Anti-Goals:** см. proposal.md.
+
+## Decisions
+
+1. **Веб — только просмотр** (решение владельца 24.10.2026: «веб только смотреть… вэб это только вью»). Ни
+ перетаскивания, ни кнопок, меняющих Jira, на новых экранах нет; все изменения — пакетами команд из MCP.
+2. **Экраны по типу доски проекта** (решение владельца): scrum — «Бэклог» и «Спринт»; kanban — «Kanban»; без доски —
+ пунктов нет. Тип доски неизвестен (`NULL`) — пунктов нет, как без доски.
+3. **Kanban-бэклог — раздел страницы «Kanban», а не отдельный пункт меню** (решение агента): признак kanban-бэклога
+ известен только из данных доски (`GET /agile`), а меню строится по `GET /projects`; отдельный пункт мигал бы при
+ загрузке. Если бэклог у доски включён — над колонками свёрнутый список «Бэклог (N)» по рангу.
+4. **«Доска» переименовывается в «Связи»** (решение владельца): подпись в меню и заголовке вкладки — «Связи», адрес —
+ `/app/jira/links`; `/app/jira/board` и прежний `/app/board` перенаправляют на него с сохранением строки запроса.
+5. **Спринты — сущности зеркала.** Таблица модуля jira `jira_sprint` (id Jira, доска, имя, состояние `active` /
+ `future` / `closed`, даты начала, конца и завершения, цель) и `issue_sprint` (задача ↔ все её спринты, любого
+ состояния). `issue.sprint` (имя текущего спринта) остаётся как есть — фильтры списка задач и доска связей его
+ используют. История закрытых спринтов (решение владельца: «лучше иметь исторические данные») — из `issue_sprint`:
+ Jira хранит у задачи все её спринты, агент передаёт их id.
+6. **Колонки доски — из настроек доски Jira** (`GET /rest/agile/1.0/board/{id}/configuration`): `jira_board` хранит
+ колонки `[{name, statuses: [имя статуса…]}]` и признак kanban-бэклога. Статус задачи без колонки — колонка «Без
+ колонки» в конце (видна, только если в ней есть задачи).
+7. **«Выполнено» — статус в последней колонке доски** (правило Jira для scrum-доски). Для закрытого спринта —
+ по текущему статусу задачи (решение агента): статуса на момент закрытия хаб не знает (журнал изменений покрывает
+ только открытые спринты и с 22.10.2026). Задача, выполненная после закрытия спринта, в прошлом спринте покажется
+ выполненной — принятое ограничение.
+8. **Свои задачи проекта.** Экраны и MCP показывают только свои задачи проекта (метка проекта), соседей нет. Спринты —
+ все спринты доски проекта, в том числе без своих задач.
+9. **Ранг — строка Jira (LexoRank) как есть**, порядок — сравнение строк; задачи без ранга — в конце, по ключу.
+10. **Оценка — story points**, число `REAL` (`issue.story_points`, `NULL` — нет оценки). Поле Jira: настройка
+ `field_ids.story_points`, если задана; иначе агент находит поле сам (решение агента в `agent-v15-sprints`).
+ Суммы по спринту: всего и выполнено; задача без оценки в сумму не входит, а число таких задач показывается.
+11. **Новые типы команд**, все «проверь, потом сделай», темп по умолчанию 30 с:
+ - `ensure_sprint_meta {sprint, rename_to?, start_date?, end_date?, goal?}` — спринт доски проекта с этим именем
+ (среди активных и будущих) существует и имеет эти поля; нет — создаётся;
+ - `ensure_sprint_state {sprint, state: "active" | "closed", move_open_to?}` — спринт в этом состоянии; при
+ `closed` невыполненные задачи (не в последней колонке) сначала переносятся в `move_open_to` (имя активного или
+ будущего спринта или `backlog`; для `closed` обязателен);
+ - `ensure_rank {key, before | after}` — задача выше (`before`) или ниже (`after`) другой задачи; уже так — без
+ записи.
+ `board_id` у `ensure_sprint_meta` и `ensure_sprint_state` хаб дописывает при постановке из проекта пакета (команды
+ не про задачу — проекта задачи нет). Пакет без проекта, проект без доски, kanban-доска, неизвестный тип доски — 422
+ с тем же текстом, что у `ensure_sprint`.
+12. **Оценка ставится через `ensure_fields`** (новое поле `story_points`: число ≥ 0 или `null` — очистить), отдельной
+ команды нет.
+13. **Данные досок — в каждом `refresh`.** Агент v15 к первой пачке состояния `refresh` прикладывает `boards` —
+ по каждой доске из параметров команды: тип, колонки, kanban-бэклог, все спринты (любого состояния); хаб заменяет
+ данные доски целиком. У задач — `rank`, `story_points`, `sprint_ids`. Результат команд видно после хвостового `refresh`, который
+ хаб уже дописывает к пишущему пакету (`hooks.tail`, по ключам задач команд); пакет с `ensure_sprint_meta` или
+ `ensure_sprint_state` получает хвост `refresh {jql: }` — у этих команд нет ключей, а
+ перенесённые при закрытии задачи заранее неизвестны. `refresh` без задач (все ключи пропали) агент v15 всё равно
+ отправляет одной пустой пачкой с `boards`.
+14. **Совместимость с агентом v14.** Без `boards` и новых полей хаб ничего не теряет: прежние данные досок и задач
+ остаются; экраны без данных доски показывают «Нужен агент v15 и обновление из Jira». Новые типы команд агенту v14 —
+ `failed` «неизвестный тип» на его стороне; `min_agent_version` хаб не поднимает (поднимет владелец после выкатки v15).
+15. **API — один маршрут `GET /agile?project=<метка>`** (данные доски, спринты со своими задачами и суммами, бэклог).
+ Веб и MCP читают его; отдельных маршрутов на спринт и бэклог нет.
+16. **Схема v9**: `issue.rank`, `issue.story_points`, таблицы `jira_board`, `jira_sprint`, `issue_sprint`.
+
+17. **Kanban без бэклога** (`kanban_backlog` `false` или `null`): первая колонка — обычная колонка доски, её задачи — в
+ `board_issues`; `backlog = null`.
+18. **`ensure_sprint_meta` ищет спринт по имени среди всех спринтов доски**: закрытый — `failed` «спринт закрыт»;
+ несколько с этим именем — `failed` с их id; `rename_to` на имя другого спринта доски — `failed` «имя занято».
+ `ensure_sprint_state {state: closed}` для будущего спринта — `failed` «спринт не начат»; `active` для закрытого —
+ `failed` «спринт закрыт». Результаты: `ensure_sprint_meta` — `{sprint_id, created}`, `ensure_sprint_state` —
+ `{sprint_id, moved}`, `ensure_rank` — `{changed}`. Подтверждения по зеркалу у новых типов нет (`check_confirmed` —
+ нет): повторная выдача без отчёта выполняется агентом заново, это безопасно по «проверь, потом сделай».
+19. **Проектный пакет:** `ensure_rank` принадлежит проекту по `key` (своя задача или временный ключ пакета); `before` /
+ `after` — любая задача зеркала. `ensure_sprint_meta`/`ensure_sprint_state` в пакете проекта проверяются по доске
+ проекта (решение 11), не по задаче. `command_retry` снимает `board_id` у всех трёх типов со спринтом, как у
+ `ensure_sprint`.
+20. **Форма новых полей состояния:** `story_points: null` и `rank: null` — «очистить»; `sprint_ids: []` — у задачи нет
+ спринтов; `sprint_ids: null` или не-список целых — 422 `bad_state`.
+21. **Меню модуля зависит от проекта:** пункт раздела модуля веба может нести условие по проекту (тип доски из `GET
+ /projects`); пока проекты не загружены, условные пункты скрыты. Смена проекта на странице, которой у нового проекта
+ нет, оставляет адрес и показывает «У проекта нет …-доски» (как прямой переход).
+22. **Тип доски — одна правда, `jira_project.board_type`:** тип из `boards` пачки состояния хаб записывает и в
+ `jira_board.type`, и в `jira_project.board_type` проектов этой доски (как результат `refresh`, только `scrum`/
+ `kanban`). Меню и 422 постановки читают `jira_project`, экраны — `GET /agile`, тип в нём — из `jira_project`.
+23. **Три состояния данных доски на экранах:** данных нет (`fetched_at = null`) — «Нужен агент v15 и обновление из
+ Jira»; данные есть, колонок нет (агент не смог прочитать настройки) — «Не удалось прочитать настройки доски из
+ Jira» и обновление; колонки есть — экран. Без колонок `done` у всех задач — `false`.
+24. **Даты:** в командах — `ГГГГ-ММ-ДД` (агент переводит в формат Jira); в `jira_sprint` — строки Jira как есть
+ (ISO-8601 со смещением); веб показывает их датой «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до
+ `end_date`.
+25. **История закрытых спринтов — из базовой JQL:** базовая JQL проектов не отсекает закрытые задачи (`labels in
+ (…)`), полный `refresh` возвращает и их — членство всех своих задач в закрытых спринтах приходит первым же полным
+ `refresh` агента v15.
+26. **«Доска связей» → «Связи»** (сейчас пункт меню называется «Доска связей», адрес `/jira/board`); `/app/board`
+ перенаправляет сразу на `/app/jira/links`. Документы, где адрес упомянут (README, CLAUDE.md репозитория, e2e), —
+ в том же change.
+
+## Контракт данных
+
+`POST /agent/state` (агент v15), необязательные поля:
+- у задачи: `rank` (строка или `null`), `story_points` (число или `null`), `sprint_ids` (список целых — все спринты
+ задачи); присланное `sprint_ids` заменяет все строки `issue_sprint` задачи, задача без поля свои строки сохраняет;
+- в пачке: `boards: [{id, type, columns: [{name, statuses: [строка]}] | null, kanban_backlog: bool | null,
+ sprints: [{id, name, state, start_date, end_date, complete_date, goal}]}]`; доска из `boards` заменяет свою строку
+ `jira_board` и все свои строки `jira_sprint`. Даты спринтов — строки Jira как есть или `null`. Пустой `refresh` —
+ одна пачка `{issues: [], links: [], boards}`.
+
+`GET /agile?project=P` →
+```
+{board: {id, type, columns, kanban_backlog, fetched_at} | null,
+ sprints: [{id, name, state, start_date, end_date, complete_date, goal,
+ count, count_done, unestimated, points, points_done, issues: [строка]}],
+ backlog: [строка] | null,
+ board_issues: [строка] | null}
+```
+Строка задачи: `key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done` (`done` —
+статус в последней колонке). Порядок спринтов: активные (по дате начала), будущие (по id), закрытые (по дате
+завершения, новые сверху). `backlog`: scrum — свои задачи не в активном и не в будущем спринте и не выполненные;
+kanban — свои задачи в статусах первой колонки, если kanban-бэклог включён, иначе `null`. `board_issues` (только
+kanban) — свои задачи в статусах остальных колонок (при выключенном или неизвестном бэклоге — и первой) и без колонки;
+выполненные — только с `jira_updated` за последние 14 дней. Всё
+по рангу.
+
+## Risks / Trade-offs
+
+- **Поля Jira банка не проверены**: имя поля story points, тип поля ранга, признак kanban-бэклога в
+ `board/{id}/configuration` Jira Server. Проверка — на живой Jira при выкатке агента; не найдено — агент передаёт
+ `null`, экраны показывают «—», признак kanban-бэклога `null` — раздела «Бэклог» нет.
+- **Объём закрытых спринтов**: агент читает список всех спринтов доски каждым `refresh` (постранично, без задач) — на
+ доске с десятками спринтов это несколько запросов; задачи закрытых спринтов не дочитываются, членство — из поля
+ спринта задач.
+- **Story points на экране редактирования Jira**: если поле не на экране AURORA, `ensure_fields story_points` получит
+ ошибку Jira — `failed` с её текстом; нужна настройка Jira (у владельца/администратора).
+
+## Decisions made without discussion
+
+- 24.10.2026 — R-1 (design #3): kanban-бэклог — раздел страницы «Kanban», не пункт меню. Цена ошибки — перенести
+ раздел в отдельную страницу.
+- 24.10.2026 — R-2 (design #7): «выполнено» у закрытого спринта — по текущему статусу. Цена ошибки — считать по
+ журналу изменений, когда он появится по закрытым спринтам.
+- 24.10.2026 — R-3 (design #8): только свои задачи проекта на экранах и в MCP. Цена ошибки — добавить соседей флагом.
+- 24.10.2026 — R-4 (design #10): поле оценки — `field_ids.story_points` или поиск агентом. Цена ошибки — настройка руками.
+- 24.10.2026 — R-5 (design #11): имена и параметры команд `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`;
+ «выше/ниже» — отношение, а не «вплотную». Цена ошибки — переименование типов до выкатки.
+- 24.10.2026 — R-6 (design #13): данные досок — в каждом `refresh`, в том числе по ключам. Цена ошибки — лишние
+ запросы к Jira (несколько на доску).
+- 24.10.2026 — R-7 (design #15): один маршрут `GET /agile`. Цена ошибки — разделить маршрут.
+- 24.10.2026 — R-8 (контракт): kanban — выполненные задачи за 14 дней. Цена ошибки — число в настройке.
+- 24.10.2026 — R-9 (design #6): колонка «Без колонки» для статусов вне настроек доски. Цена ошибки — нет.
+- 24.10.2026 — R-10 (design #13): хвост пакета со спринтами — `refresh` по JQL проекта; пустой `refresh` шлёт пачку с
+ `boards`. Цена ошибки — лишнее чтение проекта (~3 запроса `search`).
+- 24.10.2026 — R-11 (design #17–#20): правила kanban без бэклога, поиска спринта по имени, ошибок состояний, проектной
+ принадлежности `ensure_rank`, формы полей. Цена ошибки — правка проверок.
+- 24.10.2026 — R-12 (design #21–#26): условные пункты меню, тип доски в `jira_project`, три состояния данных доски,
+ даты в поясе браузера, история из базовой JQL, переименование «Доска связей». Цена ошибки — правка веба.
+- 24.10.2026 — R-28 (план агента R-64, R-65): поле Jira не найдено — ключ у задач не передаётся (хаб сохраняет прежнее),
+ а не `null`; ошибка чтения списка спринтов — доска не входит в `boards` (иначе стёрлась бы история спринтов).
+ Цена ошибки — нет.
+- 24.10.2026 — R-13 (контракт ядра): `CommandHooks.tail` получает `(conn, cmds, project)` вместо `(cmds)`: хвост
+ пакета со спринтовыми командами — `refresh` по JQL проекта пакета (design #13), а без соединения и проекта хук JQL
+ не построит. Правится ядро (`core/modules.py`, `core/queue.py`), wiki (`tail=lambda conn, cmds, project: None`) и
+ две заглушки в тестах. Цена ошибки — вернуть сигнатуру и строить хвост в `check_batch` (дописывая `refresh` в конец
+ пакета).
+- 24.10.2026 — R-14 (форма `boards`): у доски в `boards` поле `sprints` отсутствует или `null` — спринты доски в
+ зеркале не меняются (агент не смог их прочитать, agent spec «прежние правила для спринтов»); `[]` — у доски спринтов
+ нет, строки удаляются. `columns` отсутствует или `null` — пишется `NULL`. `boards: null` в пачке — как отсутствие.
+ Цена ошибки — устаревшие спринты на экране до следующего `refresh` с `sprints: []`.
+- 24.10.2026 — R-15 (проверки постановки): хаб проверяет `start_date`/`end_date` (`ГГГГ-ММ-ДД`, настоящая дата, начало
+ не позже конца), непустые `sprint`/`rename_to`, `state` ∈ {`active`, `closed`}, `move_open_to` (обязателен при
+ `closed`, 422 при `active`, не равен `sprint`), у `ensure_rank` ровно одно из `before`/`after` и не сама задача,
+ `story_points` — конечное число ≥ 0 или `null`. Цена ошибки — правка проверок.
+- 24.10.2026 — R-16 (подтверждение по зеркалу): `ensure_fields` со `story_points` подтверждается, только если
+ `issue.story_points == params.story_points` точно (`null` = `null`, `0` ≠ `null`); прочие поля — как раньше. Цена
+ ошибки — лишняя довыдача.
+- 24.10.2026 — R-17 (`GET /agile`): `board_type = null` → `sprints: []`, `backlog: null`, `board_issues: null`;
+ scrum-бэклог исключает задачи в активном/будущем спринте любой доски из `jira_sprint` (спринт, которого нет в
+ `jira_sprint`, не учитывается); kanban: выполненная задача без `jira_updated` не показывается; `kanban_backlog` в
+ ответе — `true`/ `false`/`null`; суммы `points`/`points_done` — сумма чисел (без оценок — `0`). Цена ошибки — правка
+ выборки.
+- 24.10.2026 — R-18 (Kanban с бэклогом): при `kanban_backlog = true` первая колонка доски не рисуется — её задачи в
+ «Бэклог (N)» (как kanban-бэклог Jira). Цена ошибки — вернуть колонку.
+- 24.10.2026 — R-19 (три состояния): «Нужен агент v15 и обновление из Jira» — `board = null` или `board.fetched_at =
+ null`; «Не удалось прочитать настройки доски из Jira» — `fetched_at` есть, `columns = null` (design #23 вместо
+ противоречивой фразы web-app «`board.columns = null` или ответ без доски»). Цена ошибки — правка условия.
+- 24.10.2026 — R-20 (кнопка «Обновить»): на экранах — существующая `RefreshButton` («Обновить из Jira», в шапке и в
+ сообщениях трёх состояний). Правило «кнопок нет, кроме раскрытия секций и карточки» к ней не относится: она читает
+ Jira, а не пишет. Цена ошибки — убрать кнопку из шапки.
+- 24.10.2026 — R-21 (переименование): «Связи» — подпись пункта меню, заголовок вкладки и ссылка в карточке jira на
+ «Обзоре»; заголовок `h1` страницы остаётся «Доска связей» (спека говорит о меню и вкладке). Цена ошибки — правка
+ `BoardHeader` и e2e `geometry.spec.ts`.
+- 24.10.2026 — R-22 (смена проекта): фраза «Проект в приложении» («смена проекта на странице, которой нет у нового
+ проекта, открывает «Обзор»») не меняется в коде: других условных страниц нет, а для экранов доски действует design
+ #21 (адрес остаётся, сообщение «У проекта нет …-доски») — `Layout.select` уже сохраняет раздел. Цена ошибки —
+ переход на «Обзор» для новых страниц.
+- 24.10.2026 — R-23 (NotFound): «не найдено» перечисляет только безусловные пункты (без `when`). Цена ошибки — нет.
+- 24.10.2026 — R-24 (склонение): «N задач» — с русским склонением (1 задача, 2 задачи, 5 задач). Цена ошибки — нет.
+- 24.10.2026 — R-25 (тексты MCP): шапки таблиц и отказы `sprints`/`sprint`/`backlog` — по задаче 7. Цена ошибки —
+ правка текста.
+- 24.10.2026 — R-26 (даты): хаб сортирует спринты по `history.jira_utc(дата)`; веб перед `new Date` приводит `±HHMM` к
+ `±HH:MM`. Цена ошибки — нет.
+- 24.10.2026 — R-27 (e2e): тестовый хаб засевает второй проект `KANBAN_E2E` (доска 5120, kanban, задачи
+ AURORA-9101…9103); доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки —
+ правка сида.
+- 24.10.2026 — R-29: трейлер коммитов — Co-Authored-By: Claude Sonnet 5.5 (инструкция сессии точнее перечня плана). Цена ошибки — переписать трейлеры, ничего не пушилось.
+- 24.10.2026 — R-30 (задача 3, `GET /agile`, по R-17): доска с заданным `board_id`, но неизвестным типом (`board_type` NULL) —
+ `board` с `type: null`, `sprints: []`, `backlog: null`, `board_issues: null`; kanban без `columns` при
+ `kanban_backlog = 1` — `backlog: []`, все задачи в `board_issues`. Выбрано исполнителем задачи 3, подтверждено
+ ревью как следствие R-17. Цена ошибки — правка выборки в `agile.view`.
+- 24.10.2026 — R-31 (задача 4, по R-15): пустая строка в `move_open_to` при `state = active` и в `before`/`after` у
+ `ensure_rank` трактуется как «не задано»; `ensure_sprint_meta` без параметров, кроме `sprint`, допустима. Выбрано
+ исполнителем задачи 4. Цена ошибки — ужесточить проверку (422 вместо `failed` у агента).
+- 24.10.2026 — R-32 (задача 9, по web-app): блок «Прошлые спринты (N)» на «Бэклоге» не рисуется при N = 0 (спека не
+ требует пустой блок). Выбрано исполнителем задачи 9. Цена ошибки — рисовать «Прошлые спринты (0)» свёрнутым.
+- 24.10.2026 — R-33 (финальное ревью M-3): до выкатки агента v15 команда `ensure_fields` со `story_points`, поставленная
+ хабу, будет выполнена агентом v14 как `done` с пустым `changed` (неизвестное поле v14 пропускает), хаб при постановке
+ версию агента не проверяет. Принято как ограничение окна выкатки: README требует публиковать v15 сразу после
+ перезапуска хаба. Цена ошибки — ложное «done» по оценке в окне между перезапуском и публикацией v15; исправление —
+ 422 при постановке, если `agent_version` модуля jira < 15.
diff --git a/openspec/changes/sprints-backlog/proposal.md b/openspec/changes/sprints-backlog/proposal.md
new file mode 100644
index 0000000..c6a9bf7
--- /dev/null
+++ b/openspec/changes/sprints-backlog/proposal.md
@@ -0,0 +1,51 @@
+## Why
+
+Владелец 23.10.2026 наметил для модуля jira спринты и бэклог «как в Jira». 24.10.2026 (brainstorming #108) уточнил:
+в первую очередь — визуализация в вебе, похожая на Jira (бэклог и доска спринта, для kanban-досок — kanban), и
+действия из чата через MCP: создавать спринты, запускать и завершать их, ранжировать задачи в бэклоге. Веб — только
+просмотр. Нужна история закрытых спринтов и оценки: в AURORA пока не оценивали, владелец хочет ретроспективно прикинуть
+оценки по прошедшему спринту, чтобы дальше планировать покером.
+
+Сейчас хаб знает спринт задачи только как имя строкой, у задач закрытых спринтов имя теряется; ни спринтов (даты,
+состояние, цель), ни ранга, ни оценок, ни колонок доски в зеркале нет.
+
+## What Changes
+
+- **Зеркало (схема v9):** спринты досок (`jira_sprint`), членство задач во всех их спринтах (`issue_sprint`), колонки
+ и kanban-бэклог доски (`jira_board`), у задачи — ранг (`rank`) и оценка (`story_points`).
+- **Агент v15** (design-only change `agent-v15-sprints` в jira-agent): в `refresh` читает настройки досок, все спринты,
+ ранг, story points и все спринты задачи; выполняет новые команды.
+- **Команды:** `ensure_sprint_meta` (создать или поправить спринт: имя, даты, цель), `ensure_sprint_state` (запустить
+ или завершить, при завершении — куда невыполненные), `ensure_rank` (выше/ниже другой задачи); `ensure_fields` —
+ поле `story_points`.
+- **API:** `GET /agile?project=` — доска, спринты (активные, будущие, закрытые) со своими задачами и суммами оценок,
+ бэклог, задачи kanban-доски.
+- **MCP `sdlc-jira`:** `sprints`, `sprint`, `backlog` — чтение с оценками и суммами; новые типы команд — через
+ `batch_create` с подсказками параметров.
+- **Веб модуля jira:** страницы «Бэклог», «Спринт» (scrum) и «Kanban» (kanban), только просмотр; пункты меню — по типу
+ доски проекта, без доски — нет; «Доска» → «Связи» (`/app/jira/links`, прежние адреса перенаправляют).
+
+## Non-Goals
+
+- Правки Jira из веба (перетаскивание, кнопки спринтов, смена статуса) — веб только смотрит.
+- Отчёты Jira (burndown, velocity), покер планирования, эпики на бэклоге отдельной панелью, версии (fix version).
+- Статус задачи на момент закрытия спринта (по журналу) — «выполнено» по текущему статусу.
+- Соседи (задачи других проектов) на новых экранах.
+- Подъём `min_agent_version` — владелец после выкатки v15.
+
+## Anti-Goals
+
+- Экраны, которые выглядят как Jira, но расходятся с ней по составу: спринт с задачами не того проекта, бэклог с
+ выполненными задачами, порядок не по рангу Jira.
+- Команда спринта, которая при повторе делает второй спринт или второй перенос.
+- Пункт меню, ведущий на пустую страницу у проекта без доски.
+- Потеря работы хаба со старым агентом v14 до выкатки v15.
+
+## Impact
+
+- Хаб: `modules/jira/{schema,mirror,api,mcp,commands,hooks,settings}.py`, `core/db.py` (v9), веб
+ `web/src/modules/jira/**` (новые страницы, переименование, меню), спеки `data-model`, `commands`, `agent`, `hub-api`,
+ `mcp`, `web-app`.
+- jira-agent: v15 (`src/agent.py`), свой design-only change.
+- Выкатка: хаб (kickstart, `make web`, `/mcp` reconnect) → `agent publish` v15 → проверка полей Jira банка →
+ `field_ids.story_points`, если агент поле не нашёл.
diff --git a/openspec/changes/sprints-backlog/specs/agent/spec.md b/openspec/changes/sprints-backlog/specs/agent/spec.md
new file mode 100644
index 0000000..8729207
--- /dev/null
+++ b/openspec/changes/sprints-backlog/specs/agent/spec.md
@@ -0,0 +1,28 @@
+## ADDED Requirements
+
+### Requirement: Спринты, ранг и оценки
+Агент v15 SHALL в каждом `refresh` (по JQL и по ключам) для каждой доски из `boards` команды с типом `scrum` или
+`kanban` (известным или только что определённым) прочитать настройки доски (`GET /rest/agile/1.0/board/{id}/configuration`:
+колонки и их статусы — по именам статусов; признак kanban-бэклога, если Jira его отдаёт, иначе `null`) и, у `scrum`,
+все спринты доски любого состояния (`GET /rest/agile/1.0/board/{id}/sprint` постранично) и приложить к первой пачке
+состояния `boards` (см. `hub-api`, «Приём досок и спринтов»). Ошибка чтения настроек доски — доска с `columns: null`,
+`kanban_backlog: null`; ошибка чтения списка спринтов scrum-доски — доска в `boards` не входит (хаб сохраняет прежние
+данные), имена спринтов задач — по правилам v12; причина в лог; `refresh` продолжается.
+
+Каждая задача в состоянии (с v15) SHALL нести `rank` — значение поля ранга Jira (поле типа LexoRank), `story_points` —
+значение поля оценки (`field_ids.story_points`, если задано, иначе поле Jira с именем «Story Points»), и `sprint_ids` —
+id всех спринтов задачи из поля спринта Jira (любого состояния). Поле не найдено — ключ у задач не передаётся (хаб
+сохраняет прежнее значение) и запись в лог один раз за `refresh`.
+
+Агент v15 SHALL выполнять `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` и `story_points` в
+`ensure_fields` по контракту `commands`; сравнение — до записи, повтор без изменений — `done` без записи.
+
+#### Scenario: Доска с закрытыми спринтами
+- **WHEN** `refresh {jql: …, boards: [{id: 4216, type: "scrum"}]}`, у доски закрытые спринты 1–2, активный 3, будущий 4
+- **THEN** первая пачка состояния несёт `boards` с доской 4216, четырьмя спринтами и колонками; у задачи спринта 2,
+ перенесённой в 3, — `sprint_ids: [2, 3]`
+
+#### Scenario: Нет поля оценки
+- **WHEN** в Jira нет поля «Story Points», `field_ids.story_points` не задан
+- **THEN** у задач нет ключа `story_points`; `ensure_fields {key: X, story_points: 3}` — `failed` «поле оценки не найдено
+ в Jira: задайте field_ids.story_points»
diff --git a/openspec/changes/sprints-backlog/specs/commands/spec.md b/openspec/changes/sprints-backlog/specs/commands/spec.md
new file mode 100644
index 0000000..9e7dd4f
--- /dev/null
+++ b/openspec/changes/sprints-backlog/specs/commands/spec.md
@@ -0,0 +1,129 @@
+## MODIFIED Requirements
+
+### Requirement: Команда — желаемый результат на одной задаче
+Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить →
+записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done`
+без записи. `fetch_attachment` в Jira не пишет: она читает файл вложения и передаёт его хабу; повтор безопасен. Типы команд и их контракт:
+
+| Тип | Параметры | Что читает | Что пишет |
+|---|---|---|---|
+| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` |
+| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee`, `story_points` (число ≥ 0 или `null` — очистить) | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode`; оценка — в поле story points (см. `agent`, «Спринты, ранг и оценки»); поля нет — `failed` «поле оценки не найдено в Jira: задайте field_ids.story_points» |
+| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет |
+| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных |
+| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием |
+| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` |
+| `ensure_sprint_meta` | `sprint` (имя), необязательные `rename_to`, `start_date`, `end_date` (`ГГГГ-ММ-ДД`), `goal`; `board_id` хаб дописывает при постановке (от клиента — 422) | спринты доски `board_id` (все состояния) | спринта с именем `sprint` нет — `POST /rest/agile/1.0/sprint` (`originBoardId`, имя `rename_to` или `sprint`, даты, цель); есть — `POST /rest/agile/1.0/sprint/{id}` только с отличающимися полями; спринт закрыт — `failed` «спринт закрыт»; результат `{sprint_id, created}` |
+| `ensure_sprint_state` | `sprint`, `state` (`active` или `closed`), `move_open_to` (имя активного или будущего спринта той же доски или `backlog`; при `closed` обязателен, при `active` — 422); `board_id` — как у `ensure_sprint_meta` | спринты доски (все состояния), задачи спринта и колонки доски | `active`: спринт будущий — `POST /sprint/{id}` `state = active` (нет дат у спринта — `failed` «у спринта нет дат: задайте ensure_sprint_meta»); `closed`: задачи спринта не в последней колонке доски — в `move_open_to`, затем `state = closed`; уже в нужном состоянии — ничего; результат `{sprint_id, moved: [ключ…]}` |
+| `ensure_rank` | `key` и ровно одно из `before`, `after` (ключ другой задачи) | ранг обеих задач | `key` уже выше `before` (ниже `after`) — ничего; иначе `PUT /rest/agile/1.0/issue/rank` (`rankBeforeIssue` / `rankAfterIssue`) |
+| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` |
+| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал |
+| `fetch_attachment` | `key`, `attachment_id` (id вложения Jira, строка из цифр) | `GET /issue/{key}?fields=attachment` (имя и размер вложения) | ничего в Jira; содержимое — `GET /secure/attachment/{id}/{имя}`, затем хабу `POST /agent/files`; результат `{attachment_id, file, size}` |
+
+Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в
+зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` —
+доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта
+нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P
+ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе
+не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и
+`kanban` в проекты с этой доской; `null` тип не меняет.
+
+Доску для `ensure_sprint_meta` и `ensure_sprint_state` хаб SHALL определять при постановке по проекту пакета
+(`project` пакета): пакет без проекта — 422 «команды спринта — только в пакете проекта»; у проекта нет доски, доска
+`kanban` или тип ещё не известен — 422 с теми же текстами, что у `ensure_sprint`. `ensure_rank` в пакете проекта
+принадлежит проекту по `key`; `before`/`after` — любая задача. Повтор команды (`command_retry`) SHALL снимать `board_id`
+у `ensure_sprint_meta` и `ensure_sprint_state`, как у `ensure_sprint`. Хвостовой `refresh` пишущего пакета с
+`ensure_sprint_meta` или `ensure_sprint_state` SHALL быть `refresh {jql: }` (`project =
+ AND labels in ("<метка>")`); иначе — как раньше, по ключам команд. Подтверждения по состоянию зеркала у
+`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` нет.
+
+`ensure_sprint_meta` SHALL искать спринт по имени среди всех спринтов доски: закрытый — `failed` «спринт закрыт»;
+несколько с этим именем — `failed` «несколько спринтов с именем …: »; `rename_to` на имя другого спринта доски —
+`failed` «имя занято». `ensure_sprint_state`: `closed` для будущего спринта — `failed` «спринт не начат»; `active` для
+закрытого — `failed` «спринт закрыт»; результат `ensure_rank` — `{changed}`.
+
+Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в
+`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026).
+
+Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira
+без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный
+в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026).
+
+#### Scenario: Повтор уже выполненной команды
+- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10
+- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1`
+
+#### Scenario: Описание отличается только переводами строк
+- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"`
+- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []`
+
+#### Scenario: Замена связи
+- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между
+ ними «блокирует начало» 1537→1538
+- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями
+
+#### Scenario: Связь-конфликт без replace
+- **WHEN** та же команда с `replace: false`
+- **THEN** `failed`, в `error` найденная связь, Jira не изменена
+
+#### Scenario: Переход по пути
+- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки»
+- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи
+
+#### Scenario: Создание ошибки
+- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках
+ `issue_types.bug = {name: "Ошибка"}` без `id`
+- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом
+
+#### Scenario: Вложение уже есть
+- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того
+ же размера
+- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1`
+
+#### Scenario: Вложение с тем же именем, другого размера
+- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера
+- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id`
+
+#### Scenario: Создание с проверкой дубля
+- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад
+- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной
+
+#### Scenario: Спринт задачи проекта
+- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}`
+- **THEN** пакет создан, в параметрах команды `board_id = 4216`
+
+#### Scenario: Спринт на канбане
+- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}`
+- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет»
+
+#### Scenario: Тип доски определён
+- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}`
+- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban`
+
+### Requirement: Политика темпа и пауза на команде
+Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и
+`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `ensure_attachment` 30, `refresh` и `fetch_attachment` 2; тип, которого нет в
+сохранённой настройке `pacing`, получает зазор по умолчанию). При выдаче команды
+хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего
+последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент
+SHALL выдерживать зазор типа команды между записями.
+
+#### Scenario: Нет следующей команды — нет паузы
+- **WHEN** очередь пуста
+- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса
+
+#### Scenario: Создание спринта повтором не дублируется
+- **WHEN** пакет проекта `AWPCA` (доска 4216, scrum) `ensure_sprint_meta {sprint: "AWPCA_Sprint_4", start_date: "2026-10-28", end_date: "2026-11-11", goal: "свод ВГО"}` выполнен, затем поставлен повторно
+- **THEN** первый раз спринт создан (`created: true`), второй — `done` без записи (`created: false`)
+
+#### Scenario: Завершение спринта с переносом
+- **WHEN** `ensure_sprint_state {sprint: "AWPCA_Sprint_3_Corrections", state: "closed", move_open_to: "AWPCA_Sprint_4"}`, в спринте 37 задач, 12 не в последней колонке доски
+- **THEN** 12 задач перенесены в `AWPCA_Sprint_4`, спринт закрыт; результат `moved` — их ключи; повтор — `done` без записи
+
+#### Scenario: Спринт без проекта пакета
+- **WHEN** `POST /batches` без `project` с `ensure_sprint_meta {sprint: "S"}`
+- **THEN** 422 «команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта»
+
+#### Scenario: Ранг уже такой
+- **WHEN** `ensure_rank {key: AURORA-1612, before: AURORA-1601}`, ранг 1612 меньше ранга 1601
+- **THEN** `done` без записи в Jira
diff --git a/openspec/changes/sprints-backlog/specs/data-model/spec.md b/openspec/changes/sprints-backlog/specs/data-model/spec.md
new file mode 100644
index 0000000..6448162
--- /dev/null
+++ b/openspec/changes/sprints-backlog/specs/data-model/spec.md
@@ -0,0 +1,160 @@
+## MODIFIED Requirements
+
+### Requirement: Схема базы хаба
+База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_attachment`,
+`attachment_file`, `issue_note`, `issue_sprint`, `jira_board`, `jira_sprint`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при
+старте по номеру версии в `setting.schema_version` (текущая — 9). Миграция существующей базы SHALL выполняться в одной
+транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в
+`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни
+открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их
+при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и
+дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени
+Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала.
+
+Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю
+настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта
+`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`.
+
+Миграция v5 → v6 SHALL: создать пустые `issue_attachment` (с индексом по `key`) и `attachment_file`. Списки вложений
+появляются с первым `refresh` агента v14.
+
+Миграция v7 → v8 SHALL: удалить колонку `issue_note.fact_status` (если она есть; значения удаляются безвозвратно, копия —
+в `backups/` по общему правилу) и ключи `status_labels` и `transitions_map` из `setting`, если они есть, и поднять
+`settings_version`, если хотя бы один из ключей был удалён. Прочие поля заметок, задачи и настройки миграция MUST NOT
+менять.
+
+Миграция v8 → v9 SHALL: добавить колонки `issue.rank` и `issue.story_points` (у имеющихся задач `NULL`); таблицы
+`jira_board`, `jira_sprint`, `issue_sprint` создаются схемой пустыми. Данные появляются с первым `refresh` агента v15.
+
+#### 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` = текущая
+
+#### Scenario: Миграция базы v2
+- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки
+- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version` = текущая
+
+#### 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` = текущая
+
+#### 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`; затем миграция v5 → v6; `schema_version` = текущая
+
+#### Scenario: Миграция базы v5
+- **WHEN** хаб стартует с базой `schema_version = 5`
+- **THEN** в `backups/` копия базы v5; есть пустые `issue_attachment` и `attachment_file`; прочие таблицы не тронуты;
+ `schema_version` = текущая
+
+#### Scenario: Миграция базы v7
+- **WHEN** хаб стартует с базой `schema_version = 7`, у задачи X заметка `fact_status = "В работе"`, `work_kind =
+ "Аналитика"`, в `setting` есть `status_labels` и `transitions_map`, `settings_version = 12`
+- **THEN** в `backups/` копия базы v7; в `issue_note` нет колонки `fact_status`, у X `work_kind = "Аналитика"`,
+ `updated_at` прежний; ключей `status_labels` и `transitions_map` в `setting` нет; `settings_version = 13`;
+ `schema_version` = текущая
+
+#### Scenario: Миграция базы v8
+- **WHEN** хаб стартует с базой `schema_version = 8`, в зеркале задача X со спринтом «Спринт 2»
+- **THEN** в `backups/` копия базы v8; у X `rank` и `story_points` — `NULL`, `sprint` прежний; таблицы `jira_board`,
+ `jira_sprint`, `issue_sprint` пусты; `schema_version = 9`
+
+#### 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`
+
+### Requirement: Зеркало задачи
+Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki),
+`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список),
+`sprint`, `rank` (ранг Jira строкой как есть или `NULL`), `story_points` (оценка, число или `NULL`),
+`jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link`
+SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается
+на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть:
+`key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Таблица `issue_attachment` SHALL
+хранить список вложений задачи без содержимого: `attachment_id` (PK, id вложения Jira), `key`, `filename`, `size` (байт
+или `NULL`), `mime_type`, `author`, `created` (время Jira как есть), `created_at` (UTC, приведённое из `created`; не
+разобралось — `NULL`). Обновлять `issue`, `issue_link`, `issue_comment` и
+`issue_attachment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные
+комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии
+SHALL сохранять. Так же со вложениями: присланное поле `attachments` заменяет все прежние вложения задачи, задача без
+поля (агент до v14) свои вложения сохраняет. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не
+обновлённые им, — только если прогон загрузил хотя бы одну задачу.
+
+#### Scenario: Загрузка состояния заменяет связи
+- **WHEN** агент прислал состояние задачи X с двумя связями
+- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные
+ со стороны X, не трогаются до загрузки их источника
+
+#### Scenario: Соседняя задача
+- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL
+- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0`
+
+#### Scenario: Пустой полный прогон
+- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач
+- **THEN** признак `neighbor` задач зеркала не меняется
+
+#### Scenario: Комментарий удалён в Jira
+- **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]`
+- **THEN** у X в `issue_comment` только 102
+
+#### Scenario: Состояние от агента до v10
+- **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments`
+- **THEN** поля X обновлены, три комментария на месте
+
+#### Scenario: Вложения в состоянии
+- **WHEN** у X в зеркале вложения #1 и #2, агент v14 прислал X с `attachments = [{id: "2", …}, {id: "3", …}]`
+- **THEN** у X в `issue_attachment` #2 (поля из состояния) и #3; #1 удалено; `attachment_file` не тронута
+
+#### Scenario: Состояние от агента до v14
+- **WHEN** у X в зеркале два вложения, агент v13 прислал X без поля `attachments`
+- **THEN** поля X обновлены, два вложения на месте
+
+## ADDED Requirements
+
+### Requirement: Доски, спринты и членство задач
+Модуль jira SHALL хранить данные досок проектов, присланные агентом в `refresh` (см. `hub-api`, «Приём досок и
+спринтов»):
+- `jira_board`: `board_id` (PK), `type` (`scrum` | `kanban` | `NULL`), `columns` (JSON-список `[{name, statuses:
+ [имя статуса]}]` в порядке доски или `NULL`), `kanban_backlog` (1 / 0 / `NULL` — неизвестно), `fetched_at` (UTC);
+- `jira_sprint`: `sprint_id` (PK, id спринта Jira), `board_id`, `name`, `state` (`active` | `future` | `closed`),
+ `start_date`, `end_date`, `complete_date` (строки Jira как есть или `NULL`), `goal`;
+- `issue_sprint`: `key`, `sprint_id` (PK — пара) — все спринты задачи любого состояния.
+Писать их SHALL только загрузка состояния от агента. Выполненной на доске SHALL считаться задача, чей `status` входит в
+`statuses` последней колонки доски; колонок нет (`NULL`) — выполненных нет.
+
+#### Scenario: Задача прошла два спринта
+- **WHEN** агент прислал задачу X с `sprint_ids: [101, 102]`, спринт 101 закрыт, 102 активен
+- **THEN** у X две строки `issue_sprint`; X в составе закрытого спринта 101 и активного 102
+
+#### Scenario: Колонки доски
+- **WHEN** у доски 4216 колонки «К выполнению» [`Backlog`, `Ожидает анализа`], «В работе» […], «Готово» [`Готово`,
+ `Протестировано`, `Снято`]
+- **THEN** задача в статусе `Протестировано` выполнена, в статусе `Backlog` — нет
diff --git a/openspec/changes/sprints-backlog/specs/hub-api/spec.md b/openspec/changes/sprints-backlog/specs/hub-api/spec.md
new file mode 100644
index 0000000..e918b26
--- /dev/null
+++ b/openspec/changes/sprints-backlog/specs/hub-api/spec.md
@@ -0,0 +1,54 @@
+## ADDED Requirements
+
+### Requirement: Приём досок и спринтов
+`POST /agent/state` (агент v15) SHALL принимать необязательные поля:
+- у задачи — `rank` (строка или `null`), `story_points` (число или `null`), `sprint_ids` (список целых); присланные
+ `rank` и `story_points` записываются в `issue`; присланный `sprint_ids` заменяет все строки `issue_sprint` задачи;
+ задача без поля свои значения сохраняет;
+- в пачке — `boards`: список `{id, type, columns, kanban_backlog, sprints}`; `columns` — список `{name, statuses}` или
+ `null`, `kanban_backlog` — `true`/`false`/`null`, `sprints` — список `{id, name, state, start_date, end_date,
+ complete_date, goal}`. Доска из `boards` SHALL заменять свою строку `jira_board` и все строки `jira_sprint` этой
+ доски; доски, которых нет в `boards`, не меняются.
+Форму хаб SHALL проверять до записи, как `comments`: нарушение — 422 `bad_state` с ключом задачи или номером доски и
+именем поля, пачка не применяется. `state` спринта вне `active`/`future`/`closed`, `sprint_ids` не список целых (в том
+числе `null`) — нарушение; `rank: null` и `story_points: null` — очистить поле. Тип доски из `boards` (`scrum` /
+`kanban`) хаб SHALL записывать и в `jira_project.board_type` проектов этой доски; `null` тип не меняет. Пачка может
+быть пустой (`issues: []`) и нести только `boards`.
+
+#### Scenario: Пачка с досками
+- **WHEN** пачка `refresh` несёт `boards: [{id: 4216, type: "scrum", columns: […], kanban_backlog: null, sprints: [101
+ closed, 102 active, 103 future]}]`, раньше у доски были спринты 101 и 102
+- **THEN** у доски 4216 строки `jira_sprint` 101, 102, 103 с присланными полями; колонки заменены
+
+#### Scenario: Агент v14
+- **WHEN** пачка без `boards`, задачи без `rank`, `story_points`, `sprint_ids`
+- **THEN** данные досок и эти поля задач не меняются
+
+### Requirement: Маршрут планирования
+`GET /agile?project=<метка>` (клиентский токен) SHALL отдавать для проекта (неизвестный — 404; без `project` — 422):
+`{board, sprints, backlog, board_issues}`.
+- `board` — `{id, type, columns, kanban_backlog, fetched_at}` доски проекта; доски нет — `null` (остальные поля —
+ `[]`/`null`); доска есть, а её данных от агента нет — `{id, type, columns: null, kanban_backlog: null, fetched_at:
+ null}`.
+- `sprints` — все спринты доски: `{id, name, state, start_date, end_date, complete_date, goal, count, count_done,
+ unestimated, points, points_done, issues}`; `issues` — свои задачи проекта в этом спринте (по `issue_sprint`);
+ `count`/`count_done` — их число и число выполненных, `points`/`points_done` — сумма `story_points` всех и выполненных,
+ `unestimated` — число без оценки. Порядок: `active` по `start_date`, `future` по id, `closed` по `complete_date`, новые
+ сверху. У kanban-доски — `[]`.
+- `backlog` — scrum: свои задачи не в активном и не в будущем спринте и не выполненные; kanban: свои задачи в статусах
+ первой колонки, если `kanban_backlog = true`, иначе `null`.
+- `board_issues` — только kanban: свои задачи в статусах остальных колонок (при `kanban_backlog` не `true` — и
+ первой) и без колонки; выполненные — только с
+ `jira_updated` не старше 14 дней; scrum — `null`.
+Строка задачи — `{key, summary, issue_type, status, priority, assignee, epic_key, story_points, rank, done}`; списки — по
+`rank` (строки сравниваются как есть; `null` — в конце), затем по `key`.
+
+#### Scenario: Спринт и бэклог
+- **WHEN** у `AWPCA` активный спринт 102 с задачами A (3 SP, в последней колонке), B (без оценки), в бэклоге C и
+ выполненная D без спринта
+- **THEN** спринт 102: `count = 2`, `count_done = 1`, `points = 3`, `points_done = 3`, `unestimated = 1`; `backlog` —
+ только C
+
+#### Scenario: Проект без доски
+- **WHEN** `GET /agile?project=STOCKS_DRRK`, у проекта нет доски
+- **THEN** 200, `board = null`, `sprints = []`, `backlog = null`, `board_issues = null`
diff --git a/openspec/changes/sprints-backlog/specs/mcp/spec.md b/openspec/changes/sprints-backlog/specs/mcp/spec.md
new file mode 100644
index 0000000..67cceac
--- /dev/null
+++ b/openspec/changes/sprints-backlog/specs/mcp/spec.md
@@ -0,0 +1,22 @@
+## ADDED Requirements
+
+### Requirement: Спринты и бэклог в MCP
+MCP `sdlc-jira` SHALL давать инструменты чтения по `GET /agile` проекта сессии (сессия без проекта — тот же отказ, что у
+прочих инструментов с проектом):
+- `sprints` — таблица спринтов доски: имя, состояние, даты, задач (выполнено/всего), SP (выполнено/всего), без оценки;
+ закрытые — 10 последних и строка «ещё N закрытых», если их больше; без доски — «у проекта нет доски»;
+- `sprint` (`name`, по умолчанию — активный) — шапка спринта (имя, состояние, даты, цель, суммы) и задачи по колонкам
+ доски: ключ, тема (до 80 символов), исполнитель, SP;
+- `backlog` (`limit`, по умолчанию 50) — бэклог по рангу: позиция, ключ, тема, статус, исполнитель, SP; в конце «ещё N»,
+ если задач больше `limit`.
+Таблица `issues` SHALL получить колонку «SP». Описание `batch_create` SHALL перечислять `ensure_sprint_meta`,
+`ensure_sprint_state`, `ensure_rank` и `story_points` в `ensure_fields` с параметрами; хвостовой `refresh` хаб
+дописывает сам (см. `commands`).
+
+#### Scenario: Спринты проекта
+- **WHEN** сессия привязана к `AWPCA`, вызван `sprints`
+- **THEN** строки: активный `AWPCA_Sprint_3_Corrections`, будущие, затем закрытые; у каждой — задачи и SP «выполнено/всего»
+
+#### Scenario: Бэклог с лимитом
+- **WHEN** `backlog {limit: 2}`, в бэклоге 188 задач
+- **THEN** две строки по рангу и «ещё 186»
diff --git a/openspec/changes/sprints-backlog/specs/web-app/spec.md b/openspec/changes/sprints-backlog/specs/web-app/spec.md
new file mode 100644
index 0000000..fb51e48
--- /dev/null
+++ b/openspec/changes/sprints-backlog/specs/web-app/spec.md
@@ -0,0 +1,187 @@
+## MODIFIED Requirements
+
+### Requirement: Оболочка и адреса
+Приложение SHALL открываться под префиксом `/app/`. Главная страница — «Обзор» ядра (`/app/?project=<метка>`,
+требование «Обзор проекта»). Страницы модулей SHALL лежать под префиксом имени модуля: модуль jira — `/app/jira/links`
+(«Связи» — доска связей), `/app/jira/backlog` («Бэклог»), `/app/jira/sprint` («Спринт»), `/app/jira/kanban` («Kanban»;
+три последних — требование «Спринты и бэклог»), `/app/jira/issues` (список задач), `/app/jira/feed` (лента изменений, требование «Лента изменений») и
+`/app/jira/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); модуль wiki —
+`/app/wiki` (требование «Вики проекта»). Прежние адреса `/app/board` и `/app/jira/board` (на `/app/jira/links`),
+`/app/issues`, `/app/issues/{key}`, `/app/feed` SHALL перенаправлять на новые с сохранением строки запроса, без новой записи истории. Неизвестный путь — страница «не
+найдено» со ссылками на «Обзор» и разделы модулей.
+
+Боковая панель SHALL быть закреплена у левого края окна на всю его высоту и MUST NOT прокручиваться вместе со
+страницей; если её содержимое не помещается по высоте, прокручивается сама панель. Сверху вниз панель SHALL содержать:
+название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий отмечен, в
+конце «+ Новый проект…»; требование «Проект в приложении»); пункт «Обзор»; разделы модулей группами — заголовок группы
+— название модуля («Jira», «Вики»), под ним ссылки разделов модуля со значками (текущий отмечен
+`aria-current="page"`); внизу — блок состояния модулей (требование «Состояние и обновление модулей»), переключатель
+темы (требование «Тема») и меню с действием «Сменить токен». Свёрнутая панель SHALL быть узкой полосой с теми же
+действиями значками: «Развернуть панель», метка текущего проекта (полностью — в подсказке), «Обзор», разделы
+(группы разделены линией), по значку состояния на модуль, тема, меню; названия — в подсказках и `aria-label`. Свёртка
+SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута).
+
+На экранах уже 640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», меткой текущего
+проекта и сводной точкой состояния модулей (ошибка, если она есть у какого-нибудь модуля; иначе — пакет в работе;
+иначе — все агенты на связи или нет), которая открывает панель. «Меню» открывает развёрнутую панель поверх страницы
+модальным окном: фокус переходит в панель и остаётся в ней, страница под ней недоступна; панель закрывается выбором
+раздела, клавишей Escape (если не открыто вложенное меню — тогда Escape закрывает только его) или щелчком мимо неё; фокус
+возвращается на «Меню». Горизонтальной прокрутки страницы на любой ширине нет.
+
+Заголовок вкладки браузера SHALL быть «<название страницы> · <метка проекта> · SDLC Hub» (без проекта — без средней
+части); название страницы даёт оболочка («Обзор») или модуль. Неожиданная ошибка отрисовки страницы SHALL показываться
+сообщением с кнопкой «Перезагрузить», не пустым экраном.
+
+#### Scenario: Прежний адрес доски связей
+- **WHEN** открыт `/app/jira/board?project=AWPCA`
+- **THEN** адрес — `/app/jira/links?project=AWPCA`, показана доска связей, в меню отмечен пункт «Связи», заголовок
+ вкладки — «Связи · AWPCA · SDLC Hub»
+
+#### Scenario: Переход между разделами
+- **WHEN** на доске в боковой панели выбран раздел «Задачи» группы «Jira»
+- **THEN** адрес — `/app/jira/issues` с текущим проектом, показан список задач; «назад» возвращает на доску
+
+#### Scenario: Раздел «Лента»
+- **WHEN** на доске в боковой панели выбран раздел «Лента»
+- **THEN** адрес — `/app/jira/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели
+
+#### Scenario: Сменить токен
+- **WHEN** в меню боковой панели выбрано «Сменить токен»
+- **THEN** токен очищен, показан экран входа
+
+#### Scenario: Панель на месте при прокрутке
+- **WHEN** доска прокручена до самого низа
+- **THEN** боковая панель видна целиком на прежнем месте: разделы и блок состояния модулей доступны без прокрутки вверх
+
+#### Scenario: Свёрнутая панель
+- **WHEN** нажата «Свернуть панель», страница перезагружена
+- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; видны два значка
+ состояния — Jira и Вики; «Развернуть панель» возвращает полную панель
+
+#### Scenario: Узкий экран
+- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи»
+- **THEN** панель открылась поверх страницы, фокус в панели; после выбора панель закрыта, фокус на «Меню», показан список
+ задач; горизонтальной прокрутки нет
+
+#### Scenario: Прежний адрес
+- **WHEN** открыт `/app/issues/AURORA-1536?project=AWPCA`
+- **THEN** адрес заменён на `/app/jira/issues/AURORA-1536?project=AWPCA`, карточка открыта поверх списка задач
+
+#### Scenario: Заголовок вкладки
+- **WHEN** открыта страница «Вики» проекта `AWPCA`
+- **THEN** заголовок вкладки — «Вики · AWPCA · SDLC Hub»
+
+### Requirement: Проект в приложении
+«Обзор», доска, список задач, лента и вики SHALL показывать один проект — текущий. Текущий проект SHALL задаваться
+параметром `project` в адресе страниц; без параметра — последний выбранный в этом браузере (`localStorage`), если он
+есть в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории.
+Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и подсказку выбрать
+проект в списке «Проект» боковой панели (на узком экране — в «Меню») вместо данных. Проектов нет — страницы SHALL
+показывать приглашение создать проект («+ Новый проект…»). Переходы между страницами SHALL сохранять текущий проект;
+смена проекта на странице, которой нет у нового проекта, открывает «Обзор»; исключение — «Бэклог», «Спринт», «Kanban»:
+адрес остаётся, страница показывает «У проекта нет …-доски» (требование «Спринты и бэклог»). Карточка задачи
+(`/app/jira/issues/{key}`) от проекта не зависит.
+
+Доска SHALL загружать `GET /board?project=<текущий>`. Раскладка, дорожки и фильтры доски в `localStorage` SHALL
+храниться отдельно для каждого проекта; у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов,
+сохраняется). Фильтр спринтов SHALL показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`)
+есть спринт; иначе — «колонки «Спринт» нет — показаны все задачи».
+
+Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка (с
+подсказкой «метка задач в Jira и имя проекта в хабе») и название, затем поля каждого модуля в своей группе с
+заголовком — названием модуля: «Jira» — номер доски Jira (необязательно; подсказка «scrum или kanban определится
+сам»), «Вики» — корень вики (необязательно; подсказка «DOC-… или ссылка на страницу Eva»). Подсказка — текстом под
+полем, не в placeholder. Пустое необязательное поле в запрос не попадает; числовое поле с нецелым значением — ошибка у
+поля, запрос не уходит. «Создать» — `POST /projects` с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с
+кнопкой, окно остаётся открытым; успех — окно закрывается, текущим становится новый проект, каждый пакет из ответа
+(`batches`) отслеживается своим модулем (требование «Состояние и обновление модулей»).
+
+#### Scenario: Первый вход после выкатки
+- **WHEN** открыт `/app/jira/links` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK`
+- **THEN** адрес — `/app/jira/links?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек
+
+#### Scenario: Переключение проекта
+- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи»
+- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» —
+ `/app/jira/issues?project=STOCKS_DRRK`
+
+#### Scenario: Неизвестный проект в адресе
+- **WHEN** открыт `/app/jira/issues?project=NOPE`
+- **THEN** «проект NOPE не найден» и подсказка выбрать проект в боковой панели; запроса `GET /issues` нет
+
+#### Scenario: Создание проекта из формы
+- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска и корень вики пусто; нажато «Создать»
+- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`;
+ строка «Jira» блока состояния показывает «пакет #N выполняется»
+
+#### Scenario: Проект с корнем вики из формы
+- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», корень `DOC-001234`; нажато «Создать»
+- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", wiki_root: "DOC-001234", author: "viewer"}`; строка
+ «Jira» показывает пакет обновления, строка «Вики» — пакет синхронизации
+
+#### Scenario: Метка занята
+- **WHEN** в форме введена метка `awpca`, хаб ответил 409
+- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба
+
+#### Scenario: Канбан-проект
+- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть)
+- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи»
+
+#### Scenario: Нецелый номер доски
+- **WHEN** в поле «Номер доски Jira» введено `51.5`, нажато «Создать»
+- **THEN** у поля — «нужно целое число», запроса `POST /projects` нет
+
+## ADDED Requirements
+
+### Requirement: Спринты и бэклог
+Модуль jira SHALL показывать для проекта с доской страницы по образцу Jira — только для просмотра: на них MUST NOT быть
+действий, меняющих Jira (перетаскивания, кнопок спринтов, смены статуса или ранга). Пункты меню группы «Jira» —
+«Связи», «Задачи», «Лента» и по типу доски текущего проекта (`board_type` из `GET /projects`): `scrum` — «Бэклог» и
+«Спринт»; `kanban` — «Kanban»; доски нет или тип неизвестен — новых пунктов нет, а прямой переход на их адрес
+показывает «У проекта нет scrum-доски» («…kanban-доски» для «Kanban») со ссылкой на «Связи». Данные — `GET
+/agile?project=<текущий>`; если доска есть, но её данных от агента нет (`board = null` или `board.fetched_at = null`) —
+«Нужен агент v15 и обновление из Jira» с кнопкой «Обновить» блока состояния модуля; данные доски есть (`fetched_at` задан), а колонок нет (`columns = null`) — «Не удалось
+прочитать настройки доски из Jira» и та же кнопка. Пока проекты не загружены, условные пункты меню скрыты; смена проекта
+на странице, которой у нового проекта нет, оставляет адрес и показывает сообщение «У проекта нет …-доски». Даты
+спринтов (ISO-8601 Jira) показываются «ДД.ММ» в часовом поясе браузера; «осталось N дн.» — от сегодня до конца спринта.
+
+Строка задачи и карточка задачи на этих страницах: ключ, значок типа, тема, исполнитель (или «—»), оценка (число или
+«—»); выполненная — приглушена. Щелчок открывает карточку задачи (окно поверх страницы, как в списке задач).
+
+**«Бэклог»** (`/app/jira/backlog`), сверху вниз: секции активных спринтов, будущих спринтов, «Бэклог», затем
+свёрнутый список «Прошлые спринты (N)». Заголовок секции спринта: имя, состояние, даты «ДД.ММ–ДД.ММ», цель (если есть),
+«N задач», «S SP (выполнено D)» и «без оценки: U», если `U > 0`. Задачи секции — по рангу. Секция без задач показывает
+«Нет задач». Прошлый спринт в списке — строка «имя · закрыт ДД.ММ · выполнено X из N · S SP»; раскрытие показывает его
+задачи с пометкой «не выполнено» у невыполненных.
+
+**«Спринт»** (`/app/jira/sprint`): активный спринт доски — заголовок (имя, даты, «осталось N дн.» или «срок прошёл»,
+цель, суммы оценок) и колонки доски Jira с числом задач, карточки в колонке — по рангу; колонка «Без колонки» — для
+статусов вне настроек доски, только если в ней есть задачи. Несколько активных спринтов — переключатель спринтов над
+доской (выбор в адресе `?sprint=`). Активного нет — «Нет активного спринта» и ссылка на «Бэклог».
+
+**«Kanban»** (`/app/jira/kanban`): колонки доски из `board_issues`, карточки по рангу, у последней колонки подпись
+«за 14 дней»; если `kanban_backlog = true` — над колонками свёрнутый список «Бэклог (N)» по рангу.
+
+Колонки, не помещающиеся по ширине, SHALL прокручиваться по горизонтали внутри доски, не страницы. Заголовок вкладки —
+«Бэклог», «Спринт», «Kanban» по общему правилу оболочки.
+
+#### Scenario: Меню scrum-проекта
+- **WHEN** текущий проект `AWPCA`, `board_type = scrum`
+- **THEN** в группе «Jira» пункты «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; пункта «Kanban» нет
+
+#### Scenario: Проект без доски
+- **WHEN** текущий проект `STOCKS_DRRK` без доски, открыт `/app/jira/backlog?project=STOCKS_DRRK`
+- **THEN** пунктов «Бэклог», «Спринт», «Kanban» в меню нет; страница — «У проекта нет scrum-доски» со ссылкой на «Связи»
+
+#### Scenario: Бэклог только смотрит
+- **WHEN** открыт «Бэклог» `AWPCA`
+- **THEN** на странице нет перетаскиваемых элементов и кнопок, кроме раскрытия секций и открытия карточки задачи
+
+#### Scenario: Прошлый спринт
+- **WHEN** спринт `AWPCA_Sprint_2_VGO` закрыт, в нём 73 задачи, 61 в последней колонке, оценки у 10 задач на 25 SP
+- **THEN** в «Прошлые спринты» строка «AWPCA_Sprint_2_VGO · закрыт ДД.ММ · выполнено 61 из 73 · 25 SP»; раскрытие —
+ 73 задачи, 12 с пометкой «не выполнено»
+
+#### Scenario: Агент без досок
+- **WHEN** `AWPCA` scrum, агент v14 (данных доски нет), открыт «Спринт»
+- **THEN** «Нужен агент v15 и обновление из Jira» и кнопка «Обновить»
diff --git a/src/sdlc_hub/core/db.py b/src/sdlc_hub/core/db.py
index 45a88fc..b4b34b1 100644
--- a/src/sdlc_hub/core/db.py
+++ b/src/sdlc_hub/core/db.py
@@ -8,7 +8,7 @@ from pathlib import Path
from . import backup, paths
-SCHEMA_VERSION = 8
+SCHEMA_VERSION = 9
CORE_SCHEMA = """
CREATE TABLE IF NOT EXISTS setting (
@@ -116,11 +116,11 @@ def schema() -> str:
return CORE_SCHEMA + "".join(m.schema for m in registry.modules())
-def _add_column(table: str, column: str):
+def _add_column(table: str, column: str, decl: str = "TEXT"):
"""Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из схемы уже с новой колонкой."""
def step(conn) -> None:
if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}:
- conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT")
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
return step
@@ -136,6 +136,7 @@ MIGRATIONS = {
6: [_tokens_v7, _agent_status_v7,
_project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
7: [_note_fields_v8], # v7 → v8: без статуса по факту и его настроек (fact-status-removal)
+ 8: [_add_column("issue", "rank"), _add_column("issue", "story_points", "REAL")], # v8 → v9: ранг и оценка задачи; таблицы досок и спринтов создаются схемой (sprints-backlog)
}
diff --git a/src/sdlc_hub/core/modules.py b/src/sdlc_hub/core/modules.py
index e26f1c7..6bef970 100644
--- a/src/sdlc_hub/core/modules.py
+++ b/src/sdlc_hub/core/modules.py
@@ -15,7 +15,7 @@ class CommandHooks:
read_types: frozenset[str] # не пишут во внешнюю систему: без хвоста и копии базы
validate: Callable[[list], list[dict]] # CommandError
check_batch: Callable[[Connection, list[dict], str | None, Path | None], list[dict]] # project, files_dir
- tail: Callable[[list[dict]], dict | None] # хвостовая команда пишущего пакета
+ tail: Callable[[Connection, list[dict], str | None], dict | None] # (conn, cmds, проект пакета): хвостовая команда пишущего пакета
pause: Callable[[Connection, str], int] # пауза типа, с
deliver_params: Callable[[Connection, dict, Path | None], dict]
temp_definer: tuple[str, str, str] | None # (тип, поле params с временным ключом, поле result с ключом)
diff --git a/src/sdlc_hub/core/queue.py b/src/sdlc_hub/core/queue.py
index f3b7f24..44fe504 100644
--- a/src/sdlc_hub/core/queue.py
+++ b/src/sdlc_hub/core/queue.py
@@ -76,7 +76,7 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic
h = _batch_hooks(cmds)
cmds = h.validate(cmds)
cmds = h.check_batch(conn, cmds, project, files_dir)
- tail = h.tail(cmds)
+ tail = h.tail(conn, cmds, project)
if tail is not None:
cmds.append(tail)
return insert_batch(conn, title, author, cmds, project)
diff --git a/src/sdlc_hub/modules/jira/agile.py b/src/sdlc_hub/modules/jira/agile.py
new file mode 100644
index 0000000..6beb44a
--- /dev/null
+++ b/src/sdlc_hub/modules/jira/agile.py
@@ -0,0 +1,105 @@
+"""Чтение для GET /agile: доска проекта, спринты со своими задачами и суммами оценок, бэклог. Только чтение зеркала;
+время Jira сравнивается в UTC через history.jira_utc (смещения ±HHMM и ±HH:MM, Z)."""
+import json
+from datetime import datetime, timedelta, timezone
+
+from . import history
+
+KANBAN_DONE_DAYS = 14
+
+_FIELDS = ("key", "summary", "issue_type", "status", "priority", "assignee", "epic_key", "story_points", "rank")
+
+
+def _rank_key(row: dict):
+ return (row["rank"] is None, row["rank"] or "", row["key"])
+
+
+def _sprint_order(sprints: list[dict]) -> list[dict]:
+ """active — по началу (без даты в конце), future — по id, closed — по закрытию по убыванию (без даты в конце), при равенстве — id."""
+ far = "9999-12-31T23:59:59+00:00"
+
+ def ts(value):
+ return history.jira_utc(value)
+
+ active = sorted((s for s in sprints if s["state"] == "active"), key=lambda s: (ts(s["start_date"]) or far, s["id"]))
+ future = sorted((s for s in sprints if s["state"] == "future"), key=lambda s: s["id"])
+ closed = [s for s in sprints if s["state"] == "closed"]
+ dated = sorted((s for s in closed if ts(s["complete_date"])),
+ key=lambda s: (-datetime.fromisoformat(ts(s["complete_date"])).timestamp(), s["id"]))
+ undated = sorted((s for s in closed if not ts(s["complete_date"])), key=lambda s: s["id"])
+ other = [s for s in sprints if s["state"] not in ("active", "future", "closed")]
+ return active + future + dated + undated + sorted(other, key=lambda s: s["id"])
+
+
+def view(conn, label: str, now: datetime | None = None) -> dict:
+ now = now or datetime.now(timezone.utc)
+ own = not conn.in_transaction
+ if own:
+ conn.execute("BEGIN")
+ try:
+ return _view(conn, label, now)
+ finally:
+ if own:
+ conn.execute("COMMIT")
+
+
+def _view(conn, label: str, now: datetime) -> dict:
+ proj = conn.execute("SELECT board_id, board_type FROM jira_project WHERE label = ?", (label,)).fetchone()
+ if proj is None or proj["board_id"] is None:
+ return {"board": None, "sprints": [], "backlog": None, "board_issues": None}
+ board_id, btype = proj["board_id"], proj["board_type"]
+ brow = conn.execute("SELECT * FROM jira_board WHERE board_id = ?", (board_id,)).fetchone()
+ columns = json.loads(brow["columns"]) if brow and brow["columns"] else None
+ kb = brow["kanban_backlog"] if brow else None
+ board = {"id": board_id, "type": btype, "columns": columns,
+ "kanban_backlog": None if kb is None else bool(kb), "fetched_at": brow["fetched_at"] if brow else None}
+ if btype is None:
+ return {"board": board, "sprints": [], "backlog": None, "board_issues": None}
+
+ done_statuses = set(columns[-1]["statuses"]) if columns else set()
+ rows = []
+ for r in conn.execute(
+ "SELECT i.key, i.summary, i.issue_type, i.status, i.priority, i.assignee, i.epic_key, i.story_points, i.rank, "
+ "i.jira_updated FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)", (label,)):
+ d = dict(r)
+ d["done"] = d["status"] in done_statuses
+ rows.append(d)
+ rows.sort(key=_rank_key)
+
+ def out(r):
+ return {k: r[k] for k in _FIELDS} | {"done": r["done"]}
+
+ if btype == "kanban":
+ first = set(columns[0]["statuses"]) if columns else set()
+ cutoff = now - timedelta(days=KANBAN_DONE_DAYS)
+ backlog, board_issues = ([] if kb == 1 else None), []
+ for r in rows:
+ if kb == 1 and r["status"] in first:
+ backlog.append(out(r))
+ continue
+ if r["done"]:
+ u = history.jira_utc(r["jira_updated"])
+ if u is None or datetime.fromisoformat(u) < cutoff:
+ continue
+ board_issues.append(out(r))
+ return {"board": board, "sprints": [], "backlog": backlog, "board_issues": board_issues}
+
+ members: dict[int, list[dict]] = {}
+ for r in conn.execute("SELECT key, sprint_id FROM issue_sprint"):
+ members.setdefault(r["sprint_id"], []).append(r["key"])
+ by_key = {r["key"]: r for r in rows}
+ sprints = []
+ for s in conn.execute("SELECT * FROM jira_sprint WHERE board_id = ?", (board_id,)):
+ issues = [out(by_key[k]) for k in members.get(s["sprint_id"], []) if k in by_key]
+ issues.sort(key=_rank_key)
+ done = [i for i in issues if i["done"]]
+ pts = lambda xs: sum(i["story_points"] for i in xs if i["story_points"] is not None) # noqa: E731
+ sprints.append({"id": s["sprint_id"], "name": s["name"], "state": s["state"], "start_date": s["start_date"],
+ "end_date": s["end_date"], "complete_date": s["complete_date"], "goal": s["goal"],
+ "count": len(issues), "count_done": len(done),
+ "unestimated": sum(1 for i in issues if i["story_points"] is None),
+ "points": pts(issues), "points_done": pts(done), "issues": issues})
+ busy = {r["key"] for r in conn.execute(
+ "SELECT s.key FROM issue_sprint s JOIN jira_sprint p ON p.sprint_id = s.sprint_id WHERE p.state IN ('active', 'future')")}
+ backlog = [out(r) for r in rows if r["key"] not in busy and not r["done"]]
+ return {"board": board, "sprints": _sprint_order(sprints), "backlog": backlog, "board_issues": None}
diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py
index c001c6d..7e372c5 100644
--- a/src/sdlc_hub/modules/jira/api.py
+++ b/src/sdlc_hub/modules/jira/api.py
@@ -9,7 +9,7 @@ from urllib.parse import quote
from ...core import db, files, queue, settings
from ...core.errors import CommandError, HttpMapped
from ...core.http import ApiError, Raw, int_param, mapped, not_found, project_param
-from . import attachments, fetch, history, mirror, refresh
+from . import agile, attachments, fetch, history, mirror, refresh
from . import projects as jira_projects
log = logging.getLogger(__name__)
@@ -22,14 +22,17 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def h_agent_state(hub, conn, m, q, body):
issues = body.get("issues") or []
+ boards = body.get("boards")
try:
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
mirror.check_attachments(issues)
+ mirror.check_agile_fields(issues)
+ mirror.check_boards(boards)
except ValueError as e:
raise ApiError(422, "bad_state", str(e))
with db.transaction(conn):
out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
- authoritative=refresh.is_full_refresh(conn, body.get("command_id")))
+ authoritative=refresh.is_full_refresh(conn, body.get("command_id")), boards=boards)
if body.get("command_id"):
refresh.record_state(conn, body["command_id"], out["issues"], out["links"])
return 200, out
@@ -95,6 +98,13 @@ def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q))
+def h_agile(hub, conn, m, q, body):
+ label = project_param(conn, q)
+ if label is None:
+ raise ApiError(422, "bad_request", "project — метка проекта")
+ return 200, agile.view(conn, label)
+
+
def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"}
try:
@@ -177,6 +187,7 @@ ROUTES = (
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
+ ("GET", r"^/agile$", h_agile, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py
index eac51d6..a5317f6 100644
--- a/src/sdlc_hub/modules/jira/commands.py
+++ b/src/sdlc_hub/modules/jira/commands.py
@@ -1,7 +1,9 @@
"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
Здесь валидация, работа с ключами (в том числе временными new:), оценка длительности и
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
+import math
import re
+from datetime import date
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
from . import mirror
@@ -20,9 +22,10 @@ SPEC = {
"epic_key", "assignee"],
"keys": ["temp_key", "epic_key"],
"hints": {"temp_key": "new:", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}},
- "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"],
+ "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove", "story_points"],
"keys": ["key", "epic_key"],
- "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"},
+ "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список",
+ "story_points": "число ≥ 0 или null"},
"note": "хотя бы одно поле"},
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"],
"hints": {"marker": "метка в начале комментария"}},
@@ -39,10 +42,22 @@ SPEC = {
"hints": {"attachment_id": "id вложения из issue (цифры)"}},
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"},
"note": "keys или jql"},
+ "ensure_sprint_meta": {"required": ["sprint"], "optional": ["rename_to", "start_date", "end_date", "goal"], "keys": [],
+ "hints": {"sprint": "имя спринта", "rename_to": "новое имя", "start_date": "YYYY-MM-DD",
+ "end_date": "YYYY-MM-DD", "goal": "цель спринта"}},
+ "ensure_sprint_state": {"required": ["sprint", "state"], "optional": ["move_open_to"], "keys": [],
+ "hints": {"state": "active|closed",
+ "move_open_to": "имя спринта или backlog; обязателен при closed"}},
+ "ensure_rank": {"required": ["key"], "optional": ["before", "after"], "keys": ["key", "before", "after"],
+ "hints": {"before": "ключ задачи, выше которой", "after": "ключ задачи, ниже которой"},
+ "note": "ровно одно из before, after"},
}
WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")]
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"}
+NUMBER_PARAMS = {"story_points"}
+SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch)
+DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
def is_temp(key) -> bool:
@@ -66,6 +81,13 @@ def temp_keys_used(cmd) -> list[str]:
return [k for k in referenced_keys(cmd) if is_temp(k) and k != own]
+def _finite(value) -> bool:
+ try:
+ return math.isfinite(value)
+ except OverflowError: # целое 10**400 не приводится к float
+ return False
+
+
def _type_error(ctype, name, value) -> str | None:
"""Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500.
null допустим везде — обязательность проверяется отдельно."""
@@ -75,11 +97,62 @@ def _type_error(ctype, name, value) -> str | None:
return None if _is_str_list(value) else f"{name} — список строк"
if name in BOOL_PARAMS:
return None if isinstance(value, bool) else f"{name} — true или false"
+ if name in NUMBER_PARAMS: # конечное число ≥ 0; bool, NaN и Infinity — не число (R-15)
+ ok = isinstance(value, (int, float)) and not isinstance(value, bool) and _finite(value) and value >= 0
+ return None if ok else f"{name} — число ≥ 0 или null"
if ctype == "ensure_status" and name == "target":
return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк"
return None if isinstance(value, str) else f"{name} — строка"
+def _date(value) -> date | None:
+ """Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None."""
+ if not isinstance(value, str) or not DATE_RE.match(value):
+ return None
+ try:
+ return date.fromisoformat(value)
+ except ValueError:
+ return None
+
+
+def _rule_error(ctype, p) -> str | None:
+ """Смысловые проверки постановки (R-15): типы уже проверены в _type_error."""
+ if ctype == "ensure_sprint_meta":
+ if not p["sprint"].strip():
+ return "sprint — непустое имя"
+ if p.get("rename_to") is not None and not p["rename_to"].strip():
+ return "rename_to — непустое имя"
+ for name in ("start_date", "end_date"):
+ if p.get(name) is not None and _date(p[name]) is None:
+ return f"{name} — дата ГГГГ-ММ-ДД"
+ start, end = _date(p.get("start_date")), _date(p.get("end_date"))
+ if start and end and start > end:
+ return "start_date позже end_date"
+ if ctype == "ensure_sprint_state":
+ if not p["sprint"].strip():
+ return "sprint — непустое имя"
+ if p["state"] not in ("active", "closed"):
+ return "state — active или closed"
+ move = (p.get("move_open_to") or "").strip()
+ if p["state"] == "closed":
+ if not move:
+ return "move_open_to обязателен при state = closed"
+ if move == p["sprint"].strip():
+ return "move_open_to — другой спринт"
+ elif move:
+ return "move_open_to — только при state = closed"
+ if ctype == "ensure_rank":
+ before = (p.get("before") or "").strip() or None # пустая и пробельная строка — «не задано» (R-31)
+ after = (p.get("after") or "").strip() or None
+ if bool(before) == bool(after):
+ return "ровно одно из before, after"
+ if before == p["key"].strip():
+ return "before — другая задача"
+ if after == p["key"].strip():
+ return "after — другая задача"
+ return None
+
+
def _is_str_list(value) -> bool:
return isinstance(value, list) and all(isinstance(v, str) for v in value)
@@ -132,6 +205,9 @@ def validate(commands) -> list[dict]:
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
if bad:
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
+ rule = _rule_error(ctype, params)
+ if rule:
+ raise CommandError(f"команда {i} ({ctype}): {rule}")
if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]):
raise CommandError(f"команда {i}: ensure_fields без полей")
if ctype == "refresh" and not (params.get("keys") or params.get("jql")):
@@ -183,14 +259,18 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не
подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля
(bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в
- зеркале нет (bug-and-attachments)."""
+ зеркале нет (bug-and-attachments); команды спринта и ранга в зеркале не видны (design #18).
+ story_points сравнивается точно: null равен только null, 0 не равен null (R-16)."""
ctype, p = cmd["type"], cmd["params"]
- if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh"):
+ if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh", "ensure_rank",
+ *SPRINT_TYPES):
return False, None
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
if issue is None:
return False, None
if ctype == "ensure_fields":
+ if "story_points" in p and issue.get("story_points") != p["story_points"]:
+ return False, None
for field in FIELD_PARAMS:
if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
return False, None
diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py
index 46bf060..6fd0af1 100644
--- a/src/sdlc_hub/modules/jira/hooks.py
+++ b/src/sdlc_hub/modules/jira/hooks.py
@@ -67,15 +67,41 @@ def _assign_boards(conn, cmds) -> None:
if len({p["board_id"] for p in own}) > 1:
raise CommandError(f"команда {i}: {key} в проектах {', '.join(p['label'] for p in own)} с разными досками")
p = own[0]
- if p["board_id"] is None:
- raise CommandError(f"команда {i}: у проекта {p['label']} нет доски Jira")
- if p["board_type"] == "kanban":
- raise CommandError(f"команда {i}: у проекта {p['label']} канбан-доска, спринтов нет")
- if p["board_type"] is None:
- raise CommandError(f"команда {i}: тип доски проекта {p['label']} ещё не известен — дождитесь обновления из Jira")
+ err = _board_error(p["label"], p["board_id"], p["board_type"])
+ if err:
+ raise CommandError(f"команда {i}: {err}")
c["params"] = {**c["params"], "board_id": p["board_id"]}
+def _board_error(label, board_id, board_type) -> str | None:
+ """Общие тексты 422 для ensure_sprint и команд спринта: доски нет, канбан, тип ещё не известен; None — доска годится."""
+ if board_id is None:
+ return f"у проекта {label} нет доски Jira"
+ if board_type == "kanban":
+ return f"у проекта {label} канбан-доска, спринтов нет"
+ if board_type is None:
+ return f"тип доски проекта {label} ещё не известен — дождитесь обновления из Jira"
+ return None
+
+
+def _assign_project_board(conn, cmds, project) -> None:
+ """Доска проекта пакета в params команд спринта (ensure_sprint_meta/state): спринт не про задачу, проект — из пакета.
+ Новые словари params — входные не мутируются. Пакет без проекта, нет доски, канбан, тип не известен — 422."""
+ board = None
+ for i, c in enumerate(cmds, 1):
+ if c["type"] not in commands.SPRINT_TYPES:
+ continue
+ if project is None:
+ raise CommandError(f"команда {i} ({c['type']}): команды спринта — только в пакете проекта")
+ if board is None:
+ found = next((p for p in jira_projects.light_list(conn) if p["label"] == project), None)
+ board = found or {"label": project, "board_id": None, "board_type": None}
+ err = _board_error(board["label"], board["board_id"], board["board_type"])
+ if err:
+ raise CommandError(f"команда {i}: {err}")
+ c["params"] = {**c["params"], "board_id": board["board_id"]}
+
+
def _check_project(conn, cmds, project) -> list[dict]:
"""Проверки пакета проекта (spec commands, «Пакет проекта»): новые словари команд — входные params не мутируются.
Номер команды — как в присланном пакете."""
@@ -104,6 +130,8 @@ def _check_project(conn, cmds, project) -> list[dict]:
elif t == "ensure_link":
if not (own(p["source"]) or own(p["target"])):
raise CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}")
+ elif t in commands.SPRINT_TYPES:
+ pass # спринт — не задача проекта: доску ставит _assign_project_board
elif t not in READ_TYPES: # fetch_attachment — не запись в Jira, проекта не касается
if t == "ensure_fields":
if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}:
@@ -127,11 +155,16 @@ def check_batch(conn, cmds, project, files_dir) -> list[dict]:
if project is not None:
cmds = _check_project(conn, cmds, project)
check_setup(conn, cmds, files_dir)
+ _assign_project_board(conn, cmds, project)
return cmds
-def tail(cmds) -> dict | None:
- """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат."""
+def tail(conn, cmds, project) -> dict | None:
+ """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат. Пакет с командами
+ спринта — refresh по JQL проекта пакета: у них нет ключей, а перенесённые при закрытии задачи заранее
+ неизвестны (design #13)."""
+ if project and any(c["type"] in commands.SPRINT_TYPES for c in cmds):
+ return {"type": "refresh", "params": {"jql": jira_projects.project_jql(conn, project)}}
keys = []
for c in cmds:
keys += [k for k in commands.referenced_keys(c) if k not in keys]
@@ -249,8 +282,9 @@ def check_confirmed(conn, cmd) -> tuple[bool, dict | None]:
def retry_params(cmd) -> dict:
- """Повтор ensure_sprint берёт доску заново (она в params ставится при постановке)."""
- return {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")}
+ """Повтор ensure_sprint и команд спринта берёт доску заново (она в params ставится при постановке)."""
+ drop = cmd["type"] in ("ensure_sprint", *commands.SPRINT_TYPES)
+ return {k: v for k, v in cmd["params"].items() if not (drop and k == "board_id")}
def file_refs(conn) -> set[str]:
diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py
index c07bb17..b4fd06e 100644
--- a/src/sdlc_hub/modules/jira/mcp.py
+++ b/src/sdlc_hub/modules/jira/mcp.py
@@ -26,10 +26,126 @@ fmt_agent = core.fmt_agent
def fmt_issues(issues) -> str:
if not issues:
return "задач нет"
- lines = ["ключ | тема | статус | срок | исполнитель | спринт"]
+ lines = ["ключ | тема | статус | срок | исполнитель | спринт | SP"]
for i in issues:
lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")),
- core.value(i.get("assignee")), core.value(i.get("sprint"))]))
+ core.value(i.get("assignee")), core.value(i.get("sprint")), fmt_sp(i.get("story_points"))]))
+ return "\n".join(lines)
+
+
+def fmt_sp(v) -> str:
+ """Оценка в story points: пусто — «—», целое без «.0»."""
+ return "—" if v is None else f"{v:g}"
+
+
+def fmt_day(value) -> str:
+ """Время Jira (±HHMM, ±HH:MM, Z) → «ДД.ММ» в местном поясе; пусто или неразбираемо — «—»."""
+ if not isinstance(value, str) or not value:
+ return "—"
+ text = value.strip()
+ if text.endswith(("Z", "z")):
+ text = text[:-1] + "+00:00"
+ if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit():
+ text = text[:-2] + ":" + text[-2:]
+ try:
+ at = datetime.fromisoformat(text)
+ except ValueError:
+ return "—"
+ if at.tzinfo is not None:
+ at = at.astimezone()
+ return at.strftime("%d.%m")
+
+
+NO_BOARD = "у проекта нет доски"
+NO_DATA = "данных доски нет — нужен агент v15 и обновление из Jira"
+KANBAN = "у проекта канбан-доска, спринтов нет"
+SPRINT_STATES = {"active": "активный", "future": "будущий", "closed": "закрыт"}
+CLOSED_SHOWN = 10
+
+
+def _agile_refusal(data: dict, sprints: bool) -> str | None:
+ board = data.get("board")
+ if board is None:
+ return NO_BOARD
+ if board.get("fetched_at") is None:
+ return NO_DATA
+ if sprints and board.get("type") == "kanban":
+ return KANBAN
+ return None
+
+
+def _sprint_dates(s: dict) -> str:
+ return f"{fmt_day(s.get('start_date'))}–{fmt_day(s.get('end_date'))}"
+
+
+def fmt_sprints(data: dict) -> str:
+ refusal = _agile_refusal(data, True)
+ if refusal:
+ return refusal
+ items = data.get("sprints") or []
+ if not items:
+ return "спринтов нет"
+ lines = ["спринт | состояние | даты | задачи | SP | без оценки"]
+ closed = 0
+ for s in items:
+ if s["state"] == "closed":
+ closed += 1
+ if closed > CLOSED_SHOWN:
+ continue
+ lines.append(f"{s['name']} | {SPRINT_STATES.get(s['state'], s['state'])} | {_sprint_dates(s)} | {s['count_done']}/{s['count']} | "
+ f"{fmt_sp(s['points_done'])}/{fmt_sp(s['points'])} | {s['unestimated']}")
+ if closed > CLOSED_SHOWN:
+ lines.append(f"ещё {closed - CLOSED_SHOWN} закрытых")
+ return "\n".join(lines)
+
+
+def fmt_sprint(data: dict, name: str) -> str:
+ refusal = _agile_refusal(data, True)
+ if refusal:
+ return refusal
+ items = data.get("sprints") or []
+ if name:
+ s = next((x for x in items if x["name"] == name), None)
+ if s is None:
+ return f"спринта «{name}» нет на доске"
+ else:
+ s = next((x for x in items if x["state"] == "active"), None)
+ if s is None:
+ return "активного спринта нет"
+ lines = [f"{s['name']} — {SPRINT_STATES.get(s['state'], s['state'])}; {_sprint_dates(s)}; цель: {core.value(s.get('goal'))}",
+ f"задачи {s['count_done']}/{s['count']}; SP {fmt_sp(s['points_done'])}/{fmt_sp(s['points'])}; без оценки: {s['unestimated']}"]
+
+ def row(i):
+ return f" {i['key']} {core.cut(i.get('summary'), 80)} — {core.value(i.get('assignee'))}, SP {fmt_sp(i.get('story_points'))}"
+ rest = list(s.get("issues") or [])
+ for col in data["board"].get("columns") or []:
+ names = set(col.get("statuses") or [])
+ mine = [i for i in rest if i.get("status") in names]
+ rest = [i for i in rest if i.get("status") not in names]
+ lines.append(f"{col['name']} ({len(mine)}):")
+ lines.extend(row(i) for i in mine)
+ if rest:
+ lines.append(f"Без колонки ({len(rest)}):")
+ lines.extend(row(i) for i in rest)
+ return "\n".join(lines)
+
+
+def fmt_backlog(data: dict, limit: int) -> str:
+ refusal = _agile_refusal(data, False)
+ if refusal:
+ return refusal
+ rows = data.get("backlog")
+ if rows is None:
+ return "у доски нет бэклога"
+ if not rows:
+ return "бэклог пуст"
+ shown = rows[:max(1, limit)]
+ lines = ["# | ключ | тема | статус | исполнитель | SP"]
+ for n, i in enumerate(shown, 1):
+ lines.append(" | ".join([str(n), i["key"], core.cut(i.get("summary")), core.value(i.get("status")),
+ core.value(i.get("assignee")), fmt_sp(i.get("story_points"))]))
+ if len(rows) > len(shown):
+ lines.append(f"ещё {len(rows) - len(shown)}")
return "\n".join(lines)
@@ -381,7 +497,7 @@ def _doc(text: str):
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]):
""" + describe() + """
-Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.
+Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
def batch_create(title: str, commands: list[dict]) -> str:
@@ -393,6 +509,30 @@ def batch_create(title: str, commands: list[dict]) -> str:
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
+@mcp.tool()
+@core.guard
+@core.scoped(True)
+def sprints() -> str:
+ """Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних"""
+ return fmt_sprints(core.client().get("/agile", project=core.PROJECT))
+
+
+@mcp.tool()
+@core.guard
+@core.scoped(True)
+def sprint(name: str = "") -> str:
+ """Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски"""
+ return fmt_sprint(core.client().get("/agile", project=core.PROJECT), name)
+
+
+@mcp.tool()
+@core.guard
+@core.scoped(True)
+def backlog(limit: int = 50) -> str:
+ """Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)"""
+ return fmt_backlog(core.client().get("/agile", project=core.PROJECT), limit)
+
+
@mcp.tool()
@core.guard
@core.scoped(False)
diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py
index be3cd08..90da1a3 100644
--- a/src/sdlc_hub/modules/jira/mirror.py
+++ b/src/sdlc_hub/modules/jira/mirror.py
@@ -1,13 +1,15 @@
"""Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
+import math
-from . import attachments, history
+from . import attachments, history, projects
from ...core import db
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"]
+SPRINT_STATES = ("active", "future", "closed")
def check_comments(issues) -> None:
"""Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
@@ -53,16 +55,102 @@ def check_attachments(issues) -> None:
raise ValueError(f"{where}.{f} — строка или null")
+_INT_MAX = 2**63 - 1 # диапазон SQLite INTEGER
+
+
+def _in_range(v) -> bool:
+ return -2**63 <= v <= _INT_MAX
+
+
+def _finite(v) -> bool:
+ """Конечное число; огромные целые (10**400) не падают с OverflowError, а считаются неподходящими."""
+ try:
+ return math.isfinite(v)
+ except OverflowError:
+ return False
+
+
+def check_agile_fields(issues) -> None:
+ """Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное
+ не проверяется). Нарушение — ValueError с ключом и полем."""
+ for it in issues:
+ key = it.get("key")
+ if "rank" in it and it["rank"] is not None and not isinstance(it["rank"], str):
+ raise ValueError(f"{key}: rank — строка или null")
+ if "story_points" in it and it["story_points"] is not None:
+ sp = it["story_points"]
+ if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not _finite(sp) or (isinstance(sp, int) and not _in_range(sp)):
+ raise ValueError(f"{key}: story_points — число или null")
+ if "sprint_ids" in it:
+ ids = it["sprint_ids"]
+ if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) or not _in_range(i) for i in ids):
+ raise ValueError(f"{key}: sprint_ids — список целых")
+
+
+def _is_id(v) -> bool:
+ return isinstance(v, int) and not isinstance(v, bool) and 0 < v <= _INT_MAX
+
+
+def check_boards(boards) -> None:
+ """Форма списка boards состояния (агент v15+); None — доски не присланы. Нарушение — ValueError с доской и полем."""
+ if boards is None:
+ return
+ if not isinstance(boards, list):
+ raise ValueError("boards — список досок")
+ for n, b in enumerate(boards):
+ if not isinstance(b, dict):
+ raise ValueError(f"boards[{n}] — объект доски")
+ if not _is_id(b.get("id")):
+ raise ValueError(f"boards[{n}].id — целое > 0")
+ where = f"доска {b['id']}: "
+ if b.get("type") is not None and b["type"] not in projects.BOARD_TYPES:
+ raise ValueError(where + "type — scrum, kanban или null")
+ cols = b.get("columns")
+ if cols is not None:
+ if not isinstance(cols, list):
+ raise ValueError(where + "columns — список колонок или null")
+ for k, c in enumerate(cols):
+ if not isinstance(c, dict) or not isinstance(c.get("name"), str):
+ raise ValueError(where + f"columns[{k}].name — строка")
+ st = c.get("statuses")
+ if not isinstance(st, list) or any(not isinstance(s, str) for s in st):
+ raise ValueError(where + f"columns[{k}].statuses — список строк")
+ if b.get("kanban_backlog") is not None and not isinstance(b["kanban_backlog"], bool):
+ raise ValueError(where + "kanban_backlog — true, false или null")
+ sprints = b.get("sprints")
+ if sprints is None:
+ continue
+ if not isinstance(sprints, list):
+ raise ValueError(where + "sprints — список спринтов или null")
+ for k, s in enumerate(sprints):
+ w = where + f"sprints[{k}]"
+ if not isinstance(s, dict):
+ raise ValueError(f"{w} — объект спринта")
+ if not _is_id(s.get("id")):
+ raise ValueError(f"{w}.id — целое > 0")
+ if not isinstance(s.get("name"), str) or not s["name"]:
+ raise ValueError(f"{w}.name — непустая строка")
+ if s.get("state") not in SPRINT_STATES:
+ raise ValueError(f"{w}.state — active, future или closed")
+ for f in ("start_date", "end_date", "complete_date", "goal"):
+ if s.get(f) is not None and not isinstance(s[f], str):
+ raise ValueError(f"{w}.{f} — строка или null")
+
+
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
"work_kind": "n.work_kind = ?"}
-def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict:
+def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True, boards=None) -> dict:
"""Upsert задач; связи с source_key среди присланных задач заменяются присланными.
Связи, у которых источник не прислан, не трогаются до загрузки их источника.
authoritative=True — загрузка по базовой JQL: флаг neighbor пересчитывается по neighbor_keys (задача, вошедшая
в базовую выборку, перестаёт быть соседней). authoritative=False — точечное обновление (по ключам, по
- произвольной JQL): флаг только поднимается, никогда не снижается."""
+ произвольной JQL): флаг только поднимается, никогда не снижается.
+ Поля v15 (агент v15+): у задачи rank, story_points — пишутся, только если ключ есть в словаре задачи (null очищает);
+ sprint_ids — список заменяет спринты задачи в issue_sprint. boards — список досок: доска заменяет свою строку
+ jira_board и, если sprints — список, свои спринты (sprints null/нет — спринты не трогаются); тип доски
+ записывается проектам с этой доской. Без поля или без boards прежние данные не меняются (агент v14)."""
fetched_at = fetched_at or db.now()
neighbors = set(neighbor_keys)
keys = []
@@ -72,6 +160,9 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
row["labels"] = json.dumps(row.get("labels") or [], ensure_ascii=False)
row["fetched_at"] = fetched_at
row["neighbor"] = 1 if it["key"] in neighbors else 0
+ for f in ("rank", "story_points"):
+ if f in it:
+ row[f] = it[f]
cols = list(row)
sets = [f"{c} = excluded.{c}" for c in cols if c not in ("key", "neighbor")]
sets.append("neighbor = excluded.neighbor" if authoritative else "neighbor = MAX(issue.neighbor, excluded.neighbor)")
@@ -80,6 +171,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets),
[row[c] for c in cols])
keys.append(it["key"])
+ if isinstance(it.get("sprint_ids"), list): # без поля (агент до v15) спринты задачи не трогаем
+ conn.execute("DELETE FROM issue_sprint WHERE key = ?", (it["key"],))
+ conn.executemany("INSERT OR IGNORE INTO issue_sprint(key, sprint_id) VALUES (?, ?)",
+ [(it["key"], sid) for sid in it["sprint_ids"]])
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
conn.executemany(
@@ -98,6 +193,21 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
(link["source_key"], link["target_key"], link["link_type"]))
+ for b in boards or []:
+ conn.execute(
+ "INSERT OR REPLACE INTO jira_board(board_id, type, columns, kanban_backlog, fetched_at) VALUES (?, ?, ?, ?, ?)",
+ (b["id"], b.get("type"),
+ json.dumps(b["columns"], ensure_ascii=False) if b.get("columns") is not None else None,
+ None if b.get("kanban_backlog") is None else int(b["kanban_backlog"]), fetched_at))
+ if isinstance(b.get("sprints"), list): # null/нет — спринты доски не меняются (R-14)
+ conn.execute("DELETE FROM jira_sprint WHERE board_id = ?", (b["id"],))
+ conn.executemany(
+ "INSERT OR REPLACE INTO jira_sprint(sprint_id, board_id, name, state, start_date, end_date, complete_date, goal) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ [(s["id"], b["id"], s["name"], s["state"], s.get("start_date"), s.get("end_date"),
+ s.get("complete_date"), s.get("goal")) for s in b["sprints"]])
+ if b.get("type") in projects.BOARD_TYPES:
+ projects.record_board_types(conn, {str(b["id"]): b["type"]})
return {"issues": len(keys), "links": len(links)}
diff --git a/src/sdlc_hub/modules/jira/projects.py b/src/sdlc_hub/modules/jira/projects.py
index 06a5241..a90f458 100644
--- a/src/sdlc_hub/modules/jira/projects.py
+++ b/src/sdlc_hub/modules/jira/projects.py
@@ -24,6 +24,12 @@ def base_jql(conn) -> str | None:
return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})"
+def project_jql(conn, label: str) -> str:
+ """JQL одного проекта-метки — в формате base_jql (тот же project_key и кавычки)."""
+ key = settings.get(conn, "project_key") or "AURORA"
+ return f'project = {key} AND labels in ("{label}")'
+
+
def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
"""Проект с доской: проект ядра и строка jira_project (прямой путь без POST /projects: init, миграция, тесты).
Неверная доска — ProjectError("board_id", …) до записи."""
diff --git a/src/sdlc_hub/modules/jira/schema.py b/src/sdlc_hub/modules/jira/schema.py
index e728f11..44c4fc6 100644
--- a/src/sdlc_hub/modules/jira/schema.py
+++ b/src/sdlc_hub/modules/jira/schema.py
@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS issue (
epic_key TEXT,
labels TEXT NOT NULL DEFAULT '[]',
sprint TEXT,
+ rank TEXT,
+ story_points REAL,
created TEXT,
jira_updated TEXT,
fetched_at TEXT NOT NULL,
@@ -102,4 +104,28 @@ CREATE TABLE IF NOT EXISTS jira_project (
board_id INTEGER,
board_type TEXT
);
+CREATE TABLE IF NOT EXISTS jira_board (
+ board_id INTEGER PRIMARY KEY,
+ type TEXT,
+ columns TEXT,
+ kanban_backlog INTEGER,
+ fetched_at TEXT NOT NULL
+);
+CREATE TABLE IF NOT EXISTS jira_sprint (
+ sprint_id INTEGER PRIMARY KEY,
+ board_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ state TEXT NOT NULL,
+ start_date TEXT,
+ end_date TEXT,
+ complete_date TEXT,
+ goal TEXT
+);
+CREATE INDEX IF NOT EXISTS ix_jira_sprint_board ON jira_sprint(board_id);
+CREATE TABLE IF NOT EXISTS issue_sprint (
+ key TEXT NOT NULL,
+ sprint_id INTEGER NOT NULL,
+ PRIMARY KEY (key, sprint_id)
+);
+CREATE INDEX IF NOT EXISTS ix_issue_sprint_sprint ON issue_sprint(sprint_id);
"""
diff --git a/src/sdlc_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py
index 6f110e9..127390c 100644
--- a/src/sdlc_hub/modules/jira/settings.py
+++ b/src/sdlc_hub/modules/jira/settings.py
@@ -3,7 +3,8 @@ from ...core import settings as core_settings
from . import projects
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
- "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2}
+ "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2,
+ "ensure_sprint_meta": 30, "ensure_sprint_state": 30, "ensure_rank": 30}
DEFAULTS = {
"min_agent_version": 1,
@@ -75,7 +76,8 @@ def pacing_from_config(p: dict) -> dict:
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
- "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2}
+ "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2,
+ "ensure_sprint_meta": comment, "ensure_sprint_state": comment, "ensure_rank": comment}
def _issue_types(cfg: dict) -> dict:
diff --git a/src/sdlc_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py
index c105594..6084c97 100644
--- a/src/sdlc_hub/modules/wiki/commands.py
+++ b/src/sdlc_hub/modules/wiki/commands.py
@@ -94,7 +94,7 @@ def after_result(conn, cmd, status) -> None:
# wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py
HOOKS = CommandHooks(
types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch,
- tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"],
+ tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"],
temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params,
drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"],
check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed,
diff --git a/tests/conftest.py b/tests/conftest.py
index d3ac10d..f7f8dc7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -56,7 +56,7 @@ def _stub_hooks() -> CommandHooks:
noop = lambda *a, **k: None # noqa: E731
return CommandHooks(
types=frozenset({"stub_cmd"}), read_types=frozenset({"stub_cmd"}), validate=lambda cmds: cmds,
- check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda cmds: None, pause=lambda conn, t: 0,
+ check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda conn, cmds, project: None, pause=lambda conn, t: 0,
deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda c: [],
substitute=lambda t, p, mapping: p, drop_unresolved=lambda c, u: u, retry_params=lambda c: c["params"],
check_confirmed=lambda conn, c: (True, None), on_claim=noop, on_failed=noop,
diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json
index dd6551e..d70e36d 100644
--- a/tests/fixtures/baseline/mcp_tools.expected.json
+++ b/tests/fixtures/baseline/mcp_tools.expected.json
@@ -228,7 +228,7 @@
"name": "notes_set"
},
{
- "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.",
+ "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб; ensure_sprint_meta и ensure_sprint_state — только в сессии с проектом, хвост такого пакета — refresh по JQL проекта; оценка — story_points в ensure_fields.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.",
"inputSchema": {
"properties": {
"commands": {
@@ -253,6 +253,45 @@
},
"name": "batch_create"
},
+ {
+ "description": "Спринты доски проекта: состояние, даты, задачи и SP «выполнено/всего», без оценки; закрытые — 10 последних",
+ "inputSchema": {
+ "properties": {},
+ "title": "sprintsArguments",
+ "type": "object"
+ },
+ "name": "sprints"
+ },
+ {
+ "description": "Спринт (по умолчанию активный): шапка с суммами и задачи по колонкам доски",
+ "inputSchema": {
+ "properties": {
+ "name": {
+ "default": "",
+ "title": "Name",
+ "type": "string"
+ }
+ },
+ "title": "sprintArguments",
+ "type": "object"
+ },
+ "name": "sprint"
+ },
+ {
+ "description": "Бэклог проекта по рангу: позиция, ключ, тема, статус, исполнитель, SP; limit — сколько строк (по умолчанию 50)",
+ "inputSchema": {
+ "properties": {
+ "limit": {
+ "default": 50,
+ "title": "Limit",
+ "type": "integer"
+ }
+ },
+ "title": "backlogArguments",
+ "type": "object"
+ },
+ "name": "backlog"
+ },
{
"description": "Состояние пакета и его команд.",
"inputSchema": {
diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json
index bf83729..e2e48ac 100644
--- a/tests/fixtures/baseline/routes.expected.json
+++ b/tests/fixtures/baseline/routes.expected.json
@@ -83,6 +83,12 @@
"client",
"h_agents_get"
],
+ [
+ "GET",
+ "^/agile$",
+ "client",
+ "h_agile"
+ ],
[
"GET",
"^/app(?:/(.*))?$",
diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json
index c7288d8..ca2c5fe 100644
--- a/tests/fixtures/baseline/schema.expected.json
+++ b/tests/fixtures/baseline/schema.expected.json
@@ -25,6 +25,16 @@
"ix_issue_link_target",
"CREATE INDEX ix_issue_link_target ON issue_link(target_key)"
],
+ [
+ "index",
+ "ix_issue_sprint_sprint",
+ "CREATE INDEX ix_issue_sprint_sprint ON issue_sprint(sprint_id)"
+ ],
+ [
+ "index",
+ "ix_jira_sprint_board",
+ "CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)"
+ ],
[
"table",
"agent_status",
@@ -48,7 +58,7 @@
[
"table",
"issue",
- "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)"
+ "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n rank TEXT,\n story_points REAL,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)"
],
[
"table",
@@ -75,11 +85,26 @@
"issue_note",
"CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)"
],
+ [
+ "table",
+ "issue_sprint",
+ "CREATE TABLE issue_sprint (\n key TEXT NOT NULL,\n sprint_id INTEGER NOT NULL,\n PRIMARY KEY (key, sprint_id)\n)"
+ ],
+ [
+ "table",
+ "jira_board",
+ "CREATE TABLE jira_board (\n board_id INTEGER PRIMARY KEY,\n type TEXT,\n columns TEXT,\n kanban_backlog INTEGER,\n fetched_at TEXT NOT NULL\n)"
+ ],
[
"table",
"jira_project",
"CREATE TABLE jira_project (\n label TEXT PRIMARY KEY REFERENCES project(label),\n board_id INTEGER,\n board_type TEXT\n)"
],
+ [
+ "table",
+ "jira_sprint",
+ "CREATE TABLE jira_sprint (\n sprint_id INTEGER PRIMARY KEY,\n board_id INTEGER NOT NULL,\n name TEXT NOT NULL,\n state TEXT NOT NULL,\n start_date TEXT,\n end_date TEXT,\n complete_date TEXT,\n goal TEXT\n)"
+ ],
[
"table",
"project",
@@ -116,5 +141,5 @@
"CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)"
]
],
- "schema_version": 8
+ "schema_version": 9
}
diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json
index 2e3eaa1..88ea33d 100644
--- a/tests/fixtures/baseline/settings.expected.json
+++ b/tests/fixtures/baseline/settings.expected.json
@@ -38,7 +38,10 @@
"ensure_fields": 120,
"ensure_issue": 120,
"ensure_link": 30,
+ "ensure_rank": 30,
"ensure_sprint": 30,
+ "ensure_sprint_meta": 30,
+ "ensure_sprint_state": 30,
"ensure_status": 30,
"fetch_attachment": 2,
"refresh": 2
diff --git a/tests/test_agile.py b/tests/test_agile.py
new file mode 100644
index 0000000..4ac9b4e
--- /dev/null
+++ b/tests/test_agile.py
@@ -0,0 +1,163 @@
+from datetime import datetime, timezone
+
+import pytest
+
+from sdlc_hub.core import settings
+from sdlc_hub.core.client import HubError
+from sdlc_hub.modules.jira import agile, mirror
+
+COLS = [{"name": "К выполнению", "statuses": ["Backlog", "Ожидает анализа"]}, {"name": "В работе", "statuses": ["В работе"]},
+ {"name": "Готово", "statuses": ["Готово", "Протестировано", "Снято"]}]
+
+
+@pytest.fixture
+def conn(open_db, add_project):
+ c = open_db()
+ settings.ensure_defaults(c)
+ add_project(c)
+ return c
+
+
+def issue(key, status, **over):
+ base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "", "status": status,
+ "priority": "Средний", "assignee": None, "reporter": "me", "duedate": None, "planned_start": None,
+ "planned_end": None, "epic_key": None, "labels": ["AWPCA"], "sprint": None,
+ "created": "2026-10-10T12:00:00.000+0300", "jira_updated": "2026-10-11T12:00:00.000+0300"}
+ return base | over
+
+
+def kissue(key, status, **over):
+ return issue(key, status, labels=["K"], **over)
+
+
+def board(id=4216, type="scrum", sprints=(), **over):
+ return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over}
+
+
+def sprint(id, state, name=None, **over):
+ return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None,
+ "complete_date": None, "goal": None, **over}
+
+
+def load(conn, issues, boards):
+ mirror.apply_state(conn, issues, [], boards=boards)
+
+
+def keys(rows):
+ return [r["key"] for r in rows]
+
+
+def test_sprint_and_backlog_sums(conn): # сценарий «Спринт и бэклог»
+ load(conn, [issue("A", "Готово", story_points=3, sprint_ids=[102], rank="0|a"), issue("B", "В работе", sprint_ids=[102], rank="0|b"),
+ issue("C", "Backlog", sprint_ids=[]), issue("D", "Готово", sprint_ids=[])],
+ [board(sprints=[sprint(102, "active")])])
+ v = agile.view(conn, "AWPCA")
+ s = v["sprints"][0]
+ assert (s["count"], s["count_done"], s["points"], s["points_done"], s["unestimated"]) == (2, 1, 3, 3, 1)
+ assert keys(s["issues"]) == ["A", "B"] and keys(v["backlog"]) == ["C"] and v["board_issues"] is None
+
+
+def test_done_is_last_column(conn): # сценарий «Колонки доски»
+ load(conn, [issue("P", "Протестировано", sprint_ids=[102]), issue("Q", "Backlog", sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
+ assert {i["key"]: i["done"] for i in agile.view(conn, "AWPCA")["sprints"][0]["issues"]} == {"P": True, "Q": False}
+
+
+def test_project_without_board(conn, add_project): # сценарий «Проект без доски»
+ add_project(conn, "STOCKS_DRRK", board_id=None, board_type=None)
+ assert agile.view(conn, "STOCKS_DRRK") == {"board": None, "sprints": [], "backlog": None, "board_issues": None}
+
+
+def test_board_without_agent_data(conn): # design #23, состояние 1
+ load(conn, [issue("A", "Готово")], [])
+ v = agile.view(conn, "AWPCA")
+ assert v["board"] == {"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "fetched_at": None}
+ assert [i["done"] for i in v["backlog"]] == [False] # без колонок выполненных нет
+
+
+def test_sprint_order_mixed_offsets(conn): # порядок спринтов; Review Focus 1
+ load(conn, [], [board(sprints=[
+ sprint(3, "active", start_date="2026-10-14T09:00:00.000+0300"), sprint(5, "active", start_date="2026-10-01T09:00:00.000+03:00"),
+ sprint(9, "future"), sprint(7, "future"),
+ sprint(1, "closed", complete_date="2026-10-01T02:00:00.000+0300"), sprint(2, "closed", complete_date="2026-09-30T23:30:00.000Z")])])
+ assert [s["id"] for s in agile.view(conn, "AWPCA")["sprints"]] == [5, 3, 7, 9, 2, 1] # 2 закрыт позже 1 по UTC
+
+
+def test_rank_order_nulls_last_then_key(conn):
+ load(conn, [issue("Z-1", "Backlog", rank="0|i0002:"), issue("Z-2", "Backlog", rank="0|i0001:"),
+ issue("B-1", "Backlog", rank=None), issue("A-1", "Backlog")], [board()])
+ assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["Z-2", "Z-1", "A-1", "B-1"]
+
+
+def test_only_own_issues_and_empty_sprint(conn): # design #8
+ load(conn, [issue("N", "Backlog", labels=["OTHER"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
+ s = agile.view(conn, "AWPCA")["sprints"][0]
+ assert s["issues"] == [] and s["count"] == 0
+
+
+def test_shared_board_own_issues_only(conn, add_project): # Review Focus 3
+ add_project(conn, "C", board_id=4216)
+ load(conn, [issue("A", "Backlog", sprint_ids=[102]), issue("K", "Backlog", labels=["C"], sprint_ids=[102])], [board(sprints=[sprint(102, "active")])])
+ assert keys(agile.view(conn, "C")["sprints"][0]["issues"]) == ["K"] and keys(agile.view(conn, "AWPCA")["sprints"][0]["issues"]) == ["A"]
+
+
+def test_closed_sprint_done_by_current_status(conn): # design #7
+ load(conn, [issue("X", "Готово", sprint_ids=[101])], [board(sprints=[sprint(101, "closed")])])
+ assert agile.view(conn, "AWPCA")["sprints"][0]["count_done"] == 1
+
+
+def test_backlog_keeps_issue_of_closed_sprint(conn):
+ load(conn, [issue("X", "Backlog", sprint_ids=[101]), issue("Y", "Backlog", sprint_ids=[103])],
+ [board(sprints=[sprint(101, "closed"), sprint(103, "future")])])
+ assert keys(agile.view(conn, "AWPCA")["backlog"]) == ["X"]
+
+
+KCOLS = [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]},
+ {"name": "Готово", "statuses": ["Готово"]}]
+NOW = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
+
+
+def test_kanban_with_backlog(conn, add_project): # design #3, «Контракт данных»
+ add_project(conn, "K", board_id=5120, board_type="kanban")
+ now = datetime(2026, 10, 24, 12, tzinfo=timezone.utc)
+ load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе"), kissue("K3", "Готово", jira_updated="2026-10-21T10:00:00.000+0300"),
+ kissue("K4", "Готово", jira_updated="2026-10-01T10:00:00.000+0300"), kissue("K5", "Странный"),
+ kissue("K6", "Готово", jira_updated=None)],
+ [board(id=5120, type="kanban", kanban_backlog=True, columns=[{"name": "Бэклог", "statuses": ["Backlog"]},
+ {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}])])
+ v = agile.view(conn, "K", now=now)
+ assert v["sprints"] == [] and keys(v["backlog"]) == ["K1"] and sorted(keys(v["board_issues"])) == ["K2", "K3", "K5"]
+ assert v["board"]["kanban_backlog"] is True
+
+
+def test_kanban_without_backlog(conn, add_project): # design #17
+ add_project(conn, "K", board_id=5120, board_type="kanban")
+ load(conn, [kissue("K1", "Backlog"), kissue("K2", "В работе")],
+ [board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
+ v = agile.view(conn, "K", now=NOW)
+ assert v["backlog"] is None and sorted(keys(v["board_issues"])) == ["K1", "K2"]
+ assert v["board"]["kanban_backlog"] is False
+
+
+def test_kanban_done_cutoff_with_offsets(conn, add_project): # Review Focus 1: граница 14 дней по UTC
+ add_project(conn, "K", board_id=5120, board_type="kanban")
+ load(conn, [kissue("OLD", "Готово", jira_updated="2026-10-10T14:30:00.000+0300"), # 11:30Z — старше 14 дней
+ kissue("NEW", "Готово", jira_updated="2026-10-10T15:30:00.000+0300")], # 12:30Z — в пределах
+ [board(id=5120, type="kanban", kanban_backlog=False, columns=KCOLS)])
+ assert keys(agile.view(conn, "K", now=NOW)["board_issues"]) == ["NEW"]
+
+
+def test_agile_route(hub_server, add_project):
+ h = hub_server["hub"]
+ c = h.conn()
+ try:
+ add_project(c)
+ finally:
+ c.close()
+ client = hub_server["client"]
+ with pytest.raises(HubError) as e:
+ client.get("/agile")
+ assert e.value.status == 422 and e.value.error == "bad_request"
+ with pytest.raises(HubError) as e:
+ client.get("/agile", project="NOPE")
+ assert e.value.status == 404
+ assert set(client.get("/agile", project="AWPCA")) == {"board", "sprints", "backlog", "board_issues"}
diff --git a/tests/test_api.py b/tests/test_api.py
index 6abe76b..efc2a39 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1221,3 +1221,52 @@ def test_sweep_files_downloads(hub):
finally:
conn.close()
assert not d.exists()
+
+
+@pytest.mark.parametrize("over, detail", [
+ ({"sprint_ids": None}, "A-1: sprint_ids"), ({"sprint_ids": ["1"]}, "A-1: sprint_ids"), ({"sprint_ids": [True]}, "A-1: sprint_ids"),
+ ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"),
+ ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5
+ ({"rank": 5}, "A-1: rank"),
+ ({"story_points": 10**400}, "A-1: story_points"), ({"story_points": 2**63}, "A-1: story_points"),
+ ({"sprint_ids": [2**63]}, "A-1: sprint_ids"), ({"sprint_ids": [-2**63 - 1]}, "A-1: sprint_ids"),
+])
+def test_agent_state_bad_agile_fields_422(hub, over, detail):
+ with pytest.raises(HubError) as e:
+ hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": [], **over}], "links": []})
+ assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail
+ assert hub["client"].get("/issues")["issues"] == [] # пачка не применена
+
+
+@pytest.mark.parametrize("boards, detail", [
+ ("x", "boards — список досок"), ([{"id": "4216"}], "boards[0].id"),
+ ([{"id": 4216, "type": "scrum", "columns": None, "kanban_backlog": None, "sprints": [{"id": 1, "name": "S", "state": "closing"}]}],
+ "доска 4216: sprints[0].state — active, future или closed"),
+ ([{"id": 4216, "type": "sprint"}], "доска 4216: type"),
+ ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"),
+ ([{"id": 2**63}], "boards[0].id"),
+ ([{"id": 4216, "sprints": [{"id": 2**63, "name": "S", "state": "active"}]}], "sprints[0].id"),
+])
+def test_agent_state_bad_boards_422(hub, boards, detail):
+ with pytest.raises(HubError) as e:
+ hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": []}], "links": [], "boards": boards})
+ assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail
+ c = hub["hub"].conn()
+ try:
+ assert c.execute("SELECT COUNT(*) FROM jira_board").fetchone()[0] == 0
+ finally:
+ c.close()
+ assert hub["client"].get("/issues")["issues"] == [] # пачка не применена
+
+
+def test_agent_state_empty_batch_with_boards(hub):
+ b = {"id": 4216, "type": "scrum", "columns": [{"name": "К", "statuses": ["Backlog"]}], "kanban_backlog": None,
+ "sprints": [{"id": 101, "name": "S1", "state": "active", "start_date": None, "end_date": None,
+ "complete_date": None, "goal": None}]}
+ assert hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": [b]}) == {"issues": 0, "links": 0}
+ c = hub["hub"].conn()
+ try:
+ assert c.execute("SELECT type FROM jira_board WHERE board_id = 4216").fetchone()[0] == "scrum"
+ assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
+ finally:
+ c.close()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index b047460..a55c623 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,3 +1,5 @@
+import re
+
import pytest
from sdlc_hub.modules.jira import commands, mirror
@@ -266,3 +268,65 @@ def test_fetch_not_confirmed_by_state(conn):
assert (ok, result) == (False, None)
assert "fetch_attachment" not in commands.WRITE_TYPES
assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"]
+
+
+META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}}
+def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}}
+def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}}
+
+def test_new_types_validate_ok():
+ out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"),
+ {"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}},
+ {"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}])
+ assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"]
+
+@pytest.mark.parametrize("cmd, msg", [
+ ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
+ ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"),
+ ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"),
+ ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"),
+ ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"),
+ ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
+ (state(state="open"), "state — active или closed"),
+ (state(state="closed"), "move_open_to обязателен при state = closed"),
+ (state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"),
+ (state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"),
+ (rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"),
+ (rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
+ ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError
+ ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"),
+ (state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"),
+ (state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"),
+ (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
+ (rank(before=" ", after=" "), "ровно одно из before, after"),
+ (rank(before=" AURORA-1612 "), "before — другая задача"),
+])
+def test_new_types_errors(cmd, msg):
+ with pytest.raises(CommandError, match=re.escape(msg)):
+ commands.validate([cmd])
+
+def test_blank_state_move_open_to_is_unset_when_active(): # R-31: пробельная строка — «не задано»
+ assert commands.validate([state(state="active", move_open_to=" ")])
+ assert commands.validate([rank(before="A-2", after=" ")])
+
+def test_rank_temp_keys():
+ ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")]
+ assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"]
+ with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"):
+ commands.validate([rank(before="new:2")])
+
+def test_new_types_not_confirmed_by_state(conn): # design #18
+ for cmd in (META, state(state="active"), rank(before="A-2")):
+ assert commands.check_confirmed(conn, cmd, {}) == (False, None)
+
+def test_story_points_confirmation(conn): # R-16
+ mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], [])
+ sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}}
+ assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True
+ assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False
+ assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True
diff --git a/tests/test_db.py b/tests/test_db.py
index 3c67f36..4b86003 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -19,6 +19,8 @@ def test_connect_creates_schema(open_db):
assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")}
assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")}
assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")}
+ assert {"jira_board", "jira_sprint", "issue_sprint"} <= names
+ assert {"rank", "story_points"} <= {r["name"] for r in conn.execute("PRAGMA table_info(issue)")}
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
@@ -402,7 +404,7 @@ def test_migrates_v7_drops_fact_status(tmp_path):
path = tmp_path / "hub.sqlite"
_v7_database(path)
conn = db.connect(path)
- assert db._schema_version(conn) == 8
+ assert db._schema_version(conn) == db.SCHEMA_VERSION
assert _note_after_v8(conn, "A-1") == "Аналитика"
row = conn.execute("SELECT updated_at, updated_by FROM issue_note WHERE key = 'A-1'").fetchone()
assert (row["updated_at"], row["updated_by"]) == ("2026-10-20T00:00:00+00:00", "import")
@@ -416,7 +418,7 @@ def test_migrates_v7_without_keys_keeps_settings_version(tmp_path):
path = tmp_path / "hub.sqlite"
_v7_database(path, with_keys=False)
conn = db.connect(path)
- assert db._schema_version(conn) == 8 and settings.get(conn, "settings_version") == 12
+ assert db._schema_version(conn) == db.SCHEMA_VERSION and settings.get(conn, "settings_version") == 12
conn.close()
@@ -428,7 +430,7 @@ def test_migrates_v7_without_settings_version(tmp_path):
raw.commit()
raw.close()
conn = db.connect(path)
- assert db._schema_version(conn) == 8
+ assert db._schema_version(conn) == db.SCHEMA_VERSION
assert conn.execute("SELECT COUNT(*) AS n FROM setting WHERE key = 'settings_version'").fetchone()["n"] == 0
conn.close()
@@ -457,3 +459,36 @@ def test_obsolete_keys_removed_by_ensure_defaults(tmp_path):
assert {"status_labels", "transitions_map"}.isdisjoint(settings.get_all(conn))
assert settings.get(conn, "settings_version") == version
conn.close()
+
+
+def _v8_database(path):
+ """База v8: задача X со спринтом «Спринт 2», без колонок rank/story_points и таблиц досок (sprints-backlog)."""
+ conn = db.connect(path)
+ settings.ensure_defaults(conn)
+ conn.execute("INSERT INTO issue(key, summary, sprint, labels, fetched_at) VALUES ('X', 'т', 'Спринт 2', '[]', ?)", (db.now(),))
+ for col in ("rank", "story_points"):
+ conn.execute(f"ALTER TABLE issue DROP COLUMN {col}")
+ for t in ("jira_board", "jira_sprint", "issue_sprint"):
+ conn.execute(f"DROP TABLE {t}")
+ conn.execute("UPDATE setting SET value = '8' WHERE key = 'schema_version'")
+ conn.close()
+
+
+def test_migrates_v8_adds_agile_columns(tmp_path): # сценарий «Миграция базы v8»
+ path = tmp_path / "hub.sqlite"; _v8_database(path)
+ conn = db.connect(path)
+ assert db._schema_version(conn) == 9
+ row = conn.execute("SELECT rank, story_points, sprint FROM issue WHERE key = 'X'").fetchone()
+ assert (row["rank"], row["story_points"], row["sprint"]) == (None, None, "Спринт 2")
+ types = {r["name"]: r["type"] for r in conn.execute("PRAGMA table_info(issue)")}
+ assert types["rank"] == "TEXT" and types["story_points"] == "REAL"
+ for t in ("jira_board", "jira_sprint", "issue_sprint"):
+ assert conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] == 0
+ assert len(list((tmp_path / "backups").glob("*-schema-v8.sqlite"))) == 1
+ conn.close()
+
+
+def test_v9_reconnect_is_stable(tmp_path): # повторный connect не мигрирует и не копирует
+ path = tmp_path / "hub.sqlite"; _v8_database(path)
+ db.connect(path).close(); db.connect(path).close()
+ assert len(list((tmp_path / "backups").glob("*-schema-v*.sqlite"))) == 1
diff --git a/tests/test_mcp.py b/tests/test_mcp.py
index 42ee818..59781ad 100644
--- a/tests/test_mcp.py
+++ b/tests/test_mcp.py
@@ -85,8 +85,8 @@ def test_formatters():
"sprint": None, "fact_status": "В работе"}])
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
head, row = text.splitlines()
- assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal)
- assert len(row.split(" | ")) == 6 and "В работе" not in row
+ assert head == "ключ | тема | статус | срок | исполнитель | спринт | SP" # статуса по факту нет (fact-status-removal)
+ assert len(row.split(" | ")) == 7 and "В работе" not in row
assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
"settings_version": None})
@@ -221,7 +221,7 @@ def test_fmt_batch_warns_about_claimed():
def test_batch_create_mentions_labels():
doc = jmcp.batch_create.__doc__ or ""
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc
- assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc
+ assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint, ensure_sprint_meta и ensure_sprint_state выставляет хаб" in doc
assert "AWPCA" not in doc
@@ -859,3 +859,96 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch):
asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
assert path.read_bytes() == b"OLD" * 3
assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет
+
+
+# --- спринты, спринт, бэклог, колонка SP (#108, Task 7) ---------------------------------------------------------
+
+def _sp(id, name, state, **over):
+ return {"id": id, "name": name, "state": state, "start_date": "2026-10-14T09:00:00.000+0300", "end_date": "2026-10-28T18:00:00.000+0300",
+ "complete_date": None, "goal": None, "count": 0, "count_done": 0, "unestimated": 0, "points": 0, "points_done": 0, "issues": [], **over}
+
+
+BOARD = {"id": 4216, "type": "scrum", "columns": [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}],
+ "kanban_backlog": None, "fetched_at": "2026-10-24T10:00:00+00:00"}
+
+
+def test_fmt_issues_sp_column():
+ out = jmcp.fmt_issues([{"key": "A-1", "story_points": 3.0}, {"key": "A-2", "story_points": 2.5}, {"key": "A-3", "story_points": None}])
+ assert out.splitlines()[0].endswith("| SP") and [l.rsplit("| ", 1)[1] for l in out.splitlines()[1:]] == ["3", "2.5", "—"]
+
+
+def test_fmt_sprints_order_and_sums(): # сценарий «Спринты проекта»
+ data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints":
+ [_sp(3, "AWPCA_Sprint_3_Corrections", "active", count=37, count_done=25, points=40, points_done=25), _sp(4, "AWPCA_Sprint_4", "future")]
+ + [_sp(100 - n, f"S{n}", "closed") for n in range(12)]}
+ lines = jmcp.fmt_sprints(data).splitlines()
+ assert lines[1].startswith("AWPCA_Sprint_3_Corrections | активный | ") and "| 25/37 | 25/40 |" in lines[1]
+ assert lines[2].startswith("AWPCA_Sprint_4 | будущий")
+ assert len([l for l in lines if "| закрыт |" in l]) == 10 and lines[-1] == "ещё 2 закрытых"
+
+
+@pytest.mark.parametrize("data, text", [
+ ({"board": None, "sprints": [], "backlog": None, "board_issues": None}, "у проекта нет доски"),
+ ({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "у проекта канбан-доска, спринтов нет"),
+ ({"board": {**BOARD, "fetched_at": None, "columns": None}, "sprints": [], "backlog": [], "board_issues": None}, "данных доски нет — нужен агент v15 и обновление из Jira"),
+])
+def test_fmt_sprints_refusals(data, text):
+ assert jmcp.fmt_sprints(data) == text
+
+
+def test_fmt_sprint_by_columns():
+ def it(key, status, summary="т", sp=None, assignee=None):
+ return {"key": key, "summary": summary, "status": status, "assignee": assignee, "story_points": sp}
+ issues = [it("A-1", "Backlog", "д" * 100, 2, "Иванов"), it("A-2", "Готово", sp=2.5), it("A-3", "Странный")]
+ data = {"board": BOARD, "backlog": [], "board_issues": None, "sprints": [
+ _sp(5, "S5", "active", goal="Цель", count=3, count_done=1, points=4.5, points_done=2.5, unestimated=1, issues=issues),
+ _sp(4, "S4", "future")]}
+ out = jmcp.fmt_sprint(data, "").splitlines()
+ assert out[0].startswith("S5 — активный; ") and out[0].endswith("; цель: Цель")
+ assert out[1] == "задачи 1/3; SP 2.5/4.5; без оценки: 1"
+ assert out[2:] == ["К выполнению (1):", out[3], "Готово (1):",
+ " A-2 т — —, SP 2.5", "Без колонки (1):", " A-3 т — —, SP —"]
+ assert out[3].startswith(" A-1 ") and out[3].endswith(" — Иванов, SP 2") and len(out[3].split(" — ")[0]) <= len(" A-1 ") + 80
+ assert jmcp.fmt_sprint(data, "S9") == "спринта «S9» нет на доске"
+ assert jmcp.fmt_sprint(data, "S4").splitlines()[0].startswith("S4 — будущий; ")
+ data["sprints"] = [_sp(4, "S4", "future")]
+ assert jmcp.fmt_sprint(data, "") == "активного спринта нет"
+ assert jmcp.fmt_sprint({"board": {**BOARD, "type": "kanban"}, "sprints": [], "backlog": None, "board_issues": []}, "") \
+ == "у проекта канбан-доска, спринтов нет"
+
+
+def test_fmt_backlog_limit(): # сценарий «Бэклог с лимитом»
+ rows = [{"key": f"A-{n}", "summary": "т", "status": "Backlog", "assignee": None, "story_points": None} for n in range(188)]
+ out = jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": rows, "board_issues": None}, 2).splitlines()
+ assert out[1].startswith("1 | A-0 |") and out[2].startswith("2 | A-1 |") and out[-1] == "ещё 186" and len(out) == 4
+ assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": [], "board_issues": None}, 5) == "бэклог пуст"
+ assert jmcp.fmt_backlog({"board": BOARD, "sprints": [], "backlog": None, "board_issues": None}, 5) == "у доски нет бэклога"
+
+
+def test_fmt_day_offsets():
+ want = datetime(2026, 10, 14, 12, tzinfo=timezone.utc).astimezone().strftime("%d.%m") # один момент во всех записях
+ for raw in ("2026-10-14T12:00:00.000Z", "2026-10-14T15:00:00.000+0300", "2026-10-14T15:00:00.000+03:00"):
+ assert jmcp.fmt_day(raw) == want
+ assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—"
+
+
+def test_fmt_sp():
+ assert [jmcp.fmt_sp(v) for v in (None, 3.0, 2.5, 0)] == ["—", "3", "2.5", "0"]
+
+
+def test_agile_tools_live(live):
+ live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Backlog", "labels": ["AWPCA"],
+ "rank": "0|a", "story_points": 2, "sprint_ids": [102]}], "links": [],
+ "boards": [{"id": 4216, "type": "scrum", "columns": BOARD["columns"], "kanban_backlog": None, "sprints": [_sp(102, "S2", "active")]}]})
+ assert "S2 | активный" in jmcp.sprints() and "A-1 Тема" in jmcp.sprint() and "бэклог пуст" == jmcp.backlog()
+
+
+def test_agile_tools_unbound(live, monkeypatch):
+ monkeypatch.setattr(core_mcp, "PROJECT", None)
+ assert jmcp.sprints() == jmcp.sprint() == jmcp.backlog() == jmcp.issues() # тот же отказ, что у прочих
+
+
+def test_batch_create_description_lists_sprint_types():
+ doc = jmcp.batch_create.__doc__
+ for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
+ assert word in doc
diff --git a/tests/test_mirror.py b/tests/test_mirror.py
index 6baec01..78c5558 100644
--- a/tests/test_mirror.py
+++ b/tests/test_mirror.py
@@ -1,3 +1,5 @@
+import json
+
import pytest
from sdlc_hub.core import db
@@ -406,3 +408,89 @@ def test_get_issue_commands_skip_fetch(conn):
conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)",
(seq, typ, '{"key": "A-1", "attachment_id": "1"}'))
assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"]
+
+
+COLS = [{"name": "К выполнению", "statuses": ["Backlog"]}, {"name": "Готово", "statuses": ["Готово"]}]
+
+
+def board(id=4216, type="scrum", sprints=(), **over):
+ return {"id": id, "type": type, "columns": COLS, "kanban_backlog": None, "sprints": list(sprints), **over}
+
+
+def sprint(id, state, name=None, **over):
+ return {"id": id, "name": name or f"S{id}", "state": state, "start_date": None, "end_date": None,
+ "complete_date": None, "goal": None, **over}
+
+
+def sprint_rows(conn, key):
+ return sorted(r[0] for r in conn.execute("SELECT sprint_id FROM issue_sprint WHERE key = ?", (key,)))
+
+
+def test_state_issue_in_two_sprints(conn): # сценарий «Задача прошла два спринта»
+ mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [], boards=[board(sprints=[sprint(101, "closed"), sprint(102, "active")])])
+ assert sprint_rows(conn, "X") == [101, 102]
+
+
+def test_state_sprint_ids_replace_keep_clear(conn):
+ mirror.apply_state(conn, [issue("X", sprint_ids=[101, 102])], [])
+ mirror.apply_state(conn, [issue("X", sprint_ids=[102])], []); assert sprint_rows(conn, "X") == [102]
+ mirror.apply_state(conn, [issue("X")], []); assert sprint_rows(conn, "X") == [102] # агент v14
+ mirror.apply_state(conn, [issue("X", sprint_ids=[])], []); assert sprint_rows(conn, "X") == []
+
+
+def test_state_rank_and_points_set_keep_clear(conn):
+ mirror.apply_state(conn, [issue("X", rank="0|i0001:", story_points=3)], [])
+ mirror.apply_state(conn, [issue("X", summary="другая")], []) # без полей — прежние значения
+ row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone()
+ assert (row["rank"], row["story_points"]) == ("0|i0001:", 3.0)
+ mirror.apply_state(conn, [issue("X", rank=None, story_points=None)], [])
+ row = conn.execute("SELECT rank, story_points FROM issue WHERE key = 'X'").fetchone()
+ assert (row["rank"], row["story_points"]) == (None, None)
+
+
+def test_state_board_replaces_row_and_sprints(conn): # сценарий «Пачка с досками»
+ mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active", "старое"), sprint(102, "future")]),
+ board(id=5120, type="kanban", sprints=[sprint(201, "active")])])
+ new = [sprint(101, "closed", complete_date="2026-10-14T18:00:00.000+03:00"), sprint(102, "active"), sprint(103, "future")]
+ mirror.apply_state(conn, [], [], boards=[board(columns=COLS[:1], sprints=new)])
+ rows = {r["sprint_id"]: dict(r) for r in conn.execute("SELECT * FROM jira_sprint WHERE board_id = 4216")}
+ assert set(rows) == {101, 102, 103} and rows[101]["state"] == "closed" and rows[101]["name"] == "S101"
+ assert rows[101]["complete_date"] == "2026-10-14T18:00:00.000+03:00"
+ b = conn.execute("SELECT * FROM jira_board WHERE board_id = 4216").fetchone()
+ assert json.loads(b["columns"]) == COLS[:1] and b["kanban_backlog"] is None and b["fetched_at"]
+ assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint WHERE board_id = 5120")] == [201] # не в пачке — не тронута
+
+
+def test_state_board_without_sprints_keeps_them(conn): # R-14
+ mirror.apply_state(conn, [], [], boards=[board(sprints=[sprint(101, "active")])])
+ mirror.apply_state(conn, [], [], boards=[{**board(), "sprints": None, "columns": None}])
+ assert [r[0] for r in conn.execute("SELECT sprint_id FROM jira_sprint")] == [101]
+ assert conn.execute("SELECT columns FROM jira_board").fetchone()[0] is None
+ mirror.apply_state(conn, [], [], boards=[board(sprints=[])])
+ assert conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 0
+
+
+def test_state_board_type_to_all_projects_of_board(conn, add_project): # Review Focus 3
+ add_project(conn, board_type=None); add_project(conn, "C", board_id=4216, board_type=None)
+ mirror.apply_state(conn, [], [], boards=[board(type="kanban", kanban_backlog=True)])
+ assert {r["label"]: r["board_type"] for r in conn.execute("SELECT label, board_type FROM jira_project")} == {"AWPCA": "kanban", "C": "kanban"}
+ assert tuple(conn.execute("SELECT type, kanban_backlog FROM jira_board").fetchone()) == ("kanban", 1)
+ mirror.apply_state(conn, [], [], boards=[board(type=None)]) # null тип проекта не меняет
+ assert conn.execute("SELECT board_type FROM jira_project WHERE label = 'AWPCA'").fetchone()[0] == "kanban"
+
+
+def test_state_v14_keeps_agile_data(conn): # сценарий «Агент v14»
+ mirror.apply_state(conn, [issue("X", rank="r", story_points=2, sprint_ids=[101])], [], boards=[board(sprints=[sprint(101, "active")])])
+ mirror.apply_state(conn, [issue("X")], [])
+ assert sprint_rows(conn, "X") == [101] and conn.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
+ assert tuple(conn.execute("SELECT rank, story_points FROM issue").fetchone()) == ("r", 2.0)
+
+
+def test_check_agile_fields_ok_and_absent():
+ mirror.check_agile_fields([{"key": "A"}, {"key": "B", "rank": None, "story_points": 0.5, "sprint_ids": []},
+ {"key": "C", "rank": "r", "story_points": 3, "sprint_ids": [1, 2]}])
+
+
+def test_check_boards_ok_and_none():
+ mirror.check_boards(None)
+ mirror.check_boards([board(sprints=[sprint(1, "closed", start_date="2026-10-01")]), {"id": 5, "type": None}])
diff --git a/tests/test_projects.py b/tests/test_projects.py
index e06691c..20d018e 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -148,3 +148,11 @@ def test_ensure_legacy_skips_case_variant_and_bad_board(open_db):
c2 = open_db()
settings.ensure_defaults(c2)
assert jira_projects.ensure_legacy(c2, "oops") is True and api.project_rows(c2, ["AWPCA"])[0]["board_id"] is None
+
+
+def test_project_jql(open_db, add_project):
+ conn = open_db(); settings.ensure_defaults(conn)
+ add_project(conn)
+ assert jira_projects.project_jql(conn, "AWPCA") == 'project = AURORA AND labels in ("AWPCA")' == jira_projects.base_jql(conn)
+ add_project(conn, "C", board_id=None)
+ assert jira_projects.project_jql(conn, "C") == 'project = AURORA AND labels in ("C")'
diff --git a/tests/test_queue.py b/tests/test_queue.py
index 779d5d0..661d559 100644
--- a/tests/test_queue.py
+++ b/tests/test_queue.py
@@ -895,6 +895,59 @@ def test_retry_premigration_sprint_without_board(conn, add_project): # Review
assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216}
+def meta(name="AWPCA_Sprint_4"):
+ return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}}
+def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"):
+ return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}}
+
+def test_sprint_meta_gets_batch_project_board(conn, add_project):
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA")
+ cmds = queue.batch_view(conn, b["id"])["commands"]
+ assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216
+
+def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета»
+ add_project(conn)
+ with pytest.raises(CommandError) as e:
+ queue.create_batch(conn, "x", "claude", [meta()])
+ assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта"
+ assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
+
+@pytest.mark.parametrize("setup, msg", [
+ (dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"),
+ (dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"),
+ (dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"),
+])
+def test_sprint_state_board_rejected(conn, add_project, setup, msg):
+ add_project(conn, **setup)
+ with pytest.raises(CommandError) as e:
+ queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
+ assert str(e.value) == msg
+
+def test_rank_belongs_to_project_by_key(conn, add_project): # design #19
+ add_project(conn); add_project(conn, "C", board_id=5120)
+ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], [])
+ queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
+ with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"):
+ queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA")
+
+def test_retry_sprint_meta_recomputes_board(conn, add_project):
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
+ cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
+ conn.execute("UPDATE jira_project SET board_id = 5120")
+ r = queue.retry_command(conn, cid, "claude")
+ assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120
+
+def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA")
+ cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
+ conn.execute("UPDATE jira_project SET board_type = 'kanban'")
+ with pytest.raises(CommandError, match="канбан-доска, спринтов нет"):
+ queue.retry_command(conn, cid, "claude")
+
+
def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1
add_project(conn)
add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)
@@ -1212,3 +1265,29 @@ def test_estimate_sums_module_pauses_replaces_commands_estimate(conn):
через pacing -> hooks.pause -> queue.estimate."""
settings.set_(conn, "pacing", {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2})
assert queue.estimate(conn, ["ensure_fields", "ensure_comment", "refresh"]) == 152
+
+
+def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10
+ add_project(conn); add_project(conn, "C", board_id=None)
+ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], [])
+ b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA")
+ cmds = queue.batch_view(conn, b["id"])["commands"]
+ assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"]
+ assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
+
+
+def test_rank_batch_tail_by_keys(conn, add_project):
+ add_project(conn)
+ mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], [])
+ b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA")
+ assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]}
+
+
+def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
+ add_project(conn)
+ b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA")
+ cid = queue.batch_view(conn, b["id"])["commands"][0]["id"]
+ r = queue.retry_command(conn, cid, "claude")
+ cmds = queue.batch_view(conn, r["id"])["commands"]
+ assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
+ assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
diff --git a/tests/test_queue_core.py b/tests/test_queue_core.py
index a8cefc5..db92646 100644
--- a/tests/test_queue_core.py
+++ b/tests/test_queue_core.py
@@ -21,7 +21,7 @@ def make_stub(events, refs=()):
hooks = CommandHooks(
types=frozenset({"w", "r"}), read_types=frozenset({"r"}), validate=_validate,
check_batch=lambda conn, cmds, project, files_dir: cmds,
- tail=lambda cmds: {"type": "r", "params": {}} if any(c["type"] == "w" for c in cmds) else None,
+ tail=lambda conn, cmds, project: {"type": "r", "params": {}} if any(c["type"] == "w" for c in cmds) else None,
pause=lambda conn, ctype: 7, deliver_params=lambda conn, cmd, files_dir: cmd["params"],
temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda t, p, m: p,
drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"],
diff --git a/tests/test_settings.py b/tests/test_settings.py
index ecf0971..50c6f20 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -128,3 +128,13 @@ def test_pacing_fallback_for_new_type(conn):
settings.set_(conn, "pacing", {"ensure_fields": 5})
assert jira_settings.pacing(conn)["fetch_attachment"] == 2
settings.validate("pacing", {"fetch_attachment": 3})
+
+
+def test_sprint_types_pacing(conn):
+ for t in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"):
+ assert jira_settings.DEFAULT_PACING[t] == 30 and jira_settings.pacing(conn)[t] == 30
+ assert jira_settings.pacing_from_config({"comment_seconds": 45})[t] == 45
+ settings.set_(conn, "pacing", {"ensure_fields": 5}) # тип не в сохранённой настройке — умолчание
+ assert jira_settings.pacing(conn)["ensure_rank"] == 30
+ settings.validate("pacing", {"ensure_rank": 10})
+ assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90
diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts
new file mode 100644
index 0000000..5c03720
--- /dev/null
+++ b/web/e2e/agile.spec.ts
@@ -0,0 +1,81 @@
+import { expect, test } from "@playwright/test";
+import { login } from "./hub.ts";
+
+// экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27)
+const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" });
+
+test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => {
+ await login(page, "/app/jira/backlog?project=AWPCA");
+ await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible();
+ const past = page.getByText("Прошлые спринты (1)");
+ await expect(past).toBeVisible();
+ await past.click();
+ await page.getByText("AWPCA_Спринт 1").first().click();
+ const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last();
+ await expect(row).toBeVisible();
+ await row.click();
+ await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page).toHaveURL(/\/app\/jira\/backlog/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", async ({ page }) => {
+ await login(page, "/app/jira/sprint?project=AWPCA");
+ await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible();
+ await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible();
+ for (const name of ["К выполнению", "В работе", "Готово"]) {
+ await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible();
+ }
+ await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click();
+ await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/);
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page).toHaveURL(/\/app\/jira\/sprint/);
+});
+
+test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => {
+ await login(page, "/app/jira/kanban?project=KANBAN_E2E");
+ await expect(page.getByText("Бэклог (1)")).toBeVisible();
+ await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible();
+ await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible();
+ await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0);
+ await expect(page.getByText("за 14 дней")).toBeVisible();
+ await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible();
+ await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible();
+});
+
+test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => {
+ await login(page, "/app/jira/backlog?project=KANBAN_E2E");
+ await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible();
+ await page.getByRole("link", { name: "Связи", exact: true }).last().click();
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/);
+});
+
+test("меню по проекту: у AWPCA «Бэклог» и «Спринт», у KANBAN_E2E — «Kanban»", async ({ page }) => {
+ await login(page, "/app/jira/links?project=AWPCA");
+ await expect(side(page).getByRole("link", { name: "Бэклог" })).toBeVisible();
+ await expect(side(page).getByRole("link", { name: "Спринт" })).toBeVisible();
+ await expect(side(page).getByRole("link", { name: "Kanban" })).toHaveCount(0);
+ await page.goto("/app/jira/links?project=KANBAN_E2E");
+ await expect(side(page).getByRole("link", { name: "Kanban" })).toBeVisible();
+ await expect(side(page).getByRole("link", { name: "Бэклог" })).toHaveCount(0);
+ await expect(side(page).getByRole("link", { name: "Спринт" })).toHaveCount(0);
+});
+
+test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", async ({ page }) => {
+ await page.setViewportSize({ width: 900, height: 900 });
+ await login(page, "/app/jira/sprint?project=AWPCA");
+ await expect(page.locator(".agile-col").first()).toBeVisible();
+ const m = await page.evaluate(() => {
+ const d = document.documentElement, c = document.querySelector(".agile-columns")!;
+ return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth],
+ gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left };
+ });
+ expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0);
+ expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]);
+ expect(m.cols[0]).toBeGreaterThan(m.cols[1]);
+});
diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts
index 6ccfce5..74cb14c 100644
--- a/web/e2e/board.spec.ts
+++ b/web/e2e/board.spec.ts
@@ -59,7 +59,7 @@ test("ⓘ открывает карточку окном поверх доски
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001$/);
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
await expect(page.getByTestId("page-board")).toBeAttached();
- await expect(page.getByRole("link", { name: "Доска связей", exact: true })).toHaveAttribute("aria-current", "page");
+ await expect(page.getByRole("link", { name: "Связи", exact: true })).toHaveAttribute("aria-current", "page");
});
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
@@ -80,13 +80,13 @@ test("M-3: прокрутка и данные доски после окна к
await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
- await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
expect(boardLoads).toBe(0); // доска не перезагружалась
// боковая панель закреплена (#69): ссылки разделов видны и после прокрутки, щелчок не двигает доску
await page.getByRole("link", { name: "Задачи", exact: true }).click();
await expect(page).toHaveURL(/\/app\/jira\/issues\?project=AWPCA$/);
- await page.getByRole("link", { name: "Доска связей", exact: true }).click();
+ await page.getByRole("link", { name: "Связи", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});
diff --git a/web/e2e/hub.ts b/web/e2e/hub.ts
index 9641d4e..bb177da 100644
--- a/web/e2e/hub.ts
+++ b/web/e2e/hub.ts
@@ -5,12 +5,12 @@ import { HUB_HOME, HUB_URL } from "../playwright.config.ts";
export const tokens = () => JSON.parse(readFileSync(`${HUB_HOME}/tokens.json`, "utf8")) as { client: string; agents: Record };
const BASE = HUB_URL;
-export async function login(page: Page, path = "/app/jira/board") {
+export async function login(page: Page, path = "/app/jira/links") {
await page.goto(path);
await page.getByLabel("Клиентский токен хаба").fill(tokens().client);
await page.getByRole("button", { name: "Войти" }).click();
// оболочка: на широком экране — ссылки разделов в боковой панели, на узком — кнопка «Меню» (#69)
- await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Доска связей" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible();
+ await expect(page.getByRole("complementary", { name: "Панель приложения" }).getByRole("link", { name: "Связи" }).or(page.getByRole("button", { name: "Меню" }))).toBeVisible();
}
async function call(path: string, body: unknown) {
diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts
index a369251..999ca30 100644
--- a/web/e2e/issues.spec.ts
+++ b/web/e2e/issues.spec.ts
@@ -76,7 +76,7 @@ test("переход по связи и «назад»; ✕ и щелчок по
await page.mouse.click(5, 300); // фон слева от окна
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.goBack();
- await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
await expect(page.getByRole("dialog")).toHaveCount(0);
});
diff --git a/web/e2e/layout.spec.ts b/web/e2e/layout.spec.ts
index 2b9623a..e0fce73 100644
--- a/web/e2e/layout.spec.ts
+++ b/web/e2e/layout.spec.ts
@@ -49,7 +49,7 @@ test("C-1: на средних ширинах страница не шире о
await expect(page.locator("table.issue-table tbody tr").first()).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280);
await page.setViewportSize({ width: 1000, height: 800 });
- await page.getByRole("link", { name: "Доска связей" }).click();
+ await page.getByRole("link", { name: "Связи" }).click();
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
await page.getByRole("button", { name: "По датам" }).click();
await expect(page.locator(".dcol").first()).toBeVisible();
@@ -71,10 +71,16 @@ test("1440 px: «Задачи» не шире окна, h1 «Вики» — 20px
expect(await h1.evaluate((el) => getComputedStyle(el).fontSize)).toBe("20px");
});
-test("/app/board → /app/jira/board с сохранением search", async ({ page }) => {
+test("/app/board → /app/jira/links с сохранением search", async ({ page }) => {
await login(page);
await page.goto("/app/board?project=AWPCA");
- await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
+});
+
+test("/app/jira/board → /app/jira/links с сохранением search", async ({ page }) => {
+ await login(page);
+ await page.goto("/app/jira/board?project=AWPCA");
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
});
test("390 px: метка проекта в верхней строке, «Меню» модально", async ({ page }) => {
diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts
index 9e6399e..c27418e 100644
--- a/web/e2e/login.spec.ts
+++ b/web/e2e/login.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
test("неверный токен — пояснение; верный — доска", async ({ page }) => {
- await page.goto("/app/jira/board");
+ await page.goto("/app/jira/links");
await page.getByLabel("Клиентский токен хаба").fill("wrong-token");
await page.getByRole("button", { name: "Войти" }).click();
await expect(page.getByText(/Токен не подошёл/)).toBeVisible();
@@ -16,7 +16,7 @@ test("прежние адреса ведут на страницы модуле
await expect(page).toHaveURL(/\/app\/?\?project=AWPCA$/);
await expect(page.getByTestId("page-overview")).toBeVisible();
await page.goto("/app/board?project=AWPCA");
- await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
await page.goto("/app/issues/AURORA-9001?project=AWPCA");
await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001\?project=AWPCA$/);
});
diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts
index 3566d85..22270b6 100644
--- a/web/e2e/projects.spec.ts
+++ b/web/e2e/projects.spec.ts
@@ -5,7 +5,7 @@ import { agentServeRefresh, login } from "./hub.ts";
test.describe.serial("проекты", () => {
test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => {
await login(page);
- await expect(page).toHaveURL(/\/app\/jira\/board\?project=AWPCA$/);
+ await expect(page).toHaveURL(/\/app\/jira\/links\?project=AWPCA$/);
await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible();
});
@@ -35,7 +35,7 @@ test.describe.serial("проекты", () => {
});
test("переключение проекта и раздела", async ({ page }) => {
- await login(page, "/app/jira/board?project=STOCKS_DRRK");
+ await login(page, "/app/jira/links?project=STOCKS_DRRK");
await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" });
await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible();
await page.getByRole("link", { name: "Задачи", exact: true }).click();
diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts
index 7959b5c..b4fdb4b 100644
--- a/web/e2e/screens.spec.ts
+++ b/web/e2e/screens.spec.ts
@@ -26,7 +26,7 @@ for (const theme of ["light", "dark"] as const) {
};
test("login", async ({ page }) => {
- await page.goto("/app/jira/board");
+ await page.goto("/app/jira/links");
await expect(page.getByLabel("Клиентский токен хаба")).toBeVisible();
await settle(page);
await shot(page, "login");
diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py
index 56450e2..e38ae95 100644
--- a/web/scripts/seed-hub.py
+++ b/web/scripts/seed-hub.py
@@ -3,10 +3,11 @@
Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
import json
import sys
+from datetime import date, timedelta
from pathlib import Path
from sdlc_hub.core import db, paths, settings
-from sdlc_hub.modules.jira import mirror
+from sdlc_hub.modules.jira import mirror, projects as jira_projects
from sdlc_hub.modules.wiki import paths as wiki_paths, search as wiki_search, sync as wiki_sync
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
@@ -25,7 +26,35 @@ def own_labels(i):
issues = [{k: v for k, v in i.items() if k not in ("work_kind", "neighbor")} | {"id": i["key"]}
| {"labels": own_labels(i)} | extra_issues.get(i["key"], {}) for i in snap["issues"]]
links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]]
-mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]])
+# доски для экранов «Бэклог», «Спринт», «Kanban» (R-27): AWPCA — scrum 4216, KANBAN_E2E — kanban 5120
+SPRINTS = {"AWPCA_Спринт 1": 101, "AWPCA_Спринт 2": 102, "AWPCA_Спринт 3": 103}
+for n, i in enumerate(issues, 1):
+ i["rank"] = f"0|i{n:04d}:"
+ sid = SPRINTS.get(i.get("sprint"))
+ i["sprint_ids"] = [] if sid is None else [101, 102] if i["key"] == "AURORA-9001" else [sid]
+ if n % 2:
+ i["story_points"] = float(n % 5 + 1)
+today = date.today()
+iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00"
+now_iso = iso(today, "12:00")
+issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний",
+ "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})}
+ for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)]
+jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120)
+boards = [
+ {"id": 4216, "type": "scrum", "kanban_backlog": None,
+ "columns": [{"name": "К выполнению", "statuses": ["Backlog", "К выполнению", "Ожидание", "Ожидание разработки"]},
+ {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}],
+ "sprints": [{"id": 101, "name": "AWPCA_Спринт 1", "state": "closed", "start_date": iso(today - timedelta(days=21), "09:00"),
+ "end_date": iso(today - timedelta(days=8), "09:00"), "complete_date": iso(today - timedelta(days=8), "18:00"), "goal": None},
+ {"id": 102, "name": "AWPCA_Спринт 2", "state": "active", "start_date": iso(today - timedelta(days=7), "09:00"),
+ "end_date": iso(today + timedelta(days=7), "12:00"), "complete_date": None, "goal": "Тестовая цель спринта"},
+ {"id": 103, "name": "AWPCA_Спринт 3", "state": "future", "start_date": None, "end_date": None, "complete_date": None, "goal": None}]},
+ {"id": 5120, "type": "kanban", "kanban_backlog": True,
+ "columns": [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]},
+ {"name": "Готово", "statuses": ["Готово"]}]},
+]
+mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]], boards=boards)
for i in snap["issues"]:
note = {k: i[k] for k in ("work_kind",) if i.get(k)}
if note:
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 1de4031..792206e 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -20,29 +20,44 @@ describe("оболочка", () => {
});
it("переход «Задачи» и обратно", async () => {
- const router = at("/app/jira/board");
+ const router = at("/app/jira/links");
render();
await userEvent.click(screen.getByRole("link", { name: "Задачи" }));
expect(router.state.location.pathname).toBe("/app/jira/issues");
expect(router.state.location.search).toBe("?project=AWPCA");
expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
- await userEvent.click(screen.getByRole("link", { name: "Доска связей" }));
- expect(router.state.location.pathname).toBe("/app/jira/board");
+ await userEvent.click(screen.getByRole("link", { name: "Связи" }));
+ expect(router.state.location.pathname).toBe("/app/jira/links");
expect(router.state.location.search).toBe("?project=AWPCA");
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
});
+ it("прежний адрес доски связей", async () => {
+ const router = at("/app/jira/board?project=AWPCA");
+ render();
+ await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe("/app/jira/links?project=AWPCA"));
+ expect(await screen.findByRole("link", { name: "Связи" })).toHaveAttribute("aria-current", "page");
+ await waitFor(() => expect(document.title).toBe("Связи · AWPCA · SDLC Hub"));
+ });
+
+ it("/app/board → сразу /app/jira/links", async () => {
+ const router = at("/app/board?project=AWPCA");
+ render();
+ await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/links"));
+ expect(router.state.location.search).toBe("?project=AWPCA");
+ });
+
it("неизвестный путь — «не найдено» со ссылками", async () => {
render();
expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument();
const main = within(screen.getByRole("main"));
expect(main.getByRole("link", { name: "Обзор" })).toHaveAttribute("href", expect.stringMatching(/^\/app\/?$/));
- expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/jira/board");
+ expect(main.getByRole("link", { name: "Связи" })).toHaveAttribute("href", "/app/jira/links");
expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/jira/issues");
});
it("«Сменить токен» показывает экран входа", async () => {
- render();
+ render();
await userEvent.click(within(await screen.findByRole("complementary", { name: "Панель приложения" })).getByRole("button", { name: "Ещё" })); // #68 M-2: на доске есть ещё и «Ещё — доска»
await userEvent.click(screen.getByRole("button", { name: "Сменить токен" }));
expect(getAuth().token).toBe("");
@@ -59,13 +74,13 @@ describe("оболочка", () => {
expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
expect(screen.getAllByRole("alert")).toHaveLength(1);
- expect(screen.getByRole("link", { name: "Доска связей" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Связи" })).toBeInTheDocument();
} finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
});
it("без токена — только экран входа", () => {
clearToken();
- render();
- expect(screen.queryByRole("link", { name: "Доска связей" })).not.toBeInTheDocument();
+ render();
+ expect(screen.queryByRole("link", { name: "Связи" })).not.toBeInTheDocument();
});
});
diff --git a/web/src/modules/jira/Summary.tsx b/web/src/modules/jira/Summary.tsx
index 18a249a..467e255 100644
--- a/web/src/modules/jira/Summary.tsx
+++ b/web/src/modules/jira/Summary.tsx
@@ -24,7 +24,7 @@ export function Summary({ project }: { project: Project }) {
{own.length} задач · {open.length} открытых · {late} просрочено
)}
diff --git a/web/src/modules/jira/agile/AgileCard.tsx b/web/src/modules/jira/agile/AgileCard.tsx
new file mode 100644
index 0000000..99249e5
--- /dev/null
+++ b/web/src/modules/jira/agile/AgileCard.tsx
@@ -0,0 +1,27 @@
+import type { MouseEvent } from "react";
+import type { AgileIssue } from "../api/types.ts";
+import { IssueLink, useOpenIssue } from "../issues/IssueLink.tsx";
+import { fmtSp } from "./model.ts";
+import { TypeIcon } from "./TypeIcon.tsx";
+
+// карточка задачи в колонке доски; щелчок (не по ссылке и не при выделении текста) открывает карточку задачи поверх страницы
+export function AgileCard({ issue }: { issue: AgileIssue }) {
+ const open = useOpenIssue();
+ const click = (e: MouseEvent) => {
+ if ((e.target as HTMLElement).closest("a")) return;
+ const sel = window.getSelection();
+ if (sel && !sel.isCollapsed && sel.containsNode(e.currentTarget, true)) return;
+ open(issue.key);
+ };
+ return (
+