diff --git a/openspec/changes/fix-sources-add-channel/.openspec.yaml b/openspec/changes/address-field-enhancement/.openspec.yaml
similarity index 100%
rename from openspec/changes/fix-sources-add-channel/.openspec.yaml
rename to openspec/changes/address-field-enhancement/.openspec.yaml
diff --git a/openspec/changes/address-field-enhancement/design.md b/openspec/changes/address-field-enhancement/design.md
new file mode 100644
index 0000000..e2567a8
--- /dev/null
+++ b/openspec/changes/address-field-enhancement/design.md
@@ -0,0 +1,202 @@
+# address-field-enhancement
+
+## Дизайн
+
+### 1. Backend: парсер адреса и получение названия
+
+**Новая функция в `sources/sources.py`** (или отдельный модуль — решаю по месту;
+парсинг адреса — чистая функция без зависимостей):
+
+```python
+_TG_HANDLE_RE = re.compile(r"^(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})$")
+_RSS_URL_RE = re.compile(r"^https?://", re.I)
+
+def parse_address(address: str, crawler: str) -> dict:
+ """Разбирает одно поле Address в словарь полей источника.
+
+ telegram: address = @handle | t.me/handle | https://t.me/s/handle | handle
+ → {slug: handle, channel: handle, url: https://t.me/handle}
+ rss: address = https://...feed.xml | http://...
+ → {feed_url: address, url: address} (slug — из домена/ручного ввода)
+ Возвращает dict с заполненными полями, пустые — None. Гарантирует валидность
+ по _validate_source (slug для telegram; feed_url для rss).
+ """
+ addr = (address or "").strip()
+ if crawler == "telegram":
+ m = _TG_HANDLE_RE.match(addr) or _TG_HANDLE_RE.match("https://t.me/" + addr.lstrip("@/"))
+ if not m:
+ return {}
+ handle = m.group(1)
+ return {"slug": handle, "channel": handle, "url": f"https://t.me/{handle}"}
+ else: # rss
+ if not _RSS_URL_RE.match(addr):
+ return {}
+ # slug берём из имени файла/домена, чтобы был стабильным и уникальным
+ from urllib.parse import urlparse
+ host = urlparse(addr).netloc.replace("www.", "").split(".")[0]
+ return {"feed_url": addr, "url": addr, "slug": host}
+```
+
+**Получение названия (route preview)** — в `web/app.py`:
+
+```python
+@app.get("/sources/preview")
+async def sources_preview(request: Request, address: str = "", crawler: str = "telegram"):
+ """GET /sources/preview?address=t.me/linuxklub&crawler=telegram → JSON.
+
+ telegram: get_entity из Telethon → name=title канала.
+ rss: feedparser на адрес → name=feed.title.
+ Ошибки (канал не найден, сеть) → {"error": "..."} (200, чтобы JS читал).
+ """
+ _require_auth(request)
+ if not (address or "").strip():
+ return {"error": "адрес пуст"}
+ parsed = parse_address(address, crawler)
+ if not parsed:
+ return {"error": "не могу разобрать адрес для crawler=" + crawler}
+ name = None
+ if crawler == "telegram":
+ from crawler.telegram_crawler import make_client
+ from config import TG_SESSION_DIR
+ import asyncio
+ async def _get_title():
+ client = make_client(TG_SESSION_DIR)
+ await client.start()
+ try:
+ ent = await client.get_entity(parsed["channel"])
+ return ent.title
+ finally:
+ await client.disconnect()
+ try:
+ name = asyncio.run(_get_title())
+ except Exception as e:
+ return {"error": f"TG: {e}", **parsed}
+ else:
+ import feedparser
+ try:
+ d = feedparser.parse(parsed["feed_url"])
+ if d.bozo and not d.feed.get("title"):
+ return {"error": f"фид не читается: {d.bozo_exception}", **parsed}
+ name = d.feed.get("title") or None
+ except Exception as e:
+ return {"error": f"RSS: {e}", **parsed}
+ return {"slug": parsed["slug"], "name": name, **parsed}
+```
+
+`_form_source` — замена url/channel/feed_url одиночным address:
+
+```python
+def _form_source(form) -> dict:
+ def s(k, default=None):
+ v = form.get(k)
+ return (v or "").strip() if v else default
+ crawler = s("crawler", "telegram")
+ src = {
+ "slug": s("slug"),
+ "name": s("name"),
+ "crawler": crawler,
+ "direction": s("direction"),
+ "lang": s("lang", "ru"),
+ "priority": s("priority", "P1"),
+ "enabled": bool(form.get("enabled")),
+ "own": bool(form.get("own")),
+ }
+ parsed = parse_address(s("address"), crawler)
+ if crawler == "telegram":
+ src["channel"] = parsed.get("channel") or s("channel")
+ src["url"] = parsed.get("url") or s("url")
+ src["feed_url"] = s("feed_url")
+ else:
+ src["feed_url"] = parsed.get("feed_url") or s("feed_url")
+ src["url"] = parsed.get("url") or s("url")
+ src["channel"] = s("channel")
+ if not src["slug"] and parsed.get("slug"):
+ src["slug"] = parsed["slug"]
+ return src
+```
+
+`_validate_source`: для rss достаточно проверки feed_url (уже есть), для
+telegram — channel (уже есть). Доп. проверка: если crawler=rss и address не URL
+→ _form_source уже вернул пустые, валидатор отдаст 'rss-источнику нужен feed_url'.
+Ничего менять не нужно.
+
+**DIRECTION_OPTIONS** — в app.py при рендере /sources:
+
+```python
+@app.get("/sources")
+async def sources(request: Request, did: str = ""):
+ ...
+ conn = _db()
+ rows = conn.execute(
+ "SELECT DISTINCT direction FROM sources WHERE direction IS NOT NULL AND direction != '' ORDER BY direction"
+ ).fetchall()
+ directions = sorted(set(DIRECTIONS) | {r[0] for r in rows})
+ return templates.TemplateResponse("sources.html", {
+ "sources": srcs, "did": did, "directions": directions,
+ "request": request,
+ })
+```
+
+### 2. Frontend: форма
+
+Поля `url`, `channel`, `feed_url` из формы удаляются, вместо них:
+
+```html
+
+
+
+
+```
+
+Кнопка «Найти» (для обоих типов; для telegram — получение названия через TG):
+
+```html
+
+
+
+```
+
+JS (замена старого скрипта required):
+
+```html
+
+```
+
+### 3. Изменения только в форме — edit-форма не трогается
+
+Таблица и edit (изменение существующих) остаются как есть: у источника в строке
+показываются channel/feed_url. Для edit поле address не добавляется — там
+сохранены отношения url/channel/feed_url.
+
+## Проверка
+
+1. `openspec validate address-field-enhancement` — чисто.
+2. `.venv/bin/python -c "from sources.sources import parse_address; print(parse_address('https://t.me/mknewsru','telegram'))"` → slug=mknewsru, channel=mknewsru.
+3. `curl 'http://127.0.0.1:8400/sources/preview?address=t.me/linuxklub&crawler=telegram'` (с авторизацией) → {"slug":"linuxklub","name":"Linux Club",...}.
+4. Рестарт vesti-web; ручная проверка формы (address → Найти → название появилось).
\ No newline at end of file
diff --git a/openspec/changes/address-field-enhancement/proposal.md b/openspec/changes/address-field-enhancement/proposal.md
new file mode 100644
index 0000000..4230ea3
--- /dev/null
+++ b/openspec/changes/address-field-enhancement/proposal.md
@@ -0,0 +1,45 @@
+# address-field-enhancement
+
+Улучшение формы добавления источника: одно поле Address вместо url/channel/feed_url,
+авто-подстановка названия и slug, direction с автодополнением из существующих значений.
+
+## Why
+
+Пользователь: при добавлении telegram-источника хочется вставить адрес типа
+`https://t.me/mknewsru`, а название канала получить из TG API автоматически
+(«Мой компьютер»), slug — из адреса (`mknewsru`). Сейчас приходится вручную
+заполнять 4 поля (url, channel, feed_url, название), часть из которых избыточна.
+
+Также вместо жёсткого списка DIRECTIONS в datalist — подставлять реально
+существующие в БД направления + возможность ввода нового.
+
+## What Changes
+
+- **web/templates/sources.html**: поле `channel`/`feed_url`/`url` заменяются
+ одним полем `address`; JS: при выборе crawler подсказка меняется;
+ кнопка «Найти название» (для telegram) вызывает GET /sources/preview?address=...
+ и заполняет slug/name; direction — datalist из `DIRECTIONS` + свободный ввод.
+- **web/app.py**: новый route `GET /sources/preview` — принимает address,
+ определяет тип (telegram/rss), парсит slug, получает название (telegram:
+ Telethon get_entity; rss: feedparser), возвращает JSON {slug, name, url, channel/feed_url}.
+ `_form_source`: `address` → парсинг в зависимости от crawler (telegram:
+ channel из address, url=t.me/...; rss: feed_url=address, url=address).
+ `_validate_source`: rss проверяет address как URL (непустой), telegram — channel.
+- **web/templates/sources.html**: datalist direction строится из DIRECTION_OPTIONS
+ (переменная из app.py: DIRECTIONS + уникальные из БД), input остаётся свободным.
+
+## Why Not
+
+- Не изменяем БД/схему: `address` — только UI-концепция, в БД по-прежнему
+ url/channel/feed_url.
+- Не трогаем краулеры: они читают channel/feed_url из yaml — парсинг на этапе
+ добавления в yaml.
+
+## Impact
+
+- Форма проще: одно поле для адреса + crawler.
+- Название и slug подставляются автоматически (для telegram — реальное имя
+ канала; для rss — title фида).
+- direction: реальные значения из БД, свободный ввод остаётся.
+- Обратная совместимость: старые источники с заполненными url/channel/feed_url
+ работают как раньше (адрес в форму можно не вводить при edit).
\ No newline at end of file
diff --git a/openspec/changes/address-field-enhancement/tasks.md b/openspec/changes/address-field-enhancement/tasks.md
new file mode 100644
index 0000000..911bec5
--- /dev/null
+++ b/openspec/changes/address-field-enhancement/tasks.md
@@ -0,0 +1,18 @@
+# address-field-enhancement
+
+## Problem
+
+Форма добавления источника требует url/channel/feed_url/название вручную.
+Хочется: одно поле Address, название — из TG API (RSS — из фида), slug — из адреса.
+Direction — datalist из реальных значений БД + свободный ввод.
+
+## Tasks
+
+- [ ] sources/sources.py: `parse_address(address, crawler)` — telegram/rss парсинг адреса
+- [ ] web/app.py: route GET /sources/preview (Telethon get_entity / feedparser title)
+- [ ] web/app.py: _form_source — address вместо url/channel/feed_url; slug из address
+- [ ] web/app.py: /sources — DIRECTIONS + уникальные из БД → directions в контекст
+- [ ] web/templates/sources.html: одно поле address, кнопка «Найти» → slug/name; datalist direction из данных
+- [ ] openspec validate clean
+- [ ] рестарт vesti-web; curl preview (telegram+rss); ручная проверка формы
+- [ ] STATUS.md актуализирован; commit + push gitverse
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml b/openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml
new file mode 100644
index 0000000..96db9a4
--- /dev/null
+++ b/openspec/changes/archive/2026-09-15-sources-admin/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-15
diff --git a/openspec/changes/archive/2026-09-15-sources-admin/design.md b/openspec/changes/archive/2026-09-15-sources-admin/design.md
new file mode 100644
index 0000000..871a22d
--- /dev/null
+++ b/openspec/changes/archive/2026-09-15-sources-admin/design.md
@@ -0,0 +1,51 @@
+# Design: sources-admin
+
+## Модель данных и источник истины
+
+- `sources/sources.yaml` — **источник истины** (в Git). Краулеры при запуске
+ вызывают `sync_sources_to_db()` → таблица `sources`.
+- Любое веб-изменение: сначала пишем в yaml (`upsert_source_yaml` /
+ `delete_source_yaml` / toggle enabled), затем синкаем yaml → БД
+ (`sync_yaml_to_db_and_back`), чтобы бд-строка не расходилась.
+
+## Изменения в sources/sources.py
+
+1. `delete_source_db(slug)` — удаляет строку из `sources` по slug. Связанные
+ данные: `posts` (FK source_id) — задаём `source_id=NULL` (посты остаются
+ историей, без источника); `runs` — `source_id=NULL`; `rss_state` — DELETE;
+ `classifications` — не трогаем (привязаны к posts).
+ Порядок: `delete_source_yaml` (yaml) → `delete_source_db` (БД).
+2. `set_source_enabled(slug, enabled)` — обновляет enabled в yaml
+ (через upsert_source_yaml с ключом enabled) и в БД (`sync_yaml_to_db_and_back`).
+ Пауза: enabled=false; снятие: enabled=true.
+3. `add_or_update_source(data)` — валидация (slug обязателен, уникален;
+ crawler ∈ {telegram, rss}; priority ∈ {P0..P3}; для rss — feed_url обязателен)
+ → upsert_source_yaml → sync_yaml_to_db_and_back → возврат slug.
+
+## Веб-слой (web/app.py)
+
+- `GET /sources` — страница (за auth): таблица всех источников (slug, name,
+ crawler, direction, lang, priority, enabled, url/feed_url, last_fetch/status),
+ форма добавления, кнопки действий. Шаблон `web/templates/sources.html`.
+- `POST /sources/add` — добавление (форма). Redirect на /sources.
+- `POST /sources/update/` — переименование/правка полей.
+- `POST /sources/{slug}/toggle` — пауза/снятие с паузы.
+- `POST /sources/{slug}/delete` — удаление (с подтверждением на стороне формы:
+ `onclick="return confirm(...)"`).
+- Все POST — с `_require_auth`, следуют паттерну /crawlers (async + form).
+- Валидация ошибок → flash message + редирект (не 500).
+- Навбар: ссылка «Источники» между «Краулеры» и «Выйти».
+
+## Шаблон sources.html
+
+- Таблица + модальный диалог (Bootstrap) для add/edit (одна форма).
+- Кнопки: Изменить (заполняет модалку), Удалить (confirm), Пауза/Снять (toggle).
+- Приоритет — селект P0/P1/P2/P3; crawler — селект telegram/rss; direction —
+ селект из DIRECTIONS_CANON (keywords) + «—».
+- Для rss: поле feed_url; для telegram: channel.
+
+## Безопасность
+
+- Пароль ADMIN_PASSWORD, как на /crawlers.
+- slug — белый список [a-z0-9_-], валидация на добавление (иначе 400).
+- Удаление — только POST + confirm (никаких GET-удалений).
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-15-sources-admin/proposal.md b/openspec/changes/archive/2026-09-15-sources-admin/proposal.md
new file mode 100644
index 0000000..1543b92
--- /dev/null
+++ b/openspec/changes/archive/2026-09-15-sources-admin/proposal.md
@@ -0,0 +1,32 @@
+# Proposal: sources-admin
+
+## Why
+
+Сейчас источники редактируются вручную в `sources/sources.yaml` (Git-файл).
+Веба для управления нет: на /crawlers источники только читаются. Пользователь
+хочет отдельную страницу управления источниками:
+
+- добавлять новые источники (telegram/rss), указывая slug, name, url/feed_url, направление, приоритет;
+- переименовывать (name), менять slug при необходимости;
+- удалять (с подтверждением);
+- ставить на паузу (enabled=false) и снимать с паузы;
+- менять приоритет (priority P0–P3).
+
+`sources.yaml` остаётся источником истины (краулеры при запуске делают
+`sync_sources_to_db`). CRUD-функции в sources.py уже есть (upsert_source_yaml,
+delete_source_yaml, db_source_to_yaml) — не хватает удаления/паузы в БД и
+веб-слоя. Проблема: удаление источника из yaml оставляет его в БД (сирота с
+постами/ранами); пауза = enabled=0 в yaml+БД.
+
+## Success criteria
+
+- Страница /sources (за auth): таблица всех источников, кнопки Добавить / Изменить / Удалить / Пауза / Снять с паузы.
+- Добавление/изменение через модальную форму (slug, name, crawler, url/feed_url, direction, lang, priority).
+- Удаление с подтверждением (confirm) и каскадной чисткой связанных данных.
+- Каждое изменение пишется в sources.yaml И БД атомарно (yaml — источник истины).
+- /crawlers продолжает работать (читает ту же таблицу).
+
+## Out of scope
+
+- Тестирование краулеров из веба (запуск оставлен на /crawlers).
+- Управление направлениями (направления — код в keywords.py, не CRUD).
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md b/openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md
new file mode 100644
index 0000000..f2de8f8
--- /dev/null
+++ b/openspec/changes/archive/2026-09-15-sources-admin/specs/sources-admin/spec.md
@@ -0,0 +1,92 @@
+# Spec: sources-admin
+
+## Purpose
+Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение,
+удаление, пауза и смена приоритета источников без ручной правки YAML.
+`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД.
+
+## ADDED Requirements
+
+### Requirement: Просмотр списка источников
+Страница /sources показывает таблицу всех источников с их полями и статусом.
+
+#### Scenario: просмотр списка источников
+- **Given** веб запущен, пользователь авторизован
+- **When** он открывает `/sources`
+- **Then** страница показывает таблицу всех источников: slug, name, crawler,
+ direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error)
+
+### Requirement: Добавление нового источника
+Форма «Добавить» создаёт запись в sources.yaml и БД.
+
+#### Scenario: добавление нового источника
+- **Given** страница /sources
+- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1)
+- **And** отправлена
+- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД
+- **And** страница показывает новый источник в таблице
+
+#### Scenario: валидация добавления
+- **Given** форма добавления с невалидным slug (`my source!`)
+- **When** отправлена
+- **Then** добавление отклонено, показана ошибка (flash), ничего не записано
+- **And** slug принимается только `[a-z0-9_-]+`
+
+### Requirement: Изменение полей источника
+Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД.
+
+#### Scenario: переименование / изменение полей
+- **Given** существующий источник `lwn`
+- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2
+- **Then** изменения применяются в yaml и БД, таблица обновляется
+
+### Requirement: Пауза и снятие с паузы
+Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет.
+
+#### Scenario: пауза и снятие с паузы
+- **Given** источник `lwn` (enabled=true)
+- **When** пользователь нажимает «Пауза»
+- **Then** `enabled=false` в yaml и БД
+- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает)
+- **When** пользователь нажимает «Снять с паузы»
+- **Then** `enabled=true`, сбор возобновляется
+
+### Requirement: Удаление источника
+Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты.
+
+#### Scenario: удаление источника
+- **Given** источник `opennet` с постами в БД
+- **When** пользователь подтверждает удаление (confirm)
+- **Then** запись удалена из yaml и sources БД
+- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется)
+- **And** rss_state для него удалён
+
+#### Scenario: удаление без подтверждения
+- **Given** форма удаления
+- **When** confirm отклонён (Cancel)
+- **Then** ничего не удалено, данные не меняются
+
+### Requirement: Доступ без авторизации
+Страница /sources защищена паролем, как остальные админ-страницы.
+
+#### Scenario: доступ без авторизации
+- **Given** пользователь не авторизован
+- **When** он открывает `/sources`
+- **Then** происходит редирект на /login (303)
+
+### Requirement: Приоритет источника
+Приоритет P0–P3 влияет на порядок обработки источников.
+
+#### Scenario: приоритет
+- **Given** источники с priority P0–P3
+- **When** страница /sources загружена
+- **Then** приоритеты отображаются и доступны для изменения (P0–P3)
+- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета
+
+### Requirement: Навигация
+Ссылка «Источники» присутствует в навбаре.
+
+#### Scenario: навигация
+- **Given** любая страница веб (base.html)
+- **When** открыт навбар
+- **Then** есть ссылка «Источники» на `/sources`
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-15-sources-admin/tasks.md b/openspec/changes/archive/2026-09-15-sources-admin/tasks.md
new file mode 100644
index 0000000..b42a973
--- /dev/null
+++ b/openspec/changes/archive/2026-09-15-sources-admin/tasks.md
@@ -0,0 +1,35 @@
+# Tasks: sources-admin
+
+## 1. sources.py — CRUD для БД + валидация
+
+- [x] 1.1 `delete_source_db(slug)`: UPDATE posts/runs SET source_id=NULL, DELETE rss_state, DELETE sources.
+ Проверка: юнит-тест — после delete_source_db строки sources нет, rss_state нет, posts.source_id=NULL.
+- [x] 1.2 `set_source_enabled(slug, enabled)`: обновить yaml (upsert с enabled) + sync_yaml_to_db_and_back.
+ Проверка: юнит — после toggle enabled в БД = 0/1, в yaml = false/true.
+- [x] 1.3 `add_or_update_source(data)`: валидация (slug regex, crawler ∈ {telegram,rss}, priority ∈ P0..P3, rss→feed_url обязателен) → upsert yaml → sync.
+ Проверка: юнит — add 'opennet' (rss) → есть в yaml и БД; невалидный 400.
+
+## 2. Веб-слой
+
+- [x] 2.1 GET /sources — страница (таблица + форма). Шаблон sources.html.
+ Проверка: GET с auth → 200, в таблице все источники (10).
+- [x] 2.2 POST /sources/add, /sources/update/ — добавление/правка. Валидация, flash, редирект.
+ Проверка: POST add opennet → в БД/yaml появился; POST update — name изменился.
+- [x] 2.3 POST /sources/{slug}/toggle — пауза (enabled 0) / снятие (enabled 1).
+ Проверка: toggle lwn → enabled=0 в БД и yaml; в /crawlers lwn помечен disabled.
+- [x] 2.4 POST /sources/{slug}/delete — удаление с confirm. Каскад: posts/runs source_id=NULL, rss_state удалён.
+ Проверка: delete тестового источника → из yaml и БД исчез, посты не потеряны (source_id NULL).
+- [x] 2.5 Навбар: ссылка «Источники» (была добавлена — проверена в base.html).
+
+## 3. Интеграция, доки
+
+- [x] 3.1 AGENT.MD / STATUS.md / TODO.md — /sources (добавлено/закрыто).
+- [x] 3.2 Ссылка /sources в nav (проверка вёрстки).
+- [x] 3.3 `openspec validate sources-admin` → valid.
+- [x] 3.4 Рестарт vesti-web, ручная проверка GET /sources 200.
+
+## Примечания
+- sources.yaml — источник истины; каждое изменение пишется в yaml И БД.
+- Удалённые посты НЕ удаляются (история), только source_id=NULL.
+- Пауза не трогает уже собранные посты — только прекращает сбор (enabled=0).
+- HTTP-тест: add→toggle(pause)→toggle(resume)→delete прошёл end-to-end (тест. источник test_tmp_src удалён, чисто).
\ No newline at end of file
diff --git a/openspec/changes/candidates-buttons-order/.openspec.yaml b/openspec/changes/archive/2026-09-16-candidates-buttons-order/.openspec.yaml
similarity index 100%
rename from openspec/changes/candidates-buttons-order/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/.openspec.yaml
diff --git a/openspec/changes/candidates-buttons-order/design.md b/openspec/changes/archive/2026-09-16-candidates-buttons-order/design.md
similarity index 100%
rename from openspec/changes/candidates-buttons-order/design.md
rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/design.md
diff --git a/openspec/changes/candidates-buttons-order/proposal.md b/openspec/changes/archive/2026-09-16-candidates-buttons-order/proposal.md
similarity index 100%
rename from openspec/changes/candidates-buttons-order/proposal.md
rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/proposal.md
diff --git a/openspec/changes/candidates-buttons-order/tasks.md b/openspec/changes/archive/2026-09-16-candidates-buttons-order/tasks.md
similarity index 100%
rename from openspec/changes/candidates-buttons-order/tasks.md
rename to openspec/changes/archive/2026-09-16-candidates-buttons-order/tasks.md
diff --git a/openspec/changes/candidates-only-external-new/.openspec.yaml b/openspec/changes/archive/2026-09-16-candidates-only-external-new/.openspec.yaml
similarity index 100%
rename from openspec/changes/candidates-only-external-new/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/.openspec.yaml
diff --git a/openspec/changes/candidates-only-external-new/design.md b/openspec/changes/archive/2026-09-16-candidates-only-external-new/design.md
similarity index 100%
rename from openspec/changes/candidates-only-external-new/design.md
rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/design.md
diff --git a/openspec/changes/candidates-only-external-new/proposal.md b/openspec/changes/archive/2026-09-16-candidates-only-external-new/proposal.md
similarity index 100%
rename from openspec/changes/candidates-only-external-new/proposal.md
rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/proposal.md
diff --git a/openspec/changes/candidates-only-external-new/tasks.md b/openspec/changes/archive/2026-09-16-candidates-only-external-new/tasks.md
similarity index 100%
rename from openspec/changes/candidates-only-external-new/tasks.md
rename to openspec/changes/archive/2026-09-16-candidates-only-external-new/tasks.md
diff --git a/openspec/changes/candidates-read-flag/.openspec.yaml b/openspec/changes/archive/2026-09-16-candidates-read-flag/.openspec.yaml
similarity index 100%
rename from openspec/changes/candidates-read-flag/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-candidates-read-flag/.openspec.yaml
diff --git a/openspec/changes/candidates-read-flag/design.md b/openspec/changes/archive/2026-09-16-candidates-read-flag/design.md
similarity index 100%
rename from openspec/changes/candidates-read-flag/design.md
rename to openspec/changes/archive/2026-09-16-candidates-read-flag/design.md
diff --git a/openspec/changes/candidates-read-flag/proposal.md b/openspec/changes/archive/2026-09-16-candidates-read-flag/proposal.md
similarity index 100%
rename from openspec/changes/candidates-read-flag/proposal.md
rename to openspec/changes/archive/2026-09-16-candidates-read-flag/proposal.md
diff --git a/openspec/changes/candidates-read-flag/tasks.md b/openspec/changes/archive/2026-09-16-candidates-read-flag/tasks.md
similarity index 100%
rename from openspec/changes/candidates-read-flag/tasks.md
rename to openspec/changes/archive/2026-09-16-candidates-read-flag/tasks.md
diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml b/openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml
new file mode 100644
index 0000000..96db9a4
--- /dev/null
+++ b/openspec/changes/archive/2026-09-16-crawler-queue/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-15
diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/design.md b/openspec/changes/archive/2026-09-16-crawler-queue/design.md
new file mode 100644
index 0000000..6daf022
--- /dev/null
+++ b/openspec/changes/archive/2026-09-16-crawler-queue/design.md
@@ -0,0 +1,107 @@
+# Design: crawler-queue
+
+## Approach
+
+Двухфазный пайплайн поверх существующей модели данных (БЕЗ новой таблицы-очереди):
+
+```
+Фаза 1 (сбор) Фаза 3 (обработка, ПАРАЛЛЕЛЬНО)
+sources.yaml ──► ┌──────────────┐
+ telegram_crawler ──► │ ThreadPool │──► posts(classified=0)
+ rss_crawler ──► │ (8 воркеров)│──► keywords → Ollama → direction
+ │ └──────────────┘ + trafilatura (полный текст)
+ ▼
+posts (status='new', classified=0) = ОЧЕРЕДЬ
+```
+
+- **Очередь = таблица `posts`** уже существует (`status='new'` + `classified=0`).
+ Размер очереди: `SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0`.
+- **Фаза 3** — новый `crawler/worker.py`: ThreadPoolExecutor(8), каждый воркер
+ claim'ит посты (`UPDATE posts SET classified=-1 WHERE id=? AND classified=0`
+ → атомарный claim, без дублей), классифицирует (keywords → Ollama), пишет
+ direction/relevance/interest/summary, ставит classified=1. Упавшие → classified=0
+ обратно (ретрай).
+- **Фаза 1** — `crawler/crawl_sources.py`: обёртка, запускающая telegram_crawler
+ и rss_crawler (сбор источников → посты в очередь). Раздельные запуски.
+- **Страница `/crawlers`** — FastAPI route в `web/app.py` + шаблон
+ `web/templates/crawlers.html`: таблица источников (из `sources` + `rss_state`),
+ размер очереди (pending/processing/done), кнопки «Запустить сейчас»
+ (POST /crawlers/run) и «Сбросить dead» (POST /crawlers/reset).
+
+## Files
+
+```bash
+# Новое
+crawler/worker.py # фаза 3: ThreadPoolExecutor, claim, классификация
+crawler/crawl_sources.py # фаза 1: запуск telegram+rss краулеров (сбор в очередь)
+web/templates/crawlers.html # страница /crawlers
+
+# Изменения
+web/app.py # routes: GET /crawlers, POST /crawlers/run, POST /crawlers/reset
+sources/sources.py # + get_rss_state(), + reset_source_status()
+AGENT.MD / STATUS.md / TODO.md / WALKTHROUGH.md # доки
+openspec/changes/crawler-queue/ # этот change
+```
+
+## Worker (фаза 3)
+
+```python
+# crawler/worker.py
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import sqlite3
+
+def claim_post(conn, worker_id) -> row | None:
+ # атомарный claim: одна строка — один воркер
+ conn.execute("BEGIN IMMEDIATE")
+ row = conn.execute(
+ "SELECT id, text, views, reactions_total, is_own FROM posts "
+ "WHERE classified IS NULL OR classified=0 "
+ "ORDER BY is_own DESC, id LIMIT 1").fetchone()
+ if row:
+ conn.execute("UPDATE posts SET classified=-1 WHERE id=?", (row["id"],))
+ conn.commit()
+ return row
+
+def process_one(row) -> dict: # classify_text из classifier.classify
+ ...
+
+def run(workers: int = 8, limit: int = 200):
+ with ThreadPoolExecutor(max_workers=workers) as ex:
+ futs = [ex.submit(work_loop, worker_id=i) for i in range(workers)]
+ for f in as_completed(futs):
+ ...
+```
+
+Каждый воркер:
+1. `claim_post` → строка post (classified=0 → -1).
+2. `classify_text` (keywords → Ollama; трафилатура для summary-only).
+3. UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?
+4. При исключении → UPDATE posts SET classified=0 WHERE id=? (вернуть в очередь).
+
+## Страница /crawlers
+
+| Источник | slug | name | crawler | status | last_fetch | last_error | Приоритет | Действия |
+Снизу — карточки очереди:
+- Pending: COUNT(classified IS NULL OR 0)
+- Processing: COUNT(classified=-1)
+- Done (сегодня): COUNT(classified=1 AND fetched_at >= today)
+
+Кнопки:
+- «Запустить сейчас» → POST /crawlers/run → запускает worker.run(8) синхронно (или
+ через subprocess в фоне), редирект на /crawlers.
+- «Сбросить dead» → POST /crawlers/reset → UPDATE sources SET status='alive', error_count=0
+ WHERE status='dead'.
+
+## CLI
+
+```bash
+.venv/bin/python -m crawler.worker --workers 8 --limit 200 # фаза 3, параллельно
+.venv/bin/python -m crawler.crawl_sources --all # фаза 1, сбор в очередь
+```
+
+## Verification
+
+- [ ] `openspec validate crawler-queue` → 0 ошибок
+- [ ] `worker.run(8)` обрабатывает ≥5 постов из очереди; ПОВТОРНЫЙ запуск — 0 новых (все classified=1)
+- [ ] Дубли не возникают: 100 постов, 8 воркеров → 100 строк обновлены, 0 пропущено
+- [ ] Страница /crawlers: показывает таблицу источников, размер очереди; кнопка «Сбросить dead» обнуляет error_count
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/proposal.md b/openspec/changes/archive/2026-09-16-crawler-queue/proposal.md
new file mode 100644
index 0000000..92541e2
--- /dev/null
+++ b/openspec/changes/archive/2026-09-16-crawler-queue/proposal.md
@@ -0,0 +1,46 @@
+# Proposal: crawler-queue
+
+## Why
+
+Сейчас фазы сбора и обработки не разделены: каждый краулер (telegram/rss) сам
+собирает посты и сам же (через `classifier.classify`) разбирает их на
+классификацию. Это последовательно: весь прогон — один поток, один источник за
+раз. При этом самая дорогая часть — фаза 3 (классификация через Ollama,
+дотягивание текста трафилатурой) — выполняется последовательно и без видимости
+процесса: непонятно, сколько кандидатов в очереди, какие источники живы, что
+упало.
+
+Пользователь хочет:
+1. Отдельная страница `/crawlers` — статус работы краулеров (alive/dead,
+ last_fetch, ошибки) и размер очереди.
+2. Двухфазный сбор: (1) краулеры проходят по источникам → ищут новых кандидатов;
+ (2) найденное кладётся в очередь; (3) воркеры разбирают очередь в НЕСКОЛЬКО
+ ПОТОКОВ.
+
+Анализ текущего кода:
+- Очередь фазы 2 уже существует — это `posts` со `status='new'` и
+ `classified IS NULL OR classified=0` (классификатор выбирает именно их,
+ `SELECT ... WHERE classified IS NULL OR classified=0 LIMIT ?`).
+- Отдельная таблица `crawl_queue` НЕ нужна — она дублировала бы `posts`.
+ «Размер очереди» = `COUNT(*) FROM posts WHERE classified IS NULL OR classified=0`.
+- Чего нет: (а) параллельной фазы 3 (ThreadPoolExecutor), (б) страницы `/crawlers`,
+ (в) разделения «сбор» и «обработка» как независимых запусков.
+
+## Goal
+
+- Параллельная фаза 3: воркеры (N потоков) разбирают очередь
+ `posts(classified=0)` — классификация (keywords → Ollama) + дотягивание текста
+ (trafilatura) по пайплайну `classifier.classify`.
+- Страница `/crawlers` (веб, за auth): таблица источников (slug, name, crawler,
+ status, last_fetch, last_error, error_count, priority), размер очереди
+ (pending/processing/done), кнопки «Запустить сейчас» и «Сбросить dead».
+- Разделение: `crawler/worker.py` (фаза 3, N потоков) и `crawler/crawl_sources.py`
+ (фаза 1: собрать новых кандидатов в очередь) — независимые запуски.
+- Терминология (для доков): true-конкурентность на IO-bound задачах через потоки;
+ GIL не мешает, т.к. фаза 3 — ожидание сети.
+
+## Non-goals
+
+- Не вводим Redis/RabbitMQ/брокеры — SQLite-очередь (claim по `posts`) достаточна.
+- Не выносим в микросервисы — всё в рамках существующего веба/краулера.
+- Не переписываем telegram_crawler; RSS-краулер уже работает.
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md b/openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md
new file mode 100644
index 0000000..ca62ef0
--- /dev/null
+++ b/openspec/changes/archive/2026-09-16-crawler-queue/specs/crawler-queue/spec.md
@@ -0,0 +1,74 @@
+# Spec: crawler-queue
+
+## Purpose
+
+Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают
+кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры
+(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница
+`/crawlers` показывает статус источников и размер очереди.
+
+## ADDED Requirements
+
+### Requirement: Параллельная фаза 3 (воркеры)
+Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`,
+где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1
+WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama,
+trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary,
+classified=1). При исключении посте MUST возвращаться в очередь (classified=0).
+
+#### Scenario: Параллельная обработка очереди
+- **GIVEN** 100 постов со `classified=0`
+- **WHEN** `python -m crawler.worker --workers 8 --limit 200`
+- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз)
+
+#### Scenario: Сбой воркера
+- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна)
+- **WHEN** воркер обрабатывает пост
+- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает
+
+### Requirement: Очередь на основе posts (без новой таблицы)
+«Размер очереди» MUST вычисляться из существующей таблицы `posts`:
+`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending),
+`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today).
+Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`.
+
+#### Scenario: Размер очереди
+- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1)
+- **WHEN** страница /crawlers запрашивает размер очереди
+- **THEN** pending=10, processing=2, done today=0
+
+### Requirement: Страница /crawlers
+Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников
+(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count,
+priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди
+(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST
+устанавливать sources.status='alive', error_count=0, last_error=NULL для всех
+источников со status='dead'.
+
+#### Scenario: Просмотр статуса
+- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди
+- **WHEN** GET /crawlers
+- **THEN** страница показывает lwn с статусом alive, размер очереди 15
+
+#### Scenario: Сброс dead-источников
+- **GIVEN** источник со status='dead', error_count=7
+- **WHEN** POST /crawlers/reset
+- **THEN** источник становится alive, error_count=0, last_error=NULL
+
+### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка)
+Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех
+включённых источников (telegram_crawler + rss_crawler) без классификации;
+новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3)
+запускается отдельно (`crawler/worker.py`).
+
+#### Scenario: Сбор без классификации
+- **GIVEN** включённые источники telegram и rss
+- **WHEN** `python -m crawler.crawl_sources --all`
+- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена
+
+## NOT Requirements
+
+- НЕ создаём отдельную таблицу очереди (`crawl_queue`) — используется `posts`.
+- НЕ вводим Redis/RabbitMQ/брокеры — атомарный claim через SQLite достаточен.
+- НЕ переписываем telegram_crawler/rss_crawler (фаза 1) — они уже работают.
+- НЕ выносим воркеры в отдельный процесс/сервис — модуль в том же проекте.
\ No newline at end of file
diff --git a/openspec/changes/archive/2026-09-16-crawler-queue/tasks.md b/openspec/changes/archive/2026-09-16-crawler-queue/tasks.md
new file mode 100644
index 0000000..16f5846
--- /dev/null
+++ b/openspec/changes/archive/2026-09-16-crawler-queue/tasks.md
@@ -0,0 +1,40 @@
+# Tasks: crawler-queue
+
+## 1. Воркер (фаза 3)
+
+- [x] 1.1 crawler/worker.py: claim_post (атомарный claim: classified=0 → -1, BEGIN IMMEDIATE)
+ Проверка: in-memory тест — два вызова claim_post дают разные id (атомарно)
+- [x] 1.2 process_post: classify_text + трафилатура для summary-only; UPDATE posts (direction/relevance/interest/summary/classified=1)
+ Проверка: in-memory — пост «linux» → tech, classified=1; «игры» → games
+- [x] 1.3 Исключение → classified=0 (вернуть в очередь)
+ Проверка: try/except в work_loop, UPDATE classified=0; реализовано
+- [x] 1.4 run(workers=8, limit=200): ThreadPoolExecutor, as_completed, счётчики (processed/classified/llm_ok)
+ Проверка: `python -m crawler.worker --workers 4 --limit 2` → 8 обработано; без ошибок
+
+## 2. Сбор (фаза 1)
+
+- [x] 2.1 crawler/crawl_sources.py: запуск telegram_crawler + rss_crawler (сбор новых кандидатов в очередь)
+ Проверка: `python -m crawler.crawl_sources --all` → запускает оба; `--crawler rss` — только RSS
+
+## 3. Страница /crawlers
+
+- [x] 3.1 web/app.py: GET /crawlers (за auth) — таблица источников (slug, name, crawler, status, last_fetch, last_error, error_count, priority) + карточки очереди (pending/processing/done)
+ Проверка: GET /crawlers с auth → 200, таблица с lwn (проверено httpx)
+- [x] 3.2 web/templates/crawlers.html — шаблон (таблица + карточки + кнопки)
+- [x] 3.3 POST /crawlers/run → запуск worker.run (фаза 3); POST /crawlers/reset → sources.status='alive', error_count=0
+ Проверка: POST /crawlers/run → воркер реально стартует (logs/worker.log), 200/302; reset — UPDATE
+
+## 4. Интеграция и доки
+
+- [x] 4.1 AGENT.MD — команды worker/crawl_sources (добавлены)
+- [x] 4.2 STATUS.md / TODO.md — страница /crawlers, параллельная фаза 3 (обновлены)
+- [x] 4.3 `openspec validate crawler-queue` → 0 ошибок
+- [x] 4.4 TODO.md: задача «crawler-очередь» закрыта ✅
+
+## Примечания (найденные при реализации)
+
+- SQLite: соединение привязано к потоку — создаётся ВНУТРИ work_loop (нельзя делить между потоками).
+- Локальная Ollama (qwen3:8b) держит 1 слот — 8 параллельных LLM-запросов → таймауты (посты возвращаются в очередь, не теряются). Добавлен `LLM_SEM` (threading.BoundedSemaphore, MAX_CONCURRENT_LLM=2).
+- Очередь = posts (classified IS NULL OR 0) — отдельная таблица НЕ нужна (дублирование).
+- error_count/etag для RSS — из rss_state (в sources их нет); запрос /crawlers использует COALESCE.
+- **SOURCE_RULES (classifier/keywords.py)**: правило источника с приоритетом над словарём и LLM. `lwn → tech` — все посты LWN (технологическое СМИ) получают направление tech детерминированно, без LLM (method='source-rule'). Работает в worker.py и старом CLI classifier.classify (оба JOIN sources → source_slug).
\ No newline at end of file
diff --git a/openspec/changes/fix-date-group-status/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-date-group-status/.openspec.yaml
similarity index 100%
rename from openspec/changes/fix-date-group-status/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-fix-date-group-status/.openspec.yaml
diff --git a/openspec/changes/fix-date-group-status/design.md b/openspec/changes/archive/2026-09-16-fix-date-group-status/design.md
similarity index 100%
rename from openspec/changes/fix-date-group-status/design.md
rename to openspec/changes/archive/2026-09-16-fix-date-group-status/design.md
diff --git a/openspec/changes/fix-date-group-status/proposal.md b/openspec/changes/archive/2026-09-16-fix-date-group-status/proposal.md
similarity index 100%
rename from openspec/changes/fix-date-group-status/proposal.md
rename to openspec/changes/archive/2026-09-16-fix-date-group-status/proposal.md
diff --git a/openspec/changes/fix-date-group-status/tasks.md b/openspec/changes/archive/2026-09-16-fix-date-group-status/tasks.md
similarity index 100%
rename from openspec/changes/fix-date-group-status/tasks.md
rename to openspec/changes/archive/2026-09-16-fix-date-group-status/tasks.md
diff --git a/openspec/changes/fix-media-mount/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-media-mount/.openspec.yaml
similarity index 100%
rename from openspec/changes/fix-media-mount/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-fix-media-mount/.openspec.yaml
diff --git a/openspec/changes/fix-media-mount/design.md b/openspec/changes/archive/2026-09-16-fix-media-mount/design.md
similarity index 100%
rename from openspec/changes/fix-media-mount/design.md
rename to openspec/changes/archive/2026-09-16-fix-media-mount/design.md
diff --git a/openspec/changes/fix-media-mount/proposal.md b/openspec/changes/archive/2026-09-16-fix-media-mount/proposal.md
similarity index 100%
rename from openspec/changes/fix-media-mount/proposal.md
rename to openspec/changes/archive/2026-09-16-fix-media-mount/proposal.md
diff --git a/openspec/changes/fix-media-mount/tasks.md b/openspec/changes/archive/2026-09-16-fix-media-mount/tasks.md
similarity index 100%
rename from openspec/changes/fix-media-mount/tasks.md
rename to openspec/changes/archive/2026-09-16-fix-media-mount/tasks.md
diff --git a/openspec/changes/fix-published-page/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-published-page/.openspec.yaml
similarity index 100%
rename from openspec/changes/fix-published-page/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-fix-published-page/.openspec.yaml
diff --git a/openspec/changes/fix-published-page/design.md b/openspec/changes/archive/2026-09-16-fix-published-page/design.md
similarity index 100%
rename from openspec/changes/fix-published-page/design.md
rename to openspec/changes/archive/2026-09-16-fix-published-page/design.md
diff --git a/openspec/changes/fix-published-page/proposal.md b/openspec/changes/archive/2026-09-16-fix-published-page/proposal.md
similarity index 100%
rename from openspec/changes/fix-published-page/proposal.md
rename to openspec/changes/archive/2026-09-16-fix-published-page/proposal.md
diff --git a/openspec/changes/fix-published-page/tasks.md b/openspec/changes/archive/2026-09-16-fix-published-page/tasks.md
similarity index 100%
rename from openspec/changes/fix-published-page/tasks.md
rename to openspec/changes/archive/2026-09-16-fix-published-page/tasks.md
diff --git a/openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml
new file mode 100644
index 0000000..63c288a
--- /dev/null
+++ b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/.openspec.yaml
@@ -0,0 +1,3 @@
+schema: spec-driven
+created: 2026-09-16
+skip_specs: true
\ No newline at end of file
diff --git a/openspec/changes/fix-sources-add-channel/design.md b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/design.md
similarity index 100%
rename from openspec/changes/fix-sources-add-channel/design.md
rename to openspec/changes/archive/2026-09-16-fix-sources-add-channel/design.md
diff --git a/openspec/changes/fix-sources-add-channel/proposal.md b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/proposal.md
similarity index 100%
rename from openspec/changes/fix-sources-add-channel/proposal.md
rename to openspec/changes/archive/2026-09-16-fix-sources-add-channel/proposal.md
diff --git a/openspec/changes/fix-sources-add-channel/tasks.md b/openspec/changes/archive/2026-09-16-fix-sources-add-channel/tasks.md
similarity index 100%
rename from openspec/changes/fix-sources-add-channel/tasks.md
rename to openspec/changes/archive/2026-09-16-fix-sources-add-channel/tasks.md
diff --git a/openspec/changes/keep-candidates-context/.openspec.yaml b/openspec/changes/archive/2026-09-16-keep-candidates-context/.openspec.yaml
similarity index 100%
rename from openspec/changes/keep-candidates-context/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-keep-candidates-context/.openspec.yaml
diff --git a/openspec/changes/keep-candidates-context/design.md b/openspec/changes/archive/2026-09-16-keep-candidates-context/design.md
similarity index 100%
rename from openspec/changes/keep-candidates-context/design.md
rename to openspec/changes/archive/2026-09-16-keep-candidates-context/design.md
diff --git a/openspec/changes/keep-candidates-context/proposal.md b/openspec/changes/archive/2026-09-16-keep-candidates-context/proposal.md
similarity index 100%
rename from openspec/changes/keep-candidates-context/proposal.md
rename to openspec/changes/archive/2026-09-16-keep-candidates-context/proposal.md
diff --git a/openspec/changes/keep-candidates-context/tasks.md b/openspec/changes/archive/2026-09-16-keep-candidates-context/tasks.md
similarity index 100%
rename from openspec/changes/keep-candidates-context/tasks.md
rename to openspec/changes/archive/2026-09-16-keep-candidates-context/tasks.md
diff --git a/openspec/changes/own-content-hub/.openspec.yaml b/openspec/changes/archive/2026-09-16-own-content-hub/.openspec.yaml
similarity index 100%
rename from openspec/changes/own-content-hub/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-own-content-hub/.openspec.yaml
diff --git a/openspec/changes/own-content-hub/design.md b/openspec/changes/archive/2026-09-16-own-content-hub/design.md
similarity index 100%
rename from openspec/changes/own-content-hub/design.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/design.md
diff --git a/openspec/changes/own-content-hub/proposal.md b/openspec/changes/archive/2026-09-16-own-content-hub/proposal.md
similarity index 100%
rename from openspec/changes/own-content-hub/proposal.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/proposal.md
diff --git a/openspec/changes/own-content-hub/specs/classifier/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/classifier/spec.md
similarity index 100%
rename from openspec/changes/own-content-hub/specs/classifier/spec.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/classifier/spec.md
diff --git a/openspec/changes/own-content-hub/specs/news-store/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/news-store/spec.md
similarity index 100%
rename from openspec/changes/own-content-hub/specs/news-store/spec.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/news-store/spec.md
diff --git a/openspec/changes/own-content-hub/specs/tg-crawler/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-crawler/spec.md
similarity index 100%
rename from openspec/changes/own-content-hub/specs/tg-crawler/spec.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-crawler/spec.md
diff --git a/openspec/changes/own-content-hub/specs/tg-publisher/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-publisher/spec.md
similarity index 100%
rename from openspec/changes/own-content-hub/specs/tg-publisher/spec.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/tg-publisher/spec.md
diff --git a/openspec/changes/own-content-hub/specs/vesti-web/spec.md b/openspec/changes/archive/2026-09-16-own-content-hub/specs/vesti-web/spec.md
similarity index 100%
rename from openspec/changes/own-content-hub/specs/vesti-web/spec.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/specs/vesti-web/spec.md
diff --git a/openspec/changes/own-content-hub/tasks.md b/openspec/changes/archive/2026-09-16-own-content-hub/tasks.md
similarity index 93%
rename from openspec/changes/own-content-hub/tasks.md
rename to openspec/changes/archive/2026-09-16-own-content-hub/tasks.md
index c02b040..b18f580 100644
--- a/openspec/changes/own-content-hub/tasks.md
+++ b/openspec/changes/archive/2026-09-16-own-content-hub/tasks.md
@@ -15,7 +15,7 @@
Проверка: код реализован (store_posts/is_own_source, py_compile OK); интеграционная проверка ждёт бэкфилла 2.3
- [x] 2.2 Форварды в своём канале: чужой форвард сохраняется с is_own=1 + fwd-полями, медиа НЕ скачивается
Проверка: код реализован (skip_media для форвардов); интеграционная проверка после бэкфилла
-- [ ] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг)
+- [x] 2.3 Бэкфилл своего канала: первый прогон ~1039 постов (медиа по возможности; при лимите — текст без медиа, бэкфилл-флаг) — СДЕЛАНО 2026-09-10 (backfill_dedinit.py, fetched=1040, max_post_id=1092, 846 в БД; ныне 848 is_own)
Проверка: `sqlite3 db/vesti.db "SELECT COUNT(*) FROM posts WHERE is_own=1"` > 0 (до ~1039)
- [x] 2.4 Дедуп: тот же sha256/url во внешнем канале → is_own=0 (упоминание), канонический остаётся is_own_canonical=1
Проверка: код реализован (канон vs упоминание); проверка на реальных данных после бэкфилла
@@ -54,7 +54,7 @@
## 7. Проверка интеграции и документация
-- [ ] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию)
+- [x] 7.1 Полный прогон: синк → краулер dedinit → классификатор → веб (approve с fan-out) → бандлы; внешние источники не затронуты (is_own=0 по умолчанию) — СДЕЛАНО (реальные approve 136/137, 2026-09-09; бэкфилл 2026-09-10)
Проверка: counts по is_own в БД, бандлы, /published, runs ok — ждёт бэкфилла 2.3 (реальная сеть)
- [x] 7.2 Обновить STATUS.md / TODO.md / WALKTHROUGH.md (что сделано, как запускать, питфолы)
Проверка: документы отражают новое состояние (обновлено при закрытии сессии)
\ No newline at end of file
diff --git a/openspec/changes/publisher-service/design.md b/openspec/changes/archive/2026-09-16-publisher-service/design.md
similarity index 100%
rename from openspec/changes/publisher-service/design.md
rename to openspec/changes/archive/2026-09-16-publisher-service/design.md
diff --git a/openspec/changes/publisher-service/proposal.md b/openspec/changes/archive/2026-09-16-publisher-service/proposal.md
similarity index 100%
rename from openspec/changes/publisher-service/proposal.md
rename to openspec/changes/archive/2026-09-16-publisher-service/proposal.md
diff --git a/openspec/changes/publisher-service/specs/tg-publisher-service/spec.md b/openspec/changes/archive/2026-09-16-publisher-service/specs/tg-publisher-service/spec.md
similarity index 100%
rename from openspec/changes/publisher-service/specs/tg-publisher-service/spec.md
rename to openspec/changes/archive/2026-09-16-publisher-service/specs/tg-publisher-service/spec.md
diff --git a/openspec/changes/publisher-service/tasks.md b/openspec/changes/archive/2026-09-16-publisher-service/tasks.md
similarity index 94%
rename from openspec/changes/publisher-service/tasks.md
rename to openspec/changes/archive/2026-09-16-publisher-service/tasks.md
index d5ca554..73d972a 100644
--- a/openspec/changes/publisher-service/tasks.md
+++ b/openspec/changes/archive/2026-09-16-publisher-service/tasks.md
@@ -48,4 +48,4 @@
- [x] 6.3 Обновить STATUS.md / TODO.md — сделано; WALKTHROUGH/PRD — обновлено (см. PRD.md)
## Открытые пункты
-- [ ] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске (нужен маппинг путей или передача имени файла)
\ No newline at end of file
+- [x] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске — ЗАКРЫТО change fix-media-mount (2026-09-14): контейнер монтирует весь каталог media/, publisher резолвит media/ и media/media/
\ No newline at end of file
diff --git a/openspec/changes/reject-stay-candidates/.openspec.yaml b/openspec/changes/archive/2026-09-16-reject-stay-candidates/.openspec.yaml
similarity index 100%
rename from openspec/changes/reject-stay-candidates/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/.openspec.yaml
diff --git a/openspec/changes/reject-stay-candidates/design.md b/openspec/changes/archive/2026-09-16-reject-stay-candidates/design.md
similarity index 100%
rename from openspec/changes/reject-stay-candidates/design.md
rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/design.md
diff --git a/openspec/changes/reject-stay-candidates/proposal.md b/openspec/changes/archive/2026-09-16-reject-stay-candidates/proposal.md
similarity index 100%
rename from openspec/changes/reject-stay-candidates/proposal.md
rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/proposal.md
diff --git a/openspec/changes/reject-stay-candidates/tasks.md b/openspec/changes/archive/2026-09-16-reject-stay-candidates/tasks.md
similarity index 100%
rename from openspec/changes/reject-stay-candidates/tasks.md
rename to openspec/changes/archive/2026-09-16-reject-stay-candidates/tasks.md
diff --git a/openspec/changes/rss-formatting-preserve/.openspec.yaml b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/.openspec.yaml
similarity index 100%
rename from openspec/changes/rss-formatting-preserve/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/.openspec.yaml
diff --git a/openspec/changes/rss-formatting-preserve/design.md b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/design.md
similarity index 100%
rename from openspec/changes/rss-formatting-preserve/design.md
rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/design.md
diff --git a/openspec/changes/rss-formatting-preserve/proposal.md b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/proposal.md
similarity index 100%
rename from openspec/changes/rss-formatting-preserve/proposal.md
rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/proposal.md
diff --git a/openspec/changes/rss-formatting-preserve/tasks.md b/openspec/changes/archive/2026-09-16-rss-formatting-preserve/tasks.md
similarity index 100%
rename from openspec/changes/rss-formatting-preserve/tasks.md
rename to openspec/changes/archive/2026-09-16-rss-formatting-preserve/tasks.md
diff --git a/openspec/changes/selected-list/.openspec.yaml b/openspec/changes/archive/2026-09-16-selected-list/.openspec.yaml
similarity index 100%
rename from openspec/changes/selected-list/.openspec.yaml
rename to openspec/changes/archive/2026-09-16-selected-list/.openspec.yaml
diff --git a/openspec/changes/selected-list/design.md b/openspec/changes/archive/2026-09-16-selected-list/design.md
similarity index 100%
rename from openspec/changes/selected-list/design.md
rename to openspec/changes/archive/2026-09-16-selected-list/design.md
diff --git a/openspec/changes/selected-list/proposal.md b/openspec/changes/archive/2026-09-16-selected-list/proposal.md
similarity index 100%
rename from openspec/changes/selected-list/proposal.md
rename to openspec/changes/archive/2026-09-16-selected-list/proposal.md
diff --git a/openspec/changes/selected-list/tasks.md b/openspec/changes/archive/2026-09-16-selected-list/tasks.md
similarity index 100%
rename from openspec/changes/selected-list/tasks.md
rename to openspec/changes/archive/2026-09-16-selected-list/tasks.md
diff --git a/openspec/changes/rewrite-as-author/.openspec.yaml b/openspec/changes/rewrite-as-author/.openspec.yaml
new file mode 100644
index 0000000..04dfde5
--- /dev/null
+++ b/openspec/changes/rewrite-as-author/.openspec.yaml
@@ -0,0 +1,3 @@
+schema: spec-driven
+created: 2026-09-15
+skip_specs: true
\ No newline at end of file
diff --git a/openspec/changes/rewrite-as-author/design.md b/openspec/changes/rewrite-as-author/design.md
new file mode 100644
index 0000000..3535b0d
--- /dev/null
+++ b/openspec/changes/rewrite-as-author/design.md
@@ -0,0 +1,52 @@
+# Design: rewrite-as-author
+
+## 1. candidates.html (блок пересказа)
+
+```diff
+ {% if selected.rewritten_text %}
+
+-
+-
++
++
+
+ {% endif %}
+```
+
+## 2. app.py — новый эндпоинт
+
+```python
+@app.post("/posts/{post_id}/rewrite-save")
+async def post_rewrite_save(post_id, request, group_by="", direction="", own="", q=""):
+ _require_auth(request)
+ conn = _db()
+ p = conn.execute("SELECT id FROM posts WHERE id=?", (post_id,)).fetchone()
+ if not p: return RedirectResponse(..., status_code=302)
+ form = await request.form()
+ draft = str(form.get("rewritten_text") or "").strip()
+ conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft or None, post_id))
+ conn.commit(); conn.close()
+ return RedirectResponse(..., status_code=302)
+```
+
+## 3. card.py — тело и подпись
+
+```python
+body = (post.get("rewritten_text") or post.get("text") or "").strip()
+rewritten = bool((post.get("rewritten_text") or "").strip())
+...
+if rewritten:
+ tail = "✍️ Дед в АйТи (@dedinit)" + (f" · Источник: {orig}" if orig else "")
+elif is_own: ... # как было
+elif orig: tail = f"🔗 Оригинал: {orig}"
+```
+
+## Верификация
+
+- `py_compile web/app.py publisher/card.py`
+- Реальный кандидат с rewritten_text (id=4): GET /candidates?selected=4 → textarea без readonly, есть «Сохранить пересказ».
+- `make_card` (read-only БД) с постом id=4 → текст начинается с пересказа, содержит «Дед в АйТи», не содержит «🔗 Оригинал»; media сохраняется (если есть).
\ No newline at end of file
diff --git a/openspec/changes/rewrite-as-author/proposal.md b/openspec/changes/rewrite-as-author/proposal.md
new file mode 100644
index 0000000..a6698e6
--- /dev/null
+++ b/openspec/changes/rewrite-as-author/proposal.md
@@ -0,0 +1,25 @@
+# Proposal: rewrite-as-author
+
+## Why
+
+Функция «✍️ Переписать» в карточке кандидата:
+
+1. textarea с черновиком была `readonly` — пользователь не мог отредактировать сгенерированный LLM пересказ (генерируем 2000 символов, а в канал уходил исходный текст поста как «богатый репост» с `🔗 Оригинал`).
+2. Даже после «Переписать» публикация шла с `post["text"]` (исходник), а не с `rewritten_text` — пересказ игнорировался.
+
+Ожидание пользователя: пересказ — это его авторский текст, публикуемый ОТ ИМЕНИ канала «Дед в АйТи», а не пересылка чужого сообщения.
+
+## What Changes
+
+- `web/templates/candidates.html`: textarea пересказа становится редактируемой, добавляется кнопка «💾 Сохранить пересказ» → `POST /posts/{id}/rewrite-save`.
+- `web/app.py`: новый эндпоинт `rewrite-save` (сохраняет отредактированный `rewritten_text`).
+- `publisher/card.py make_card`: при наличии непустого `rewritten_text` тело карточки = пересказ; подпись «✍️ Дед в АйТи (@dedinit) · Источник: » вместо `🔗 Оригинал: ` (публикация от имени автора).
+
+## Why Not
+
+- Не делаем отдельного поля «автор» и персистенции источника пересказа — пересказ всегда от имени владельца канала; ссылка на оригинал остаётся строкой «Источник: …» для атрибуции.
+
+## Impact
+
+- Кандидат: textarea редактируемо, кнопка save. Публикация: пересказ приоритетнее оригинала, подпись от имени канала.
+- Обратная совместимость: посты без rewritten_text публикуются как раньше (полный текст + 🔗 Оригинал).
\ No newline at end of file
diff --git a/openspec/changes/rewrite-as-author/tasks.md b/openspec/changes/rewrite-as-author/tasks.md
new file mode 100644
index 0000000..85dd09b
--- /dev/null
+++ b/openspec/changes/rewrite-as-author/tasks.md
@@ -0,0 +1,23 @@
+# Tasks: rewrite-as-author
+
+## 1. Шаблон (web/templates/candidates.html)
+
+- [x] 1.1 Убрать `readonly` у textarea пересказа, переделать блок: label «редактируйте и сохраните», форма → `/posts/{id}/rewrite-save`, кнопка «💾 Сохранить пересказ»
+- [x] 1.2 Проверка: GET /candidates?selected=4 (пост с пересказом) — textarea редактируема, кнопка на месте
+
+## 2. Эндпоинт (web/app.py)
+
+- [x] 2.1 `POST /posts/{post_id}/rewrite-save`: сохраняет отредактированный textarea в `posts.rewritten_text` (async, str-коэршн), редирект обратно с selected
+- [x] 2.2 Компиляция + рестарт vesti-web (active)
+
+## 3. Карточка (publisher/card.py)
+
+- [x] 3.1 `make_card`: при непустом rewritten_text тело = пересказ, подпись «✍️ Дед в АйТи (@dedinit) · Источник: …» вместо «🔗 Оригинал»
+- [x] 3.2 Проверка на реальном посте id=4 (read-only): текст начинается с пересказа, есть «Дед в АйТи», нет «🔗 Оригинал»
+- [x] 3.3 Посты без пересказа — регресс (поведение не изменилось): тело = text, подпись 🔗 Оригинал
+
+## 4. Документация
+
+- [ ] 4.1 STATUS.md обновлён
+- [ ] 4.2 openspec validate rewrite-as-author — чисто
+- [ ] 4.3 git commit + push (после подтверждения пользователя)
\ No newline at end of file
diff --git a/openspec/changes/rss-crawler/.openspec.yaml b/openspec/changes/rss-crawler/.openspec.yaml
new file mode 100644
index 0000000..96db9a4
--- /dev/null
+++ b/openspec/changes/rss-crawler/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-15
diff --git a/openspec/changes/rss-crawler/design.md b/openspec/changes/rss-crawler/design.md
new file mode 100644
index 0000000..8bcd206
--- /dev/null
+++ b/openspec/changes/rss-crawler/design.md
@@ -0,0 +1,170 @@
+# Design: rss-crawler
+
+## Approach
+
+Синхронный краулер RSS/Atom-лент, следующий общему паттерну telegram_crawler:
+реестр источников в `sources.yaml` → выборка включённых → запуск по каждому
+(с записью в `runs`) → нормализация → дедуп → вставка в `posts`.
+
+Отличия от telegram_crawler:
+- транспорт — HTTP (httpx), а не MTProto;
+- состояние ленты — `rss_state` (etag/modified), а не `tg_state` (last_post_id);
+- условные GET (304 = пропуск) вместо инкрементального обхода истории;
+- извлечение полного текста (trafilatura) для summary-only записей;
+- данные ленты (summary/links/author) кладутся в текст записи, sha256 берётся
+ с канонического текста — поэтому дубль статьи в двух лентах схлопнется.
+
+Схема потока:
+```
+sources.yaml ──► rss_crawler.py ──► HTTP GET (If-None-Match/If-Modified-Since)
+ │ 304 → skip (экономно)
+ ▼
+ feedparser.parse
+ │
+ ├─► нормализация URL (UTM/якоря) → canonical_url
+ ├─► трафилатура (полный текст) если summary-only
+ ▼
+ posts (sha256-дедуп) + rss_state (etag/modified/status)
+```
+
+## Files
+
+```bash
+# Новый модуль
+crawler/rss_crawler.py # краулер: CLI + run_source + fetch_feed + store
+
+# Изменения
+db/schema.sql # + CREATE TABLE rss_state; sources + feed_url
+db/db.py # + миграция: ALTER TABLE sources ADD COLUMN feed_url (если нет)
+sources/sources.py # + синк feed_url в БД
+requirements.txt # + trafilatura
+sources/sources.yaml # + lwn (enabled), пример opennet (закомментирован)
+config.py # + RSS_USER_AGENT (env, default), RSS_DEFAULT_TIMEOUT
+.env.example # + RSS_USER_AGENT
+STATUS.md / TODO.md / WALKTHROUGH.md / AGENT.MD # статус и команды
+```
+
+## Data / Config
+
+```bash
+# .env (опционально, честный User-Agent с контактом)
+RSS_USER_AGENT=vesti-rss/0.1 (+https://vesti.nixg.ru)
+```
+
+`sources.yaml` — формат записи (продолжает существующий):
+```yaml
+- slug: lwn
+ name: "LWN.net"
+ url: https://lwn.net/
+ feed_url: https://lwn.net/headlines/rss
+ channel: null # только для telegram
+ crawler: rss # rss | telegram
+ direction: linux # linux | tech | politics | games | electronics | llm
+ lang: en
+ priority: P0 # P0 (15 мин) | P1 (1 ч) | P2 (4 ч)
+ enabled: true
+```
+
+```sql
+CREATE TABLE IF NOT EXISTS sources (
+ ...
+ feed_url TEXT, -- только для crawler: rss — URL ленты
+ ...
+);
+```
+
+Миграция существующей БД (CREATE IF NOT EXISTS не меняет таблицу):
+`db/db.py` init_db — после executescript проверить `PRAGMA table_info(sources)`,
+при отсутствии `feed_url` выполнить `ALTER TABLE sources ADD COLUMN feed_url TEXT`.
+`sources.py` sync_sources_to_db — добавить `feed_url` в INSERT/ON CONFLICT UPDATE.
+
+## rss_state (новая таблица)
+
+```sql
+CREATE TABLE IF NOT EXISTS rss_state (
+ slug TEXT PRIMARY KEY, -- = sources.slug
+ etag TEXT,
+ modified TEXT,
+ last_build_date TEXT,
+ last_error TEXT,
+ error_count INTEGER DEFAULT 0,
+ status TEXT DEFAULT 'alive', -- alive | dead | new
+ updated_at TEXT DEFAULT (datetime('now'))
+);
+```
+
+## Алгоритм run_source
+
+1. `start_run(source_id, f"rss:{slug}", trigger)`.
+2. Прочитать `rss_state` по slug; собрать заголовки `If-None-Match`/`If-Modified-Since`, если есть.
+3. `httpx.get(feed_url, headers={If-None-Match, If-Modified-Since, User-Agent}, timeout=20)`.
+4. `304` → `finish_run(ok, 0, 0)` (экономия трафика); не трогаем status.
+5. `200` → `feedparser.parse(resp.content)`:
+ - если в контенте указано `etag`/`modified` — сохранить в rss_state;
+ - для каждой записи (первые MAX_ITEMS=25, см. лимит ниже):
+ - `canonical_url = normalize_url(entry.link)` (UTM/якоря),
+ - `sha = sha256(canonical text)` (текст см. ниже),
+ - `text = entry.summary или выжимка полного контента`,
+ - если запись summary-only — попробовать полный текст через trafilatura
+ (timeout, не ронять источник при сбое),
+ - `published_at` из `published_parsed`/`updated_parsed` (dateutil) или None,
+ - вставка через `store_rss_post` (дедуп: sha256 уникальна; url — доп. ключ).
+6. `finish_run(ok, fetched, new)`; `rss_state.status='alive'`, `last_error=NULL`,
+ `error_count=0`; `last_build_date` из ленты.
+7. Ошибка сети/парсинга → `finish_run(error)`, `last_error`, `error_count+1`;
+ при `error_count>=5` → `status='dead'`. `sources.status` (alive/dead) — как у TG.
+
+### Текст записи (что идёт в posts.text)
+- Если есть `content[0].value` — раскрываем HTML в plain text (selectolax), берём его.
+- Иначе `summary` — раскрываем в plain text.
+- Если записи нет полного текста, а выжимка короткая (< 220 симв.) ИЛИ в ленте нет
+ `content` вовсе — пробуем trafilatura по `canonical_url`.
+- Итог: `text` = полный текст (если дотянут) или выжимка; `content_type` = 'text'.
+- sha256 считаем от финального `text` (после нормализации пробелов) — это делает
+ дедуп устойчивым к мелким различиям лент.
+
+## Стандартизация URL (canonical_url)
+
+```python
+def normalize_url(url):
+ if not url: return url
+ u = urlparse(url)
+ q = parse_qsl(u.query, keep_blank_values=True)
+ q = [(k, v) for k, v in q if k.lower() not in UTM_PARAMS] # utm_*, fbclid, gclid
+ return urlunparse(u._replace(query=urlencode(q), fragment=""))
+```
+UTM_PARAMS = {utm_source, utm_medium, utm_campaign, utm_term, utm_content, fbclid, gclid, ref}.
+
+## CLI
+
+```bash
+.venv/bin/python -m crawler.rss_crawler --all # все включённые rss-источники
+.venv/bin/python -m crawler.rss_crawler --direction linux
+.venv/bin/python -m crawler.rss_crawler --source lwn
+.venv/bin/python -m crawler.rss_crawler --dry-run # не писать в БД, только печать
+```
+
+## Команды
+
+```bash
+cd /opt/vesti
+.venv/bin/pip install -r requirements.txt # + trafilatura
+.venv/bin/python -m crawler.rss_crawler --all --dry-run
+.venv/bin/python -m crawler.rss_crawler --source lwn
+# БД: проверить
+.venv/bin/python - <<'EOF'
+import sqlite3
+c = sqlite3.connect('db/vesti.db')
+print(c.execute("SELECT slug,status,error_count FROM rss_state").fetchall())
+print(c.execute("SELECT COUNT(*) FROM posts p JOIN sources s ON s.id=p.source_id WHERE s.crawler='rss'").fetchone())
+EOF
+```
+
+## Verification
+
+- [ ] `openspec validate rss-crawler` → 0 ошибок
+- [ ] `--dry-run` против lwn: печатает ≥5 записей, ничего не пишет в БД
+- [ ] Реальный прогон `--source lwn`: posts.rss_count>0, в rss_state появились etag/last_build_date, status=alive
+- [ ] Повторный прогон: 304 (вторичный запуск) или 0 новых (если лента без etag); runs — ok
+- [ ] Повторная вставка той же записи (та же лента дважды) → новых 0, дублей 0
+- [ ] Веб-кандидаты показывают RSS-пост (с направлением после классификации)
\ No newline at end of file
diff --git a/openspec/changes/rss-crawler/proposal.md b/openspec/changes/rss-crawler/proposal.md
new file mode 100644
index 0000000..24f571d
--- /dev/null
+++ b/openspec/changes/rss-crawler/proposal.md
@@ -0,0 +1,85 @@
+# Proposal: rss-crawler
+
+## Why
+
+В VESTI сейчас есть только telegram_crawler (Telethon, MTProto). RSS-источники —
+самый массовый и дешёвый класс источников (по оценке PRD — ~80% объёма новостей):
+веб-СМИ, блоги, IT-порталы публикуют RSS/Atom-ленты. Без RSS-краулера они недоступны.
+
+Проблемы, которые решает модуль:
+- **нет спроса на RSS вовсе**: в источники (sources.yaml) нельзя добавить ни одного
+ RSS-канала — краулер просто не умеет их обрабатывать (`get_enabled_sources(crawler="rss")` вернёт пусто);
+- **негражданный режим опроса**: без If-None-Match/If-Modified-Since каждый запуск
+ тянет полные ленты — это лишний трафик для издателей и для нас;
+- **мусорные ссылки**: URL из лент часто содержат UTM-метки, якоря, трекеры —
+ дедупликация по сырому URL даёт дубли одной статьи;
+- **обрывки вместо текста**: многие ленты отдают summary вместо полного текста —
+ без дотягивания полного текста карточки кандидатов бедные;
+- **нет состояния/здоровья**: нет etag/modified/last_error/status для RSS-источников.
+
+Для пользователя это значит: можно добавить в VESTI новостные сайты/блоги и получать
+их посты в конвейере (классификация → кандидаты → публикация) наравне с Telegram-каналами.
+
+## What Changes
+
+- Новый модуль краулера `crawler/rss_crawler.py` (feedparser + httpx):
+ - читает `sources.yaml` (crawler: rss), группирует по priority;
+ - HTTP GET c `If-None-Match`/`If-Modified-Since` из таблицы `rss_state` → 304 = пропуск;
+ - нормализует URL (UTM-параметры, якоря) → `canonical_url`, дедуп по sha256;
+ - извлекает полный текст через trafilatura, если запись — обрывок;
+ - пишет посты в `posts` через существующий `store_posts`-механизм (дедуп по sha256 текста);
+ - пишет метаданные ленты в `rss_state` (etag/modified/last_error/status, last_build_date);
+ - ведёт `runs` (source_id, task, trigger) — как telegram_crawler;
+ - CLI: `python -m crawler.rss_crawler --all | --direction | --source `.
+- Новая таблица `rss_state` (etag, modified, last_error, status, last_build_date) —
+ аналог `tg_state` для RSS-лент.
+- `requirements.txt`: + trafilatura (извлечение полного текста).
+- `sources.yaml`: первый реальный RSS-источник для проверки — LWN (lwn.net) —
+ и один закомментированный пример (opennet.ru).
+- `.env.example`: + `RSS_USER_AGENT` (информативный User-Agent).
+- Cron (Hermes): `vesti-rss-crawler` (каждые 15 мин, P0) — тихий, no_agent, как telegram.
+
+### Не меняется
+
+- Дедуп и схема `posts` — как у telegram_crawler (sha256 текста; url — дополнительный ключ).
+- Классификатор, веб, publisher — не затрагиваются (RSS-посты идут в тот же `posts`).
+- `tg_state` — остаётся для Telegram; RSS использует `rss_state`.
+- Никаких автопубликаций: посты попадают в кандидаты, подтверждение — человеком.
+
+## Capabilities
+
+### New Capabilities
+- `rss-crawler`: Синхронный краулер RSS/Atom-лент: реестр из sources.yaml,
+ условные GET (etag/modified), нормализация URL, извлечение полного текста
+ (trafilatura), состояние и здоровье ленты в БД, интеграция с runs/posts.
+
+### Modified Capabilities
+- `news-store` (таблица `posts`): без изменений схемы; RSS-посты используют её как есть.
+- `sources.yaml`: новые источники с `crawler: rss`.
+- `vesti-web`: без изменений (RSS-посты автоматически видны как кандидаты).
+
+## Impact
+
+- Файлы:
+ - новый: `crawler/rss_crawler.py` (модуль-краулер);
+ - изменён: `db/schema.sql` (+CREATE TABLE rss_state), `requirements.txt`
+ (+ trafilatura), `sources/sources.yaml` (+lwn, пример opennet), `.env.example`
+ (+RSS_USER_AGENT), `config.py` (+RSS_USER_AGENT, +RSS_*);
+ - доки: STATUS.md, TODO.md, WALKTHROUGH.md, AGENT.MD (команда запуска).
+- Данные: миграция — `CREATE TABLE IF NOT EXISTS`; существующие данные не трогаются.
+- Секреты: не требует (RSS публичные). User-Agent — из .env.
+- Сеть: исходящие HTTP-запросы к сайтам издателей (только к лентам, учтиво:
+ условные GET, рейт-лимит 1–2 с между источниками).
+- Rollback: модуль не вызывается cron — просто не добавлять источники/не запускать.
+
+## Risks
+
+- Издатели режут по User-Agent / отдают капчу — лечится `RSS_USER_AGENT`
+ (честный, с контактом) и `enabled: false` для проблемных.
+- Ленты с кривой датой (`published_parsed` None) — ставим `fetched_at`, не падаем.
+- summary-only ленты — trafilatura может не найти полный текст; тогда сохраняем summary,
+ пост всё равно попадает в конвейер (пометка в content_type/text).
+- Огромные ленты (мега-фиды): ограничение — первые N записей, остальные догонятся
+ следующими запусками (инкрементально), как в telegram_crawler.
+- Троттлинг издателя: проставляем last_error и status='dead' после N=5 ошибок подряд
+ (правило фид-здоровья), watchdog молчит до перехода alive↔dead.
\ No newline at end of file
diff --git a/openspec/changes/rss-crawler/specs/rss-crawler/spec.md b/openspec/changes/rss-crawler/specs/rss-crawler/spec.md
new file mode 100644
index 0000000..b265dab
--- /dev/null
+++ b/openspec/changes/rss-crawler/specs/rss-crawler/spec.md
@@ -0,0 +1,90 @@
+# Spec: rss-crawler
+
+## Purpose
+
+Краулер RSS/Atom-лент с реестра источников (sources.yaml, crawler: rss): условные
+GET (etag/modified), нормализация URL, извлечение полного текста (trafilatura),
+состояние и здоровье ленты в БД (rss_state), интеграция с posts/runs. RSS-посты
+попадают в общий конвейер (классификация → кандидаты → публикация) наравне с
+Telegram-постами.
+
+## ADDED Requirements
+
+### Requirement: Чтение RSS-источников из реестра
+Система MUST поддерживать источники с `crawler: rss` в sources.yaml и запускать
+их краулинг через `python -m crawler.rss_crawler` (фильтры: --all / --direction /
+--source ). Каждый запуск источника MUST записываться в таблицу `runs`
+(start_run/finish_run) как задача `rss:`.
+
+#### Scenario: Список RSS-источников
+- **GIVEN** sources.yaml содержит источник `lwn` с `crawler: rss`, `enabled: true`
+- **WHEN** выполняется `python -m crawler.rss_crawler --all`
+- **THEN** краулер обрабатывает источник lwn, в `runs` появляется запись задачи `rss:lwn`
+
+### Requirement: Условные GET (etag/modified)
+Краулер MUST отправлять `If-None-Match` (etag) и `If-Modified-Since` (modified) из
+`rss_state` при запросе ленты. При ответе 304 краулер MUST НЕ парсить ленту и НЕ
+добавлять посты (запуск завершается ok с 0 fetched/0 new). Полученные etag/modified
+из ответа MUST сохраняться в `rss_state`.
+
+#### Scenario: Неизменённая лента
+- **GIVEN** rss_state для lwn содержит etag "xyz" и лента не менялась
+- **WHEN** запускается краулер
+- **THEN** сервер отвечает 304, новых постов нет, запуск в runs имеет status ok
+
+### Requirement: Нормализация URL и дедупликация
+Краулер MUST нормализовать URL записи перед использованием в качестве ключа:
+удалять UTM-параметры (utm_*), fbclid/gclid/ref и якоря (#). Дедупликация MUST
+использовать sha256 от текста записи (как в telegram_crawler); повторная вставка
+той же статьи MUST не создавать второй строки в `posts`.
+
+#### Scenario: Дубликат: та же статья, разные URL
+- **GIVEN** статья с URL вида `https://site/a?utm_source=rss&utm_medium=feed#top`
+- **WHEN** краулер обрабатывает запись
+- **THEN** canonical_url = `https://site/a` (без утм и якоря), sha256 уникален,
+ вторая идентичная запись из другой ленты не создаёт дубль
+
+### Requirement: Полный текст (trafilatura)
+Краулер MUST извлекать полный текст страницы через trafilatura для записей,
+у которых нет полного текста в ленте (только summary). Сбой извлечения MUST НЕ
+ронять источник: в `posts.text` сохраняется доступный текст (summary), запуск
+продолжается.
+
+#### Scenario: Summary-only лента
+- **GIVEN** лента отдаёт краткое описание (summary) без полного контента
+- **WHEN** краулер обрабатывает запись
+- **THEN** текст записи дотягивается trafilatura; при неудаче сохраняется summary,
+ запуск завершается ok, ошибка логгируется
+
+### Requirement: Здоровье и состояние ленты (rss_state)
+Система MUST хранить состояние ленты в таблице `rss_state` (slug PK, etag, modified,
+last_build_date, last_error, error_count, status). После успешного прогона
+`status='alive'`, error_count=0, last_error=NULL. При ошибке error_count
+инкрементируется; при error_count>=5 подряд `status='dead'` (фид-здоровье),
+sources.status синхронизируется.
+
+#### Scenario: Лента постоянно падает
+- **GIVEN** лента возвращает 500/таймаут 5 раз подряд
+- **WHEN** краулер завершает 5-й неудачный запуск
+- **THEN** rss_state.status='dead', sources.status='dead', last_error непуст;
+ watchdog (cron) уведомит при переходе alive→dead
+
+### Requirement: Запуск по приоритетам и CLI
+Краулер MUST запускаться из cron с каденцией по приоритету источника (P0 раз в
+15 мин, P1 раз в час, P2 раз в 4 часа). Ручной запуск MUST поддерживать
+`--dry-run` (печать записей без записи в БД).
+
+#### Scenario: Dry-run
+- **GIVEN** выполняется `python -m crawler.rss_crawler --source lwn --dry-run`
+- **THEN** краулер печатает записи ленты, но НЕ пишет в posts/rss_state/runs;
+ БД остаётся неизменной
+
+## NOT Requirements
+
+- Не реализуем инкрементальный обход истории (как telegram_crawler по last_post_id):
+ RSS-ленты — это «последние N записей», состояние — через etag/modified.
+- Не пишем полный контент в отдельную таблицу/колонку (text в posts уже достаточен;
+ bundles создаёт веб при approve).
+- Не скачиваем медиа из RSS (картинки-иконки ленты) — контент текстовый.
+- Не делаем автопостинг: RSS-посты проходят тот же путь подтверждения человеком.
+- Не затрагиваем tg_state/telegram_crawler (отдельный механизм для Telegram).
\ No newline at end of file
diff --git a/openspec/changes/rss-crawler/tasks.md b/openspec/changes/rss-crawler/tasks.md
new file mode 100644
index 0000000..e28b7e5
--- /dev/null
+++ b/openspec/changes/rss-crawler/tasks.md
@@ -0,0 +1,48 @@
+# Tasks: rss-crawler
+
+## 1. Схема и конфиг
+
+- [x] 1.1 db/schema.sql: + CREATE TABLE rss_state; sources + feed_url; db/db.py — миграция ALTER TABLE (feed_url); sources.py — синк feed_url
+ Проверка: `python -m db.db` (init_db) выполняется без ошибок; таблица rss_state и колонка sources.feed_url появляются
+- [x] 1.2 config.py: + RSS_USER_AGENT (env, default "vesti-rss/0.1"), RSS_DEFAULT_TIMEOUT=20
+ Проверка: `python -c "from config import RSS_USER_AGENT; print(RSS_USER_AGENT)"` → значение из .env
+- [x] 1.3 requirements.txt: + trafilatura, selectolax
+ Проверка: `.venv/bin/pip install -r requirements.txt` без ошибок
+
+## 2. Модуль краулера
+
+- [x] 2.1 crawler/rss_crawler.py: normalize_url (UTM/якоря), sha256_text
+ Проверка: изолированный тест normalize_url: utm-параметры удаляются, якорь удаляется, остальное сохраняется
+- [x] 2.2 fetch_feed: httpx.GET с If-None-Match/If-Modified-Since/User-Agent, таймаут, ретрай 429/503; 304 → пусто
+ Проверка: повторный запрос к ленте с etag → 304 (сервер LWN поддерживает)
+- [x] 2.3 parse_entries: feedparser, извлечение etag/modified из ответа, лимит записей (MAX_ITEMS=25)
+ Проверка: запись → dict {url, title, text, published_at, author}
+- [x] 2.4 full_text: trafilatura для summary-only (selectolax для HTML→text)
+ Проверка: запись с summary → в text полный текст или summary (не падает)
+- [x] 2.5 store_rss_post: дедуп по sha256; вставка в posts (source_id, url, canonical_url, content_type='text', published_at)
+ Проверка: две идентичные записи → одна строка в posts
+- [x] 2.6 run_source: start_run/finish_run, обновление rss_state (etag/modified/status/error_count), status alive/dead при error_count>=5
+ Проверка: успешный прогон → rss_state.status='alive', runs=ok; имитация ошибки → error_count растёт
+- [x] 2.7 CLI: --all / --direction / --source / --dry-run
+ Проверка: `--source lwn --dry-run` печатает записи, БД не меняется
+
+## 3. Источники и интеграция
+
+- [x] 3.1 sources.yaml: + lwn (crawler: rss, direction: linux, priority: P0, enabled: true); пример opennet закомментирован
+ Проверка: `sync_sources_to_db()` — в БД появляется источник lwn c crawler='rss'
+- [x] 3.2 .env.example: + RSS_USER_AGENT, RSS_DEFAULT_TIMEOUT
+- [x] 3.3 Реальный прогон `--source lwn` → посты в posts, rss_state настроена
+ Проверка: SQL-запросы из design.md (Verification)
+
+## 4. Cron и доки
+
+- [ ] 4.1 Hermes cron: vesti-rss-crawler (`*/15`, P0; no_agent, тихий, deliver=local)
+ Проверка: cron list показывает job; ручной запуск проходит
+- [x] 4.2 STATUS.md / AGENT.MD обновлены (команды запуска, порты/доступы)
+ Проверка: grep rss в доках
+- [x] 4.3 `openspec validate rss-crawler` → 0 ошибок
+ Проверка: команда выше → valid
+- [ ] 4.4 TODO.md: + задача веб-скедулинга RSS (веб-админ: кнопка запуска краулера)
+ Проверка: задача видна в планах
+
+Открытый вопрос: состояние таблицы rss_state ERROR (после 429 и ретрая, error_count=1) обновится при следующем удачном 200; в отсутствие ошибок не трогаем.
\ No newline at end of file
diff --git a/openspec/specs/classifier/spec.md b/openspec/specs/classifier/spec.md
new file mode 100644
index 0000000..e542635
--- /dev/null
+++ b/openspec/specs/classifier/spec.md
@@ -0,0 +1,31 @@
+# classifier Specification
+
+## Purpose
+Классификация постов по направлениям локальным LLM + словарный фильтр. Дополняется
+приоритетом «своего контента» и поддержкой мультинаправлений для fan-out.
+
+## Requirements
+
+### Requirement: Приоритет своего контента
+Классификатор MUST обрабатывать посты с `is_own=1` как «сильные кандидаты»: при наличии
+направления по словарю — relevance=critical, classified=True — даже если LLM недоступна
+(фолбэк без LLM, пост не теряется). Для внешних постов поведение без изменений.
+
+#### Scenario: Свой пост, LLM недоступна
+- **WHEN** пост is_own=1, словарь дал направление, но Ollama недоступна
+- **THEN** пост получает direction (словарь), relevance=critical, classified=True,
+ method='dict-own' (не требует LLM)
+
+#### Scenario: Свой пост без направления по словарю
+- **WHEN** пост is_own=1, словарь не дал направление
+- **THEN** классификации нет (classified=False) до LLM; пост не теряется (остаётся в очереди)
+
+### Requirement: Мультинаправления
+Классификатор MUST уметь возвращать несколько направлений для поста (для маппинга fan-out);
+основное направление хранится в posts.direction, дополнительные — в classifications
+(таблица уже позволяет несколько классификаций на пост).
+
+#### Scenario: Пост про Linux + AI
+- **WHEN** пост упоминает и линукс, и нейросети
+- **THEN** в classifications может быть несколько записей (linux, ai); fan-out использует
+ оба при подтверждении
diff --git a/openspec/specs/crawler-queue/spec.md b/openspec/specs/crawler-queue/spec.md
new file mode 100644
index 0000000..2806f6e
--- /dev/null
+++ b/openspec/specs/crawler-queue/spec.md
@@ -0,0 +1,66 @@
+# crawler-queue Specification
+
+## Purpose
+Двухфазный пайплайн сбора и обработки новостей: краулеры (фаза 1) собирают
+кандидатов в очередь (`posts` со `status='new'` и неклассифицированные), воркеры
+(фаза 3) разбирают её параллельно (ThreadPoolExecutor). Отдельная страница
+`/crawlers` показывает статус источников и размер очереди.
+
+## Requirements
+
+### Requirement: Параллельная фаза 3 (воркеры)
+Система MUST предоставлять `crawler/worker.py` с пулом `ThreadPoolExecutor(max_workers=N)`,
+где каждый воркер атомарно забирает пост из очереди (`UPDATE posts SET classified=-1
+WHERE id=? AND classified=0`), классифицирует его (`classify_text`: keywords → Ollama,
+trafilatura для summary-only) и пишет результат (direction/relevance/interest/summary,
+classified=1). При исключении посте MUST возвращаться в очередь (classified=0).
+
+#### Scenario: Параллельная обработка очереди
+- **GIVEN** 100 постов со `classified=0`
+- **WHEN** `python -m crawler.worker --workers 8 --limit 200`
+- **THEN** все 100 постов обработаны (classified=1), дублей нет (каждый обработан ровно 1 раз)
+
+#### Scenario: Сбой воркера
+- **GIVEN** пост, у которого `classify_text` бросает исключение (Ollama недоступна)
+- **WHEN** воркер обрабатывает пост
+- **THEN** пост возвращается в очередь (classified=0), воркер продолжает работу, запуск не падает
+
+### Requirement: Очередь на основе posts (без новой таблицы)
+«Размер очереди» MUST вычисляться из существующей таблицы `posts`:
+`SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0` (pending),
+`classified=-1` (processing), `classified=1 AND fetched_at >= date('now')` (done today).
+Новая таблица для очереди НЕ создаётся — она дублировала бы `posts`.
+
+#### Scenario: Размер очереди
+- **GIVEN** в posts 10 новых (classified=0) и 2 в обработке (classified=-1)
+- **WHEN** страница /crawlers запрашивает размер очереди
+- **THEN** pending=10, processing=2, done today=0
+
+### Requirement: Страница /crawlers
+Веб MUST предоставлять `GET /crawlers` (за аутентификацией): таблица источников
+(slug, name, crawler, status, last_fetch, last_error за 200 симв., error_count,
+priority; для rss — etag/modified/last_build_date из rss_state) и карточки очереди
+(pending/processing/done). Кнопка «Сбросить dead» (POST /crawlers/reset) MUST
+устанавливать sources.status='alive', error_count=0, last_error=NULL для всех
+источников со status='dead'.
+
+#### Scenario: Просмотр статуса
+- **GIVEN** источник lwn (rss, alive) и 15 новых постов в очереди
+- **WHEN** GET /crawlers
+- **THEN** страница показывает lwn с статусом alive, размер очереди 15
+
+#### Scenario: Сброс dead-источников
+- **GIVEN** источник со status='dead', error_count=7
+- **WHEN** POST /crawlers/reset
+- **THEN** источник становится alive, error_count=0, last_error=NULL
+
+### Requirement: Фаза 1 (сбор) отдельно от фазы 3 (обработка)
+Система MUST предоставлять `crawler/crawl_sources.py` — запуск сбора всех
+включённых источников (telegram_crawler + rss_crawler) без классификации;
+новые посты попадают в очередь (posts, classified=0). Обработка (фаза 3)
+запускается отдельно (`crawler/worker.py`).
+
+#### Scenario: Сбор без классификации
+- **GIVEN** включённые источники telegram и rss
+- **WHEN** `python -m crawler.crawl_sources --all`
+- **THEN** новые посты добавлены в posts с classified=0, классификация НЕ запущена
diff --git a/openspec/specs/news-store/spec.md b/openspec/specs/news-store/spec.md
new file mode 100644
index 0000000..070ba8e
--- /dev/null
+++ b/openspec/specs/news-store/spec.md
@@ -0,0 +1,22 @@
+# news-store Specification
+
+## Purpose
+Банк статей: markdown-бандлы с frontmatter + медиа. Дополняется пометкой происхождения
+своего контента и ссылкой на оригинал.
+
+## Requirements
+
+### Requirement: Атрибуция в бандле
+Бандл поста с is_own=1 MUST содержать в frontmatter `origin: own`, ссылку на оригинал
+(`source_url` = t.me/dedinit/) и имя автора («Дед в АйТи»). Бандл внешнего поста —
+как раньше (origin: external, source_url=url источника).
+
+#### Scenario: Бандл своего поста
+- **WHEN** create_bundle вызывается для поста is_own=1
+- **THEN** frontmatter содержит origin: own, source: dedinit, source_url:
+ https://t.me/dedinit/, author: Дед в АйТи
+
+#### Scenario: Бандл внешнего поста в нескольких направлениях
+- **WHEN** пост (свой или внешний) опубликован в несколько направлений
+- **THEN** бандл создаётся по каждому направлению (bundles///.md),
+ обе записи ссылаются на один и тот же original post_id
diff --git a/openspec/specs/sources-admin/spec.md b/openspec/specs/sources-admin/spec.md
new file mode 100644
index 0000000..b446678
--- /dev/null
+++ b/openspec/specs/sources-admin/spec.md
@@ -0,0 +1,92 @@
+# sources-admin Specification
+
+## Purpose
+Страница управления источниками в веб-интерфейсе VESTI: добавление, изменение,
+удаление, пауза и смена приоритета источников без ручной правки YAML.
+`sources.yaml` остаётся источником истины, веб пишет в него и синкает БД.
+
+## Requirements
+
+### Requirement: Просмотр списка источников
+Страница /sources показывает таблицу всех источников с их полями и статусом.
+
+#### Scenario: просмотр списка источников
+- **Given** веб запущен, пользователь авторизован
+- **When** он открывает `/sources`
+- **Then** страница показывает таблицу всех источников: slug, name, crawler,
+ direction, lang, priority, enabled (пауза), url/feed_url, статус (last_fetch/error)
+
+### Requirement: Добавление нового источника
+Форма «Добавить» создаёт запись в sources.yaml и БД.
+
+#### Scenario: добавление нового источника
+- **Given** страница /sources
+- **When** форма «Добавить» заполнена (slug, name, crawler=rss, feed_url, direction=tech, priority=P1)
+- **And** отправлена
+- **Then** запись появляется в `sources/sources.yaml` и таблице `sources` БД
+- **And** страница показывает новый источник в таблице
+
+#### Scenario: валидация добавления
+- **Given** форма добавления с невалидным slug (`my source!`)
+- **When** отправлена
+- **Then** добавление отклонено, показана ошибка (flash), ничего не записано
+- **And** slug принимается только `[a-z0-9_-]+`
+
+### Requirement: Изменение полей источника
+Форма «Изменить» обновляет name/priority/направление и пр. в yaml и БД.
+
+#### Scenario: переименование / изменение полей
+- **Given** существующий источник `lwn`
+- **When** форма «Изменить» меняет `name` на «LWN Tech» и `priority` на P2
+- **Then** изменения применяются в yaml и БД, таблица обновляется
+
+### Requirement: Пауза и снятие с паузы
+Переключатель enabled=false останавливает сбор источника, enabled=true возобновляет.
+
+#### Scenario: пауза и снятие с паузы
+- **Given** источник `lwn` (enabled=true)
+- **When** пользователь нажимает «Пауза»
+- **Then** `enabled=false` в yaml и БД
+- **And** краулеры больше не собирают этот источник (`get_enabled_sources` исключает)
+- **When** пользователь нажимает «Снять с паузы»
+- **Then** `enabled=true`, сбор возобновляется
+
+### Requirement: Удаление источника
+Удаление (с подтверждением) убирает источник из yaml и БД, сохраняя его посты.
+
+#### Scenario: удаление источника
+- **Given** источник `opennet` с постами в БД
+- **When** пользователь подтверждает удаление (confirm)
+- **Then** запись удалена из yaml и sources БД
+- **And** его посты НЕ удалены: `source_id=NULL` (история сохраняется)
+- **And** rss_state для него удалён
+
+#### Scenario: удаление без подтверждения
+- **Given** форма удаления
+- **When** confirm отклонён (Cancel)
+- **Then** ничего не удалено, данные не меняются
+
+### Requirement: Доступ без авторизации
+Страница /sources защищена паролем, как остальные админ-страницы.
+
+#### Scenario: доступ без авторизации
+- **Given** пользователь не авторизован
+- **When** он открывает `/sources`
+- **Then** происходит редирект на /login (303)
+
+### Requirement: Приоритет источника
+Приоритет P0–P3 влияет на порядок обработки источников.
+
+#### Scenario: приоритет
+- **Given** источники с priority P0–P3
+- **When** страница /sources загружена
+- **Then** приоритеты отображаются и доступны для изменения (P0–P3)
+- **And** /crawlers сортирует по (enabled, priority, slug) с учётом нового приоритета
+
+### Requirement: Навигация
+Ссылка «Источники» присутствует в навбаре.
+
+#### Scenario: навигация
+- **Given** любая страница веб (base.html)
+- **When** открыт навбар
+- **Then** есть ссылка «Источники» на `/sources`
diff --git a/openspec/specs/tg-crawler/spec.md b/openspec/specs/tg-crawler/spec.md
new file mode 100644
index 0000000..92b17c0
--- /dev/null
+++ b/openspec/specs/tg-crawler/spec.md
@@ -0,0 +1,33 @@
+# tg-crawler Specification
+
+## Purpose
+Чтение публичных Telegram-каналов через Telethon (MTProto) для сбора новостей с метриками
+популярности. Дополняется поддержкой «своих» источников (own: true) — контент пользователя.
+
+## Requirements
+
+### Requirement: Источники своего контента
+Краулер MUST распознавать источники с `own: true` в sources.yaml и для их постов
+проставлять `is_own=1`, `is_own_canonical=1`. Для таких источников медиа MUST
+скачиваться (контент принадлежит пользователю).
+
+#### Scenario: Краулинг своего канала
+- **WHEN** источник slug=dedinit имеет own: true
+- **THEN** посты сохраняются с is_own=1 и is_own_canonical=1; медиа скачивается;
+ инкрементальный обход и дедуп работают как обычно
+
+#### Scenario: Чужой форвард в своём канале
+- **WHEN** пост в своём канале — форвард из чужого канала
+- **THEN** пост сохраняется с is_own=1 (это пост пользователя, он его переслал) и с
+ fwd_from_channel_id/fwd_from_post_id; медиа не скачивается (содержимое чужое),
+ атрибуция оригинала сохраняется в fwd-полях
+
+### Requirement: Канонический экземпляр своего контента
+Если пост пользователя (is_own=1) позже встречается во внешнем канале (тот же sha256/url),
+внешний экземпляр MUST сохраняться как «упоминание» с is_own=0; каноническим остаётся
+первичный (is_own_canonical=1).
+
+#### Scenario: Свой пост запостили в чужой канал
+- **WHEN** краулер находит в чужом канале пост с текстом, совпадающим с is_own-постом
+- **THEN** создаётся запись is_own=0 (упоминание) без дублирования контента; веб видит
+ оба экземпляра, но кандидатом на публикацию считается канонический (is_own_canonical)
diff --git a/openspec/specs/tg-publisher-service/spec.md b/openspec/specs/tg-publisher-service/spec.md
new file mode 100644
index 0000000..006b6ba
--- /dev/null
+++ b/openspec/specs/tg-publisher-service/spec.md
@@ -0,0 +1,65 @@
+# tg-publisher-service Specification
+
+## Purpose
+Изолированный FastAPI-сервис публикации карточек в Telegram-каналы через Bot API.
+Единая точка вызова для всех компонентов (веб, cron, будущие боты). Один бот-контроллер
+публикует во все каналы, в которые добавлен администратором. Telegram доступен только
+через SOCKS5-прокси (127.0.0.1:1080).
+
+## Requirements
+
+### Requirement: Изолированный сервис публикации
+Сервис MUST быть отдельным FastAPI-приложением (контейнер), НЕ импортируемым модулем веба.
+Он MUST предоставлять `GET /healthz` (статус, бот, прокси, каналы) и
+`POST /api/v1/publish` (публикация карточки).
+
+#### Scenario: Healthcheck
+- **GIVEN** сервис запущен
+- **THEN** `GET /healthz` возвращает 200 с `{"status":"ok","bot":...,"channels":[...]}`
+ и healthcheck в docker-compose (`curl -f`) проходит
+
+#### Scenario: Публикация карточки
+- **GIVEN** POST /api/v1/publish c `card: {text, direction, lang}` и `channels: ["@dedinit_vesti"]`
+- **THEN** сервис отправляет сообщение через Bot API в каждый канал и возвращает
+ `{"ok":true,"results":{"@dedinit_vesti":{"message_id":,"views":0}}}`
+
+### Requirement: Один бот, несколько каналов (конфиг)
+Сервис MUST поддерживать список каналов из конфигурации (`VESTI_BOT_CHANNELS`, через запятую).
+`channels` в запросе MAY переопределять список. Если бот добавлен в канал администратором —
+публикация MUST работать; иначе сервис MUST вернуть понятную ошибку (403).
+
+#### Scenario: Несколько каналов
+- **GIVEN** `VESTI_BOT_CHANNELS=@dedinit_vesti,@other_channel` (оба добавлены боту)
+- **WHEN** POST /api/v1/publish без поля channels
+- **THEN** карточка публикуется в оба канала; results содержит оба message_id
+
+#### Scenario: Бот не админ канала
+- **GIVEN** канал, в котором бот не администратор
+- **WHEN** публикация в него
+- **THEN** сервис возвращает 403 с текстом ошибки Bot API (sendMessage → Forbidden)
+
+### Requirement: Прокси SOCKS5 для Bot API
+Все запросы к api.telegram.org MUST идти через прокси из `TG_PROXY=socks5://127.0.0.1:1080`.
+Если прокси недоступен — сервис MUST вернуть 502 (не падать).
+
+#### Scenario: Прокси недоступен
+- **GIVEN** прокси 127.0.0.1:1080 выключен
+- **WHEN** POST /api/v1/publish
+- **THEN** сервис возвращает 502 с ошибкой подключения (не 500, не краш)
+
+### Requirement: Сбор views
+Сервис MUST предоставлять способ получения просмотров для опубликованных сообщений
+(через Bot API getMessage), чтобы веб показывал метрики.
+
+#### Scenario: Views после публикации
+- **GIVEN** сообщение опубликовано (message_id получен)
+- **WHEN** запрос views для этого message_id
+- **THEN** возвращается число просмотров (0 если ещё нет)
+
+### Requirement: Режим подтверждения
+Сервис MUST НЕ публиковать автоматически: вызывается только по HTTP-запросу (из веба
+после клика «Опубликовать»). Автопостинга по таймеру внутри сервиса НЕТ.
+
+#### Scenario: Нет автопостинга
+- **GIVEN** сервис запущен без входящих запросов
+- **THEN** ничего не публикуется (процесс только слушает HTTP)
diff --git a/openspec/specs/tg-publisher/spec.md b/openspec/specs/tg-publisher/spec.md
new file mode 100644
index 0000000..f748b69
--- /dev/null
+++ b/openspec/specs/tg-publisher/spec.md
@@ -0,0 +1,44 @@
+# tg-publisher Specification
+
+## Purpose
+Публикация отобранных новостей через Bot API в тематические Telegram-каналы. Дополняется
+автораспространением своего контента (fan-out) по нескольким направлениям.
+
+## Requirements
+
+### Requirement: Автораспространение (fan-out)
+Подтверждение СВОЕГО поста (is_own=1) MUST публиковать карточку во ВСЕ тематические
+каналы @dedinit_vesti___bot, соответствующие выбранным направлениям
+(по умолчанию — все направления классификации поста). Каждая карточка MUST содержать
+атрибуцию «Дед в АйТи» (@dedinit) и ссылку на оригинал t.me/dedinit/.
+
+#### Scenario: Мульти-публикация
+- **WHEN** подтверждается свой пост с направлениями [linux, ai]
+- **THEN** карточка отправляется в @dedinit_vesti_linux_ru_bot и @dedinit_vesti_ai_ru_bot;
+ обе содержат ссылку на оригинал
+
+#### Scenario: Чужой пост — без fan-out
+- **WHEN** подтверждается внешний пост (is_own=0)
+- **THEN** публикуется только в канал своего направления, без атрибуции автора
+
+### Requirement: Публикация по списку направлений
+Публикатор MUST поддерживать `publish_multi(directions)` — публикацию карточки по списку
+направлений, возвращающую map {direction: message_id}. При пустом списке направлений
+MUST публиковать в направление по умолчанию (direction поста).
+
+#### Scenario: Список направлений рассылки
+- **WHEN** publish_multi вызывается с directions=[linux, ai]
+- **THEN** возвращается {linux: message_id1, ai: message_id2}; каждая публикация
+ записывается в published с distributed_dirs
+
+#### Scenario: Пустой список направлений
+- **WHEN** publish_multi вызывается без directions
+- **THEN** публикация идёт в канал направления поста (direction по умолчанию), без fan-out
+
+### Requirement: Метрики по каждому направлению
+Для опубликованных карточек MUST собираться views через Bot API по каждому направлению
+(каждому message_id), чтобы веб показывал эффективность рассылки по лентам.
+
+#### Scenario: Метрики fan-out
+- **WHEN** пост разослан в [linux, ai] и каналы набирают просмотры
+- **THEN** get_views вызывается для каждого message_id; views хранятся по направлению
diff --git a/openspec/specs/vesti-web/spec.md b/openspec/specs/vesti-web/spec.md
new file mode 100644
index 0000000..03d6ba3
--- /dev/null
+++ b/openspec/specs/vesti-web/spec.md
@@ -0,0 +1,29 @@
+# vesti-web Specification
+
+## Purpose
+Веб-интерфейс управления VESTI. Дополняется фильтром «Свои», бейджем и выбором
+направлений рассылки для своего контента.
+
+## Requirements
+
+### Requirement: Фильтр «Свои»
+Веб MUST давать фильтр постов по `is_own` (все/только свои/только внешние) и показывать
+бейдж «СВОЙ» у постов is_own=1. Для своего поста при подтверждении MUST отображаться
+выбор направлений рассылки (по умолчанию — все направления классификации поста).
+
+#### Scenario: Фильтр своих постов
+- **WHEN** админ выбирает фильтр «Свои»
+- **THEN** показываются только посты is_own=1 с бейджем «СВОЙ» и чекбоксами направлений
+
+#### Scenario: Подтверждение своего поста
+- **WHEN** админ подтверждает свой пост с выбранными направлениями [linux, ai]
+- **THEN** публикация идёт в оба канала (fan-out), результат виден в опубликованных с
+ distributed_dirs
+
+### Requirement: Список распространения
+Веб MUST показывать для опубликованного поста, в какие направления/каналы он был
+разослан (distributed_dirs) и метрики (views) по каждому каналу.
+
+#### Scenario: Просмотр распространения
+- **WHEN** админ открывает опубликованный пост (свой)
+- **THEN** видит список @dedinit_vesti___bot с views по каждому