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

844 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# История правок вики, общая лента и дифф — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** хаб хранит опубликованные версии страниц дерева проекта и отдаёт ленту правок и дифф через HTTP API, MCP
`sdlc-wiki` и общую страницу «Лента» веба с источниками «Jira» и «Вики» (sdlc-hub#140).
**Architecture:** модуль `wiki` хаба получает приём версий от агента (`stage.accept_versions`, `POST /agent/wiki/versions`),
запись версий в той же транзакции, что и копия (`sync.prepare` считает «+/−» вне транзакции, `sync._commit` пишет), и
модуль чтения `history.py` (лента, версии, дифф по запросу). В вебе страница «Лента» переезжает в ядро
(`web/src/shell/feed/`), модули дают ей источники новым полем контракта `HubModule.feed`; модуль wiki добавляет источник и
окно диффа поверх страницы. Агентская часть (eva-agent v2) — отдельный план; контракт — specs/wiki «История правок страниц».
**Tech Stack:** Python 3.13 (uv), stdlib `difflib`/`sqlite3`, pytest; React 19 + TypeScript, TanStack Query, Vitest,
Testing Library, Playwright.
**Ветка:** `feat/140-wiki-history` (worktree `sdlc-hub/.wt/wiki-history`).
**Spec:** `openspec/changes/wiki-history/` (proposal.md, design.md — решения 1–15 и «Decisions made without discussion»,
specs/wiki/spec.md, specs/mcp/spec.md, specs/web-app/spec.md). Текущее поведение — `openspec/specs/`, карта — `ARCHITECTURE.md`.
## Global Constraints
- Только хаб `sdlc-hub`; eva-agent и jira-agent не меняются. `SCHEMA_VERSION` не меняется: новые таблицы модуля
создаются `CREATE TABLE IF NOT EXISTS` в `modules/wiki/schema.py` (решение плана П-1).
- Ядро Python не знает терминов модулей: в `src/sdlc_hub/core/` нет `eva`, `wiki_`, `wiki/`, `DOC-` и импортов
`sdlc_hub.modules` (`tests/test_registry.py`). Новые имена в ядре — без названий модулей.
- Веб: ядро (`web/src/{shell,ui,api,projects,auth,styles}`) не импортирует модули (кроме `modules/index.ts`, `modules/types.ts`)
и не знает их терминов; модуль не импортирует другой модуль; класс CSS одного владельца не объявляется у другого
(`web/src/boundary.test.ts`).
- Время в базе — UTC в формате `db.now()` (`2026-10-27T20:48:02+00:00`, секунды без дробной части).
- Знак «−» в «+A / −R» — U+2212 (как в спеке), в MCP и вебе.
- Части текста — по 20 000 символов (`CHUNK`), как у `page`.
- Пачка версий — не больше 50; `limit` ленты — по умолчанию 500, 1–2000.
- Чтение истории (`GET /wiki/changes`, `…/versions`, `…/diff`, MCP `changes`/`diff`/`page`) не ставит команд.
- Тексты ошибок, комментарии, сообщения коммитов — по-русски; идентификаторы — английские.
- Эталоны (`tests/fixtures/baseline/*.expected.json`) перезаписываются только `BASELINE_WRITE=1 uv run pytest
tests/test_baseline.py` в задаче, которая меняет маршруты/схему/инструменты; эталон `mcp_tools` (сервер jira) не меняется.
- Соединения SQLite в тестах — через фикстуры `open_db`/`api` или с явным `close()`.
- Зелёным считается только весь набор: `make check` (`uv run pytest -q` + `cd web && npm run lint && npm test`).
## Review Focus
1. Фильтр автора кириллицей без учёта регистра (`author=СОСНОВ`, `author=соснов`): SQLite `LOWER`/`LIKE` знают только
ASCII — фильтр должен находить «Дмитрий Соснов». Тест `test_changes_author_cyrillic_case` в Task 3.
2. Агент переотправил ту же пачку версий (сетевая ошибка, 5xx) или прислал уже известный хабу код: в стадии одна строка
на код, «пришло N из M» считает уникальные коды, в истории дубля нет и «+/−» прежние. Тесты
`test_versions_resend_same_batch` (Task 1) и `test_known_version_resent_ignored` (Task 2).
3. Тяжёлая работа вне блокировки записи (рулинг R-6): перевод HTML версий и подсчёт «+/−» по сотням версий первой
загрузки не идут внутри `BEGIN IMMEDIATE`. Тест `test_history_heavy_work_off_write_lock` в Task 2.
4. Время версии от Eva с дробными секундами, `Z` или `+0300` нормализуется в UTC без дробей; без смещения — 422.
Тест `test_versions_created_at_forms` в Task 1.
5. Дифф частями: часть может оборваться посреди строки — окно склеивает части и показывает строку один раз и целиком.
Тест `«Показать дальше»: часть рвёт строку — строка целая и одна` в Task 7.
## Решения плана (записать в design.md)
Task 1, шаг 6 дописывает их в `openspec/changes/wiki-history/design.md`, раздел «Decisions made without discussion»
(дата 28.10.2026, решение, обоснование, цена ошибки — коротко, по образцу раздела):
- П-1 `history_at` — своя таблица `wiki_history(label, history_at)` без миграции. Цена: ещё одна таблица на проект.
- П-2 Версия, код которой уже есть в хабе, не перезаписывается (`INSERT OR IGNORE`, design 1 — опубликованная неизменна);
в стадии повтор кода заменяет строку (`INSERT OR REPLACE`), N в «пришло N из M» — число уникальных кодов в стадии.
- П-3 `result.versions` не целое ≥ 0 (строка, `true`, −1) — команда `failed` «versions — неотрицательное целое»; присланных
больше `M` — применяются все.
- П-4 «+/−» считаются в `sync.prepare` (вне транзакции), пишутся в `sync._commit`; число — строки `+`/`-` из
`difflib.unified_diff` (те же, что в диффе правки).
- П-5 Время версии: дробные секунды отбрасываются; равное время — порядок по коду строкой (спека: «затем по коду»).
- П-6 Фильтр автора — в Python (`casefold`) до среза `limit`.
- П-7 `GET …/diff`: `offset` не целое ≥ 0 — 422 `bad_request` «offset: целое ≥ 0»; `offset` за концом — `diff = ""`,
`next_offset = null`.
- П-8 MCP: шапка `diff`, строка `status` «история: <history_at>» / «история не загружена», `sync` при отменённом пакете —
«ошибка: пакет N отменён», время в «ок: синхронизировано …» — `synced_at` из `GET /wiki/status`; автор правки — имя,
иначе логин, иначе «—» (и в вебе).
- П-9 Помощники времени MCP (`local_if_naive`, `since_of`) — в `core/mcp.py`, сервер jira берёт их оттуда.
- П-10 Веб: в шапке «Ленты» — вторичные кнопки обновления модулей выбранных источников («Обновить из Jira», «Обновить вики»).
- П-11 Веб: сообщения `truncated` и ошибки источника — с префиксом «<Источник>: »; «выберите хотя бы один вид» и
«история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни один выбранный источник не
грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет; `sources=none` — явный пустой набор
(как `kinds=none`).
- П-12 Веб: `eventTime` переезжает в `ui/format.ts`, период (`PERIODS`, `parsePeriod`, `periodRange`) — в `shell/feed/period.ts`;
прежнее перенаправление `/app/feed` → `/app/jira/feed` снимается (адрес теперь страница ядра).
---
### Task 1: Схема версий, перевод без id вложений, приём `POST /agent/wiki/versions`
**Model:** haiku
**Files:**
- Modify: `src/sdlc_hub/modules/wiki/schema.py` (таблицы `wiki_version`, `wiki_version_stage`, `wiki_history`)
- Create: `src/sdlc_hub/modules/wiki/history.py` (пока только `utc`)
- Modify: `src/sdlc_hub/modules/wiki/convert.py` (`to_markdown(..., file_links=True)`)
- Modify: `src/sdlc_hub/modules/wiki/stage.py` (`accept_versions`, `MAX_VERSIONS`, `MAX_VERSION_STAGE`)
- Modify: `src/sdlc_hub/modules/wiki/api.py` (`h_agent_versions`, маршрут)
- Modify: `tests/fixtures/baseline/schema.expected.json`, `tests/fixtures/baseline/routes.expected.json` (перезапись)
- Modify: `openspec/changes/wiki-history/design.md` (решения плана П-1…П-12)
- Modify: `ARCHITECTURE.md` (строки `schema`, `stage`, `convert` таблицы модуля wiki)
- Test: `tests/test_wiki_history.py` (новый), `tests/test_wiki_convert.py`
**Interfaces:**
- Produces:
- таблицы:
```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="<p>текст</p>", author=SOSNOV, attachments=None, links=None):
return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html,
"attachments": attachments or [], "links": links or []}
def stage_rows(api, cmd_id):
return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id)
```
Тесты и утверждения:
```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", "<p>статус <b>новый</b></p>")]})
assert out == {"accepted": 1}
(row,) = stage_rows(api, cmd["id"])
p = json.loads(row["payload"])
assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00",
"author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"}
def test_versions_resend_same_batch(api): # Review Focus 2: повтор пачки — одна строка на код
cmd = api.claim(); batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")]
for _ in range(2):
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch})
assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"]
@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")])
def test_versions_created_at_forms(api, raw, stored): # Review Focus 4 → created_at в payload == stored
def test_versions_created_at_without_offset_422(api): # "2026-10-27T23:48:02" → 422 bad_versions, detail "DOCH-1: created_at"
def test_versions_author_nulls(api): # author {"login": None, "name": None} принят; author_login/author_name None
@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"),
("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]), ("links", "x")])
def test_versions_bad_field(api, field, value): # 422 bad_versions, detail == f"DOCH-1: {field}"
def test_versions_bad_batch(api): # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request;
# версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code"
def test_versions_not_claimed(api): # команда queued → 409 not_claimed; несуществующая → 404
def test_versions_wrong_agent(api): # токеном агента jira (api.jira) → 401
```
В `tests/test_wiki_convert.py`:
```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('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK], file_links=False)
assert md == "[т](page:DOC-1)\n"
```
(`THUMB`, `FILE`, `LINK` — константы файла, заведённые пакетом D.)
- [ ] **Step 2: Запустить — падают.** Run: `uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py -q` —
Expected: FAIL (404 на `/agent/wiki/versions`, `TypeError: unexpected keyword 'file_links'`).
- [ ] **Step 3: Реализовать.**
- `schema.SQL` — таблицы из Interfaces.
- `history.utc` — `datetime.fromisoformat`; `tzinfo is None` → `None`; `.astimezone(timezone.utc).replace(microsecond=0).isoformat()`.
- `convert.to_markdown(..., file_links=False)`: после готового Markdown заменить каждую `[📎 <имя>](attachment:@N)` на
`📎 <имя>` (регэксп по `\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\)`; такую ссылку выдаёт только конвертер — см.
docstring модуля) и вернуть `[]` вместо файлов.
- `stage.accept_versions` — по образцу `accept`: `MAX_VERSIONS = 50`, `MAX_VERSION_STAGE = 20000`; ошибки формы —
`ApiError(422, "bad_versions", "<код версии>: <поле>")` (до кода — `"версия <i>: …"`); список — «versions — список от 1 до
50 версий»; `_clean` на версию; `page_code`/`code` — непустые строки ≤ 200; `author` — объект, `login`/`name` — строка или
`None`; `html` — строка; `attachments`/`links` — те же проверки, что у страниц (вынести общую проверку из `_check`);
`created_at` через `history.utc`. Перевод — `convert.to_markdown(html, attachments, links, file_links=False)` до
транзакции (сбой — `"<код>: html (<ИмяИсключения>)"`); в транзакции — повторная проверка `claimed`, предел стадии
(«versions: в команде больше 20000 версий»), `INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload)`.
- `api.h_agent_versions` → `stage.accept_versions(conn, body.get("command_id"), body.get("versions"))`; маршрут в `ROUTES`
сразу после `/agent/wiki/pages`.
- [ ] **Step 4: Запустить — проходят.** Run: `uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py tests/test_wiki_sync.py -q` — Expected: PASS.
- [ ] **Step 5: Эталоны.** Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/`
— Expected: изменены только `schema.expected.json` (три таблицы, два индекса, `schema_version` прежний) и
`routes.expected.json` (одна строка `/agent/wiki/versions`). Затем `uv run pytest tests/test_baseline.py -q` — PASS.
- [ ] **Step 6: Документы.** design.md — решения П-1…П-12 из раздела «Решения плана». ARCHITECTURE.md, таблица модуля
wiki: `schema` — плюс `wiki_version`, `wiki_version_stage`, `wiki_history`; `stage` — «и пачек версий
(`accept_versions`, `POST /agent/wiki/versions`)»; `convert` — «режим без ссылок на вложения для версий (`file_links=False`)».
- [ ] **Step 7: Commit.**
```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", "<p>а</p>" * (i + 1)) for i in range(3)]
+ [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)], CODES)
assert cmd["params"]["known_versions"] == {} and done["status"] == "done"
rows = versions_of(api)
assert len(rows) == 5
assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)]
assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"]
assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"]
def test_known_versions_on_delivery_and_recovery(api): # сценарий «Догрузка» + GET /agent/commands/{id}
# после первой загрузки: следующий claim — known_versions == {"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-3": [...]}
# (порядок — по времени); GET /agent/commands/<id> токеном wiki отдаёт те же known_versions
# присланная DOCH-12 (DOC-1): added/removed == history.line_delta(md DOCH-11, md DOCH-12)
def test_agent_without_history(api): # done без versions: копия обновлена (synced_at новее), версии и history_at прежние
def test_page_left_tree(api): # у DOC-3 версии; новая синхронизация с versions=0 и codes без DOC-3 → версий DOC-3 нет
def test_foreign_page_version_dropped(api): # версия page_code DOC-99 → не записана, команда done
def test_lost_versions_batch(api): # прислано 3, versions=5 → failed, error == "нет версий: пришло 3 из 5";
# wiki_page.synced_at и wiki_version прежние, стадии обеих пусты
def test_resend_counts_unique(api): # одна и та же пачка из 2 версий дважды, versions=2 → done (не «пришло 4 из 2»)
@pytest.mark.parametrize("bad", ["5", True, -1, 1.5])
def test_versions_field_bad(api, bad): # failed, error == "versions — неотрицательное целое"
def test_late_version_recounts_successor(api): # есть V1 10:00, V3 12:00; приходит V2 11:00:
# V2 = line_delta(V1, V2), V3 пересчитана = line_delta(V2, V3)
def test_known_version_resent_ignored(api): # Review Focus 2: повтор известного кода с другим html — markdown/added прежние
def test_reset_root_drops_history(api): # PATCH wiki_root=None → wiki_version и wiki_history проекта пусты
def test_redelivery_clears_version_stage(api): # повторная выдача → wiki_version_stage команды пуста
def test_discarded_report_keeps_history(api): # корень сменён до отчёта → result.discarded, версии прежние, стадия версий пуста
def test_failed_report_clears_version_stage(api): # отчёт failed → стадия версий пуста, история прежняя
def test_history_heavy_work_off_write_lock(api, monkeypatch): # Review Focus 3
# по образцу test_no_heavy_work_under_write_lock (test_wiki_sync.py): probe() в обёртках stage.convert.to_markdown
# и sync.history.line_delta; после run_history с 3 версиями — ("delta", "free") в seen, "locked" нет
def test_line_delta():
assert history.line_delta("", "a\nb\n") == (2, 0)
assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1)
assert history.line_delta("a\n", "a\n") == (0, 0)
```
Ещё `test_housekeeping_clears_version_stage` (строка стадии версий у `cancelled` команды удаляется `sync.housekeeping`).
- [ ] **Step 2: Запустить — падают.** Run: `uv run pytest tests/test_wiki_history.py -q` — Expected: FAIL (`KeyError: 'known_versions'`, версии не записаны).
- [ ] **Step 3: Реализовать.**
- `commands.HOOKS.deliver_params = deliver_params` — один хук покрывает и `claim`, и `GET /agent/commands/{id}`
(`core/api.py`, `h_agent_command`).
- `sync.Prepared`: поля `with_history: bool = False`, `versions: list`, `recount: list`.
- `sync.prepare` после проверки страниц: если в `result` есть ключ `versions` — `M` целое ≥ 0 и не `bool`, иначе
`SyncFailed("versions — неотрицательное целое")`; `N = COUNT(*)` стадии версий команды (уникальные коды — ключ
`(command_id, code)`); `N < M` → `SyncFailed(f"нет версий: пришло {N} из {M}")`; затем
`p.versions, p.recount = _plan_versions(...)`, `p.with_history = True`. Это до транзакции отчёта (R-6).
- `_plan_versions`: из стадии — только `page_code in codes` и код, которого нет в `wiki_version` проекта; по каждой
затронутой странице — известные версии + новые, сортировка `(created_at, code)`, предыдущий текст первой — `""`; новая —
`line_delta(пред., своя)`; известная, чей предшественник новый, — пересчёт, в `recount` если значения другие.
- `sync._commit`: при `p.with_history` — `DELETE FROM wiki_version WHERE label = ? AND page_code NOT IN (SELECT value
FROM json_each(?))` (коды — `json.dumps`), `INSERT OR IGNORE` новых, `UPDATE … SET added, removed` по `recount`,
`INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES (?, now)`. Всегда — `DELETE FROM wiki_version_stage
WHERE command_id = ?`.
- Стадию версий чистить там же, где стадию страниц: `on_claim` (redelivery), `on_failed_or_reported_failed`,
`housekeeping`, ветка «корень сменился» в `on_result`.
- `projects._drop_copy`: `DELETE FROM wiki_version WHERE label = ?`, `DELETE FROM wiki_history WHERE label = ?`.
- [ ] **Step 4: Запустить — проходят.** Run: `uv run pytest tests/test_wiki_history.py tests/test_wiki_sync.py tests/test_wiki_projects.py tests/test_wiki_commands.py -q` — Expected: PASS.
- [ ] **Step 5: Документы.** ARCHITECTURE.md: строка `commands` — «`deliver_params` дописывает `known_versions`»; `sync` —
«запись версий, «+/−» (подсчёт в `prepare`), `history_at`»; `projects` — сброс корня удаляет историю; поток
«Синхронизация вики» — шаг «после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке), отчёт
`done {…, versions}`; хаб при выдаче дописывает `known_versions`; без `versions` (агент v1) история не меняется»; раздел
«Границы» — «Историю (`wiki_version`, `wiki_history`) пишет только `sync.commit` (удаляет — и `projects._drop_copy`)».
- [ ] **Step 6: Commit.**
```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 → строка «история: <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: <IconHistory />, padded: true, keepSearch: true }`;
`NAV = [OVERVIEW, FEED, ...]`.
- `jira/feed/source.tsx`: `jiraFeed: FeedSource` — `available: () => true`; `useFeed` читает `kinds` из адреса
(`parseKinds`), запрос — прежний `useChanges(...)` с `enabled: input.enabled && kinds.length > 0`; `notice` — «выберите
хотя бы один вид», если источник выбран и видов нет; группы — `groupByIssue`, `at` — время первого события группы;
`Controls` — флажки видов (`role="group"`, `aria-label="Виды событий"`, класс `feed__kinds`, `kinds=none` для пустого набора).
- [ ] **Step 1: Перенести тесты и написать новые** (Vitest, App с `mockFetch`, как в прежнем `FeedPage.test.tsx`;
в `hub()` добавить `GET /wiki/changes` — счётчик вызовов, ответ `{since:"",until:"",synced_at:null,history_at:null,truncated:false,changes:[]}`).
Все прежние тесты ленты переносятся с адресом `/app/feed`; изменения ожиданий:
- «Что закрылось за два часа» (сценарий спеки): проект `A` с `wiki_root: "DOC-1"`; клики «2 часа», снять «Вики», снять
«Описание», «Сроки», «Комментарии» → `location.search === "?project=AWPCA&period=2h&sources=jira&kinds=status"`,
`pathname === "/app/feed"`, последний `GET /changes` с `kinds=status`, `GET /wiki/changes` после снятия «Вики» не было.
- truncated: текст «Jira: показаны последние 1 событий — выберите период короче» (П-11); ошибка — «Jira: сбой» с «Повторить».
- шапка: вторичная «Обновить из Jira», primary нет; у проекта с вики — и «Обновить вики» (П-10).
- новые: `«Проект без вики»: флажков источников нет, GET /wiki/changes нет` (`sfm_kedr` без `wiki_root`);
`сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none`;
`«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить»`
(источник вики появится в Task 6: тест помечается `it.todo` здесь и включается в Task 6);
`/app/jira/feed?project=AWPCA&period=7d → адрес /app/feed?project=AWPCA&period=7d (replace)`;
`смена проекта: period, sources, kinds сохраняются (keepSearch)`.
- `period.test.ts` — прежние тесты `periodRange`/`parsePeriod` из `events.test.ts` (сценарий «Вчера»: 22.10 14:05 → since
21.10 00:00, until 22.10 00:00). `sources.test.ts`:
```ts
expect(parseSources(null, ["jira", "wiki"])).toEqual(["jira", "wiki"]);
expect(parseSources("wiki,foo", ["jira", "wiki"])).toEqual(["wiki"]);
expect(parseSources("wiki", ["jira"])).toEqual(["jira"]); // недоступный → умолчание
expect(parseSources("none", ["jira", "wiki"])).toEqual([]);
expect(sourcesParam(["wiki", "jira"], ["jira", "wiki"])).toBeNull();
expect(sourcesParam(["jira"], ["jira", "wiki"])).toBe("jira");
expect(sourcesParam([], ["jira", "wiki"])).toBe("none");
```
- `modules.test.tsx`: `PAGE_ROUTES` → `["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban",
"/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]`; пункты модулей без
`["Лента", "/jira/feed"]`; NotFound — 4 ссылки («Обзор», «Лента» + два раздела).
- `Sidebar.test.tsx`: «Лента» — сразу после «Обзора», вне групп, `href="/app/feed?project=AWPCA"`, `aria-current="page"` на `/app/feed`.
- `OverviewPage.test.tsx`: ссылка «Лента» сводки Jira — `/app/feed?project=AWPCA&sources=jira`.
- `BacklogPage.test.tsx`: меню группы «Jira» — `["Связи", "Бэклог", "Спринт", "Задачи"]` и `["Связи", "Задачи"]`.
- `boundary.test.ts`: `MODULE_TERMS` дополнить `ChangeKind|kinds|history_at|page_code|prev_version`; детектор —
`expect(MODULE_TERMS.test("kinds: string")).toBe(true)`.
- [ ] **Step 2: Запустить — падают.** Run: `cd web && npx vitest run src/shell src/modules src/boundary.test.ts src/ui` — Expected: FAIL (нет `shell/feed/*`).
- [ ] **Step 3: Реализовать.**
- `FeedPage({ project }: { project: Project })` в `shell/feed/FeedPage.tsx`, `data-testid="page-feed"`, классы `feed-page*`
(в `shell/feed/feed.css`, токены AURA; классы `feed`, `feed__controls`, `feed__hint` убрать из jira `feed.css`).
Доступные = `FEED_MODULES` с `available(project)`; выбранные = `parseSources`; ввод источника — `{project, period,
bounds: () => periodRange(period, new Date()), enabled}`; `useFeeds`. Сверху: `PageHeader` «Лента», подзаголовок
`${count} ${plural(count)} · ${fmtStamp(since)} — ${fmtStamp(until)}` по сумме `count` и первому `window` выбранных;
действия — вторичные кнопки обновления выбранных модулей с `status.refresh` (компонент на модуль,
`useModuleActivity(m.name)`). Ряд управления: `Segmented` периода; флажки источников (`role="group"`,
`aria-label="Источники"`, подписи `m.title`) — только если доступных больше одного; `Controls` выбранных источников.
Сообщения и пустой период — по П-11; группы всех выбранных — одним списком по `at` ↓ (при равенстве — порядок
`FEED_MODULES`), ключ `${m.name}:${g.key}`.
- Маршрут `/feed` в `PAGE_ROUTES` после `/` (`ProjectGate`, `key={p.label}`). Sidebar: `link(FEED)` после `link(OVERVIEW, first)`.
- jira: `FeedGroup` — прежняя разметка группы (`Panel` `feed-group`, `data-testid="feed-group"`, `IssueLink`, тема,
статус, `EventList`); `events.ts` без периода, `eventTime` — из `ui/format.ts`; `index.tsx` — без страницы и пункта
«Лента», `{ path: "/jira/feed", element: <LegacyRedirect to="/feed" /> }` вместо `/feed`, `feed: jiraFeed`;
`Summary` — «Лента» на `/feed${q}&sources=jira`. Проверить, что история в карточке задачи (`EventList`) не изменилась.
- e2e `feed.spec.ts`: адреса `/app/feed?project=AWPCA…` (поведение прежнее).
- [ ] **Step 4: Запустить — проходят.** Run: `cd web && npm run lint && npm test` — Expected: PASS (кроме `it.todo`).
- [ ] **Step 5: Документы.** ARCHITECTURE.md, раздел «Веб»: контракт `HubModule` — плюс `feed` (источник ленты: ядро ведёт
период, источники, порядок групп; модуль — запросы, группы, `Controls`); страница ядра «Лента» — `web/src/shell/feed/`;
адреса — `/app/feed`, прежний `/app/jira/feed` перенаправляется; модуль jira — без страницы «Лента», `feed/` — источник
ленты и `EventList` карточки. CLAUDE.md, строка адресов: «лента изменений — `/app/feed?project=<метка>` (источники Jira
и Вики)», прежние адреса — `/app/board`, `/app/jira/board`, `/app/issues` — на страницы модуля Jira, `/app/jira/feed` — на
`/app/feed`; строка «Веб»: «модуль jira — страницы доски и задач; лента — страница ядра». README.md — адреса так же.
- [ ] **Step 6: Commit.**
```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 схемы и утверждения тестов.