# CLAUDE.md

Памятка для работы над jellybit. Перед задачей прочитай также
[docs/passport.md](docs/passport.md), [docs/architecture.md](docs/architecture.md)
и [docs/conventions/](docs/conventions/README.md). Разработка идёт по **Spec
Driven Development** через OpenSpec — см. раздел ниже.

## Что это

Связующий сервис qBittorrent ↔ Jellyfin: принимает торрент с текстовым
контекстом, качает через qBittorrent, распознаёт фильм или сериал (LLM +
контекст + опц. метабазы) и раскладывает файлы для Jellyfin хардлинками, не
трогая исходную раздачу. Деплоится на домашний медиа-сервер umbar
(`/home/av/projects/private/umbar`).

**Чего не делает:** не ищет раздачи в трекерах, не ведёт профили качества, не
подписывается на выходящие серии, не хранит медиа и не заменяет Jellyfin.
Полная граница домена — [docs/passport.md](docs/passport.md).

## Стек

Go 1.26, один статический бинарь (`CGO_ENABLED=0`). Module path —
`git.vakhrushev.me/av/jellybit`. SQLite через `modernc.org/sqlite` + `sqlx`,
миграции `goose`, HTTP — `chi` + `html/template` + htmx, конфиг —
`pelletier/go-toml/v2`, разбор `.torrent` и инфохэшей — `anacrolix/torrent`,
пред-парс имени раздачи — `middelink/go-parse-torrent-name`, логи — `log/slog`
(структурированный JSON).

## Инварианты

Нарушать нельзя. Severity стоит здесь, а не выводится каждым проходом ревью
заново.

- **Источник неприкосновенен** — под `paths.downloads` допустимы только чтение
  и `link(2)`; никаких `unlink`, `rename`, записи. Нарушение уничтожает
  невосстановимые данные пользователя. **Необратимо. `critical`.**
  Исключения два, и оба — не наши операции с файловой системой, а вызов
  `torrents/delete` qBittorrent с `deleteFiles=true`: (1) `Delete` из
  `done`/`orphaned`/`target_missing` по явному подтверждению человека — гард
  последней копии там выключен сознательно; подтверждение допустимо **одно на
  пачку**, если называет каждую загрузку поимённо, и условия допуска групповой
  путь не смягчает
  ([state-reconciliation](openspec/specs/state-reconciliation/spec.md),
  [web-ui](openspec/specs/web-ui/spec.md));
  (2) уборка воркером **собственного** торрента, добавленного этим же `add`
  секундами ранее, когда закрытие **любым** путём (`Cancel` или `Dismiss`)
  увело задачу из `catched` в окне после `add` — уборка привязана к состоянию,
  а не к команде; признак «своё» даёт подтверждённое отсутствие инфохэша
  непосредственно перед `add`
  ([download-tracking](openspec/specs/download-tracking/spec.md),
  [state-reconciliation](openspec/specs/state-reconciliation/spec.md)). Всё
  остальное под `paths.downloads` — по-прежнему `critical`.
- **Последняя копия не снимается** — `Undo` отклоняется целиком, если у цели не
  осталось других жёстких ссылок (`nlink <= 1`) или исходного файла уже нет.
  Частичный откат тоже стёр бы часть данных. **Необратимо. `critical`.**
- **Целевой путь строго под библиотекой** — после санитизации и
  `filepath.Clean` путь обязан лежать под `paths.movies`/`paths.series`, иначе
  операция отклоняется. Выход за песочницу означает запись в чужие каталоги.
  **Необратимо. `critical`.** Выход LLM недоверенный: безопасность держится на
  этой проверке, а не на промпте.
- **Существующее не перезаписываем** — цель занята другим файлом → коллизия →
  review. Обратимо (задача уходит в ревью), но потеря чужого файла — нет.
  **`critical`.**
- **Секреты не попадают в логи, диагностику и ответы API** — пароль
  qBittorrent, ключи LLM и метабаз, токен Telegram, API-ключ Jellyfin.
  Утёкший в лог секрет отзывается вручную. **`major`.**
- **Авто-раскладка только при подтверждённом матче в метабазе** — самооценка
  LLM **единственным** гейтом не является и матч не заменяет: порог
  `[recognition].auto_confidence_threshold` стоит поверх матча дополнительным
  условием
  ([ADR](docs/adr/ADR-2026-06-13-auto-link-requires-db-match.md),
  [recognition](openspec/specs/recognition/spec.md)). Обратимо
  через `Undo`. **`major`.**
- **Не более одной активной загрузки на infohash** — проверка отсутствия другой
  активной загрузки и вставка идут одной write-транзакцией (`_txlock=immediate`,
  guarded-методы `store`); обход даёт две задачи, претендующие на одну раздачу и
  один целевой путь. Обратимо (лишняя закрывается), но состояние расходится.
  **`major`.** Поведение — [ingest](openspec/specs/ingest/spec.md).
- **Переходы состояний — только через `worker` под per-download блокировкой**,
  и только легальные по декларативному графу. Обход даёт гонку двух
  транспортов. **`major`.**
- **Время — только `store.Now()` (UTC), идентификаторы — только `ident`**;
  `ident.Parse` на каждой входной границе. Время механизировано линтером
  (`forbidigo` на `time.Now`); правило про `ident` линтером не проверяется —
  держится на ревью. **`minor`.**

## Команды

Запуск через [Task](https://taskfile.dev) (`task --list` — полный список):

- `task setup` — установка тулинга (golangci-lint + git-хуки lefthook)
- `task run` — локальный запуск (`go run ./cmd/jellybit --config ./config.toml`)
- `task build` — статический бинарь `linux/amd64` для сервера
- `task test` / `task lint` — тесты и golangci-lint
- `task gate` — детерминированный гейт ревью (см. ниже)
- `task review:context` — карта проекта для архитектурного прохода ревью
- `task tidy` — `go mod tidy`
- `task image` — docker-образ из готового бинаря

## Гейт

- **Команда целиком:** `task gate` (`BASE=<rev>` задаёт базу диффа). Без `BASE`
  база — `git merge-base HEAD master`, а на самом `master` — `HEAD~1`.
- **Где логи шагов:** `tmp/gate/<шаг>.log`, по одному файлу на шаг.
- **Что означает исход:** статусы `OK` / `FAIL` (краснит) / `WARN` (виден, не
  блокирует) / `SKIP` (не применим, всегда с причиной). Код возврата 1, если
  есть хоть один `FAIL`. Гейт **не** останавливается на первом отказе —
  ревьюверу нужна полная картина.
- **Что красит безусловно:** сборка, `go vet`, `golangci-lint`, `gofmt`, тесты,
  флаки-прогон (второй прогон разошёлся с первым), `-race`, накат миграций с
  нуля, `gitleaks`, канон документации (`docs.py check` — раскладка `docs/`,
  битые ссылки, «миграция изменена, а `database.md` нет»), каталог задач
  (`tasks.py check --dir tasks` — согласованность `tasks/BACKLOG.md` и
  `tasks/items/`), форма конфига OpenSpec (`openspec.py check` — незаменённый
  пример в `openspec/config.yaml`). Причина одна: у каждого из них есть
  объективный оракул, спорить не о чем. Каждый из трёх последних краснеет и
  когда своего скрипта нет: молча пропущенная проверка неотличима от пройденной.
- **Чего в гейте намеренно нет и кто обязан это гонять:**
  - `govulncheck` даёт `WARN`, а не `FAIL`: находка тут — состояние
    зависимостей, а не диффа. Разбирает агент ревью по трассам вызовов.
  - `-race` без gcc уходит в `SKIP` с явным «гонки НЕ проверены» — тогда их
    проверяет рассуждением тема `operations` ([docs/review.md](docs/review.md)
    → «Вопросы по темам»), и это идёт в границы покрытия.
  - Ничего не гоняется против **живого** qBittorrent, LLM и метабаз:
    интеграционные тесты за env-гейтами, запускает человек вручную.
  - Качество распознавания гейтом не проверяется вовсе и проверяться не будет:
    размеченный корпус решено не собирать (`tasks/REJECTED.md`,
    2026-08-06). Сдвиг точности виден только по рабочему потоку.

## Запреты

- **Не запускать против рабочей БД** `/data/jellybit.db` на umbar и против
  любого файла, на который указывает боевой `[storage].db_path`. Локально —
  только `./jellybit.db`.
- **Не писать в `/srv/media/downloads`** и вообще никуда под `paths.downloads`:
  там живут раздачи, которые qBittorrent продолжает сидировать.
- **Не ходить в боевой qBittorrent, Jellyfin и Telegram-бота** из тестов и
  отладочных прогонов. Интеграционные тесты — за env-гейтами
  (`*_integration_test.go`), включает человек осознанно.
- **Не расходовать лимиты метабаз и платного LLM** прогонами «посмотреть, что
  будет»: у `recognize --dry-run` есть цена.
- **`testdata`** отдельным каталогом не заводился: фикстуры чужих форматов
  живут константами в тестах пакета-разборщика (`internal/tgbot/parse_test.go`,
  `internal/magnet`, `internal/torrent`).
- **Временное — только в `tmp/`** (в `.gitignore`); туда же пишет гейт. Не в
  `/tmp`, не рядом с исходниками.

## Работа

- **Основная ветка:** `master`. От неё считается база диффа
  (`git merge-base HEAD master`), в неё вливает батч, от неё ветвятся задачи.
- **Необратимое** (спрашивается у человека всегда): всё, что пишет в
  `paths.downloads` или удаляет оттуда; удаление раздачи из qBittorrent вместе
  с файлами (`Delete`) — кроме уборки собственного, только что добавленного
  торрента, когда закрытие любым путём увело задачу из `catched` (см.
  исключения инварианта выше); снятие последней копии
  данных; правка уже применённой миграции; `git push --force`; удаление или
  перезапись файла в библиотеке Jellyfin, которого мы не создавали.
- **Что считается сломанным:** покрасневший `task gate` на `master`. Пока он
  красный, ни одна задача не считается сделанной, и чинится он раньше любой
  другой работы: гейт один на все задачи.
- **Приоритет — это порядок строк в [tasks/BACKLOG.md](tasks/BACKLOG.md).**
  Первая строка секции — то, что делают следующим. Порядок назначает человек на
  груминге (`av-dev-tasks:groom`), машина его не выводит.
- **Ориентир по размеру порции разбора на груминге:** 5–8 задач. Ориентир, а не
  закон.
- **Что такое «сделана»:** пайплайн задачи пройден целиком (спека → код → оба
  чекпоинта ревью → archive) и критерии приёмки проверены поимённо.

## Spec Driven Development (OpenSpec)

Изменения ведём через [OpenSpec](https://github.com/Fission-AI/OpenSpec)
(CLI `openspec`, v1.x). Сначала спецификация — потом код.

- `openspec/specs/<capability>/spec.md` — **нормативный дом поведения**: что
  система делает сейчас. Capability — это поведение или домен системы, а не
  пакет кода.
- `openspec/changes/<id>/` — предлагаемое изменение: `proposal.md`, `design.md`
  (для нетривиальных), дельта-спеки (`ADDED`/`MODIFIED`/`REMOVED Requirements`),
  `tasks.md`. После реализации change архивируется в
  `openspec/changes/archive/`, дельты вливаются в `openspec/specs/`.
- `openspec/config.yaml` — только нужды генерации артефактов: язык, правила
  именования capability, придирки валидатора — плюс адреса документов канона.
  Пересказа этих документов там нет: второй дом факта расходится молча.

Поток работы — через слэш-команды `opsx:*`: `opsx:explore` (продумать),
`opsx:propose` (завести change), `opsx:apply` (реализовать tasks),
`opsx:sync`/`opsx:archive` (влить и архивировать).

Правила спек — язык, именование capability и придирки валидатора — живут в
[openspec/config.yaml](openspec/config.yaml) (`context` и `rules`), оттуда их
читает порождение артефактов; здесь не дублируются. Перед коммитом change —
`openspec validate --strict`.

Ревью — два чекпоинта: ревью дизайна на предложении (после design/specs, ДО
кода) и ревью изменения после apply, до archive. Состав обоих выбирается по
метке задачи (`small` / `medium` / `large`), которую разметка ставит один раз
после propose. Настройка конвейера под проект и журнал дефектов —
[docs/review.md](docs/review.md).

## Документация

Раскладка задана каноном av-dev; проверяет её `docs.py check` внутри `task gate`.

- [docs/passport.md](docs/passport.md) — зачем и для кого, чем **не** является.
- [docs/architecture.md](docs/architecture.md) — обзор, эксплуатация, единые
  точки, деплой. **Поведения здесь нет** — оно в `openspec/specs/`.
- [docs/database.md](docs/database.md) — схема, представление данных, настройки
  с числовым значением.
- [docs/security.md](docs/security.md) — периметр, недоверенный вход, что вне
  модели.
- [docs/conventions/](docs/conventions/README.md) — как пишем код.
- [docs/research/](docs/research/README.md) — наблюдения за чужими форматами.
- [docs/adr/](docs/adr/README.md) — журнал решений, неизменяемый.
- [docs/review.md](docs/review.md) — настройка конвейера ревью и журнал дефектов.
- [tasks/](tasks/BACKLOG.md) — задачи и цели: одна запись = один файл
  в `items/` + строка в индексе, порядок строк = приоритет. Ведётся скиллом
  `av-dev-tasks:tasks`, разбор беклога — `av-dev-tasks:groom`.

**Tududi** (проект `jellybit`, project_id 14) — только инбокс сырых идей. Идея
становится задачей, когда её оформляют файлом в `tasks/items/`.

## Конвенции кода

- Раскладка: `cmd/jellybit` (точка входа) + `internal/<пакет>` по компонентам
  из [docs/architecture.md](docs/architecture.md).
- **Механизируемое проверяет `task gate`** (`.golangci.yml` +
  `internal/archrules`): форма ошибок и логов, конфиг мимо env, время мимо
  `store.Now()`, `AUTOINCREMENT` в миграциях, направление зависимостей
  ядро↔транспорты. Перечень с местом механизации —
  [docs/conventions/README.md](docs/conventions/README.md); пересказывать эти
  правила прозой не нужно.
- Прозой остаётся только то, что правилом не выражается, и читается в
  источнике: [ошибки](docs/conventions/errors.md),
  [логи](docs/conventions/logging.md), [конфиг](docs/conventions/config.md),
  [БД](docs/conventions/database.md), [веб-UI](docs/conventions/web-ui.md).
- Миграции БД (goose, `internal/store/migrations`; SQL для DDL, Go — когда
  нужен код): при изменении структуры в том же change обновляем ER-схему в
  [docs/database.md](docs/database.md) — иначе краснеет шаг `canon` гейта.

## Язык

- Документация, комментарии, сообщения коммитов — **русский**.
- Код и идентификаторы — английский.
