sdlc-hub/docs/superpowers/plans/2026-10-28-wiki-history.md
Andrey Epifancev de3c04701e План: история правок вики (#140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:47:42 +04:00

79 KiB
Raw Permalink Blame History

История правок вики, общая лента и дифф — план реализации

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: хаб хранит опубликованные версии страниц дерева проекта и отдаёт ленту правок и дифф через HTTP API, MCP sdlc-wiki и общую страницу «Лента» веба с источниками «Jira» и «Вики» (sdlc-hub#140).

Architecture: модуль wiki хаба получает приём версий от агента (stage.accept_versions, POST /agent/wiki/versions), запись версий в той же транзакции, что и копия (sync.prepare считает «+/−» вне транзакции, sync._commit пишет), и модуль чтения history.py (лента, версии, дифф по запросу). В вебе страница «Лента» переезжает в ядро (web/src/shell/feed/), модули дают ей источники новым полем контракта HubModule.feed; модуль wiki добавляет источник и окно диффа поверх страницы. Агентская часть (eva-agent v2) — отдельный план; контракт — specs/wiki «История правок страниц».

Tech Stack: Python 3.13 (uv), stdlib difflib/sqlite3, pytest; React 19 + TypeScript, TanStack Query, Vitest, Testing Library, Playwright.

Ветка: feat/140-wiki-history (worktree sdlc-hub/.wt/wiki-history).

Spec: openspec/changes/wiki-history/ (proposal.md, design.md — решения 1–15 и «Decisions made without discussion», specs/wiki/spec.md, specs/mcp/spec.md, specs/web-app/spec.md). Текущее поведение — openspec/specs/, карта — ARCHITECTURE.md.

Global Constraints

  • Только хаб sdlc-hub; eva-agent и jira-agent не меняются. SCHEMA_VERSION не меняется: новые таблицы модуля создаются CREATE TABLE IF NOT EXISTS в modules/wiki/schema.py (решение плана П-1).
  • Ядро Python не знает терминов модулей: в src/sdlc_hub/core/ нет eva, wiki_, wiki/, DOC- и импортов sdlc_hub.modules (tests/test_registry.py). Новые имена в ядре — без названий модулей.
  • Веб: ядро (web/src/{shell,ui,api,projects,auth,styles}) не импортирует модули (кроме modules/index.ts, modules/types.ts) и не знает их терминов; модуль не импортирует другой модуль; класс CSS одного владельца не объявляется у другого (web/src/boundary.test.ts).
  • Время в базе — UTC в формате db.now() (2026-10-27T20:48:02+00:00, секунды без дробной части).
  • Знак «−» в «+A / −R» — U+2212 (как в спеке), в MCP и вебе.
  • Части текста — по 20 000 символов (CHUNK), как у page.
  • Пачка версий — не больше 50; limit ленты — по умолчанию 500, 1–2000.
  • Чтение истории (GET /wiki/changes, …/versions, …/diff, MCP changes/diff/page) не ставит команд.
  • Тексты ошибок, комментарии, сообщения коммитов — по-русски; идентификаторы — английские.
  • Эталоны (tests/fixtures/baseline/*.expected.json) перезаписываются только BASELINE_WRITE=1 uv run pytest tests/test_baseline.py в задаче, которая меняет маршруты/схему/инструменты; эталон mcp_tools (сервер jira) не меняется.
  • Соединения SQLite в тестах — через фикстуры open_db/api или с явным close().
  • Зелёным считается только весь набор: make check (uv run pytest -q + cd web && npm run lint && npm test).

Review Focus

  1. Фильтр автора кириллицей без учёта регистра (author=СОСНОВ, author=соснов): SQLite LOWER/LIKE знают только ASCII — фильтр должен находить «Дмитрий Соснов». Тест test_changes_author_cyrillic_case в Task 3.
  2. Агент переотправил ту же пачку версий (сетевая ошибка, 5xx) или прислал уже известный хабу код: в стадии одна строка на код, «пришло N из M» считает уникальные коды, в истории дубля нет и «+/−» прежние. Тесты test_versions_resend_same_batch (Task 1) и test_known_version_resent_ignored (Task 2).
  3. Тяжёлая работа вне блокировки записи (рулинг R-6): перевод HTML версий и подсчёт «+/−» по сотням версий первой загрузки не идут внутри BEGIN IMMEDIATE. Тест test_history_heavy_work_off_write_lock в Task 2.
  4. Время версии от Eva с дробными секундами, Z или +0300 нормализуется в UTC без дробей; без смещения — 422. Тест test_versions_created_at_forms в Task 1.
  5. Дифф частями: часть может оборваться посреди строки — окно склеивает части и показывает строку один раз и целиком. Тест «Показать дальше»: часть рвёт строку — строка целая и одна в Task 7.

Решения плана (записать в design.md)

Task 1, шаг 6 дописывает их в openspec/changes/wiki-history/design.md, раздел «Decisions made without discussion» (дата 28.10.2026, решение, обоснование, цена ошибки — коротко, по образцу раздела):

  • П-1 history_at — своя таблица wiki_history(label, history_at) без миграции. Цена: ещё одна таблица на проект.
  • П-2 Версия, код которой уже есть в хабе, не перезаписывается (INSERT OR IGNORE, design 1 — опубликованная неизменна); в стадии повтор кода заменяет строку (INSERT OR REPLACE), N в «пришло N из M» — число уникальных кодов в стадии.
  • П-3 result.versions не целое ≥ 0 (строка, true, −1) — команда failed «versions — неотрицательное целое»; присланных больше M — применяются все.
  • П-4 «+/−» считаются в sync.prepare (вне транзакции), пишутся в sync._commit; число — строки +/- из difflib.unified_diff (те же, что в диффе правки).
  • П-5 Время версии: дробные секунды отбрасываются; равное время — порядок по коду строкой (спека: «затем по коду»).
  • П-6 Фильтр автора — в Python (casefold) до среза limit.
  • П-7 GET …/diff: offset не целое ≥ 0 — 422 bad_request «offset: целое ≥ 0»; offset за концом — diff = "", next_offset = null.
  • П-8 MCP: шапка diff, строка status «история: <history_at>» / «история не загружена», sync при отменённом пакете — «ошибка: пакет N отменён», время в «ок: синхронизировано …» — synced_at из GET /wiki/status; автор правки — имя, иначе логин, иначе «—» (и в вебе).
  • П-9 Помощники времени MCP (local_if_naive, since_of) — в core/mcp.py, сервер jira берёт их оттуда.
  • П-10 Веб: в шапке «Ленты» — вторичные кнопки обновления модулей выбранных источников («Обновить из Jira», «Обновить вики»).
  • П-11 Веб: сообщения truncated и ошибки источника — с префиксом «<Источник>: »; «выберите хотя бы один вид» и «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни один выбранный источник не грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет; sources=none — явный пустой набор (как kinds=none).
  • П-12 Веб: eventTime переезжает в ui/format.ts, период (PERIODS, parsePeriod, periodRange) — в shell/feed/period.ts; прежнее перенаправление /app/feed → /app/jira/feed снимается (адрес теперь страница ядра).

Task 1: Схема версий, перевод без id вложений, приём POST /agent/wiki/versions

Model: haiku

Files:

  • Modify: src/sdlc_hub/modules/wiki/schema.py (таблицы wiki_version, wiki_version_stage, wiki_history)
  • Create: src/sdlc_hub/modules/wiki/history.py (пока только utc)
  • Modify: src/sdlc_hub/modules/wiki/convert.py (to_markdown(..., file_links=True))
  • Modify: src/sdlc_hub/modules/wiki/stage.py (accept_versions, MAX_VERSIONS, MAX_VERSION_STAGE)
  • Modify: src/sdlc_hub/modules/wiki/api.py (h_agent_versions, маршрут)
  • Modify: tests/fixtures/baseline/schema.expected.json, tests/fixtures/baseline/routes.expected.json (перезапись)
  • Modify: openspec/changes/wiki-history/design.md (решения плана П-1…П-12)
  • Modify: ARCHITECTURE.md (строки schema, stage, convert таблицы модуля wiki)
  • Test: tests/test_wiki_history.py (новый), tests/test_wiki_convert.py

Interfaces:

  • Produces:

    • таблицы:
      CREATE TABLE IF NOT EXISTS wiki_version (
          label TEXT NOT NULL, code TEXT NOT NULL, page_code TEXT NOT NULL, created_at TEXT NOT NULL,
          author_login TEXT, author_name TEXT, markdown TEXT NOT NULL,
          added INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0,
          PRIMARY KEY (label, code));
      CREATE INDEX IF NOT EXISTS wiki_version_page ON wiki_version(label, page_code, created_at);
      CREATE INDEX IF NOT EXISTS wiki_version_time ON wiki_version(label, created_at);
      CREATE TABLE IF NOT EXISTS wiki_version_stage (
          command_id INTEGER NOT NULL, code TEXT NOT NULL, page_code TEXT NOT NULL, payload TEXT NOT NULL,
          PRIMARY KEY (command_id, code));
      CREATE TABLE IF NOT EXISTS wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL);
      
    • history.utc(value) -> str | None — ISO-8601 со смещением или Z (+03:00, +0300, дробные секунды) → UTC в формате db.now(); без смещения, не строка или неразбираемое — None.
    • convert.to_markdown(html, attachments, links, *, file_links: bool = True) -> tuple[str, list[dict]]; при file_links=False ссылка вложения — текст 📎 <имя> без ссылки (имя экранировано как в ссылке), список файлов []; ссылки на страницы — page:<код> как прежде.
    • stage.accept_versions(conn, command_id, versions) -> {"accepted": n}; payload стадии — JSON {page_code, code, created_at (UTC), author_login, author_name, markdown}.
    • маршрут ("POST", r"^/agent/wiki/versions$", h_agent_versions, "agent").
  • Step 1: Написать падающие тесты. В tests/test_wiki_history.py — помощники для Task 2–3:

from test_wiki_sync import CODES, TREE, api, first_sync, page   # noqa: F401 — api — фикстура
SOSNOV = {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
MOLCH = {"login": "molchanov-iv@rshbeco.ru", "name": "Илья Молчанов"}

def version(page_code, code, created_at, html="<p>текст</p>", author=SOSNOV, attachments=None, links=None):
    return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html,
            "attachments": attachments or [], "links": links or []}

def stage_rows(api, cmd_id):
    return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id)

Тесты и утверждения:

def test_versions_staged(api):                       # перевод и форма payload
    cmd = api.claim()
    out = api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
        version("DOC-3", "DOCH-1", "2026-10-27T23:48:02+03:00", "<p>статус <b>новый</b></p>")]})
    assert out == {"accepted": 1}
    (row,) = stage_rows(api, cmd["id"])
    p = json.loads(row["payload"])
    assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00",
                 "author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"}

def test_versions_resend_same_batch(api):            # Review Focus 2: повтор пачки — одна строка на код
    cmd = api.claim(); batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")]
    for _ in range(2):
        api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch})
    assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"]

@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
                                        ("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
                                        ("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")])
def test_versions_created_at_forms(api, raw, stored):   # Review Focus 4 → created_at в payload == stored
def test_versions_created_at_without_offset_422(api):   # "2026-10-27T23:48:02" → 422 bad_versions, detail "DOCH-1: created_at"
def test_versions_author_nulls(api):                    # author {"login": None, "name": None} принят; author_login/author_name None

@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"),
    ("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]), ("links", "x")])
def test_versions_bad_field(api, field, value):        # 422 bad_versions, detail == f"DOCH-1: {field}"

def test_versions_bad_batch(api):     # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request;
                                      # версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code"
def test_versions_not_claimed(api):   # команда queued → 409 not_claimed; несуществующая → 404
def test_versions_wrong_agent(api):   # токеном агента jira (api.jira) → 401

В tests/test_wiki_convert.py:

def test_version_markdown_attachment_as_text():
    md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False)
    assert "📎 model.xlsx" in md and "attachment:" not in md and files == []

def test_version_markdown_page_links_kept():
    md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK], file_links=False)
    assert md == "[т](page:DOC-1)\n"

(THUMB, FILE, LINK — константы файла, заведённые пакетом D.)

  • Step 2: Запустить — падают. Run: uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py -q — Expected: FAIL (404 на /agent/wiki/versions, TypeError: unexpected keyword 'file_links').

  • Step 3: Реализовать.

    • schema.SQL — таблицы из Interfaces.
    • history.utc — datetime.fromisoformat; tzinfo is None → None; .astimezone(timezone.utc).replace(microsecond=0).isoformat().
    • convert.to_markdown(..., file_links=False): после готового Markdown заменить каждую [📎 <имя>](attachment:@N) на 📎 <имя> (регэксп по \[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\); такую ссылку выдаёт только конвертер — см. docstring модуля) и вернуть [] вместо файлов.
    • stage.accept_versions — по образцу accept: MAX_VERSIONS = 50, MAX_VERSION_STAGE = 20000; ошибки формы — ApiError(422, "bad_versions", "<код версии>: <поле>") (до кода — "версия <i>: …"); список — «versions — список от 1 до 50 версий»; _clean на версию; page_code/code — непустые строки ≤ 200; author — объект, login/name — строка или None; html — строка; attachments/links — те же проверки, что у страниц (вынести общую проверку из _check); created_at через history.utc. Перевод — convert.to_markdown(html, attachments, links, file_links=False) до транзакции (сбой — "<код>: html (<ИмяИсключения>)"); в транзакции — повторная проверка claimed, предел стадии («versions: в команде больше 20000 версий»), INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload).
    • api.h_agent_versions → stage.accept_versions(conn, body.get("command_id"), body.get("versions")); маршрут в ROUTES сразу после /agent/wiki/pages.
  • Step 4: Запустить — проходят. Run: uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py tests/test_wiki_sync.py -q — Expected: PASS.

  • Step 5: Эталоны. Run: BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/ — Expected: изменены только schema.expected.json (три таблицы, два индекса, schema_version прежний) и routes.expected.json (одна строка /agent/wiki/versions). Затем uv run pytest tests/test_baseline.py -q — PASS.

  • Step 6: Документы. design.md — решения П-1…П-12 из раздела «Решения плана». ARCHITECTURE.md, таблица модуля wiki: schema — плюс wiki_version, wiki_version_stage, wiki_history; stage — «и пачек версий (accept_versions, POST /agent/wiki/versions)»; convert — «режим без ссылок на вложения для версий (file_links=False)».

  • Step 7: Commit.

git add src/sdlc_hub/modules/wiki/{schema,history,convert,stage,api}.py tests/test_wiki_history.py tests/test_wiki_convert.py \
  tests/fixtures/baseline/ ARCHITECTURE.md openspec/changes/wiki-history/design.md
git commit -m "Вики: таблицы версий и приём POST /agent/wiki/versions (#140)"

Task 2: Выдача known_versions и запись версий в синхронизации

Model: sonnet

Files:

  • Modify: src/sdlc_hub/modules/wiki/history.py (known_versions, line_delta, history_at)
  • Modify: src/sdlc_hub/modules/wiki/commands.py (deliver_params в HOOKS)
  • Modify: src/sdlc_hub/modules/wiki/sync.py (Prepared, prepare, _plan_versions, _commit, очистка стадии версий)
  • Modify: src/sdlc_hub/modules/wiki/projects.py (_drop_copy)
  • Modify: ARCHITECTURE.md (поток «Синхронизация вики», границы, строки commands/sync/projects)
  • Test: tests/test_wiki_history.py

Interfaces:

  • Consumes: таблицы и stage.accept_versions из Task 1.

  • Produces:

    • history.known_versions(conn, label) -> dict[str, list[str]] — коды версий страниц текущей копии (JOIN wiki_page), по странице в порядке (created_at, code); без копии — {}.
    • history.line_delta(old: str, new: str) -> tuple[int, int] — (added, removed): строки +/- из difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="") без заголовков +++/---.
    • history.history_at(conn, label) -> str | None.
    • commands.deliver_params(conn, cmd, files_dir) -> dict — wiki_sync: {**cmd["params"], "known_versions": …}; иначе cmd["params"].
    • sync._plan_versions(conn, label, codes, staged) -> tuple[list[dict], list[tuple[int, int, str]]] — (новые строки wiki_version с added/removed, пересчёт (added, removed, code) известных версий, чей предшественник новый).
  • Step 1: Написать падающие тесты (в tests/test_wiki_history.py; помощник обмена):

def run_history(api, pages, versions, codes=None, reported=None):
    """Взять wiki_sync, прислать страницы и версии (пачками по 50), отчитаться done с versions=reported (None — len)."""
    cmd = api.claim()
    api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages})
    for i in range(0, len(versions), 50):
        api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": versions[i:i + 50]})
    result = {"root_title": "АРМ КА", "codes": codes or [p["code"] for p in pages], "eva_calls": 3,
              "versions": len(versions) if reported is None else reported}
    return cmd, api.report(cmd, "done", result)

def versions_of(api, page_code=None): ...  # SELECT code, page_code, added, removed, created_at … ORDER BY page_code, created_at, code
def test_first_history_load(api):                     # сценарий «Первая загрузка истории»
    cmd, done = run_history(api, TREE, [version("DOC-1", f"DOCH-1{i}", f"2026-10-2{i}T10:00:00Z", "<p>а</p>" * (i + 1)) for i in range(3)]
                            + [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)], CODES)
    assert cmd["params"]["known_versions"] == {} and done["status"] == "done"
    rows = versions_of(api)
    assert len(rows) == 5
    assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)]
    assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"]
    assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"]

def test_known_versions_on_delivery_and_recovery(api):   # сценарий «Догрузка» + GET /agent/commands/{id}
    # после первой загрузки: следующий claim — known_versions == {"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-3": [...]}
    # (порядок — по времени); GET /agent/commands/<id> токеном wiki отдаёт те же known_versions
    # присланная DOCH-12 (DOC-1): added/removed == history.line_delta(md DOCH-11, md DOCH-12)

def test_agent_without_history(api):     # done без versions: копия обновлена (synced_at новее), версии и history_at прежние
def test_page_left_tree(api):            # у DOC-3 версии; новая синхронизация с versions=0 и codes без DOC-3 → версий DOC-3 нет
def test_foreign_page_version_dropped(api):  # версия page_code DOC-99 → не записана, команда done
def test_lost_versions_batch(api):       # прислано 3, versions=5 → failed, error == "нет версий: пришло 3 из 5";
                                         # wiki_page.synced_at и wiki_version прежние, стадии обеих пусты
def test_resend_counts_unique(api):      # одна и та же пачка из 2 версий дважды, versions=2 → done (не «пришло 4 из 2»)
@pytest.mark.parametrize("bad", ["5", True, -1, 1.5])
def test_versions_field_bad(api, bad):   # failed, error == "versions — неотрицательное целое"
def test_late_version_recounts_successor(api):   # есть V1 10:00, V3 12:00; приходит V2 11:00:
    # V2 = line_delta(V1, V2), V3 пересчитана = line_delta(V2, V3)
def test_known_version_resent_ignored(api):      # Review Focus 2: повтор известного кода с другим html — markdown/added прежние
def test_reset_root_drops_history(api):          # PATCH wiki_root=None → wiki_version и wiki_history проекта пусты
def test_redelivery_clears_version_stage(api):   # повторная выдача → wiki_version_stage команды пуста
def test_discarded_report_keeps_history(api):    # корень сменён до отчёта → result.discarded, версии прежние, стадия версий пуста
def test_failed_report_clears_version_stage(api):  # отчёт failed → стадия версий пуста, история прежняя
def test_history_heavy_work_off_write_lock(api, monkeypatch):   # Review Focus 3
    # по образцу test_no_heavy_work_under_write_lock (test_wiki_sync.py): probe() в обёртках stage.convert.to_markdown
    # и sync.history.line_delta; после run_history с 3 версиями — ("delta", "free") в seen, "locked" нет

def test_line_delta():
    assert history.line_delta("", "a\nb\n") == (2, 0)
    assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1)
    assert history.line_delta("a\n", "a\n") == (0, 0)

Ещё test_housekeeping_clears_version_stage (строка стадии версий у cancelled команды удаляется sync.housekeeping).

  • Step 2: Запустить — падают. Run: uv run pytest tests/test_wiki_history.py -q — Expected: FAIL (KeyError: 'known_versions', версии не записаны).

  • Step 3: Реализовать.

    • commands.HOOKS.deliver_params = deliver_params — один хук покрывает и claim, и GET /agent/commands/{id} (core/api.py, h_agent_command).
    • sync.Prepared: поля with_history: bool = False, versions: list, recount: list.
    • sync.prepare после проверки страниц: если в result есть ключ versions — M целое ≥ 0 и не bool, иначе SyncFailed("versions — неотрицательное целое"); N = COUNT(*) стадии версий команды (уникальные коды — ключ (command_id, code)); N < M → SyncFailed(f"нет версий: пришло {N} из {M}"); затем p.versions, p.recount = _plan_versions(...), p.with_history = True. Это до транзакции отчёта (R-6).
    • _plan_versions: из стадии — только page_code in codes и код, которого нет в wiki_version проекта; по каждой затронутой странице — известные версии + новые, сортировка (created_at, code), предыдущий текст первой — ""; новая — line_delta(пред., своя); известная, чей предшественник новый, — пересчёт, в recount если значения другие.
    • sync._commit: при p.with_history — DELETE FROM wiki_version WHERE label = ? AND page_code NOT IN (SELECT value FROM json_each(?)) (коды — json.dumps), INSERT OR IGNORE новых, UPDATE … SET added, removed по recount, INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES (?, now). Всегда — DELETE FROM wiki_version_stage WHERE command_id = ?.
    • Стадию версий чистить там же, где стадию страниц: on_claim (redelivery), on_failed_or_reported_failed, housekeeping, ветка «корень сменился» в on_result.
    • projects._drop_copy: DELETE FROM wiki_version WHERE label = ?, DELETE FROM wiki_history WHERE label = ?.
  • Step 4: Запустить — проходят. Run: uv run pytest tests/test_wiki_history.py tests/test_wiki_sync.py tests/test_wiki_projects.py tests/test_wiki_commands.py -q — Expected: PASS.

  • Step 5: Документы. ARCHITECTURE.md: строка commands — «deliver_params дописывает known_versions»; sync — «запись версий, «+/−» (подсчёт в prepare), history_at»; projects — сброс корня удаляет историю; поток «Синхронизация вики» — шаг «после страниц eva-agent v2 шлёт версии POST /agent/wiki/versions (≤50 в пачке), отчёт done {…, versions}; хаб при выдаче дописывает known_versions; без versions (агент v1) история не меняется»; раздел «Границы» — «Историю (wiki_version, wiki_history) пишет только sync.commit (удаляет — и projects._drop_copy)».

  • Step 6: Commit.

git add src/sdlc_hub/modules/wiki/{history,commands,sync,projects}.py tests/test_wiki_history.py ARCHITECTURE.md
git commit -m "Вики: known_versions при выдаче и запись версий в синхронизации (#140)"

Task 3: Чтение истории — лента, версии, дифф, поля страницы и состояния

Model: haiku

Files:

  • Modify: src/sdlc_hub/modules/wiki/history.py (feed, page_versions, diff, last_change, NoVersions, NoVersion, CHUNK)
  • Modify: src/sdlc_hub/modules/wiki/api.py (h_changes, h_versions, h_diff, поля в h_page, h_status)
  • Modify: tests/fixtures/baseline/routes.expected.json (перезапись)
  • Modify: ARCHITECTURE.md (строка history, api, открытый вопрос), README.md (таблица маршрутов)
  • Test: tests/test_wiki_history_api.py (новый)

Interfaces:

  • Consumes: history.utc, history.history_at, history.line_delta; помощники run_history, version, SOSNOV, MOLCH из test_wiki_history.

  • Produces:

    • history.feed(conn, label, since: str, until: str, author: str | None, limit: int) -> {"truncated": bool, "changes": [...]} — элементы {at, page_code, title, path, version, author: {login, name}, added, removed}, только страницы текущей копии (JOIN wiki_page), at в [since, until], по at ↓, затем version ↓; автор — подстрока casefold имени или логина (в Python, до среза).
    • history.page_versions(conn, label, code) -> list[dict] — [{version, at, author, added, removed}], новые первыми.
    • history.diff(conn, label, code, version: str | None, offset: int) -> dict — поля ответа маршрута, кроме title/path (их добавляет api); NoVersions — у страницы нет версий, NoVersion — нет такой версии. difflib.unified_diff(prev.splitlines(), cur.splitlines(), fromfile=prev_code or "", tofile=code, n=3, lineterm=""), строки через "\n" и "\n" в конце (пустой дифф — ""); часть [offset:offset + CHUNK], CHUNK = 20000.
    • history.last_change(conn, label, code) -> {"modified_at", "modified_by", "versions"} (modified_by — {login, name} или None).
    • маршруты GET ^/wiki/changes$, GET ^/wiki/pages/([^/]+)/versions$, GET ^/wiki/pages/([^/]+)/diff$ (клиент).
  • Step 1: Написать падающие тесты. Фикстура history (через api): синхронизация TREE/CODES с версиями DOC-3 (Соснов) 2026-10-27T17:59:00+03:00, 2026-10-27T18:02:00+03:00 и DOC-2 (Молчанов) 2026-10-26T12:00:00+03:00.

def test_changes_today(api, history):                       # сценарий «Что сделали за сегодня»
    d = api.client.get("/wiki/changes", project="AWPCA", since="2026-10-27T00:00:00+03:00", until="2026-10-28T00:00:00+03:00")
    assert [c["at"] for c in d["changes"]] == ["2026-10-27T15:02:00+00:00", "2026-10-27T14:59:00+00:00"]
    c = d["changes"][0]
    assert c["page_code"] == "DOC-3" and c["title"] == "Статусы" and c["path"] == "АРМ КА/01_Статусы"
    assert c["author"] == {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
    assert isinstance(c["added"], int) and isinstance(c["removed"], int)
    assert d["since"] == "2026-10-26T21:00:00+00:00" and d["truncated"] is False and d["synced_at"] and d["history_at"]

@pytest.mark.parametrize("author", ["соснов", "СОСНОВ", "Sosnov-DVI"])
def test_changes_author_cyrillic_case(api, history, author):   # Review Focus 1 → только две правки DOC-3
def test_changes_default_window(api, history):     # без since/until: until − since == 24 ч (по ответу)
def test_changes_limit_truncated(api, history):    # limit=1 → одна правка, truncated True
@pytest.mark.parametrize("q,param", [({"since": "2026-10-27T00:00:00"}, "since"), ({"until": "вчера"}, "until"),
    ({"limit": "0"}, "limit"), ({"limit": "2001"}, "limit"), ({"limit": "x"}, "limit"),
    ({"since": "2026-10-28T00:00:00Z", "until": "2026-10-27T00:00:00Z"}, "since")])
def test_changes_bad_params(api, history, q, param):   # 422 bad_request, detail.startswith(param)
def test_changes_history_not_loaded(api):          # только синхронизация без versions → 200, history_at None, changes []
def test_changes_no_root_and_unknown(api):         # проект без корня → 409 no_root; неизвестный → 404
def test_versions_list(api, history):              # DOC-3 → versions[0]["at"] == "2026-10-27T15:02:00+00:00", 2 шт.; DOC-99 → 404
def test_diff_last(api):                           # сценарий «Дифф последней правки»: 4 версии DOC-3 →
    # index 4, total 4, prev_version == третий код, diff.startswith(f"--- {v3}\n+++ {v4}\n"), next_offset None
def test_diff_first_version(api, history):         # version=первая → prev_version None, diff.startswith("--- \n+++ "), removed 0
def test_diff_chunks(api):                         # сценарий «Длинный дифф»: версия ~3000 строк; full = diff целиком
    # (склейка частей): offset=0 → len 20000, next_offset 20000; offset=40000 → diff == full[40000:], next_offset None;
    # части 0, 20000, 40000 вместе == full; offset=-1 и "x" → 422 bad_request "offset: целое ≥ 0"
def test_diff_errors(api, history):                # DOC-99 → 404 not_found; version=DOCH-404 → 404 no_version;
                                                   # DOC-1 без версий → 404 no_versions
def test_page_last_change(api):                    # сценарий «Последняя правка в странице»: последняя версия
    # created_at "2026-10-27T23:48:02+03:00" → modified_at "2026-10-27T20:48:02+00:00", modified_by.name "Дмитрий Соснов",
    # versions 4; страница без версий → modified_at None, modified_by None, versions 0
def test_status_history_at(api, history):          # GET /wiki/status → history_at == строке wiki_history; без истории — None
  • Step 2: Запустить — падают. Run: uv run pytest tests/test_wiki_history_api.py -q — Expected: FAIL (404 маршрутов, нет modified_at).

  • Step 3: Реализовать функции из Interfaces и обработчики. h_changes: _project(conn, q); время — свой _time_param(q, name, default) через history.utc (без смещения — 422 bad_request «<имя>: ожидается время ISO-8601 со смещением или Z»); until по умолчанию db.now(), since — until − 24 ч; since > until — «since: позже until»; limit — «limit: целое от 1 до 2000»; ответ {since, until, synced_at, history_at, truncated, changes}. h_diff: offset (по умолчанию 0) — «offset: целое ≥ 0»; NoVersions → ApiError(404, "no_versions", …), NoVersion → "no_version"; страница не в копии — 404 not_found; в ответ добавить code, title, path. h_page — **history.last_change(…); h_status — history_at. Маршруты — после ^/wiki/pages/([^/]+)$.

  • Step 4: Запустить — проходят. Run: uv run pytest tests/test_wiki_history_api.py tests/test_wiki_api.py -q — Expected: PASS.

  • Step 5: Эталон маршрутов. Run: BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff tests/fixtures/baseline/ — Expected: в routes.expected.json плюс три строки, остальные эталоны без изменений.

  • Step 6: Документы. ARCHITECTURE.md: строка history в таблице модуля wiki — «чтение истории: лента feed, версии страницы, дифф по запросу (difflib, части по 20 000), known_versions, line_delta; HTTP не знает»; строка api — новые маршруты; поток «История вики: … → GET /wiki/changes, …/versions, …/diff, MCP changes/diff, веб «Лента» (только база хаба)»; цепочка api → history, sync → history. Открытый вопрос «История страниц вики и частичная перекачка» заменить на «Частичная перекачка: каждая синхронизация перекачивает всё дерево страниц (версии — только новые, change wiki-history)». README.md — строки GET /wiki/changes, GET /wiki/pages/{code}/versions, GET /wiki/pages/{code}/diff, поля modified_at/modified_by/versions у страницы и history_at у состояния.

  • Step 7: Commit.

git add src/sdlc_hub/modules/wiki/{history,api}.py tests/test_wiki_history_api.py tests/fixtures/baseline/ ARCHITECTURE.md README.md
git commit -m "Вики: лента правок, версии и дифф в HTTP API (#140)"

Task 4: MCP вики — changes, diff, «изменено» в page, короткий sync, история в status

Model: haiku

Files:

  • Modify: src/sdlc_hub/core/mcp.py (local_if_naive, since_of)
  • Modify: src/sdlc_hub/modules/jira/mcp.py (берёт помощники из ядра; описания инструментов не меняются)
  • Modify: src/sdlc_hub/modules/wiki/mcp.py (changes, diff, fmt_wiki_changes, page, sync, status)
  • Modify: tests/fixtures/baseline/mcp_wiki_tools.expected.json (перезапись)
  • Modify: ARCHITECTURE.md (строка mcp модуля wiki), README.md (таблица инструментов sdlc-wiki)
  • Test: tests/test_wiki_mcp.py

Interfaces:

  • Consumes: ответы маршрутов Task 3 (/wiki/changes, /wiki/pages/{code}/diff, поля page/status).

  • Produces:

    • core.mcp.local_if_naive(value: str) -> str (перенос jira.mcp._local_if_naive без изменений поведения); core.mcp.since_of(hours: float | None, since: str, now: datetime) -> str — hours → now − hours, пусто → начало суток now, иначе since.
    • инструменты changes(hours: float | None = None, since: str = "", until: str = "", author: str = "") -> str, diff(code: str, version: str = "", offset: int = 0) -> str; порядок инструментов — ["tree", "page", "search", "changes", "diff", "attachment", "sync", "status", "root_set", "projects", "project_create"].
  • Step 1: Написать падающие тесты (tests/test_wiki_mcp.py, фикстура hub и Fake — прежние; loc = lambda iso: datetime.fromisoformat(iso).astimezone(); сегодняшние at строятся из местного времени):

def test_changes_today_grouped(hub):        # сценарий «Что сделали аналитики сегодня»
    t1, t2 = today_at(11, 2), today_at(12, 40)          # ISO UTC сегодняшних 11:02 и 12:40 местного
    fake = hub({"/wiki/changes": {"since": today_at(0, 0), "until": today_at(14, 5), "synced_at": today_at(14, 0),
        "history_at": today_at(14, 0), "truncated": False, "changes": [
        chg("DOC-3", "DOCH-2", t2, 40, 12), chg("DOC-3", "DOCH-1", t1, 5, 0), chg("DOC-2", "DOCH-9", "2026-10-26T09:00:00+00:00", 1, 1, MOLCH)]}})
    lines = wmcp.changes().splitlines()
    assert lines[0] == f"правки вики с {loc(today_at(0,0)):%d.%m %H:%M} по {loc(today_at(14,5)):%d.%m %H:%M}"
    assert lines[1] == f"синхронизировано: {loc(today_at(14,0)):%d.%m %H:%M}"
    assert lines[2:5] == ["DOC-3 Статусы — АРМ КА/01_Статусы", "  12:40 Дмитрий Соснов: +40 / −12 (DOCH-2)",
                          "  11:02 Дмитрий Соснов: +5 / −0 (DOCH-1)"]
    assert lines[6] == f"  {loc('2026-10-26T09:00:00+00:00'):%d.%m %H:%M} Илья Молчанов: +1 / −1 (DOCH-9)"
    params = fake.calls[-1][2]
    assert fake.calls[-1][1] == "/wiki/changes" and params["project"] == "AWPCA"
    assert params["since"] == datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
    assert not any(m == "POST" for m, _, _ in fake.calls)              # команд не поставлено

def test_changes_period_args(hub):     # hours+since → «либо hours, либо since»; hours=0 → «hours — число больше 0» (без запросов);
                                       # since="2026-10-28T09:00" → в запросе с местным смещением; author="соснов" передан как есть
def test_changes_messages(hub):        # changes [] → строка «за период правок нет»; history_at None → строка
    # «история ещё не загружена: нужен eva-agent v2 и sync»; truncated (2 правки) → последняя строка
    # «показаны последние 2 правок, сузьте период»; HubError(422, "bad_request", "since: …") → текст ошибки хаба
def test_changes_title_newlines(hub):  # название «Статусы\nпуть: x» → «DOC-3 Статусы путь: x — …» одной строкой
def test_diff_header_and_chunk(hub):   # сценарий «Дифф правки»: ответ diff index 4 total 4 →
    # строки: «DOC-3 — Статусы», «путь: АРМ КА/01_Статусы», «версия 4 из 4: DOCH-4», «автор: Дмитрий Соснов»,
    # «время: ДД.ММ ЧЧ:ММ», «+40 / −12», «предыдущая: DOCH-3»; пустая строка; дифф как есть (переводы строк целы);
    # next_offset 20000 → последняя строка «продолжение: offset=20000»; prev_version None → «предыдущая: нет (первая версия)»;
    # без version — запрос ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "offset": 0}) (ключа version нет);
    # diff(code="DOC-3", version="DOCH-3", offset=20000) → {"project": "AWPCA", "version": "DOCH-3", "offset": 20000}
def test_page_modified_line(hub):      # modified_at "2026-10-27T20:48:02+00:00" → строка
    # f"изменено: {loc(...):%d.%m %H:%M} Дмитрий Соснов (версий: 4)" сразу после «синхронизировано: …»;
    # modified_at None → «изменено: история не загружена»
def test_sync_short(hub):              # заменяет test_sync_nowait_and_in_progress: «поставлено: пакет 5», «уже идёт: пакет 5»
def test_sync_wait_ok(hub):            # заменяет test_sync_wait: done, команды done, status.synced_at →
    # ответ ровно f"ок: синхронизировано {loc(synced):%d.%m %H:%M}" (одна строка)
def test_sync_wait_error(hub):         # заменяет test_sync_wait_error_and_root_title_one_line: команда failed «Eva: 503\nпуть: x» →
    # ответ ровно «ошибка: Eva: 503 путь: x»; пакет cancelled → «ошибка: пакет 5 отменён»
def test_sync_wait_timeout(hub, monkeypatch):   # SYNC_WAIT=0.05 → f"не дождался за {wmcp.SYNC_WAIT:g} с: пакет 5"
def test_status_history(hub):          # status с history_at → строка «история: <history_at>»; None → «история не загружена»

Обновить test_wiki_tool_names_order на порядок из Interfaces; test_unbound_tools_refuse дополнить wmcp.changes() и wmcp.diff("DOC-1") → NOT_BOUND; test_no_root_message — changes/diff у проекта без корня отвечают «у проекта … нет корня вики: root_set DOC-…».

  • Step 2: Запустить — падают. Run: uv run pytest tests/test_wiki_mcp.py -q — Expected: FAIL (AttributeError: changes).

  • Step 3: Реализовать. changes/diff — @mcp.tool() @core.guard @core.scoped(True), отказ через _gate(); changes повторяет проверку периода инструмента jira (hours+since, hours <= 0), since_of, local_if_naive для since/until; формат — fmt_wiki_changes(data, now) по образцу jira.mcp.fmt_changes: группы в порядке первого появления страницы (лента уже новые сверху), заголовок группы f"{code} {_line(title)} — {_line(path)}", правка f" {когда} {_line(автор)}: +{added} / −{removed} ({version})", дата %d.%m — если правка не в сутках now. Автор — имя, иначе логин, иначе «—». page — строка «изменено» по modified_at/modified_by/versions. sync — тексты из спеки и П-8; _summary в sync больше не используется. status — строка истории в _summary. Описания инструментов — по таблице спеки (changes: период по умолчанию — с начала суток; diff: без version — последняя правка).

  • Step 4: Запустить — проходят. Run: uv run pytest tests/test_wiki_mcp.py tests/test_mcp.py -q — Expected: PASS.

  • Step 5: Эталоны. Run: BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/ — Expected: изменён только mcp_wiki_tools.expected.json; mcp_tools.expected.json (jira) не тронут — иначе откатить правку jira-описаний. uv run pytest tests/test_baseline.py tests/test_registry.py -q — PASS.

  • Step 6: Документы. ARCHITECTURE.md, строка mcp модуля wiki — плюс changes, diff; «sync отвечает коротко; page, changes, diff читают только копию». README.md — строки changes, diff, новая шапка page, ответ sync, status.

  • Step 7: Commit.

git add src/sdlc_hub/core/mcp.py src/sdlc_hub/modules/{jira,wiki}/mcp.py tests/test_wiki_mcp.py tests/fixtures/baseline/ ARCHITECTURE.md README.md
git commit -m "MCP вики: changes, diff, «изменено» в page, короткий sync (#140)"

Task 5: Веб — общая «Лента» ядра и источник Jira

Model: sonnet

Files:

  • Modify: web/src/modules/types.ts (типы источника ленты, HubModule.feed)
  • Modify: web/src/modules/index.ts (FEED_MODULES, useFeeds)
  • Create: web/src/shell/feed/period.ts, period.test.ts (перенос из modules/jira/feed/events.ts и его тестов)
  • Create: web/src/shell/feed/sources.ts, sources.test.ts
  • Create: web/src/shell/feed/FeedPage.tsx, feed.css, FeedPage.test.tsx (перенос тестов modules/jira/feed/FeedPage.test.tsx)
  • Modify: web/src/ui/format.ts (+ eventTime), web/src/ui/format.test.ts
  • Modify: web/src/shell/nav.tsx (FEED), web/src/shell/Sidebar.tsx, web/src/shell/pages.tsx
  • Create: web/src/modules/jira/feed/source.tsx (jiraFeed), web/src/modules/jira/feed/FeedGroup.tsx
  • Delete: web/src/modules/jira/feed/FeedPage.tsx, web/src/modules/jira/feed/FeedPage.test.tsx
  • Modify: web/src/modules/jira/feed/events.ts, events.test.ts, EventList.tsx, feed.css, web/src/modules/jira/index.tsx, web/src/modules/jira/Summary.tsx
  • Modify tests: web/src/modules/modules.test.tsx, web/src/shell/Sidebar.test.tsx, web/src/shell/OverviewPage.test.tsx, web/src/modules/jira/agile/BacklogPage.test.tsx (меню без «Ленты»), web/src/boundary.test.ts
  • Modify: web/e2e/feed.spec.ts (адрес /app/feed), ARCHITECTURE.md (раздел «Веб»), CLAUDE.md (адреса), README.md (адреса)

Interfaces:

  • Produces (в web/src/modules/types.ts):
export type FeedBounds = { since: Date; until: Date };
export type FeedSourceInput = {
  project: Project;
  period: string;                 // ключ кэша: "2h" | "today" | "yesterday" | "7d"
  bounds: () => FeedBounds;       // границы на момент запроса (перечитывание берёт «до сейчас»)
  enabled: boolean;               // источник выбран и доступен; false — запросов нет
};
export type FeedGroup = { key: string; at: string; node: ReactNode };   // at — ISO последнего события группы
export type FeedResult = {
  groups: FeedGroup[];
  count: number;                                        // событий в ответе — для подзаголовка
  window: { since: string; until: string } | null;      // границы из ответа хаба; null — данных нет
  pending: boolean;
  error: { message: string; retry(): void } | null;
  truncated: boolean;
  notice: string | null;          // своё сообщение на месте событий источника (тогда groups пусты)
};
export type FeedSource = {
  available(p: Project): boolean;
  useFeed(input: FeedSourceInput): FeedResult;   // хук: ядро вызывает у всех модулей с feed в постоянном порядке
  Controls?: ComponentType;                       // свои настройки над лентой; видны, пока источник выбран
};
// HubModule: feed?: FeedSource — id источника в адресе sources= — имя модуля (m.name), подпись флажка — m.title
  • modules/index.ts: FEED_MODULES: readonly HubModule[] (модули с feed, порядок MODULES), useFeeds(inputs: FeedSourceInput[]): FeedResult[] (по образцу useOverlays).

  • shell/feed/period.ts: Period, PERIODS, parsePeriod, periodRange — без изменений поведения.

  • shell/feed/sources.ts: parseSources(v: string | null, available: string[]): string[] («none» → []; неизвестные и недоступные отбрасываются; ни одного — все доступные), sourcesParam(selected: string[], available: string[]): string | null (порядок — как available; все доступные → null; пусто → "none").

  • ui/format.ts: eventTime(at: string | null, now: Date, alwaysDate = false): string (перенос из jira events.ts).

  • shell/nav.tsx: FEED: NavItem = { to: "/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true }; NAV = [OVERVIEW, FEED, ...].

  • jira/feed/source.tsx: jiraFeed: FeedSource — available: () => true; useFeed читает kinds из адреса (parseKinds), запрос — прежний useChanges(...) с enabled: input.enabled && kinds.length > 0; notice — «выберите хотя бы один вид», если источник выбран и видов нет; группы — groupByIssue, at — время первого события группы; Controls — флажки видов (role="group", aria-label="Виды событий", класс feed__kinds, kinds=none для пустого набора).

  • Step 1: Перенести тесты и написать новые (Vitest, App с mockFetch, как в прежнем FeedPage.test.tsx; в hub() добавить GET /wiki/changes — счётчик вызовов, ответ {since:"",until:"",synced_at:null,history_at:null,truncated:false,changes:[]}). Все прежние тесты ленты переносятся с адресом /app/feed; изменения ожиданий:

    • «Что закрылось за два часа» (сценарий спеки): проект A с wiki_root: "DOC-1"; клики «2 часа», снять «Вики», снять «Описание», «Сроки», «Комментарии» → location.search === "?project=AWPCA&period=2h&sources=jira&kinds=status", pathname === "/app/feed", последний GET /changes с kinds=status, GET /wiki/changes после снятия «Вики» не было.
    • truncated: текст «Jira: показаны последние 1 событий — выберите период короче» (П-11); ошибка — «Jira: сбой» с «Повторить».
    • шапка: вторичная «Обновить из Jira», primary нет; у проекта с вики — и «Обновить вики» (П-10).
    • новые: «Проект без вики»: флажков источников нет, GET /wiki/changes нет (sfm_kedr без wiki_root); сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none; «Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить» (источник вики появится в Task 6: тест помечается it.todo здесь и включается в Task 6); /app/jira/feed?project=AWPCA&period=7d → адрес /app/feed?project=AWPCA&period=7d (replace); смена проекта: period, sources, kinds сохраняются (keepSearch).
    • period.test.ts — прежние тесты periodRange/parsePeriod из events.test.ts (сценарий «Вчера»: 22.10 14:05 → since 21.10 00:00, until 22.10 00:00). sources.test.ts:
expect(parseSources(null, ["jira", "wiki"])).toEqual(["jira", "wiki"]);
expect(parseSources("wiki,foo", ["jira", "wiki"])).toEqual(["wiki"]);
expect(parseSources("wiki", ["jira"])).toEqual(["jira"]);          // недоступный → умолчание
expect(parseSources("none", ["jira", "wiki"])).toEqual([]);
expect(sourcesParam(["wiki", "jira"], ["jira", "wiki"])).toBeNull();
expect(sourcesParam(["jira"], ["jira", "wiki"])).toBe("jira");
expect(sourcesParam([], ["jira", "wiki"])).toBe("none");
  • modules.test.tsx: PAGE_ROUTES → ["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]; пункты модулей без ["Лента", "/jira/feed"]; NotFound — 4 ссылки («Обзор», «Лента» + два раздела).

  • Sidebar.test.tsx: «Лента» — сразу после «Обзора», вне групп, href="/app/feed?project=AWPCA", aria-current="page" на /app/feed.

  • OverviewPage.test.tsx: ссылка «Лента» сводки Jira — /app/feed?project=AWPCA&sources=jira.

  • BacklogPage.test.tsx: меню группы «Jira» — ["Связи", "Бэклог", "Спринт", "Задачи"] и ["Связи", "Задачи"].

  • boundary.test.ts: MODULE_TERMS дополнить ChangeKind|kinds|history_at|page_code|prev_version; детектор — expect(MODULE_TERMS.test("kinds: string")).toBe(true).

  • Step 2: Запустить — падают. Run: cd web && npx vitest run src/shell src/modules src/boundary.test.ts src/ui — Expected: FAIL (нет shell/feed/*).

  • Step 3: Реализовать.

    • FeedPage({ project }: { project: Project }) в shell/feed/FeedPage.tsx, data-testid="page-feed", классы feed-page* (в shell/feed/feed.css, токены AURA; классы feed, feed__controls, feed__hint убрать из jira feed.css). Доступные = FEED_MODULES с available(project); выбранные = parseSources; ввод источника — {project, period, bounds: () => periodRange(period, new Date()), enabled}; useFeeds. Сверху: PageHeader «Лента», подзаголовок ${count} ${plural(count)} · ${fmtStamp(since)} — ${fmtStamp(until)} по сумме count и первому window выбранных; действия — вторичные кнопки обновления выбранных модулей с status.refresh (компонент на модуль, useModuleActivity(m.name)). Ряд управления: Segmented периода; флажки источников (role="group", aria-label="Источники", подписи m.title) — только если доступных больше одного; Controls выбранных источников. Сообщения и пустой период — по П-11; группы всех выбранных — одним списком по at ↓ (при равенстве — порядок FEED_MODULES), ключ ${m.name}:${g.key}.
    • Маршрут /feed в PAGE_ROUTES после / (ProjectGate, key={p.label}). Sidebar: link(FEED) после link(OVERVIEW, first).
    • jira: FeedGroup — прежняя разметка группы (Panel feed-group, data-testid="feed-group", IssueLink, тема, статус, EventList); events.ts без периода, eventTime — из ui/format.ts; index.tsx — без страницы и пункта «Лента», { path: "/jira/feed", element: <LegacyRedirect to="/feed" /> } вместо /feed, feed: jiraFeed; Summary — «Лента» на /feed${q}&sources=jira. Проверить, что история в карточке задачи (EventList) не изменилась.
    • e2e feed.spec.ts: адреса /app/feed?project=AWPCA… (поведение прежнее).
  • Step 4: Запустить — проходят. Run: cd web && npm run lint && npm test — Expected: PASS (кроме it.todo).

  • Step 5: Документы. ARCHITECTURE.md, раздел «Веб»: контракт HubModule — плюс feed (источник ленты: ядро ведёт период, источники, порядок групп; модуль — запросы, группы, Controls); страница ядра «Лента» — web/src/shell/feed/; адреса — /app/feed, прежний /app/jira/feed перенаправляется; модуль jira — без страницы «Лента», feed/ — источник ленты и EventList карточки. CLAUDE.md, строка адресов: «лента изменений — /app/feed?project=<метка> (источники Jira и Вики)», прежние адреса — /app/board, /app/jira/board, /app/issues — на страницы модуля Jira, /app/jira/feed — на /app/feed; строка «Веб»: «модуль jira — страницы доски и задач; лента — страница ядра». README.md — адреса так же.

  • Step 6: Commit.

git add web/src web/e2e/feed.spec.ts ARCHITECTURE.md CLAUDE.md README.md
git commit -m "Веб: общая «Лента» ядра с источниками модулей, источник Jira (#140)"

Task 6: Веб — источник «Вики» в ленте

Model: sonnet

Files:

  • Create: web/src/modules/wiki/paths.ts (wikiPageHref, versionHref, VERSION_PATH)
  • Create: web/src/modules/wiki/feed/source.tsx (wikiFeed), web/src/modules/wiki/feed/group.ts, group.test.ts, web/src/modules/wiki/feed/WikiFeedGroup.tsx, web/src/modules/wiki/feed/feed.css
  • Modify: web/src/modules/wiki/api/queries.ts (WikiChange, WikiChanges, wikiQk.changes, useWikiChanges, history_at в WikiStatus)
  • Modify: web/src/modules/wiki/index.tsx (feed: wikiFeed), web/src/modules/wiki/Summary.tsx (ссылка «Правки»)
  • Test: web/src/shell/feed/FeedPage.test.tsx (включить it.todo из Task 5 и новые), web/src/modules/wiki/Summary.test.tsx

Interfaces:

  • Consumes: FeedSource, FeedSourceInput, FeedResult (Task 5), eventTime (ui/format.ts), GET /wiki/changes (Task 3).

  • Produces:

    • wikiPageHref(project: string, code: string): string → /wiki?project=<метка>&page=<код> (через URLSearchParams); versionHref(project: string, code: string, version: string): string → /wiki/pages/<код>/versions/<версия>?project=<метка> (сегменты — encodeURIComponent); VERSION_PATH = "/wiki/pages/:code/versions/:version".
    • WikiChange = { at: string; page_code: string; title: string | null; path: string | null; version: string; author: { login: string | null; name: string | null }; added: number; removed: number }; WikiChanges = { since; until; synced_at; history_at: string | null; truncated: boolean; changes: WikiChange[] }.
    • wikiQk.changes(p, period) = ["wiki", p, "changes", period] (перечитывается wikiStatus.invalidate по префиксу проекта).
    • useWikiChanges(project, period, bounds, enabled) — GET /wiki/changes {project, since, until, limit: 2000}, refetchOnWindowFocus: true.
    • groupByPage(changes: WikiChange[]): { code, title, path, changes: WikiChange[] }[] — по первому появлению; deltaText(c) = "+" + added + " / −" + removed; authorName(a) — имя, иначе логин, иначе «—».
    • wikiFeed: FeedSource — available: (p) => !!p.wiki_root; notice при history_at === null — «история вики ещё не загружена — нужен eva-agent v2».
  • Step 1: Написать падающие тесты.

    • group.test.ts: группировка двух правок DOC-3 и одной DOC-2 → ["DOC-3", "DOC-2"], правки DOC-3 в порядке ответа; deltaText({added: 40, removed: 12}) → "+40 / −12"; authorName({login: "x", name: null}) → "x", оба null → "—".
    • FeedPage.test.tsx (проект AWPCA с wiki_root):
      • «Правка из ленты»: группа — название ссылкой на /app/wiki?project=AWPCA&page=DOC-101252 и путь; правка «12:40 Дмитрий Соснов +40 / −12» — ссылка на /app/wiki/pages/DOC-101252/versions/DOCH-2?project=AWPCA; запрос GET /wiki/changes с project=AWPCA, since = полночь, limit=2000.
      • «Jira и Вики вперемешку»: AURORA-1 10:00, DOC-3 11:00, AURORA-2 12:00 (сегодня) → группы feed-group сверху вниз AURORA-2, DOC-3, AURORA-1.
      • «История не загружена»: history_at: null → «история вики ещё не загружена — нужен eva-agent v2»; события Jira на месте.
      • «Ошибка одного источника» (из it.todo): /wiki/changes 500 → события Jira, «Вики: …» и «Повторить»; повтор перезапрашивает только /wiki/changes.
      • «Пустой период»: обе ленты пусты → «за период изменений нет».
      • смена проекта на sfm_kedr при sources=wiki → источник Jira, флажков нет, GET /wiki/changes по sfm_kedr нет.
    • Summary.test.tsx: у проекта с корнем ссылка «Правки» → /app/feed?project=AWPCA&sources=wiki.
  • Step 2: Запустить — падают. Run: cd web && npx vitest run src/shell/feed src/modules/wiki — Expected: FAIL.

  • Step 3: Реализовать. WikiFeedGroup — Panel с классом wiki-feed-group и data-testid="feed-group", шапка: Link на wikiPageHref (название, иначе код) и путь; список правок — Link на versionHref со state={{ background: { pathname, search }, depth: 1 }} (текущий адрес страницы), текст: время (eventTime(at, now), now — момент ответа), имя автора, deltaText. Классы — только wiki-feed-* в modules/wiki/feed/feed.css (токены, перенос длинных названий overflow-wrap:anywhere). useFeed: count — changes.length, window — {since, until} ответа, truncated, ошибка с refetch, at группы — at первой правки.

  • Step 4: Запустить — проходят. Run: cd web && npm run lint && npm test — Expected: PASS.

  • Step 5: Commit.

git add web/src
git commit -m "Веб: правки вики в общей ленте (#140)"

Task 7: Веб — окно правки с диффом поверх ленты, e2e

Model: sonnet

Files:

  • Create: web/src/modules/wiki/versions/overlay.tsx (useVersionOverlay), versionState.ts, versionState.test.ts
  • Create: web/src/modules/wiki/versions/VersionModal.tsx, diffLines.ts, diffLines.test.ts, versions.css, VersionModal.test.tsx
  • Modify: web/src/modules/wiki/api/queries.ts (WikiDiff, WikiVersion, useWikiDiff, useWikiVersions, ключи)
  • Modify: web/src/modules/wiki/index.tsx (useOverlay: useVersionOverlay)
  • Modify: web/scripts/seed-hub.py (две версии DOC-9003 сегодня и wiki_history), web/e2e/wiki.spec.ts
  • Modify: ARCHITECTURE.md (модуль wiki в разделе «Веб»)

Interfaces:

  • Consumes: versionHref, wikiPageHref, VERSION_PATH (Task 6); GET /wiki/pages/{code}/diff, …/versions (Task 3); Overlay (modules/types.ts), Dialog (ui).

  • Produces:

    • VersionState = { background: { pathname: string; search: string }; depth: number; direct: boolean }; versionState(s: unknown): VersionState | null (как cardState у jira — своя копия, импорт чужого модуля запрещён); pageBackground(code, search) → { pathname: "/wiki", search: "?project=<из search>&page=<код>" }.
    • useVersionOverlay(): Overlay — по VERSION_PATH: нет state — replace той же записи с {background: pageBackground(…), depth: 0, direct: true}; pageLocation = фон; закрытие — depth ≥ 1 → navigate(-depth), иначе navigate(фон, { replace: true }); переход к соседней правке — navigate(versionHref(…), { replace: true, state }) (то же state, «назад» закрывает окно).
    • diffLines(text: string): { kind: "head" | "hunk" | "add" | "del" | "ctx"; text: string }[] — ---/+++ → head, @@ → hunk, + → add, - → del, прочее → ctx; хвостовой пустой строки нет.
    • useWikiDiff(project, code, version) — useInfiniteQuery, pageParam — offset (0, затем next_offset); useWikiVersions(project, code) — GET …/versions.
  • Step 1: Написать падающие тесты.

    • diffLines.test.ts: "--- A\n+++ B\n@@ -1 +1 @@\n-x\n+y\n z\n" → kinds ["head","head","hunk","del","add","ctx"].
    • versionState.test.ts: мусорный state → null; pageBackground("DOC-1", "?project=AWPCA") → {pathname: "/wiki", search: "?project=AWPCA&page=DOC-1"}.
    • VersionModal.test.tsx (App, mockFetch; versions — DOCH-4…DOCH-1 новыми первыми, diff по version):
      • «Правка из ленты»: окно — «правка 4 из 4», Соснов, 12:40, «+40 / −12», строки диффа с классами add/del и подписью «добавлено»/«удалено» для экранных читалок; запрос diff с version=DOCH-4; лента под окном (page-feed).
      • «Предыдущая правка»: правка 3 из 4, «Следующая правка» доступна, «Предыдущая» у первой недоступна; history.back закрывает окно, адрес — лента с прежними period и sources.
      • Escape и кнопка «Закрыть» закрывают окно — адрес ленты прежний.
      • «Окно по ссылке»: /app/wiki/pages/DOC-101252/versions/DOCH-3159076?project=AWPCA — окно поверх «Вики» с DOC-101252; закрытие → /app/wiki?project=AWPCA&page=DOC-101252.
      • «Длинный дифф»: next_offset 20000 — кнопка «Показать дальше», запрос offset=20000, вторая часть ниже первой; next_offset null — кнопки нет.
      • «Показать дальше»: часть рвёт строку — строка целая и одна (Review Focus 5: первая часть кончается на "+строка дл", вторая начинается "инная\n" → один элемент add с текстом "+строка длинная").
      • «Открыть страницу» ведёт на /app/wiki?project=AWPCA&page=DOC-101252.
      • окно ничего не пишет: за всё время теста нет запросов, кроме GET.
    • web/e2e/wiki.spec.ts: правка из ленты: окно с диффом, «Предыдущая правка», назад — лента (сид: две версии DOC-9003); окно правки на 390 px: горизонтальной прокрутки страницы нет (document.documentElement.scrollWidth <= clientWidth).
  • Step 2: Запустить — падают. Run: cd web && npx vitest run src/modules/wiki — Expected: FAIL.

  • Step 3: Реализовать. VersionModal — Dialog (size="lg", класс wiki-version-modal), закрытие Escape/кнопкой/ щелчком по фону — как IssueModal (нажатие и отпускание на фоне); шапка: h2 название, путь, «правка N из M», автор (authorName), время (eventTime(at, now)), deltaText; кнопки «Предыдущая правка» (старее: следующая в списке versions), «Следующая правка» (новее), ссылка «Открыть страницу» (wikiPageHref). Дифф — склейка diff всех загруженных частей, затем diffLines; строки — моноширинный шрифт, white-space: pre-wrap; overflow-wrap: anywhere (без горизонтальной прокрутки страницы); add — --color-status-success на --color-status-success-bg, del — --color-status-error на --color-status-error-bg, head/hunk — --color-text-secondary; подписи add/del — visually-hidden «добавлено: »/«удалено: ». Классы — только wiki-version-* в versions.css. «Показать дальше» — fetchNextPage, пока next_offset !== null. Сид: INSERT INTO wiki_version двух версий DOC-9003 (сегодня, Markdown отличается строкой) и wiki_history AWPCA.

  • Step 4: Запустить — проходят. Run: cd web && npm run lint && npm test && npx playwright test e2e/wiki.spec.ts e2e/feed.spec.ts — Expected: PASS. Если браузеры Playwright не установлены (npx playwright install chromium недоступен) — отметить O-n в ledger и не считать задачу выполненной без прогона e2e у контроллера.

  • Step 5: Документы. ARCHITECTURE.md, абзац модуля wiki в разделе «Веб»: источник ленты (feed/), окно правки /app/wiki/pages/{code}/versions/{version} поверх страницы (versions/overlay.tsx, фон — location.state.background, прямой адрес — поверх «Вики» с этой страницей), дифф versions/.

  • Step 6: Commit.

git add web/src web/scripts/seed-hub.py web/e2e/wiki.spec.ts ARCHITECTURE.md
git commit -m "Веб: окно правки вики с диффом поверх ленты (#140)"

Task 8: Проверка всего набора

Model: haiku

Files: без новых изменений; правки — только если проверка нашла расхождение (тогда — отдельный коммит с маркером находки).

  • Step 1: Хаб. Run: uv run pytest -q — Expected: все тесты PASS, предупреждений о незакрытых соединениях нет.
  • Step 2: Границы и эталоны. Run: uv run pytest tests/test_registry.py tests/test_baseline.py -q && git diff main --stat -- tests/fixtures/baseline/ — Expected: PASS; изменены schema, routes, mcp_wiki_tools; mcp_tools.expected.json и settings.expected.json — нет.
  • Step 3: Фронт. Run: make check — Expected: pytest, npm run lint и npm test зелёные.
  • Step 4: Сборка и e2e. Run: cd web && npm run build && npx playwright test — Expected: сборка без ошибок tsc, e2e PASS.
  • Step 5: Сверка документов. Run: grep -n "jira/feed\|История страниц вики" CLAUDE.md ARCHITECTURE.md README.md — Expected: /app/jira/feed упоминается только как прежний адрес с перенаправлением; открытого вопроса «История страниц вики» нет.
  • Step 6: Итог. Записать в ledger результат каждой команды (число тестов). Коммита нет, если правок не было.

Self-review

  • Покрытие спеки. specs/wiki «История правок страниц» — Task 1 (форма версий, 409/422, перевод 📎), Task 2 (выдача known_versions, запись в commit, «+/−» и пересчёт следующей, удаление вне дерева, history_at, «пришло N из M», агент v1, отброшенный отчёт, сброс корня, очистка стадии), все шесть сценариев — тесты Task 2. «Маршруты истории вики» — Task 3, все шесть сценариев. specs/mcp «MCP вики» — Task 4 (таблица инструментов, период changes, строки в одну линию, sync, status, сценарии «Что сделали аналитики», «Дифф правки», «Синхронизация с ожиданием», «История ещё не загружена»; сервер jira без изменений — эталон). specs/web-app «Лента изменений» и «Оболочка и адреса» — Task 5 (страница ядра, источники, флажки, адрес, перенаправление, пункт «Лента», NotFound), Task 6 («Jira и Вики вперемешку», «Проект без вики», «Ошибка одного источника», «Пустой период»), «Обзор проекта» — Task 5 (Jira) и Task 6 («Правки»), «Спринты и бэклог» (меню без «Ленты») — Task 5, «Правки вики в ленте» — Task 6 (группа, история не загружена, контракт) и Task 7 (окно, все сценарии). ARCHITECTURE.md/CLAUDE.md/README.md — шаги «Документы» Task 1–5, 7.
  • Шаги. Тела функций не выписаны там, где их задают сигнатура и тест; алгоритм пересчёта «+/−» и правила сообщений ленты описаны, потому что тесты их не задают целиком.
  • Типы. history.utc/known_versions/line_delta/history_at/feed/page_versions/diff/last_change — одинаково в Task 1–4; FeedSource/FeedResult/FeedSourceInput — Task 5, используются в Task 6; versionHref/wikiPageHref/VERSION_PATH — Task 6 → 7.
  • Review Focus. Пять пунктов с тестами в задачах-владельцах.
  • Пропорция. План ~ того же порядка, что спека (≈ 750 строк), кода — сигнатуры, SQL схемы и утверждения тестов.