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

54 KiB
Raw Blame History

Модуль 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:

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, дописать):

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:

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

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: Падающие тесты:

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

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: Падающие тесты:

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=», если текст не кончился;
      • поиск по 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):

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

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)».