sdlc-hub/docs/superpowers/plans/2026-10-23-hub-modules.md
Andrey Epifancev 085e96e7e8 docs(plan): уточнение про DEFAULTS (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:38:13 +04:00

41 KiB
Raw Permalink Blame History

Ядро и модуль jira (hub-modules) — план реализации

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: разделить пакет jira_hub и веб-приложение на ядро и модуль jira с явным контрактом подключения, не меняя внешнего поведения.

Architecture: Python-пакет делится на src/jira_hub/core/ и src/jira_hub/modules/jira/; модуль описывается объектом Module (core/modules.py), список модулей — core/registry.py (импорт модуля ленивый). Очередь, HTTP, настройки, MCP и CLI ядра зовут обработчики модуля по типу команды или по списку модулей. Веб: web/src/modules/jira/ плюс описание модуля, оболочка собирает страницы и панель из web/src/modules/index.ts. Первая задача фиксирует снимки внешнего поведения (golden), дальше каждый шаг обязан их сохранить.

Tech Stack: Python 3.13 (uv, pytest, stdlib http.server, SQLite, пакет mcp<2), React 19 + TypeScript + Vite, Vitest, Playwright.

Spec: openspec/changes/hub-modules/ — design.md (решения 1–13), proposal.md, specs/mcp/spec.md, specs/modules/spec.md.

Global Constraints

  • Внешнее поведение не меняется: адреса, коды, тела ответов HTTP API; инструменты MCP (имена, описания, схемы параметров, тексты ответов); адреса веба /app/board, /app/issues, /app/feed, /app/issues/{key}; ключи localStorage (jira-hub-sidebar, ключи доски); протокол агента /agent/*.
  • Схема базы остаётся v6 (SCHEMA_VERSION = 6), таблицы не переименовываются, файлы в каталоге данных не переносятся.
  • Имя пакета jira_hub, точка входа jira-hub, имя MCP-сервера jira-hub, каталог сборки веба src/jira_hub/web/app/ — не меняются.
  • Ядро (src/jira_hub/core/) не импортирует jira_hub.modules.*, кроме core/registry.py (ленивый импорт внутри функции) и шагов истории миграций в core/db.py (ленивый импорт внутри шага).
  • Новые имена в ядре — без «jira».
  • Тесты меняют только импорты, обращения к перенесённым именам и места подстановки зависимостей (monkeypatch по имени модуля, атрибуты Hub); утверждения (assert) существующих тестов не меняются. Эталоны tests/fixtures/baseline/*.expected.json после Task 1 не меняются.
  • Каждая задача заканчивается зелёным uv run pytest -q (весь набор); задачи, трогающие web/, — ещё make check.
  • Места долга (design, решение 12) помечаются комментарием # долг hub-modules: проекты → модуль jira (change wiki) (в TS — // долг hub-modules: …).
  • Язык комментариев и коммитов — русский; коммит заканчивается строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. Хаб стартует как процесс (jira-hub serve, jira-hub mcp) без циклического импорта — в тестах всё уже импортировано, и цикл виден только в чистом интерпретаторе. → Task 2 (тест test_fresh_imports).
  2. Запись .mcp.json awp-ca (jira-hub mcp --project AWPCA) поднимает тот же сервер; project_create отдаёт фрагмент .mcp.json с прежним путём к репозиторию (HUB_ROOT после переноса файла). → Task 6.
  3. Собранное веб-приложение находится там же (WEB_APP после переноса api.py). → Task 5.
  4. Старая база (v1…v5) мигрирует до v6 после сборки схемы из модулей. → Task 3 (существующие тесты test_db.py + сверка схемы с эталоном).
  5. Отчёт агента по refresh/fetch_attachment/ensure_issue (в том числе поздний — после отмены) даёт те же записи в sync_run, attachment_file, заметках и тот же reconcile. → Task 4 (существующие тесты test_queue.py).

Структура файлов (итог)

src/jira_hub/
  __init__.py
  cli.py                      # тонкая точка входа: from .core.cli import main (pyproject не меняется)
  web/app/                    # сборка (не в git), место прежнее
  core/
    __init__.py
    paths.py backup.py files.py relay.py release.py client.py   # перенос как есть (files.sweep — решение 12)
    errors.py                 # CommandError, HttpMapped
    modules.py                # Module, CommandHooks, Route
    registry.py               # modules(), module_of(), command_types(), check()
    db.py                     # ядро схемы + сборка schema(), история MIGRATIONS
    settings.py               # ключи ядра + объединение с модулями
    projects.py               # метка, название, проверка, список (+ долг: board_id, issues_count)
    queue.py                  # механика пакетов
    http.py                   # ApiError, Raw, общие помощники обработчиков
    api.py                    # Hub, Handler, маршруты ядра, tick
    mcp.py                    # client, guard, scoped, projects/project_create, run()
    cli.py                    # main(): init, serve, tokens, mcp, agent publish + подкоманды модулей
  modules/
    __init__.py
    jira/
      __init__.py             # MODULE = Module(...)
      schema.py               # SQL таблиц модуля
      settings.py             # умолчания, проверка, ключи агента, init_from_config
      commands.py             # перенос (SPEC, validate, substitute, check_confirmed, estimate → pause)
      hooks.py                # CommandHooks модуля: check_batch, tail, deliver_params, on_claim, …
      projects.py             # base_jql, boards, record_board_types, parse_legacy_labels, ensure_legacy, migrate_v3
      mirror.py history.py attachments.py importer.py   # перенос
      fetch.py                # request_fetch, TooLarge, AgentTooOld, FETCH_AGENT_VERSION
      refresh.py              # is_full_refresh, record_state, maybe_auto_refresh (tick)
      api.py                  # маршруты модуля
      mcp.py                  # mcp = FastMCP("jira-hub") и инструменты модуля
      cli.py                  # подкоманда import
web/src/
  modules/index.ts            # export const MODULES: HubModule[] = [jiraModule]
  modules/types.ts            # тип HubModule
  modules/jira/
    index.tsx                 # jiraModule
    board/ issues/ feed/      # перенос
    refresh/                  # refresh.ts, RefreshProvider.tsx, refreshStatus.ts (+ тесты), DataStatus.tsx
    api/queries.ts            # запросы модуля из api/queries.ts

Task 1: Эталоны внешнего поведения

Files:

  • Create: tests/test_baseline.py, tests/fixtures/baseline/{mcp_tools,routes,settings,schema}.expected.json

Interfaces:

  • Produces: тест tests/test_baseline.py, который в каждой следующей задаче меняет только строки импорта.

  • Step 1: Написать тест со сбором снимков

Четыре теста, каждый сравнивает снимок с tests/fixtures/baseline/<имя>.expected.json; при переменной окружения BASELINE_WRITE=1 — пишет эталон (так же, как устроены golden-тесты проекта: tests/fixtures/<name>.expected.*).

  • test_mcp_tools: asyncio.run(mcp_server.mcp.list_tools()) → список {name, description, inputSchema} в порядке регистрации.

  • test_routes: api.ROUTES → список [method, pattern, scope, handler.__name__], отсортированный по (pattern, method) (порядок может смениться при сборке из модулей; набор и сопоставление — нет).

  • test_settings: {"defaults": settings.DEFAULTS, "agent_keys": settings.AGENT_KEYS, "protected": sorted(settings.PROTECTED)}.

  • test_schema: свежая база в tmp_path через db.connect → SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name и schema_version.

  • Step 2: Записать эталоны и проверить

Run: BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && uv run pytest tests/test_baseline.py -q Expected: 4 passed (второй прогон — сравнение с записанным).

  • Step 3: Весь набор зелёный, коммит

Run: uv run pytest -q — Expected: всё passed.

git add tests/test_baseline.py tests/fixtures/baseline
git commit -m "test: эталоны внешнего поведения перед перестройкой (#87)"

Task 2: Пакеты core и modules.jira — перенос файлов без логики

Files:

  • Move (git mv): paths.py backup.py files.py relay.py release.py client.py → src/jira_hub/core/; mirror.py history.py attachments.py commands.py importer.py → src/jira_hub/modules/jira/; db.py settings.py projects.py queue.py api.py mcp_server.py cli.py → src/jira_hub/core/ (временно целиком — разделяются в Task 3–7).
  • Create: src/jira_hub/core/__init__.py, src/jira_hub/modules/__init__.py, src/jira_hub/modules/jira/__init__.py (пустые), src/jira_hub/cli.py (from .core.cli import main), tests/test_layout.py.
  • Modify: импорты во всём src/, tests/, web/scripts/seed-hub.py; api.WEB_APP, mcp_server.HUB_ROOT — путь от нового места файла.

Interfaces:

  • Produces: модули по новым путям (jira_hub.core.queue, jira_hub.modules.jira.mirror …). pyproject.toml (jira-hub = "jira_hub.cli:main") не меняется.

  • Step 1: Тест на чистый импорт и пути

tests/test_layout.py:

  • test_fresh_imports: subprocess.run([sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"]) → returncode == 0 (в Task 5–6 имя core.mcp_server заменится на core.mcp и modules.jira.mcp).

  • test_web_app_path: api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app".

  • test_hub_root: mcp_server.HUB_ROOT — каталог, где лежит pyproject.toml проекта.

  • Step 2: Убедиться, что тест падает — Run: uv run pytest tests/test_layout.py -q — Expected: FAIL (нет jira_hub.core).

  • Step 3: Перенести файлы git mv, поправить импорты

Относительные импорты: из core/* к соседям — from . import …, к модулю jira — from ..modules.jira import … (это временно, такие импорты уберут Task 3–7). Из modules/jira/* — from ...core import db, settings, …. Поиск остатков: grep -rn "jira_hub\.\(db\|queue\|mirror\|api\|settings\|projects\|history\|files\|commands\|attachments\|mcp_server\|relay\|release\|client\|paths\|backup\|importer\)\b" src tests web/scripts — пусто. monkeypatch.setattr("jira_hub.X…") в тестах — на новые пути.

  • Step 4: Весь набор — Run: uv run pytest -q — Expected: всё passed, включая test_baseline.py и test_layout.py.

  • Step 5: Коммит — git commit -m "refactor: пакеты core и modules.jira — перенос без логики (#87)"


Task 3: Контракт модуля, реестр, схема и настройки

Files:

  • Create: core/errors.py, core/modules.py, core/registry.py, modules/jira/schema.py, modules/jira/settings.py, modules/jira/projects.py, tests/test_registry.py
  • Modify: core/db.py, core/settings.py, core/projects.py, modules/jira/__init__.py, modules/jira/commands.py (CommandError — из core.errors), core/cli.py (init), tests/test_baseline.py (импорты)

Interfaces:

  • Produces (core/errors.py):

    class CommandError(ValueError): ...            # прежний commands.CommandError; commands реэкспортирует его
    class HttpMapped(Exception):                   # примесь: исключение модуля со своим кодом HTTP
        status: int = 500
        error: str = "internal"
    
  • Produces (core/modules.py) — dataclass'ы frozen=True; поля, не нужные модулю, — по умолчанию:

    Route = tuple[str, str, Callable, str | None]          # method, regex, handler(hub, conn, m, q, body), scope
    
    @dataclass(frozen=True)
    class CommandHooks:
        types: frozenset[str]
        read_types: frozenset[str]                          # не пишут во внешнюю систему: без хвоста и копии базы
        validate: Callable[[list], list[dict]]              # CommandError
        check_batch: Callable[[Connection, list[dict], str | None, Path | None], list[dict]]  # project, files_dir
        tail: Callable[[list[dict]], dict | None]           # хвостовая команда пишущего пакета
        pause: Callable[[Connection, str], int]             # пауза типа, с
        deliver_params: Callable[[Connection, dict, Path | None], dict]
        temp_definer: tuple[str, str, str] | None           # (тип, поле params с временным ключом, поле result с ключом)
        temp_keys_used: Callable[[dict], list[str]]
        substitute: Callable[[str, dict, dict], dict]
        drop_unresolved: Callable[[dict, list[str]], list[str]]   # поправить params команды, вернуть неразрешённые
        retry_params: Callable[[dict], dict]
        check_confirmed: Callable[[Connection, dict], tuple[bool, dict | None]]
        on_claim: Callable[[Connection, dict, str, bool], None]       # cmd, now, redelivery
        on_failed: Callable[[Connection, dict, str], None]            # исчерпаны выдачи
        check_result: Callable[[Connection, dict, str, dict | None, Path | None], tuple[str, str | None]]
        on_result: Callable[[Connection, dict, str, dict | None, int, int, str, Path | None], None]  # в транзакции
        after_result: Callable[[Connection, dict, str], None]         # вне транзакции
        on_late_result: Callable[[Connection, dict, str, dict | None, int, int], bool]
    
    @dataclass(frozen=True)
    class Module:
        name: str
        schema: str = ""
        settings_defaults: dict = field(default_factory=dict)
        agent_keys: tuple[str, ...] = ()
        validate_setting: Callable[[str, object], bool] | None = None   # True — ключ модуля проверен (ValueError — плохо)
        settings_view: Callable[[Connection], dict] | None = None       # вычисляемые поля GET/PUT /settings
        on_init: Callable[[Connection, dict, bool], list[str]] | None = None   # init --config: строки для вывода
        commands: CommandHooks | None = None
        routes: tuple[Route, ...] = ()
        tick: tuple[Callable[[Any, Connection], None], ...] = ()        # (hub, conn)
        file_refs: Callable[[Connection], set[str]] | None = None       # sha файлов, нужных командам
        on_project_created: Callable[[Connection, dict, str], dict | None] | None = None   # project, author → пакет
        mcp_server: Callable[[], Any] | None = None                     # FastMCP модуля
        cli_register: Callable[[Any], None] | None = None               # subparsers
        cli_run: Callable[[Any], int | None] | None = None              # None — не своя подкоманда
    
  • Produces (core/registry.py): _cache: list[Module] | None = None; modules() -> list[Module] (при _cache is None — импорт modules.jira внутри функции и check, результат в _cache; тесты подменяют _cache); module(name) -> Module | None; module_of(ctype: str) -> Module | None; check(mods: list[Module], core_routes, core_settings: dict) -> None — RuntimeError с текстом конфликта: тип команды «тип refresh: модули jira и x», ключ настроек, маршрут (метод + шаблон).

  • Produces (core/db.py): CORE_SCHEMA (setting, project, batch, command, agent_status); schema() -> str = CORE_SCHEMA + schema модулей по порядку; connect/migrate используют schema(). MIGRATIONS — на месте; шаги, зовущие код модуля, импортируют его внутри шага. jira_utc и _fill_comment_utc — в modules/jira/mirror.py (или history.py, где используется), шаг v4→v5 зовёт их лениво.

  • Produces (core/settings.py): CORE_DEFAULTS (backup_keep, min_agent_version, port, settings_version, initialized, relay_dir), defaults() -> dict (ядро + модули), agent_keys() -> list[str]; validate — сначала ключи ядра, затем validate_setting модулей, затем проверка по типу умолчания из defaults(); get_all, ensure_defaults, for_agent — по объединённому набору. PROTECTED — в ядре. Констант DEFAULTS/AGENT_KEYS в ядре больше нет (их значение зависит от модулей, а импорт модулей при импорте ядра запрещён): тесты и эталон зовут defaults() и agent_keys() — это смена обращения, утверждения и эталон те же.

  • Produces (modules/jira/settings.py): DEFAULTS (остальные ключи, включая pacing, auto_refresh_minutes, project_key, closed_statuses), AGENT_KEYS, DEFAULT_PACING, pacing(conn), validate_setting (base_jql, board_id — отказ с прежними текстами, pacing, auto_refresh_minutes), settings_view(conn) -> {"base_jql": …}, on_init(conn, cfg, force) -> list[str] — прежние settings.init_from_config + projects.ensure_legacy и строки вывода (перенесено: …, проект AWPCA «АРМ КА», доска N).

  • Produces (modules/jira/projects.py): base_jql, boards, record_board_types, parse_legacy_labels, ensure_legacy, migrate_v3, LEGACY_LABEL, LEGACY_NAME. В core/projects.py остаются validate, get, create, list_projects, light_list, labels, ProjectError, ProjectExists; board_id/board_type/ issues_count в них — с меткой долга.

  • modules/jira/__init__.py: MODULE = Module(name="jira", schema=schema.SQL, settings_defaults=settings.DEFAULTS, agent_keys=tuple(settings.AGENT_KEYS), validate_setting=…, settings_view=…, on_init=…) — остальные поля добавляют Task 4–6.

  • Step 1: Тесты реестра — tests/test_registry.py:

    • test_jira_registered: [m.name for m in registry.modules()] == ["jira"].
    • test_command_type_conflict: два Module с CommandHooks(types=frozenset({"refresh"}), …) → check бросает RuntimeError, в тексте refresh, jira, имя второго модуля.
    • test_settings_conflict: модуль с settings_defaults={"port": 1} → RuntimeError с port.
    • test_route_conflict: модуль с маршрутом ("GET", r"^/projects$", h, "client") → RuntimeError с /projects.
    • test_core_imports: AST всех src/jira_hub/core/*.py, кроме registry.py и db.py, — нет импортов modules/jira_hub.modules; в registry.py и db.py такие импорты только внутри функций.
  • Step 2: Тесты падают — Run: uv run pytest tests/test_registry.py -q — Expected: FAIL (нет registry).

  • Step 3: Реализовать errors, modules, registry, разделить db, settings, projects; init в core/cli.py зовёт on_init модулей и печатает их строки в прежнем порядке, затем токены. Эталон схемы требует тот же набор таблиц и тот же SQL: порядок CREATE внутри sqlite_master сравнивается по имени, поэтому порядок сборки на эталон не влияет, а текст SQL таблиц переносится символ в символ.

  • Step 4: Весь набор — Run: uv run pytest -q — Expected: всё passed (test_db.py — миграции v1…v5, test_settings.py, test_projects.py, test_cli.py, эталоны).

  • Step 5: Коммит — git commit -m "refactor: контракт модуля, реестр, схема и настройки по модулям (#87)"


Task 4: Очередь — механика в ядре, смысл типов в модуле

Files:

  • Create: modules/jira/hooks.py, modules/jira/fetch.py, modules/jira/refresh.py, tests/test_queue_core.py
  • Modify: core/queue.py, core/files.py (sweep берёт ссылки из file_refs модулей), modules/jira/commands.py (estimate уходит, пауза — hooks.pause), modules/jira/__init__.py (commands=hooks.HOOKS, file_refs=hooks.file_refs), tests/test_queue.py, tests/test_files.py (импорты: request_fetch, TooLarge, AgentTooOld, maybe_auto_refresh, is_full_refresh, record_state — из модуля jira)

Interfaces:

  • Consumes: CommandHooks, registry.module_of, registry.modules (Task 3).

  • Produces (core/queue.py, публичное): create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dict (прежняя сигнатура), insert_batch(conn, title, author, cmds, project=None) -> dict (бывший _insert_batch), claim, report_result, reconcile, cancel_batch, cancel_command, retry_command, batch_view, list_batches, get_command, agent_view, deliver_params(conn, cmd, files_dir) -> dict (делегирует модулю), agent_version(conn) -> str | None, has_active(conn) -> bool (есть queued/claimed), estimate(conn, types) -> int (сумма pause модулей), TERMINAL, MAX_DELIVERIES, ERROR_LIMIT, ONLINE_MINUTES, ClaimedError, NotClaimedError.

  • Produces (modules/jira/fetch.py): TooLarge(CommandError, HttpMapped) с status=413, error="too_large"; AgentTooOld(ValueError, HttpMapped) с status=409, error="agent_too_old"; FETCH_AGENT_VERSION = 14; request_fetch(conn, files_dir, attachment_id, author) -> dict (прежнее поведение, через queue.insert_batch, queue.agent_version).

  • Produces (modules/jira/refresh.py): is_full_refresh(conn, command_id) -> bool, record_state(conn, command_id, issues_count, links_count), maybe_auto_refresh(conn) -> dict | None (через queue.agent_view, queue.has_active, queue.create_batch), auto_refresh_tick(hub, conn) -> None.

  • Правила ядра:

    • Модуль пакета: модули известных типов команд; больше одного — CommandError("команды разных модулей в одном пакете"); ни одного — validate первого модуля реестра (он выдаёт прежнюю ошибку о неизвестном типе).
    • create_batch: validate → check_batch(conn, cmds, project, files_dir) (внутри модуля прежний порядок: _check_project, если project задан, затем _check_setup, затем _assign_boards) → tail(cmds) → insert_batch.
    • «Пакет с записями» в claim — есть команда, чей тип не в read_types своего модуля (SQL с типами из реестра, а не литералами).
    • Временные ключи: _mapping/_definer_status строятся по temp_definer модуля; _next зовёт temp_keys_used, substitute, drop_unresolved (у jira: refresh выбрасывает временные ключи из keys, если остались ключи — неразрешённых нет).
    • report_result: терминальная команда → on_late_result; иначе check_result (может сменить статус на failed и ошибку) → запись команды и agent_status → on_result (в той же транзакции) → после транзакции after_result (jira: reconcile после refresh done).
    • files.sweep(conn, root, now=None) — защищённые файлы = объединение file_refs(conn) модулей.
    • retry_command берёт params через retry_params (jira: без board_id у ensure_sprint).
  • Step 1: Тест ядра очереди на модуле-заглушке — tests/test_queue_core.py: модуль "t" с типами {"w", "r"} (r — read_types), pause = 7, tail возвращает {"type": "r", "params": {}} для пакета с w; реестр подменяется через monkeypatch.setattr(registry, "_cache", [jira.MODULE, stub]).

    • test_tail_and_estimate: пакет [w] → две команды w, r; estimate_seconds == 14.
    • test_mixed_modules: пакет [w, refresh] → CommandError, текст «команды разных модулей в одном пакете».
    • test_on_result_called: claim → report_result(done) → заглушка on_result получила status == "done", after_result вызван после фиксации (видит команду done в новом соединении).
    • test_sweep_uses_file_refs: файл старше 30 дней, file_refs заглушки возвращает его sha → не удалён.
  • Step 2: Тесты падают — Run: uv run pytest tests/test_queue_core.py -q — Expected: FAIL.

  • Step 3: Перенести логику типов в modules/jira/hooks.py, fetch.py, refresh.py; core/queue.py — без 'refresh', 'ensure_*', 'fetch_attachment', sync_run, issue_attachment, attachment_file; без импортов mirror, history, attachments, projects-модуля. Тексты ошибок и порядок проверок — прежние (их проверяет test_queue.py).

  • Step 4: Весь набор — Run: uv run pytest -q — Expected: всё passed (99 тестов test_queue.py без изменения утверждений).

  • Step 5: Коммит — git commit -m "refactor: очередь ядра и обработчики команд модуля jira (#87)"


Task 5: HTTP — сервер ядра, маршруты модуля

Files:

  • Create: core/http.py, modules/jira/api.py
  • Modify: core/api.py, modules/jira/__init__.py (routes, tick, on_project_created), tests/test_api.py, tests/test_web_app.py, tests/test_baseline.py, tests/test_layout.py (импорты; hub.downloads_dir — см. ниже)

Interfaces:

  • Produces (core/http.py): ApiError(status, error, detail=None), Raw(content_type, data, headers=None), int_group(m, i=1) -> int (бывший _int), int_param(q, name, default) -> int, not_found(obj, what), project_param(conn, q) -> str | None, mapped(e: HttpMapped) -> ApiError.

  • Produces (core/api.py): Hub (без downloads_dir; новое hub.module_state: dict[str, dict] — память модулей между тиками), CORE_ROUTES, ROUTES (вычисляется при импорте: CORE_ROUTES + routes модулей, после registry.check), make_server, start, serve, WEB_APP (путь прежний — src/jira_hub/web/app). Hub.tick: sweep_files ядра, затем tick модулей; сбой каждой работы логируется и не мешает остальным. Обработчики ядра, ловившие queue.TooLarge/AgentTooOld, ловят HttpMapped раньше CommandError. GET/PUT /settings дописывают settings_view модулей. POST /projects: в одной транзакции projects.create и on_project_created модулей; batch_id ответа — из первого вернувшего пакет.

  • Produces (modules/jira/api.py): обработчики и ROUTES модуля: /agent/state, /agent/history, /agent/known-keys, /issues, /issues/{key}, /issues/{key}/note, /board, /changes, /attachments/… (три), /refresh, /sync-runs. downloads_tick(hub, conn) — уборка downloads/ (<каталог базы>/downloads) раз в сутки, время прошлой — hub.module_state["jira"]["downloads_at"]; on_project_created(conn, project, author) — пакет «Новый проект <метка>».

  • Маршруты ядра — все остальные из нынешней ROUTES (design, решение 6).

  • Step 1: Тест сборки маршрутов — в tests/test_registry.py: test_routes_assembled: {(m, p) for m, p, _, _ in api.ROUTES} == {(m, p) for m, p, _, _ in api.CORE_ROUTES} | {(m, p) for m, p, _, _ in jira.MODULE.routes} и пересечения нет. Эталон routes.expected.json — тот же (в test_baseline.py меняется только импорт).

  • Step 2: Тест падает — Run: uv run pytest tests/test_registry.py::test_routes_assembled -q — Expected: FAIL.

  • Step 3: Разделить core/api.py; тесты, читавшие hub.downloads_dir, берут путь hub.db_path.parent / "downloads".

  • Step 4: Весь набор — Run: uv run pytest -q — Expected: всё passed (test_api.py, test_web_app.py, test_e2e_hub.py, test_layout.py::test_web_app_path, эталоны).

  • Step 5: Коммит — git commit -m "refactor: HTTP — сервер ядра, маршруты модуля jira (#87)"


Task 6: MCP и CLI по модулям

Files:

  • Create: core/mcp.py, modules/jira/mcp.py (из core/mcp_server.py), modules/jira/cli.py
  • Delete: core/mcp_server.py
  • Modify: core/cli.py, modules/jira/__init__.py (mcp_server, cli_register, cli_run), tests/test_mcp.py, tests/test_cli.py, tests/test_baseline.py, tests/test_layout.py (импорты)

Interfaces:

  • Produces (core/mcp.py): HUB_ROOT (каталог с pyproject.toml, как сейчас), mcp_json(label) -> dict, unknown_project, guard, scoped, client(), bound_project, fmt_projects, функции инструментов projects(), project_create(label, name, board_id=None) (долг: board_id), register_core_tools(mcp) -> None, run(module_name: str, project: str | None) -> int — неизвестный модуль: печать в stderr неизвестный модуль <имя>; модули: jira, код 2, сервер не запускается.

  • Produces (modules/jira/mcp.py): mcp = FastMCP("jira-hub"), инструменты модуля на нём в прежнем порядке, затем register_core_tools(mcp) — projects, project_create последними, как сейчас.

  • CLI: jira-hub mcp [module] [--project <метка>] — позиционный module с nargs="?", default="jira", без choices (проверка в run, текст из спеки). import — подкоманда модуля (cli_register/cli_run). agent publish, init, serve, tokens — ядро.

  • Step 1: Тесты — в tests/test_cli.py:

    • test_mcp_default_module: main(["mcp", "--project", "AWPCA"]) и main(["mcp", "jira", "--project", "AWPCA"]) при подменённом mcp.run (как в test_mcp.py) — оба запускают объект modules.jira.mcp.mcp, привязка — AWPCA.
    • test_mcp_unknown_module: main(["mcp", "nope"]) == 2, в stderr неизвестный модуль nope и jira.
  • Step 2: Тесты падают — Run: uv run pytest tests/test_cli.py -q — Expected: FAIL.

  • Step 3: Разделить MCP и CLI.

  • Step 4: Весь набор — Run: uv run pytest -q — Expected: всё passed (эталон mcp_tools.expected.json тот же, test_layout.py::test_hub_root, test_fresh_imports с jira_hub.core.mcp, jira_hub.modules.jira.mcp).

  • Step 5: Ручная проверка запуска — Run: printf '' | timeout 5 uv run jira-hub mcp nope; echo $? — Expected: строка неизвестный модуль nope; модули: jira и 2.

  • Step 6: Коммит — git commit -m "refactor: MCP и CLI по модулям; jira-hub mcp [модуль] (#87)"


Task 7: Веб — модуль jira и сборка оболочки из модулей

Files:

  • Move (git mv): web/src/{board,issues,feed} → web/src/modules/jira/; web/src/shell/{refresh.ts,refresh.test.tsx,RefreshProvider.tsx,refreshStatus.ts,refreshStatus.test.ts,DataStatus.tsx} → web/src/modules/jira/refresh/
  • Create: web/src/modules/types.ts, web/src/modules/index.ts, web/src/modules/jira/index.tsx, web/src/modules/jira/api/queries.ts, web/src/modules/jira/issues/overlay.tsx, web/src/modules/modules.test.tsx
  • Modify: web/src/App.tsx, web/src/shell/{Layout,Sidebar,pages}.tsx, web/src/api/queries.ts (остаются запросы ядра: проекты, настройки, пакеты), web/src/projects/NewProjectDialog.tsx (метка долга у поля доски), web/scripts/{test-hub.sh,golden-from-links.ts}, web/e2e/board.spec.ts, web/src/test/* (пути)

Interfaces:

  • Produces (web/src/modules/types.ts):

    export type NavItem = { to: string; label: string; icon: React.ReactNode };
    export type Overlay = { pageLocation: Location | null; element: React.ReactNode };   // null — окна нет
    export type HubModule = {
      name: string;
      pages: RouteObject[];                        // без "*" — NotFound добавляет оболочка последним
      nav: NavItem[];
      Provider?: React.ComponentType<{ children: React.ReactNode }>;
      SidebarStatus?: React.ComponentType;         // плашка данных и кнопка обновления в боковой панели
      useOverlay?: () => Overlay;                  // окно поверх страницы (карточка задачи)
      useOnProjectCreated?: () => (batchId: number) => void;
    };
    
  • Produces (web/src/modules/jira/index.tsx): jiraModule: HubModule — pages: / → /board, /board, /issues, /feed (как в нынешнем shell/pages.tsx); nav: «Доска связей», «Задачи», «Лента» с прежними значками; Provider: RefreshProvider; SidebarStatus: DataStatus + кнопка обновления с событием пакета (то, что сейчас рисует Sidebar через useJiraRefresh/refreshText); useOverlay: логика окна карточки из Layout.tsx (issueKey, cardState, bgLocation, close, abortClose, CloseAbortContext, IssueModal); useOnProjectCreated: refresh.track(batchId).

  • Оболочка: App.tsx вкладывает Provider всех модулей; pages.tsx — PAGE_ROUTES = страницы модулей + NotFound; Sidebar.tsx — nav модулей (тип Section становится string), SidebarStatus модулей; Layout.tsx — первый useOverlay, вернувший pageLocation, определяет адрес страницы и окно. Хуки модулей вызываются в постоянном порядке (список модулей — константа).

  • Step 1: Тест сборки — web/src/modules/modules.test.tsx:

    • пути PAGE_ROUTES == ["/", "/board", "/issues", "/feed", "*"];
    • пункты панели (подписи, адреса) == «Доска связей» /board, «Задачи» /issues, «Лента» /feed;
    • имена модулей уникальны.
  • Step 2: Тест падает — Run: cd web && npx vitest run src/modules/modules.test.tsx — Expected: FAIL.

  • Step 3: Перенести папки git mv, вынести описание модуля, переписать оболочку. Поиск остатков: grep -rn "\.\./board\|\.\./issues\|\.\./feed\|shell/refresh\|shell/DataStatus\|src/board" web/src web/scripts web/e2e — только новые пути.

  • Step 4: Проверки — Run: make check — Expected: pytest, lint, vitest — всё passed (golden доски — без изменения эталонов, Sidebar.test.tsx, IssueModal.test.tsx, projects.test.tsx — только импорты).

  • Step 5: e2e — Run: cd web && npm run e2e — Expected: всё passed.

  • Step 6: Коммит — git commit -m "refactor(web): модуль jira и оболочка из списка модулей (#87)"


Task 8: Граница ядра, метки долга, документация

Files:

  • Modify: tests/test_registry.py, места долга в core/projects.py, core/mcp.py, core/api.py (если issues_count/board_id проходят через него), web/src/projects/NewProjectDialog.tsx; ARCHITECTURE.md, CLAUDE.md, README.md (пути модулей, команда jira-hub mcp [модуль])

Interfaces:

  • Consumes: всё выше.

  • Step 1: Тест границы — test_core_has_no_jira_terms в tests/test_registry.py: в файлах src/jira_hub/core/*.py, кроме db.py (история MIGRATIONS), строки с любым из 'refresh', "refresh", ensure_, fetch_attachment, sync_run, issue_attachment, attachment_file, issue_comment, jql допустимы только если в той же или предыдущей строке есть долг hub-modules. Падение показывает файл и строку.

  • Step 2: Прогнать — Run: uv run pytest tests/test_registry.py -q — Expected: PASS; если FAIL — перенести найденное в модуль (не помечать долгом ничего, кроме перечисленного в design, решение 12).

  • Step 3: Метки долга на месте — Run: grep -rn "долг hub-modules" src web/src — Expected: места из design (решение 12): core/projects.py (board_id/board_type, проверка board_id, issues_count), core/mcp.py (project_create.board_id, fmt_projects), NewProjectDialog.tsx (поле доски).

  • Step 4: Документация — ARCHITECTURE.md: таблица модулей ядра и модуля jira с новыми путями, контракт Module/CommandHooks, раздел «Границы» (ядро не импортирует модули; типы команд — в модулях; долг проектов), цепочки зависимостей; веб — web/src/modules/. CLAUDE.md: раздел «Архитектура» и команды (jira-hub mcp [модуль] [--project]). Переименование в sdlc-hub — строка в «Открытых вопросах» ARCHITECTURE.md.

  • Step 5: Полная проверка — Run: make check && (cd web && npm run e2e) — Expected: всё passed.

  • Step 6: Коммит — git commit -m "docs: ядро и модули хаба; тест границы ядра (#87)"