sdlc-hub/docs/superpowers/plans/2026-10-24-wiki-module.md
Andrey Epifancev f44993c73f docs(plan): план wiki-module (#91)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:32:29 +04:00

604 lines
54 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.

# Модуль wiki, виды агентов и поля проектов — план реализации
> **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:** проект хаба связывается с корнем Eva Wiki; хаб держит Markdown-копию дерева, отдаёт её через MCP `jira-hub-wiki` и страницу «Вики»; ядро различает агентов (jira, wiki) и больше не знает про доски Jira.
**Architecture:** ядро получает агентов по модулям (токен → модуль, `agent_status`/релиз/`claim` по агенту) и поля проекта от модулей (`ProjectField`, `project_view`, `on_project_created/updated`). Модуль `jira_hub/modules/wiki/` — по образцу модуля jira: схема, настройки, хуки команд `wiki_sync`/`wiki_fetch`, приём пачек страниц, перевод HTML → Markdown, сборка копии с атомарной подменой каталога, FTS5-поиск, маршруты, MCP, фоновая синхронизация. Веб — модуль `web/src/modules/wiki/`. Миграция схемы 6 → 7 одним шагом.
**Tech Stack:** Python 3.13 (uv), stdlib `http.server`, SQLite (FTS5), `mcp<2` (FastMCP), `markdownify`; React 19 + TS + Vite, TanStack Query, `react-markdown` + `remark-gfm`, Vitest, Playwright.
**Spec:** `openspec/changes/wiki-module/` — `proposal.md`, `design.md` (решения 1–16, «Decisions made without discussion» 1–17), `specs/{modules,data-model,hub-api,wiki,mcp,web-app}/spec.md`. Спека — источник истины; план — её аргумент.
## Global Constraints
- jira-agent v14 работает без обновления: прежний агентский токен, `POST /agent/claim` (тело с `last_jira_call_at`), ответ `claim`, `GET /agent/settings` (те же ключи и значения, кроме удалённого `board_id`), `/agent/state`, `/agent/history`, `/agent/known-keys`, `/agent/files*`, `/agent/release`.
- `tests/fixtures/baseline/mcp_tools.expected.json` (сервер `jira-hub`) НЕ меняется ни в одной задаче. Эталоны `routes`, `settings`, `schema` переписываются (`BASELINE_WRITE=1`) только в задачах, где это сказано, и в отчёте задачи перечисляется каждая изменившаяся строка эталона.
- Схема — `SCHEMA_VERSION = 7`, один шаг `MIGRATIONS[6]`; копия базы перед миграцией — прежний механизм `db.migrate`.
- Адреса веба, ключи `localStorage`, адреса и ответы API модуля jira — прежние; `GET /agent` без параметра — агент `jira`.
- Новые имена в ядре — без «jira» и без «wiki»; ядро не импортирует модули, кроме `core/registry.py` и шагов `MIGRATIONS` (`tests/test_registry.py`).
- Токен Eva в хабе не появляется нигде (ни в настройках, ни в логах, ни в тестах).
- Тестовые HTML-страницы Eva — синтетические, с форматами адресов из design.md (Context); текст ТЗ из awp-ca не копируется.
- Живой хаб не трогать: ни launchd, ни `~/.local/share/jira-hub`; тесты — во временном `JIRA_HUB_HOME`/`tmp_path`. Любой запуск CLI в тестах и вручную — с `JIRA_HUB_HOME` во временном каталоге.
- Новые зависимости: Python — `markdownify` (последняя стабильная, нижняя граница в `pyproject.toml`); веб — `react-markdown`, `remark-gfm` (точные версии, как остальные в `package.json`).
- Коммиты — по-русски, последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Каждая задача заканчивается зелёным `uv run pytest -q` (и `make check`, если задача трогает `web/`).
- Каждое решение исполнителя, которого нет в спеке и плане, — в отчёт задачи (контроллер переносит в design.md «Decisions made without discussion»).
## Review Focus
1. Хаб v6 с живыми данными (токен, `agent_status` id=1 v14, доски AWPCA 4216 scrum, публикация `release/agent.py` v14) после обновления: jira-agent с прежним токеном получает команды и релиз v14 — тест `test_v6_upgrade_keeps_jira_agent` (Task 1, дополняется в Task 2 и Task 3).
2. Синхронизация вики идёт часами (агент не установлен) — jira-команды выдаются, автообновление jira не блокируется: `test_wiki_queue_does_not_block_jira` (Task 2), `test_auto_refresh_ignores_wiki_batches` (Task 5).
3. HTML Eva с повторяющимися ссылками, миниатюрами файлов, значками `static/`, относительными `project/Document/DOC-…`, таблицами — копия без сырого HTML, одна ссылка на файл: тесты `tests/test_wiki_convert.py` (Task 6).
4. Сбой посреди подмены каталога (хаб упал после переименования старой копии) — при старте копия доводится, поиск и страницы сходятся: `test_finish_pending_swap` (Task 7).
5. Два проекта с одним корнем или пересекающимися деревьями; название страницы с `/`, `..`, эмодзи, 200 символов, совпадающие названия соседей — пути внутри `wiki/<метка>/`, без выхода наружу: `test_layout_names_safe` (Task 7).
---
## Целевая раскладка
```
src/jira_hub/core/
modules.py + ProjectField; Module: project_fields, project_view, on_project_created(conn, project, fields, author),
on_project_updated, agent_min_version_key, mcp_project_columns
registry.py + agent_modules(); check(): поля проекта
settings.py tokens → {client, agents}; agent_token(), agent_keys(module), for_agent(conn, module); min_agent_version → jira
http.py + AGENT, agent_of(q)
db.py SCHEMA_VERSION 7, MIGRATIONS[6], agent_status по агенту
queue.py claim(conn, agent, …), agent_version(conn, agent), agent_view(conn, agent), has_active(conn, module=None)
release.py + module_dir(), migrate_layout()
projects.py только label/name/created_at
api.py Hub.agent_update по агентам, Hub.release_root, /agent?module, /agents, POST/PATCH /projects из полей модулей, do_PATCH
mcp.py register_core_tools(mcp, *, projects_doc, columns, project_create), create_project(), mcp_json(label, module)
cli.py tokens по агентам, agent publish --module
src/jira_hub/modules/jira/
schema.py + jira_project; projects.py: доски из jira_project, migrate_v6 (перенос)
project_fields.py поле board_id, project_view (board_id, board_type, issues_count)
src/jira_hub/modules/wiki/
__init__.py schema.py settings.py paths.py projects.py commands.py convert.py stage.py sync.py
search.py attachments.py api.py mcp.py tick.py
web/src/modules/wiki/ index.tsx, api/queries.ts, WikiPage.tsx, Tree.tsx, PageView.tsx, Search.tsx, RootForm.tsx, SyncStatus.tsx
web/src/projects/NewProjectDialog.tsx поля из MODULES[].projectFields
tests/fixtures/schema_v6.json копия baseline/schema.expected.json до Task 1
tests/v6db.py make_v6_db(path)
```
---
### Task 1: Миграция v7 — токены и статус по агентам
**Files:**
- Create: `tests/fixtures/schema_v6.json` (копия `tests/fixtures/baseline/schema.expected.json` на коммите до правок), `tests/v6db.py`, `tests/test_agents.py`
- Modify: `src/jira_hub/core/{db.py,settings.py,registry.py,modules.py,http.py,api.py,cli.py,queue.py}`, `src/jira_hub/modules/jira/{settings.py,api.py,__init__.py}`, `tests/fixtures/baseline/{schema,settings,routes}.expected.json` (переписать), затронутые тесты (`test_settings.py`, `test_api.py`, `test_cli.py`, `test_db.py` — обращения к `tokens["agent"]`, `agent_status id=1`)
**Interfaces:**
- Produces:
- `Module.agent_min_version_key: str | None = None` (у jira — `"min_agent_version"`).
- `registry.agent_modules() -> list[Module]` — модули с `commands`, в порядке реестра.
- `settings.tokens(conn) -> {"client": str, "agents": {module: str}}`; `settings.agent_token(conn, module) -> str`; `settings.agent_keys(module: str) -> list[str]` (ключи модуля, затем `CORE_AGENT_KEYS = ["settings_version"]`); `settings.for_agent(conn, module) -> dict`.
- `http.AGENT = "_agent"`; `http.agent_of(q) -> str` — имя модуля агента запроса (диспетчер кладёт в `query[AGENT]` для scope `agent`, перезаписывая значение из адреса).
- Таблица `agent_status(agent TEXT PRIMARY KEY, last_seen_at, agent_version, settings_version, current_command_id, last_call_at)`; строки для `registry.agent_modules()` вставляет `db.migrate`.
- `tests/v6db.py: make_v6_db(path, *, agent_token="T-agent", client_token="T-client") -> None` — база v6 из `schema_v6.json`: `schema_version = 6`, `tokens = {agent, client}`, `agent_status` id=1 `agent_version = "14"`, проекты `AWPCA` (4216, `scrum`) и `sfm_kedr` (`NULL`, `NULL`), умолчания настроек v6.
- [ ] **Step 1: Скопировать эталон схемы v6 в `tests/fixtures/schema_v6.json`, написать `tests/v6db.py`.**
- [ ] **Step 2: Фикстуры второго модуля в `tests/conftest.py`.** `stub_module` — `Module(name="stub", commands=CommandHooks(types={"stub_cmd"}, read_types={"stub_cmd"}, …заглушки…), agent_keys=("stub_key",), settings_defaults={"stub_key": 1}, routes=(("POST", r"^/agent/stub$", h, "agent"),))`, подключается подменой `registry._cache` (`monkeypatch`). На её основе — `conn_two_modules` и `hub_server_two_modules`. Ими пользуются Task 1–3.
- [ ] **Step 2a: Падающие тесты в `tests/test_agents.py`:**
```python
def test_v6_upgrade_keeps_jira_agent(tmp_path):
make_v6_db(tmp_path / "hub.sqlite")
conn = db.connect(tmp_path / "hub.sqlite")
assert db._schema_version(conn) == 7
tok = settings.tokens(conn)
assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok
row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone()
assert row["agent_version"] == "14"
assert list((tmp_path / "backups").glob("*schema-v6*")) # копия перед миграцией
def test_agent_token_routes(hub_server): # hub_server — фикстура теста API (порт 0), как в test_api.py
# токен jira: /agent/claim 200, /agent/state 200; клиентский на /agent/claim — 401
def test_module_agent_route_requires_own_module(hub_server_two_modules):
# заглушка второго модуля с commands (types={"stub_cmd"}) и маршрутом ("POST", r"^/agent/stub$", h, "agent"):
# его токеном POST /agent/state — 401; токеном jira POST /agent/stub — 401; своим — 200
def test_agent_settings_per_module(hub_server_two_modules):
# токен jira: ключи jira + settings_version, без ключей заглушки и без board_id; токен заглушки — её ключи + settings_version
def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys):
# JIRA_HUB_HOME=tmp_path; `jira-hub tokens` → строки «клиентский токен: …», «агент jira: …»
```
- [ ] **Step 3: Запустить — FAIL** (`uv run pytest tests/test_agents.py -q`).
- [ ] **Step 4: Реализовать.**
- `db.py`: `SCHEMA_VERSION = 7`; в `CORE_SCHEMA` новая `agent_status`; `MIGRATIONS[6] = [_tokens_v7, _agent_status_v7]`:
- `_tokens_v7`: `tokens {agent, client}` → `{client, agents: {jira: agent}}`;
- `_agent_status_v7`: пересоздание таблицы (CREATE `agent_status_new`, INSERT … SELECT `'jira'`, `last_jira_call_at` → `last_call_at`, DROP, RENAME).
- Шаги ядра не импортируют модули: имя `jira` в шаге миграции — данные истории (как `_migrate_projects`), допустимо по границе (исключение `core/db.py` в `test_core_has_no_jira_terms` уже есть).
- В конце `migrate` вместо `INSERT … (id) VALUES (1)` — по строке на `registry.agent_modules()`.
- `settings.py`:
- `ensure_defaults` создаёт `tokens` с `client` и токеном каждого агента и дописывает недостающие токены агентов при каждом вызове;
- `min_agent_version` уходит из `CORE_DEFAULTS` и `validate` в модуль jira (`DEFAULTS`, `validate_setting`, `AGENT_KEYS`), ключ и значение прежние;
- `board_id` удалить из `AGENT_KEYS` jira.
- `api.py`:
- `_COMPILED` хранит модуль-владелец маршрута (`None` у ядра);
- `_auth(scope, owner) -> str | None`: для `agent` ищет модуль по токену (`hmac.compare_digest` с каждым), владелец не `None` и не совпал — 401; возвращает имя модуля;
- `_dispatch` кладёт его в `query[http.AGENT]`;
- `h_agent_settings` → `settings.for_agent(conn, http.agent_of(q))`.
- `queue.py`: `agent_status` — `WHERE agent = ?` (пока везде `'jira'`, Task 2 заменит на агента запроса).
- `cli._print_tokens`: «клиентский токен: …», затем «агент <модуль>: …» по `agent_modules()`, затем «база: …».
- [ ] **Step 5: Эталоны.** `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py`. Допустимые изменения:
- `schema` — версия 7 и `agent_status`;
- `settings` — `min_agent_version` переехал в ключи модуля, нет `board_id` в `agent_keys`.
`mcp_tools` не меняется. Затем `uv run pytest -q` — PASS.
- [ ] **Step 6: Commit** «Миграция v7: токены и статус по агентам модулей (#91)».
---
### Task 2: Очередь, релиз и статус по агенту
**Files:**
- Modify: `src/jira_hub/core/{queue.py,api.py,release.py,cli.py,paths.py}`, `src/jira_hub/modules/jira/{fetch.py,refresh.py,mcp.py}`, `tests/test_agents.py`, `tests/test_queue*.py`, `tests/test_release.py`, `tests/test_cli.py`, `tests/fixtures/baseline/routes.expected.json`
**Interfaces:**
- Consumes: Task 1 — `http.agent_of`, `registry.agent_modules`, `agent_status` по агенту.
- Produces:
- `queue.claim(conn, agent: str, agent_version=None, settings_version=None, last_call_at=None, backup=None, files_dir=None)` — `_next`/`_redeliver` берут только типы `registry.module(agent).commands.types`; `current_command_id`, `last_seen_at` — строки агента.
- `queue.agent_version(conn, agent: str) -> str | None`; `queue.agent_view(conn, agent: str) -> dict` (поле `module`; `warning` по `Module.agent_min_version_key`; `redelivered`, `rate_limited_24h` — только команды типов модуля).
- `queue.has_active(conn, module: str | None = None) -> bool` — `None` как сейчас (любой модуль).
- `release.module_dir(root, module) -> Path` (`root/<module>`, 0700); `release.migrate_layout(root) -> None` — перенос `root/agent.py`, `root/manifest.json` в `root/jira/`, если `root/jira/` нет; ошибка — `log.warning`, без исключения.
- `Hub.release_root: Path` (бывший `Hub.release_dir`), `Hub.agent_update: dict[str, dict]`, `Hub.note_agent_update(agent, body)`.
- `GET /agent?module=<имя>` (по умолчанию `jira`; неизвестный — 404 `not_found`), `GET /agents` → `{"agents": [view, …]}` в порядке `agent_modules()`.
- CLI `jira-hub agent publish [--module M] PATH` (по умолчанию `jira`; неизвестный или без команд — код 2, stderr «неизвестный модуль M; модули с агентами: jira, …»).
- [ ] **Step 1: Падающие тесты** (`tests/test_agents.py`, дописать):
```python
def test_wiki_queue_does_not_block_jira(conn_two_modules):
# пакет 1 stub_cmd, пакет 2 ensure_issue; claim(conn, "stub") → stub_cmd; claim(conn, "jira") → ensure_issue,
# хотя stub_cmd claimed; agent_status: current_command_id своих строк
def test_redeliver_only_own(conn_two_modules): # claimed stub_cmd не довыдаётся агенту jira
def test_agent_version_per_agent(conn_two_modules) # claim(conn, "jira", agent_version=14) не трогает строку stub
def test_release_per_module(tmp_path) # publish в module_dir(root,"stub") v1 при jira v14: claim агентов — свои версии
def test_release_layout_migration(tmp_path) # root/agent.py+manifest v14 → после Hub(...) — root/jira/, claim jira v14
def test_publish_unknown_module(tmp_path, capsys) # код 2, stderr «неизвестный модуль nope»
def test_agents_endpoint(hub_server_two_modules) # GET /agents — два элемента с module; GET /agent — jira; GET /agent?module=nope — 404
def test_v6_upgrade_keeps_jira_agent(...) # дописать: release/agent.py v14 в tmp → claim токеном T-agent отдаёт agent_release v14
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализовать.**
- `h_claim` берёт агента из `q`, читает `body.get("last_call_at", body.get("last_jira_call_at"))`, релиз — `release.module_dir(hub.release_root, agent)`.
- `h_agent_release` — релиз своего агента.
- `Hub.__init__` вызывает `release.migrate_layout(self.release_root)`.
- Модуль jira:
- `fetch.py` — `agent_version(conn, "jira")`;
- `refresh.maybe_auto_refresh` — `has_active(conn, "jira")`;
- `mcp.py`: `batch_wait`/`agent_status` — `GET /agent` (как было).
- Реле не меняется.
- [ ] **Step 4: Эталон `routes`** (`BASELINE_WRITE=1`). Допустимое изменение — новый маршрут `GET ^/agents$`.
`uv run pytest -q` — PASS.
- [ ] **Step 5: Commit** «Очередь, релиз и статус по агенту модуля (#91)».
---
### Task 3: Поля проекта от модулей; доски — в модуль jira
**Files:**
- Modify: `src/jira_hub/core/{modules.py,registry.py,projects.py,api.py,mcp.py,http.py}`, `src/jira_hub/modules/jira/{schema.py,projects.py,hooks.py,settings.py,api.py,mcp.py,__init__.py}`
- Create: `src/jira_hub/modules/jira/project_fields.py`, `tests/test_project_fields.py`
- Modify tests: `tests/test_projects.py`, `tests/test_api.py`, `tests/test_registry.py` (снять исключение строк `долг hub-modules` в `test_core_has_no_jira_terms`), `tests/conftest.py::add_project` (доска — в `jira_project`), эталоны `schema`, `routes`
**Interfaces:**
- Produces:
```python
@dataclass(frozen=True)
class ProjectField:
name: str # ключ в теле POST/PATCH и в GET /projects
validate: Callable[[Any], Any] # ValueError(текст) → 422 с полем; возвращает нормализованное значение
editable: bool = False
# Module:
project_fields: tuple[ProjectField, ...] = ()
project_view: Callable[[Connection, list[str]], dict[str, dict]] | None = None # label → {поле: значение}
on_project_created: Callable[[Connection, dict, dict, str], dict | None] | None = None # project, свои поля, author → пакет
on_project_updated: Callable[[Connection, dict, dict, str], list[dict]] | None = None # project, изменённые свои поля, author → пакеты
```
- Ядро:
- `projects.create(conn, label, name) -> dict`, `projects.validate(label, name)`, `projects.get`;
- `projects.list_projects(conn) -> list[dict]` — только `label`, `name`, `created_at`;
- `projects.ProjectError(field, reason)`, `ProjectExists` — прежние.
- `registry.check(mods, core_routes, core_settings)` дополнительно падает на поле проекта в двух модулях или с именем `label|name|created_at|author` («поле проекта <имя>: <модуль> и <модуль>»).
- `api.project_rows(conn, labels=None) -> list[dict]` — поля ядра плюс `project_view` всех модулей.
- `POST /projects` — неизвестное поле 422 `bad_project` «<поле>: неизвестное поле».
- `PATCH /projects/{label}` → `{"project": row, "batch_ids": [...]}`; неизменяемое поле — 422 `bad_project` «<поле> после создания не меняется».
- `core/mcp.py`:
- `register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Callable[[dict], str]]], project_create: Callable) -> None`;
- `create_project(label, name, fields: dict, module: str) -> str` — тело запроса и текст ответа прежние;
- `mcp_json(label, module="jira")` — у jira прежний фрагмент, у прочих — сервер `jira-hub-<модуль>` с аргументами `mcp <модуль> --project <метка>`;
- `fmt_projects(items, current, columns)`.
- Модуль jira:
- таблица `jira_project(label TEXT PRIMARY KEY REFERENCES project(label), board_id INTEGER, board_type TEXT)`;
- `projects.boards`, `projects.record_board_types`, `light_list` читают и пишут `jira_project`;
- шаг миграции `projects.migrate_v6(conn)` — ленивый импорт в `MIGRATIONS[6]`: копирует доски, пересоздаёт `project` без колонок досок;
- `project_fields.FIELDS = (ProjectField("board_id", validate_board, editable=False),)` — сообщение прежнее «ожидается целое > 0 или null»;
- `project_fields.view(conn, labels)` → `board_id`, `board_type`, `issues_count`;
- в `modules/jira/mcp.py` своя `project_create(label, name, board_id=None)` с прежними docstring и сигнатурой, она вызывает `core.create_project`; `register_core_tools(mcp, projects_doc=<прежний docstring projects>, columns=[("доска", …), ("тип доски", … «неизвестен»), ("задач", …)], project_create=project_create)`.
- [ ] **Step 1: Падающие тесты** (`tests/test_project_fields.py`):
```python
def test_v6_boards_moved(tmp_path): # make_v6_db → connect: jira_project AWPCA 4216 scrum, sfm_kedr NULL NULL; в project нет board_id
def test_projects_get_shape(api) # GET /projects: AWPCA {label,name,created_at,board_id:4216,board_type:"scrum",issues_count:…}
def test_post_unknown_field(api) # {label:"X",name:"X",board:5} → 422, detail содержит "board"; проекта X нет
def test_patch_board_forbidden(api) # PATCH /projects/AWPCA {board_id:1} → 422 «board_id после создания не меняется»
def test_patch_unknown_project(api) # PATCH /projects/NOPE {} → 404
def test_field_conflict() # registry.check с двумя модулями-заглушками с полем "root" → RuntimeError, текст содержит "root" и оба имени
def test_mcp_jira_tools_unchanged() # список инструментов jira-сервера == baseline mcp_tools (уже в test_baseline — убедиться, что зелёный)
def test_mcp_json_per_module() # mcp_json("AWPCA") — прежний; mcp_json("AWPCA","wiki") → ключ "jira-hub-wiki", args [..., "mcp","wiki","--project","AWPCA"]
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализовать.** `Handler.do_PATCH` → `_dispatch("PATCH")` (спека `hub-api`: 501 — только методы кроме GET, POST, PUT, PATCH). Шаг `projects.migrate_v6` дописывается в конец `MIGRATIONS[6]`. Снять все метки `долг hub-modules` в `src/`; веб — Task 4. `test_core_has_no_jira_terms` без исключения для меток. `grep -rn "долг hub-modules" src` — пусто.
- [ ] **Step 4: Эталоны** `schema` (`project` без досок, `jira_project`) и `routes` (`PATCH ^/projects/([^/]+)$`). `mcp_tools` не меняется. `uv run pytest -q` — PASS.
- [ ] **Step 5: Commit** «Поля проекта от модулей, доски — в модуль jira (#91)».
---
### Task 4: Веб — поля проекта модулей
**Files:**
- Modify: `web/src/modules/types.ts`, `web/src/modules/jira/index.tsx`, `web/src/projects/NewProjectDialog.tsx`, `web/src/api/{types.ts,queries.ts}`, тесты рядом (`*.test.tsx`), `web/e2e/` (если создание проекта там проверяется)
**Interfaces:**
- Produces:
- `HubModule.projectFields?: ProjectFieldDef[]`, где `type ProjectFieldDef = { name: string; label: string; hint: string; kind: "int" | "text" }`.
- Окно собирает поля из `MODULES.flatMap(m => m.projectFields ?? [])`. Пустое значение в тело не попадает, `int` — `Number(...)`.
- Тип `Project` — `{label, name, created_at} & Record<string, unknown>`. Поля jira (`board_id`, `board_type`, `issues_count`) типизирует модуль jira.
- `useUpdateProject()` — `PATCH /projects/{label}`; понадобится в Task 10.
- У jira: `{name: "board_id", label: "Номер доски Jira", hint: "scrum или kanban определится сам", kind: "int"}`.
- [ ] **Step 1: Падающий тест** `NewProjectDialog.test.tsx`: доска пусто → тело `{label, name}` без `board_id`; доска «5120» → `board_id: 5120`. Поле с подписью «Номер доски Jira» есть.
- [ ] **Step 2: FAIL** (`cd web && npx vitest run src/projects`).
- [ ] **Step 3: Реализовать.** Метку `долг hub-modules` из диалога снять. Вёрстка окна — как сейчас.
- [ ] **Step 4:** `make check` и `cd web && npm run e2e` — PASS.
- [ ] **Step 5: Commit** «Веб: поля проекта от модулей (#91)».
---
### Task 5: Модуль wiki — каркас, корень проекта, команды
**Files:**
- Create:
- `src/jira_hub/modules/wiki/{__init__.py,schema.py,settings.py,paths.py,projects.py,commands.py,tick.py}`;
- `tests/test_wiki_projects.py`, `tests/test_wiki_commands.py`.
- Modify:
- `src/jira_hub/core/registry.py` (`mods = [jira.MODULE, wiki.MODULE]`);
- `pyproject.toml` (`markdownify`), `uv.lock`;
- эталоны `schema`, `settings`, `routes`;
- `tests/test_agents.py` (токен `wiki` появляется при миграции v6).
**Interfaces:**
- Consumes: Task 1–3 (агенты, `ProjectField`, `on_project_*`).
- Produces:
- `schema.SQL`:
- `wiki_project(label PK REFERENCES project(label), root_code TEXT NOT NULL, root_title TEXT, synced_at TEXT, failed_at TEXT, pages_count INTEGER NOT NULL DEFAULT 0, sync_error TEXT)`;
- `wiki_page(label, code, eva_id, title, parent_code, orderno, depth, path, url, size, synced_at, PRIMARY KEY(label, code))`;
- `wiki_attachment(id INTEGER PK AUTOINCREMENT, label, page_code, url, name, state TEXT NOT NULL DEFAULT 'none', file, size, error, fetched_at, UNIQUE(label, page_code, url))`;
- `wiki_sync_stage(command_id, code, payload TEXT NOT NULL, PRIMARY KEY(command_id, code))`.
- `settings.DEFAULTS = {"wiki_pacing": 0.2, "wiki_auto_sync_hours": 24, "wiki_min_agent_version": 1}`, `AGENT_KEYS = ["wiki_pacing"]`. Проверки:
- `wiki_pacing` — число 0–10;
- `wiki_auto_sync_hours` — целое ≥ 0;
- `wiki_min_agent_version` — целое ≥ 0.
- `paths.root(conn) -> Path` — `Path(<файл базы из PRAGMA database_list>).parent / "wiki"`; `paths.project_dir(conn, label) -> Path`.
- `projects.parse_root(value) -> str | None` — `None`/`""` → `None`; строка с `DOC-\d+` (без учёта регистра, в том числе внутри ссылки) → `DOC-<цифры>` в верхнем регистре; иначе `ValueError("ожидается код DOC-… или ссылка на документ Eva")`.
- `projects.FIELDS = (ProjectField("wiki_root", parse_root, editable=True),)`.
- `projects.view(conn, labels)` → `wiki_root`, `wiki_root_title`, `wiki_pages`, `wiki_synced_at`, `wiki_sync_error` (`None`, если корня нет).
- `projects.on_created(conn, project, fields, author) -> dict | None` — корень задан: строка `wiki_project` и пакет `wiki_sync`.
- `projects.on_updated(conn, project, fields, author) -> list[dict]`:
- отменяет `queued` `wiki_sync` проекта (`queue.cancel_batch`);
- новый корень — обновляет `root_code`, ставит пакет;
- `None` — удаляет строки проекта (`wiki_project`, `wiki_page`, `wiki_attachment`, FTS) и каталог `paths.project_dir`.
- `commands.TYPES = {"wiki_sync", "wiki_fetch"}`, оба в `read_types`; параметры `wiki_sync {label: str, root: "DOC-…"}`, `wiki_fetch {attachment_id: int, url: str}`; `commands.HOOKS: CommandHooks` (пауза — 0; `check_batch` — проект пакета равен `label`).
- `commands.enqueue_sync(conn, label, root, author) -> dict` — пакет «Вики <метка>» с `project = label`; `commands.active_sync(conn, label) -> dict | None` — пакет `wiki_sync` проекта не в терминальном статусе.
- `tick.auto_sync_tick(hub, conn)`:
- по каждому `wiki_project`: `wiki_auto_sync_hours > 0`, с `max(synced_at, failed_at)` прошло больше часов, нет `active_sync` → `enqueue_sync(…, "hub")`;
- атрибут `label = "синхронизация вики"`.
- `MODULE = Module(name="wiki", agent_min_version_key="wiki_min_agent_version", …)`.
- [ ] **Step 1: Падающие тесты:**
```python
def test_parse_root():
assert parse_root("https://eva.example.ru/project/Document/doc-001234") == "DOC-001234"
assert parse_root(" DOC-7 ") == "DOC-7" and parse_root(None) is None and parse_root("") is None
with pytest.raises(ValueError): parse_root("страница про статусы")
def test_post_project_with_root(api) # POST {label:"S",name:"S",wiki_root:"DOC-001234"} → 201; batch_id — refresh jira; в очереди wiki_sync {label:"S",root:"DOC-001234"}
def test_patch_root_existing(api) # PATCH /projects/AWPCA {wiki_root:"DOC-000777"} → 200, batch_ids из одного wiki_sync; GET /projects wiki_root
def test_patch_root_bad(api) # {wiki_root:"страница"} → 422, detail про wiki_root; корень прежний
def test_patch_root_cancels_queued(api) # корень DOC-12 + queued wiki_sync; PATCH DOC-21 → прежний пакет cancelled, новый {root:"DOC-21"}
def test_reset_root_removes_copy(api, tmp_path) # каталог wiki/AWPCA и строки есть; PATCH {wiki_root:None} → каталога и строк нет
def test_auto_sync_tick(conn) # synced_at 25 ч назад → пакет; 23 ч → нет; active_sync → нет; wiki_auto_sync_hours 0 → нет
def test_auto_refresh_ignores_wiki_batches(conn) # queued wiki_sync не мешает maybe_auto_refresh jira
def test_wiki_agent_token_after_upgrade(tmp_path) # make_v6_db → tokens.agents имеет jira=T-agent и новый wiki
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализовать.** `on_result`, `check_result` и прочие хуки результата пока пустые: Task 7 их заполнит.
- [ ] **Step 4: Эталоны** `schema`, `settings`, `routes` (новых маршрутов пока нет — `routes` не должен измениться). `uv run pytest -q` — PASS.
- [ ] **Step 5: Commit** «Модуль wiki: корень проекта, команды, фоновая синхронизация (#91)».
---
### Task 6: Перевод HTML Eva в Markdown
**Files:**
- Create: `src/jira_hub/modules/wiki/convert.py`, `tests/test_wiki_convert.py`, `tests/fixtures/wiki/*.html` (синтетические страницы)
**Interfaces:**
- Produces:
- `convert.to_markdown(html: str, attachments: list[dict], links: list[dict]) -> tuple[str, list[dict]]`:
- вход — `attachments [{ref, url, name}]`, `links [{ref, code}]`, как в спеке;
- выход — Markdown и список вложений страницы `[{url, name}]` без повторов по `url`, в порядке первого появления.
- Правила замены:
- `ref` вложения → `[📎 <name>](attachment:@<n>)`, где `n` — индекс в возвращённом списке;
- повторная ссылка на тот же `url` (миниатюра и файл) удаляется;
- `ref` ссылки → `[<текст ссылки или код>](page:<code>)`;
- `img`/`a` с адресом, начинающимся на `static/` или `/static/`, удаляется;
- прочие ссылки остаются как есть;
- сырого HTML (тегов) в результате нет;
- заголовки ATX.
- `convert.fill_ids(markdown, ids: list[int]) -> str` — `attachment:@<n>` → `attachment:<ids[n]>`.
- [ ] **Step 1: Фикстуры и падающие тесты.** Фикстуры — 3 синтетические страницы с реальными форматами адресов:
- картинки `/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/image2026-7-27_14-20-6.png?version=1`;
- файл: миниатюра `…/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и ссылка `…/model.xlsx`;
- `<a href="project/Document/DOC-019834">Модель данных</a>`;
- таблица;
- вложенные списки;
- внешняя ссылка `https://example.org`.
```python
def test_image_becomes_attachment() # одна строка "[📎 image2026-7-27_14-20-6.png](attachment:@0)"
def test_thumbnail_and_file_one_link() # список вложений — 1 (model.xlsx); в тексте одна ссылка; "attach-file" нет
def test_page_link() # "[Модель данных](page:DOC-019834)"
def test_external_link_kept() # "(https://example.org)" есть
def test_no_raw_html() # re.search(r"<[a-zA-Z/]", md) is None
def test_table_kept() # строка с "|" и разделитель "---"
def test_fill_ids() # fill_ids("[x](attachment:@1)", [7, 9]) == "[x](attachment:9)"
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализовать** на `markdownify.MarkdownConverter` (подкласс с `convert_img`/`convert_a`).
- `ref` сравнивается со значением атрибута ровно, без нормализации.
- Неизвестные теги разворачиваются в текст.
- [ ] **Step 4: PASS.**
- [ ] **Step 5: Commit** «wiki: перевод HTML Eva в Markdown (#91)».
---
### Task 7: Синхронизация — приём пачек, сборка копии, поиск
**Files:**
- Create: `src/jira_hub/modules/wiki/{stage.py,sync.py,search.py}`, `tests/test_wiki_sync.py`
- Modify: `src/jira_hub/modules/wiki/{commands.py,__init__.py,api.py (создать: маршрут агента)}`, эталон `routes`
**Interfaces:**
- Consumes: Task 5 (`paths`, `commands`, таблицы), Task 6 (`to_markdown`, `fill_ids`).
- Produces:
- Маршрут `POST /agent/wiki/pages` (scope `agent`, владелец `wiki`) → `stage.accept(conn, command_id, pages) -> {"accepted": n}`.
- Перевод `to_markdown` — до транзакции; в стадию кладётся JSON `{code, eva_id, title, parent_code, orderno, url, markdown, attachments}`.
- Ошибки: 409 `not_claimed` (команда не `claimed` или не `wiki_sync`); 422 `bad_pages` «<code>: <поле>» (обязательные строки: `code`, `title`, `html`; `orderno` — целое или `null`; `parent_code` — строка или `null`).
- `sync.build_copy(stage_pages: list[dict], codes: list[str], target: Path, ids: dict[tuple[str, str], int]) -> list[dict]`:
- строит дерево по `parent_code`, корень — `codes[0]`;
- соседи сортируются по `(orderno is None, orderno, code)`;
- каталоги `NN_<safe_name>`, у корня без номера;
- `safe_name` — как в `eva_wiki_export.py`: `[<>:"/\\|?*\x00-\x1f]` → `_`, обрезка 80 символов, `.` и пробелы по краям убираются, пустое → `untitled`;
- совпадения у соседей — `_2`, `_3`; `..` исключён;
- каждый путь проверяется: он внутри `target`;
- пишет `index.md` (frontmatter `code`, `title`, `url`, `synced_at`; `# title`; текст) и `_tree.json`;
- права каталогов 0700;
- возвращает строки `wiki_page`.
- `sync.apply(conn, cmd, result) -> None` — вызывается из `CommandHooks.on_result` при `done`:
1. Проверки:
- `params.root` ≠ текущий `root_code` или корня нет → `result.discarded = "корень сменился"`, ничего не меняется;
- код из `codes` без стадии → статус `failed` «нет страницы <код>». Эту проверку делает `check_result`.
2. Выделение `id` вложений: существующие по `(label, page_code, url)` сохраняются, новые вставляются. Это делается в той же транзакции, что и запись; сборка копии идёт в `wiki/.<label>.new/` до коммита. Из-за этого `apply` разбит на `prepare`, вне транзакции, и `commit`, в `on_result`. Точное разбиение — решение исполнителя, с условием: `markdownify` и запись файлов не идут под `BEGIN IMMEDIATE`.
3. Подмена каталога: `wiki/<label>` → `wiki/.<label>.old`, `.new` → `<label>`, удаление `.old`.
4. `wiki_page` и FTS пишутся заново; пропавшие вложения удаляются.
5. Запись `synced_at`, `pages_count`, `root_title`; `sync_error` и `failed_at` обнуляются; стадия команды очищается.
- `sync.on_failed_or_reported_failed(conn, cmd, error)` — `sync_error`, `failed_at`, очистка стадии.
- Повторная выдача (`on_claim` с `redelivery=True`) очищает стадию команды.
- `sync.finish_pending_swaps(root: Path) -> None` — вызывается из `tick` модуля при первом тике:
- есть `.<label>.new` и нет `<label>` → переименовать;
- есть `.<label>.old` → удалить.
- `search.ensure(conn) -> bool`: `CREATE VIRTUAL TABLE IF NOT EXISTS wiki_page_fts USING fts5(label UNINDEXED, code UNINDEXED, title, body, tokenize='unicode61 remove_diacritics 2')`; `OperationalError` → `False` и одно `log.warning`.
- `search.replace(conn, label, pages: list[(code, title, markdown)])`.
- `search.query(conn, label, q, limit) -> list[{code, title, path, snippet}]`:
- слова запроса превращаются в `"<слово>"*` через AND, кавычки в слове экранируются;
- `snippet(...)` по `body`;
- без FTS — `LIKE` по файлам `index.md` проекта.
- [ ] **Step 1: Падающие тесты** (`tests/test_wiki_sync.py`; агента имитирует тест через HTTP на порту 0):
```python
def test_first_sync(api) # DOC-1 корень, DOC-2, DOC-3 дети; пачка + done → 3 index.md, GET /projects wiki_pages 3, root_title
def test_failed_keeps_copy(api) # вторая синхронизация failed "Eva: HTTP 503" → копия и поиск прежние, wiki_sync_error
def test_missing_page_fails(api) # codes [DOC-1, DOC-2], пачка только DOC-1 → failed «нет страницы DOC-2», копия прежняя
def test_page_removed(api) # было DOC-1..3, стало DOC-1, DOC-3 → каталога DOC-2 нет, поиск не находит, вложения удалены
def test_attachment_id_survives(api) # вложение 7 ready; та же страница и url → id 7, state ready
def test_stale_root_discarded(api) # wiki_sync {root DOC-12} выдан, корень → DOC-21, done → копия прежняя, result.discarded
def test_redelivery_clears_stage(api) # пачка, повторная выдача, done без новых пачек → failed «нет страницы …»
def test_pages_wrong_agent(api) # токеном jira POST /agent/wiki/pages → 401
def test_pages_not_claimed(api) # команда queued → 409 not_claimed
def test_bad_page(api) # страница без title → 422 bad_pages "DOC-2: title"
def test_layout_names_safe(tmp_path) # названия "../x", "a/b", "😀"*200, два "Статусы" → все пути внутри target; "Статусы", "Статусы_2"
def test_finish_pending_swap(tmp_path) # есть .AWPCA.new и нет AWPCA → после finish — AWPCA; .AWPCA.old удалён
def test_search_prefix(conn) # страница с «статус «На согласовании»» находится по «согласов»; кавычка в запросе не роняет
```
- [ ] **Step 2: FAIL.**
- [ ] **Step 3: Реализовать.**
- [ ] **Step 4: Эталон `routes`:** допустимое изменение — `POST ^/agent/wiki/pages$`. `uv run pytest -q` — PASS.
- [ ] **Step 5: Commit** «wiki: синхронизация — пачки страниц, копия, поиск (#91)».
---
### Task 8: Маршруты вики и вложения по запросу
**Files:**
- Create: `src/jira_hub/modules/wiki/attachments.py`, `tests/test_wiki_api.py`
- Modify: `src/jira_hub/modules/wiki/{api.py,commands.py,__init__.py}` (`file_refs`), эталон `routes`
**Interfaces:**
- Consumes: Task 5, Task 7.
- Produces (клиентский токен; проект — `project=<метка>`, неизвестный → 404, без корня → 409 `no_root`):
- `GET /wiki/status` → `{root, root_title, synced_at, pages, error, batch: {id, status} | null}`.
- `GET /wiki/tree` → `{"pages": [{code, title, depth, parent_code, size}]}`.
- `GET /wiki/pages/{code}` → `{code, title, path, url, synced_at, markdown, attachments: [{id, name, state, size}]}`; страницы нет → 404.
- `GET /wiki/search` с `q` и `limit` (1–50, по умолчанию 10; пустой `q` → 422 `bad_query`) → `{"results": [...]}`.
- `POST /wiki/sync {project, author?}` → 201 `{batch_id}`; идёт синхронизация → 409 `sync_in_progress` с `batch_id` в `detail`.
- Вложения:
- `POST /wiki/attachments/{id}/fetch` → состояние `{id, name, page_code, url, state, size, error}`, при новой постановке 202;
- `GET /wiki/attachments/{id}`;
- `GET /wiki/attachments/{id}/content` → `Raw` с именем файла; не `ready` — 409 `not_ready`.
- `attachments.request_fetch(conn, attachment_id, author) -> dict` — одна транзакция:
- `ready` → как есть;
- `fetching` → как есть;
- иначе `state = fetching` и пакет `wiki_fetch`.
- Хуки результата `wiki_fetch`:
- `check_result` → файла `result.file` нет в `files/` или размер не совпал → `failed`;
- `on_result` → `ready`, `file`, `size`, `fetched_at` либо `failed` с `error`;
- `on_failed` → `failed`.
- `file_refs(conn)` → `file` вложений в `ready`.
- [ ] **Step 1: Падающие тесты:**
```python
def test_status_tree_page(api) # после синхронизации из Task 7: tree — 3 узла в порядке обхода; page DOC-3 — markdown и attachments
def test_no_root(api) # sfm_kedr: /wiki/tree → 409 no_root
def test_sync_in_progress(api) # второй POST /wiki/sync → 409 sync_in_progress, batch_id прежний
def test_fetch_flow(api) # fetch → 202 fetching; агент POST /agent/files + done {file,size} → ready; content → байты
def test_fetch_too_big(api) # агент failed «файл больше 10 МБ — откройте в Eva» → state failed, content → 409 not_ready
def test_fetch_size_mismatch(api) # done с size ≠ фактическому → failed
def test_file_refs_keep_ready(api) # files.sweep не удаляет файл ready-вложения
def test_search_empty_query(api) # q="" → 422
```
- [ ] **Step 2: FAIL. Step 3: Реализовать.**
- [ ] **Step 4: Эталон `routes`** — только новые маршруты `/wiki/*`. PASS.
- [ ] **Step 5: Commit** «wiki: маршруты и вложения по запросу (#91)».
---
### Task 9: MCP вики
**Files:**
- Create: `src/jira_hub/modules/wiki/mcp.py`, `tests/test_wiki_mcp.py`, `tests/fixtures/baseline/mcp_wiki_tools.expected.json` (эталон через `BASELINE_WRITE=1`, тест в `tests/test_baseline.py::test_mcp_wiki_tools`)
- Modify: `src/jira_hub/modules/wiki/__init__.py` (`mcp_server`)
**Interfaces:**
- Consumes: `core/mcp` (`guard`, `scoped`, `register_core_tools`, `create_project`, `client`, `PROJECT`), маршруты Task 8.
- Produces:
- `FastMCP("jira-hub-wiki")`.
- Инструменты по таблице из спеки `mcp`, «MCP вики»: `tree`, `page`, `search`, `attachment`, `sync`, `status`, `root_set`, затем общие `projects`, `project_create`.
- Тексты отказов дословно:
- «у проекта <метка> нет корня вики: root_set DOC-…»;
- «вики проекта ещё не синхронизирована: sync».
- `page`:
- порция 20 000 символов;
- последняя строка «продолжение: offset=<N>», если текст не кончился;
- поиск по `title`: точное совпадение без учёта регистра, иначе до 10 кандидатов «<код> <название>», поиск через `GET /wiki/tree`.
- `attachment`:
- ждёт до 60 с, опрос раз в 2 с;
- готово — копия `downloads/wiki-<id>/<имя>` (каталог 0700, как у jira) и путь;
- иначе — состояние и подсказка повторить.
- `sync(wait=False)`: при `wait` ждёт пакет до 300 с.
- `status` дописывает агента `wiki` из `GET /agent?module=wiki` в формате `fmt_agent` модуля jira. `fmt_agent` переносится в `core/mcp.py` как общий, тексты не меняются: эталон `mcp_tools` jira не меняется.
- [ ] **Step 1: Падающие тесты** (клиент хаба подменяется, как в `tests/test_mcp.py`):
```python
def test_unbound_tools_refuse() # без --project: tree → NOT_BOUND
def test_no_root_message() # проект без корня: tree → «у проекта sfm_kedr нет корня вики: root_set DOC-…»; запроса /wiki/tree нет
def test_page_chunks() # markdown 45 000 → первые 20 000 + «продолжение: offset=20000»; offset 40000 → без строки продолжения
def test_page_by_title_candidates() # title «Стат» → кандидаты с кодами
def test_jira_server_unchanged() # baseline mcp_tools (jira) совпадает
def test_mcp_wiki_tools() # эталон mcp_wiki_tools.expected.json
def test_run_wiki_module() # core.mcp.run("wiki", "AWPCA") поднимает сервер jira-hub-wiki (без stdio: проверить registry.module("wiki").mcp_server().name)
```
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.**
- [ ] **Step 5: Commit** «wiki: MCP-сервер jira-hub-wiki (#91)».
---
### Task 10: Веб — страница «Вики»
**Files:**
- Create: `web/src/modules/wiki/{index.tsx,api/queries.ts,WikiPage.tsx,Tree.tsx,PageView.tsx,Search.tsx,RootForm.tsx,SyncStatus.tsx,wiki.css}` и тесты рядом, `web/e2e/wiki.spec.ts`
- Modify: `web/src/modules/index.ts` (`MODULES = [jiraModule, wikiModule]`), `web/package.json` (+`react-markdown`, `remark-gfm`), `web/scripts/test-hub.sh`/`web/scripts/seed-hub.py` (засеять копию вики для e2e: корень и 3 страницы через `POST /agent/wiki/pages` токеном агента `wiki`)
**Interfaces:**
- Consumes: маршруты Task 8, `useUpdateProject` (Task 4), `HubModule`.
- Produces:
- `wikiModule: HubModule`:
- страница `/app/wiki`, пункт панели «Вики»;
- `projectFields: [{name: "wiki_root", label: "Корень вики", hint: "DOC-… или ссылка на страницу Eva", kind: "text"}]`;
- `SidebarStatus` — ход синхронизации текущего проекта (опрос `GET /wiki/status` раз в 3 с, пока `batch` не терминальный).
- Поведение — по требованию спеки `web-app`, «Вики проекта»:
- страница выбирается параметром `page`;
- ссылки `page:` → `?project=…&page=<код>`;
- ссылки `attachment:` → загрузка как у вложений карточки задачи: `POST …/fetch`, опрос `GET /wiki/attachments/{id}`, затем `api.blob` на `/content`;
- Markdown — `react-markdown` + `remark-gfm`, без `rehype-raw`.
- Дизайн — скилл `aura-design`, общие компоненты `web/src/ui/`, цвета только токенами.
- [ ] **Step 1: Падающие тесты** (Vitest):
```tsx
test("ссылка page: открывает страницу") // клик по [Модель данных](page:DOC-2) → адрес ?project=AWPCA&page=DOC-2, справа DOC-2, узел выделен
test("без корня — форма привязки") // status 409 no_root → форма; ввод DOC-001234 → PATCH /projects/sfm_kedr {wiki_root}
test("поиск заменяет дерево") // ввод «согласов» → результаты GET /wiki/search; очистка → дерево
test("сырой HTML не исполняется") // markdown с "<script>" показан текстом, script в DOM нет
test("ошибка синхронизации видна") // status.error → плашка с текстом
test("поле корня в окне нового проекта") // NewProjectDialog: поле «Корень вики»; ввод → wiki_root в теле
```
e2e (`web/e2e/wiki.spec.ts`): открыть `/app/wiki?project=AWPCA`, увидеть дерево из 3 страниц, открыть страницу, найти по слову.
- [ ] **Step 2: FAIL. Step 3: Реализовать. Step 4:** `make check`, `cd web && npm run e2e` — PASS.
- [ ] **Step 5: Commit** «Веб: страница «Вики» (#91)».
---
### Task 11: Граница, документация, эталоны — финал
**Files:**
- Modify: `ARCHITECTURE.md`, `CLAUDE.md` (репозитория хаба), `README.md`, `tests/test_registry.py` (если нужно), `openspec/changes/wiki-module/design.md` (только раздел «Decisions made without discussion» — дописывает контроллер)
- [ ] **Step 1: Проверки:**
- `grep -rn "долг hub-modules" src web/src` — пусто;
- `test_core_has_no_jira_terms` — без исключений для меток долга;
- новый тест `test_core_has_no_module_terms`: в `src/jira_hub/core/` нет `wiki_`, `wiki/`, `DOC-`, `eva`, кроме `core/registry.py`.
- [ ] **Step 2: Документация:**
- `ARCHITECTURE.md`:
- агенты модулей;
- поля проекта;
- модуль `wiki` — таблица файлов, как у jira, и потоки «Синхронизация вики», «Вложение вики»;
- раздел «Границы» без долга;
- открытые вопросы: запись в вики, история, переименование.
- `CLAUDE.md`:
- данные `wiki/`;
- команды `jira-hub mcp wiki`, `agent publish --module`, `tokens`.
- `README.md` — подключение `jira-hub-wiki` в `.mcp.json` и установка eva-agent («отдельный репозиторий aep/eva-agent»).
- [ ] **Step 3:** `make check` и `cd web && npm run e2e` — PASS; `openspec validate wiki-module --strict` — valid.
- [ ] **Step 4: Commit** «Документация: модуль wiki и агенты модулей (#91)».