План: история правок вики (#140)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 11:47:42 +04:00
parent 98842208f5
commit de3c04701e

View File

@ -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` «история: <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 схемы и утверждения тестов.