sdlc-hub/docs/superpowers/plans/2026-10-23-hub-modules.md
Andrey Epifancev 05352ff5e5 docs(plan): план hub-modules (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:38:04 +04:00

490 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Ядро и модуль 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.
```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()` (меняется только вызов в `test_baseline.py`, эталон тот же).
- 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)"`