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