79 KiB
История правок вики, общая лента и дифф — план реализации
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, MCPchanges/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
- Фильтр автора кириллицей без учёта регистра (
author=СОСНОВ,author=соснов): SQLiteLOWER/LIKEзнают только ASCII — фильтр должен находить «Дмитрий Соснов». Тестtest_changes_author_cyrillic_caseв Task 3. - Агент переотправил ту же пачку версий (сетевая ошибка, 5xx) или прислал уже известный хабу код: в стадии одна строка
на код, «пришло N из M» считает уникальные коды, в истории дубля нет и «+/−» прежние. Тесты
test_versions_resend_same_batch(Task 1) иtest_known_version_resent_ignored(Task 2). - Тяжёлая работа вне блокировки записи (рулинг R-6): перевод HTML версий и подсчёт «+/−» по сотням версий первой
загрузки не идут внутри
BEGIN IMMEDIATE. Тестtest_history_heavy_work_off_write_lockв Task 2. - Время версии от Eva с дробными секундами,
Zили+0300нормализуется в UTC без дробей; без смещения — 422. Тестtest_versions_created_at_formsв Task 1. - Дифф частями: часть может оборваться посреди строки — окно склеивает части и показывает строку один раз и целиком.
Тест
«Показать дальше»: часть рвёт строку — строка целая и однав 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 — 422bad_request«offset: целое ≥ 0»;offsetза концом —diff = "",next_offset = null. - П-8 MCP: шапка
diff, строкаstatus«история: <history_at>» / «история не загружена»,syncпри отменённом пакете — «ошибка: пакет N отменён», время в «ок: синхронизировано …» —synced_atизGET /wiki/status; автор правки — имя, иначе логин, иначе «—» (и в вебе). - П-9 Помощники времени MCP (
local_if_naive,since_of) — вcore/mcp.py, сервер jira берёт их оттуда. - П-10 Веб: в шапке «Ленты» — вторичные кнопки обновления модулей выбранных источников («Обновить из Jira», «Обновить вики»).
- П-11 Веб: сообщения
truncatedи ошибки источника — с префиксом «<Источник>: »; «выберите хотя бы один вид» и «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни один выбранный источник не грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет;sources=none— явный пустой набор (какkinds=none). - П-12 Веб:
eventTimeпереезжает вui/format.ts, период (PERIODS,parsePeriod,periodRange) — вshell/feed/period.ts; прежнее перенаправление/app/feed→/app/jira/feedснимается (адрес теперь страница ядра).
Task 1: Схема версий, перевод без id вложений, приём POST /agent/wiki/versions
Model: haiku
Files:
- Modify:
src/sdlc_hub/modules/wiki/schema.py(таблицыwiki_version,wiki_version_stage,wiki_history) - Create:
src/sdlc_hub/modules/wiki/history.py(пока толькоutc) - Modify:
src/sdlc_hub/modules/wiki/convert.py(to_markdown(..., file_links=True)) - Modify:
src/sdlc_hub/modules/wiki/stage.py(accept_versions,MAX_VERSIONS,MAX_VERSION_STAGE) - Modify:
src/sdlc_hub/modules/wiki/api.py(h_agent_versions, маршрут) - Modify:
tests/fixtures/baseline/schema.expected.json,tests/fixtures/baseline/routes.expected.json(перезапись) - Modify:
openspec/changes/wiki-history/design.md(решения плана П-1…П-12) - Modify:
ARCHITECTURE.md(строкиschema,stage,convertтаблицы модуля wiki) - Test:
tests/test_wiki_history.py(новый),tests/test_wiki_convert.py
Interfaces:
-
Produces:
- таблицы:
CREATE TABLE IF NOT EXISTS wiki_version ( label TEXT NOT NULL, code TEXT NOT NULL, page_code TEXT NOT NULL, created_at TEXT NOT NULL, author_login TEXT, author_name TEXT, markdown TEXT NOT NULL, added INTEGER NOT NULL DEFAULT 0, removed INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (label, code)); CREATE INDEX IF NOT EXISTS wiki_version_page ON wiki_version(label, page_code, created_at); CREATE INDEX IF NOT EXISTS wiki_version_time ON wiki_version(label, created_at); CREATE TABLE IF NOT EXISTS wiki_version_stage ( command_id INTEGER NOT NULL, code TEXT NOT NULL, page_code TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (command_id, code)); CREATE TABLE IF NOT EXISTS wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL); history.utc(value) -> str | None— ISO-8601 со смещением илиZ(+03:00,+0300, дробные секунды) → UTC в форматеdb.now(); без смещения, не строка или неразбираемое —None.convert.to_markdown(html, attachments, links, *, file_links: bool = True) -> tuple[str, list[dict]]; приfile_links=Falseссылка вложения — текст📎 <имя>без ссылки (имя экранировано как в ссылке), список файлов[]; ссылки на страницы —page:<код>как прежде.stage.accept_versions(conn, command_id, versions) -> {"accepted": n}; payload стадии — JSON{page_code, code, created_at (UTC), author_login, author_name, markdown}.- маршрут
("POST", r"^/agent/wiki/versions$", h_agent_versions, "agent").
- таблицы:
-
Step 1: Написать падающие тесты. В
tests/test_wiki_history.py— помощники для Task 2–3:
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
SOSNOV = {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
MOLCH = {"login": "molchanov-iv@rshbeco.ru", "name": "Илья Молчанов"}
def version(page_code, code, created_at, html="<p>текст</p>", author=SOSNOV, attachments=None, links=None):
return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html,
"attachments": attachments or [], "links": links or []}
def stage_rows(api, cmd_id):
return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id)
Тесты и утверждения:
def test_versions_staged(api): # перевод и форма payload
cmd = api.claim()
out = api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
version("DOC-3", "DOCH-1", "2026-10-27T23:48:02+03:00", "<p>статус <b>новый</b></p>")]})
assert out == {"accepted": 1}
(row,) = stage_rows(api, cmd["id"])
p = json.loads(row["payload"])
assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00",
"author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"}
def test_versions_resend_same_batch(api): # Review Focus 2: повтор пачки — одна строка на код
cmd = api.claim(); batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")]
for _ in range(2):
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch})
assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"]
@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")])
def test_versions_created_at_forms(api, raw, stored): # Review Focus 4 → created_at в payload == stored
def test_versions_created_at_without_offset_422(api): # "2026-10-27T23:48:02" → 422 bad_versions, detail "DOCH-1: created_at"
def test_versions_author_nulls(api): # author {"login": None, "name": None} принят; author_login/author_name None
@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"),
("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]), ("links", "x")])
def test_versions_bad_field(api, field, value): # 422 bad_versions, detail == f"DOCH-1: {field}"
def test_versions_bad_batch(api): # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request;
# версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code"
def test_versions_not_claimed(api): # команда queued → 409 not_claimed; несуществующая → 404
def test_versions_wrong_agent(api): # токеном агента jira (api.jira) → 401
В tests/test_wiki_convert.py:
def test_version_markdown_attachment_as_text():
md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False)
assert "📎 model.xlsx" in md and "attachment:" not in md and files == []
def test_version_markdown_page_links_kept():
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK], file_links=False)
assert md == "[т](page:DOC-1)\n"
(THUMB, FILE, LINK — константы файла, заведённые пакетом D.)
-
Step 2: Запустить — падают. Run:
uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py -q— Expected: FAIL (404 на/agent/wiki/versions,TypeError: unexpected keyword 'file_links'). -
Step 3: Реализовать.
schema.SQL— таблицы из Interfaces.history.utc—datetime.fromisoformat;tzinfo is None→None;.astimezone(timezone.utc).replace(microsecond=0).isoformat().convert.to_markdown(..., file_links=False): после готового Markdown заменить каждую[📎 <имя>](attachment:@N)на📎 <имя>(регэксп по\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\); такую ссылку выдаёт только конвертер — см. docstring модуля) и вернуть[]вместо файлов.stage.accept_versions— по образцуaccept:MAX_VERSIONS = 50,MAX_VERSION_STAGE = 20000; ошибки формы —ApiError(422, "bad_versions", "<код версии>: <поле>")(до кода —"версия <i>: …"); список — «versions — список от 1 до 50 версий»;_cleanна версию;page_code/code— непустые строки ≤ 200;author— объект,login/name— строка илиNone;html— строка;attachments/links— те же проверки, что у страниц (вынести общую проверку из_check);created_atчерезhistory.utc. Перевод —convert.to_markdown(html, attachments, links, file_links=False)до транзакции (сбой —"<код>: html (<ИмяИсключения>)"); в транзакции — повторная проверкаclaimed, предел стадии («versions: в команде больше 20000 версий»),INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload).api.h_agent_versions→stage.accept_versions(conn, body.get("command_id"), body.get("versions")); маршрут вROUTESсразу после/agent/wiki/pages.
-
Step 4: Запустить — проходят. Run:
uv run pytest tests/test_wiki_history.py tests/test_wiki_convert.py tests/test_wiki_sync.py -q— Expected: PASS. -
Step 5: Эталоны. Run:
BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/— Expected: изменены толькоschema.expected.json(три таблицы, два индекса,schema_versionпрежний) иroutes.expected.json(одна строка/agent/wiki/versions). Затемuv run pytest tests/test_baseline.py -q— PASS. -
Step 6: Документы. design.md — решения П-1…П-12 из раздела «Решения плана». ARCHITECTURE.md, таблица модуля wiki:
schema— плюсwiki_version,wiki_version_stage,wiki_history;stage— «и пачек версий (accept_versions,POST /agent/wiki/versions)»;convert— «режим без ссылок на вложения для версий (file_links=False)». -
Step 7: Commit.
git add src/sdlc_hub/modules/wiki/{schema,history,convert,stage,api}.py tests/test_wiki_history.py tests/test_wiki_convert.py \
tests/fixtures/baseline/ ARCHITECTURE.md openspec/changes/wiki-history/design.md
git commit -m "Вики: таблицы версий и приём POST /agent/wiki/versions (#140)"
Task 2: Выдача known_versions и запись версий в синхронизации
Model: sonnet
Files:
- Modify:
src/sdlc_hub/modules/wiki/history.py(known_versions,line_delta,history_at) - Modify:
src/sdlc_hub/modules/wiki/commands.py(deliver_paramsвHOOKS) - Modify:
src/sdlc_hub/modules/wiki/sync.py(Prepared,prepare,_plan_versions,_commit, очистка стадии версий) - Modify:
src/sdlc_hub/modules/wiki/projects.py(_drop_copy) - Modify:
ARCHITECTURE.md(поток «Синхронизация вики», границы, строкиcommands/sync/projects) - Test:
tests/test_wiki_history.py
Interfaces:
-
Consumes: таблицы и
stage.accept_versionsиз Task 1. -
Produces:
history.known_versions(conn, label) -> dict[str, list[str]]— коды версий страниц текущей копии (JOIN wiki_page), по странице в порядке(created_at, code); без копии —{}.history.line_delta(old: str, new: str) -> tuple[int, int]—(added, removed): строки+/-изdifflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="")без заголовков+++/---.history.history_at(conn, label) -> str | None.commands.deliver_params(conn, cmd, files_dir) -> dict—wiki_sync:{**cmd["params"], "known_versions": …}; иначеcmd["params"].sync._plan_versions(conn, label, codes, staged) -> tuple[list[dict], list[tuple[int, int, str]]]— (новые строкиwiki_versionсadded/removed, пересчёт(added, removed, code)известных версий, чей предшественник новый).
-
Step 1: Написать падающие тесты (в
tests/test_wiki_history.py; помощник обмена):
def run_history(api, pages, versions, codes=None, reported=None):
"""Взять wiki_sync, прислать страницы и версии (пачками по 50), отчитаться done с versions=reported (None — len)."""
cmd = api.claim()
api.wiki.post("/agent/wiki/pages", {"command_id": cmd["id"], "pages": pages})
for i in range(0, len(versions), 50):
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": versions[i:i + 50]})
result = {"root_title": "АРМ КА", "codes": codes or [p["code"] for p in pages], "eva_calls": 3,
"versions": len(versions) if reported is None else reported}
return cmd, api.report(cmd, "done", result)
def versions_of(api, page_code=None): ... # SELECT code, page_code, added, removed, created_at … ORDER BY page_code, created_at, code
def test_first_history_load(api): # сценарий «Первая загрузка истории»
cmd, done = run_history(api, TREE, [version("DOC-1", f"DOCH-1{i}", f"2026-10-2{i}T10:00:00Z", "<p>а</p>" * (i + 1)) for i in range(3)]
+ [version("DOC-3", f"DOCH-3{i}", f"2026-10-2{i}T10:00:00Z") for i in range(2)], CODES)
assert cmd["params"]["known_versions"] == {} and done["status"] == "done"
rows = versions_of(api)
assert len(rows) == 5
assert [(r["added"], r["removed"]) for r in rows if r["code"] in ("DOCH-10", "DOCH-30")] == [(1, 0), (1, 0)]
assert api.rows("SELECT history_at FROM wiki_history")[0]["history_at"]
assert "known_versions" not in api.rows("SELECT params FROM command WHERE id = ?", cmd["id"])[0]["params"]
def test_known_versions_on_delivery_and_recovery(api): # сценарий «Догрузка» + GET /agent/commands/{id}
# после первой загрузки: следующий claim — known_versions == {"DOC-1": ["DOCH-10", "DOCH-11"], "DOC-3": [...]}
# (порядок — по времени); GET /agent/commands/<id> токеном wiki отдаёт те же known_versions
# присланная DOCH-12 (DOC-1): added/removed == history.line_delta(md DOCH-11, md DOCH-12)
def test_agent_without_history(api): # done без versions: копия обновлена (synced_at новее), версии и history_at прежние
def test_page_left_tree(api): # у DOC-3 версии; новая синхронизация с versions=0 и codes без DOC-3 → версий DOC-3 нет
def test_foreign_page_version_dropped(api): # версия page_code DOC-99 → не записана, команда done
def test_lost_versions_batch(api): # прислано 3, versions=5 → failed, error == "нет версий: пришло 3 из 5";
# wiki_page.synced_at и wiki_version прежние, стадии обеих пусты
def test_resend_counts_unique(api): # одна и та же пачка из 2 версий дважды, versions=2 → done (не «пришло 4 из 2»)
@pytest.mark.parametrize("bad", ["5", True, -1, 1.5])
def test_versions_field_bad(api, bad): # failed, error == "versions — неотрицательное целое"
def test_late_version_recounts_successor(api): # есть V1 10:00, V3 12:00; приходит V2 11:00:
# V2 = line_delta(V1, V2), V3 пересчитана = line_delta(V2, V3)
def test_known_version_resent_ignored(api): # Review Focus 2: повтор известного кода с другим html — markdown/added прежние
def test_reset_root_drops_history(api): # PATCH wiki_root=None → wiki_version и wiki_history проекта пусты
def test_redelivery_clears_version_stage(api): # повторная выдача → wiki_version_stage команды пуста
def test_discarded_report_keeps_history(api): # корень сменён до отчёта → result.discarded, версии прежние, стадия версий пуста
def test_failed_report_clears_version_stage(api): # отчёт failed → стадия версий пуста, история прежняя
def test_history_heavy_work_off_write_lock(api, monkeypatch): # Review Focus 3
# по образцу test_no_heavy_work_under_write_lock (test_wiki_sync.py): probe() в обёртках stage.convert.to_markdown
# и sync.history.line_delta; после run_history с 3 версиями — ("delta", "free") в seen, "locked" нет
def test_line_delta():
assert history.line_delta("", "a\nb\n") == (2, 0)
assert history.line_delta("a\nb\nc\n", "a\nB\nc\nd\n") == (2, 1)
assert history.line_delta("a\n", "a\n") == (0, 0)
Ещё test_housekeeping_clears_version_stage (строка стадии версий у cancelled команды удаляется sync.housekeeping).
-
Step 2: Запустить — падают. Run:
uv run pytest tests/test_wiki_history.py -q— Expected: FAIL (KeyError: 'known_versions', версии не записаны). -
Step 3: Реализовать.
commands.HOOKS.deliver_params = deliver_params— один хук покрывает иclaim, иGET /agent/commands/{id}(core/api.py,h_agent_command).sync.Prepared: поляwith_history: bool = False,versions: list,recount: list.sync.prepareпосле проверки страниц: если вresultесть ключversions—Mцелое ≥ 0 и неbool, иначеSyncFailed("versions — неотрицательное целое");N = COUNT(*)стадии версий команды (уникальные коды — ключ(command_id, code));N < M→SyncFailed(f"нет версий: пришло {N} из {M}"); затемp.versions, p.recount = _plan_versions(...),p.with_history = True. Это до транзакции отчёта (R-6)._plan_versions: из стадии — толькоpage_code in codesи код, которого нет вwiki_versionпроекта; по каждой затронутой странице — известные версии + новые, сортировка(created_at, code), предыдущий текст первой —""; новая —line_delta(пред., своя); известная, чей предшественник новый, — пересчёт, вrecountесли значения другие.sync._commit: приp.with_history—DELETE FROM wiki_version WHERE label = ? AND page_code NOT IN (SELECT value FROM json_each(?))(коды —json.dumps),INSERT OR IGNOREновых,UPDATE … SET added, removedпоrecount,INSERT OR REPLACE INTO wiki_history(label, history_at) VALUES (?, now). Всегда —DELETE FROM wiki_version_stage WHERE command_id = ?.- Стадию версий чистить там же, где стадию страниц:
on_claim(redelivery),on_failed_or_reported_failed,housekeeping, ветка «корень сменился» вon_result. projects._drop_copy:DELETE FROM wiki_version WHERE label = ?,DELETE FROM wiki_history WHERE label = ?.
-
Step 4: Запустить — проходят. Run:
uv run pytest tests/test_wiki_history.py tests/test_wiki_sync.py tests/test_wiki_projects.py tests/test_wiki_commands.py -q— Expected: PASS. -
Step 5: Документы. ARCHITECTURE.md: строка
commands— «deliver_paramsдописываетknown_versions»;sync— «запись версий, «+/−» (подсчёт вprepare),history_at»;projects— сброс корня удаляет историю; поток «Синхронизация вики» — шаг «после страниц eva-agent v2 шлёт версииPOST /agent/wiki/versions(≤50 в пачке), отчётdone {…, versions}; хаб при выдаче дописываетknown_versions; безversions(агент v1) история не меняется»; раздел «Границы» — «Историю (wiki_version,wiki_history) пишет толькоsync.commit(удаляет — иprojects._drop_copy)». -
Step 6: Commit.
git add src/sdlc_hub/modules/wiki/{history,commands,sync,projects}.py tests/test_wiki_history.py ARCHITECTURE.md
git commit -m "Вики: known_versions при выдаче и запись версий в синхронизации (#140)"
Task 3: Чтение истории — лента, версии, дифф, поля страницы и состояния
Model: haiku
Files:
- Modify:
src/sdlc_hub/modules/wiki/history.py(feed,page_versions,diff,last_change,NoVersions,NoVersion,CHUNK) - Modify:
src/sdlc_hub/modules/wiki/api.py(h_changes,h_versions,h_diff, поля вh_page,h_status) - Modify:
tests/fixtures/baseline/routes.expected.json(перезапись) - Modify:
ARCHITECTURE.md(строкаhistory,api, открытый вопрос),README.md(таблица маршрутов) - Test:
tests/test_wiki_history_api.py(новый)
Interfaces:
-
Consumes:
history.utc,history.history_at,history.line_delta; помощникиrun_history,version,SOSNOV,MOLCHизtest_wiki_history. -
Produces:
history.feed(conn, label, since: str, until: str, author: str | None, limit: int) -> {"truncated": bool, "changes": [...]}— элементы{at, page_code, title, path, version, author: {login, name}, added, removed}, только страницы текущей копии (JOIN wiki_page),atв[since, until], поat↓, затемversion↓; автор — подстрокаcasefoldимени или логина (в Python, до среза).history.page_versions(conn, label, code) -> list[dict]—[{version, at, author, added, removed}], новые первыми.history.diff(conn, label, code, version: str | None, offset: int) -> dict— поля ответа маршрута, кромеtitle/path(их добавляет api);NoVersions— у страницы нет версий,NoVersion— нет такой версии.difflib.unified_diff(prev.splitlines(), cur.splitlines(), fromfile=prev_code or "", tofile=code, n=3, lineterm=""), строки через"\n"и"\n"в конце (пустой дифф —""); часть[offset:offset + CHUNK],CHUNK = 20000.history.last_change(conn, label, code) -> {"modified_at", "modified_by", "versions"}(modified_by—{login, name}илиNone).- маршруты
GET ^/wiki/changes$,GET ^/wiki/pages/([^/]+)/versions$,GET ^/wiki/pages/([^/]+)/diff$(клиент).
-
Step 1: Написать падающие тесты. Фикстура
history(черезapi): синхронизацияTREE/CODESс версиями DOC-3 (Соснов)2026-10-27T17:59:00+03:00,2026-10-27T18:02:00+03:00и DOC-2 (Молчанов)2026-10-26T12:00:00+03:00.
def test_changes_today(api, history): # сценарий «Что сделали за сегодня»
d = api.client.get("/wiki/changes", project="AWPCA", since="2026-10-27T00:00:00+03:00", until="2026-10-28T00:00:00+03:00")
assert [c["at"] for c in d["changes"]] == ["2026-10-27T15:02:00+00:00", "2026-10-27T14:59:00+00:00"]
c = d["changes"][0]
assert c["page_code"] == "DOC-3" and c["title"] == "Статусы" and c["path"] == "АРМ КА/01_Статусы"
assert c["author"] == {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
assert isinstance(c["added"], int) and isinstance(c["removed"], int)
assert d["since"] == "2026-10-26T21:00:00+00:00" and d["truncated"] is False and d["synced_at"] and d["history_at"]
@pytest.mark.parametrize("author", ["соснов", "СОСНОВ", "Sosnov-DVI"])
def test_changes_author_cyrillic_case(api, history, author): # Review Focus 1 → только две правки DOC-3
def test_changes_default_window(api, history): # без since/until: until − since == 24 ч (по ответу)
def test_changes_limit_truncated(api, history): # limit=1 → одна правка, truncated True
@pytest.mark.parametrize("q,param", [({"since": "2026-10-27T00:00:00"}, "since"), ({"until": "вчера"}, "until"),
({"limit": "0"}, "limit"), ({"limit": "2001"}, "limit"), ({"limit": "x"}, "limit"),
({"since": "2026-10-28T00:00:00Z", "until": "2026-10-27T00:00:00Z"}, "since")])
def test_changes_bad_params(api, history, q, param): # 422 bad_request, detail.startswith(param)
def test_changes_history_not_loaded(api): # только синхронизация без versions → 200, history_at None, changes []
def test_changes_no_root_and_unknown(api): # проект без корня → 409 no_root; неизвестный → 404
def test_versions_list(api, history): # DOC-3 → versions[0]["at"] == "2026-10-27T15:02:00+00:00", 2 шт.; DOC-99 → 404
def test_diff_last(api): # сценарий «Дифф последней правки»: 4 версии DOC-3 →
# index 4, total 4, prev_version == третий код, diff.startswith(f"--- {v3}\n+++ {v4}\n"), next_offset None
def test_diff_first_version(api, history): # version=первая → prev_version None, diff.startswith("--- \n+++ "), removed 0
def test_diff_chunks(api): # сценарий «Длинный дифф»: версия ~3000 строк; full = diff целиком
# (склейка частей): offset=0 → len 20000, next_offset 20000; offset=40000 → diff == full[40000:], next_offset None;
# части 0, 20000, 40000 вместе == full; offset=-1 и "x" → 422 bad_request "offset: целое ≥ 0"
def test_diff_errors(api, history): # DOC-99 → 404 not_found; version=DOCH-404 → 404 no_version;
# DOC-1 без версий → 404 no_versions
def test_page_last_change(api): # сценарий «Последняя правка в странице»: последняя версия
# created_at "2026-10-27T23:48:02+03:00" → modified_at "2026-10-27T20:48:02+00:00", modified_by.name "Дмитрий Соснов",
# versions 4; страница без версий → modified_at None, modified_by None, versions 0
def test_status_history_at(api, history): # GET /wiki/status → history_at == строке wiki_history; без истории — None
-
Step 2: Запустить — падают. Run:
uv run pytest tests/test_wiki_history_api.py -q— Expected: FAIL (404 маршрутов, нетmodified_at). -
Step 3: Реализовать функции из Interfaces и обработчики.
h_changes:_project(conn, q); время — свой_time_param(q, name, default)черезhistory.utc(без смещения — 422bad_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"; страница не в копии — 404not_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, MCPchanges/diff, веб «Лента» (только база хаба)»; цепочкаapi → history,sync → history. Открытый вопрос «История страниц вики и частичная перекачка» заменить на «Частичная перекачка: каждая синхронизация перекачивает всё дерево страниц (версии — только новые, changewiki-history)». README.md — строкиGET /wiki/changes,GET /wiki/pages/{code}/versions,GET /wiki/pages/{code}/diff, поляmodified_at/modified_by/versionsу страницы иhistory_atу состояния. -
Step 7: Commit.
git add src/sdlc_hub/modules/wiki/{history,api}.py tests/test_wiki_history_api.py tests/fixtures/baseline/ ARCHITECTURE.md README.md
git commit -m "Вики: лента правок, версии и дифф в HTTP API (#140)"
Task 4: MCP вики — changes, diff, «изменено» в page, короткий sync, история в status
Model: haiku
Files:
- Modify:
src/sdlc_hub/core/mcp.py(local_if_naive,since_of) - Modify:
src/sdlc_hub/modules/jira/mcp.py(берёт помощники из ядра; описания инструментов не меняются) - Modify:
src/sdlc_hub/modules/wiki/mcp.py(changes,diff,fmt_wiki_changes,page,sync,status) - Modify:
tests/fixtures/baseline/mcp_wiki_tools.expected.json(перезапись) - Modify:
ARCHITECTURE.md(строкаmcpмодуля wiki),README.md(таблица инструментовsdlc-wiki) - Test:
tests/test_wiki_mcp.py
Interfaces:
-
Consumes: ответы маршрутов Task 3 (
/wiki/changes,/wiki/pages/{code}/diff, поляpage/status). -
Produces:
core.mcp.local_if_naive(value: str) -> str(переносjira.mcp._local_if_naiveбез изменений поведения);core.mcp.since_of(hours: float | None, since: str, now: datetime) -> str—hours→now − hours, пусто → начало сутокnow, иначеsince.- инструменты
changes(hours: float | None = None, since: str = "", until: str = "", author: str = "") -> str,diff(code: str, version: str = "", offset: int = 0) -> str; порядок инструментов —["tree", "page", "search", "changes", "diff", "attachment", "sync", "status", "root_set", "projects", "project_create"].
-
Step 1: Написать падающие тесты (
tests/test_wiki_mcp.py, фикстураhubиFake— прежние;loc = lambda iso: datetime.fromisoformat(iso).astimezone(); сегодняшниеatстроятся из местного времени):
def test_changes_today_grouped(hub): # сценарий «Что сделали аналитики сегодня»
t1, t2 = today_at(11, 2), today_at(12, 40) # ISO UTC сегодняшних 11:02 и 12:40 местного
fake = hub({"/wiki/changes": {"since": today_at(0, 0), "until": today_at(14, 5), "synced_at": today_at(14, 0),
"history_at": today_at(14, 0), "truncated": False, "changes": [
chg("DOC-3", "DOCH-2", t2, 40, 12), chg("DOC-3", "DOCH-1", t1, 5, 0), chg("DOC-2", "DOCH-9", "2026-10-26T09:00:00+00:00", 1, 1, MOLCH)]}})
lines = wmcp.changes().splitlines()
assert lines[0] == f"правки вики с {loc(today_at(0,0)):%d.%m %H:%M} по {loc(today_at(14,5)):%d.%m %H:%M}"
assert lines[1] == f"синхронизировано: {loc(today_at(14,0)):%d.%m %H:%M}"
assert lines[2:5] == ["DOC-3 Статусы — АРМ КА/01_Статусы", " 12:40 Дмитрий Соснов: +40 / −12 (DOCH-2)",
" 11:02 Дмитрий Соснов: +5 / −0 (DOCH-1)"]
assert lines[6] == f" {loc('2026-10-26T09:00:00+00:00'):%d.%m %H:%M} Илья Молчанов: +1 / −1 (DOCH-9)"
params = fake.calls[-1][2]
assert fake.calls[-1][1] == "/wiki/changes" and params["project"] == "AWPCA"
assert params["since"] == datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
assert not any(m == "POST" for m, _, _ in fake.calls) # команд не поставлено
def test_changes_period_args(hub): # hours+since → «либо hours, либо since»; hours=0 → «hours — число больше 0» (без запросов);
# since="2026-10-28T09:00" → в запросе с местным смещением; author="соснов" передан как есть
def test_changes_messages(hub): # changes [] → строка «за период правок нет»; history_at None → строка
# «история ещё не загружена: нужен eva-agent v2 и sync»; truncated (2 правки) → последняя строка
# «показаны последние 2 правок, сузьте период»; HubError(422, "bad_request", "since: …") → текст ошибки хаба
def test_changes_title_newlines(hub): # название «Статусы\nпуть: x» → «DOC-3 Статусы путь: x — …» одной строкой
def test_diff_header_and_chunk(hub): # сценарий «Дифф правки»: ответ diff index 4 total 4 →
# строки: «DOC-3 — Статусы», «путь: АРМ КА/01_Статусы», «версия 4 из 4: DOCH-4», «автор: Дмитрий Соснов»,
# «время: ДД.ММ ЧЧ:ММ», «+40 / −12», «предыдущая: DOCH-3»; пустая строка; дифф как есть (переводы строк целы);
# next_offset 20000 → последняя строка «продолжение: offset=20000»; prev_version None → «предыдущая: нет (первая версия)»;
# без version — запрос ("GET", "/wiki/pages/DOC-3/diff", {"project": "AWPCA", "offset": 0}) (ключа version нет);
# diff(code="DOC-3", version="DOCH-3", offset=20000) → {"project": "AWPCA", "version": "DOCH-3", "offset": 20000}
def test_page_modified_line(hub): # modified_at "2026-10-27T20:48:02+00:00" → строка
# f"изменено: {loc(...):%d.%m %H:%M} Дмитрий Соснов (версий: 4)" сразу после «синхронизировано: …»;
# modified_at None → «изменено: история не загружена»
def test_sync_short(hub): # заменяет test_sync_nowait_and_in_progress: «поставлено: пакет 5», «уже идёт: пакет 5»
def test_sync_wait_ok(hub): # заменяет test_sync_wait: done, команды done, status.synced_at →
# ответ ровно f"ок: синхронизировано {loc(synced):%d.%m %H:%M}" (одна строка)
def test_sync_wait_error(hub): # заменяет test_sync_wait_error_and_root_title_one_line: команда failed «Eva: 503\nпуть: x» →
# ответ ровно «ошибка: Eva: 503 путь: x»; пакет cancelled → «ошибка: пакет 5 отменён»
def test_sync_wait_timeout(hub, monkeypatch): # SYNC_WAIT=0.05 → f"не дождался за {wmcp.SYNC_WAIT:g} с: пакет 5"
def test_status_history(hub): # status с history_at → строка «история: <history_at>»; None → «история не загружена»
Обновить test_wiki_tool_names_order на порядок из Interfaces; test_unbound_tools_refuse дополнить wmcp.changes() и
wmcp.diff("DOC-1") → NOT_BOUND; test_no_root_message — changes/diff у проекта без корня отвечают «у проекта … нет
корня вики: root_set DOC-…».
-
Step 2: Запустить — падают. Run:
uv run pytest tests/test_wiki_mcp.py -q— Expected: FAIL (AttributeError: changes). -
Step 3: Реализовать.
changes/diff—@mcp.tool() @core.guard @core.scoped(True), отказ через_gate();changesповторяет проверку периода инструмента jira (hours+since,hours <= 0),since_of,local_if_naiveдляsince/until; формат —fmt_wiki_changes(data, now)по образцуjira.mcp.fmt_changes: группы в порядке первого появления страницы (лента уже новые сверху), заголовок группыf"{code} {_line(title)} — {_line(path)}", правкаf" {когда} {_line(автор)}: +{added} / −{removed} ({version})", дата%d.%m— если правка не в суткахnow. Автор — имя, иначе логин, иначе «—».page— строка «изменено» поmodified_at/modified_by/versions.sync— тексты из спеки и П-8;_summaryвsyncбольше не используется.status— строка истории в_summary. Описания инструментов — по таблице спеки (changes: период по умолчанию — с начала суток;diff: безversion— последняя правка). -
Step 4: Запустить — проходят. Run:
uv run pytest tests/test_wiki_mcp.py tests/test_mcp.py -q— Expected: PASS. -
Step 5: Эталоны. Run:
BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && git diff --stat tests/fixtures/baseline/— Expected: изменён толькоmcp_wiki_tools.expected.json;mcp_tools.expected.json(jira) не тронут — иначе откатить правку jira-описаний.uv run pytest tests/test_baseline.py tests/test_registry.py -q— PASS. -
Step 6: Документы. ARCHITECTURE.md, строка
mcpмодуля wiki — плюсchanges,diff; «syncотвечает коротко;page,changes,diffчитают только копию». README.md — строкиchanges,diff, новая шапкаpage, ответsync,status. -
Step 7: Commit.
git add src/sdlc_hub/core/mcp.py src/sdlc_hub/modules/{jira,wiki}/mcp.py tests/test_wiki_mcp.py tests/fixtures/baseline/ ARCHITECTURE.md README.md
git commit -m "MCP вики: changes, diff, «изменено» в page, короткий sync (#140)"
Task 5: Веб — общая «Лента» ядра и источник Jira
Model: sonnet
Files:
- Modify:
web/src/modules/types.ts(типы источника ленты,HubModule.feed) - Modify:
web/src/modules/index.ts(FEED_MODULES,useFeeds) - Create:
web/src/shell/feed/period.ts,period.test.ts(перенос изmodules/jira/feed/events.tsи его тестов) - Create:
web/src/shell/feed/sources.ts,sources.test.ts - Create:
web/src/shell/feed/FeedPage.tsx,feed.css,FeedPage.test.tsx(перенос тестовmodules/jira/feed/FeedPage.test.tsx) - Modify:
web/src/ui/format.ts(+eventTime),web/src/ui/format.test.ts - Modify:
web/src/shell/nav.tsx(FEED),web/src/shell/Sidebar.tsx,web/src/shell/pages.tsx - Create:
web/src/modules/jira/feed/source.tsx(jiraFeed),web/src/modules/jira/feed/FeedGroup.tsx - Delete:
web/src/modules/jira/feed/FeedPage.tsx,web/src/modules/jira/feed/FeedPage.test.tsx - Modify:
web/src/modules/jira/feed/events.ts,events.test.ts,EventList.tsx,feed.css,web/src/modules/jira/index.tsx,web/src/modules/jira/Summary.tsx - Modify tests:
web/src/modules/modules.test.tsx,web/src/shell/Sidebar.test.tsx,web/src/shell/OverviewPage.test.tsx,web/src/modules/jira/agile/BacklogPage.test.tsx(меню без «Ленты»),web/src/boundary.test.ts - Modify:
web/e2e/feed.spec.ts(адрес/app/feed),ARCHITECTURE.md(раздел «Веб»),CLAUDE.md(адреса),README.md(адреса)
Interfaces:
- Produces (в
web/src/modules/types.ts):
export type FeedBounds = { since: Date; until: Date };
export type FeedSourceInput = {
project: Project;
period: string; // ключ кэша: "2h" | "today" | "yesterday" | "7d"
bounds: () => FeedBounds; // границы на момент запроса (перечитывание берёт «до сейчас»)
enabled: boolean; // источник выбран и доступен; false — запросов нет
};
export type FeedGroup = { key: string; at: string; node: ReactNode }; // at — ISO последнего события группы
export type FeedResult = {
groups: FeedGroup[];
count: number; // событий в ответе — для подзаголовка
window: { since: string; until: string } | null; // границы из ответа хаба; null — данных нет
pending: boolean;
error: { message: string; retry(): void } | null;
truncated: boolean;
notice: string | null; // своё сообщение на месте событий источника (тогда groups пусты)
};
export type FeedSource = {
available(p: Project): boolean;
useFeed(input: FeedSourceInput): FeedResult; // хук: ядро вызывает у всех модулей с feed в постоянном порядке
Controls?: ComponentType; // свои настройки над лентой; видны, пока источник выбран
};
// HubModule: feed?: FeedSource — id источника в адресе sources= — имя модуля (m.name), подпись флажка — m.title
-
modules/index.ts:FEED_MODULES: readonly HubModule[](модули сfeed, порядокMODULES),useFeeds(inputs: FeedSourceInput[]): FeedResult[](по образцуuseOverlays). -
shell/feed/period.ts:Period,PERIODS,parsePeriod,periodRange— без изменений поведения. -
shell/feed/sources.ts:parseSources(v: string | null, available: string[]): string[](«none» →[]; неизвестные и недоступные отбрасываются; ни одного — все доступные),sourcesParam(selected: string[], available: string[]): string | null(порядок — какavailable; все доступные →null; пусто →"none"). -
ui/format.ts:eventTime(at: string | null, now: Date, alwaysDate = false): string(перенос из jiraevents.ts). -
shell/nav.tsx:FEED: NavItem = { to: "/feed", label: "Лента", icon: <IconHistory />, padded: true, keepSearch: true };NAV = [OVERVIEW, FEED, ...]. -
jira/feed/source.tsx:jiraFeed: FeedSource—available: () => true;useFeedчитаетkindsиз адреса (parseKinds), запрос — прежнийuseChanges(...)сenabled: input.enabled && kinds.length > 0;notice— «выберите хотя бы один вид», если источник выбран и видов нет; группы —groupByIssue,at— время первого события группы;Controls— флажки видов (role="group",aria-label="Виды событий", классfeed__kinds,kinds=noneдля пустого набора). -
Step 1: Перенести тесты и написать новые (Vitest, App с
mockFetch, как в прежнемFeedPage.test.tsx; вhub()добавитьGET /wiki/changes— счётчик вызовов, ответ{since:"",until:"",synced_at:null,history_at:null,truncated:false,changes:[]}). Все прежние тесты ленты переносятся с адресом/app/feed; изменения ожиданий:- «Что закрылось за два часа» (сценарий спеки): проект
Aсwiki_root: "DOC-1"; клики «2 часа», снять «Вики», снять «Описание», «Сроки», «Комментарии» →location.search === "?project=AWPCA&period=2h&sources=jira&kinds=status",pathname === "/app/feed", последнийGET /changesсkinds=status,GET /wiki/changesпосле снятия «Вики» не было. - truncated: текст «Jira: показаны последние 1 событий — выберите период короче» (П-11); ошибка — «Jira: сбой» с «Повторить».
- шапка: вторичная «Обновить из Jira», primary нет; у проекта с вики — и «Обновить вики» (П-10).
- новые:
«Проект без вики»: флажков источников нет, GET /wiki/changes нет(sfm_kedrбезwiki_root);сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none;«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить»(источник вики появится в Task 6: тест помечаетсяit.todoздесь и включается в Task 6);/app/jira/feed?project=AWPCA&period=7d → адрес /app/feed?project=AWPCA&period=7d (replace);смена проекта: period, sources, kinds сохраняются (keepSearch). period.test.ts— прежние тестыperiodRange/parsePeriodизevents.test.ts(сценарий «Вчера»: 22.10 14:05 → since 21.10 00:00, until 22.10 00:00).sources.test.ts:
- «Что закрылось за два часа» (сценарий спеки): проект
expect(parseSources(null, ["jira", "wiki"])).toEqual(["jira", "wiki"]);
expect(parseSources("wiki,foo", ["jira", "wiki"])).toEqual(["wiki"]);
expect(parseSources("wiki", ["jira"])).toEqual(["jira"]); // недоступный → умолчание
expect(parseSources("none", ["jira", "wiki"])).toEqual([]);
expect(sourcesParam(["wiki", "jira"], ["jira", "wiki"])).toBeNull();
expect(sourcesParam(["jira"], ["jira", "wiki"])).toBe("jira");
expect(sourcesParam([], ["jira", "wiki"])).toBe("none");
-
modules.test.tsx:PAGE_ROUTES→["/", "/feed", "/jira/links", "/jira/backlog", "/jira/sprint", "/jira/kanban", "/jira/issues", "/jira/board", "/board", "/issues", "/issues/:key", "/jira/feed", "/wiki", "*"]; пункты модулей без["Лента", "/jira/feed"]; NotFound — 4 ссылки («Обзор», «Лента» + два раздела). -
Sidebar.test.tsx: «Лента» — сразу после «Обзора», вне групп,href="/app/feed?project=AWPCA",aria-current="page"на/app/feed. -
OverviewPage.test.tsx: ссылка «Лента» сводки Jira —/app/feed?project=AWPCA&sources=jira. -
BacklogPage.test.tsx: меню группы «Jira» —["Связи", "Бэклог", "Спринт", "Задачи"]и["Связи", "Задачи"]. -
boundary.test.ts:MODULE_TERMSдополнитьChangeKind|kinds|history_at|page_code|prev_version; детектор —expect(MODULE_TERMS.test("kinds: string")).toBe(true). -
Step 2: Запустить — падают. Run:
cd web && npx vitest run src/shell src/modules src/boundary.test.ts src/ui— Expected: FAIL (нетshell/feed/*). -
Step 3: Реализовать.
FeedPage({ project }: { project: Project })вshell/feed/FeedPage.tsx,data-testid="page-feed", классыfeed-page*(вshell/feed/feed.css, токены AURA; классыfeed,feed__controls,feed__hintубрать из jirafeed.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— прежняя разметка группы (Panelfeed-group,data-testid="feed-group",IssueLink, тема, статус,EventList);events.tsбез периода,eventTime— изui/format.ts;index.tsx— без страницы и пункта «Лента»,{ path: "/jira/feed", element: <LegacyRedirect to="/feed" /> }вместо/feed,feed: jiraFeed;Summary— «Лента» на/feed${q}&sources=jira. Проверить, что история в карточке задачи (EventList) не изменилась. - e2e
feed.spec.ts: адреса/app/feed?project=AWPCA…(поведение прежнее).
-
Step 4: Запустить — проходят. Run:
cd web && npm run lint && npm test— Expected: PASS (кромеit.todo). -
Step 5: Документы. ARCHITECTURE.md, раздел «Веб»: контракт
HubModule— плюсfeed(источник ленты: ядро ведёт период, источники, порядок групп; модуль — запросы, группы,Controls); страница ядра «Лента» —web/src/shell/feed/; адреса —/app/feed, прежний/app/jira/feedперенаправляется; модуль jira — без страницы «Лента»,feed/— источник ленты иEventListкарточки. CLAUDE.md, строка адресов: «лента изменений —/app/feed?project=<метка>(источники Jira и Вики)», прежние адреса —/app/board,/app/jira/board,/app/issues— на страницы модуля Jira,/app/jira/feed— на/app/feed; строка «Веб»: «модуль jira — страницы доски и задач; лента — страница ядра». README.md — адреса так же. -
Step 6: Commit.
git add web/src web/e2e/feed.spec.ts ARCHITECTURE.md CLAUDE.md README.md
git commit -m "Веб: общая «Лента» ядра с источниками модулей, источник Jira (#140)"
Task 6: Веб — источник «Вики» в ленте
Model: sonnet
Files:
- Create:
web/src/modules/wiki/paths.ts(wikiPageHref,versionHref,VERSION_PATH) - Create:
web/src/modules/wiki/feed/source.tsx(wikiFeed),web/src/modules/wiki/feed/group.ts,group.test.ts,web/src/modules/wiki/feed/WikiFeedGroup.tsx,web/src/modules/wiki/feed/feed.css - Modify:
web/src/modules/wiki/api/queries.ts(WikiChange,WikiChanges,wikiQk.changes,useWikiChanges,history_atвWikiStatus) - Modify:
web/src/modules/wiki/index.tsx(feed: wikiFeed),web/src/modules/wiki/Summary.tsx(ссылка «Правки») - Test:
web/src/shell/feed/FeedPage.test.tsx(включитьit.todoиз Task 5 и новые),web/src/modules/wiki/Summary.test.tsx
Interfaces:
-
Consumes:
FeedSource,FeedSourceInput,FeedResult(Task 5),eventTime(ui/format.ts),GET /wiki/changes(Task 3). -
Produces:
wikiPageHref(project: string, code: string): string→/wiki?project=<метка>&page=<код>(черезURLSearchParams);versionHref(project: string, code: string, version: string): string→/wiki/pages/<код>/versions/<версия>?project=<метка>(сегменты —encodeURIComponent);VERSION_PATH = "/wiki/pages/:code/versions/:version".WikiChange = { at: string; page_code: string; title: string | null; path: string | null; version: string; author: { login: string | null; name: string | null }; added: number; removed: number };WikiChanges = { since; until; synced_at; history_at: string | null; truncated: boolean; changes: WikiChange[] }.wikiQk.changes(p, period) = ["wiki", p, "changes", period](перечитываетсяwikiStatus.invalidateпо префиксу проекта).useWikiChanges(project, period, bounds, enabled)—GET /wiki/changes {project, since, until, limit: 2000},refetchOnWindowFocus: true.groupByPage(changes: WikiChange[]): { code, title, path, changes: WikiChange[] }[]— по первому появлению;deltaText(c) = "+" + added + " / −" + removed;authorName(a)— имя, иначе логин, иначе «—».wikiFeed: FeedSource—available: (p) => !!p.wiki_root;noticeприhistory_at === null— «история вики ещё не загружена — нужен eva-agent v2».
-
Step 1: Написать падающие тесты.
group.test.ts: группировка двух правок DOC-3 и одной DOC-2 →["DOC-3", "DOC-2"], правки DOC-3 в порядке ответа;deltaText({added: 40, removed: 12})→"+40 / −12";authorName({login: "x", name: null})→"x", обаnull→"—".FeedPage.test.tsx(проектAWPCAсwiki_root):«Правка из ленты»: группа — название ссылкой на /app/wiki?project=AWPCA&page=DOC-101252 и путь; правка «12:40 Дмитрий Соснов +40 / −12» — ссылка на /app/wiki/pages/DOC-101252/versions/DOCH-2?project=AWPCA; запросGET /wiki/changesсproject=AWPCA,since= полночь,limit=2000.«Jira и Вики вперемешку»: AURORA-1 10:00, DOC-3 11:00, AURORA-2 12:00 (сегодня) → группыfeed-groupсверху вниз AURORA-2, DOC-3, AURORA-1.«История не загружена»:history_at: null→ «история вики ещё не загружена — нужен eva-agent v2»; события Jira на месте.«Ошибка одного источника»(изit.todo):/wiki/changes500 → события Jira, «Вики: …» и «Повторить»; повтор перезапрашивает только/wiki/changes.«Пустой период»: обе ленты пусты → «за период изменений нет».смена проекта на sfm_kedr при sources=wiki → источник Jira, флажков нет, GET /wiki/changes по sfm_kedr нет.
Summary.test.tsx: у проекта с корнем ссылка «Правки» →/app/feed?project=AWPCA&sources=wiki.
-
Step 2: Запустить — падают. Run:
cd web && npx vitest run src/shell/feed src/modules/wiki— Expected: FAIL. -
Step 3: Реализовать.
WikiFeedGroup—Panelс классомwiki-feed-groupиdata-testid="feed-group", шапка:LinkнаwikiPageHref(название, иначе код) и путь; список правок —LinkнаversionHrefсоstate={{ background: { pathname, search }, depth: 1 }}(текущий адрес страницы), текст: время (eventTime(at, now),now— момент ответа), имя автора,deltaText. Классы — толькоwiki-feed-*вmodules/wiki/feed/feed.css(токены, перенос длинных названийoverflow-wrap:anywhere).useFeed:count—changes.length,window—{since, until}ответа,truncated, ошибка сrefetch,atгруппы —atпервой правки. -
Step 4: Запустить — проходят. Run:
cd web && npm run lint && npm test— Expected: PASS. -
Step 5: Commit.
git add web/src
git commit -m "Веб: правки вики в общей ленте (#140)"
Task 7: Веб — окно правки с диффом поверх ленты, e2e
Model: sonnet
Files:
- Create:
web/src/modules/wiki/versions/overlay.tsx(useVersionOverlay),versionState.ts,versionState.test.ts - Create:
web/src/modules/wiki/versions/VersionModal.tsx,diffLines.ts,diffLines.test.ts,versions.css,VersionModal.test.tsx - Modify:
web/src/modules/wiki/api/queries.ts(WikiDiff,WikiVersion,useWikiDiff,useWikiVersions, ключи) - Modify:
web/src/modules/wiki/index.tsx(useOverlay: useVersionOverlay) - Modify:
web/scripts/seed-hub.py(две версииDOC-9003сегодня иwiki_history),web/e2e/wiki.spec.ts - Modify:
ARCHITECTURE.md(модуль wiki в разделе «Веб»)
Interfaces:
-
Consumes:
versionHref,wikiPageHref,VERSION_PATH(Task 6);GET /wiki/pages/{code}/diff,…/versions(Task 3);Overlay(modules/types.ts),Dialog(ui). -
Produces:
VersionState = { background: { pathname: string; search: string }; depth: number; direct: boolean };versionState(s: unknown): VersionState | null(какcardStateу jira — своя копия, импорт чужого модуля запрещён);pageBackground(code, search)→{ pathname: "/wiki", search: "?project=<из search>&page=<код>" }.useVersionOverlay(): Overlay— поVERSION_PATH: нетstate—replaceтой же записи с{background: pageBackground(…), depth: 0, direct: true};pageLocation= фон; закрытие —depth ≥ 1→navigate(-depth), иначеnavigate(фон, { replace: true }); переход к соседней правке —navigate(versionHref(…), { replace: true, state })(то жеstate, «назад» закрывает окно).diffLines(text: string): { kind: "head" | "hunk" | "add" | "del" | "ctx"; text: string }[]—---/+++→head,@@→hunk,+→add,-→del, прочее →ctx; хвостовой пустой строки нет.useWikiDiff(project, code, version)—useInfiniteQuery,pageParam—offset(0, затемnext_offset);useWikiVersions(project, code)—GET …/versions.
-
Step 1: Написать падающие тесты.
diffLines.test.ts:"--- A\n+++ B\n@@ -1 +1 @@\n-x\n+y\n z\n"→ kinds["head","head","hunk","del","add","ctx"].versionState.test.ts: мусорныйstate→null;pageBackground("DOC-1", "?project=AWPCA")→{pathname: "/wiki", search: "?project=AWPCA&page=DOC-1"}.VersionModal.test.tsx(App,mockFetch;versions— DOCH-4…DOCH-1 новыми первыми,diffпоversion):«Правка из ленты»: окно — «правка 4 из 4», Соснов, 12:40, «+40 / −12», строки диффа с классами add/del и подписью «добавлено»/«удалено» для экранных читалок; запрос diff с version=DOCH-4; лента под окном (page-feed).«Предыдущая правка»: правка 3 из 4, «Следующая правка» доступна, «Предыдущая» у первой недоступна; history.back закрывает окно, адрес — лента с прежними period и sources.Escape и кнопка «Закрыть» закрывают окно — адрес ленты прежний.«Окно по ссылке»: /app/wiki/pages/DOC-101252/versions/DOCH-3159076?project=AWPCA — окно поверх «Вики» с DOC-101252; закрытие → /app/wiki?project=AWPCA&page=DOC-101252.«Длинный дифф»: next_offset 20000 — кнопка «Показать дальше», запрос offset=20000, вторая часть ниже первой; next_offset null — кнопки нет.«Показать дальше»: часть рвёт строку — строка целая и одна(Review Focus 5: первая часть кончается на"+строка дл", вторая начинается"инная\n"→ один элементaddс текстом"+строка длинная").«Открыть страницу» ведёт на /app/wiki?project=AWPCA&page=DOC-101252.окно ничего не пишет: за всё время теста нет запросов, кроме GET.
web/e2e/wiki.spec.ts:правка из ленты: окно с диффом, «Предыдущая правка», назад — лента(сид: две версии DOC-9003);окно правки на 390 px: горизонтальной прокрутки страницы нет(document.documentElement.scrollWidth <= clientWidth).
-
Step 2: Запустить — падают. Run:
cd web && npx vitest run src/modules/wiki— Expected: FAIL. -
Step 3: Реализовать.
VersionModal—Dialog(size="lg", классwiki-version-modal), закрытие Escape/кнопкой/ щелчком по фону — какIssueModal(нажатие и отпускание на фоне); шапка:h2название, путь, «правка N из M», автор (authorName), время (eventTime(at, now)),deltaText; кнопки «Предыдущая правка» (старее: следующая в спискеversions), «Следующая правка» (новее), ссылка «Открыть страницу» (wikiPageHref). Дифф — склейкаdiffвсех загруженных частей, затемdiffLines; строки — моноширинный шрифт,white-space: pre-wrap; overflow-wrap: anywhere(без горизонтальной прокрутки страницы);add—--color-status-successна--color-status-success-bg,del—--color-status-errorна--color-status-error-bg,head/hunk—--color-text-secondary; подписиadd/del—visually-hidden«добавлено: »/«удалено: ». Классы — толькоwiki-version-*вversions.css. «Показать дальше» —fetchNextPage, покаnext_offset !== null. Сид:INSERT INTO wiki_versionдвух версий DOC-9003 (сегодня, Markdown отличается строкой) иwiki_historyAWPCA. -
Step 4: Запустить — проходят. Run:
cd web && npm run lint && npm test && npx playwright test e2e/wiki.spec.ts e2e/feed.spec.ts— Expected: PASS. Если браузеры Playwright не установлены (npx playwright install chromiumнедоступен) — отметить O-n в ledger и не считать задачу выполненной без прогона e2e у контроллера. -
Step 5: Документы. ARCHITECTURE.md, абзац модуля
wikiв разделе «Веб»: источник ленты (feed/), окно правки/app/wiki/pages/{code}/versions/{version}поверх страницы (versions/overlay.tsx, фон —location.state.background, прямой адрес — поверх «Вики» с этой страницей), диффversions/. -
Step 6: Commit.
git add web/src web/scripts/seed-hub.py web/e2e/wiki.spec.ts ARCHITECTURE.md
git commit -m "Веб: окно правки вики с диффом поверх ленты (#140)"
Task 8: Проверка всего набора
Model: haiku
Files: без новых изменений; правки — только если проверка нашла расхождение (тогда — отдельный коммит с маркером находки).
- Step 1: Хаб. Run:
uv run pytest -q— Expected: все тесты PASS, предупреждений о незакрытых соединениях нет. - Step 2: Границы и эталоны. Run:
uv run pytest tests/test_registry.py tests/test_baseline.py -q && git diff main --stat -- tests/fixtures/baseline/— Expected: PASS; измененыschema,routes,mcp_wiki_tools;mcp_tools.expected.jsonиsettings.expected.json— нет. - Step 3: Фронт. Run:
make check— Expected: pytest,npm run lintиnpm testзелёные. - Step 4: Сборка и e2e. Run:
cd web && npm run build && npx playwright test— Expected: сборка без ошибокtsc, e2e PASS. - Step 5: Сверка документов. Run:
grep -n "jira/feed\|История страниц вики" CLAUDE.md ARCHITECTURE.md README.md— Expected:/app/jira/feedупоминается только как прежний адрес с перенаправлением; открытого вопроса «История страниц вики» нет. - Step 6: Итог. Записать в ledger результат каждой команды (число тестов). Коммита нет, если правок не было.
Self-review
- Покрытие спеки. specs/wiki «История правок страниц» — Task 1 (форма версий, 409/422, перевод
📎), Task 2 (выдачаknown_versions, запись вcommit, «+/−» и пересчёт следующей, удаление вне дерева,history_at, «пришло N из M», агент v1, отброшенный отчёт, сброс корня, очистка стадии), все шесть сценариев — тесты Task 2. «Маршруты истории вики» — Task 3, все шесть сценариев. specs/mcp «MCP вики» — Task 4 (таблица инструментов, периодchanges, строки в одну линию,sync,status, сценарии «Что сделали аналитики», «Дифф правки», «Синхронизация с ожиданием», «История ещё не загружена»; сервер jira без изменений — эталон). specs/web-app «Лента изменений» и «Оболочка и адреса» — Task 5 (страница ядра, источники, флажки, адрес, перенаправление, пункт «Лента», NotFound), Task 6 («Jira и Вики вперемешку», «Проект без вики», «Ошибка одного источника», «Пустой период»), «Обзор проекта» — Task 5 (Jira) и Task 6 («Правки»), «Спринты и бэклог» (меню без «Ленты») — Task 5, «Правки вики в ленте» — Task 6 (группа, история не загружена, контракт) и Task 7 (окно, все сценарии). ARCHITECTURE.md/CLAUDE.md/README.md — шаги «Документы» Task 1–5, 7. - Шаги. Тела функций не выписаны там, где их задают сигнатура и тест; алгоритм пересчёта «+/−» и правила сообщений ленты описаны, потому что тесты их не задают целиком.
- Типы.
history.utc/known_versions/line_delta/history_at/feed/page_versions/diff/last_change— одинаково в Task 1–4;FeedSource/FeedResult/FeedSourceInput— Task 5, используются в Task 6;versionHref/wikiPageHref/VERSION_PATH— Task 6 → 7. - Review Focus. Пять пунктов с тестами в задачах-владельцах.
- Пропорция. План ~ того же порядка, что спека (≈ 750 строк), кода — сигнатуры, SQL схемы и утверждения тестов.