sdlc-hub/docs/superpowers/plans/2026-10-24-sprints-backlog-hub.md
Andrey Epifancev 19912491cc План: спринты и бэклог, хаб (#108)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:27:02 +04:00

1270 lines
106 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Спринты и бэклог (хаб) 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 <noreply@anthropic.com>`. Не пушить.
- Зелёным считается только `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>`** в адресе «Спринта» (спринт закрыт, чужой 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 <noreply@anthropic.com>"
```
---
### 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, {"<id>": type}) -> int`.
- Produces:
- `mirror.SPRINT_STATES = ("active", "future", "closed")`
- `mirror.check_agile_fields(issues: list[dict]) -> None` — `ValueError` с текстом `"<key>: rank — строка или null"`, `"<key>: story_points — число или null"` (bool, `NaN`, `±Infinity` — нарушение), `"<key>: sprint_ids — список целых"` (`null`, bool, строки — нарушение). Поле не прислано — не проверяется.
- `mirror.check_boards(boards) -> None` — `None` → ничего; не список → `"boards — список досок"`; элемент не объект или `id` не целое > 0 → `"boards[<n>]: …"`; дальше префикс `"доска <id>: "` и поле: `type — scrum, kanban или null`, `columns — список колонок или null`, `columns[<k>].name — строка`, `columns[<k>].statuses — список строк`, `kanban_backlog — true, false или null`, `sprints — список спринтов или null`, `sprints[<k>].id — целое > 0`, `sprints[<k>].name — непустая строка`, `sprints[<k>].state — active, future или closed`, `sprints[<k>].<start_date|end_date|complete_date|goal> — строка или 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 = <project_key> 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 <noreply@anthropic.com>"
```
---
### 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>» нет на доске»; без `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 <noreply@anthropic.com>"
```
---
### 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: <IconBoard /> }`. Порядок `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 <noreply@anthropic.com>"
```
---
### 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: <IconBacklog />, padded: true, when: (p) => boardType(p) === "scrum" }` после «Связи»; страница `/jira/backlog` — после `/jira/links`.
Состав «Бэклога» (спека web-app): секции активных, затем будущих спринтов — `<section>` с заголовком: имя, «активный»/
«будущий», `fmtRange`, цель (если есть), `${count} ${tasksWord(count)}`, `${fmtSp(points)} SP (выполнено
${fmtSp(points_done)})`, «без оценки: U» при `U > 0`; задачи по порядку ответа; пусто — «Нет задач». Затем секция
«Бэклог» (`data.backlog`), пусто — «Нет задач». Внизу `<details>` «Прошлые спринты (N)»: по `<details>` на закрытый
спринт, `<summary>` — `${name} · закрыт ${fmtDay(complete_date)} · выполнено ${count_done} из ${count} ·
${fmtSp(points)} SP`, внутри строки задач с `mark="не выполнено"` у `!done`. Ни `draggable`, ни кнопок, кроме
`<summary>` и `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 <noreply@anthropic.com>"
```
---
### 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=<id>`, нет такого активного (закрыт, чужой, мусор) — первый активный (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: <IconSprint />, 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=<id> и его задачи", ...)
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 <noreply@anthropic.com>"
```
---
### 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` над колонками `<details>` «Бэклог (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: <IconKanban />, 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 <noreply@anthropic.com>"
```
---
### 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<NNNN>:` по порядку фикстуры; `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=<id>`), «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 <noreply@anthropic.com>"
```
---
## 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`, которые
тестом однозначно не определяются.