# История правок вики, общая лента и дифф — план реализации > **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` «история: » / «история не загружена», `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: - таблицы: ```sql 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: ```python 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="

текст

", 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) ``` Тесты и утверждения: ```python 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", "

статус новый

")]}) 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`: ```python 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('

т

', [], [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", "<код версии>: <поле>")` (до кода — `"версия : …"`); список — «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.** ```bash 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`; помощник обмена): ```python 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 ``` ```python 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", "

а

" * (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/ токеном 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.** ```bash 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`. ```python 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.** ```bash 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` строятся из местного времени): ```python 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 → строка «история: »; 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.** ```bash 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`): ```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: , 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`: ```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: }` вместо `/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.** ```bash 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.** ```bash 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.** ```bash 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 схемы и утверждения тестов.