diff --git a/docs/superpowers/plans/2026-10-28-wiki-history.md b/docs/superpowers/plans/2026-10-28-wiki-history.md new file mode 100644 index 0000000..4807a2e --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-wiki-history.md @@ -0,0 +1,843 @@ +# История правок вики, общая лента и дифф — план реализации + +> **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 схемы и утверждения тестов.