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

106 KiB
Raw Blame History

Спринты и бэклог (хаб) 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)

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.

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, где он используется)

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:

@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.

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, Ожидает анализа; В работе: В работе; Готово: Готово, Протестировано, Снято].

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.

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)

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:

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.

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)

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.

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:

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:

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.

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 — первый активный, иначе «активного спринта нет». Шапка: 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)

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.

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:

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):

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.

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:

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 — фабрика с активным, будущим и закрытым спринтами):

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.

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:

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:

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.

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)

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.

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)

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.

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, которые тестом однозначно не определяются.