# Техническое задание: gntodo

Персональный менеджер задач для повседневных дел и личных проектов.

| | |
|---|---|
| Версия ТЗ | 0.96 |
| Дата | 2026-10-10 |
| Статус | На обсуждении |

---

## 1. Общие сведения

### 1.1. Назначение

Продвинутый личный таск-менеджер. Ключевая идея — **минимальный трением ввод**: задача закидывается в систему в одну строку, без выбора проекта, тегов и приоритета. Система сама делает предварительную детализацию, а пользователь позже утверждает или правит её при разборе.

### 1.2. Пользователи

- Несколько пользователей: данные (задачи, проекты, теги, документы, сад, XP/монеты,
  настройки) полностью изолированы по `user_id` — один пользователь не видит чужого.
  Сад (0.58) в интерфейсе отключён, но его данные по-прежнему изолированы.
- Авторизация — через централизованный SSO-сервер **auth.gnexus.space** с клиентской библиотекой **gnexus-gauth** (см. 6.4). Пользователь создаётся в БД при первом входе (email, avatar, locale — из SSO).
- **Claim при первом логине** (миграция на мультиюзерность): все строки с
  `user_id = NULL` (наследие однопользовательской версии) и глобальные настройки
  забираются первым вошедшим пользователем.
- Косвенные «пользователи» — ИИ-агенты, действующие от имени своего пользователя через MCP (3.10).

### 1.3. Развёртывание

- Собственный сервер (VPS), доступ из интернета.
- HTTPS, реальный домен; TLS терминирует внешний reverse-proxy VPS —
  docker-compose (web-контейнер) слушает :80 за ним.
- Данные хранятся только на своём сервере.

## 2. Сценарии использования

**Сценарий А — веб-интерфейс (основной).** Разбор стека входящих, детализация и утверждение задач, работа с проектами, планирование, просмотр дерева задач.

**Сценарий Б — ИИ-агент через MCP.** Агент от имени пользователя добавляет задачи (например, из переписки или диалога с LLM). Большинство AI-задач выполняется на стороне агента; сервер предоставляет MCP-интерфейс к данным.

## 3. Функциональные требования

### 3.1. Быстрый захват задач (стек входящих)

- Задача создаётся одним текстом (заголовок). Выбор проекта, тегов, приоритета — **не обязателен** и не предлагается принудительно при вводе.
- **Массовое добавление (0.39)**: после сохранения полной формы создания она не закрывается, а очищается (выбранный проект сохраняется, фокус в заголовок) — можно вводить следующую задачу. Страница за формой обновляется (глобальный «+» с любой страницы, кроме страницы задачи, переводит её к созданной задаче).
- **Проект из контекста**: на странице проекта новая задача по умолчанию относится к нему; на странице задачи — к её проекту (подзадачи наследуют проект родителя).
- Такие задачи создаются «сырыми» (`raw`) и ожидают детализации.
- **В стек попадают только задачи без проекта** (0.95): задача, созданная сразу в
  проекте (страница проекта, подзадача, агент через MCP), уже получила контекст от
  владельца — она не «входящая». Стек — стопка разбора для того, что захвачено
  на бегу и ещё ни к чему не отнесено. Такие задачи никуда не пропадают: они видны
  на странице своего проекта, автодетализация для них идёт как обычно, а предложение
  ИИ ждёт на странице самой задачи.
- Стек — отдельное представление с количеством входящих, видимое с главного экрана.

### 3.2. Автоматическая предварительная детализация

При создании задачи система самостоятельно предлагает черновые метаданные — независимо
от того, попадёт ли задача в стек: задача, созданная сразу в проекте, детализируется так
же, просто ждёт разбора не в стопке входящих, а на своей странице.
Состав предложения (решение 2026-10-10, 0.93):

- **теги** — максимум 3: подходящие из существующего справочника или новые;
  **новые теги — только на английском**;
- **градация приоритета** — very_low / low / medium / high / urgent (шкала
  интерфейса, см. 3.13);
- **оценка длительности** — минуты; она же включает задачу в режим «3 варианта» (3.9);
- LLM отвечает **на языке исходного текста задачи**.

Заголовок и описание LLM **не предлагает** (решение 2026-10-10): дело не в размере
модели, а в отсутствии контекста. Создавая задачу, владелец владеет контекстом
(«зачем употребил то или иное слово»); у модели его нет, поэтому сжатый текст теряет
важные детали или меняет суть задачи. Проект ИИ тоже не предлагает.

Требования:

- Это **не окончательная** детализация — промахи ожидаемы и нормальны.
- Правило применения (решение 2026-10-10) — **предложение нельзя испортить**:
  - **теги дополняют** назначенные, а не заменяют их (поэтому существующие теги
    задачи уходят в контекст модели — она не должна предлагать их повторно);
  - приоритет и оценка длительности пишутся **только в пустое поле**: если владелец
    уже указал время или приоритет, они не меняются, даже если модель что-то
    предложила и пользователь согласился.
- **Заполненные пользователем поля не предлагаются**: при первичном анализе
  приоритет и оценка запрашиваются только у пустых полей; теги — всегда. «Переспросить
  ИИ» (redetail) сбрасывает текущее предложение и спрашивает заново по **текущему**
  тексту задачи — то же правило пустых полей действует и там.
- При разборе задачи в стеке (карточка стека — 3.7) доступны варианты:
  1. **«Да, всё верно»** — принять предложенные метаданные одним действием
     (теги **добавляются** к назначенным, пользовательские не снимаются, заполненные
     приоритет и оценка остаются как есть);
  2. **отредактировать** — изменить предложенное вручную, затем утвердить.
- Задача считается утверждённой (`approved`) только после явного действия пользователя.
- Если предложить нечего (модель не добавила тегов, а приоритет и оценка у задачи уже
  стоят) — **предложение не показывается вовсе**: пустая карточка только сбивает с
  толку. Задачу в этом случае утверждают как есть, без применения предложения.
- Ответ LLM валидируется: строки-заглушки («NULL», пустые/пробельные, «n/a»)
  отбрасываются, длина ограничивается, дубли тегов схлопываются, градация приоритета
  переводится в число шкалы, оценка ограничивается разумными границами.
- Механизм предсказания — см. 3.11 и раздел открытых вопросов.

### 3.3. Содержание и редактирование задачи

- Описание задачи — **Markdown** (редактирование с форматированием).
- Вложения — изображения, в том числе **вставка из буфера обмена** (Ctrl+V прямо в редактор).
  Вставка работает и в форме создания (вложение привязывается к документу: при
  создании задачи откладывается до первого сохранения — в поле сначала
  blob-плейсхолдер с живым превью, после создания файлы грузятся и ссылки
  меняются на настоящие); в заметке проекта при отсутствии документа он
  материализуется текущей заметкой (0.52).
- Отображение Markdown в описании (просмотр и редактирование).
- **Чекбоксы списков задач** (`- [ ]` / `- [x]`) в отображаемом описании кликабельны:
  клик переключает пункт и сохраняет статус в markdown описания.
- **Инлайн-редактирование** на странице задачи: каждое редактируемое поле правится
  прямо на месте (клик по значению → редактор на его месте, ✓/✗ или Esc); пустые
  поля тоже показываются («— не указано») и редактируются кликом. То же — на
  странице проекта (название, заметка, актуальность, приоритет). Полный режим
  правки (вся форма целиком) сохраняется наряду с инлайном.
  Открытие редактора ставит фокус в первое поле (клик по значению = «начать
  печатать»), а Esc отменяет правку независимо от того, где сейчас фокус (0.90):
  без этого Esc работал, только пока фокус оставался внутри редактора, — после
  клика по соседней строке он молча перестаёт действовать.
- **Длительность — часами и минутами** (0.90): оценка и факт вводятся двумя полями
  («ч» и «мин»), а не переводом в минуты. Пока в полях печатают, они источник
  истины: пришедшее от страницы значение применяется, только если не совпадает с
  уже набранным (иначе очищенное поле мгновенно наполнялось прежним числом, и
  переписать значение было нельзя). Границы — как у API (`1…1440` мин): выход из
  полей сворачивает минуты в часы («0 ч 90 мин» → «1 ч 30 мин») и зажимает
  значение, а не сервер отвечает 422 после нажатия ✓.
- **README вместо описания-ссылки** (0.41): если **описание задачи** состоит из
  одной ссылки на git-репозиторий, вместо неё показывается `README.md` из
  репозитория (с подписью-ссылкой на источник). Хост не обязан быть
  GitHub/GitLab — селф-хостед системы ищутся эвристикой (API github/gitlab/gitea +
  raw-пути web-интерфейсов, дефолтная ветка HEAD); не нашли — остаётся ссылка.
  Сырой текст в БД не меняется; кэш прочтённого README — 10 минут.
  Пользователю подсказывается об опции: в форме создания задачи (подпись
  редактора) и в пустом описании/инлайн-правке на странице задачи.
  У проекта этого механизма больше нет (0.67): ссылка на репозиторий живёт
  отдельным полем, а README показывается вторым описанием (см. 3.8).
- **Линки описания/заметки — в новую вкладку** (0.41): всё, что рендерится
  из Markdown (описание, заметка, README), открывается во внешность
  (`target="_blank"`, `rel="noopener"`) — PWA не ловит навигацию в своё окно.
- **Длинная заметка сворачивается** (0.41): если блок заметки (или README из
  ссылки, а с 0.67 — и README репозитория проекта) выше ~420px, показывается
  начало + подпись «Заметка большая — показано начало» и кнопка «Развернуть»
  (обратно — «Свернуть»).

### 3.4. Статусы, дедлайны, прогнозы, бюджет

**Тип задачи**: каждая задача имеет тип — **разовая** или **регулярная** (повторяющаяся; см. 3.5).

**Статусы выполнения**:

| Статус | Смысл |
|---|---|
| `to_do` — К выполнению | Принята в работу, ожидает выполнения |
| `in_progress` — В работе | Выполняется сейчас |
| `done` — Завершено (закрыто) | Выполнена |
| `cancelled` — Отменено | Была актуальной, затем перестала быть актуальной. **Не равно удалению** — задача сохраняется, т.к. может снова стать актуальной |
| `deferred` — Отложено | На текущий момент не нужна или не в приоритете; возможно, позже это изменится |

- Статус `cancelled` и `deferred` — обратимы: задача может быть возвращена в `to_do`.
- Удаление — отдельное явное действие, не статус.
- Детализационный жизненный цикл (`raw` → `approved`) — **отдельная плоскость**, не статус выполнения: «сырая» задача из стека получает статус только после утверждения.
- **Приёмка агентской работы** (`accept_state`: нет | `pending` | `accepted` | `rejected`) — вторая отдельная плоскость (3.20): закрытие задачи ИИ-агентом помечается «ждёт приёмки», владелец принимает работу или возвращает задачу в работу. Набор статусов выполнения при этом не меняется.

**Дедлайны — двух видов**:
  - *строгий*: «сделать до конкретной даты»;
  - *нестрогий*: «сделать в течение недели / месяца / года» — не привязан к конкретному дню, задаётся периодом.

**Прогнозирование времени выполнения** — длительность задачи оценивается автодетализацией (3.2, 0.93) или задаётся вручную; уточнение по истории завершённых задач (факт) — открытый вопрос 8.2.

**Бюджет** — опциональный, на задачу; есть не у каждой задачи:
  - задаётся пользователем вручную (деньги и/или время);
  - рядом с бюджетом отображается **оценка затрат** по задаче, чтобы сразу видеть: задачу можно взять в работу или на неё не хватает денег.

### 3.5. Регулярные задачи

- Отдельный **тип задачи** — регулярная (повторяющаяся) в противовес разовой.
- Повторение создаёт новую задачу по правилу **после закрытия** предыдущей
  (закрытие — единственный момент, когда рождается следующий экземпляр: отмена и
  «отложено» цепочку не продолжают).
- Правила повторения (реализованы с 0.4): **интервал** — каждые N дней от даты
  создания, **дни недели** — ближайший следующий из выбранных, **месячное число** —
  указанное число месяца (нет такого дня — последний день месяца). Отсчёт идёт по
  календарю, а не «через N суток после выполнения».
- Новый экземпляр наследует **доступность для ИИ-агента** (3.20) от предыдущего: цепочка регулярной не теряет мандат.
- **Дата следующего экземпляра видна сразу** (0.91). Она вычисляется тем же
  правилом, что и рождение экземпляра, и до 0.91 выбрасывалась: закрыв ежедневную
  задачу утром, владелец видел её же снова «к выполнению», без даты и неотличимо от
  задачи на сегодня. Теперь она ложится в существующее поле `deadline_date`
  (отдельной сущности и миграции нет). Поле переиспользуется осознанно: у
  регулярной это **«когда пора»**, а не срок работы, поэтому напоминания о сроках
  (3.21) её не касаются, а подпись даты в интерфейсе читается как «след. 12.10.2026».
  Если у экземпляра задан **нестрогий период** («в течение недели»), дата не
  ставится: 3.4 запрещает оба поля разом, а период у регулярной осмыслен сам по себе.
- **Метка в интерфейсе** (0.91): в карточке задачи (3.6) регулярная отмечена
  **акцентной полосой по левой кромке** и **кружком с иконкой повтора** перед
  названием — задача опознаётся по типу, не читая заголовок и бейджи; подсказка
  кружка говорит правило словами и называет дату следующего экземпляра.

### 3.6. Представления задач

- **Классический вид**: список с фильтрами (статус, приоритет, тег).
  Шкала приоритета — 0-10 с градациями (очень низкий…срочный, 3.13); задача без
  приоритета считается «очень низким». В интерфейсе фильтр приоритета — включение
  «приоритетные» (выше среднего, то есть high и urgent), отсечка по градациям
  остаётся в API.
- **Статус — табами, тег — поиском по имени** (0.70): выпадашки не помогали при
  росте данных. Пять статусов спрятаны в селекте — теперь это **табы**
  («Все» + пять статусов, иконки как в бейджах статусов); на узком экране табы
  **прокручиваются по горизонтали** внутри своего ряда, страница по горизонтали не
  едет. Тегов у пользователя десятки — найти нужный в селекте глазами нельзя,
  поэтому тег задаётся **поиском по подстроке имени** (без учёта регистра): список
  задач сужается **по мере ввода**, а не после выбора тега из подсказок; пустой
  ввод фильтр не применяет. Подсказки (совпавшие теги) показывает `GnCombobox` кита.
  Доработка 0.71: подсказки показываются **только под набранное** — на фокусе
  пустого поля вместо простыни из всех тегов стоит подсказка «начните вводить
  название тега». Внутри поля, справа — **крестик сброса**: у поиска нет пункта
  «— любой —», а фильтр ещё и запоминается (3.8), поэтому снять его иначе было
  нечем; пустой ввод после сброса применяется сразу, без задержки ввода.
  Доработка 0.73: фильтра **по проекту в списке нет** — разрез по проектам это
  раздел «Проекты» (3.8), в общем списке селект только дублировал его (со страницы
  ушла и загрузка списка проектов — она была нужна лишь этому фильтру).
  Подписи у поля тега нет (0.73): табы подписаны собой, а у поля есть иконка тега и
  подсказка; поле при этом стало **300px**, как на странице проекта (0.71), иначе имя
  тега в нём не читалось.
  Доработка 0.74: фильтр приоритета — **один переключатель «Приоритетные»**
  (`GnSwitch` кита) вместо табов приоритета (они прожили только 0.73): из шести
  пунктов нужен был ровно один вопрос — «показать главное». Включённый переключатель
  оставляет задачи **выше среднего**: градации high и urgent (7-10 по шкале 3.13).
  Задача без приоритета (very_low, пустое значение) в отсечку не попадает — она и
  есть самая низкая. Переключатель стоит рядом с полем тега (с 0.75 — в шапке
  страницы, см. ниже) и выровнен по центру поля: короткий контрол при выравнивании
  по низу липнул бы к нижнему краю 50-пиксельной коробки. Доработка 0.76: в шапке
  выровнены и сами элементы друг относительно друга — у кита под полем остаётся
  `margin-bottom` 15px у `form-group`, а над полем 8px у `input`, из-за чего поле
  стояло на 3.5px выше центра ряда; в фильтре эти отступы сняты, и обёртка поля
  совпадает с его коробкой. Крестик сброса отсчитывается от низа обёртки, поэтому
  до 0.76 «уплывал» к нижнему краю поля на те же 15px.
- **API списка** принимает подстрочный фильтр тега параметром `tag` у
  `GET /api/tasks` (0.70; регистронезависимо, служебные знаки `%` и `_` во вводе
  экранируются — «100%» не превращается в маску) и отсечку по нижней границе
  приоритета параметром `priority_min` (0.74; целое 0-10, вне шкалы — 422), которой
  работает переключатель «Приоритетные» (`priority_min=7`). Параметры по `id`/градации
  (`tag_id`, `priority`) остаются — их держат тесты и старые клиенты: MCP-инструмент
  `list_tasks` фильтрует по тегу своей выборкой в БД, REST-параметр не использует.
  Задачи без приоритета (`NULL`) отсечку `priority_min` не проходят.
- **Фильтр по типу задачи** (0.91): параметр `task_type` у `GET /api/tasks`
  (`one_time` | `recurring`, иное значение — 422), которым работает переключатель
  **«Регулярные»** — второй `GnSwitch` рядом с «Приоритетными» в шапке списка
  (3.5): «показать главное» и «показать повторяющееся» — два вопроса об одном
  списке, и читаются они парой. Фильтры списка применяет сервер (как и остальные),
  персистентности у них нет — по 3.8 запоминаются фильтры только на странице проекта.
- **Фильтр «Созданные агентом»** (0.94): третий `GnSwitch` в шапке списка, параметр
  `created_by_kind=agent` у `GET /api/tasks` (3.20). До 0.94 это был `GnSelect` с
  тремя пунктами — «все / мои / агента»; средний пункт ничего не прибавлял к
  «всем» (своих задач у владельца подавляющее большинство), а спрашивалось в нём
  ровно одно: «показать только то, что завёл агент». Теперь так и спрашивается.
- **Фильтры-поля — в шапке страницы** (0.75): переключатель «Приоритетные» и поиск
  по тегу собраны справа от заголовка (слот действий `GnPageHeader`) одной группой;
  отдельной строкой под шапкой остались только табы статуса. На узком экране группа
  переносится по строкам сама (переключатель строкой, поле — следующей).
- **Пагинация**: длинные списки (список задач, стек на главной, задачи проекта,
  проекты, архив) разбиваются на страницы **по 30 элементов**; навигация кнопками
  (‹ номера ›, с усечением «…» и подписью «Страница N из M»). Смена фильтров
  возвращает на первую страницу; при одной странице контрол не показывается.
- Подзадачи показываются карточками внутри задачи и в списке; представление
  «дерево» убрано (проба 0.5 показала, что оно мешает — возможен возврат позже).
  Связь подзадачи с родителем (`parent_task_id`) в модели данных сохраняется.
- Карточки списка задач раскладываются **сеткой по три в ряд** (0.68; на десктопе
  контейнер расширен до 1440px, см. 4): до 1200px — две колонки, до 900px — одна
  (там список остаётся колонкой флексом). Скелетон загрузки повторяет ту же сетку,
  чтобы загрузка не дёргала раскладку.
- **Карточка задачи кликабельна целиком** (0.75): клик мимо внутренних элементов
  ведёт на страницу задачи. Свои интерактивные элементы карточка не перехватывает —
  заголовок-ссылка, **бейдж проекта** (с 0.75 это тоже ссылка, на страницу проекта;
  при наведении бейдж подсвечивается акцентом) и «⋯»-меню обрабатывают клик сами —
  иначе получалась бы двойная навигация. Выделенный в карточке текст кликом не
  считается: иначе не скопировать сниппет. Курсор и подсветка рамки при наведении —
  как у карточки проекта.
- **Свежесть задачи на карточке** (0.75): когда задача добавлена, словами
  («Сегодня», «Вчера», «3 дня назад», «2 недели назад» — тот же `timeAgo`, что у
  свежести проекта, 0.69); полная дата — в подсказке. Это контекст, а не метаданные,
  поэтому не бейдж, а приглушённый текст; на странице задачи дата добавления как
  была — в сайдбаре «Даты» в полном формате. С 0.76 живёт в **подвале карточки**
  (слот `footer` китового `GnCard`), а не в ряду бейджей: подвал отделён чертой
  1px и прижат к низу карточки, поэтому в сетке списка он у всех карточек на одной
  линии. Иконка рядом с текстом идёт с китовым классом `normalize` — без него глиф
  иконочного шрифта садится на 1.7px выше середины строки (в подвале карточки сама
  поправка снята с 0.83 — см. 3.19).
- **Проект задачи — из меню карточки** (0.82): в «⋯»-меню карточки задачи рядом стоят
  «Перейти к проекту» и «Переместить в проект» — это один и тот же вопрос «в каком
  проекте задача». «Перейти» показывается у задачи с проектом и **не показывается на
  странице самого проекта** (там задача и так лежит в нём). «Переместить» открывает
  модалку с выбором проекта: `GnSelect` кита, первым пунктом «— без проекта —»,
  текущий проект выбран по умолчанию; задача в **архивном** проекте тоже остаётся
  выбранной, хотя в справочнике активных проектов её нет. Перенос уходит тем же
  `PATCH /api/tasks/{id}` с `project_id`, что и правка поля «Проект» на странице задачи:
  бэкенд проверяет, что проект принадлежит пользователю, и рассылает `task.changed`.
  **XP и монеты за перенос не начисляются** — они висят только на переходе в
  «завершено». Успех подтверждается тостом, список перечитывается сразу (`moved`),
  поэтому задача тут же уходит со страницы прежнего проекта. В 0.83 пункт сокращён до
  **«В другой проект»**: надпись «Переместить в проект» в китовом меню шириной 220px
  рвалась на две строки.

### 3.7. Главная страница (дашборд + стек)

- Главная совмещает два блока: **дашборд активного** сверху и **стек входящих** снизу.
- В стеке — только задачи без проекта (правило 0.95, см. 3.1).
- Дашборд: «В работе», «Просрочено», дедлайны на неделю, «Следующие» (to_do по
  приоритету, топ-5); сайдбар — сводка по статусам и активные проекты с прогрессом.
- Пустые секции дашборда не показываются; при отсутствии и активных, и проектов —
  нейтральное пустое состояние.
- Карточки стека раскладываются **сеткой по три в ряд** (0.59): на узких экранах —
  две и одна (ломающиеся точки 1080 и 720px); скелетон загрузки повторяет ту же
  сетку. Карточки ряда — **одной высоты** (0.95; в 0.59 они выравнивались по верху,
  и кнопки утверждения плясали между соседями): карточка тянется по высоте ряда,
  а блок предложения ИИ с кнопками прижат к низу — бейджей у задач разное число,
  без этого кнопки встают на разной высоте.
- **Карточка стека — обычная карточка задачи** (0.95): тот же компонент `TaskCard`,
  что в списке задач и в выдаче, — заголовок-ссылка на задачу (без принудительного
  аперкейса), сниппет описания, бейджи задачи, подвал «свежесть» и «⋯»-меню. Своё у
  стека одно: предложение автодетализации бейджами и кнопки **«Да, всё верно»** и
  **«Детализировать»**, а в «⋯» добавляется **«Переспросить ИИ»** (её место — там,
  где живут остальные действия задачи). Бейдж «в стеке» и абсолютная дата создания
  из карточки ушли: и то и другое дублировало место карточки и её подвал.

### 3.8. Проекты

- Статус **актуальности** проекта (активен / приостановлен; «закрыт» выражается архивом, см. 3.8.1).
- **Приоритет** проекта.
- **Заметка к проекту**: Markdown-поле для ссылок на ресурсы проекта и прочего контекста; заметка — тот же «документ», что и описание задачи: в неё можно вставлять изображения из буфера (Ctrl+V).
- **Проекты без задач — отдельной секцией в конце списка** (0.60): на карточке
  бейдж «Задач закрыто 0/0» ничем не отличался от заполненных, и пустой проект
  терялся среди остальных. Список проектов делится на секции «С задачами» и
  «Без задач» (иконка `ph-folder-dotted`), счётчики в заголовках — по всему списку;
  заголовки секций показываются только когда пустые проекты есть, иначе список
  выглядит как раньше. Порядок применяется **до** пагинации (3.6), чтобы секция не
  разъезжалась по страницам.
- Карточки проектов раскладываются **сеткой по три в ряд** (0.68; в 0.61 их было
  две): в три колонки список читается компактнее и помещается в широкий контейнер
  (1440px, см. 4), а на узких экранах — две колонки до 1200px и одна до 860px.
  Заголовок секции занимает всю ширину строки; скелетон загрузки повторяет
  ту же сетку. Карточки ряда **одной высоты с подвалом по низу** (0.66; в 0.61 они
  выравнивались по верху и подвал «плясал» между соседями).
- **Карточка в списке — метрики, без заметки** (0.62): заметка (Markdown с ресурсами)
  остаётся только на странице проекта — в списке она делала карточки разной высоты
  и мешала сравнивать проекты. В шапке карточки справа — актуальность; ниже ряд
  метрик: приоритет, «N в работе», «просрочено N» (нулевые метрики не показываются —
  иначе ряд бейджей шумит одинаковыми «0»); полоса прогресса «Закрыто N из M»
  с процентом (оформление полосы изменено в 0.64); подвал виден всегда (0.69) —
  слева свежесть словами, справа ближайший дедлайн по открытым задачам
  (просроченный красным).
  У проекта без задач вместо полосы — подсказка «Задач в проекте
  пока нет». Метрики считаются по всем задачам проекта (как и полоса прогресса),
  просрочка — по незакрытым.
- **Блок задач на карточке — счётчики и клетки по задачам** (0.64): подпись
  «Закрыто N из M» заменена счётчиком **«✓ N / M»** (закрытых — крупнее, всего —
  тише; полная фраза осталась в подсказке при наведении), процент кита — справа.
  Полоса разбита насечками: **одна клетка — одна задача**, закрытые клетки залиты
  **цветом-меткой проекта** (у проекта без цвета — акцент кита). Клетки рисуются
  при 2..24 задачах: у одной задачи дробить нечего, при большем числе клетка
  становится уже 4px и полоса рябит — там она сплошная. Заливка считается по
  клеткам (`100%/M × N`), а не процентом кита: у процента округление до целого,
  и на границе клетки оставалась бы щель. Трек на карточке **тоньше китовских
  18px — 8px** (0.68): полоса здесь подложка под счётчиком, а не самостоятельный
  элемент. Проект закрыт целиком — полоса и счётчик
  уходят в зелёный кита. Цвет проекта **в тексте** не используется (тёмная ступень
  цветных рядов и тёмный край нейтральной шкалы, 0.72 — нечитаемы как текст) — им
  закрашена только полоса.
- **Метка проекта — кружок и фавикон сайта** (0.65): в шапке страницы проекта и на
  карточке списка проект помечен парой «цвет-кружок + иконка сайта»: на карточке
  кружок 16px и фавикон 18px (было 9px без иконки), в шапке страницы — 18px и 21px
  при H1 23px. Метка идёт размером от кегля имени (в карточке база — тайтл), поэтому
  растёт вместе с ним. Иконку тянет тот же бэкенд-прокси (3.11): нет сайта или
  иконки — остаётся один кружок, **битой картинки нет** (404 — штатный ответ).
  В шапке страницы фавикон — **ссылка на сайт** (короткий путь к нему рядом с
  именем), на карточке — просто картинка: карточка целиком ссылка, вложенные
  ссылки невалидны (0.63). Заодно **тайтл карточки списка крупнее** (16px вместо
  14px, приходивших наследованием): имя проекта — главный ориентир в списке.
- **Ссылка на сайт — чип в шапке проекта** (0.65): прежняя подпись (13px
  приглушённым цветом, без рамки) выпадала из ряда бейджей и читалась подписью, а
  не действием. Теперь это чип геометрии бейджа кита (высота 24px, радиус 3px,
  13px/600) с иконкой сайта 20px и подсветкой акцентом при наведении и фокусе;
  капса нет — домен в верхнем регистре читается хуже. Иконки нет — глобус.
  Обводку чип не носит (снята в 0.65 по просьбе пользователя): рамка вокруг
  ссылки-действия читалась как второй бейдж в ряду.
- **Карточка списка кликабельна целиком** (0.63): кнопка «Открыть» дублировала
  заголовок-ссылку и занимала подвал карточки. Теперь карточка — обёртка-ссылка
  (семантичный `<a>`: клик в любом месте, фокус с клавиатуры, «открыть в новой
  вкладке»), заголовок внутри — не ссылка (вложенные ссылки невалидны). В архиве
  (3.8.1) кнопка «Открыть» остаётся — там рядом действия восстановления/удаления.
- **Цвет-метка проекта** (0.63): у проекта может быть цвет-кружок — «узнаваемость»
  проекта в списках. Поле необязательное: новым проектам цвет **не назначается**,
  пока пользователь сам не выберет (пусто — кружка нет). Выбор — палитра
  **27 оттенков** из 8 базовых тонов (3.19), на странице проекта (инлайн-строка
  «Цвет») и в форме правки; в БД — HEX `#rrggbb` (палитра — представление
  фронта, бэкенд проверяет только формат, поэтому цвет из MCP вне палитры тоже
  валиден и показывается отдельной клеткой). Показывается в трёх местах:
  **карточка в списке проектов, шапка страницы проекта, метка
  проекта у задач** (списки, дашборд, страница задачи). В фильтрах и селектах
  проектов цвета нет: в выпадашке это шум, а не узнаваемость.
  Базовая палитра уточнена в 0.69: **ряд «песочного» (warning кита, оттенок 35°)
  заменён своим жёлтым** (`#e5cf5a`, оттенок 50°) — рядом с оранжевым (25°) он
  читался вторым оранжевым, а не жёлтым; жёлтого тона в ките нет вовсе, поэтому
  база своя. Добавлен **ряд нейтральных от белого** — он последний в сетке:
  цветные метки узнаются быстрее, а серые нужны редко и на своём месте не мешают.
  Палитра пересобрана в 0.72 по замечанию «слишком одинаковые цвета»: клеток
  стало меньше, но **любые две клетки сетки различимы**. Убраны приглушённые
  варианты (тот же тон с половинной насыщенностью) — именно они и давали
  «одинаковые» клетки: и рядом со светлой ступенью своего ряда, и с приглушёнными
  чужих тонов. Из базовых тонов убран **голубой** `#7dcfff` (199°): он стоял
  в 12° от циана и в 20° от синего — три холодных клетки читались одним синим;
  добавлен **коричневый** `#8c5e35` (28°, светлота 38%, насыщенность 45%) —
  коричневого тона в ките нет, и это свой цвет, а не «тёмный оранжевый».
  Ступени считаются **от светлоты базового** (светлая +18%, тёмная −24%), а не
  заданы абсолютной шкалой: у тона, стоящего у предела светлоты (36..88%),
  ступени просто нет, поэтому ряды коричневого и сиреневого короче — это честнее
  клетки, не отличимой от соседней. Разница измеримая: минимальная попарная
  разница по CIELAB — **ΔE 12.5**. Ряд нейтральных сокращён с 12 ступеней
  до **5** через 16%: от белого `#ffffff` до `#5c5c5c`. До чёрного шкала
  не доходит намеренно: чёрная метка на тёмной карточке кита не видна, то есть
  была бы выбором без результата (раньше это называлось ценой полноты шкалы).
- **Сайт проекта** (0.63): ссылка на сайт проекта — отдельное поле, отдельно от
  заметки (в заметке ссылка тонула в тексте). Показывается **только на странице
  проекта**: в шапке — имя хоста с иконкой сайта, клик открывает сайт в новой
  вкладке (вид ссылки уточнён в 0.65); та же ссылка правится строкой «Сайт»
  в параметрах и в форме правки. На карточке списка ссылки нет (карточка целиком
  ведёт на проект), но **иконка сайта стоит в метке проекта** (0.65). Иконку тянет
  бэкенд-прокси с кэшем (3.11); если её нет — глобус. Поле необязательное.
- **Репозиторий проекта** (0.67): ссылка на git-репозиторий — **отдельное поле**
  `repository_url`, отдельно и от заметки, и от сайта. Раньше её клали в заметку,
  и она блокировала саму заметку (заметка из одной ссылки **подменялась** README):
  у проекта теперь **два описания** — заметка остаётся заметкой, а из репозитория
  подтягивается и показывается **README как дополнительное описание**. Механизм
  распознавания ссылки в заметке проекта снят (у задачи README-вместо-описания
  остаётся, см. 3.3). README показывается **карточкой «README из репозитория»**
  под заметкой: в шапке карточки — ссылка на репозиторий (хост и путь без схемы,
  открывается в новой вкладке), в теле — отрендеренный Markdown; длинный README
  сворачивается тем же порогом ~420px (3.3). Тянет его **бэкенд-прокси**
  `/api/projects/{id}/readme` с кэшем 10 минут (данные не покидают VPS — как
  у фавикона, 3.11): привязка к проекту, а не `?url=`, чтобы эндпоинт не стал
  открытым прокси для чтения произвольных адресов. README в репозитории не нашёлся
  — тихая подпись «README не найден» (поле необязательное, проект живёт и без
  него). Правится строкой «Репозиторий» в параметрах проекта и в форме правки.
  Форма ссылки не проверяется (кроме http(s) с хостом): не похожа на репозиторий —
  прокси честно ответит «не найден». Существующие заметки-ссылки в БД не
  переписываются: ссылку нужно указать в новом поле.
- **Закрепление проектов** (0.66): у проекта есть булавка `pinned` — важные проекты
  держатся наверху списка и не уезжают вниз с пагинацией. Закреплённые живут
  **отдельной секцией «Закреплённые» вверху** (иконка `ph-push-pin`, янтарный
  бейдж-счётчик), независимо от наличия задач: закреплённый проект без задач
  остаётся в «Закреплённых», а не в «Без задач». Переключателей два, поле одно:
  **булавка в правом верхнем углу карточки списка** — рядом с бейджем актуальности
  (шапка сдвигается левее), видна всегда, в покое **не приглушена**; и **строка
  «Закреплён» в параметрах страницы проекта** (значение кликабельно, как у
  остальных строк).
  Место булавки переставлено в 0.67: в правом нижнем углу карточки она читалась
  частью подвала, а на странице проекта её и вовсе не находили — поэтому у имени
  проекта появилась **кнопка-булавка рядом с названием** (в архиве скрыта:
  страница только для чтения, состояние видно строкой «Закреплён»; строка
  в параметрах остаётся — второй переключатель). Новый проект
  создаётся незакреплённым. В MCP — поле `pinned` у `update_project` (3.10).
- **Сортировка списка по свежести активности** (0.66): выше те проекты, где
  недавно что-то происходило. Порядок — закреплённые → с задачами → без задач,
  внутри группы по убыванию активности, затем по id. **Активность — самая поздняя
  из задач проекта** (`created_at` или `done_at`); правки самого проекта (заметка,
  цвет, статус) активностью не считаются — сортировка про работу над проектом,
  а не про правку его карточки. Проект без задач стоит в конце своей группы.
  Порядок применяется **до** пагинации (3.6), а счётчики секций и показ заголовков
  считаются **по всему списку**, не по странице: иначе на второй странице заголовки
  то появлялись бы, то исчезали.
- **Подвал карточки — отсчёт до дедлайна** (0.66): ближайший срок показывается
  словами — «сегодня», «завтра», «через 3 дня», «просрочено на 5 дней» (просрочка
  красным), полная дата уходит в подсказку при наведении: дата без отсчёта
  требовала сравнения с календарём на каждом проекте. Формы числа — по правилу
  локали (0.69, как у подписи свежести, см. ниже): в 0.66 это было «через N дн.» —
  сокращение из-за отсутствия плюрализации, теперь формы полные. Дедлайн
  отсчитывается по календарным суткам
  (без часовых поясов и перехода на летнее время). Красный в подвале — про сам
  ближайший срок, а не про наличие просроченных задач: бейдж «просрочено N» в ряду
  метрик остаётся независимым.
- **Подвал карточки виден всегда, слева — свежесть** (0.69): подвал перестал быть
  местом только для дедлайна. Слева теперь свежесть проекта — **когда последний раз
  была активность по задачам**, словами: «сегодня», «вчера», «3 дня назад»,
  «2 недели назад», «месяц назад», «4 месяца назад», «год назад» (та же активность,
  по которой сортируется список, 0.66: позднейшая из `created_at`/`done_at` задач;
  правки самого проекта не считаются); полная дата — в подсказке. Срок при этом
  стоит справа — важное в одном и том же углу у всех карточек, независимо от того,
  есть ли свежесть. Сутки календарные, ниже недели — дни, дальше округление вниз до
  крупной единицы (45 дней — это уже месяцы, 400 — годы). **Появились
  плюральные формы**: их выбирает правило самой локали (`Intl.PluralRules`) —
  «2 дня», а не «2 день»; в словаре формы записаны одной строкой через «|» (ru/uk —
  три, en — две), выбор формы в коде не хардкодится. Число при единственном числе
  остаётся («1 месяц назад»). У проекта без задач свежести нет — строка пустая, но
  место подвала сохраняется, чтобы низ карточек в ряду не разъезжался.
- **Фильтры задач проекта — статус табами, тег поиском** (0.70): на странице проекта
  тот же форм-фактор, что в списке задач (3.6) — табы статуса («Все» + пять статусов,
  горизонтальная прокрутка на узком экране) и поиск по подстроке имени тега (с
  подсказкой на пустом вводе и крестиком сброса внутри поля, 0.71 — как в 3.6).
  Подписи у поля нет: фильтр на странице один, и он шире, чем в списке —
  **300px** вместо 224px, иначе имя тега в нём не читалось (0.71). Тег здесь
  фильтрует список уже загруженных задач проекта — по имени, без учёта регистра.
  Выбранные фильтры (статус и текст поиска тега) **запоминаются в
  localStorage** отдельно для каждого проекта и пользователя: вернувшись на страницу,
  видишь те же фильтры. В приватном окне или после очистки данных страница просто
  работает на значениях по умолчанию (по статусу — «К выполнению»). Сохранённый
  прежними версиями выбор тега по `id` не восстанавливается осознанно: имя тега по
  `id` известно только после загрузки справочника.
- **Теплокарта активности проекта** (0.83): на странице проекта, в сайдбаре рядом с
  «Статистикой», — та же сетка «как в гитхабе», что на странице статистики, но по
  задачам одного проекта: столбец — неделя (с понедельника), строка — день недели,
  заливка — по числу **закрытых** за день задач (шкала общая со статистикой:
  1 / 2–3 / 4–6 / 7+). Дни без закрытий — пустые клетки, будущие дни — почти
  погашенные. В подсказке клетки — дата и оба числа: «закрыто N, создано M»
  (заливка про закрытия — это и есть работа по проекту, появления видны в
  подсказке). Период один — **полгода**, 26 недель (182 дня), переключателя
  периодов нет: в 0.83 периодов было два («за месяц» и «за полгода»), но
  полугодовая сетка в содержимое карточки влезает целиком, а месячная занимала
  пятую часть ширины и читалась недоделкой — в 0.84 переключатель убран.
  Бэкенд для неё не нужен: страница проекта и так держит все свои задачи, включая
  завершённые (3.6), поэтому история считается на клиенте — закрытия по `done_at`,
  появления по `created_at`. В содержимое сайдбара сетка влезает без горизонтальной
  прокрутки: ячейки 9px с зазором 2px (26 недель — 284px; у статистики ячейки
  12px, но там карточка во всю ширину), легенда шкалы идёт под сеткой.
- **Теги проекта — облаком, клик по тегу фильтрует** (0.83): список тегов в карточке
  «Теги» заменён облаком — теги идут подряд, а размер шрифта показывает, сколько
  задач с тегом (0.85–1.2em по ранжиру, точное число — в подсказке). Клик по тегу
  ставит его в фильтр (тот же поиск по подстроке имени, 0.70), клик по активному тегу
  фильтр снимает; активный тег подсвечен акцентом. До 960px сайдбар стоит ниже списка
  задач, поэтому после клика список подтягивается к верху экрана — иначе результат
  фильтра оказывался бы за кадром.
- **Задачи проекта — по две в ряд** (0.83): карточки задач на странице проекта
  раскладываются сеткой по две в ряд (до 700px — одна колонка, как и раньше).
  Карточки одного ряда выравниваются по высоте, подвал прижат к низу.

#### 3.8.1. Архив

Проект, с которым пользователь закончил работу, **отправляется в архив** — один архив
на все проекты, отдельных архивов задач нет.

- Архивация — с подтверждением («Отправить проект и его N задач в архив?»).
- Архивный проект пропадает из рабочих видов: списка проектов, фильтров, выдачи
  задачи (3.9); его задачи не видны в активных списках задач. **Задачи живут и
  уезжают в архив только вместе с проектом**: отдельной архивации/удаления
  для задач внутри проекта нет (удаление отдельной задачи остаётся — гигиена).
- Статистика и XP не пересчитываются: закрытия в архивных проектах остаются в истории.
- **Страница «Архив»** (отдельный раздел меню): история архивных проектов —
  открытие проекта, все задачи, заметка. Восстановление возвращает проект
  со всеми задачами и статусами как были. Доступно и «удалить навсегда».
- У архивного проекта страница доступна на чтение (с плашкой «в архиве»);
  создание задач и правки спрятаны.

### 3.9. Выдача задачи (бывший режим «3 вариантов»)

Анти-прокрастинационный режим: пользователь указывает доступное время в часах (например, «3», можно 0.5), система предлагает подходящие задачи; предложение — те же карточки, что в списке задач.

- Выбор кандидатов учитывает: прогнозируемую длительность ≤ доступного времени, приоритеты, статус актуальности проектов.
- Варианты должны быть осмысленно разными (не дубликаты).
- Карточек в выдаче не больше трёх, и раскладываются они **в ряд по три** (0.95;
  просили «давай 3 в ряд»): сетка та же, что у стека входящих (3.7), — весь ответ
  ложится одной строкой, а на узких экранах сжимается до двух и одной (те же
  ломающиеся точки 1080 и 720px).
- Отдельной кнопки выбора/выполнения на странице выдачи нет: пользователь открывает карточку задачи (переход несёт `?via=options`) и берёт её в работу на странице задачи; закрытие задачи, открытой из выдачи, помечается «выбрал и сделал».

### 3.10. MCP-интерфейс

- Собственный **MCP-сервер** как часть backend.
- Инструменты (первичный набор, расширяется):
  - `create_task` — добавить задачу (достаточно текста; метаданные опциональны);
  - `update_task`, `complete_task`;
  - `list_tasks` — фильтры по проекту (id или имени), тегу, статусу, подстроке;
  - `get_task` — полное описание с вложениями;
  - `list_projects` / `get_project` — обнаружение проектов и **восстановление
    контекста проекта** (заметка проекта + его открытые задачи); список отдаётся
    **закреплёнными первыми** (0.66), затем по имени — как в UI;
  - `create_project` — новый проект (имя, опционально заметка, приоритет,
    `color`, `site_url` и `repository_url`); дубликат имени отклоняется с подсказкой;
    заметка уходит в фоновую суммаризацию; микронаграда как при создании из UI;
  - `update_project` — переименование, заметка, relevance_status
    (`active`/`paused`), приоритет, `color`, `site_url`, `repository_url`, `pinned`
    (true — закрепить, false — открепить; 0.66); адресация по id или имени;
  - `color` (`#rrggbb`), `site_url` и `repository_url` (`http(s)://host/...`) обоих
    тудов — необязательные поля проекта (3.8); как принято в файле, `None` — «не менять»,
    а **пустая строка убирает** значение; невалидный формат отклоняется с подсказкой
    формата (`repository_url` — репозиторий, из которого в интерфейсе подтягивается
    README вторым описанием, 0.67).
  - `archive_project` / `restore_project` — **архив вместо удаления** (0.45):
    проект прячется вместе с задачами из активных (история сохраняется),
    restore возвращает; вызовы идемпотентны; архивные проекты видны через
    `list_projects(include_archived=True)`. Безвозвратного удаления проектов
    в MCP нет намеренно;
  - `list_tags` — справочник тегов (id для параметра `tag_ids`);
  - `list_available_tasks` — задачи, доступные ИИ-агенту (3.20): помеченные владельцем,
    в статусе `to_do`, со свободной арендой; уже взятая этим агентом задача остаётся в
    списке — у неё видно время аренды (0.85);
  - `claim_task` / `release_task` — взять задачу в работу (повторный вызов — продление
    аренды, а не второе взятие) и вернуть её, если она не по силам (0.85, 3.20);
  - `complete_task` — закрытие задачи; для действия от имени агента **обязателен
    комментарий** о том, что сделано (0.85). Тот же параметр `comment` у
    `update_task(status="done")` — закрытие идёт и через него.
- Эргономика для агентов (в т.ч. небольших моделей):
  - серверные `instructions`: порядок работы, правило привязки задач к проекту,
    восстановление контекста через `get_project` после перерыва, запрет
    придумывать id, автоматика наград/спавна при закрытии;
  - адресация проекта по имени: параметры `project_name` (регистронезависимо)
    в тудах задач (`create_task`/`update_task`/`list_tasks`) и в тудах
    управления проектами (`update_project`/`archive_project`/
    `restore_project`) наряду с `project_id`;
  - описания параметров в схеме каждого инструмента (enum значений, форматы дат,
    диапазоны приоритета);
  - ошибки подсказывают следующий шаг («call list_projects()», «найдите id через
    list_tasks(query=...)»); ответ `create_task` без проекта содержит подсказку.
- В «Настройках» — раздел **«Токены MCP»**: генерация и отзыв персональных токенов
  (label + plaintext `gnt_...`, показывается один раз при создании; в БД только
  sha256-хэш). Там же ссылка на страницу-инструкцию по MCP (`/mcp-help`): что
  реализует, адрес сервера (совпадает с адресом приложения), токен, примеры
  подключения (Claude Code, универсальный JSON).
- Аутентификация агентов — **per-user токены** (с 0.34): Bearer-токен из «Токенов MCP»
  → агент действует от имени своего пользователя и видит только его данные.
  Переменная окружения `MCP_TOKEN` удалена — старые конфиги агентов получат 401,
  пока владелец не создаст токен в «Настройках». С 0.42 Bearer-токен принимается
  и на REST `/api/*` — им ходит браузерное расширение (3.18).
- Закрытие задачи агентом (`update_task`/`complete_task`) идёт по тому же общему
  пути, что и в UI: спавн регулярной, XP, монеты (3.5, 3.13); в открытых
  вкладках — скромный тост о награде (3.14). Растение больше не выдаётся —
  сад отключён в 0.58.
- **Актор действия** (0.85, 3.20): у изменяющих тулов есть флаг `is_user` — по умолчанию
  действие считается **агентским**, `is_user=true` объявляет действие от имени владельца
  (тот же смысл у заголовка `X-Actor: user` на REST). Актор и канал (`ui` / `api` / `mcp`)
  попадают в журнал `task_events`. Без флага агент изменяет и закрывает только задачи,
  помеченные «доступно для ИИ-агента», и закрывает их в режим приёмки; флаг он может
  поставить лишь при создании задачи.

### 3.11. Локальные AI-функции

- Автодетализация (3.2) выполняется **маленькой LLM, запущенной локально через Ollama**:
  - модель и параметры — **конфигурацией приложения**, без зашивки в код;
  - LLM предлагает метаданные задачи: теги (из справочника или новые на английском),
    градацию приоритета и оценку длительности — на языке исходного текста;
  - заголовок и описание LLM не переписывает (0.93): контекста задачи у неё нет, а
    сжатый текст теряет детали или меняет суть (3.2);
  - результат — черновые метаданные для утверждения (3.2), не окончательное решение.
- **Суммаризация проектов (0.40)**: заметка проекта может быть большой для
  маленькой модели, поэтому для контекста автодетализации LLM видит
  `projects.summary` — краткое резюме заметки (суть + полезные признаки для
  классификации задач). Суммаризация генерируется той же LLM фоном при создании
  проекта с заметкой и при правке заметки (кэш в БД); без суммаризации в контекст
  идёт обрезка заметки.
- **Иконка сайта проекта (favicon, 0.63)** — бэкенд-прокси с кэшем: фронт
  запрашивает `/api/projects/{id}/favicon`, бэкенд тянет иконку с сайта проекта
  и кэширует её на диске (7 дней; негативный результат «иконки нет» — 1 час,
  чтобы не долбить сайт). Показывается в метке проекта (карточка списка и шапка
  страницы проекта) и в чипе-ссылке на сайт (0.65). Сторонние favicon-сервисы
  **не используются**: адрес
  проекта не должен утекать третьей стороне (принцип «данные не покидают VPS»).
  Кандидаты: `<link rel="icon">` из `<head>` сайта, затем `/favicon.ico`,
  `/favicon.png`, `/apple-touch-icon.png`. Тип определяется по magic-bytes
  (PNG/JPEG/GIF/ICO/WebP); **SVG-иконки не принимаются** — скриптуемый формат
  (как запрещённые mime вложений). Ограничения запроса — в 4.
- **README репозитория проекта (0.67)** — такой же бэкенд-прокси с кэшем: фронт
  запрашивает `/api/projects/{id}/readme`, бэкенд читает `repository_url` проекта
  и тянет сырой README перебором известных путей (см. 3.3), кэшируя прочтённое
  на 10 минут (в том числе негативный результат — чтобы не долбить git-хост).
  Привязка к проекту, а не параметр `?url=`: открытым прокси для чтения
  произвольных адресов эндпоинт не становится. Ссылку на репозиторий фронт
  не передаёт — её знает только бэкенд. По той же причине, что и у фавикона,
  сторонние сервисы чтения README **не используются**.
- Все вызовы LLM — на собственном сервере, данные не покидают VPS.
- JSON-колонки хранят кириллицу без \u-эскейпов (0.40): MCP-агенты читают
  читаемый текст (ensure_ascii=False).
- Спекулярные LLM-сценарии вне метаданных — не в скоупе: большинство ИИ-задач выполняют внешние агенты через MCP (3.10).

### 3.12. Мультиязычность

- UI поддерживает три языка: **ru** (базовый/fallback), **en**, **uk**.
- Язык по умолчанию берётся из gnexus auth (`profile.locale` из SSO-профиля, scope `profile`).
- В «Настройках» возможно ручное переопределение языка; значение хранится в глобальных
  настройках приложения (`app_settings`, ключ `language`), пустое значение = следовать SSO.
- Даты, суммы и названия дней недели форматируются через Intl по активной локали.
- Переводится только собственный UI: gnexus-ui-kit текстов не содержит (тексты через props).

### 3.13. Геймификация

Маленькая дополнительная награда за выполнение задач — мотивация без негативных
последствий. Принцип **только позитив**: XP только начисляется, ничего не сгорает
и не отнимается; стрики/серии (которые «ломаются» при пропуске) не используются.

- **XP за закрытие задачи**: база 10 + бонус за приоритет (0–15) + бонус за
  объём по оценке времени (0–15). Начисление — один раз на задачу (идемпотентно);
  удаление задачи не отнимает заработанное. Хранение — таблица `xp_events`
  (поле `kind` разделяет события: `task` — закрытие, `create_task` /
  `create_project` — создание).
- **Награда за агентское закрытие** (0.85, 3.20): XP и монеты начисляются сразу при
  закрытии задачи агентом — как за любое закрытие; ожидание приёмки награду не
  откладывает, а возврат работы её не отзывает (принцип «только позитив»).
- **Микронаграды за создание** (без конфетти — скромный тост «+N XP • +M монет»):
  создание задачи — +2 XP и +2 монеты, создание проекта — +5 XP и +4 монеты
  (0.48: монеты ×2).
  В суммарный опыт (уровень) входят, растений в саду не рождают (сад отключён
  в 0.58 — выдача и так остановлена), счётчик
  закрытых задач не увеличивают; хранятся с `task_id = NULL` — удаление задачи
  или проекта награду не отнимает.
- **Ежедневная награда за визит**: при открытии приложения фронт запрашивает
  `POST /api/xp/daily` — раз в сутки (граница дня UTC) начисляется **50 монет**
  (только монеты, XP нет), повторные визиты в те же сутки молча пропускаются.
  Тост «+50 монет» только в день первого визита.
- **Уровень**: растёт из суммарного XP (уровень не падает); прогресс до следующего
  уровня и **название звания** показываются на странице «Достижения» (0.58; ранее —
  в саду). Звания ботанические (Росток, Сеянцев, Садовник, … Легенда сада —
  13 званий; порог уровня L = 100·L·(L−1)/2 XP). **Дерево званий** — модалка со
  списком всех уровней: звание и порог XP; пройденные отмечены, текущий подсвечен.
  В саду (код сохранён) та же модалка дополнительно показывает, что открывается
  на уровне — виды растений с ценой семени и расширения карты.
- **Момент награды**: при закрытии задачи — короткая конфетти-анимация и тост
  «+N XP • +N монет» с похвалой. Особые закрытия (крупная задача — от 25 XP,
  «не хочется делать») — усиленное «золотое» конфетти. Редкость закрытия
  (см. ниже) в 0.58 в UI не показывается, но по-прежнему пишется в `xp_events` —
  как история для возврата сада.
- **Страница «Достижения»** (`/achievements`, 0.58): весь видимый прогресс без
  сада. Бейджи шапки — уровень с названием звания, суммарный XP и **монеты**;
  полоса прогресса до следующего уровня; кнопка «Дерево званий»; справка
  «Как это работает»; **ачивки с тирами** (см. ниже) — тот же блок, что и в саду
  (общий компонент, логика не дублируется). Данные — `GET /api/tasks`,
  `GET /api/xp`, `GET /api/xp/events`; `GET /api/garden` **не вызывается** (это и
  есть механизм отключения выдачи растений, см. ниже). Страница слушает SSE
  `xp.changed` и `task.changed`. Пункт меню — между «Проектами» и «Статистикой»
  (место прежнего «Сада»), путь `/garden` редиректит на `/achievements`
  (старые ссылки и закладки PWA не ломаются).
- **Монеты**: начисляются при закрытии задачи — xp целиком (0.48: было
  `floor(xp / 2)`; заголовок `X-Earned-Coins` рядом с XP в тосте), за создание
  задачи/проекта и бонусом **50 × L** при достижении уровня L (0.48: было
  25 × L). Ежедневная награда не менялась (50 монет).
  Хранение — таблица `coin_events` (журнал заработка и трат; баланс =
  сумма amount). Траты — отрицательные события (`source = shop`); продажи нет
  («только позитив»). Баланс виден на «Достижениях» — `GET /api/xp` отдаёт поле
  `coins` (0.58). Пока сад отключён, тратить монеты негде: они копятся и
  пригодятся, когда сад вернётся.
- **Ачивки с тирами**: у каждой ачивки ступени I/II/III (например, 10/50/100
  задач), прогресс к следующей ступени виден всегда. Набор: вехи закрытий,
  уровни, макс. задач за день, разные проекты, суммарные часы, строгие
  дедлайны, закрытия через выдачу задачи (см. 3.9), регулярная, проект.
  Ачивки считаются на фронте из задач и `xp_events` и показываются на
  «Достижениях» (0.58) — общий блок с садом (`components/AchievementsPanel.vue`).
  **Описание по клику (0.49)**: карточка-ачивка — триггер GnPopover кита,
  клик открывает панель с кратким описанием условия (`progress.achHint`,
  i18n ×3; тултип по hover не годится — не работает на тач-экранах).
- **Связка с выдачей задачи** (3.9): закрытие задачи, открытой из выдачи, помечается
  (`earned_via`) и празднуется меткой «выбрал и сделал» + счётчик-ачивкой;
  XP при этом не меняется.
- **Страница «Статистика»**: закрываемость за выбранный **день / неделю / месяц**
  (период листается стрелками назад/вперёд). Метрики — закрытые задачи, XP,
  созданные задачи, часы работы, «дни с результатом» — каждая со сравнением
  с **предыдущим аналогичным периодом** (дельта и проценты). Анимированный
  **сравнительный** график закрытых задач по бакетам периода (часы / дни недели /
  дни месяца): текущий и предыдущий период — пары столбиков. **Сетка активности**
  «как в гитхабе» за полгода или год (переключатель диапазона; цвет по закрытым
  задачам или XP, переключается).
  Данные считаются из `xp_events` и дат задач, без новых эндпоинтов.
- **Метка «ментально сложная»**: у незакрытой задачи есть кнопка-переключатель
  **«Не хочется делать»** (warning-стиль) — она ставит метку «ментально сложная»:
  трудность не по объёму, а из-за требуемых волевых усилий. Метку можно снять до
  закрытия, после закрытия она не меняется. Закрытие такой задачи даёт **+10 XP**
  (сила воли) и всегда празднуется золотым конфетти; ачивка **«Преодоление»**
  (5/25/50 закрытых «ментально сложных») — на «Достижениях».
- **Справка «Как это работает»**: кнопка на «Достижениях» (и, при возврате сада,
  на странице сада) открывает модалку с объяснением механик (XP, уровни,
  монеты, ачивки, принцип «только позитив»); параграфы — `progress.about`.
- **Сад отключён (0.58)**: сад не готов к использованию и недостаточно проработан,
  а сервис расширяется — приоритет отдан основному функционалу. Страница убрана из
  интерфейса, `/garden` редиректит на `/achievements`, код сохранён для возврата
  (по прецеденту представления «дерево», 3.6). **Растения больше не выдаются**:
  `GardenItem` создаётся лениво — в `ensure_garden()`, которую зовёт только
  `GET /api/garden`; UI этот эндпоинт не вызывает, поэтому выдача прекращается
  сама. Модель `GardenItem`, журнал `coin_events` и накопленная редкость
  (`xp_events.rarity`) сохраняются — сад вернётся с прежним состоянием. Всё
  описанное ниже — **исторический блок** (как сад устроен), актуальны только
  XP, уровни, монеты и ачивки.
- **Сад** (отдельная страница): живая **сцена** — графическое представление сада
  с домиком в центре; вокруг сад на мелкой сетке (ячейка ~20px, привязка при
  перемещении, наложения разрешены). Каждая закрытая задача — растение в саду;
  сад только растёт, продавать и удалять растения нельзя. Растения и декорации
  можно перетаскивать (drag&drop). Рядом — уровень, **кошелёк** (баланс монет),
  **«последний росток»** (что и когда закрыто последним), **история по месяцам**
  (12 месяцев, столбики) и ачивки.
  - **Отрисовка сцены** — пиксель-арт на **PixiJS 8** (WebGL): `nearest`
    масштабирование без сглаживания, целочисленный «нативный пиксель» (матрицы
    16px × 2 = 32px на клетку). Все спрайты (тайлы травы/дикой земли, изгородь,
    домик, 13 видов растений × 3 стадии, декорации, кольца редкости, блёстки)
    генерируются из пиксель-матриц прямо в коде — внешних файлов-ассетов нет.
    Рендерер (`src/game/GardenRenderer.ts`) — обычный класс, **не** оборачивается
    во Vue-reactive (deep-прокси ломает внутренние identity-проверки Pixi);
    Pixi загружается динамическим импортом (отдельный чанк). Инвентарь, поповер
    и drag-ghost — DOM поверх канваса.
  - **Перемещение объектов (drag&drop UX)**: зона захвата объекта — клетка
    (с запасом), а не тонкая текстура — промахи исключены. Фидбек жеста:
    «взял в руку» (лёгкое увеличение + подъём тени), подсветка целевой клетки
    по валидности (зелёная — можно, красная — нельзя, зеркало правил бэка:
    растениям запрещён домик, за краем сада подсветка скрыта), «приземление» —
    плавная доводка к центру клетки. Бросок мимо сада или на запрещённую
    клетку возвращает объект на исходную позицию анимацией **без запроса**
    к API (валидность фронт проверяет сам; бэк остаётся источником истины).
- **Две награды: опыт и монеты.** (Исторический блок сада.) Опыт — «ранг
  садовника»: только растёт, ничего не открывает в магазине напрямую. Монеты —
  «валюта сада»: тратятся. Общий принцип: **контент открывается за опыт,
  покупается за монеты** — опыт (уровень) открывает виды растений и землю,
  монеты покупают рост растений, декорации и расширения карты.
- **Редкие растения**: редкость решается в момент закрытия по весу задачи —
  чем больше XP, тем выше шанс редкого; эпик-растения — только у крупных
  закрытий. Редкость хранится в `xp_events`, визуально — золото + бейдж.
- **Растения и виды**: растение рождается только от закрытой задачи (отпечаток
  работы), вид выбирается детерминированно из пула видов, открытых **уровнем на
  момент рождения** (premium-виды — только для rare/epic закрытий), и
  фиксируется за растением (`garden_items.item_key`) — открытые позже виды не
  перерисовывают старые растения. Растение привязано к событию XP (переживает
  удаление задачи).
- **Семена растений** (покупка видов за монеты): в маркете блок **«Семена
  растений»** — открытые уровнем виды продаются семенами (цены 30–1200 по
  «дороговизне» вида; 13 видов, открытие растянуто по уровням 1–13 —
  прогрессия рассчитана примерно на 300 закрытых задач при среднем
  ~26 XP за закрытие). Покупка кладёт растение-росток в **инвентарь** (как
  декорации); перетаскивание из инвентаря на свободную клетку сажает его.
  Размещённое семя обратно в инвентарь вернуть нельзя (сад только растёт);
  редкость — common, задача-родитель отсутствует. Доступность семени — по
  уровню вида (🔒 «откроется на уровне N»), независимо от premium-флага вида
  (вопрос о premium-видах в семенах — открыт, см. раздел 8).
- **Рост растений за монеты**: растение растёт стадиями росток → куст →
  цветение, каждая стадия покупается монетами (цена по редкости:
  common 20/50, rare 40/100, epic 80/200). Автороста по времени нет.
- **Маркет сада**: в модалке — **расширения карты** (первым блоком),
  **декорации**, **семена растений** и каталог видов (справка о редкости).
  У каждой покупки три состояния: 🔒 «откроется на уровне N» (если уровень
  не открывает), цена золотом (кнопка активна), цена серым + «не хватает
  N монет».
  - **Декорации** — чисто косметика: фонарь, лавочка, поилка, пруд, беседка,
    флюгер, скворечник, садовый самоцвет, теплица (уникальные), изгородь,
    клумба и куст (повторяемые). Двигаются как растения. **Изгородь —
    самосоединяющиеся секции**: текстура выбирается по маске соседей (N/E/S/W),
    секция соединяется только с соседними секциями изгороди. **Постройки
    в изометрии (домик, теплица, беседка) — крупная изометрия 2:1** (домик
    с двором, теплица со стеклянными стенами и каркасом, беседка с площадкой
    у входа). Дорожки убраны (не прижились — сложность рендера и соединений).
  - **Подпись выбранного объекта**: клик по любому объекту сада (растение
    или декорация) показывает у клетки чип с иконкой и названием
    («Скворечник», «Куст», «Подсолнух»); у растений одновременно открывается
    поповер роста. Повторный клик по объекту или клик по пустому месту
    снимает выбор.
  - **Инвентарь декораций**: покупка кладёт декорацию в **инвентарь** под картой
    (`garden_items.x/y = NULL`) — место покупки всегда видно пользователю. Из
    инвентаря декорация перетаскивается на карту; перетаскивание размещённой
    декорации обратно на полосу инвентаря убирает её с карты (растения в
    инвентарь вернуть нельзя — сад только растёт).
  - **Расширения карты** (0.37): стартовая сетка **24×12** ячеек — компактный
    дворик заполняется быстро, расширение ощущается событием. Первое расширение
    (ур. 3, 100 монет) возвращает размер прежнего старта 32×20, второе
    (ур. 12, 250 монет) — до 40×28; у существующих садов предметы, оказавшиеся
    за новой границей, при первом заходе переставляются по спирали к дому.

### 3.14. Реактивность: серверные события (SSE)

Данные меняются не только из UI: MCP-агенты (3.10) и фоновая автодетализация (3.2)
пишут в БД вне HTTP-запроса. Все виды обновляются событиями от сервера — без
обновления страницы и переходов между страницами.

- **Эндпоинт**: `GET /api/events` (авторизация штатная, 401 — на вход).
  Транспорт — **SSE** (`text/event-stream`), по одному соединению на вкладку
  (`EventSource`, авто-reconnect браузером). Keep-alive: при тишине дольше 20 с
  сервер шлёт кадр `ping`, чтобы прокси (Vite dev, nginx) не рвал поток.
- **Модель — грубозернистые события**: событие это `kind` + опциональные данные;
  фронт реагирует **refetch'ем** (перечитывает данные целиком), а не патчами.
  Трейлинг-debounce 300 мс коалесцирует бёрсты (закрытие задачи = 2–3 события).
  Виды подписываются на свои kind'ы: стек/список — `task.changed`,
  `task.deleted`, `detail.changed`, `project.changed`, `project.deleted`;
  статистика и «Достижения» — `xp.changed`, `task.changed`; сад (код сохранён,
  страница отключена в 0.58) — `garden.changed`, `xp.changed`, `task.changed`
  (рефetch подавляется на время drag по сцене); страница задачи —
  перечитывается при `task.changed`/`detail.changed` своего `id`.
  `garden.changed` продолжает публиковаться, но слушателей в UI больше нет —
  событие безвредно (0.58).
- **Точки публикации** (строго после коммита транзакции): HTTP-хендлеры задач,
  проектов, сада, XP-дейли и настроек; MCP-инструменты и фоновая детализация —
  broadcast (всем вкладкам). Список kind'ов: `task.changed {id}`,
  `task.deleted {id}`, `detail.changed {id}`, `project.changed {id}`,
  `project.deleted {id}`, `garden.changed {reason}`, `xp.changed {amount?,
  celebrate}`, `settings.changed`, `ready` (первый кадр), `ping`.
  С 0.85 — ещё два кадра о работе агента (3.20): `task.claimed {id, claimed_by}`
  (задачу взял агент — в интерфейсе появляется пометка «в работе у агента») и
  `task.review {id, title}` (агент закрыл задачу — владельцу приходит тост
  «Нави закрыла задачу: …», а задача встаёт в фильтр «Ждёт приёмки»).
  Упрощение: закрытие задачи через MCP не шлёт `garden.changed` — refetch по
  `task.changed` покрывает (сад отключён в 0.58, слушателей у события нет).
- **Второй транспорт — Web Push** (0.88, 3.21): те же события о работе агента
  (`task.claimed`, `task.review`) уходят системным уведомлением в закрытое приложение,
  а напоминания приходят только так. Шину это не заменяет: мгновенный refetch открытого
  интерфейса остаётся за SSE, а при видимой вкладке системное уведомление не показывается
  — иначе оно дублировало бы тост.
- **Дедуп XP-тостов**: действие своей вкладки празднуется по заголовкам ответа
  (`X-Earned-XP`, ТЗ 3.13); SSE-эхо и эхо соседней вкладки молчат — окно
  активности 3 с (localStorage). Соседние вкладки показывают скромный тост
  «+N XP» без конфетти.
- **Reconnect**: при обрыве — backoff 1→2→5→15 с; 401 при проверке сессии —
  редирект на вход; после переподключения — форс-рефреш всех открытых видов
  (данные за время разрыва могли измениться).

### 3.15. PWA

Приложение устанавливается на устройство как приложение (Chrome/Android; вопрос
оффлайн-режима данных — открытый вопрос 8.4, пока приложение всегда онлайн).

- **Манифест**: standalone, тёмная палитра кита (`#16161e` — фон и theme-color,
  чтобы не мигало белым при запуске и не зеленела шапка окна), иконки 192/512 +
  maskable-512, `id`/`start_url` = `/`, язык ru.
- **Иконки** (0.90; иконки расширения и «прозрачные углы у всех» — 0.96): растрируются
  из лого скриптом `tools/rasterize_icons.sh` — единственный источник, поэтому знак в
  шапке, иконка вкладки и иконка приложения не разъезжаются. У скругления холста
  логотипа (`rx` 10 = 15% стороны) **углы прозрачны у всех иконок без исключения**
  (решение владельца 2026-10-10): фон впечатывался ровно тот же, что заливка холста, и
  скругление сливалось с ней — иконка читалась квадратом с острыми углами. Замечено
  владельцем в шторке системных уведомлений (3.21), а затем и в панели браузера у
  иконок расширения. Знак во весь кадр идут `icon-192`/`icon-512` (они же `icon`
  уведомления, 0.90), `apple-touch-icon.png` (180 — отдельный файл с тем же знаком, а
  не 192 из «any»: iOS рисует прозрачность чёрным) и иконки расширения 16/32/48/128
  (панель браузера, страница расширений и магазин PNG не маскируют). У
  `maskable-512.png` скруглён во всю иконку кадр, а знак стоит в safe zone 80%: при
  обрезке по кругу Android срезает кадр, но не знак. Свои маски iOS и Android
  накладывают поверх и углы срезают сами — а там, где иконка показывается без маски
  (лаунчер, магазин, панель браузера), скруглённая читается лучше квадрата.
- **Service Worker** (vite-plugin-pwa, `autoUpdate`; с 0.88 — свой воркер `src/sw.ts`,
  стратегия `injectManifest`): precache оболочки — собранные ассеты, шрифты/иконки кита
  (~6 МБ лимит), `/api/*`, `/auth/*`, `/mcp/*` никогда не кэшируются и не получают
  fallback на `index.html`. Свой воркер понадобился ради обработчика `push` (3.21):
  сгенерированный workbox-воркер его не содержит, а вложить туда код нечем. Поэтому
  precache, SPA-fallback и CacheFirst вложений теперь описаны в самом `sw.ts`, а
  `sw.js` отдаётся без кэша.
- **Оффлайн-оболочка**: при отсутствии сети SW отдаёт оболочку приложения
  (SPA-fallback) — интерфейс открывается, данные при этом недоступны (запросы к
  API не кэшируются). Вложения и статические ассеты — CacheFirst.
- **Обновление**: новая версия подхватывается автоматически при следующем визите
  (`autoUpdate`); SSE-соединение при обновлении рвётся — reconnect покрывает (3.14).
- **Установка с телефона**: требует secure context — при доступе по LAN по HTTP
  (не localhost) браузер SW не зарегистрирует; нужен HTTPS (M7 Docker/nginx).
- **Dev-режим**: SW не включён (`devOptions` выключен) — PWA проверяется на прод-сборке
  (`dist`), не на dev-сервере; для отладки push в dev воркер поднимается явным флагом
  `VITE_PWA_DEV=1` (0.88, 3.21).
- **iOS Safari (0.47)**: типовые проблемы iPhone-версии устранены:
  - поля/селекты/поле тегов на touch-устройствах 16px (media
    `hover:none, pointer:coarse`) — иначе iOS зумит страницу при фокусе и зум
    застревает;
  - `100dvh` вместо `100vh` у drawer'ов, мобильного меню и модалок (низ уезжал
    под панель Safari и индикатор «домой»); на старых Safari остаётся `100vh`;
  - safe-area (viewport-fit=cover): тосты, контент drawer'а и топбар отступают
    от индикатора «домой» и статус-бара (в PWA контент идёт под них);
    `apple-mobile-web-app-*` меты: standalone для «На экран Домой» (до iOS 16.4
    манифест не читается), `black-translucent` статус-бар, короткий тайтл;
  - тап-подсветка WebKit отключена (у кита есть `:active`-состояния);
  - кнопка fullscreen сада скрыта, если API не поддерживается (iPhone Safari
    умеет fullscreen только для video). *(Сад отключён в 0.58 — правило
    историческое, вернётся вместе с садом.)*

### 3.16. Вебхуки системы авторизации (0.35, профиль 0.36)

- gnexus-auth асинхронно доставляет подписанные события на `POST /auth/webhook`
  (контракт — docs/04-events-and-webhooks.md в репозитории gnexus-auth): HMAC-SHA256
  заголовков `X-GNexus-*`, секрет — `GAUTH_WEBHOOK_SECRET` (создаётся/ротируется в
  админке SSO, `client.webhook/rotate-secret`); секрет не задан — приём выключен (503).
- Обрабатываемые события:
  - `user.email_changed`, `user.profile_updated` — синхронизация профиля
    `User` (email, name ← `profile.display_name`, locale, avatar_url) без
    повторного логина; ключа нет в `profile` — поле не менялось, не трогаем;
  - `user.blocked`, `user.archived`, `user.deleted` — отзыв всех MCP-токенов
    пользователя (агент теряет доступ вместе с аккаунтом).
- Профиль в БД — источник истины: `/auth/me` отдаёт свежие данные из БД,
  а не из cookie-сессии, поэтому изменения профиля от webhook видны сразу
  (без повторного логина).
- Незнакомые типы событий (включая `webhook.test` и будущие) подтверждаются 200
  без действий — чтобы SSO не гонял retry'и по незнакомому типу; при недействительной
  подписи/битом payload — 400.
- Ограничение: cookie-сессии gntodo stateless (подписанная кука), серверно их не
  отозвать — при блокировке/выходе доступ закрывается на стороне SSO; у gntodo
  webhook отзывает только MCP-токены.

### 3.17. Экран входа (0.38, карточка — 0.55)

- Отдельный маршрут `/login` вне навигационной оболочки: **карточка кита**
  (`GnCard`, 460px) с логотипом, названием и единственной кнопкой «Войти» —
  инициация SSO-флоу (`/auth/login` с `return_to`). Пока `/auth/me` не ответил,
  в карточке только лого и лоадер — кнопка не вспыхивает перед редиректом уже
  залогиненного; тот же вид, что на экране входа gnexus-creds.
- Не залогиненный пользователь направляется на экран входа с любого маршрута
  (`/auth/me` → 401); после логина возвращается на исходный адрес. Истёкшая
  сессия в API/SSE-коде тоже ведёт на `/login`, а не на бэкендовый SSO-флоу.
- `/auth/logout` после очистки сессии редиректит на `/login` (не оставляет
  пользователя на JSON-ответе).
- Оффлайн-исключение (ТЗ 3.15): сетевая ошибка `/auth/me` не считается «не
  залогинен» — оффлайн-оболочка открывается без редиректа.


### 3.18. Браузерное расширение быстрого захвата (0.42)

Расширение для Chrome/Firefox (MV3): клик по иконке в трее браузера → попап с двумя
вкладками — «Добавить» и «В работе». Быстрый захват входящих (3.1) вне страницы
сервиса.

- **Вкладка «Добавить»**: многострочное поле (первая строка — название,
  остальное — описание), Ctrl+Enter или кнопка; опциональный выбор проекта
  (создание = POST `/api/tasks` + PATCH `project_id`, как в веб-форме). Фидбек —
  анимация-галочка и тост «Задача добавлена (+N XP)»; поле очищается, выбранный
  проект сохраняется; кнопка «Открыть GNexus Tasks» ведёт на главную.
- **Вкладка «В работе» — только просмотр** (0.44: вместо «Активных» со всеми
  незакрытыми статусами): задачи в работе (`in_progress`; название, чип проекта,
  метка просрочки, теги); клик открывает задачу в сервисе, действий в попапе нет.
- **Аутентификация — Bearer MCP-токен** (3.10): сессионная кука сервиса не уходит
  запросам из extension-контекста (SameSite=Lax режет cross-site), поэтому попап
  ходит на REST `/api/*` с `Authorization: Bearer <MCP-токен>`. Токен вставляется
  один раз из «Настроек → Токены MCP», хранится в `chrome.storage.local`; отзыв
  токена отключает расширение (как и агентов, 3.10).
- **Базовый URL настраиваемый**: по умолчанию `http://localhost:15134`; произвольный
  адрес задаётся в настройках попапа и запрашивает host-разрешение
  (`optional_host_permissions` + `chrome.permissions.request`).
- **Черновик форм сохраняется** (0.51): попап — транзиентное окно и закрывается
  при потере фокуса (клик мимо, даже переключение раскладки Win+Space) — API
  «не закрывать» у Chromium нет. Поэтому всё заполненное (текст, выбранный
  проект, вкладка, настройка адреса/токена) пишется в `chrome.storage.local` и
  восстанавливается при следующем открытии; после успешной отправки текст
  очищается (как в веб-форме).
- **Импорт из BugTrail работает и здесь** (0.92, см. 3.22): ссылка на багрепорт,
  вставленная в пустое поле захвата, разворачивается в «первую строку — название,
  остальное — описание» — тот же формат, что и при ручном вводе (3.18). Багрепорт
  ловится в браузере, поэтому этот путь даже вероятнее веб-формы.
- Сборка — vite + Vue 3 + **gnexus-ui-kit** (тот же кит, что веб-интерфейс);
  язык интерфейса — русский.
- **Страница-инструкция в меню веб-версии** (`/extension`): скачивание ZIP-сборки
  расширения (раздаётся статикой фронта; в Docker-образе фронта собирается из
  `extension/`), шаги загрузки unpacked и подключения токена. Из установленного
  PWA пункт меню скрыт (`display-mode: standalone`) — по прямому адресу страница
  показывает пояснение.


### 3.19. Визуальный стиль и кастомные компоненты (0.50)

- Веб-интерфейс использует **gnexus-ui-kit 1.0.0**: кит задаёт системе визуальный
  язык (панели/карточки — радиус 6px и бордер 2px `--gn-border-color-muted`,
  мелкие контролы/инсеты — 3px; темы Verdant/Ember/Mono и дизайн-токены `--gn-*`
  доступны, но не используются пока в gntodo).
- **Полосы прокрутки — с китовыми цветами, но скруглённые** (0.76, пересмотрено
  в 0.79): свою полосу кит рисует только меню-дроверу (`.nav-drawer-body`: тонкая,
  бегунок акцентом `#7aa2f7` по фону панели), всё остальное берёт глобальное
  правило кита: 10px, дорожка `#16161e`, бегунок `#414868`, кнопок нет. В 0.76
  полосы были переведены на «китовый язык» целиком — тонкие, с акцентным бегунком
  и прозрачной дорожкой; правка отменена: акцентный бегунок читался поверх шапки,
  а прозрачная дорожка снимала с полосы её служебный вид. Теперь **толщина и цвета
  китовые, добавлено только скругление бегунка**. `scrollbar-width` и
  `scrollbar-color` намеренно не задаются: как только они выставлены, Chromium
  игнорирует `::-webkit-scrollbar-*` целиком, и скругление пропадает; у
  `.tabs-list` и `.nav-drawer-body` они стоят у кита — там полосы остаются как
  были.
- **Подвал карточки задачи — из слота `footer` кита** (0.76): кит рендерит его как
  `<footer class="card-footer">` с одними отступами, а раскладку, размер шрифта и
  цвет (flex / 13px / `#a9b1d6`) задаёт только горизонтальному варианту карточки —
  вертикальному их даём сами. Содержимое карточки кит тянет на `height:100%`: с
  подвалом оно вытолкнуло бы его за карточку (в сетке списка у карточки
  `height:100%`, а `overflow:hidden` кита в приложении снят), поэтому карточка
  задачи раскладывается колонкой, а остаток высоты отдаётся содержимому — подвал
  прижат к низу и у карточек одного ряда стоит на одной линии.
- **Страница по горизонтали не едет на узком экране** (0.78): на 390px карточка
  задачи уезжала вбок — `documentElement.scrollWidth` был 533 при вьюпорте 390.
  Причина в сетке страницы задачи: мобильное правило задавало
  `grid-template-columns: 1fr`, а голый `1fr` — это `minmax(auto, 1fr)`, и трек не
  сжимается ниже `min-content` содержимого (517px в коробке 358px). Теперь трек —
  `minmax(0, 1fr)`, а колонки-элементы получили `min-width: 0`. Вторая причина
  того же симптома — длинные неразрывные токены в описании (URL, инлайновый
  `code` вида `GAUTH_REDIRECT_URI=https://…`): абзац не помещался в узкую карточку,
  и `md-view` со `overflow-x: auto` честно скроллил его по горизонтали (коробка
  324px, содержимое 483px). Теперь переносим токен по символам
  (`overflow-wrap: break-word` — именно `break-word`, а не `anywhere`: intrinsic-
  размеры не меняются, поэтому таблицы и фенсед-блоки по-прежнему прокручиваются,
  а не сжимаются; `pre` с `white-space: pre` перенос не подхватывает). На широком
  экране переносить нечего — десктопная раскладка не изменилась.
- **Табы статуса в «Списке задач» оторваны от списка** (0.78): отступ снизу под
  табами был 0.6rem, и табы липли к первой карточке; теперь 1.25rem — тот же шаг,
  что у сетки страницы задачи. Прокрутка табов по горизонтали на узком экране
  осталась (0.70).
- **Выпадающие меню на мобильных — шторка снизу** (0.77): кит позиционирует
  `.dropdown-menu` от левого края триггера (`position:absolute; left:0`,
  `min-width:220px`), поэтому у триггеров у правого края экрана меню уезжало за
  вьюпорт, и приложение выравнивало координаты инлайном уже после открытия — на
  телефоне это давало постоянные «съехавшие» меню. До 767px меню вместо
  выпадашки становится **шторкой снизу**: панель во всю ширину экрана, прижатая к
  низу, со скруглением верхних углов 12px, «ручкой»-грабером, пунктами высотой
  44px (китовые 34px мелковаты под палец) и прокруткой при длинном списке;
  появление — выездом снизу (0.22s), а не китовым `overlay_reveal`. Экран под
  шторкой затемняется (слой `#16161e` с прозрачностью 0.72), тап по затемнению
  закрывает меню и не нажимает то, что под ним. Затемняющий слой лежит вне `#app`
  (в `index.html`) и показывается по `:has(.dropdown.is-open)`: для китового
  обработчика клик по нему — «вне меню», поэтому закрытие работает **без JS**, а
  выравнивание координат на мобильных выключено за ненадобностью. Китовый
  `panel_boot` оставляет у шапки страницы `transform`, а это содержащий блок для
  `position:fixed` — на мобильных анимация шапки снята, иначе шторка из шапки
  раскладывалась бы по коробке шапки, а не по экрану; правило «меню в шапке
  прижато вправо» тоже оставлено десктопу, на мобильных оно подрезало бы шторку
  справа. Правка задумана как временная: **та же задача оформлена для
  gnexus-ui-kit** (там её место — в самом `GnDropdown`), при переходе на
  реализацию кита приложение удаляет свой CSS-блок, слой в `index.html` и
  мобильную ветку в `dropdownClamp.ts`.
- **Дропдаун из шапки страницы задачи на мобильных** (0.79): шапка страницы
  (`.app-content .page-header`) держала `position: relative; z-index: 5` — правило
  нужно десктопу (китовый `panel_boot` оставляет `transform`, и без `z-index`
  карточки рисуются поверх выпадашки), но `z-index` создаёт контекст наложения.
  Внутри него заперта `fixed`-шторка: слой затемнения (вне `#app`, `z-index` 1090)
  ложился **поверх** шторки и перехватывал тапы — меню из шапки на странице задачи
  открывалось затемнённым, а клик по пункту лишь закрывал его. Теперь `position:
  relative` и `z-index` включены только с 768px; на мобильных шапка контекста не
  создаёт, шторка попадает в корневой контекст и лежит выше затемнения.
- **Форма создания задачи на мобильных закрывается сама** (0.86): после успешного
  создания задача форма закрывается — и в глобальной форме из «+» в топбаре, и в
  форме новой задачи проекта, и в форме подзадачи. На десктопе она, наоборот,
  остаётся открытой: там удобно набивать несколько задач подряд, не открывая форму
  заново. Правило применяется только к **созданию**: при правке существующей задачи
  форма закрывается всегда (после сохранения смотреть в ней больше нечего).
  Порог мобильного — тот же 767px, что у шторки меню (0.77), и объявлен в одном месте
  (`frontend/src/viewport.ts`), чтобы поведения на одной ширине не разошлись.
  Быстрый захват строкой в стеке (3.1) не форма: он очищает поле и остаётся на месте.
- **Меню у правого края экрана** (0.79): выравнивание координат (`dropdownClamp.ts`)
  ставило меню инлайновый `right: 0`, но китовый `.dropdown-menu{left: 0}` при этом
  продолжал действовать — оба края заданы, и при `min-width: 220px` меню оставалось
  у левого края триггера: у правой колонки карточек (три колонки на 1440px) оно
  выезжало за вьюпорт на всю свою ширину (right 1593 при 1440). Теперь при
  развороте влево выставляется и `left: auto`.
- **«⋯» в шапке страницы на узком экране** (0.80): действия шапки у кита
  переносятся на свою строку и встают у её левого края — на странице задачи меню
  действий оказывалось под бейджами слева. До 640px строка действий растягивается
  на всю ширину и прижимается вправо, как «⋯» в карточке. Иконка приведена к общей:
  `ph-dots-three-outline` — как в карточках задач, стека и на странице проекта
  (на странице задачи стоял залитый `ph-dots-three`).
- **Полоса прокрутки у табов статуса спрятана** (0.80): ряд прокручивается по
  горизонтали (у кита `.tabs-list{overflow-x:auto}`), но видимая полоса тут только
  шумит — обрезанный шестой таб и так читается как «дальше есть». Прокрутка
  сохранена, спрятана только полоса (`scrollbar-width: none` + `::-webkit-scrollbar`
  для Safari).
- **Переключатель в ряду фильтров сдвинут на 3px вправо** (0.80): у кита бегунок —
  квадрат 18×18 в `left: -5px` от коробки контрола, поэтому видимый левый край
  свитча выходил на 3–5px левее соседей по ряду. Сдвиг — компенсация выступа, а не
  отступ между элементами.
- **Подвал карточки проекта — та же полоса, что у карточки задачи** (0.81): у
  карточки проекта подвал был обычной строкой внутри содержимого — без черты-
  разделителя, тогда как у карточки задачи с 0.76 это китовый слот `footer` с чертой
  во всю ширину. Теперь подвал проекта тоже в слоте `footer`: содержимое забирает
  свободную высоту карточки, подвал ложится вниз и отделён чертой во всю ширину,
  слева свежесть, справа срок — как в карточке задачи. Сама полоса (черта, раскладка,
  мягкий текст мельче основного, высота пустой строки) стала **общей** для обеих
  карточек и живёт в `kit-overrides.css`: правила были одни и те же в двух местах.
  Пустой подвал (проект без задач и без срока) держит высоту строки — иначе черта у
  соседей по ряду встала бы на разной высоте. Скелетон списка повторяет эту полосу,
  чтобы загрузка не дёргала раскладку.
- **Строка подвала карточки стоит по центру полосы** (0.81): у кита `.card-footer` —
  8px сверху против 15px снизу, поэтому «2 недели назад» висело на 3.5px выше середины
  полосы. Отступы выровнены (12px сверху и снизу), полоса от этого выше прежней на
  пиксель.
- **Иконка в подвале карточки: китовая поправка снята** (0.83): китовое
  `.ph.normalize` (`position:relative; top:.15em`) рассчитано на иконку **на общей
  базовой линии** с текстом — там оно опускает глиф из 0.5em над базовой линией
  (середина ink иконочного шрифта) до середины прописных, 0.36em, и это верно. Но
  строка подвала — флекс с `align-items:center`: флекс-элемент коробкой в базовой
  линии не участвует, а у иконочного шрифта нет нижнего выноса
  (`fontBoundingBoxDescent = 0`), поэтому коробка иконки центрируется в строке и её
  низ ложится на 0.125em **ниже** базовой линии текста. Поправка кита складывалась с
  этим центрированием, и глиф уезжал вниз на 0.135em ≈ 1.6px: низ глифа уходил под
  базовую линию, иконка читалась провалившейся. Замер по пикселям на одной и той же
  строке («2 недели назад»): центр глифа на 1.3px ниже центра цифры «2». В подвале
  карточки (свежесть, «без активности», отсчёт срока) поправка снята — `top: 0` в
  `kit-overrides.css`: расчёт даёт 0.375em против 0.36em, то есть 0.2px, и низ глифа
  садится на базовую линию, как у буквы. Класс `normalize` в разметке остаётся —
  у кита он один на все иконки в тексте, а правка кита заказана отдельной задачей
  в проекте GNtodo (иконке в флекс-строке поправка не нужна вовсе: предлагается
  `vertical-align: -0.15em` вместо `position:relative; top`, либо отдельный класс
  без поправки).
- **Иконка в подвале карточки проекта выровнена по тексту** (0.81): у свежести и у
  отсчёта срока иконка шла без китового класса `normalize`, глиф иконочного шрифта
  садился выше середины строки и читался подвешенным — как в карточке задачи, где
  этот класс стоит с 0.76.
- **Карточки проекта и задачи различаются корешком** (0.82): карточки двух сущностей
  выглядели одинаково — та же панель с рамкой 2px, тот же подвал, — и в списке не
  читалось, что перед тобой папка с задачами, а не сама задача. Теперь у **карточки
  проекта левая грань 6px** (китовый `--gn-border-width-accent`, тот же шаг, что у
  полосы шапки страницы) и **цвета проекта** — того же, что метка у имени и заливка
  прогресса; у проекта без цвета грань берёт акцент кита, как и его прогресс. У
  карточки задачи рамка осталась ровной 2px, у задачи без изменений. Тот же корешок
  у карточки проекта в архиве и у заготовок списков (иначе при загрузке карточка
  дёрнула бы ширину содержимого). Наведение подкрашивает рамку, но **не корешок**:
  цвет грани — принадлежность проекту, а не состояние карточки.
- **Пустой подвал карточки проекта говорит «без активности»** (0.82): у проекта без
  задач активности по задачам нет, и подвал оставался пустой полосой — читалось как
  недоделка. Теперь на месте свежести стоит «без активности» (ключ `projects.noActivity`).
- **Полоса прокрутки спрятана у всех табов** (0.82): правило было только у табов-
  фильтра «Списка задач», поэтому на странице проекта табы статуса оставались с
  полосой; теперь оно общее для `.tabs-list` в `kit-overrides.css`. Прокрутка
  сохранена (0.80).
- **Модалка выбора проекта при переносе** (0.82): собрана на публичных `GnModal` +
  `GnSelect` кита, с подписью, какая именно задача переезжает. Акцент отдан действию
  («Переместить»), а «Отмена» осталась контурной: в прочих диалогах приложения действие
  красится в `danger`/`success`, а `primary` берёт на себя отмена — у переноса своего
  смыслового варианта нет.
- **Кнопка «назад» над шапкой страницы** (0.70): выйти с любой страницы можно было
  только через меню разделов, возврата в приложении не было. Теперь над шапкой
  страницы, на каждой странице, стоит **стрелка назад** — но только если в истории
  приложения есть куда возвращаться (переход вперёд внутри приложения; при заходе по
  прямой ссылке в новой вкладке истории нет — кнопка не показывается). Возврат на
  экран входа смысла не имеет и кнопкой не предлагается. Возврат идёт по истории
  приложения, а не по иерархии разделов. Размер кнопки — **дефолтный кита
  (34px), не уменьшенный** (0.71): кнопка нажимается часто, 28px в ней читались
  как второстепенная мелочь.
- Кастомные (сырые) блоки интерфейса оформлены в том же стиле, что и компоненты
  кита — те же бордеры/радиусы: дерево званий и ачивки на «Достижениях», график
  и heatmap статистики, блок «свежий токен» настроек, полнотекстовые превью
  и миниатюры картинок, tags-fieldset формы задачи; при возврате сада — маркет,
  дерево уровней с unlocks и оверлеи сцены (кнопки зума, подписи объектов,
  инвентарь).
- Смысловые состояния сырых блоков подчёркиваются подкрашенным бордером и лёгкой
  подложкой в цвет состояния (текущий уровень, разблокированные ачивки) —
  без `!important`-войн с китом: только каскадное переопределение поверх
  публичных классов.
- Иконки в заголовках карточек — в цвете текста заголовка (раньше затемнялись).
- **Логотип и иконки — знак «стек входящих» на холсте** (0.54, холст — 0.56,
  размер знака — 0.57):
  лого — три строки задач (синий маркер — задача в работе, зелёный — закрытая,
  остальные строки приглушены) на холсте 64×64 со скруглёнными краями (rx 10,
  заливка `--gn-surface-page`) — приём логотипа gnexus-creds: знак читается и как
  тёмная плитка на светлой панели вкладок, и на тёмных поверхностях кита. Знак
  занимает ~56% холста (в сетке 64 — 36×35, поля ~14): в 0.56 знак стоял в прежней
  сетке (62.5%), в 0.57 уменьшен на 10% — `scale(.9)` вокруг центра знака
  (32, 32.5) в `logo.svg`; пропорции строк не меняются.
  `logo.svg` (шапка, экран входа, попап расширения) и favicon `icon.svg` — один и
  тот же файл (тоже как у creds). PWA: icon-192/512 (any) и maskable-512 — холст
  80% кадра (safe zone адаптивных иконок Android). PNG расширения 16/32/48/128 и
  все PWA-иконки растеризуются из `logo.svg` скриптом `tools/rasterize_icons.sh` —
  что там с фоном и углами у каждой, см. «Иконки» в этом разделе. До 0.54 знак был
  в рамке-чипе,
  в 0.54 рамку сняли с подрезкой `viewBox` по глифу — знак занимал всю отведённую
  коробку, но в шапке и на экране входа выглядел «голым»; с 0.56 знак подан на
  холсте. Попап расширения показывает лого из своего `public/` (у упакованного
  расширения свой корень — ссылка на фронт не работает), поэтому с 0.59 копию
  обновляет тот же `tools/rasterize_icons.sh`: одна команда на правку логотипа.
- **Заголовок-ссылка в карточке — не классом `card-title`** (0.59): `card-title` —
  имя шапки карточки в ките (`header.card-title`, у него `padding: 15px 15px 0`);
  тот же класс на вложенной ссылке давал вторые 15px, и текст заголовка уезжал
  вправо от остального содержимого. Карточка задачи (`TaskCard` — список, выдача и
  стек с 0.95) носит ссылку классом `.title`, карточка проекта в архиве — `card-link`.
- **Селектор цвета проекта — сырой блок в стиле кита** (0.63): в ките компонента
  выбора цвета нет (проверено: `GnChip`/`GnChipGroup`/`GnPopover`/`GnDropdown`/
  `GnRadioGroup`/`GnRange` цвета не принимают), а `GnPopover` оборачивает
  содержимое в `<p class="popover-text">` — сетку свотчей туда не положить. Панель
  выбора — свой блок с поверхностью/рамкой/радиусом кита (как `.popover-panel`),
  свотчи — отдельные кнопки с `aria-pressed`/`aria-label` и тонкой внутренней
  обводкой (0.72: без неё тёмные ступени сливаются с поверхностью панели);
  закрытие по клику вне и Escape. Палитра (27 оттенков) строится детерминированно
  из 8 базовых тонов: шесть — цвета кита (`error` `#f7768e`, `accent` `#ff9e64`,
  `success` `#9ece6a`, `info` `#bb9af7`, `secondary` `#7aa2f7` и циан `#2ac3de`
  из палитры темы — своего токена у него нет), два — свои (жёлтый 0.69,
  коричневый 0.72: таких тонов в ките нет). От базового тона — светлая и тёмная
  ступени, посчитанные от его светлоты, — «базовый, светлый, тёмный». Ступень,
  которую зажал предел светлоты (36..88%), в сетку не берётся, поэтому ряды
  коричневого и сиреневого короче; тёмная ступень берётся с насыщенностью ×0.8: при низкой
  светлоте полная насыщенность читается на тёмной подложке как неон. Приглушённых
  вариантов (половинная насыщенность) и отсева «почти одинаковых» клеток по каналам
  (0.63..0.71, сетка 100 клеток рядами по 12..13) больше нет: в 0.72 ступень
  отведена от базового не меньше чем на 14% светлоты, а любые две клетки сетки —
  на ΔE 12.5 по CIELAB, и сетка сократилась почти вчетверо (100 → 27).
  Нейтральный ряд (0.69) выведен не из базового цвета — у серого
  оттенка нет, поэтому ступени заданы своей шкалой светлоты (в 0.72 — 5 ступеней
  через 16%, от `#ffffff` до `#5c5c5c`) и рисуются равными каналами, минуя общий
  зажим светлоты цветных рядов.
- **Ссылка-чип и крупный тайтл карточки** (0.65): в ките бейдж — `span`, ссылки
  среди бейджей нет, поэтому чип сайта — сырой `<a>` в геометрии бейджа кита
  (24px/радиус 3px/13px/600), капс не берём. Тайтл карточки крупнее: размер
  поднимается правилом `:deep(.card-title)` — в ките `.card .card-title` размера
  шрифта не задаёт (14px приходят наследованием от `body`), поэтому правило ничего
  не «перебивает». Метка проекта (кружок + фавикон) — свой компонент
  `ProjectMark`; размеры в `em` от кегля имени: в карточке база — тайтл (16px),
  в шапке страницы задаётся 18px. Кружок внутри метки крупнее одиночного
  (`ProjectColorDot` в бейджах задач и дашборда оставлен прежним) — селектор
  потомка бьёт правило дочернего компонента без `!important`. Кольца у фавикона
  нет (тонкая обводка `--gn-border-color-muted` снята в 0.65 по просьбе
  пользователя): край иконки и без неё читается на тёмном фоне.
  Бейдж проекта — **ссылка** (0.75): сам бейдж остаётся китовым `span`, клик и
  подсветку даёт `RouterLink` вокруг него (вложенная в `<span>` ссылка валидна).
  `.badge` в scoped-стилях матчится без `:deep` — это корень дочернего `GnBadge`,
  он несёт scope-атрибут родителя.
- **Кликабельная карточка задачи** (0.75): обёрткой-ссылкой её не сделать — внутри
  уже есть ссылка-заголовок, бейдж-ссылка проекта и «⋯»-меню, а вложенные ссылки
  невалидны. Поэтому `@click` висит на корне `GnCard` (кит раскрывает attrs на
  `<article class="card">`), а обработчик пропускает клики, у которых
  `event.target.closest('a, button, input, select, textarea, label')` что-то нашёл, и
  клики по выделенному тексту (`window.getSelection()` непустой — иначе сниппет не
  скопировать).
- **Цвет-метка — общий компонент-кружок** (0.63): кружок рисуется одним компонентом
  (`width/height: .65em`, `border-radius: 50%`) в четырёх местах (карточка списка,
  шапка проекта, бейдж проекта у задачи, строки проектов дашборда) — иначе стиль
  дублировался бы по файлам. Нет цвета — нет и кружка. Отступ справа 0.25em
  (0.73): кружок всегда стоит вплотную к имени проекта — в бейдже задачи он
  прилипал к нему. Метка `ProjectMark` этот отступ снимает (там зазор задаёт
  `flex-gap`), поэтому интервал перед фавиконом не удваивается.
- **Клетки прогресса — поверх полосы кита** (0.64): сегментированной полосы в ките
  нет (`GnProgress` — одна заливка, `GnProgressStages` — сетка этапов), поэтому
  карточка проекта рисует клетки насечками поверх трека (`::after` трека с
  `repeating-linear-gradient`, шаг `100%/M`), а сама полоса остаётся `GnProgress` —
  с ролью `progressbar`, `aria-valuenow/max` и анимацией ширины. Переменные
  оформления (`--card-color`, `--task-cells`, `--done-cells`) вешаются на корень
  карточки, а не на полосу: кит в `GnProgress` ставит свой `style` после `...attrs`,
  и переданный извне `style` теряется.


### 3.20. Мандат ИИ-агента: доступность, взятие, приёмка, журнал (0.85)

Агент ходит персональным токеном владельца (3.10), поэтому до 0.85 его действия были
неотличимы от пользовательских, а взять задачу в работу он мог только «вслепую».
Раздел вводит три вещи: **мандат** (какие задачи агенту можно), **взятие** (чтобы двое не
делали одну работу) и **журнал** (кто, что и когда — навсегда в БД).

**1. Доступность задачи.** У задачи есть флаг `ai_eligible` — «доступно для ИИ-агента».

- Ставит **владелец** — чекбокс в форме задачи и правкой существующей; по умолчанию
  **нет**.
- Агент может поставить флаг только **при создании** задачи (своей): иначе правило «без
  флага не трогать» обходилось бы вызовом «пометил — закрыл».
- Задачи, созданные агентом, помечаются в самой задаче (`created_by_kind = agent`,
  `created_by_name` — label токена): в интерфейсе у них бейдж, а список по этому признаку
  фильтруется (иначе признак пришлось бы вычислять join'ом).
- Регулярная задача наследует флаг от предыдущего экземпляра (3.5).
- Оценки «по силам ли задача агенту» система не делает: это решение владельца, и только
  его.

**2. Взятие в работу (аренда).** Задачу берут не «в уме», а явно — `claim_task`.

- Взять можно только задачу с флагом, в статусе `to_do` и со свободной арендой; аренда
  выдаётся **на 2 часа**, повторный вызов того же агента её продлевает (heartbeat).
- Живая чужая аренда блокирует не только взятие, но и **изменение** задачи агентом: двое
  не сделают одну работу и не закроют задачу друг за другом. Владельцу аренда не мешает —
  его действие её снимает.
- Протухшая аренда (агент пропал) считается свободной сама, по времени — без фоновых
  задач и крона. Выход задачи из `to_do`/`in_progress`, закрытие и удаление снимают аренду.
- Агент, которому задача не по зубам, возвращает её: `release_task` с причиной.

**3. Приёмка.** Работа агента не становится окончательной сама.

- Закрывая задачу, агент **обязан** приложить комментарий — что сделано; закрытие без
  комментария инструмент отклоняет. Владельцу комментарий необязателен.
- Закрытая агентом задача помечается «ждёт приёмки» (`accept_state = pending`) и попадает
  в одноимённый фильтр списка задач, а на странице задачи появляются «Принять» и «Вернуть
  в работу».
- Владелец либо **принимает** работу (`accepted`), либо **возвращает** её (`rejected` с
  причиной): задача снова в `to_do`, аренда снята. XP и монеты не отзываются (3.13) —
  принцип «только позитив»; второй награды за ту же задачу не будет (идемпотентность по
  задаче, 8.7), как и второго экземпляра регулярной.
- Приёмка — **отдельная плоскость**, а не статус (3.4): набор статусов не меняется, `done`
  остаётся «сделано» для статистики, теплокарты и спавна регулярной. Владелец, закрывший
  задачу сам, приёмкой не занимается — его закрытие снимает пометку.
- Принимать и возвращать может только владелец; агенту инструмент отказывает.

**4. Журнал** (`task_events`). Каждое действие по задаче оставляет запись: **кто**
(`actor_kind` — `user` | `agent`; `actor_name` — имя владельца или label токена), **что**
(`kind`: создание, смена статуса, смена доступности, взятие, отпуск, закрытие, приёмка,
возврат, удаление) и **когда**; канал — `ui` | `api` | `mcp`. Комментарий закрытия и
причина возврата живут в записи.

- Страница **«Журнал»** (`/journal`, отдельный пункт меню) — таблица записей с фильтрами по
  типу события и актору, с пагинацией. Это первый пагинированный список на
  бэкенде: `GET /api/task-events` отвечает конвертом `{ items, total, page, per_page }`
  (потолок `per_page` — 100). Фильтр «Созданные агентом» (3.6) и вкладка «Ждёт приёмки»
  живут в списке задач.
- Доработка 0.94 — журнал читается с телефона. **Фильтра по проекту нет**: проектов у
  владельца будут сотни, и выпадающий список из них нужный не находит — разрез по
  проектам живёт в разделе «Проекты» (3.8), как и в общем списке задач (0.73).
  **Комментарий показывается иконкой и только там, где он есть**: записей без
  комментария в журнале большинство, и колонка пустых ячеек съедала половину ширины
  таблицы, а текст автора в ячейке не влезал и рвался по строкам — целиком он
  открывается модалкой (в ней же стоит вид записи и заголовок задачи: диалог
  открывается из строки, под ним остаётся весь журнал). Заголовок колонки — та же
  иконка, слово «Комментарий» шире самой колонки. На узком экране дата и время встают
  **в две строки**, колонки «Проект» и «Актор» уходят, а актор возвращается строкой под
  бейджем события: ради актора журнал и существует, но своей колонкой он оставлял
  половину ширины пустой. На 390px таблица не едет по горизонтали.
- Записи переживают задачу: `task_id` при удалении обнуляется, но заголовок сохранён
  снапшотом — журнал читается как история, а не как список живых задач.
- О работе агента узнают не только из журнала: взятие и закрытие публикуются по SSE
  (3.14) — пометка в интерфейсе и тост владельцу.

**5. Передача задачи агенту вручную** (0.87). Агент находит задачи сам, но владельцу
часто нужно отдать конкретную: на странице задачи в меню «⋯» есть пункт **«Промпт для
ИИ»** (подпись короткая намеренно — в китовом меню 220px длинная рвётся на две строки,
как в 0.83; само окно называется «Промпт для ИИ-агента»). Он собирает текст задачи —
id, заголовок, статус, тип, приоритет, оценку и факт, деньги, срок, проект, теги,
родителя, пометку доступности, живую аренду, описание, черновое предложение
детализации, подзадачи и ссылку на страницу — и показывает его в окне, где текст можно
поправить; копирует его публичная кнопка кита (`GnCopyButton`), так что в буфер
попадает ровно то, что в окне.

- Содержимое — **только факты о задаче**: что с ней делать, владелец пишет сам в
  своём чате с агентом. Кнопка не диктует агенту ни протокол работы, ни объём.
- Текст собирается **по открытию окна**, а не один раз при загрузке страницы: пока
  окно открыто, задача успевает измениться (SSE), а в буфере должен оказаться её
  текущий вид.
- Подписи полей — из локали, значения — теми же форматтерами, что в интерфейсе
  (статус, приоритет, время, деньги, дедлайн), поэтому промпт и страница не расходятся.

**Актор действия.** У изменяющих MCP-тулов есть флаг `is_user`: по умолчанию действие
считается **агентским**, `is_user=true` — «действую от имени владельца» (на REST тот же
смысл у заголовка `X-Actor: user`, им объявляет себя браузерное расширение). В журнале у
такого действия актор — владелец, но канал остаётся `mcp`/`api`: видно, что действие
заявлено от его имени и пришло от агента.

**Это не охрана.** `ai_eligible` — не контроль доступа, а рамка делегирования. Держатель
валидного токена и так действует как владелец (токен — его принципал), и объявить
`is_user=true` он вправе в любой момент. Ценность мандата — в видимости и в защите от
случайности: агент не тронет задачу, которую владелец ему не поручал, а всё, что он сделал,
видно в журнале. Строить на `ai_eligible` разграничение прав нельзя.

### 3.21. Системные уведомления (Web Push) (0.88–0.89)

Работа ИИ-агента идёт, пока приложение закрыто, и до 0.88 владелец узнавал о ней только
из открытой вкладки (3.14, 3.20). Системные уведомления закрывают этот пробел: те же
события доставляются в закрытое приложение, туда же приходят напоминания, которых до
0.88 не существовало вовсе.

**1. Место в шине.** Web Push — **второй транспорт** событий 3.14, а не её замена.
Доставку выполняет push-сервис браузера, поэтому природа у неё другая: задержка и
батчинг, шифрованный payload до ~4 КБ, адресат — service worker, а не страница.
Мгновенный refetch открытого интерфейса, дедуп эха и форс-рефреш после reconnect остаются
на SSE: подменять их push'ем нельзя.

**2. Что приходит уведомлением.** Работа агента: «взял задачу» (`task.claimed`) и
«закрыл — ждёт приёмки» (`task.review`). Это ровно те события, о которых владелец иначе
не узнает, пока не откроет приложение. Напоминания (0.89) — второй источник: строгий
дедлайн, ритм регулярной задачи и утренняя сводка; их правила — в пункте 9.

**3. Подавление при видимой вкладке.** Перед показом воркер смотрит на окна приложения
того же источника и, если есть видимое, уведомление не показывает: там работает
внутренний тост (`GamifyBridge`). Проверяется именно **видимость** окна, а не факт
открытого SSE-соединения: вкладка в фоне соединение держит, а уведомление как раз нужно.

**4. Разрешение.** Без разрешения браузера подписка невозможна. При входе (после успешной
проверки сессии) приложение запрашивает разрешение по умолчанию: если его ещё не
спрашивали и push на сервере настроен — запрос и, при согласии, подписка. Оговорка:
Safari и Firefox требуют, чтобы запрос шёл от жеста пользователя, а возврат со SSO — это
загрузка страницы без клика, поэтому запрос может быть молча проигнорирован (Chrome в
таких случаях отвечает «тихим» отказом). Надёжный путь — переключатель в настройках: он
всегда идёт от клика. После явного отказа приложение не переспрашивает.

**5. Настройки.** На странице «Настройки» — блок «Уведомления»: переключатель (про это
устройство), пояснение, что именно придёт, текущее состояние и число подписанных
устройств. Отключение — это отписка устройства и удаление его подписки на сервере.

**6. Ключи VAPID.** Пара ключей берётся из окружения (`VAPID_PUBLIC_KEY`,
`VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`), печатает её `uv run python -m app.vapid`. Ключей
нет — уведомления тихо выключены (в блоке настроек видно, что функция не настроена), а
приложение и тесты работают: в отличие от `SESSION_SECRET` это не дыра в безопасности,
а опциональная функция, и падать на старте из-за неё нельзя.

**7. Тексты собирает сервер.** Уведомление формируется до открытия приложения, поэтому
его строки лежат в серверном словаре (ru/en/uk) и выбираются по языку пользователя:
настройка языка → SSO-локаль → русский. Строки дублируют фронтовые локали осознанно —
service worker локалей не знает.

**8. Ограничения среды.** Push требует secure context: по HTTP (не localhost) не работает
ни service worker, ни подписка — нужен HTTPS, который появится на M7. На iOS push
доступен с 16.4 и только у приложения, добавленного на «Экран Домой». Сквозной круг
доставки через push-сервис проверяется на живом устройстве: в headless-браузере подписка
может не найти push-сервис.

Уведомления в журнал `task_events` не пишутся: он про действия с задачами, а не про
доставку.

**9. Напоминания (0.89).** Приложение молчит, пока его не откроют, поэтому о сроке и о
регулярном деле напоминает планировщик — не только открытая вкладка.

*Что напоминаем.* **Строгий дедлайн разовой задачи** — накануне срока, в день срока и
один раз о просрочке. **Ритм регулярной задачи** — «пора: зарядка» в день, который правило
повторения (3.5) и так назначило; день считается тем же `recurrence.next_date`, что
создаёт следующий экземпляр, отдельного «календаря напоминаний» нет, иначе правила
разъехались бы. Следствием этого задача, созданная сегодня, впервые напомнит о себе в
свой следующий день: сегодняшний экземпляр у владельца и так на руках. **Утренняя
сводка** — «просрочено / срок сегодня / ждут приёмки», отправляется в окне 09:00–14:00
по локальному времени и молчит (не отправляется и не пишется в журнал), если сказать
нечего.

*У регулярной повод один — ритм* (0.91). С тех пор как экземпляр получает дату
следующего раза (3.5), дедлайновые правила его не касаются: иначе в день срока об одном
деле приходило бы два уведомления — «Срок сегодня» и «Пора». По той же причине
регулярные не попадают в счётчики сводки «просрочено» и «срок сегодня» (в «ждут приёмки»
попадают: закрытая агентом работа ждёт решения владельца независимо от типа задачи).

*Что не напоминаем.* **Нестрогий срок** («в течение недели/месяца/года») не напоминает
вовсе: в модели это период без дня отсчёта, разворачивать его в дату мы не стали.
Задачи в статусах `done`, `cancelled` и **`deferred`** — тоже молчание: «сейчас не в
приоритете» это осознанное решение владельца, и напоминание о сроке противоречило бы
ему.

*Дневная гранулярность.* Итерация планировщика идёт каждые 15 минут, но поводы
напоминаний дневные: напоминание не привязано к часу, и «просрочено» звучит один раз, а
не каждые сутки.

*Дедуп.* Отправленное записывается в `push_deliveries` (`unique(user_id, kind,
ref_key)`) — **до** отправки. Поэтому повторный тик и перезапуск контейнера дублей не
дают, а перенос срока рождает новый повод: в `ref_key` входят **id задачи и дата** —
у дедлайна дата срока, у ритма день, о котором напоминаем. Id обязателен (0.91): без
него ключ был общим на пользователя, и, если на сегодня выпадали две регулярные, второе
«Пора» молча съедала уникальность — за день приходило одно напоминание, сколько бы
задач ни совпало. Обратный размен (падение между записью и отправкой) даёт
пропуск, а не дубль: для напоминания это правильная сторона ошибки.

*Тихие часы.* Настройка `quiet_hours` («HH:MM-HH:MM», `app_settings`) — окно, в которое
напоминания не приходят; в интерфейсе это переключатель с окном 22:00–09:00. Окно может
пересекать полночь. Попавшее в окно напоминание **не отправляется и не записывается**:
условие дневное, поэтому следующий тик после окна отправит его сам. Работа агента
тихими часами не глушится — она ждёт решения владельца.

*Где живёт планировщик.* Задача в lifespan приложения: итерация уходит в отдельный
поток (внутри синхронные SQLAlchemy и HTTP-запросы к push-сервису), а цикл отменяется
вместе с приложением. Сбой одной итерации логируется, но не прекращает напоминания до
перезапуска. Пояс напоминаний — из окружения (`REMINDER_TIMEZONE`), один на инстанс
(открытый вопрос 8.13).

### 3.22. Импорт задачи из ссылки на багрепорт BugTrail (0.92)

Владелец ведёт баги в своём трекере **BugTrail** (`bugtrail.gnexus.space`) и заводит
по ним задачи. Ссылка на отчёт, вставленная в **пустой заголовок** формы задачи,
разворачивается в заголовок и описание — переносить руками название, текст и шаги
воспроизведения больше не нужно.

- **Что подставляется.** Заголовок — из отчёта (фолбэк: заголовок страницы, дальше
  «Багрепорт BugTrail»). Описание собирается по частям, пустые пропускаются: текст
  отчёта → **Записанные шаги** (тип, время от начала записи, элемент, введённое
  значение; скриншот шага — картинкой под своим шагом) → **Элемент** (для отчётов
  без шагов, где элемент и есть суть) → **Страница** → **Вложения** → **Источник**.
  Названия разделов и типов шагов взяты из русской локали самого BugTrail, чтобы
  задача читалась так же, как отчёт в панели.
- **Источник — строка в описании.** Отдельного поля у задачи нет и не появляется:
  последний блок описания — `**Источник:** BugTrail — {адрес}/r/{токен}`. Связь
  живёт текстом, как README у описания-ссылки (3.3). Новых полей в БД и миграций
  фича не требует.
- **Скриншоты — ссылками на трекер**, а не копиями в свои вложения: файл отчёта у
  BugTrail открывается по тому же share-токену, что и сам отчёт. Так видно всё, что
  автор приложил, и ничего не выгружается к нам.
- **Адрес трекера — только из конфига** (`BUGTRAIL_URL` — база API; по умолчанию
  прод-адрес, поэтому фича работает из коробки, без правки `.env`). Панель может
  жить на другом адресе (`BUGTRAIL_PANEL_URL`) — он идёт в человеческие ссылки;
  пусто — берётся API-база. Из вставленной ссылки берётся **только share-токен**,
  хост — из настройки, поэтому подсунуть чужой адрес нечем. Пустая настройка
  выключает импорт: эндпоинт отвечает 503.
- **Авторизация трекера не нужна**: share-токен в самой ссылке и есть доступ к
  отчёту. Читает отчёт бэкенд gntodo — у BugTrail узкий CORS (список origin'ов, без
  `*`), из браузера запрос не прошёл бы.
- **Ничего не создаётся само**: поля формы предзаполняются, задачу владелец
  создаёт кнопкой. Работает и в форме задачи (в любой: глобальная кнопка, стек,
  страница проекта), и в **попапе расширения** (3.18) — там разворот даёт то же
  «первая строка — название, остальное — описание», что и ручной ввод.
- **Когда не вмешиваемся**: вставка в непустое поле, набор текста, вставка не
  ссылки. Не наша ссылка или выключенный импорт — ссылка молча остаётся в
  заголовке, ровно как при ручной вставке сегодня. Ссылка наша, но отчёт не отдался
  (удалён, трекер недоступен) — ссылка остаётся, показывается тост об ошибке.
  Пока идёт запрос, создание задачи заблокировано: иначе Enter в поле заголовка
  отправил бы форму с сырой ссылкой и потерянным описанием.
- **Потолки сборки**: не больше 50 шагов и 20 картинок, длина описания — до 20 000
  знаков; отброшенное отмечается в тексте. Окружение и запись движений мыши не
  переносятся — они видны в отчёте по ссылке.
- **Автодетализация импорт не трогает** (0.93): заголовок и описание ИИ не
  предлагает вовсе (3.2), поэтому «Переспросить ИИ» на импортированной задаче
  перезаписать описание не может — речь идёт только о тегах, приоритете и оценке.

## 4. Нефункциональные требования

| Требование | Значение |
|---|---|
| Архитектура | Клиент-серверное; SPA + REST API + MCP |
| Фронтенд | SPA на **Vue 3** с **gnexus-ui-kit** (`npm: gnexus-ui-kit@^0.4.0`, peer: Vue ^3.4) |
| Бэкенд | **Python** (FastAPI — предположительно), клиент SSO — `gnexus-gauth` |
| СУБД | PostgreSQL (рекомендация для VPS) |
| Авторизация | SSO через **auth.gnexus.space**, библиотека **gnexus-gauth** (`https://git.gnexus.space/git/root/gnexus-auth-client-py.git`) |
| Хостинг | VPS, HTTPS |
| AI | **Ollama** на внешнем сервере (адрес и модель — конфигом, `OLLAMA_BASE_URL`/`OLLAMA_MODEL`); контейнера ollama в compose нет |
| Упаковка | В итоге всё пакуется в **Docker** (docker-compose: API, PostgreSQL, фронт; TLS — внешний reverse-proxy) |
| Мобильность | На старте — **только PWA**; Android-приложение — вне скоупа стартовой версии (вернуться к нему позже) |
| Ширина контента | До **1440px** на десктопе (0.68; было 1200px): контейнер контента тянется по ширине окна и упирается в 1440px, дальше центрируется с полями; на этой ширине списки задач и проектов раскладываются по три в ряд (3.6, 3.8) |
| Приватность | Все данные и LLM-вызовы — на собственном сервере |
| Реактивность | SSE (`/api/events`): грубозернистые события + refetch, см. 3.14 |
| PWA | манифест + SW (precache оболочки, autoUpdate), оффлайн-оболочка без кэша API, см. 3.15 |

Внешние зависимости проекта (не форкать, обновлять через пакетный менеджер):

- `gnexus-ui-kit` — часто обновляется, использовать только публичный API кита;
- `gnexus-gauth` — клиентская библиотека SSO.

**Исходящие запросы за иконкой сайта (0.63, 3.11)** — единственный случай, когда
бэкенд ходит в интернет по адресу, заданному пользователем (в т.ч. агентом через
MCP), поэтому запрос ограничен барьером SSRF:

- только схема http/https и непустой хост, длина адреса ≤ 2048;
- **только публичные адреса**: имя резолвится, и каждый полученный IP обязан быть
  глобальным (`ipaddress.is_global`) — private/loopback/link-local/reserved/CGNAT
  (включая метаданные облака `169.254.169.254`) отклоняются;
- редиректы проходятся вручную (≤ 3), хост проверяется **на каждом хопе**;
- лимиты: connect 3 c / read 4 c / всего 6 c, тело ≤ 256 КБ, `content-type` обязан
  быть изображением, а расширение определяется по magic-bytes;
- ответ отдаётся с `X-Content-Type-Options: nosniff` и `Cache-Control: private`,
  без возможности исполниться как документ.

Остаточный риск: между проверкой хоста и соединением HTTP-клиент резолвит имя
повторно (DNS-rebinding, TOCTOU); полное закрытие требует соединения по IP с
подменой Host/SNI и ломает проверку TLS-сертификата. Для личного приложения риск
принят — основной вектор (публичный URL → метаданные облака) закрыт.

## 5. Модель данных (черновик)

Мультиюзерность (с 0.34): на задачах, проектах, тегах, документах, XP/монетах,
саде и настройках есть `user_id` (nullable — NULL забирается первым вошедшим,
см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. Модель сада
(0.58) сохранена без изменений — раздел отключён, данные ждут возврата;
XP/монеты и `rarity` продолжают начисляться, как раньше.

Импорт из BugTrail (3.22, 0.92) новых полей не добавляет: источник живёт строкой
`**Источник:** …` в тексте описания, как README у описания-ссылки. Ни колонки, ни
таблицы, ни миграции.

```
User          — пользователь SSO: id (= user_id от SSO), email, name (display_name),
                avatar_url, locale, created_at
McpToken      — персональный MCP-токен (3.10): user_id, token_hash (sha256,
                уникален), label, created_at; plaintext показывается один раз

Task
  id, title
  description      markdown — живёт в Document (owner_type = task)
  task_type         one_time | recurring          — тип задачи: разовая / регулярная
  status            to_do | in_progress | done | cancelled | deferred
  detail_state      raw | approved                — плоскость детализации (стека)
  parent_task_id    nullable  — дерево подзадач
  project_id        nullable
  tag_ids           []
  priority          nullable  (заполняется автодетализацией / вручную)
  deadline          nullable  — дедлайн:
                        strict  { date }                  — «до конкретной даты»
                        soft    { period: week|month|year } — «в течение периода»
                        (0.91) у регулярной задачи строгая дата — дата следующего
                        экземпляра (3.5), а не срок работы; оба поля разом
                        запрещены (3.4), поэтому экземпляр с нестрогим периодом
                        даты не получает
  recurrence        nullable  — правило повторения (для task_type = recurring)
  time_estimate     nullable  — прогноз времени: из автодетализации (3.2, 0.93) или
                        задан вручную; заполненное не перетирается
  budget            nullable  — ручной бюджет (деньги и/или время), опционален
  estimated_cost    nullable  — оценка затрат по задаче (сравнивается с бюджетом)
  actual_time       nullable  — фактические затраты (для обучения прогноза)
  created_at, approved_at, done_at
  ai_eligible       bool — задача доступна ИИ-агенту (3.20, 0.85); по умолчанию false
  created_by_kind   user | agent — кто создал задачу; created_by_name — label токена
                    агента (0.85, 3.20): по этим полям список фильтруется в UI
  done_by_kind      user | agent, nullable — кто закрыл задачу последним
                    (NULL — до 0.85, неизвестно)
  accept_state      pending | accepted | rejected, nullable — плоскость приёмки
                    агентской работы (3.20); NULL — приёмка не применяется
  claim             nullable — аренда агента (3.20): claimed_by_token_id (→ mcp_tokens,
                    ON DELETE SET NULL), claimed_by_name (label, только для показа),
                    claimed_at, claim_expires_at (время протухания)

Project
  id, name
  relevance_status  active | paused               — статус актуальности
  is_archived       bool — архив (3.8.1): проект и его задачи скрыты
                    из рабочих видов, восстановимы из «Архива»
  pinned            bool — булавка (3.8, 0.66): закреплённые проекты
                    держатся секцией «Закреплённые» вверху списка
  priority
  note              markdown — живёт в Document (owner_type = project):
                    ссылки, контекст, картинки; ссылка на репозиторий —
                    в отдельном поле repository_url (0.67)
  color             nullable — цвет-метка проекта, HEX #rrggbb (3.8, 0.63);
                    палитра выбора — на фронте, бэкенд валидирует только формат
  site_url          nullable — сайт проекта, http(s)://... (3.8, 0.63);
                    иконка сайта тянется бэкенд-прокси с кэшем (3.11)
  repository_url    nullable — репозиторий проекта, http(s)://... (3.8, 0.67);
                    README оттуда тянется бэкенд-прокси с кэшем (3.11)
                    и показывается вторым описанием рядом с заметкой

Tag           — справочник тегов
Document      — markdown-текст с полиморфной привязкой к владельцу:
                owner_type (task | project), owner_id, body (у задачи —
                описание, у проекта — заметка); document всегда один на владельца
Attachment    — файлы документов (изображения), document_id, mime.
                URL скачивания файла неизменен: /api/attachments/{id}/file —
                он зашит в сохранённый markdown-текст

XpEvent       — начисление XP за закрытую задачу (идемпотентно по task_id):
                amount, rarity (common | rare | epic), via_options

CoinEvent     — журнал монет (заработок и траты; баланс = Σ amount):
                task_id (nullable), level (nullable — бонус уровня),
                source (task | level | daily | shop), amount (+/−), item_key
                (ключ покупки для трат), created_at. В 0.58 источник баланса
                для UI — `GET /api/xp` (поле coins); траты (source=shop)
                возможны только из сада

GardenItem    — элемент сцены сада: kind (plant | decoration),
                ref_id (→ xp_events для растений; уникально в паре с kind),
                item_key (вид растения / ключ декорации), x, y (ячейки сетки),
                stage (0 росток, 1 куст, 2 цветение). С 0.58 новые записи
                не создаются: ленивая ensure_garden вызывается только из
                `GET /api/garden`, который UI не зовёт

TaskEvent     — журнал действий по задачам (3.20, 0.85): кто, что и когда.
                task_id (nullable, ON DELETE SET NULL — журнал переживает задачу),
                task_title (снапшот заголовка, чтобы запись читалась после удаления),
                project_id (nullable), kind (created | eligible_changed | claimed |
                released | completed | accepted | rejected | status_changed |
                deleted), actor_kind (user | agent), actor_name (имя владельца или
                label токена), via (ui | api | mcp), comment (nullable — что сделано
                или причина возврата; у смены статуса и доступности — машинный
                переход кодами: `to_do→in_progress`, `on`/`off`, их подписывает
                интерфейс), created_at. Единственный пагинированный
                список на бэкенде: `GET /api/task-events` отвечает конвертом
                { items, total, page, per_page }, потолок per_page — 100

AppSetting    — per-user настройки (валюта, язык): PK (user_id, key), value
AppSettingGlobal — таблица-наследие однопользовательской версии
                (бывшая app_settings); источник claim при первом логине,
                позже удаляется

PushSubscription — подписка браузера на системные уведомления (3.21, 0.88):
                user_id (NOT NULL, ON DELETE CASCADE), endpoint (уникален
                глобально: тот же браузерный профиль может перейти к другому
                пользователю, тогда подписка перепривязывается), p256dh и auth
                (ключи шифрования устройства), user_agent, created_at,
                last_success_at, failure_count — мёртвые подписки (404/410 и
                постоянные ошибки) удаляются лениво, при отправке

PushDelivery  — журнал отправленных напоминаний (3.21, 0.89): user_id (NOT NULL,
                ON DELETE CASCADE), kind (вид напоминания), ref_key (повод:
                задача и дата срока, либо день ритма, либо дата сводки),
                created_at, unique(user_id, kind, ref_key). Защита от дублей:
                запись идёт до отправки, поэтому повторный тик и перезапуск
                контейнера не рассылают одно и то же. Не чистится — ретенция
                в открытом вопросе 8.14
```

История завершённых задач (фактическое время) — источник для прогнозирования длительности.

## 6. Архитектура (черновик)

```
┌─────────────┐     ┌────────────────────── VPS (docker-compose) ─────────────┐
│  Web SPA    │────▶│  API (FastAPI) ──▶ PostgreSQL                           │
│  Vue 3 +    │     │   ▲    │                                                │
│  gnexus-    │     │   │    ├──▶ Сервис автодетализации ──▶ Ollama (модель    │
│  ui-kit     │     │   │    │      — LLM, модель из конфига)                  │
└─────────────┘     │   │    ├──▶ Хранилище вложений (файлы)                   │
      ▲             │   │    └──▶ MCP-сервер (для ИИ-агентов)                  │
      └─ SSE ───────┤ шина событий (realtime)                                  │
                    └─────────────────────────────────────────────────────────┘
                                     ▲ SSO auth.gnexus.space (клиент gnexus-gauth)
```

- API и MCP-сервер — одно Python-приложение (общая бизнес-логика).
- Вложения — файлы на диске VPS + метаданные в БД.
- Изменения данных публикуются в шину событий и уходят в SPA по SSE (3.14).

## 7. Этапы разработки (предложение)

| Этап | Содержание |
|---|---|
| M0 — каркас | Репозиторий, скелет backend + frontend, SSO-интеграция (gnexus-gauth), деплой на VPS |
| M1 — MVP задач | CRUD задач, быстрый ввод, стек входящих, теги, проекты (базово), ручная детализация |
| M2 — детализация | Интеграция Ollama + модель из конфига, автопредзаполнение метаданных, подтверждение/редактирование, Markdown-редактор, вложения из буфера |
| M3 — структура | Подзадачи (parent_task_id), классический список с фильтрами, заметки проектов |
| M4 — умность | Прогнозирование времени, бюджет, выдача задачи |
| M5 — MCP | MCP-сервер, инструменты для агентов |
| M5+ — мандат агента | Доступность задачи для ИИ-агента, взятие в работу (аренда), приёмка работы, журнал `task_events` (0.85, 3.20) |
| M6 — мобильность | PWA (Android-приложение — позже, вне стартового скоупа) |
| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21). Регулярные задачи: дата следующего экземпляра у клона, метка в карточке, фильтр «Регулярные» (0.91, 3.5). Импорт задачи из ссылки на багрепорт BugTrail — в форме задачи и в попапе расширения (0.92, 3.22). Автодетализация переведена на теги, приоритет и оценку времени — заголовок и описание ИИ не предлагает (0.93, 3.2). Журнал и шапка списка приспособлены к телефону, «Кто создал» стал переключателем (0.94, 3.20, 3.6). В стек идут только задачи без проекта, карточка стека собрана из обычной карточки задачи (карточки ряда одной высоты), выдача раскладывается тремя карточками в ряд (0.95, 3.1, 3.7, 3.9). Иконки: углы прозрачны у всех без исключения — скругление знака видно и в расширении, и на «Домой» в iOS (0.96, 3.19) |
| M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) |

Порядок M4/M5 может меняться — MCP можно поднять раньше ради ИИ-сценария.

## 8. Открытые вопросы

1. ~~**Правила повторения**~~ — **решено 2026-09-19**: три правила — интервал N дней, дни недели (ISO), день месяца (без дня — последний); якорь — фиксированный календарь от даты выполнения; новый экземпляр рождается при завершении предыдущего.
2. ~~**Прогнозирование времени**~~ — **решено 2026-09-19**, **подтверждено 2026-10-10 (0.93)**: оценка длительности приходит из LLM-детализации (или задаётся вручную); точность не требуется — оценка нужна, чтобы отсекать заведомо большие задачи при малом доступном времени. Решение 2026-09-22 «планирование ИИ не делает» отменено: LLM предлагает оценку и градацию приоритета, заполненные вручную поля не перетираются (3.2).
3. ~~**Оценка затрат к бюджету**~~ — **решено 2026-09-19**: оба поля (бюджет и оценка затрат) — ручные; валюта — глобальная настройка, выбирается один раз и применяется всюду (UAH, USD, EUR, GBP, PLN).
4. **Оффлайн-режим** в PWA: нужен ли, или всегда онлайн?
5. ~~**Модель для Ollama**~~ — **решено 2026-09-21**: по умолчанию `qwen3.5:2b-q4_K_M` (проверена на M2); меняется конфигом (`OLLAMA_MODEL`) без правок кода. Ollama — внешний сервер, адрес — `OLLAMA_BASE_URL`.
6. **Практическое различие `cancelled` / `deferred`**: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся.
7. ~~**Анти-дюп награды за статус**~~ — **решено 2026-09-21**: закрытие (HTTP и MCP) идёт через общий путь; XP — один раз на задачу (grant_task_xp), спавн регулярной — один раз на цепочку (метка `spawned_at`), выход из done сбрасывает `done_at`. Повторные закрытия и обновления закрытой не дублируют награды и экземпляры — покрыто тестами.
8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. **Отложено вместе с садом (0.58)** — код сохранён, вернётся при доработке сада.
9. **Premium-виды в семенах** — **отложено вместе с садом (0.58)**: premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить при возврате сада.
10. **Самосоединяющийся забор** — **отложено вместе с садом (0.58)**: секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить при возврате сада.
11. **Возврат сада** (0.58): когда основной функционал будет в порядке, вернуть сад к доработке — выдача растений включится сама, если UI снова начнёт звать `GET /api/garden`; вопросы — что переделать в саду (сцена, маркет, прогрессия) — решать тогда, на накопленных данных.
12. **Ретенция журнала** (0.85): `task_events` пишет всё жизненное, включая смену статуса, и растёт вместе с работой. Пока пагинации и потолка `per_page` хватает; решить позже — нужен ли срок хранения, архивация старых записей или их сжатие по задаче.
13. **Часовой пояс напоминаний** (0.89): `REMINDER_TIMEZONE` — один на инстанс, поэтому «сегодня» и окно сводки считаются по поясу сервера, а не по поясу владельца. Для личного развёртывания этого достаточно (сервер и владелец в одном поясе), но при переезде или втором пользователе в другом поясе напоминания придут не в его утро. Решать вместе с вопросом мультипользовательности: пояс в профиле или вычислять по последней активности устройства.
14. **Ретенция `push_deliveries`** (0.89): журнал отправленного растёт по одной строке на повод (задача + срок, день ритма, день сводки) и не чистится — старые записи нужны только до конца своего дня. Строки дешёвые, но вечные: решить, удалять ли их по возрасту (например, старше месяца) регулярной уборкой или оставить как историю отправленного.
15. **Импорт из BugTrail в MCP** (0.92) — **не-цель**: у MCP-интерфейса нет вставки в поле, а тул «разверни ссылку в задачу» — это уже не «поддержка», а интеграция с трекером. Если понадобится, делать осознанно: с явным адресом трекера и решением, что делать с картинками (см. 16).
16. **Что тащить из отчёта** (0.92): сейчас переносятся текст, шаги, элемент, страница, вложения и картинки — а `environment` (браузер, ОС, разрешение экрана) и запись движений мыши остаются в отчёте по ссылке. Для багрепорта окружение бывает важно, так что вопрос открыт: дописывать ли его в описание (это ещё 5–10 строк служебного текста) или оставить только в трекере. Вложения сейчас идут ссылками на трекер, а не файлами у нас, — если однажды понадобится независимость от BugTrail, решать вместе с этим.